From 01414fc732ddb40689ffbcdf443b83edcd69f7da Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 20 Apr 2026 13:07:19 +0200 Subject: [PATCH] M10 bis AP-003 --- .../umbenenner/adapter/in/gui/GuiAdapter.java | 25 +- .../gui/GuiConfigurationEditorWorkspace.java | 449 ++++++++++++++++++ .../in/gui/GuiConfigurationFileLoader.java | 23 + .../in/gui/GuiConfigurationLoadException.java | 26 + .../adapter/in/gui/GuiStartupContext.java | 46 ++ .../in/gui/GuiStartupContextHolder.java | 31 ++ .../in/gui/PdfUmbenennerGuiApplication.java | 122 +---- .../editor/GuiConfigurationEditorState.java | 56 ++- .../GuiConfigurationEditorStateFactory.java | 96 ++++ .../GuiConfigurationTemplateFactory.java | 29 +- .../adapter/in/gui/GuiAdapterSmokeTest.java | 163 +++++-- .../GuiConfigurationEditorStateTest.java | 21 + .../GuiConfigurationTemplateFactoryTest.java | 26 + .../umbenenner/bootstrap/BootstrapRunner.java | 106 ++++- ...ootstrapRunnerConfigPathSemanticsTest.java | 98 +++- .../BootstrapRunnerStartupDispatchTest.java | 6 +- 16 files changed, 1139 insertions(+), 184 deletions(-) create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationFileLoader.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationLoadException.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContextHolder.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiConfigurationEditorStateFactory.java 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

- * - * - *

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"); } });