M10 bis AP-003

This commit is contained in:
2026-04-20 13:07:19 +02:00
parent 20b847d821
commit 01414fc732
16 changed files with 1139 additions and 184 deletions
@@ -34,9 +34,9 @@ import org.apache.logging.log4j.Logger;
* *
* <h2>Current scope</h2> * <h2>Current scope</h2>
* <p> * <p>
* The adapter launches a minimal GUI shell that proves the GUI startup path works. * The adapter launches the editor shell with the unloaded start state, an optional startup
* It does not provide a configuration editor, file operations, validation, provider * notice, and a file-loading callback supplied by Bootstrap. File I/O and save behavior remain
* controls, or any other functionality beyond the technical startup proof. * outside the current GUI step.
*/ */
public class GuiAdapter { public class GuiAdapter {
@@ -84,13 +84,22 @@ public class GuiAdapter {
* or if the platform is not supported * or if the platform is not supported
*/ */
public void start(Optional<String> startupNotice) { public void start(Optional<String> startupNotice) {
LOG.info("GUI-Adapter: JavaFX-Start wird eingeleitet."); start(GuiStartupContext.blank(startupNotice));
if (startupNotice.isPresent()) {
Application.launch(PdfUmbenennerGuiApplication.class,
PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX + startupNotice.get());
} else {
Application.launch(PdfUmbenennerGuiApplication.class);
} }
/**
* Starts the JavaFX GUI with the supplied startup context.
*
* @param startupContext startup data for the GUI; must not be {@code null}
*/
public void start(GuiStartupContext startupContext) {
LOG.info("GUI-Adapter: JavaFX-Start wird eingeleitet.");
GuiStartupContextHolder.install(startupContext);
try {
Application.launch(PdfUmbenennerGuiApplication.class);
} finally {
GuiStartupContextHolder.clear();
LOG.info("GUI-Adapter: JavaFX-Anwendung wurde beendet."); LOG.info("GUI-Adapter: JavaFX-Anwendung wurde beendet.");
} }
} }
}
@@ -0,0 +1,449 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Separator;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Window;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Builds the editor workspace shown after the JavaFX application starts.
* <p>
* The workspace owns the unloaded start state, the optional startup notice, the file-loading
* callback and the visible section scaffold for the single-tab shell. It performs no save
* operations and no validation logic.
*/
public final class GuiConfigurationEditorWorkspace {
private static final Logger LOG = LogManager.getLogger(GuiConfigurationEditorWorkspace.class);
private static final String WELCOME_TEXT =
"Willkommen. Legen Sie mit „Neu“ eine Standardvorlage an oder öffnen Sie eine bestehende Konfiguration.";
private final BorderPane root = new BorderPane();
private final Label statusLabel = new Label();
private final Label configurationPathValueLabel = new Label();
private final Label welcomeTitleLabel = new Label("Willkommen");
private final Label welcomeTextLabel = new Label(WELCOME_TEXT);
private final TabPane tabPane = new TabPane();
private final VBox sectionsBox = new VBox(12);
private final Button newButton = new Button("Neu");
private final Button openButton = new Button("Öffnen");
private final Button saveButton = new Button("Speichern");
private final Button saveAsButton = new Button("Speichern unter");
private final GuiConfigurationFileLoader configurationFileLoader;
private GuiConfigurationEditorState editorState;
private boolean welcomeGuidanceVisible;
/**
* Creates a new workspace with the unloaded start state.
*
* @param startupNotice optional startup notice to show in the header area
*/
public GuiConfigurationEditorWorkspace(Optional<String> startupNotice) {
this(GuiStartupContext.blank(startupNotice));
}
/**
* Creates a new workspace with the supplied startup context.
*
* @param startupContext startup data from Bootstrap; must not be {@code null}
*/
public GuiConfigurationEditorWorkspace(GuiStartupContext startupContext) {
GuiStartupContext effectiveContext = startupContext == null
? GuiStartupContext.blank(Optional.empty())
: startupContext;
this.configurationFileLoader = effectiveContext.configurationFileLoader();
this.editorState = effectiveContext.initialState();
this.welcomeGuidanceVisible = editorState.isNewConfiguration();
configureRoot();
configureHeader(effectiveContext.startupNotice());
configureTabs();
configureActionBar();
configureActions();
refreshView();
}
/**
* Returns the root node used by the JavaFX scene.
*
* @return the root pane for the workspace
*/
public Parent root() {
return root;
}
/**
* Returns the currently active editor state.
*
* @return the current editor state
*/
public GuiConfigurationEditorState editorState() {
return editorState;
}
/**
* Returns the currently displayed configuration-path text.
*
* @return the header text for the loaded configuration path
*/
public String configurationPathText() {
return configurationPathValueLabel.getText();
}
/**
* Returns the welcome text shown in the editor start state.
*
* @return the German welcome text
*/
public String welcomeText() {
return welcomeTextLabel.getText();
}
/**
* Returns whether the welcome guidance is currently visible.
*
* @return {@code true} when the welcome text is shown, otherwise {@code false}
*/
public boolean isWelcomeGuidanceVisible() {
return welcomeGuidanceVisible;
}
/**
* Returns the "Neu" action button.
*
* @return the button used to switch to the standard template
*/
public Button newButton() {
return newButton;
}
/**
* Returns the "Öffnen" action button.
*
* @return the button used for the open-file flow
*/
public Button openButton() {
return openButton;
}
/**
* Returns the "Speichern" action button.
*
* @return the button used for future save wiring
*/
public Button saveButton() {
return saveButton;
}
/**
* Returns the "Speichern unter" action button.
*
* @return the button used for future save-as wiring
*/
public Button saveAsButton() {
return saveAsButton;
}
/**
* Returns the tab pane containing the single editor tab.
*
* @return the tab pane shown in the center area
*/
public TabPane tabPane() {
return tabPane;
}
/**
* Returns the fixed section titles shown inside the single configuration tab.
*
* @return the section titles in visual order
*/
List<String> sectionTitles() {
return List.of("Pfade", "Provider", "Verarbeitungslimits", "Tests", "Meldungen");
}
/**
* Handles the explicit "Neu" action.
* <p>
* The workspace switches to the standard template and hides the welcome guidance.
*/
public void requestNewConfiguration() {
LOG.info("GUI-Editor: Neue Standardvorlage wird angezeigt.");
applyEditorState(GuiConfigurationTemplateFactory.createStandardTemplate());
}
/**
* Handles the explicit "Öffnen" action.
* <p>
* The file chooser is native to the platform and filters for {@code *.properties} files.
*/
public void requestOpenConfiguration() {
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Konfiguration öffnen");
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties"));
if (owner != null && editorState.hasLoadedFileSnapshot()) {
Path currentPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
Path parent = currentPath.getParent();
if (parent != null) {
fileChooser.setInitialDirectory(parent.toFile());
}
}
java.io.File selectedFile = fileChooser.showOpenDialog(owner);
if (selectedFile != null) {
openConfigurationFile(selectedFile.toPath());
}
}
/**
* Opens the selected configuration file on a background thread.
*
* @param configFilePath the selected file path; must not be {@code null}
*/
void openConfigurationFile(Path configFilePath) {
if (configurationFileLoader == null) {
showError("Öffnen ist derzeit nicht verfügbar.");
return;
}
Thread worker = new Thread(() -> {
try {
GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath);
Platform.runLater(() -> applyEditorState(loadedState));
} catch (Exception exception) {
Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: "
+ safeMessage(exception)));
}
}, "gui-config-loader");
worker.setDaemon(true);
worker.start();
}
/**
* Handles the explicit "Speichern" action.
* <p>
* File writing is intentionally not implemented yet.
*/
public void requestSaveConfiguration() {
LOG.info("GUI-Editor: Speichern-Aktion wurde ausgelöst, ist aber noch nicht implementiert.");
}
/**
* Handles the explicit "Speichern unter" action.
* <p>
* File writing is intentionally not implemented yet.
*/
public void requestSaveConfigurationAs() {
LOG.info("GUI-Editor: Speichern-unter-Aktion wurde ausgelöst, ist aber noch nicht implementiert.");
}
private void applyEditorState(GuiConfigurationEditorState newState) {
this.editorState = newState;
this.welcomeGuidanceVisible = false;
statusLabel.setVisible(false);
statusLabel.setManaged(false);
refreshView();
}
private void configureRoot() {
root.getStyleClass().add("gui-editor-root");
root.setPadding(new Insets(16));
}
private void configureHeader(Optional<String> startupNotice) {
Label titleLabel = new Label("PDF-Umbenenner");
titleLabel.setStyle("-fx-font-size: 24px; -fx-font-weight: bold;");
Label subtitleLabel = new Label("Konfigurationseditor");
subtitleLabel.setStyle("-fx-font-size: 12px; -fx-text-fill: #666666;");
VBox titleBox = new VBox(2, titleLabel, subtitleLabel);
Label pathCaption = new Label("Konfigurationspfad:");
pathCaption.setStyle("-fx-font-weight: bold;");
HBox pathRow = new HBox(8, pathCaption, configurationPathValueLabel);
pathRow.setAlignment(Pos.CENTER_LEFT);
statusLabel.setWrapText(true);
statusLabel.setVisible(false);
statusLabel.setManaged(false);
statusLabel.setStyle(
"-fx-padding: 8px 10px; -fx-background-color: #fff2cc; -fx-text-fill: #5c3b00; -fx-border-color: #e0b95b;");
VBox headerBox = new VBox(8, titleBox, pathRow, statusLabel, new Separator());
headerBox.setPadding(new Insets(0, 0, 16, 0));
root.setTop(headerBox);
startupNotice.ifPresent(this::showStatusMessage);
}
private void configureTabs() {
Tab editorTab = new Tab("Konfiguration");
editorTab.setClosable(false);
sectionsBox.setSpacing(12);
sectionsBox.setFillWidth(true);
ScrollPane scrollPane = new ScrollPane(sectionsBox);
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane);
tabPane.getTabs().add(editorTab);
root.setCenter(tabPane);
}
private void configureActionBar() {
HBox actionBar = new HBox(10, newButton, openButton, saveButton, saveAsButton);
actionBar.setAlignment(Pos.CENTER_LEFT);
actionBar.setPadding(new Insets(16, 0, 0, 0));
root.setBottom(actionBar);
}
private void configureActions() {
newButton.setOnAction(event -> requestNewConfiguration());
openButton.setOnAction(event -> requestOpenConfiguration());
saveButton.setOnAction(event -> requestSaveConfiguration());
saveAsButton.setOnAction(event -> requestSaveConfigurationAs());
}
private void refreshView() {
refreshHeader();
refreshSections();
}
private void refreshHeader() {
configurationPathValueLabel.setText(editorState.configurationPathText());
}
private void refreshSections() {
sectionsBox.getChildren().setAll(
createWelcomeCard(),
createPathsSection(),
createProviderSection(),
createProcessingLimitsSection(),
createTestsSection(),
createMessagesSection());
}
private Node createWelcomeCard() {
VBox card = createCardContainer();
welcomeTitleLabel.setStyle("-fx-font-size: 16px; -fx-font-weight: bold;");
welcomeTextLabel.setWrapText(true);
welcomeTextLabel.setStyle("-fx-font-size: 13px;");
card.getChildren().addAll(welcomeTitleLabel, welcomeTextLabel);
card.setVisible(welcomeGuidanceVisible);
card.setManaged(welcomeGuidanceVisible);
return card;
}
private Node createPathsSection() {
VBox card = createCardContainer();
card.getChildren().addAll(
sectionTitle("Pfade"),
textLabel("Der Bereich ist vorbereitet. Geladene Pfade werden in einem späteren Schritt editierbar ergänzt."));
return card;
}
private Node createProviderSection() {
VBox card = createCardContainer();
card.getChildren().addAll(
sectionTitle("Provider"),
textLabel("Der Bereich ist vorbereitet. Provider-Auswahl und zugehörige Eingaben folgen in einem späteren Schritt."));
return card;
}
private Node createProcessingLimitsSection() {
VBox card = createCardContainer();
card.getChildren().addAll(
sectionTitle("Verarbeitungslimits"),
textLabel("Der Bereich ist vorbereitet. Eingaben für Limits werden in einem späteren Schritt ergänzt."));
return card;
}
private Node createTestsSection() {
VBox card = createCardContainer();
card.getChildren().addAll(
sectionTitle("Tests"),
textLabel("Der Bereich ist vorbereitet. Test- und Diagnoseaktionen werden in einem späteren Schritt ergänzt."));
return card;
}
private Node createMessagesSection() {
VBox card = createCardContainer();
card.getChildren().addAll(
sectionTitle("Meldungen"),
textLabel("Der Bereich ist vorbereitet. Sichtbare Meldungen und technische Hinweise folgen in einem späteren Schritt."));
return card;
}
private VBox createCardContainer() {
VBox card = new VBox(8);
card.setStyle(
"-fx-padding: 12px; -fx-border-color: #d8d8d8; -fx-border-radius: 8px; -fx-background-radius: 8px; -fx-background-color: white;");
card.setMaxWidth(Double.MAX_VALUE);
VBox.setVgrow(card, Priority.NEVER);
return card;
}
private Label sectionTitle(String title) {
Label label = new Label(title);
label.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;");
return label;
}
private Label textLabel(String text) {
Label label = new Label(text);
label.setWrapText(true);
return label;
}
private void showStatusMessage(String message) {
statusLabel.setText(message);
statusLabel.setVisible(true);
statusLabel.setManaged(true);
}
private void showError(String message) {
LOG.warn("GUI-Editor: {}", message);
showStatusMessage(message);
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setHeaderText("Konfiguration konnte nicht geladen werden");
alert.show();
}
private String safeMessage(Throwable exception) {
return exception == null ? "unbekannter Fehler" : exception.getMessage() == null
? exception.getClass().getSimpleName()
: exception.getMessage();
}
}
@@ -0,0 +1,23 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
/**
* Loads a configuration file into a GUI editor state.
* <p>
* The interface allows Bootstrap to provide the concrete file-loading and migration logic
* while the GUI only deals with editor states.
*/
@FunctionalInterface
public interface GuiConfigurationFileLoader {
/**
* Loads the configuration file at the given path.
*
* @param configFilePath the file to load; must not be {@code null}
* @return the loaded editor state; never {@code null}
*/
GuiConfigurationEditorState load(Path configFilePath);
}
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Runtime exception thrown when the GUI configuration cannot be loaded.
*/
public class GuiConfigurationLoadException extends RuntimeException {
/**
* Creates a new load exception.
*
* @param message the exception message
* @param cause the underlying cause
*/
public GuiConfigurationLoadException(String message, Throwable cause) {
super(message, cause);
}
/**
* Creates a new load exception.
*
* @param message the exception message
*/
public GuiConfigurationLoadException(String message) {
super(message);
}
}
@@ -0,0 +1,46 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
/**
* Immutable startup data for the GUI adapter.
* <p>
* Carries the initial editor state, the optional startup notice and the file-loading callback
* that the workspace uses for native open actions.
*/
public record GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader) {
/**
* Creates a startup context.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
startupNotice = startupNotice == null ? Optional.empty() : startupNotice;
configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
"configurationFileLoader must not be null");
}
/**
* Creates a blank startup context with no loader side effects.
*
* @param startupNotice optional startup notice; {@code null} becomes empty
* @return a startup context for the unloaded editor start
*/
public static GuiStartupContext blank(Optional<String> startupNotice) {
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
startupNotice,
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState());
}
}
@@ -0,0 +1,31 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
/**
* Process-local holder for the next GUI startup context.
* <p>
* JavaFX creates the application class reflectively, so Bootstrap installs the context here
* immediately before the JavaFX launch call.
*/
final class GuiStartupContextHolder {
private static final AtomicReference<GuiStartupContext> CONTEXT = new AtomicReference<>();
private GuiStartupContextHolder() {
// Utility class.
}
static void install(GuiStartupContext context) {
CONTEXT.set(context);
}
static GuiStartupContext currentOrBlank() {
return Optional.ofNullable(CONTEXT.get()).orElseGet(() -> GuiStartupContext.blank(Optional.empty()));
}
static void clear() {
CONTEXT.set(null);
}
}
@@ -1,153 +1,61 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui; package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.List;
import java.util.Optional;
import javafx.application.Application; import javafx.application.Application;
import javafx.geometry.Pos;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
/** /**
* Minimal JavaFX {@link Application} subclass that establishes the GUI shell. * JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
* <p> * <p>
* This class is the JavaFX lifecycle entry point launched by * The application starts the editor shell in a clean, unloaded state unless Bootstrap
* {@link GuiAdapter#start(java.util.Optional)}. It creates the primary stage with a * has provided a preloaded startup context. The visible editor surface is delegated to
* minimal, neutral layout that proves the GUI startup path is technically functional. * {@link GuiConfigurationEditorWorkspace}.
* The layout is deliberately structured as a {@link BorderPane} so that later milestones
* can populate individual regions (header, center content, bottom message area) without
* requiring an architectural restructuring.
*
* <h2>Startup notice</h2>
* <p>
* When Bootstrap forwards a startup notice (e.g. because the supplied {@code --config}
* path was not found), the notice is passed as an application parameter prefixed with
* {@link #STARTUP_NOTICE_ARG_PREFIX}. This class reads the parameter via
* {@link Application#getParameters()} and displays it prominently in the center region
* instead of the default placeholder.
*
* <h2>Current scope</h2>
* <p>
* The shell displays the application window with a neutral title and either a static
* placeholder label or a startup notice. It contains no configuration editor, no file
* operations, no validation, no provider controls, and no message area. These are the
* responsibility of later milestones.
*
* <h2>Explicit non-goals</h2>
* <ul>
* <li>No configuration editor or editable input fields</li>
* <li>No file operations (Neu, Oeffnen, Speichern, Speichern unter)</li>
* <li>No validation or provider controls</li>
* <li>No message area or technical tests</li>
* <li>No welcome text in the final product sense</li>
* </ul>
*
* <h2>Threading</h2>
* <p>
* The {@link #start(Stage)} method is called by the JavaFX runtime on the
* JavaFX Application Thread. No blocking operations are performed during
* stage setup.
*/ */
public class PdfUmbenennerGuiApplication extends Application { public class PdfUmbenennerGuiApplication extends Application {
private static final Logger LOG = LogManager.getLogger(PdfUmbenennerGuiApplication.class); private static final Logger LOG = LogManager.getLogger(PdfUmbenennerGuiApplication.class);
/**
* Argument prefix used to forward a startup notice from {@link GuiAdapter} to this
* application via {@link Application#launch(Class, String[])}.
* <p>
* When an argument beginning with this prefix is present in the raw parameter list,
* the remainder of the argument string is treated as the notice text to display.
*/
static final String STARTUP_NOTICE_ARG_PREFIX = "--startup-notice=";
private static final String WINDOW_TITLE = "PDF-Umbenenner"; private static final String WINDOW_TITLE = "PDF-Umbenenner";
private static final double DEFAULT_WIDTH = 800; private static final double DEFAULT_WIDTH = 1100;
private static final double DEFAULT_HEIGHT = 600; private static final double DEFAULT_HEIGHT = 800;
/** /**
* Creates a new instance of the JavaFX application. * Creates a new instance of the JavaFX application.
* <p>
* This no-argument constructor is required by the JavaFX runtime, which
* instantiates the {@link Application} subclass reflectively.
*/ */
public PdfUmbenennerGuiApplication() { public PdfUmbenennerGuiApplication() {
// Required by JavaFX runtime for reflective instantiation. // Required by JavaFX runtime for reflective instantiation.
} }
/** /**
* Initializes and shows the primary stage with a minimal GUI shell. * Initializes and shows the primary stage.
* <p>
* The stage is set up with a {@link BorderPane} root layout. When a startup notice is
* present (forwarded via application parameters by {@link GuiAdapter}), the notice is
* displayed prominently in red in the center region. Otherwise a neutral placeholder
* label is shown. The layout structure is chosen to allow incremental extension in later
* milestones without requiring a root-level restructuring.
* <p>
* Start and shutdown events are logged via Log4j2 to satisfy the GUI logging requirements.
* *
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null} * @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/ */
@Override @Override
public void start(Stage primaryStage) { public void start(Stage primaryStage) {
LOG.info("GUI-Shell: JavaFX-Oberfläche wird initialisiert."); LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
BorderPane root = new BorderPane(); GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
Optional<String> startupNotice = extractStartupNotice();
if (startupNotice.isPresent()) {
Label noticeLabel = new Label(startupNotice.get());
noticeLabel.setWrapText(true);
noticeLabel.setAlignment(Pos.CENTER);
noticeLabel.setStyle("-fx-font-size: 13px; -fx-text-fill: #cc0000;");
root.setCenter(noticeLabel);
LOG.info("GUI-Shell: Starthinweis wird angezeigt.");
} else {
Label placeholderLabel = new Label("PDF-Umbenenner GUI wird vorbereitet …");
placeholderLabel.setStyle("-fx-font-size: 14px; -fx-text-fill: #666666;");
root.setCenter(placeholderLabel);
}
Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(WINDOW_TITLE); primaryStage.setTitle(WINDOW_TITLE);
primaryStage.setScene(scene); primaryStage.setScene(scene);
primaryStage.setOnCloseRequest(event -> primaryStage.setOnCloseRequest(event -> LOG.info("GUI: Fenster wird vom Benutzer geschlossen."));
LOG.info("GUI-Shell: Fenster wird vom Benutzer geschlossen."));
primaryStage.show(); primaryStage.show();
LOG.info("GUI-Shell: Hauptfenster erfolgreich angezeigt."); LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
}
/**
* Extracts the startup notice from the application parameters, if present.
* <p>
* Searches the raw parameter list for an argument beginning with
* {@link #STARTUP_NOTICE_ARG_PREFIX} and returns the remainder as the notice text.
* Returns an empty Optional when no such argument is present.
*
* @return the startup notice text, or an empty Optional if no notice was forwarded
*/
private Optional<String> extractStartupNotice() {
List<String> rawParams = getParameters().getRaw();
return rawParams.stream()
.filter(p -> p.startsWith(STARTUP_NOTICE_ARG_PREFIX))
.map(p -> p.substring(STARTUP_NOTICE_ARG_PREFIX.length()))
.findFirst();
} }
/** /**
* Called by the JavaFX runtime when the application is stopping. * Called by the JavaFX runtime when the application is stopping.
* <p> * <p>
* Logs the GUI shutdown event. No additional cleanup is required * Logs the GUI shutdown event. No additional cleanup is required.
* for the minimal shell.
*/ */
@Override @Override
public void stop() { public void stop() {
LOG.info("GUI-Shell: JavaFX-Anwendung wird beendet."); LOG.info("GUI: JavaFX-Anwendung wird beendet.");
} }
} }
@@ -18,7 +18,8 @@ import java.util.Optional;
public record GuiConfigurationEditorState( public record GuiConfigurationEditorState(
Optional<GuiConfigurationFileSnapshot> loadedFileSnapshot, Optional<GuiConfigurationFileSnapshot> loadedFileSnapshot,
GuiConfigurationValues baselineValues, GuiConfigurationValues baselineValues,
GuiConfigurationValues values) { GuiConfigurationValues values,
Optional<String> pendingMigrationMessage) {
/** /**
* Creates a new editor state. * Creates a new editor state.
@@ -31,6 +32,7 @@ public record GuiConfigurationEditorState(
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot; loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot;
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null"); baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null");
values = Objects.requireNonNull(values, "values must not be null"); values = Objects.requireNonNull(values, "values must not be null");
pendingMigrationMessage = pendingMigrationMessage == null ? Optional.empty() : pendingMigrationMessage;
} }
/** /**
@@ -69,6 +71,36 @@ public record GuiConfigurationEditorState(
return loadedFileSnapshot.isEmpty(); return loadedFileSnapshot.isEmpty();
} }
/**
* Returns the configuration-path text shown in the GUI header.
* <p>
* When no file snapshot is loaded, the header must remain empty so the GUI does not
* suggest a file is already associated with the editor state.
*
* @return the loaded configuration path as text, or an empty string when no file is loaded
*/
public String configurationPathText() {
return loadedFileSnapshot.map(snapshot -> snapshot.filePath().toString()).orElse("");
}
/**
* Returns whether the editor holds a migration note for later display.
*
* @return {@code true} when a migration note is stored, otherwise {@code false}
*/
public boolean hasPendingMigrationMessage() {
return pendingMigrationMessage.isPresent();
}
/**
* Returns the stored migration note text.
*
* @return the migration note text, or an empty string when no note is stored
*/
public String pendingMigrationMessageText() {
return pendingMigrationMessage.orElse("");
}
/** /**
* Returns a copy with different current editable values. * Returns a copy with different current editable values.
* *
@@ -76,7 +108,7 @@ public record GuiConfigurationEditorState(
* @return a new editor state containing the supplied values * @return a new editor state containing the supplied values
*/ */
public GuiConfigurationEditorState withValues(GuiConfigurationValues values) { public GuiConfigurationEditorState withValues(GuiConfigurationValues values) {
return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values); return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values, pendingMigrationMessage);
} }
/** /**
@@ -86,7 +118,7 @@ public record GuiConfigurationEditorState(
* @return a new editor state using the supplied baseline * @return a new editor state using the supplied baseline
*/ */
public GuiConfigurationEditorState withBaselineValues(GuiConfigurationValues baselineValues) { public GuiConfigurationEditorState withBaselineValues(GuiConfigurationValues baselineValues) {
return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values); return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values, pendingMigrationMessage);
} }
/** /**
@@ -96,7 +128,7 @@ public record GuiConfigurationEditorState(
* @return a new editor state linked to the supplied snapshot * @return a new editor state linked to the supplied snapshot
*/ */
public GuiConfigurationEditorState withLoadedFileSnapshot(GuiConfigurationFileSnapshot snapshot) { public GuiConfigurationEditorState withLoadedFileSnapshot(GuiConfigurationFileSnapshot snapshot) {
return new GuiConfigurationEditorState(Optional.of(snapshot), baselineValues, values); return new GuiConfigurationEditorState(Optional.of(snapshot), baselineValues, values, pendingMigrationMessage);
} }
/** /**
@@ -105,7 +137,18 @@ public record GuiConfigurationEditorState(
* @return a new editor state without a loaded file snapshot * @return a new editor state without a loaded file snapshot
*/ */
public GuiConfigurationEditorState withoutLoadedFileSnapshot() { public GuiConfigurationEditorState withoutLoadedFileSnapshot() {
return new GuiConfigurationEditorState(Optional.empty(), baselineValues, values); return new GuiConfigurationEditorState(Optional.empty(), baselineValues, values, pendingMigrationMessage);
}
/**
* Returns a copy with a migration note that is kept for later display.
*
* @param migrationMessage the migration note; must not be {@code null}
* @return a new editor state carrying the supplied migration note
*/
public GuiConfigurationEditorState withPendingMigrationMessage(String migrationMessage) {
return new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, values,
Optional.of(migrationMessage));
} }
/** /**
@@ -116,6 +159,7 @@ public record GuiConfigurationEditorState(
public GuiConfigurationEditorState markClean() { public GuiConfigurationEditorState markClean() {
return baselineValues.equals(values) return baselineValues.equals(values)
? this ? this
: new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, baselineValues); : new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, baselineValues,
pendingMigrationMessage);
} }
} }
@@ -0,0 +1,96 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
/**
* Creates editor states from loaded properties snapshots or from the empty start state.
* <p>
* The factory maps the raw `.properties` document into the GUI-side editor model without
* introducing validation or effective-value resolution into the file load path.
*/
public final class GuiConfigurationEditorStateFactory {
private static final String PROP_SOURCE_FOLDER = "source.folder";
private static final String PROP_TARGET_FOLDER = "target.folder";
private static final String PROP_SQLITE_FILE = "sqlite.file";
private static final String PROP_PROMPT_TEMPLATE_FILE = "prompt.template.file";
private static final String PROP_RUNTIME_LOCK_FILE = "runtime.lock.file";
private static final String PROP_LOG_DIRECTORY = "log.directory";
private static final String PROP_LOG_LEVEL = "log.level";
private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient";
private static final String PROP_MAX_PAGES = "max.pages";
private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters";
private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive";
private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
private static final String PROP_CLAUDE_MODEL = "ai.provider.claude.model";
private static final String PROP_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
private static final String PROP_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
private static final String PROP_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
private static final String PROP_OPENAI_MODEL = "ai.provider.openai-compatible.model";
private static final String PROP_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
private static final String PROP_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
private GuiConfigurationEditorStateFactory() {
// Utility class.
}
/**
* Creates an editor state from a loaded properties snapshot.
*
* @param snapshot the loaded file snapshot; must not be {@code null}
* @param pendingMigrationMessage optional migration note to retain for later display
* @return an editor state representing the loaded configuration file
*/
public static GuiConfigurationEditorState fromPropertiesSnapshot(
GuiConfigurationFileSnapshot snapshot,
Optional<String> pendingMigrationMessage) {
Properties properties = snapshot.properties();
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations = new LinkedHashMap<>();
providerConfigurations.put(AiProviderFamily.CLAUDE, new GuiProviderConfigurationState(
propertyOrBlank(properties, PROP_CLAUDE_BASE_URL),
propertyOrBlank(properties, PROP_CLAUDE_MODEL),
propertyOrBlank(properties, PROP_CLAUDE_TIMEOUT),
GuiProviderApiKeyState.unresolved(propertyOrBlank(properties, PROP_CLAUDE_API_KEY))));
providerConfigurations.put(AiProviderFamily.OPENAI_COMPATIBLE, new GuiProviderConfigurationState(
propertyOrBlank(properties, PROP_OPENAI_BASE_URL),
propertyOrBlank(properties, PROP_OPENAI_MODEL),
propertyOrBlank(properties, PROP_OPENAI_TIMEOUT),
GuiProviderApiKeyState.unresolved(propertyOrBlank(properties, PROP_OPENAI_API_KEY))));
GuiConfigurationValues values = new GuiConfigurationValues(
propertyOrBlank(properties, PROP_SOURCE_FOLDER),
propertyOrBlank(properties, PROP_TARGET_FOLDER),
propertyOrBlank(properties, PROP_SQLITE_FILE),
propertyOrBlank(properties, PROP_PROMPT_TEMPLATE_FILE),
propertyOrBlank(properties, PROP_RUNTIME_LOCK_FILE),
propertyOrBlank(properties, PROP_LOG_DIRECTORY),
propertyOrBlank(properties, PROP_LOG_LEVEL),
propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT),
propertyOrBlank(properties, PROP_MAX_PAGES),
propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS),
propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE),
propertyOrBlank(properties, PROP_ACTIVE_PROVIDER),
providerConfigurations);
return new GuiConfigurationEditorState(Optional.of(snapshot), values, values, pendingMigrationMessage);
}
/**
* Creates the empty editor state used at application startup when no configuration is loaded.
*
* @return a blank editor state
*/
public static GuiConfigurationEditorState createBlankStartState() {
return GuiConfigurationTemplateFactory.createBlankStartState();
}
private static String propertyOrBlank(Properties properties, String key) {
String value = properties.getProperty(key);
return value == null ? "" : value.trim();
}
}
@@ -44,7 +44,34 @@ public final class GuiConfigurationTemplateFactory {
*/ */
public static GuiConfigurationEditorState createStandardTemplate() { public static GuiConfigurationEditorState createStandardTemplate() {
GuiConfigurationValues standardValues = createStandardValues(); GuiConfigurationValues standardValues = createStandardValues();
return new GuiConfigurationEditorState(Optional.empty(), standardValues, standardValues); return new GuiConfigurationEditorState(Optional.empty(), standardValues, standardValues, Optional.empty());
}
/**
* Creates the empty editor state used when the GUI starts without a loaded configuration.
* <p>
* This start state intentionally does not show the standard template yet. The template
* is reserved for the explicit {@code Neu} action so the GUI starts without an implicit
* draft and only shows the welcome guidance until the user requests a new configuration.
*
* @return a clean editor state without a loaded file snapshot and without template values
*/
public static GuiConfigurationEditorState createBlankStartState() {
GuiConfigurationValues blankValues = new GuiConfigurationValues(
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
Map.of());
return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty());
} }
/** /**
@@ -1,15 +1,27 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui; package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.Optional; import java.util.Optional;
import java.util.function.BooleanSupplier;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.control.Label; import javafx.scene.control.Label;
@@ -37,6 +49,8 @@ import org.junit.jupiter.api.TestMethodOrder;
* <li>{@link GuiAdapter} can be constructed without triggering the JavaFX runtime.</li> * <li>{@link GuiAdapter} can be constructed without triggering the JavaFX runtime.</li>
* <li>The startup-notice parameter path in {@link PdfUmbenennerGuiApplication} * <li>The startup-notice parameter path in {@link PdfUmbenennerGuiApplication}
* resolves correctly when parameters are not present.</li> * resolves correctly when parameters are not present.</li>
* <li>The editor workspace exposes the empty start state and the explicit
* {@code Neu} transition to the standard template.</li>
* </ul> * </ul>
* *
* <h2>Excluded from this scope</h2> * <h2>Excluded from this scope</h2>
@@ -169,43 +183,124 @@ class GuiAdapterSmokeTest {
} }
// ========================================================================= // =========================================================================
// Startup notice parameter extraction // Startup context
// ========================================================================= // =========================================================================
/** /**
* Verifies that the startup-notice argument prefix constant in * Verifies that a blank GUI startup context starts without a loaded configuration
* {@link PdfUmbenennerGuiApplication} is non-blank and begins with {@code --}. * while still carrying a startup notice when one is supplied.
* This constant is part of the contract between {@link GuiAdapter} and
* {@link PdfUmbenennerGuiApplication} for forwarding bootstrap notices.
*/ */
@Test @Test
@Order(4) @Order(4)
void startupNoticeArgPrefix_isNonBlankAndStartsWithDoubleDash() { void startupContext_blankContextKeepsNoticeAndUsesEmptyState() {
String prefix = PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX; GuiStartupContext context = GuiStartupContext.blank(Optional.of("Hinweis"));
assertNotNull(prefix, "STARTUP_NOTICE_ARG_PREFIX must not be null");
assertFalse(prefix.isBlank(), "STARTUP_NOTICE_ARG_PREFIX must not be blank"); assertTrue(context.startupNotice().isPresent());
assertTrue(prefix.startsWith("--"), assertEquals("Hinweis", context.startupNotice().orElseThrow());
"STARTUP_NOTICE_ARG_PREFIX must start with '--' to be recognizable as an application parameter"); assertFalse(context.initialState().hasLoadedFileSnapshot());
assertEquals("", context.initialState().configurationPathText());
} }
// =========================================================================
// Editor workspace structure
// =========================================================================
/** /**
* Verifies that the startup-notice forwarding contract between {@link GuiAdapter} * Verifies that the editor workspace starts without a loaded configuration, shows the
* and {@link PdfUmbenennerGuiApplication} is consistent: when a notice is present, * welcome guidance, and exposes the fixed GUI structure of the current shell.
* the argument is constructed by prepending the prefix to the notice text. *
* @throws Exception if the FX thread task fails or times out
*/ */
@Test @Test
@Order(5) @Order(5)
void startupNotice_argIsConstructedByPrependingPrefix() { void editorWorkspace_startStateShowsEmptyHeaderWelcomeGuidanceAndOneTab() throws Exception {
String noticeText = "Konfigurationsdatei nicht gefunden: /pfad/zur/datei.properties"; CountDownLatch latch = new CountDownLatch(1);
String expectedArg = PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX + noticeText; AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicReference<GuiConfigurationEditorWorkspace> workspaceReference = new AtomicReference<>();
// Verify the concatenation produces a parseable argument string Platform.runLater(() -> {
assertTrue(expectedArg.startsWith(PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX), try {
"The constructed notice argument must start with the prefix"); GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(Optional.empty());
String extractedNotice = expectedArg.substring( workspaceReference.set(workspace);
PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX.length());
assertEquals(noticeText, extractedNotice, assertEquals("", workspace.configurationPathText(),
"The notice text must be recoverable from the argument by stripping the prefix"); "The header path must stay empty before any configuration is loaded");
assertTrue(workspace.isWelcomeGuidanceVisible(),
"The welcome guidance must be visible in the unloaded start state");
assertTrue(workspace.welcomeText().contains("Willkommen"),
"The welcome text must be shown in German");
assertNotNull(workspace.root(),
"The workspace root must be available");
assertEquals("Neu", workspace.newButton().getText(),
"The 'Neu' button must be visible");
assertEquals("Öffnen", workspace.openButton().getText(),
"The 'Öffnen' button must be visible");
assertEquals("Speichern", workspace.saveButton().getText(),
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(1, workspace.tabPane().getTabs().size(),
"Exactly one configuration tab must be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The single tab must use the configuration label");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
"The single tab must expose the fixed section structure in the documented order");
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(
latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX thread task must complete within timeout");
if (fxError.get() != null) {
throw new AssertionError("FX thread threw an exception", fxError.get());
}
assertNotNull(workspaceReference.get(),
"The workspace must have been created successfully");
}
@Test
@Order(6)
void editorWorkspace_newActionSwitchesToStandardTemplateWithoutPath() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(Optional.empty());
workspace.newButton().fire();
workspace.saveButton().fire();
workspace.saveAsButton().fire();
GuiConfigurationEditorState state = workspace.editorState();
assertFalse(workspace.isWelcomeGuidanceVisible(),
"The welcome guidance should disappear once the user creates a new template");
assertEquals("", state.configurationPathText(),
"Creating a new template must not imply a loaded file path");
assertEquals("./work/local/source", state.values().sourceFolder(),
"The standard template must populate the documented default source folder");
assertEquals("./work/local/target", state.values().targetFolder(),
"The standard template must populate the documented default target folder");
assertEquals("INFO", state.values().logLevel(),
"The standard template must remain intact after the wired placeholder actions");
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(
latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX thread task must complete within timeout");
if (fxError.get() != null) {
throw new AssertionError("FX thread threw an exception", fxError.get());
}
} }
// ========================================================================= // =========================================================================
@@ -222,7 +317,7 @@ class GuiAdapterSmokeTest {
* bootstrap module covers the full launch path via the executable JAR. * bootstrap module covers the full launch path via the executable JAR.
*/ */
@Test @Test
@Order(6) @Order(8)
void guiAdapter_startWithEmptyNotice_constructsNoNoticeArg() { void guiAdapter_startWithEmptyNotice_constructsNoNoticeArg() {
// Verify the contract: when notice is empty, no prefix-based arg is constructed. // Verify the contract: when notice is empty, no prefix-based arg is constructed.
Optional<String> emptyNotice = Optional.empty(); Optional<String> emptyNotice = Optional.empty();
@@ -230,16 +325,14 @@ class GuiAdapterSmokeTest {
"When no notice is supplied, the Optional must be empty and no notice arg is constructed"); "When no notice is supplied, the Optional must be empty and no notice arg is constructed");
} }
// ========================================================================= private static void waitFor(BooleanSupplier condition, long timeoutSeconds) throws InterruptedException {
// Helpers long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
// ========================================================================= while (System.nanoTime() < deadline) {
if (condition.getAsBoolean()) {
/** return;
* Asserts equality with a descriptive message; delegates to JUnit's assertEquals. }
*/ Thread.sleep(20L);
private static void assertEquals(String expected, String actual, String message) { }
if (!expected.equals(actual)) { throw new AssertionError("Condition did not become true within timeout");
throw new AssertionError(message + " — expected: <" + expected + "> but was: <" + actual + ">");
}
} }
} }
@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class GuiConfigurationEditorStateTest { class GuiConfigurationEditorStateTest {
@@ -53,6 +55,25 @@ class GuiConfigurationEditorStateTest {
assertFalse(withSnapshot.isNewConfiguration()); assertFalse(withSnapshot.isNewConfiguration());
assertEquals(snapshot, withSnapshot.loadedFileSnapshot().orElseThrow()); assertEquals(snapshot, withSnapshot.loadedFileSnapshot().orElseThrow());
assertFalse(withSnapshot.isDirty()); assertFalse(withSnapshot.isDirty());
assertEquals(Path.of("config/application.properties").toString(), withSnapshot.configurationPathText());
}
@Test
void configurationPathTextIsEmptyWhenNoFileIsLoaded() {
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createBlankStartState();
assertEquals("", state.configurationPathText());
}
@Test
void pendingMigrationMessageCanBeStoredWithoutChangingDirtyState() {
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
GuiConfigurationEditorState migrated = state.withPendingMigrationMessage("Legacy import");
assertTrue(migrated.hasPendingMigrationMessage());
assertEquals("Legacy import", migrated.pendingMigrationMessageText());
assertFalse(migrated.isDirty());
assertEquals(state.values(), migrated.values());
} }
@Test @Test
@@ -22,6 +22,7 @@ class GuiConfigurationTemplateFactoryTest {
assertFalse(state.isDirty()); assertFalse(state.isDirty());
assertFalse(state.hasLoadedFileSnapshot()); assertFalse(state.hasLoadedFileSnapshot());
assertTrue(state.isNewConfiguration()); assertTrue(state.isNewConfiguration());
assertFalse(state.hasPendingMigrationMessage());
GuiConfigurationValues values = state.values(); GuiConfigurationValues values = state.values();
assertEquals("./work/local/source", values.sourceFolder()); assertEquals("./work/local/source", values.sourceFolder());
@@ -57,6 +58,31 @@ class GuiConfigurationTemplateFactoryTest {
assertEquals(GuiValueOrigin.UNKNOWN, openAi.apiKey().effectiveValueOrigin()); assertEquals(GuiValueOrigin.UNKNOWN, openAi.apiKey().effectiveValueOrigin());
} }
@Test
void createBlankStartState_startsWithoutLoadedConfigurationAndWithoutTemplateValues() {
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createBlankStartState();
assertFalse(state.isDirty());
assertFalse(state.hasLoadedFileSnapshot());
assertTrue(state.isNewConfiguration());
assertEquals("", state.configurationPathText());
GuiConfigurationValues values = state.values();
assertEquals("", values.sourceFolder());
assertEquals("", values.targetFolder());
assertEquals("", values.sqliteFile());
assertEquals("", values.promptTemplateFile());
assertEquals("", values.runtimeLockFile());
assertEquals("", values.logDirectory());
assertEquals("", values.logLevel());
assertEquals("", values.maxRetriesTransient());
assertEquals("", values.maxPages());
assertEquals("", values.maxTextCharacters());
assertEquals("", values.logAiSensitive());
assertEquals("", values.activeProviderFamily());
assertTrue(values.providerConfigurations().isEmpty());
}
@Test @Test
void providerConfigurationMap_isImmutableFromOutside() { void providerConfigurationMap_isImmutableFromOutside() {
GuiConfigurationValues values = GuiConfigurationTemplateFactory.createStandardValues(); GuiConfigurationValues values = GuiConfigurationTemplateFactory.createStandardValues();
@@ -1,17 +1,28 @@
package de.gecheckt.pdf.umbenenner.bootstrap; package de.gecheckt.pdf.umbenenner.bootstrap;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Instant; import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Properties;
import java.util.UUID; import java.util.UUID;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationEditorWorkspace;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments; import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode; import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
@@ -518,25 +529,11 @@ public class BootstrapRunner {
* @return exit code: 0 for normal GUI shutdown, 1 for any GUI startup failure * @return exit code: 0 for normal GUI shutdown, 1 for any GUI startup failure
*/ */
private int startGuiMode(Optional<String> configPathOverride) { private int startGuiMode(Optional<String> configPathOverride) {
Optional<String> startupNotice = Optional.empty(); GuiStartupContext startupContext = buildGuiStartupContext(configPathOverride);
if (configPathOverride.isPresent()) {
Path configPath = Paths.get(configPathOverride.get());
if (Files.exists(configPath)) {
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
} else {
LOG.error("GUI startup: --config path not found: {}. Starting GUI without configuration override.",
configPath.toAbsolutePath());
startupNotice = Optional.of(
"Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
+ "\nDie GUI startet ohne Konfigurationsdatei.");
}
}
LOG.info("GUI startup: launching GUI adapter."); LOG.info("GUI startup: launching GUI adapter.");
try { try {
GuiAdapter guiAdapter = guiAdapterFactory.create(); GuiAdapter guiAdapter = guiAdapterFactory.create();
guiAdapter.start(startupNotice); guiAdapter.start(startupContext);
LOG.info("GUI adapter terminated normally."); LOG.info("GUI adapter terminated normally.");
return 0; return 0;
} catch (Exception e) { } catch (Exception e) {
@@ -615,6 +612,83 @@ public class BootstrapRunner {
.orElse(DEFAULT_CONFIG_PATH); .orElse(DEFAULT_CONFIG_PATH);
} }
private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
if (configPathOverride.isEmpty()) {
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.empty(),
loader);
}
Path configPath = Paths.get(configPathOverride.get());
if (!Files.exists(configPath)) {
LOG.error("GUI startup: --config path not found: {}. Starting GUI without configuration override.",
configPath.toAbsolutePath());
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.of("Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
+ "\nDie GUI startet ohne Konfigurationsdatei."),
loader);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
try {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
return new GuiStartupContext(loadedState, Optional.empty(), loader);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage()),
loader);
}
}
private GuiConfigurationEditorState loadGuiConfigurationState(Path configFilePath) {
try {
boolean legacyDetected = detectedLegacyConfiguration(configFilePath);
migrateConfigurationIfNeeded(configFilePath);
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
configFilePath,
readPropertiesSnapshot(configFilePath));
Optional<String> migrationMessage = legacyDetected
? Optional.of("Konfiguration wurde aus einer Legacy-Datei übernommen.")
: Optional.empty();
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, migrationMessage);
} catch (ConfigurationLoadingException e) {
throw new GuiConfigurationLoadException("Failed to load configuration from " + configFilePath, e);
}
}
private boolean detectedLegacyConfiguration(Path configFilePath) {
try {
String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content.replace("\\", "\\\\")));
boolean hasLegacyKey = props.containsKey("api.baseUrl")
|| props.containsKey("api.model")
|| props.containsKey("api.timeoutSeconds")
|| props.containsKey("api.key");
return hasLegacyKey && !props.containsKey("ai.provider.active");
} catch (IOException e) {
throw new GuiConfigurationLoadException("Failed to inspect legacy configuration at " + configFilePath, e);
}
}
private Properties readPropertiesSnapshot(Path configFilePath) {
try {
String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content.replace("\\", "\\\\")));
return props;
} catch (IOException e) {
throw new GuiConfigurationLoadException("Failed to create snapshot for " + configFilePath, e);
}
}
/** /**
* Runs the legacy configuration migration step against the effective configuration path. * Runs the legacy configuration migration step against the effective configuration path.
* <p> * <p>
@@ -25,6 +25,10 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
@@ -184,11 +188,13 @@ class BootstrapRunnerConfigPathSemanticsTest {
void runGui_withNonExistentConfigPath_startsGuiAndReturnsZeroOnNormalShutdown() { void runGui_withNonExistentConfigPath_startsGuiAndReturnsZeroOnNormalShutdown() {
String nonExistentPath = tempDir.resolve("missing.properties").toString(); String nonExistentPath = tempDir.resolve("missing.properties").toString();
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>(); AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
receivedNotice.set(startupNotice); receivedNotice.set(startupContext.startupNotice());
receivedState.set(startupContext.initialState());
// normal shutdown: returns without throwing // normal shutdown: returns without throwing
} }
}); });
@@ -199,6 +205,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
"GUI with non-existent --config path must still return exit code 0 on normal shutdown"); "GUI with non-existent --config path must still return exit code 0 on normal shutdown");
assertTrue(receivedNotice.get().isPresent(), assertTrue(receivedNotice.get().isPresent(),
"A startup notice must be forwarded to the GUI adapter when the config file is missing"); "A startup notice must be forwarded to the GUI adapter when the config file is missing");
assertFalse(receivedState.get().hasLoadedFileSnapshot(),
"GUI must start without a loaded configuration when the supplied file is missing");
} }
@Test @Test
@@ -223,7 +231,7 @@ class BootstrapRunnerConfigPathSemanticsTest {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
// normal termination // normal termination
} }
}); });
@@ -250,8 +258,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
receivedNotice.set(startupNotice); receivedNotice.set(startupContext.startupNotice());
} }
}); });
@@ -266,11 +274,13 @@ class BootstrapRunnerConfigPathSemanticsTest {
void runGui_withExistingConfigPath_startsGuiWithEmptyNotice(@TempDir Path workDir) throws Exception { void runGui_withExistingConfigPath_startsGuiWithEmptyNotice(@TempDir Path workDir) throws Exception {
Path existingConfigFile = Files.createFile(workDir.resolve("real.properties")); Path existingConfigFile = Files.createFile(workDir.resolve("real.properties"));
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>(); AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
receivedNotice.set(startupNotice); receivedNotice.set(startupContext.startupNotice());
receivedState.set(startupContext.initialState());
} }
}); });
@@ -281,6 +291,76 @@ class BootstrapRunnerConfigPathSemanticsTest {
"GUI with existing --config path must return exit code 0 on normal shutdown"); "GUI with existing --config path must return exit code 0 on normal shutdown");
assertFalse(receivedNotice.get().isPresent(), assertFalse(receivedNotice.get().isPresent(),
"No startup notice must be forwarded when the config file exists"); "No startup notice must be forwarded when the config file exists");
assertTrue(receivedState.get().hasLoadedFileSnapshot(),
"GUI must receive a loaded editor state when the supplied file exists");
assertEquals(existingConfigFile.toString(), receivedState.get().configurationPathText(),
"The loaded editor state must point to the supplied configuration file");
}
@Test
void runGui_withLegacyConfigPath_loadsMigratedStateAndKeepsMigrationNote(@TempDir Path workDir) throws Exception {
Path legacyConfigFile = workDir.resolve("legacy.properties");
Files.createDirectories(workDir.resolve("source"));
Files.createDirectories(workDir.resolve("target"));
Files.createDirectories(workDir.resolve("logs"));
Files.createFile(workDir.resolve("db.sqlite"));
Files.createFile(workDir.resolve("prompt.txt"));
Files.createFile(workDir.resolve("lock.pid"));
Files.writeString(legacyConfigFile, """
source.folder=%s
target.folder=%s
sqlite.file=%s
api.baseUrl=https://api.openai.com/v1
api.model=gpt-4o-mini
api.timeoutSeconds=30
api.key=test-api-key
max.retries.transient=3
max.pages=10
max.text.characters=5000
prompt.template.file=%s
runtime.lock.file=%s
log.directory=%s
log.level=INFO
log.ai.sensitive=false
""".formatted(
workDir.resolve("source"),
workDir.resolve("target"),
workDir.resolve("db.sqlite"),
workDir.resolve("prompt.txt"),
workDir.resolve("lock.pid"),
workDir.resolve("logs")));
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();
BootstrapRunner runner = new BootstrapRunner(
path -> new LegacyConfigurationMigrator().migrateIfLegacy(path),
PropertiesConfigurationPortAdapter::new,
lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(),
(config, lock) -> new MockRunBatchProcessingUseCase(true),
SchedulerBatchCommand::new,
() -> new GuiAdapter() {
@Override
public void start(GuiStartupContext startupContext) {
receivedNotice.set(startupContext.startupNotice());
receivedState.set(startupContext.initialState());
}
}
);
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.of(legacyConfigFile.toString())));
assertEquals(0, exitCode, "GUI with legacy config path must still return exit code 0 on normal shutdown");
assertFalse(receivedNotice.get().isPresent(),
"No startup notice is needed when a legacy config can be migrated and loaded");
assertTrue(receivedState.get().hasLoadedFileSnapshot(),
"The migrated editor state must carry the loaded file snapshot");
assertTrue(receivedState.get().hasPendingMigrationMessage(),
"The migrated editor state must retain the pending migration message");
assertEquals(legacyConfigFile.toString(), receivedState.get().configurationPathText(),
"The migrated editor state must report the full loaded path");
} }
@Test @Test
@@ -289,8 +369,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
receivedNotice.set(startupNotice); receivedNotice.set(startupContext.startupNotice());
} }
}); });
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration; import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
@@ -41,7 +43,7 @@ class BootstrapRunnerStartupDispatchTest {
void run_withGuiMode_returnsZeroWhenGuiStartSucceeds() { void run_withGuiMode_returnsZeroWhenGuiStartSucceeds() {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
// normal termination: returns without throwing // normal termination: returns without throwing
} }
}); });
@@ -55,7 +57,7 @@ class BootstrapRunnerStartupDispatchTest {
void run_withGuiMode_returnsOneWhenGuiStartThrowsException() { void run_withGuiMode_returnsOneWhenGuiStartThrowsException() {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override @Override
public void start(Optional<String> startupNotice) { public void start(GuiStartupContext startupContext) {
throw new RuntimeException("simulated GUI startup failure"); throw new RuntimeException("simulated GUI startup failure");
} }
}); });