diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java
index 6fca5a8..4264b08 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java
@@ -34,9 +34,9 @@ import org.apache.logging.log4j.Logger;
*
*
Current scope
*
- * The adapter launches a minimal GUI shell that proves the GUI startup path works.
- * It does not provide a configuration editor, file operations, validation, provider
- * controls, or any other functionality beyond the technical startup proof.
+ * The adapter launches the editor shell with the unloaded start state, an optional startup
+ * notice, and a file-loading callback supplied by Bootstrap. File I/O and save behavior remain
+ * outside the current GUI step.
*/
public class GuiAdapter {
@@ -84,13 +84,22 @@ public class GuiAdapter {
* or if the platform is not supported
*/
public void start(Optional startupNotice) {
+ start(GuiStartupContext.blank(startupNotice));
+ }
+
+ /**
+ * 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.");
- if (startupNotice.isPresent()) {
- Application.launch(PdfUmbenennerGuiApplication.class,
- PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX + startupNotice.get());
- } else {
+ 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.");
}
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
new file mode 100644
index 0000000..a10762f
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java
@@ -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.
+ *
+ * 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 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 sectionTitles() {
+ return List.of("Pfade", "Provider", "Verarbeitungslimits", "Tests", "Meldungen");
+ }
+
+ /**
+ * Handles the explicit "Neu" action.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 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();
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationFileLoader.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationFileLoader.java
new file mode 100644
index 0000000..1a66dbd
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationFileLoader.java
@@ -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.
+ *
+ * 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);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationLoadException.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationLoadException.java
new file mode 100644
index 0000000..0399a7e
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationLoadException.java
@@ -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);
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
new file mode 100644
index 0000000..f2f303b
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java
@@ -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.
+ *
+ * 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 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 startupNotice) {
+ return new GuiStartupContext(
+ GuiConfigurationEditorStateFactory.createBlankStartState(),
+ startupNotice,
+ configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState());
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContextHolder.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContextHolder.java
new file mode 100644
index 0000000..8671612
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContextHolder.java
@@ -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.
+ *
+ * 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 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);
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java
index 3ec93c7..8f440c8 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java
@@ -1,153 +1,61 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
-import java.util.List;
-import java.util.Optional;
-
import javafx.application.Application;
-import javafx.geometry.Pos;
import javafx.scene.Scene;
-import javafx.scene.control.Label;
-import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import org.apache.logging.log4j.LogManager;
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.
*
- * This class is the JavaFX lifecycle entry point launched by
- * {@link GuiAdapter#start(java.util.Optional)}. It creates the primary stage with a
- * minimal, neutral layout that proves the GUI startup path is technically functional.
- * 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.
- *
- *
Startup notice
- *
- * 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.
- *
- *
Current scope
- *
- * 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.
- *
- *
Explicit non-goals
- *
- * - No configuration editor or editable input fields
- * - No file operations (Neu, Oeffnen, Speichern, Speichern unter)
- * - No validation or provider controls
- * - No message area or technical tests
- * - No welcome text in the final product sense
- *
- *
- * Threading
- *
- * The {@link #start(Stage)} method is called by the JavaFX runtime on the
- * JavaFX Application Thread. No blocking operations are performed during
- * stage setup.
+ * The application starts the editor shell in a clean, unloaded state unless Bootstrap
+ * has provided a preloaded startup context. The visible editor surface is delegated to
+ * {@link GuiConfigurationEditorWorkspace}.
*/
public class PdfUmbenennerGuiApplication extends Application {
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[])}.
- *
- * 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 double DEFAULT_WIDTH = 800;
- private static final double DEFAULT_HEIGHT = 600;
+ private static final double DEFAULT_WIDTH = 1100;
+ private static final double DEFAULT_HEIGHT = 800;
/**
* Creates a new instance of the JavaFX application.
- *
- * This no-argument constructor is required by the JavaFX runtime, which
- * instantiates the {@link Application} subclass reflectively.
*/
public PdfUmbenennerGuiApplication() {
// Required by JavaFX runtime for reflective instantiation.
}
/**
- * Initializes and shows the primary stage with a minimal GUI shell.
- *
- * 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.
- *
- * Start and shutdown events are logged via Log4j2 to satisfy the GUI logging requirements.
+ * Initializes and shows the primary stage.
*
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/
@Override
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 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.setScene(scene);
- primaryStage.setOnCloseRequest(event ->
- LOG.info("GUI-Shell: Fenster wird vom Benutzer geschlossen."));
+ primaryStage.setOnCloseRequest(event -> LOG.info("GUI: Fenster wird vom Benutzer geschlossen."));
primaryStage.show();
- LOG.info("GUI-Shell: Hauptfenster erfolgreich angezeigt.");
- }
-
- /**
- * Extracts the startup notice from the application parameters, if present.
- *
- * 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 extractStartupNotice() {
- List rawParams = getParameters().getRaw();
- return rawParams.stream()
- .filter(p -> p.startsWith(STARTUP_NOTICE_ARG_PREFIX))
- .map(p -> p.substring(STARTUP_NOTICE_ARG_PREFIX.length()))
- .findFirst();
+ LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
}
/**
* Called by the JavaFX runtime when the application is stopping.
*
- * Logs the GUI shutdown event. No additional cleanup is required
- * for the minimal shell.
+ * Logs the GUI shutdown event. No additional cleanup is required.
*/
@Override
public void stop() {
- LOG.info("GUI-Shell: JavaFX-Anwendung wird beendet.");
+ LOG.info("GUI: JavaFX-Anwendung wird beendet.");
}
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java
index 72d0657..6d8010a 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorState.java
@@ -18,7 +18,8 @@ import java.util.Optional;
public record GuiConfigurationEditorState(
Optional loadedFileSnapshot,
GuiConfigurationValues baselineValues,
- GuiConfigurationValues values) {
+ GuiConfigurationValues values,
+ Optional pendingMigrationMessage) {
/**
* Creates a new editor state.
@@ -31,6 +32,7 @@ public record GuiConfigurationEditorState(
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot;
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues 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();
}
+ /**
+ * Returns the configuration-path text shown in the GUI header.
+ *
+ * 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.
*
@@ -76,7 +108,7 @@ public record GuiConfigurationEditorState(
* @return a new editor state containing the supplied 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
*/
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
*/
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
*/
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() {
return baselineValues.equals(values)
? this
- : new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, baselineValues);
+ : new GuiConfigurationEditorState(loadedFileSnapshot, baselineValues, baselineValues,
+ pendingMigrationMessage);
}
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java
new file mode 100644
index 0000000..b32f10b
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java
@@ -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.
+ *
+ * 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 pendingMigrationMessage) {
+ Properties properties = snapshot.properties();
+ Map 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();
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java
index 8a711d4..c42a7e0 100644
--- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactory.java
@@ -44,7 +44,34 @@ public final class GuiConfigurationTemplateFactory {
*/
public static GuiConfigurationEditorState createStandardTemplate() {
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.
+ *
+ * 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());
}
/**
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
index b3a045f..7bde568 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
@@ -1,15 +1,27 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
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.assertTrue;
+import java.nio.file.Path;
import java.util.Optional;
+import java.util.function.BooleanSupplier;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
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.scene.control.Label;
@@ -37,6 +49,8 @@ import org.junit.jupiter.api.TestMethodOrder;
*
{@link GuiAdapter} can be constructed without triggering the JavaFX runtime.
* The startup-notice parameter path in {@link PdfUmbenennerGuiApplication}
* resolves correctly when parameters are not present.
+ * The editor workspace exposes the empty start state and the explicit
+ * {@code Neu} transition to the standard template.
*
*
* Excluded from this scope
@@ -169,43 +183,124 @@ class GuiAdapterSmokeTest {
}
// =========================================================================
- // Startup notice parameter extraction
+ // Startup context
// =========================================================================
/**
- * Verifies that the startup-notice argument prefix constant in
- * {@link PdfUmbenennerGuiApplication} is non-blank and begins with {@code --}.
- * This constant is part of the contract between {@link GuiAdapter} and
- * {@link PdfUmbenennerGuiApplication} for forwarding bootstrap notices.
+ * Verifies that a blank GUI startup context starts without a loaded configuration
+ * while still carrying a startup notice when one is supplied.
*/
@Test
@Order(4)
- void startupNoticeArgPrefix_isNonBlankAndStartsWithDoubleDash() {
- String prefix = PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX;
- assertNotNull(prefix, "STARTUP_NOTICE_ARG_PREFIX must not be null");
- assertFalse(prefix.isBlank(), "STARTUP_NOTICE_ARG_PREFIX must not be blank");
- assertTrue(prefix.startsWith("--"),
- "STARTUP_NOTICE_ARG_PREFIX must start with '--' to be recognizable as an application parameter");
+ void startupContext_blankContextKeepsNoticeAndUsesEmptyState() {
+ GuiStartupContext context = GuiStartupContext.blank(Optional.of("Hinweis"));
+
+ assertTrue(context.startupNotice().isPresent());
+ assertEquals("Hinweis", context.startupNotice().orElseThrow());
+ assertFalse(context.initialState().hasLoadedFileSnapshot());
+ assertEquals("", context.initialState().configurationPathText());
}
+ // =========================================================================
+ // Editor workspace structure
+ // =========================================================================
+
/**
- * Verifies that the startup-notice forwarding contract between {@link GuiAdapter}
- * and {@link PdfUmbenennerGuiApplication} is consistent: when a notice is present,
- * the argument is constructed by prepending the prefix to the notice text.
+ * Verifies that the editor workspace starts without a loaded configuration, shows the
+ * welcome guidance, and exposes the fixed GUI structure of the current shell.
+ *
+ * @throws Exception if the FX thread task fails or times out
*/
@Test
@Order(5)
- void startupNotice_argIsConstructedByPrependingPrefix() {
- String noticeText = "Konfigurationsdatei nicht gefunden: /pfad/zur/datei.properties";
- String expectedArg = PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX + noticeText;
+ void editorWorkspace_startStateShowsEmptyHeaderWelcomeGuidanceAndOneTab() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference fxError = new AtomicReference<>();
+ AtomicReference workspaceReference = new AtomicReference<>();
- // Verify the concatenation produces a parseable argument string
- assertTrue(expectedArg.startsWith(PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX),
- "The constructed notice argument must start with the prefix");
- String extractedNotice = expectedArg.substring(
- PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX.length());
- assertEquals(noticeText, extractedNotice,
- "The notice text must be recoverable from the argument by stripping the prefix");
+ Platform.runLater(() -> {
+ try {
+ GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(Optional.empty());
+ workspaceReference.set(workspace);
+
+ assertEquals("", workspace.configurationPathText(),
+ "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 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.
*/
@Test
- @Order(6)
+ @Order(8)
void guiAdapter_startWithEmptyNotice_constructsNoNoticeArg() {
// Verify the contract: when notice is empty, no prefix-based arg is constructed.
Optional 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");
}
- // =========================================================================
- // Helpers
- // =========================================================================
-
- /**
- * Asserts equality with a descriptive message; delegates to JUnit's assertEquals.
- */
- private static void assertEquals(String expected, String actual, String message) {
- if (!expected.equals(actual)) {
- throw new AssertionError(message + " — expected: <" + expected + "> but was: <" + actual + ">");
+ private static void waitFor(BooleanSupplier condition, long timeoutSeconds) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
+ while (System.nanoTime() < deadline) {
+ if (condition.getAsBoolean()) {
+ return;
+ }
+ Thread.sleep(20L);
}
+ throw new AssertionError("Condition did not become true within timeout");
}
}
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java
index a673a60..9713e75 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateTest.java
@@ -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.assertTrue;
+import java.nio.file.Path;
+
import org.junit.jupiter.api.Test;
class GuiConfigurationEditorStateTest {
@@ -53,6 +55,25 @@ class GuiConfigurationEditorStateTest {
assertFalse(withSnapshot.isNewConfiguration());
assertEquals(snapshot, withSnapshot.loadedFileSnapshot().orElseThrow());
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
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java
index 071225c..067fbe1 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationTemplateFactoryTest.java
@@ -22,6 +22,7 @@ class GuiConfigurationTemplateFactoryTest {
assertFalse(state.isDirty());
assertFalse(state.hasLoadedFileSnapshot());
assertTrue(state.isNewConfiguration());
+ assertFalse(state.hasPendingMigrationMessage());
GuiConfigurationValues values = state.values();
assertEquals("./work/local/source", values.sourceFolder());
@@ -57,6 +58,31 @@ class GuiConfigurationTemplateFactoryTest {
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
void providerConfigurationMap_isImmutableFromOutside() {
GuiConfigurationValues values = GuiConfigurationTemplateFactory.createStandardValues();
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
index e00f18b..df953e6 100644
--- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java
@@ -1,17 +1,28 @@
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.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
+import java.util.Properties;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
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.StartupMode;
@@ -518,25 +529,11 @@ public class BootstrapRunner {
* @return exit code: 0 for normal GUI shutdown, 1 for any GUI startup failure
*/
private int startGuiMode(Optional configPathOverride) {
- Optional startupNotice = Optional.empty();
-
- 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.");
- }
- }
-
+ GuiStartupContext startupContext = buildGuiStartupContext(configPathOverride);
LOG.info("GUI startup: launching GUI adapter.");
try {
GuiAdapter guiAdapter = guiAdapterFactory.create();
- guiAdapter.start(startupNotice);
+ guiAdapter.start(startupContext);
LOG.info("GUI adapter terminated normally.");
return 0;
} catch (Exception e) {
@@ -615,6 +612,83 @@ public class BootstrapRunner {
.orElse(DEFAULT_CONFIG_PATH);
}
+ private GuiStartupContext buildGuiStartupContext(Optional 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 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.
*
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java
index 68a5b7e..3242327 100644
--- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java
@@ -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.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.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
@@ -184,11 +188,13 @@ class BootstrapRunnerConfigPathSemanticsTest {
void runGui_withNonExistentConfigPath_startsGuiAndReturnsZeroOnNormalShutdown() {
String nonExistentPath = tempDir.resolve("missing.properties").toString();
AtomicReference> receivedNotice = new AtomicReference<>();
+ AtomicReference receivedState = new AtomicReference<>();
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
- receivedNotice.set(startupNotice);
+ public void start(GuiStartupContext startupContext) {
+ receivedNotice.set(startupContext.startupNotice());
+ receivedState.set(startupContext.initialState());
// 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");
assertTrue(receivedNotice.get().isPresent(),
"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
@@ -223,7 +231,7 @@ class BootstrapRunnerConfigPathSemanticsTest {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
+ public void start(GuiStartupContext startupContext) {
// normal termination
}
});
@@ -250,8 +258,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
- receivedNotice.set(startupNotice);
+ public void start(GuiStartupContext startupContext) {
+ receivedNotice.set(startupContext.startupNotice());
}
});
@@ -266,11 +274,13 @@ class BootstrapRunnerConfigPathSemanticsTest {
void runGui_withExistingConfigPath_startsGuiWithEmptyNotice(@TempDir Path workDir) throws Exception {
Path existingConfigFile = Files.createFile(workDir.resolve("real.properties"));
AtomicReference> receivedNotice = new AtomicReference<>();
+ AtomicReference receivedState = new AtomicReference<>();
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
- receivedNotice.set(startupNotice);
+ public void start(GuiStartupContext startupContext) {
+ 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");
assertFalse(receivedNotice.get().isPresent(),
"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> receivedNotice = new AtomicReference<>();
+ AtomicReference 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
@@ -289,8 +369,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
- receivedNotice.set(startupNotice);
+ public void start(GuiStartupContext startupContext) {
+ receivedNotice.set(startupContext.startupNotice());
}
});
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java
index bce80b6..a815fa8 100644
--- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java
@@ -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.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.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
@@ -41,7 +43,7 @@ class BootstrapRunnerStartupDispatchTest {
void run_withGuiMode_returnsZeroWhenGuiStartSucceeds() {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
+ public void start(GuiStartupContext startupContext) {
// normal termination: returns without throwing
}
});
@@ -55,7 +57,7 @@ class BootstrapRunnerStartupDispatchTest {
void run_withGuiMode_returnsOneWhenGuiStartThrowsException() {
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
@Override
- public void start(Optional startupNotice) {
+ public void start(GuiStartupContext startupContext) {
throw new RuntimeException("simulated GUI startup failure");
}
});