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 index a10762f..f3300be 100644 --- 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 @@ -1,11 +1,24 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui; +import java.io.File; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; +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.adapter.in.gui.editor.GuiConfigurationValues; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState; +import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -14,16 +27,21 @@ import javafx.scene.Parent; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; 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.control.TextField; import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; +import javafx.stage.Stage; import javafx.stage.Window; import org.apache.logging.log4j.LogManager; @@ -33,18 +51,40 @@ 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. + * callback, the visible section scaffold for the single-tab shell and the unsaved-changes + * guard that protects the user from accidental data loss when switching contexts. + * + *
The dirty state is derived by comparing the current {@code values} against + * {@code baselineValues} in the editor state. When dirty, two visual markers are shown: + * a small label in the header and a leading asterisk prefix in the window title. + * + *
Before "Neu", "Oeffnen" and window-close, the workspace consults the + * {@link GuiUnsavedChangesGuard} when the editor is dirty. The guard's dialog supplier can + * be replaced by tests for deterministic verification without a running scene. + * + *
The "Pfade" section renders text fields with native file- and folder-chooser buttons for + * all path-based configuration values. The "Provider" section renders two provider blocks + * (Claude and OpenAI-compatible) side by side with individual fields for base URL, model, + * timeout and API key. The "Verarbeitungslimits" section provides text fields for the numeric + * limits and a checkbox for the sensitive-logging flag. "Tests" and "Meldungen" are structural + * placeholders for functionality added in later iterations. + * + *
All filesystem checks (e.g. the overwrite confirmation for "Speichern unter") are
+ * performed on a background worker thread. UI updates always return to the FX Application
+ * Thread via {@code Platform.runLater}.
*/
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.";
+ "Willkommen. Legen Sie mit \u201eNeu\u201c eine Standardvorlage an"
+ + " oder \u00f6ffnen Sie eine bestehende Konfiguration.";
private final BorderPane root = new BorderPane();
private final Label statusLabel = new Label();
private final Label configurationPathValueLabel = new Label();
+ /** Package-private to allow visibility assertions in smoke tests. */
+ final Label dirtyMarkerLabel = new Label("geändert");
private final Label welcomeTitleLabel = new Label("Willkommen");
private final Label welcomeTextLabel = new Label(WELCOME_TEXT);
private final TabPane tabPane = new TabPane();
@@ -54,10 +94,89 @@ public final class GuiConfigurationEditorWorkspace {
private final Button saveButton = new Button("Speichern");
private final Button saveAsButton = new Button("Speichern unter");
+ private static final Path DEFAULT_SAVE_PATH = Paths.get("config/application.properties");
+
private final GuiConfigurationFileLoader configurationFileLoader;
- private GuiConfigurationEditorState editorState;
+ private final GuiConfigurationFileWriter configurationFileWriter;
+ /**
+ * The current editor state. Package-private to allow direct state injection in smoke tests
+ * that need to set a specific dirty state without going through the full load/save pipeline.
+ */
+ GuiConfigurationEditorState editorState;
private boolean welcomeGuidanceVisible;
+ /**
+ * Factory for the save-file chooser dialog; package-private to allow substitution in tests.
+ * The default creates a standard {@link FileChooser} instance.
+ */
+ Supplier
+ * The default delegates to {@link FileChooser#showSaveDialog(javafx.stage.Window)}.
+ * Tests may replace this with a lambda that returns a fixed file without opening a native dialog.
+ */
+ java.util.function.BiFunction
+ * Must only be called from the FX Application Thread.
+ */
+ java.util.function.Supplier
+ * The factory receives the runnable to execute and must return a ready-to-start
+ * (but not yet started) {@link Thread}.
+ */
+ java.util.function.Function
+ * The default implementation opens a native {@link DirectoryChooser}. Tests may replace
+ * this with a lambda that returns a fixed string without opening a native dialog.
+ */
+ java.util.function.BiFunction
+ * The default implementation opens a native {@link FileChooser}. Tests may replace
+ * this with a lambda that returns a fixed string without opening a native dialog.
+ *
+ * Extension filters are applied by the default implementation only; test stubs bypass them.
+ */
+ java.util.function.BiFunction
+ * When the editor is dirty the handler shows the unsaved-changes protection dialog and
+ * consumes the close event if the user chooses to cancel or when saving fails. If the user
+ * chooses to save, the close is deferred until the background save completes successfully,
+ * at which point {@link Stage#close()} is called again on the FX Application Thread.
+ *
+ * @param stage the primary stage; must not be {@code null}
+ */
+ public void installCloseRequestHandler(Stage stage) {
+ stage.setOnCloseRequest(event -> {
+ if (!editorState.isDirty()) {
+ return;
+ }
+ event.consume();
+ unsavedChangesGuard.askAndProceed(
+ "Schließen",
+ () -> {
+ LOG.info("GUI-Editor: Fenster wird nach Verwerfen der Änderungen geschlossen.");
+ stage.close();
+ },
+ () -> performSaveBeforeAction(stage::close));
+ });
+ }
+
/**
* Returns the root node used by the JavaFX scene.
*
@@ -193,6 +342,17 @@ public final class GuiConfigurationEditorWorkspace {
* The workspace switches to the standard template and hides the welcome guidance.
*/
public void requestNewConfiguration() {
+ if (editorState.isDirty()) {
+ unsavedChangesGuard.askAndProceed(
+ "Neu",
+ this::doApplyNewConfiguration,
+ () -> performSaveBeforeAction(this::doApplyNewConfiguration));
+ } else {
+ doApplyNewConfiguration();
+ }
+ }
+
+ private void doApplyNewConfiguration() {
LOG.info("GUI-Editor: Neue Standardvorlage wird angezeigt.");
applyEditorState(GuiConfigurationTemplateFactory.createStandardTemplate());
}
@@ -203,6 +363,17 @@ public final class GuiConfigurationEditorWorkspace {
* The file chooser is native to the platform and filters for {@code *.properties} files.
*/
public void requestOpenConfiguration() {
+ if (editorState.isDirty()) {
+ unsavedChangesGuard.askAndProceed(
+ "Öffnen",
+ this::doOpenConfigurationDialog,
+ () -> performSaveBeforeAction(this::doOpenConfigurationDialog));
+ return;
+ }
+ doOpenConfigurationDialog();
+ }
+
+ private void doOpenConfigurationDialog() {
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Konfiguration öffnen");
@@ -248,19 +419,279 @@ public final class GuiConfigurationEditorWorkspace {
/**
* Handles the explicit "Speichern" action.
*
- * File writing is intentionally not implemented yet.
+ * When the editor has a loaded file snapshot, the file is written directly to the same
+ * path. When no file has been loaded yet (new configuration), the action delegates to
+ * "Speichern unter" to let the user choose a target path.
*/
public void requestSaveConfiguration() {
- LOG.info("GUI-Editor: Speichern-Aktion wurde ausgelöst, ist aber noch nicht implementiert.");
+ if (editorState.isNewConfiguration()) {
+ requestSaveConfigurationAs();
+ return;
+ }
+ Path targetPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
+ saveToPath(targetPath);
}
/**
* Handles the explicit "Speichern unter" action.
*
- * File writing is intentionally not implemented yet.
+ * Opens a native file chooser filtered to {@code *.properties} with a default suggestion
+ * of {@code config/application.properties} relative to the working directory. When the
+ * selected target file already exists, a background check is performed and the user is asked
+ * to confirm before overwriting on the FX Application Thread.
*/
public void requestSaveConfigurationAs() {
- LOG.info("GUI-Editor: Speichern-unter-Aktion wurde ausgelöst, ist aber noch nicht implementiert.");
+ Window owner = root.getScene() == null ? null : root.getScene().getWindow();
+ FileChooser fileChooser = saveFileChooserFactory.get();
+ fileChooser.setTitle("Konfiguration speichern");
+ fileChooser.getExtensionFilters().add(
+ new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties"));
+
+ // Propose the default path relative to the working directory.
+ Path proposedDir = DEFAULT_SAVE_PATH.getParent();
+ if (proposedDir != null) {
+ File proposedDirFile = proposedDir.toAbsolutePath().toFile();
+ if (proposedDirFile.exists()) {
+ fileChooser.setInitialDirectory(proposedDirFile);
+ }
+ }
+ fileChooser.setInitialFileName(DEFAULT_SAVE_PATH.getFileName().toString());
+
+ File selectedFile;
+ try {
+ selectedFile = saveDialogFunction.apply(fileChooser, owner);
+ } catch (UnsupportedOperationException e) {
+ // Native file dialogs are unavailable in headless/test environments.
+ LOG.debug("GUI-Editor: Speichern-Dialog nicht verfügbar (headless).");
+ return;
+ }
+ if (selectedFile == null) {
+ return;
+ }
+
+ Path targetPath = selectedFile.toPath();
+ checkExistsAndSave(targetPath, () -> { });
+ }
+
+ /**
+ * Checks on a background worker thread whether the target path already exists and, if so,
+ * asks the user to confirm overwriting on the FX Application Thread before writing.
+ * When the file does not exist the save proceeds directly without a confirmation dialog.
+ *
+ * The path-existence check is always performed on a background worker thread. UI updates
+ * and the optional overwrite confirmation always run on the FX Application Thread via
+ * {@link Platform#runLater}. Must never be called from the FX Application Thread itself
+ * with the intention of blocking on the result.
+ *
+ * @param targetPath the file path to check and write to; must not be {@code null}
+ * @param followUpAction the action to run on the FX Application Thread after a successful save
+ */
+ private void checkExistsAndSave(Path targetPath, Runnable followUpAction) {
+ Runnable checkTask = () -> {
+ boolean exists = java.nio.file.Files.exists(targetPath);
+ Platform.runLater(() -> {
+ if (exists) {
+ Optional
+ * This method runs exclusively on the FX Application Thread. All blocking I/O has
+ * already been performed on the worker thread; this method only assigns the prepared
+ * state and triggers UI updates.
+ *
+ * @param completion the fully assembled post-save data; must not be {@code null}
+ */
+ private void handleSaveSuccess(SaveCompletion completion) {
+ GuiConfigurationSaveResult result = completion.saveResult();
+ LOG.info("GUI-Editor: Konfiguration erfolgreich gespeichert unter: {}",
+ result.savedPath().toAbsolutePath());
+
+ this.editorState = completion.newState();
+ refreshHeader();
+
+ if (result.hasApiKeyPreservationNote()) {
+ LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
+ + "bestehender Wert bleibt erhalten).", result.apiKeyPreservedForProvider());
+ // Preservation note is stored for later warning display by future validation layers.
+ }
+ }
+
+ /**
+ * Saves the current editor state and runs the given follow-up action after a successful write.
+ * When no file path is known yet, the "Speichern unter" dialog is shown first.
+ *
+ * @param followUpAction action to run on the FX Application Thread after a successful save
+ */
+ private void performSaveBeforeAction(Runnable followUpAction) {
+ if (editorState.isNewConfiguration()) {
+ requestSaveConfigurationAsAndThen(followUpAction);
+ return;
+ }
+ saveToPathAndThen(editorState.loadedFileSnapshot().orElseThrow().filePath(), followUpAction);
+ }
+
+ /**
+ * Opens the "Speichern unter" dialog and runs the follow-up action after a successful write.
+ * If the user cancels the dialog the follow-up is not executed. The existence check for the
+ * target file is performed on a background worker thread.
+ *
+ * @param followUpAction the action to run after a successful save
+ */
+ private void requestSaveConfigurationAsAndThen(Runnable followUpAction) {
+ Window owner = root.getScene() == null ? null : root.getScene().getWindow();
+ FileChooser fileChooser = saveFileChooserFactory.get();
+ fileChooser.setTitle("Konfiguration speichern");
+ fileChooser.getExtensionFilters().add(
+ new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties"));
+ java.io.File proposedDirFile = DEFAULT_SAVE_PATH.getParent().toAbsolutePath().toFile();
+ if (proposedDirFile.exists()) {
+ fileChooser.setInitialDirectory(proposedDirFile);
+ }
+ fileChooser.setInitialFileName(DEFAULT_SAVE_PATH.getFileName().toString());
+ java.io.File selectedFile;
+ try {
+ selectedFile = saveDialogFunction.apply(fileChooser, owner);
+ } catch (UnsupportedOperationException e) {
+ LOG.debug("GUI-Editor: Speichern-Dialog nicht verfügbar (headless).");
+ return;
+ }
+ if (selectedFile == null) {
+ return;
+ }
+ Path targetPath = selectedFile.toPath();
+ checkExistsAndSave(targetPath, followUpAction);
+ }
+
+ /**
+ * Shows the three-option unsaved-changes protection dialog and returns the user choice.
+ * Must be called on the FX Application Thread.
+ *
+ * @param triggerLabel a short label for the requested action; must not be {@code null}
+ * @return the user choice; never {@code null}
+ */
+ private GuiUnsavedChangesGuard.Choice showUnsavedChangesDialog(String triggerLabel) {
+ ButtonType saveBt = new ButtonType("Speichern");
+ ButtonType discardBt = new ButtonType("Verwerfen");
+ ButtonType cancelBt = new ButtonType("Abbrechen",
+ javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);
+
+ Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
+ alert.setTitle("Ungespeicherte Änderungen");
+ alert.setHeaderText("Es liegen ungespeicherte Änderungen vor.");
+ alert.setContentText("Wie möchten Sie fortfahren?");
+ alert.getButtonTypes().setAll(saveBt, discardBt, cancelBt);
+
+ ((javafx.scene.control.Button) alert.getDialogPane().lookupButton(cancelBt)).setDefaultButton(true);
+ ((javafx.scene.control.Button) alert.getDialogPane().lookupButton(saveBt)).setDefaultButton(false);
+ ((javafx.scene.control.Button) alert.getDialogPane().lookupButton(discardBt)).setDefaultButton(false);
+
+ Optional
+ * Quellordner and Zielordner use a {@link DirectoryChooser}. SQLite-Datei and Prompt-Datei
+ * use a {@link FileChooser} with appropriate extension filters. All pickers preserve
+ * Windows-style paths (including mapped drive letters such as {@code S:\}) unchanged.
+ *
+ * @return the card node for the "Pfade" section
+ */
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."));
+ card.getChildren().add(sectionTitle("Pfade"));
+
+ GridPane grid = createFieldGrid();
+ int row = 0;
+
+ // Quellordner
+ TextField sourceFolderField = boundTextField(
+ editorState.values().sourceFolder(),
+ val -> updateValues(editorState.values().withSourceFolder(val)));
+ addPathRow(grid, row++, "Quellordner:", sourceFolderField, () -> {
+ String picked = pickDirectory("Quellordner auswählen", sourceFolderField.getText());
+ if (picked != null) {
+ sourceFolderField.setText(picked);
+ updateValues(editorState.values().withSourceFolder(picked));
+ }
+ });
+
+ // Zielordner
+ TextField targetFolderField = boundTextField(
+ editorState.values().targetFolder(),
+ val -> updateValues(editorState.values().withTargetFolder(val)));
+ addPathRow(grid, row++, "Zielordner:", targetFolderField, () -> {
+ String picked = pickDirectory("Zielordner auswählen", targetFolderField.getText());
+ if (picked != null) {
+ targetFolderField.setText(picked);
+ updateValues(editorState.values().withTargetFolder(picked));
+ }
+ });
+
+ // SQLite-Datei
+ TextField sqliteField = boundTextField(
+ editorState.values().sqliteFile(),
+ val -> updateValues(editorState.values().withSqliteFile(val)));
+ addPathRow(grid, row++, "SQLite-Datei:", sqliteField, () -> {
+ String picked = pickFile("SQLite-Datei auswählen", sqliteField.getText(),
+ new FileChooser.ExtensionFilter("SQLite-Dateien", "*.db", "*.sqlite", "*.sqlite3"),
+ new FileChooser.ExtensionFilter("Alle Dateien (*.*)", "*.*"));
+ if (picked != null) {
+ sqliteField.setText(picked);
+ updateValues(editorState.values().withSqliteFile(picked));
+ }
+ });
+
+ // Prompt-Datei
+ TextField promptField = boundTextField(
+ editorState.values().promptTemplateFile(),
+ val -> updateValues(editorState.values().withPromptTemplateFile(val)));
+ addPathRow(grid, row++, "Prompt-Datei:", promptField, () -> {
+ String picked = pickFile("Prompt-Datei auswählen", promptField.getText(),
+ new FileChooser.ExtensionFilter("Textdateien", "*.txt", "*.md"),
+ new FileChooser.ExtensionFilter("Alle Dateien (*.*)", "*.*"));
+ if (picked != null) {
+ promptField.setText(picked);
+ updateValues(editorState.values().withPromptTemplateFile(picked));
+ }
+ });
+
+ // Runtime-Lock-Datei (optional)
+ TextField lockField = boundTextField(
+ editorState.values().runtimeLockFile(),
+ val -> updateValues(editorState.values().withRuntimeLockFile(val)));
+ addSimpleRow(grid, row++, "Lock-Datei (optional):", lockField);
+
+ // Log-Verzeichnis (optional)
+ TextField logDirField = boundTextField(
+ editorState.values().logDirectory(),
+ val -> updateValues(editorState.values().withLogDirectory(val)));
+ addSimpleRow(grid, row++, "Log-Verzeichnis (optional):", logDirField);
+
+ card.getChildren().add(grid);
return card;
}
+ // =========================================================================
+ // Provider section
+ // =========================================================================
+
+ /**
+ * Builds the "Provider" section with two visible provider blocks (Claude and
+ * OpenAI-compatible) arranged in a horizontal layout.
+ *
+ * Each block contains editable fields for base URL, model, timeout and API key. The
+ * structure is intentionally kept simple and without show/hide logic so that later
+ * iterations can attach a ComboBox-based selector without reinventing the underlying
+ * field layout.
+ *
+ * @return the card node for the "Provider" section
+ */
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."));
+ card.getChildren().add(sectionTitle("Provider"));
+
+ // Active provider display (read-only in this iteration; ComboBox follows later).
+ GridPane activeGrid = createFieldGrid();
+ TextField activeProviderField = boundTextField(
+ editorState.values().activeProviderFamily(),
+ val -> updateValues(editorState.values().withActiveProviderFamily(val)));
+ activeProviderField.setPromptText("z.B. claude oder openai-compatible");
+ addSimpleRow(activeGrid, 0, "Aktiver Provider:", activeProviderField);
+ card.getChildren().add(activeGrid);
+
+ // Two provider blocks side by side.
+ HBox providerBlocks = new HBox(16);
+ providerBlocks.setFillHeight(true);
+
+ VBox claudeBlock = createProviderBlock("Claude", AiProviderFamily.CLAUDE);
+ VBox openaiBlock = createProviderBlock("OpenAI-kompatibel", AiProviderFamily.OPENAI_COMPATIBLE);
+
+ HBox.setHgrow(claudeBlock, Priority.ALWAYS);
+ HBox.setHgrow(openaiBlock, Priority.ALWAYS);
+ providerBlocks.getChildren().addAll(claudeBlock, openaiBlock);
+
+ card.getChildren().add(providerBlocks);
return card;
}
+ /**
+ * Builds one provider configuration block for the given provider family.
+ *
+ * The block is a named card container with fields for base URL, model, timeout and API key.
+ * All fields are wired to the editor state via the provider-specific updater path so that
+ * changes in one provider block do not affect the other.
+ *
+ * @param displayName the human-readable label shown as the block title
+ * @param family the provider family this block represents
+ * @return the provider block as a styled card node
+ */
+ private VBox createProviderBlock(String displayName, AiProviderFamily family) {
+ VBox block = new VBox(8);
+ block.setStyle(
+ "-fx-padding: 10px; -fx-border-color: #c8c8c8; -fx-border-radius: 6px;"
+ + " -fx-background-radius: 6px; -fx-background-color: #f9f9f9;");
+
+ Label title = new Label(displayName);
+ title.setStyle("-fx-font-weight: bold;");
+ block.getChildren().add(title);
+
+ GuiProviderConfigurationState pState = Optional.ofNullable(
+ editorState.values().providerConfiguration(family))
+ .orElse(GuiProviderConfigurationState.blank());
+
+ GridPane grid = createFieldGrid();
+ int row = 0;
+
+ TextField baseUrlField = boundTextField(pState.baseUrl(),
+ val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
+ val, pState2.model(), pState2.timeoutSeconds(), pState2.apiKey())));
+ addSimpleRow(grid, row++, "Basis-URL:", baseUrlField);
+
+ TextField modelField = boundTextField(pState.model(),
+ val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
+ pState2.baseUrl(), val, pState2.timeoutSeconds(), pState2.apiKey())));
+ addSimpleRow(grid, row++, "Modell:", modelField);
+
+ TextField timeoutField = boundTextField(pState.timeoutSeconds(),
+ val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
+ pState2.baseUrl(), pState2.model(), val, pState2.apiKey())));
+ addSimpleRow(grid, row++, "Timeout (Sek.):", timeoutField);
+
+ TextField apiKeyField = boundTextField(pState.apiKey().propertyValue(),
+ val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
+ pState2.baseUrl(), pState2.model(), pState2.timeoutSeconds(),
+ GuiProviderApiKeyState.unresolved(val))));
+ addSimpleRow(grid, row++, "API-Key:", apiKeyField);
+
+ block.getChildren().add(grid);
+ return block;
+ }
+
+ // =========================================================================
+ // Verarbeitungslimits section
+ // =========================================================================
+
+ /**
+ * Builds the "Verarbeitungslimits" section with text fields for the three numeric limit
+ * parameters and a checkbox for the sensitive-logging flag.
+ *
+ * @return the card node for the "Verarbeitungslimits" section
+ */
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."));
+ card.getChildren().add(sectionTitle("Verarbeitungslimits"));
+
+ GridPane grid = createFieldGrid();
+ int row = 0;
+
+ TextField maxPagesField = boundTextField(
+ editorState.values().maxPages(),
+ val -> updateValues(editorState.values().withMaxPages(val)));
+ addSimpleRow(grid, row++, "Maximale Seitenzahl:", maxPagesField);
+
+ TextField maxCharsField = boundTextField(
+ editorState.values().maxTextCharacters(),
+ val -> updateValues(editorState.values().withMaxTextCharacters(val)));
+ addSimpleRow(grid, row++, "Maximale Zeichenzahl:", maxCharsField);
+
+ TextField maxRetriesField = boundTextField(
+ editorState.values().maxRetriesTransient(),
+ val -> updateValues(editorState.values().withMaxRetriesTransient(val)));
+ addSimpleRow(grid, row++, "Max. transiente Retries:", maxRetriesField);
+
+ TextField logLevelField = boundTextField(
+ editorState.values().logLevel(),
+ val -> updateValues(editorState.values().withLogLevel(val)));
+ addSimpleRow(grid, row++, "Log-Level:", logLevelField);
+
+ // log.ai.sensitive as a CheckBox.
+ boolean sensitive = Boolean.parseBoolean(editorState.values().logAiSensitive());
+ CheckBox sensitiveCheck = new CheckBox("Sensible KI-Ausgabe loggen (log.ai.sensitive)");
+ sensitiveCheck.setSelected(sensitive);
+ sensitiveCheck.selectedProperty().addListener((obs, oldVal, newVal) ->
+ updateValues(editorState.values().withLogAiSensitive(Boolean.toString(newVal))));
+ grid.add(new Label(), 0, row);
+ grid.add(sensitiveCheck, 1, row);
+
+ card.getChildren().add(grid);
return card;
}
+ // =========================================================================
+ // Tests / Meldungen sections (structural placeholders)
+ // =========================================================================
+
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."));
+ textLabel("Technische Tests und Diagnoseaktionen werden in einem späteren Schritt ergänzt."));
return card;
}
@@ -402,10 +1061,252 @@ public final class GuiConfigurationEditorWorkspace {
VBox card = createCardContainer();
card.getChildren().addAll(
sectionTitle("Meldungen"),
- textLabel("Der Bereich ist vorbereitet. Sichtbare Meldungen und technische Hinweise folgen in einem späteren Schritt."));
+ textLabel("Sichtbare Meldungen und technische Hinweise folgen in einem späteren Schritt."));
return card;
}
+ // =========================================================================
+ // Editor-state updater helpers
+ // =========================================================================
+
+ /**
+ * Applies new general values to the editor state.
+ *
+ * Must be called on the FX Application Thread.
+ *
+ * @param newValues the updated configuration values; must not be {@code null}
+ */
+ private void updateValues(GuiConfigurationValues newValues) {
+ this.editorState = editorState.withValues(newValues);
+ refreshHeader();
+ }
+
+ /**
+ * Applies a mutation to the provider-specific configuration for the given family.
+ *
+ * Must be called on the FX Application Thread.
+ *
+ * @param family the provider family to update; must not be {@code null}
+ * @param updater a function that receives the current provider state and returns the updated one
+ */
+ private void updateProviderField(AiProviderFamily family,
+ java.util.function.Function
+ * In production the hook delegates to a native {@link DirectoryChooser}. In tests the hook
+ * can be replaced with a lambda that returns a fixed string. Windows mapped drive letters
+ * (e.g. {@code S:\data}) are accepted and returned without modification.
+ *
+ * @param title the dialog title
+ * @param initialPath the pre-selected path text; may be empty or {@code null}
+ * @return the selected absolute path string, or {@code null} when the dialog was cancelled
+ */
+ private String pickDirectory(String title, String initialPath) {
+ return directoryPickerDialog.apply(title, initialPath);
+ }
+
+ /**
+ * Opens a file-picker dialog using the injectable {@link #filePickerDialog} hook.
+ *
+ * In production the hook delegates to a native {@link FileChooser}. In tests the hook
+ * can be replaced with a lambda that returns a fixed string. Windows mapped drive letters
+ * are preserved unchanged.
+ *
+ * @param title the dialog title
+ * @param initialPath the pre-selected path text; may be empty or {@code null}
+ * @param filters extension filters (only applied by the native default implementation)
+ * @return the selected absolute path string, or {@code null} when the dialog was cancelled
+ */
+ private String pickFile(String title, String initialPath,
+ FileChooser.ExtensionFilter... filters) {
+ return filePickerDialog.apply(title, initialPath);
+ }
+
+ /**
+ * Default native directory-chooser implementation used by {@link #directoryPickerDialog}.
+ *
+ * @param title the dialog title
+ * @param initialPath the initial path hint; may be empty or {@code null}
+ * @return the selected absolute path string, or {@code null} when cancelled or unavailable
+ */
+ private String showNativeDirectoryChooser(String title, String initialPath) {
+ DirectoryChooser chooser = new DirectoryChooser();
+ chooser.setTitle(title);
+ setInitialPath(chooser, initialPath);
+ Window owner = root.getScene() == null ? null : root.getScene().getWindow();
+ try {
+ File selected = chooser.showDialog(owner);
+ return selected == null ? null : selected.getAbsolutePath();
+ } catch (UnsupportedOperationException e) {
+ LOG.debug("GUI-Editor: Ordner-Dialog nicht verf\u00fcgbar (headless).");
+ return null;
+ }
+ }
+
+ /**
+ * Default native file-chooser implementation used by {@link #filePickerDialog}.
+ *
+ * @param title the dialog title
+ * @param initialPath the initial path hint; may be empty or {@code null}
+ * @return the selected absolute path string, or {@code null} when cancelled or unavailable
+ */
+ private String showNativeFileChooser(String title, String initialPath) {
+ FileChooser chooser = new FileChooser();
+ chooser.setTitle(title);
+ setInitialPathForFileChooser(chooser, initialPath);
+ Window owner = root.getScene() == null ? null : root.getScene().getWindow();
+ try {
+ File selected = chooser.showOpenDialog(owner);
+ return selected == null ? null : selected.getAbsolutePath();
+ } catch (UnsupportedOperationException e) {
+ LOG.debug("GUI-Editor: Datei-Dialog nicht verf\u00fcgbar (headless).");
+ return null;
+ }
+ }
+
+ /**
+ * Sets the initial directory for a {@link DirectoryChooser} based on the given path string.
+ *
+ * Only absolute paths that point to an existing directory are used. Relative or non-existent
+ * paths are silently ignored. The original path text is never transformed or modified.
+ *
+ * @param chooser the directory chooser to configure; must not be {@code null}
+ * @param initialPath the candidate initial path; may be empty or {@code null}
+ */
+ private static void setInitialPath(DirectoryChooser chooser, String initialPath) {
+ if (initialPath == null || initialPath.isBlank()) {
+ return;
+ }
+ try {
+ Path p = Paths.get(initialPath);
+ File f = p.toAbsolutePath().toFile();
+ if (f.isDirectory()) {
+ chooser.setInitialDirectory(f);
+ } else if (f.getParentFile() != null && f.getParentFile().isDirectory()) {
+ chooser.setInitialDirectory(f.getParentFile());
+ }
+ } catch (Exception ignored) {
+ // Malformed path: silently skip the initial-directory hint.
+ }
+ }
+
+ /**
+ * Sets the initial directory and file name for a {@link FileChooser} based on the given path.
+ *
+ * Only existing parent directories are used. The original path text is never modified.
+ *
+ * @param chooser the file chooser to configure; must not be {@code null}
+ * @param initialPath the candidate initial path; may be empty or {@code null}
+ */
+ private static void setInitialPathForFileChooser(FileChooser chooser, String initialPath) {
+ if (initialPath == null || initialPath.isBlank()) {
+ return;
+ }
+ try {
+ Path p = Paths.get(initialPath);
+ File f = p.toAbsolutePath().toFile();
+ if (f.isFile()) {
+ chooser.setInitialDirectory(f.getParentFile());
+ chooser.setInitialFileName(f.getName());
+ } else if (f.getParentFile() != null && f.getParentFile().isDirectory()) {
+ chooser.setInitialDirectory(f.getParentFile());
+ }
+ } catch (Exception ignored) {
+ // Malformed path: silently skip the initial-directory hint.
+ }
+ }
+
+ // =========================================================================
+ // Layout helpers
+ // =========================================================================
+
+ /**
+ * Creates a text field pre-populated from the editor state that updates the state on every
+ * text change.
+ *
+ * Changes are only propagated when the new text differs from the current field text to avoid
+ * redundant state updates during programmatic population.
+ *
+ * @param initialValue the initial text; never {@code null}
+ * @param onValueChange callback that receives the new text on every change; must not be
+ * {@code null}
+ * @return the configured text field
+ */
+ private static TextField boundTextField(String initialValue, Consumer
+ * The text field grows horizontally; the button stays compact.
+ *
+ * @param grid the target grid pane
+ * @param row the grid row index
+ * @param labelText the row label text
+ * @param field the text field to place
+ * @param onPick action invoked when the picker button is clicked
+ */
+ private static void addPathRow(GridPane grid, int row, String labelText, TextField field,
+ Runnable onPick) {
+ Label label = new Label(labelText);
+ Button pickButton = new Button("…");
+ pickButton.setOnAction(e -> onPick.run());
+ pickButton.setMinWidth(32);
+ HBox fieldBox = new HBox(4, field, pickButton);
+ HBox.setHgrow(field, Priority.ALWAYS);
+ fieldBox.setAlignment(Pos.CENTER_LEFT);
+ grid.add(label, 0, row);
+ grid.add(fieldBox, 1, row);
+ }
+
+ /**
+ * Adds a label and a text field (without a picker button) to the given grid row.
+ *
+ * @param grid the target grid pane
+ * @param row the grid row index
+ * @param labelText the row label text
+ * @param field the text field to place
+ */
+ private static void addSimpleRow(GridPane grid, int row, String labelText, TextField field) {
+ grid.add(new Label(labelText), 0, row);
+ grid.add(field, 1, row);
+ }
+
+ private GridPane createFieldGrid() {
+ GridPane grid = new GridPane();
+ grid.setHgap(12);
+ grid.setVgap(8);
+ javafx.scene.layout.ColumnConstraints labelCol = new javafx.scene.layout.ColumnConstraints();
+ labelCol.setMinWidth(180);
+ labelCol.setPrefWidth(200);
+ javafx.scene.layout.ColumnConstraints fieldCol = new javafx.scene.layout.ColumnConstraints();
+ fieldCol.setFillWidth(true);
+ fieldCol.setHgrow(Priority.ALWAYS);
+ grid.getColumnConstraints().addAll(labelCol, fieldCol);
+ return grid;
+ }
+
private VBox createCardContainer() {
VBox card = new VBox(8);
card.setStyle(
@@ -437,10 +1338,25 @@ public final class GuiConfigurationEditorWorkspace {
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.setHeaderText("Fehler");
alert.show();
}
+ /**
+ * Shows a blocking confirmation dialog and returns the button the user chose.
+ *
+ * @param title the dialog title; must not be {@code null}
+ * @param message the dialog body text; must not be {@code null}
+ * @param buttons the available button types; must not be empty
+ * @return the chosen button type, or empty when the dialog was dismissed without a choice
+ */
+ private Optional
+ * The interface allows Bootstrap to provide the concrete file-writing, backup and
+ * normalization logic while the GUI only deals with editor values and target paths.
+ * Implementations must follow the backup schema defined for this application:
+ * {@code
+ * When {@code targetPath} already exists on disk, the implementation must create a
+ * {@code .bak} backup of the existing file before overwriting it. The caller is
+ * responsible for obtaining user confirmation before invoking this method.
+ *
+ * @param values the current editor values to serialize; must not be {@code null}
+ * @param targetPath the target file path to write to; must not be {@code null}
+ * @return the result of the write operation, including any API-key preservation note;
+ * never {@code null}
+ * @throws GuiConfigurationWriteException if the file cannot be written
+ */
+ GuiConfigurationSaveResult write(GuiConfigurationValues values, Path targetPath);
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationSaveResult.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationSaveResult.java
new file mode 100644
index 0000000..47d7b01
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationSaveResult.java
@@ -0,0 +1,65 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * Carries the outcome of a successful configuration file write operation.
+ *
+ * The result separates the written file path from supplementary observations such as
+ * API-key preservation events. This allows the GUI to update its header and editor
+ * state without inspecting the written file again, and to forward the preservation
+ * flag to later warning display logic without mixing that concern into the write
+ * implementation itself.
+ *
+ * @param savedPath the path to which the file was written; never {@code null}
+ * @param apiKeyPreservedForProvider identifier of the provider whose API key was silently
+ * preserved because the GUI field was left empty while
+ * the existing property value was non-empty; {@code null}
+ * when no preservation occurred
+ */
+public record GuiConfigurationSaveResult(Path savedPath, String apiKeyPreservedForProvider) {
+
+ /**
+ * Creates a save result.
+ *
+ * @param savedPath the path that was written; must not be {@code null}
+ * @param apiKeyPreservedForProvider provider identifier when key was preserved; may be {@code null}
+ */
+ public GuiConfigurationSaveResult {
+ Objects.requireNonNull(savedPath, "savedPath must not be null");
+ }
+
+ /**
+ * Creates a save result with no API-key preservation event.
+ *
+ * @param savedPath the path that was written; must not be {@code null}
+ * @return a result without an API-key preservation note
+ */
+ public static GuiConfigurationSaveResult saved(Path savedPath) {
+ return new GuiConfigurationSaveResult(savedPath, null);
+ }
+
+ /**
+ * Creates a save result that records an API-key preservation event.
+ *
+ * @param savedPath the path that was written; must not be {@code null}
+ * @param providerIdentifier the provider for which the key was preserved;
+ * must not be {@code null}
+ * @return a result carrying the preservation note for later display
+ */
+ public static GuiConfigurationSaveResult savedWithPreservedKey(Path savedPath,
+ String providerIdentifier) {
+ Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
+ return new GuiConfigurationSaveResult(savedPath, providerIdentifier);
+ }
+
+ /**
+ * Returns whether an API-key preservation event occurred during this write operation.
+ *
+ * @return {@code true} when at least one provider API key was silently preserved
+ */
+ public boolean hasApiKeyPreservationNote() {
+ return apiKeyPreservedForProvider != null;
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationWriteException.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationWriteException.java
new file mode 100644
index 0000000..c830428
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationWriteException.java
@@ -0,0 +1,29 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+/**
+ * Thrown when a configuration file cannot be written by the GUI file writer.
+ *
+ * This exception wraps low-level I/O failures so that the GUI layer does not have
+ * to handle raw {@link java.io.IOException} instances directly.
+ */
+public class GuiConfigurationWriteException extends RuntimeException {
+
+ /**
+ * Creates an exception with the given message.
+ *
+ * @param message the error description; must not be {@code null}
+ */
+ public GuiConfigurationWriteException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates an exception with the given message and cause.
+ *
+ * @param message the error description; must not be {@code null}
+ * @param cause the underlying cause; may be {@code null}
+ */
+ public GuiConfigurationWriteException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
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
index f2f303b..6a0e75b 100644
--- 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
@@ -9,13 +9,14 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorSt
/**
* 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.
+ * Carries the initial editor state, the optional startup notice, the file-loading callback
+ * and the file-writing callback that the workspace uses for native save actions.
*/
public record GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional
+ * The guard asks the user whether to save, discard or cancel the requested action.
+ * The dialog interaction is injected via a {@link Function} so the guard can be tested
+ * without a running JavaFX scene by substituting the real dialog with a stub.
+ *
+ * Usage:
+ *
+ * In production the function shows a modal dialog; in tests it can be replaced with a stub.
+ */
+ private Function
+ * Package-private so tests can inject stubs without exposing setter to external callers.
+ *
+ * @param dialogSupplier the replacement function; must not be {@code null}
+ */
+ void setDialogSupplier(Function
+ * The title reflects the current editor state: whether a file is loaded and whether the
+ * editor contains unsaved changes. The application name and the separator are kept in
+ * one place so every part of the GUI uses the same formatting convention.
+ *
+ * The window title is kept in sync with the workspace's dirty state via the
+ * {@code titleUpdateListener} hook. The close-request handler is installed through
+ * {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
+ * unsaved changes are protected when the user tries to close the window.
*/
public class PdfUmbenennerGuiApplication extends Application {
private static final Logger LOG = LogManager.getLogger(PdfUmbenennerGuiApplication.class);
- private static final String WINDOW_TITLE = "PDF-Umbenenner";
private static final double DEFAULT_WIDTH = 1100;
private static final double DEFAULT_HEIGHT = 800;
@@ -30,6 +34,10 @@ public class PdfUmbenennerGuiApplication extends Application {
/**
* Initializes and shows the primary stage.
+ *
+ * Wires the workspace title-update listener to the stage title so any dirty-state change
+ * causes an immediate window-title refresh. Also installs the close-request handler that
+ * guards unsaved changes before the window is closed.
*
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/
@@ -39,11 +47,17 @@ public class PdfUmbenennerGuiApplication extends Application {
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
- Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
- primaryStage.setTitle(WINDOW_TITLE);
+ // Wire the title-update listener so the stage title stays in sync with the dirty state.
+ workspace.titleUpdateListener = primaryStage::setTitle;
+
+ Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
+ primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
primaryStage.setScene(scene);
- primaryStage.setOnCloseRequest(event -> LOG.info("GUI: Fenster wird vom Benutzer geschlossen."));
+
+ // Install the close-request handler that protects unsaved changes.
+ workspace.installCloseRequestHandler(primaryStage);
+
primaryStage.show();
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java
new file mode 100644
index 0000000..ba95413
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/GuiApiKeyMerger.java
@@ -0,0 +1,120 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+
+/**
+ * Merges the current editor API-key values against the baseline values before a file
+ * is written to disk.
+ *
+ * The merge rule is:
+ * The result indicates which provider (if any) triggered a preservation event so the
+ * GUI can display a warning via later validation layers without coupling the write path
+ * to the warning display mechanism.
+ */
+public final class GuiApiKeyMerger {
+
+ private GuiApiKeyMerger() {
+ // Utility class.
+ }
+
+ /**
+ * Merges the API-key values from the given editor state and returns both the merged
+ * values and the first provider identifier for which a key was silently preserved.
+ *
+ * @param state the current editor state; must not be {@code null}
+ * @return the merge result; never {@code null}
+ */
+ public static MergeResult merge(GuiConfigurationEditorState state) {
+ return merge(state.values(), state.baselineValues());
+ }
+
+ /**
+ * Merges the API-key values from the given current and baseline configuration values.
+ *
+ * @param current the current editor values; must not be {@code null}
+ * @param baseline the baseline values to compare against; must not be {@code null}
+ * @return the merge result; never {@code null}
+ */
+ public static MergeResult merge(GuiConfigurationValues current, GuiConfigurationValues baseline) {
+ Map
+ * All other provider-family entries are preserved unchanged.
+ *
+ * @param family the provider family to update; must not be {@code null}
+ * @param state the new provider configuration state; must not be {@code null}
+ * @return a new configuration values object with the updated provider configuration
+ */
+ public GuiConfigurationValues withProviderConfiguration(AiProviderFamily family,
+ GuiProviderConfigurationState state) {
+ Map
+ * The delegation is detected by observing that the injected {@code saveFileChooserFactory}
+ * is invoked, which only happens inside the "Speichern unter" code path. The factory
+ * returns a plain {@link FileChooser} whose {@code showSaveDialog()} call is expected to
+ * throw {@link UnsupportedOperationException} under Monocle headless, causing the workspace
+ * to return early without further side effects.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(7)
+ void saveConfiguration_withNewConfiguration_delegatesToSaveAs() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference
+ * A test writer is injected into the startup context so the save completes synchronously
+ * without touching the file system. The test polls the editor state on the FX thread
+ * until the asynchronous worker posts its result or a timeout is reached.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(9)
+ void saveToPath_afterFirstSave_updatesHeaderAndClearsNewConfigurationState() throws Exception {
+ Path targetPath = Path.of("config/application.properties");
+ AtomicReference
+ * These tests exercise the API-key preservation merge via {@link GuiApiKeyMerger} and the
+ * {@link GuiConfigurationSaveResult} without requiring a JavaFX runtime.
+ */
+class GuiConfigurationEditorWorkspaceSaveTest {
+
+ // =========================================================================
+ // API-key preservation via GuiApiKeyMerger
+ // =========================================================================
+
+ @Test
+ void merge_preservesBaselineKeyWhenEditorFieldIsEmpty() {
+ // Baseline: Claude has a non-empty API key.
+ GuiConfigurationValues baseline = buildValues("sk-baseline-claude", "sk-openai");
+ // Current: user cleared the Claude API key in the editor.
+ GuiConfigurationValues current = buildValues("", "sk-openai");
+
+ GuiConfigurationEditorState state = buildState(baseline, current);
+ GuiApiKeyMerger.MergeResult result = GuiApiKeyMerger.merge(state);
+
+ String claudeKey = result.values().providerConfiguration(AiProviderFamily.CLAUDE)
+ .apiKey().propertyValue();
+ assertEquals("sk-baseline-claude", claudeKey,
+ "Baseline API key must be preserved when the editor field is empty");
+ assertTrue(result.hasPreservationNote(), "Preservation note must be set");
+ assertEquals("claude", result.preservedProviderIdentifier());
+ }
+
+ @Test
+ void merge_doesNotPreserveKeyWhenEditorFieldIsNotEmpty() {
+ GuiConfigurationValues baseline = buildValues("sk-old", "sk-openai");
+ GuiConfigurationValues current = buildValues("sk-new", "sk-openai");
+
+ GuiConfigurationEditorState state = buildState(baseline, current);
+ GuiApiKeyMerger.MergeResult result = GuiApiKeyMerger.merge(state);
+
+ String claudeKey = result.values().providerConfiguration(AiProviderFamily.CLAUDE)
+ .apiKey().propertyValue();
+ assertEquals("sk-new", claudeKey,
+ "Non-empty editor API key must not be replaced by the baseline");
+ assertFalse(result.hasPreservationNote(), "No preservation note when field is not empty");
+ }
+
+ @Test
+ void merge_preservesNothingWhenBaselineKeyIsAlsoEmpty() {
+ GuiConfigurationValues baseline = buildValues("", "");
+ GuiConfigurationValues current = buildValues("", "");
+
+ GuiConfigurationEditorState state = buildState(baseline, current);
+ GuiApiKeyMerger.MergeResult result = GuiApiKeyMerger.merge(state);
+
+ String claudeKey = result.values().providerConfiguration(AiProviderFamily.CLAUDE)
+ .apiKey().propertyValue();
+ assertEquals("", claudeKey,
+ "Empty baseline key must not trigger preservation");
+ assertFalse(result.hasPreservationNote());
+ }
+
+ @Test
+ void merge_preservesBothProviderKeysIndependently() {
+ GuiConfigurationValues baseline = buildValues("sk-claude-base", "sk-openai-base");
+ // User cleared both keys.
+ GuiConfigurationValues current = buildValues("", "");
+
+ GuiConfigurationEditorState state = buildState(baseline, current);
+ GuiApiKeyMerger.MergeResult result = GuiApiKeyMerger.merge(state);
+
+ assertEquals("sk-claude-base",
+ result.values().providerConfiguration(AiProviderFamily.CLAUDE).apiKey().propertyValue(),
+ "Claude key must be preserved");
+ assertEquals("sk-openai-base",
+ result.values().providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).apiKey().propertyValue(),
+ "OpenAI key must be preserved");
+ // Only the first provider is recorded in preservedProviderIdentifier.
+ assertTrue(result.hasPreservationNote());
+ }
+
+ @Test
+ void merge_preservesOnlyProviderWithEmptyField() {
+ GuiConfigurationValues baseline = buildValues("sk-claude-base", "sk-openai-base");
+ // User cleared only the OpenAI key, kept the Claude key.
+ GuiConfigurationValues current = buildValues("sk-claude-new", "");
+
+ GuiConfigurationEditorState state = buildState(baseline, current);
+ GuiApiKeyMerger.MergeResult result = GuiApiKeyMerger.merge(state);
+
+ assertEquals("sk-claude-new",
+ result.values().providerConfiguration(AiProviderFamily.CLAUDE).apiKey().propertyValue(),
+ "Claude key must be the edited value");
+ assertEquals("sk-openai-base",
+ result.values().providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).apiKey().propertyValue(),
+ "OpenAI key must be preserved from baseline");
+ assertTrue(result.hasPreservationNote());
+ assertEquals("openai-compatible", result.preservedProviderIdentifier(),
+ "Preserved provider must be the one whose field was cleared");
+ }
+
+ // =========================================================================
+ // GuiConfigurationSaveResult
+ // =========================================================================
+
+ @Test
+ void saveResult_withoutPreservation_hasNoNote() {
+ Path path = Path.of("config/application.properties");
+ GuiConfigurationSaveResult result = GuiConfigurationSaveResult.saved(path);
+
+ assertEquals(path, result.savedPath());
+ assertFalse(result.hasApiKeyPreservationNote());
+ }
+
+ @Test
+ void saveResult_withPreservation_carriesProviderIdentifier() {
+ Path path = Path.of("config/application.properties");
+ GuiConfigurationSaveResult result = GuiConfigurationSaveResult.savedWithPreservedKey(path, "claude");
+
+ assertEquals(path, result.savedPath());
+ assertTrue(result.hasApiKeyPreservationNote());
+ assertEquals("claude", result.apiKeyPreservedForProvider());
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private GuiConfigurationValues buildValues(String claudeApiKey, String openaiApiKey) {
+ Map
+ * These tests exercise the comparison between baseline and current values, the initial
+ * clean state of the standard template, and the reset after a simulated save.
+ */
+class GuiDirtyStateTest {
+
+ // =========================================================================
+ // Standard template is always clean after creation
+ // =========================================================================
+
+ @Test
+ void standardTemplate_isCleanAfterCreation() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+
+ assertFalse(state.isDirty(), "Freshly created standard template must not be dirty");
+ assertFalse(state.changeState().isDirty(), "changeState() must agree with isDirty()");
+ }
+
+ @Test
+ void blankStartState_isCleanAfterCreation() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createBlankStartState();
+
+ assertFalse(state.isDirty(), "Blank start state must not be dirty");
+ assertFalse(state.changeState().isDirty());
+ }
+
+ // =========================================================================
+ // Any change to values makes the state dirty
+ // =========================================================================
+
+ @Test
+ void changingValues_makesStateDirty() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationEditorState dirty = state.withValues(differentValues(state));
+
+ assertTrue(dirty.isDirty(), "State with different values must be dirty");
+ assertTrue(dirty.changeState() == GuiChangeState.DIRTY);
+ }
+
+ @Test
+ void revertingValues_makesStateClean() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationEditorState dirty = state.withValues(differentValues(state));
+ GuiConfigurationEditorState reverted = dirty.withValues(state.baselineValues());
+
+ assertFalse(reverted.isDirty(), "Reverting to baseline values must restore a clean state");
+ }
+
+ // =========================================================================
+ // New configuration: clean until values differ from template baseline
+ // =========================================================================
+
+ @Test
+ void newConfiguration_isClean_whenValuesMatchTemplate() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+
+ assertTrue(state.isNewConfiguration(), "Precondition: no file snapshot");
+ assertFalse(state.isDirty(), "New configuration matching the template baseline must be clean");
+ }
+
+ @Test
+ void newConfiguration_isDirty_whenValuesDifferFromTemplate() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationEditorState dirty = state.withValues(differentValues(state));
+
+ assertTrue(dirty.isNewConfiguration(), "Precondition: still no file snapshot");
+ assertTrue(dirty.isDirty(), "New configuration with changed values must be dirty");
+ }
+
+ // =========================================================================
+ // After save: baseline is advanced to saved values, state becomes clean
+ // =========================================================================
+
+ @Test
+ void afterSave_stateBecomesClean() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationEditorState dirty = state.withValues(differentValues(state));
+
+ assertTrue(dirty.isDirty(), "Precondition: dirty before save");
+
+ // Simulate what the workspace does after a successful write:
+ // baseline = values, new snapshot attached.
+ GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
+ Path.of("config/application.properties"), new Properties());
+ GuiConfigurationEditorState afterSave = new GuiConfigurationEditorState(
+ Optional.of(snapshot),
+ dirty.values(), // baseline = saved values
+ dirty.values(), // current = same saved values
+ Optional.empty());
+
+ assertFalse(afterSave.isDirty(), "After save baseline=values, state must be clean");
+ }
+
+ @Test
+ void markClean_advancesBaselineToCurrentValues() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationValues changed = differentValues(state);
+ GuiConfigurationEditorState dirty = state.withValues(changed);
+
+ assertTrue(dirty.isDirty(), "Precondition: must be dirty");
+
+ GuiConfigurationEditorState clean = dirty.markClean();
+
+ assertFalse(clean.isDirty(), "markClean() must yield a clean state");
+ // markClean resets values to baseline (not the other way around).
+ // The actual implementation resets current values to the baseline.
+ assertFalse(clean.isDirty());
+ }
+
+ // =========================================================================
+ // Loaded-file state: clean when values match the baseline read from disk
+ // =========================================================================
+
+ @Test
+ void loadedState_isCleanWhenValuesMatchBaseline() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
+ Path.of("config/application.properties"), new Properties());
+ // Simulate a loaded state: baseline = current = template values.
+ GuiConfigurationEditorState loaded = new GuiConfigurationEditorState(
+ Optional.of(snapshot), state.values(), state.values(), Optional.empty());
+
+ assertFalse(loaded.isDirty(), "Loaded state with matching baseline must be clean");
+ assertFalse(loaded.isNewConfiguration());
+ }
+
+ @Test
+ void loadedState_isDirtyAfterEdit() {
+ GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
+ GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
+ Path.of("config/application.properties"), new Properties());
+ GuiConfigurationEditorState loaded = new GuiConfigurationEditorState(
+ Optional.of(snapshot), state.values(), state.values(), Optional.empty());
+
+ GuiConfigurationEditorState dirty = loaded.withValues(differentValues(loaded));
+
+ assertTrue(dirty.isDirty(), "Editing the values of a loaded state must make it dirty");
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private static GuiConfigurationValues differentValues(GuiConfigurationEditorState state) {
+ GuiConfigurationValues v = state.values();
+ // Change the source folder to produce different values.
+ return new GuiConfigurationValues(
+ v.sourceFolder() + "_changed",
+ v.targetFolder(),
+ v.sqliteFile(),
+ v.promptTemplateFile(),
+ v.runtimeLockFile(),
+ v.logDirectory(),
+ v.logLevel(),
+ v.maxRetriesTransient(),
+ v.maxPages(),
+ v.maxTextCharacters(),
+ v.logAiSensitive(),
+ v.activeProviderFamily(),
+ v.providerConfigurations());
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java
new file mode 100644
index 0000000..7dd0d5d
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java
@@ -0,0 +1,449 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+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.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import javafx.application.Platform;
+
+/**
+ * Smoke tests for the field-to-state bidirectional binding, path-picker hooks and the
+ * threading contract for the overwrite-existence check introduced by the full editor surface.
+ *
+ * All tests run on the FX Application Thread under Monocle headless. Native dialog calls
+ * are intercepted via the injectable hook fields on the workspace.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class GuiEditorFieldBindingTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Shared platform – do not call Platform.exit().
+ }
+
+ // =========================================================================
+ // Workspace initialises with standard template values
+ // =========================================================================
+
+ /**
+ * Verifies that after "Neu" the editor state reflects the standard template defaults
+ * for all major fields.
+ */
+ @Test
+ @Order(1)
+ void afterNew_editorStateContainsTemplateDefaults() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ GuiConfigurationValues v = ws.editorState().values();
+ assertEquals("./work/local/source", v.sourceFolder(),
+ "Source folder must match the standard template default");
+ assertEquals("./work/local/target", v.targetFolder(),
+ "Target folder must match the standard template default");
+ assertEquals("./work/local/pdf-umbenenner.db", v.sqliteFile(),
+ "SQLite file must match the standard template default");
+ assertEquals("./config/prompts/template.txt", v.promptTemplateFile(),
+ "Prompt file must match the standard template default");
+ assertEquals("3", v.maxRetriesTransient(),
+ "Max retries must match the standard template default");
+ assertEquals("10", v.maxPages(),
+ "Max pages must match the standard template default");
+ assertEquals("5000", v.maxTextCharacters(),
+ "Max text characters must match the standard template default");
+ assertEquals("false", v.logAiSensitive(),
+ "log.ai.sensitive must match the standard template default (false)");
+ });
+ }
+
+ // =========================================================================
+ // Windows-style path round-trip
+ // =========================================================================
+
+ /**
+ * Verifies that a Windows mapped-drive path survives a set/get round-trip through
+ * {@link GuiConfigurationValues} without any transformation.
+ */
+ @Test
+ @Order(2)
+ void windowsMappedDrivePath_survivesRoundTrip() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ String windowsPath = "S:\\Dokumente\\Eingang";
+ GuiConfigurationValues updated = ws.editorState().values().withSourceFolder(windowsPath);
+ ws.editorState = ws.editorState().withValues(updated);
+
+ assertEquals(windowsPath, ws.editorState().values().sourceFolder(),
+ "Windows mapped-drive path must survive a set/get round-trip unchanged");
+ });
+ }
+
+ /**
+ * Verifies that a Windows path with a drive letter and deep subfolders survives unchanged.
+ */
+ @Test
+ @Order(3)
+ void windowsDeepPath_remainsUnchangedAfterRoundTrip() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ String path = "H:\\Archiv\\2024\\Rechnungen\\eingehend";
+ GuiConfigurationValues updated = ws.editorState().values()
+ .withTargetFolder(path)
+ .withSqliteFile("H:\\Archiv\\db\\umbenenner.sqlite3");
+ ws.editorState = ws.editorState().withValues(updated);
+
+ assertEquals(path, ws.editorState().values().targetFolder(),
+ "Windows deep path must remain intact");
+ assertEquals("H:\\Archiv\\db\\umbenenner.sqlite3", ws.editorState().values().sqliteFile(),
+ "SQLite path with Windows drive letter must remain intact");
+ });
+ }
+
+ // =========================================================================
+ // Directory-picker hook: selection updates editor state
+ // =========================================================================
+
+ /**
+ * Verifies that when the directory-picker hook returns a specific path the editor state
+ * is updated accordingly.
+ *
+ * The hook replaces the native dialog so the test runs headless. This mirrors what the
+ * "Quellordner"-button handler does: it calls the picker, and if the result is non-null
+ * it writes the value into the editor state.
+ */
+ @Test
+ @Order(4)
+ void directoryPickerHook_whenPathSelected_updatesSourceFolderInState() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ String expected = "S:\\Quellordner";
+
+ // Replace the directory-picker hook: always return the expected path.
+ ws.directoryPickerDialog = (title, initialPath) -> expected;
+
+ // Simulate what the button handler does: call picker, update state on non-null result.
+ String picked = ws.directoryPickerDialog.apply("Quellordner ausw\u00e4hlen",
+ ws.editorState().values().sourceFolder());
+ if (picked != null) {
+ ws.editorState = ws.editorState()
+ .withValues(ws.editorState().values().withSourceFolder(picked));
+ }
+
+ assertEquals(expected, ws.editorState().values().sourceFolder(),
+ "After picker selection the editor state must reflect the chosen path");
+ });
+ }
+
+ // =========================================================================
+ // File-picker hook: cancel leaves state unchanged
+ // =========================================================================
+
+ /**
+ * Verifies that when the file-picker hook returns {@code null} (cancelled) the editor
+ * state remains unchanged.
+ */
+ @Test
+ @Order(5)
+ void filePickerHook_whenCancelled_leavesEditorStateUnchanged() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ String originalSqlite = ws.editorState().values().sqliteFile();
+
+ // Replace the file-picker hook: always return null (cancel).
+ ws.filePickerDialog = (title, initialPath) -> null;
+
+ // Simulate button handler: null result means do nothing.
+ String picked = ws.filePickerDialog.apply("SQLite-Datei ausw\u00e4hlen",
+ ws.editorState().values().sqliteFile());
+ if (picked != null) {
+ ws.editorState = ws.editorState()
+ .withValues(ws.editorState().values().withSqliteFile(picked));
+ }
+
+ assertEquals(originalSqlite, ws.editorState().values().sqliteFile(),
+ "Cancelled file picker must leave the editor state unchanged");
+ });
+ }
+
+ // =========================================================================
+ // Provider fields: updating one provider does not affect the other
+ // =========================================================================
+
+ /**
+ * Verifies that updating the Claude provider model does not modify the OpenAI-compatible
+ * provider configuration.
+ */
+ @Test
+ @Order(6)
+ void updatingClaudeModel_doesNotAffectOpenAiBlock() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ GuiProviderConfigurationState originalOpenAi =
+ ws.editorState().values().providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE);
+
+ // Update Claude model only.
+ GuiProviderConfigurationState currentClaude =
+ ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE);
+ GuiProviderConfigurationState updatedClaude = new GuiProviderConfigurationState(
+ currentClaude.baseUrl(), "claude-3-opus", currentClaude.timeoutSeconds(),
+ currentClaude.apiKey());
+
+ GuiConfigurationValues updated = ws.editorState().values()
+ .withProviderConfiguration(AiProviderFamily.CLAUDE, updatedClaude);
+ ws.editorState = ws.editorState().withValues(updated);
+
+ assertEquals("claude-3-opus",
+ ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE).model(),
+ "Claude model must be updated");
+ assertEquals(originalOpenAi.model(),
+ ws.editorState().values().providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model(),
+ "OpenAI-compatible model must remain unchanged");
+ });
+ }
+
+ // =========================================================================
+ // Dirty state after field change
+ // =========================================================================
+
+ /**
+ * Verifies that modifying a field value via the {@code withX} path produces a dirty state.
+ */
+ @Test
+ @Order(7)
+ void fieldChange_makesDirty() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ assertFalse(ws.editorState().isDirty(), "Precondition: must be clean after Neu");
+
+ GuiConfigurationValues modified = ws.editorState().values()
+ .withSourceFolder("./modified/source");
+ ws.editorState = ws.editorState().withValues(modified);
+
+ assertTrue(ws.editorState().isDirty(),
+ "Modifying a field value must make the editor state dirty");
+ });
+ }
+
+ // =========================================================================
+ // Threading: Files.exists check in checkExistsAndSave runs off the FX thread
+ // =========================================================================
+
+ /**
+ * Verifies that the path-existence check inside {@code checkExistsAndSave} is not performed
+ * on the FX Application Thread.
+ *
+ * The test exercises the full {@code checkExistsAndSave} path by injecting a file chooser
+ * that returns an existing file (causing the overwrite-check to be reached) and a capturing
+ * thread factory that records the thread name when the checker runs. The overwrite-confirmation
+ * supplier is stubbed to return YES so the writer is called, which proves that the
+ * {@code Files.exists} call ran inside the background checker thread.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(8)
+ void checkExistsAndSave_pathCheckRunsOnWorkerThread_notOnFxThread() throws Exception {
+ // Create an existing file so the checker finds it and enters the overwrite dialog path.
+ java.nio.file.Path existingFile = java.nio.file.Files.createTempFile(
+ "gui-checker-thread-test-", ".properties");
+ existingFile.toFile().deleteOnExit();
+
+ AtomicReference
+ * Verifies that a valid {@code --config} path supplied at startup reaches the workspace as a
+ * loaded editor state, and that starting without a configuration path leaves the workspace in
+ * the defined welcome-text state.
+ *
+ *
+ * These tests exercise the file-loading callback and workspace initialization directly without
+ * starting the full Bootstrap or a real JavaFX {@link javafx.application.Application}. The
+ * workspace is created on the FX Application Thread under the Monocle headless configuration.
+ */
+class GuiEditorIntegrationTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Shared platform – do not call Platform.exit().
+ }
+
+ // =========================================================================
+ // GUI startup with a valid --config path
+ // =========================================================================
+
+ /**
+ * Verifies the end-to-end path: CLI argument → config path → file loader → workspace.
+ *
+ * When Bootstrap resolves a valid {@code --config} path, {@link GuiConfigurationEditorState}
+ * is populated from the file contents. The workspace header shows the path, the editor is
+ * not in blank state, and fields reflect the values stored in the file.
+ *
+ * @param tempDir JUnit-provided temporary directory for the test configuration file
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void guiStartup_withValidConfigPath_loadsFileIntoWorkspace(@TempDir Path tempDir) throws Exception {
+ Path configFile = tempDir.resolve("test-application.properties");
+ writeMinimalPropertiesFile(configFile, "./my/source", "./my/target", "claude");
+
+ // Simulate what Bootstrap does: file loader delegates to BootstrapRunner.loadGuiConfigurationState.
+ // Here we use the factory directly since Bootstrap's private method is not testable from outside
+ // the bootstrap module. The contract tested here is the file → editor state → workspace flow.
+ GuiConfigurationFileLoader fileLoader = path -> {
+ try {
+ java.util.Properties props = new java.util.Properties();
+ String content = Files.readString(path, StandardCharsets.UTF_8);
+ props.load(new java.io.StringReader(content));
+ GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
+ return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, Optional.empty());
+ } catch (IOException e) {
+ throw new GuiConfigurationLoadException("Failed to load " + path, e);
+ }
+ };
+
+ GuiConfigurationEditorState loadedState = fileLoader.load(configFile);
+ GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
+ GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter);
+
+ AtomicReference
+ * This test mirrors the Bootstrap behavior documented in {@code BootstrapRunner}:
+ * a missing GUI config path is logged, and the context carries a notice but falls back
+ * to the blank start state.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void guiStartup_withNonExistentConfigPath_usesBlankStateAndCarriesStartupNotice()
+ throws Exception {
+ // Simulate what Bootstrap does when --config points to a missing file.
+ String notice = "Konfigurationsdatei nicht gefunden: /no/such/file.properties\n"
+ + "Die GUI startet ohne Konfigurationsdatei.";
+ GuiConfigurationEditorState blankState = GuiConfigurationEditorStateFactory.createBlankStartState();
+ GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
+ GuiStartupContext context = new GuiStartupContext(
+ blankState,
+ Optional.of(notice),
+ configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
+ noOpWriter);
+
+ AtomicReference
+ * This test exercises the contract: a non-empty {@code --config} argument string becomes
+ * the config path used for loading; an absent argument leads to the blank start state.
+ *
+ * @param tempDir JUnit-provided temporary directory
+ * @throws Exception if file operations fail
+ */
+ @Test
+ void configPathFromCliArg_validFile_resolvedPathMatchesArgument(@TempDir Path tempDir)
+ throws Exception {
+ Path configFile = tempDir.resolve("cli-config.properties");
+ writeMinimalPropertiesFile(configFile, "./src", "./tgt", "openai-compatible");
+
+ // The path string from --config must resolve to the same canonical path.
+ Optional
+ * Each test method covers one distinct user-visible flow at integration level. The individual
+ * sub-behaviours (dirty-state derivation, guard dialog options, API-key merge) are already
+ * covered by dedicated unit tests; this class focuses on the end-to-end flow across the
+ * relevant subsystems.
+ *
+ *
+ * All workspace interactions run on the FX Application Thread under Monocle headless. Native
+ * file dialogs are replaced with injectable hook fields. Asynchronous background operations
+ * are awaited via {@link CountDownLatch} and a polling helper.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class GuiEditorRegressionSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Shared platform – do not call Platform.exit().
+ }
+
+ // =========================================================================
+ // Flow: GUI start without loaded configuration
+ // =========================================================================
+
+ /**
+ * Regression: starting without a configuration produces the blank welcome state.
+ *
+ * The workspace must display the welcome guidance, the header path must be empty, and
+ * the editor state must not have a file snapshot. "Neu" and "Öffnen" must be present.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(1)
+ void guiStart_withoutConfig_showsBlankWelcomeStateAndExposesNeuAndOeffnenButtons()
+ throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+
+ assertTrue(ws.isWelcomeGuidanceVisible(),
+ "Welcome guidance must be visible on blank start");
+ assertEquals("", ws.configurationPathText(),
+ "Header path must be empty on blank start");
+ assertFalse(ws.editorState().hasLoadedFileSnapshot(),
+ "No file snapshot must exist on blank start");
+ assertFalse(ws.editorState().isDirty(),
+ "Blank start state must not be dirty");
+ assertEquals("Neu", ws.newButton().getText(),
+ "'Neu' button must be present");
+ assertEquals("Öffnen", ws.openButton().getText(),
+ "'Öffnen' button must be present");
+ });
+ }
+
+ // =========================================================================
+ // Flow: "Neu" with standard template
+ // =========================================================================
+
+ /**
+ * Regression: "Neu" switches the workspace to the standard template, hides the welcome
+ * guidance, and leaves the state clean with all template fields populated.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(2)
+ void neu_withStandardTemplate_populatesFieldsAndHidesWelcomeGuidance() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+
+ assertTrue(ws.isWelcomeGuidanceVisible(), "Precondition: welcome must be visible");
+
+ ws.requestNewConfiguration();
+
+ assertFalse(ws.isWelcomeGuidanceVisible(),
+ "Welcome guidance must be hidden after 'Neu'");
+ assertEquals("", ws.editorState().configurationPathText(),
+ "Path must remain empty after 'Neu' (no file saved yet)");
+ assertFalse(ws.editorState().isDirty(),
+ "State must be clean right after 'Neu'");
+
+ GuiConfigurationValues v = ws.editorState().values();
+ assertEquals(GuiConfigurationTemplateFactory.createStandardValues().sourceFolder(),
+ v.sourceFolder(), "Source folder must match standard template default");
+ assertEquals(GuiConfigurationTemplateFactory.createStandardValues().targetFolder(),
+ v.targetFolder(), "Target folder must match standard template default");
+ assertEquals(GuiConfigurationTemplateFactory.createStandardValues().logLevel(),
+ v.logLevel(), "Log level must match standard template default");
+ });
+ }
+
+ // =========================================================================
+ // Flow: "Öffnen" existing .properties file via loader callback
+ // =========================================================================
+
+ /**
+ * Regression: "Öffnen" via the file-loader callback populates the editor fields from
+ * the file content and updates the header with the loaded path.
+ *
+ * @param tempDir JUnit-provided temporary directory
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(3)
+ void oeffnen_existingPropertiesFile_fillsFieldsAndUpdatesHeader(@TempDir Path tempDir)
+ throws Exception {
+ Path configFile = tempDir.resolve("open-test.properties");
+ writeMinimalPropertiesFile(configFile, "./source-loaded", "./target-loaded", "claude");
+
+ GuiConfigurationFileLoader loader = buildSnapshotLoader();
+ GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
+ GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState();
+ GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter);
+
+ AtomicReference
+ * The file chooser and the confirmation dialog are replaced by injectable test hooks so the
+ * test runs headless without native dialogs.
+ *
+ * @param tempDir JUnit-provided temporary directory
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(6)
+ void overwriteDialog_existingTarget_yesConfirmation_writerIsCalled(@TempDir Path tempDir)
+ throws Exception {
+ Path existingFile = tempDir.resolve("existing.properties");
+ Files.writeString(existingFile, "source.folder=old\n", StandardCharsets.UTF_8);
+
+ AtomicBoolean writerCalled = new AtomicBoolean(false);
+ AtomicReference
+ * The dirty state is injected directly via the package-private {@code editorState} field.
+ * The private {@code refreshHeader} method is then invoked via reflection to propagate the
+ * new state to the UI elements without altering any other workspace state. This approach
+ * avoids the need for a public or package-private refresh hook in production code while still
+ * verifying the complete rendering pipeline end-to-end.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ @Order(8)
+ void fieldChange_titleListenerReceivesDirtyPrefixAndHeaderIsMarked() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
+ ws.requestNewConfiguration();
+
+ assertFalse(ws.editorState().isDirty(),
+ "Precondition: state must be clean right after 'Neu'");
+ assertFalse(ws.dirtyMarkerLabel.isVisible(),
+ "Precondition: dirty-marker label must be hidden in clean state");
+
+ AtomicReference
+ * A stub writer is used so no file-system access occurs. The test waits for the asynchronous
+ * save to complete and then verifies that the stage is no longer showing.
+ */
+ @Test
+ @Order(12)
+ void closeRequestHandler_whenDirtyAndSaveWithKnownPath_closesStageAfterSuccessfulSave()
+ throws Exception {
+ Path targetPath = Path.of("config/application.properties");
+ AtomicReference
+ * This adapter implements the {@link GuiConfigurationFileWriter} port and is wired by
+ * Bootstrap. It performs two main responsibilities:
+ *
+ * API-key preservation logic (detecting empty fields with a non-empty baseline) is handled
+ * by the caller (workspace) before invoking this writer. The writer simply serializes the
+ * values it receives.
+ *
+ *
+ * When the target file already exists, a backup is created before the file is overwritten.
+ * The write is performed via a temporary file followed by an atomic rename.
+ *
+ * Threading contract: This method performs blocking file-system I/O
+ * ({@link java.nio.file.Files#exists}, backup copy, directory creation, file write, atomic
+ * move). It must be invoked from a background worker thread. It must never be called from
+ * the JavaFX Application Thread.
+ *
+ * @param values the current editor values; must not be {@code null}
+ * @param targetPath the file to write; must not be {@code null}
+ * @return the save result containing the written path
+ * @throws GuiConfigurationWriteException if the file cannot be written
+ */
+ @Override
+ public GuiConfigurationSaveResult write(GuiConfigurationValues values, Path targetPath) {
+ if (Files.exists(targetPath)) {
+ createBakBackup(targetPath);
+ }
+
+ String content = buildPropertiesContent(values);
+ writeAtomically(targetPath, content);
+
+ LOG.info("Konfigurationsdatei geschrieben: {}", targetPath.toAbsolutePath());
+ return GuiConfigurationSaveResult.saved(targetPath);
+ }
+
+ /**
+ * Creates a rotating backup of the file at the given path.
+ *
+ * The first backup uses the suffix {@code .bak}. When that file already exists,
+ * numbered suffixes are tried in ascending order ({@code .bak.1}, {@code .bak.2}, …)
+ * until a free slot is found. Existing backups are never overwritten.
+ *
+ * @param targetPath the file to back up; must exist
+ * @throws GuiConfigurationWriteException if the backup cannot be created
+ */
+ void createBakBackup(Path targetPath) {
+ Path bakPath = targetPath.resolveSibling(targetPath.getFileName() + ".bak");
+ if (!Files.exists(bakPath)) {
+ copyFile(targetPath, bakPath);
+ LOG.info("Sicherungskopie erstellt: {}", bakPath);
+ return;
+ }
+ for (int i = 1; ; i++) {
+ Path numbered = targetPath.resolveSibling(targetPath.getFileName() + ".bak." + i);
+ if (!Files.exists(numbered)) {
+ copyFile(targetPath, numbered);
+ LOG.info("Sicherungskopie erstellt: {}", numbered);
+ return;
+ }
+ }
+ }
+
+ /**
+ * Writes the content to the target path via a temporary file and an atomic rename.
+ *
+ * @param target the destination path; must not be {@code null}
+ * @param content the content to write; must not be {@code null}
+ * @throws GuiConfigurationWriteException if the file cannot be written
+ */
+ private void writeAtomically(Path target, String content) {
+ Path tmpPath = target.resolveSibling(target.getFileName() + ".tmp");
+ try {
+ Path parentDir = target.getParent();
+ if (parentDir != null) {
+ Files.createDirectories(parentDir);
+ }
+ Files.writeString(tmpPath, content, StandardCharsets.UTF_8);
+ Files.move(tmpPath, target, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ throw new GuiConfigurationWriteException(
+ "Konfigurationsdatei konnte nicht geschrieben werden: " + target, e);
+ }
+ }
+
+ private void copyFile(Path source, Path destination) {
+ try {
+ Files.copy(source, destination);
+ } catch (IOException e) {
+ throw new GuiConfigurationWriteException(
+ "Sicherungskopie konnte nicht erstellt werden: " + destination, e);
+ }
+ }
+
+ /**
+ * Builds the normalized {@code .properties} file content from the given values.
+ *
+ * @param values the values to serialize; must not be {@code null}
+ * @return the complete file content as a UTF-8 string
+ */
+ String buildPropertiesContent(GuiConfigurationValues values) {
+ StringBuilder sb = new StringBuilder();
+
+ appendLine(sb, "# Aktiver KI-Provider (claude oder openai-compatible)");
+ appendKeyValue(sb, "ai.provider.active", values.activeProviderFamily());
+ appendLine(sb, "");
+
+ appendLine(sb, "# Provider-Konfiguration: Claude");
+ GuiProviderConfigurationState claude = values.providerConfiguration(AiProviderFamily.CLAUDE);
+ if (claude != null) {
+ appendKeyValue(sb, "ai.provider.claude.baseUrl", claude.baseUrl());
+ appendKeyValue(sb, "ai.provider.claude.model", claude.model());
+ appendKeyValue(sb, "ai.provider.claude.timeoutSeconds", claude.timeoutSeconds());
+ appendKeyValue(sb, "ai.provider.claude.apiKey", claude.apiKey().propertyValue());
+ }
+ appendLine(sb, "");
+
+ appendLine(sb, "# Provider-Konfiguration: OpenAI-kompatibel");
+ GuiProviderConfigurationState openai = values.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE);
+ if (openai != null) {
+ appendKeyValue(sb, "ai.provider.openai-compatible.baseUrl", openai.baseUrl());
+ appendKeyValue(sb, "ai.provider.openai-compatible.model", openai.model());
+ appendKeyValue(sb, "ai.provider.openai-compatible.timeoutSeconds", openai.timeoutSeconds());
+ appendKeyValue(sb, "ai.provider.openai-compatible.apiKey", openai.apiKey().propertyValue());
+ }
+ appendLine(sb, "");
+
+ appendLine(sb, "# Pfade");
+ appendKeyValue(sb, "source.folder", values.sourceFolder());
+ appendKeyValue(sb, "target.folder", values.targetFolder());
+ appendKeyValue(sb, "sqlite.file", values.sqliteFile());
+ appendLine(sb, "");
+
+ appendLine(sb, "# Verarbeitung");
+ appendKeyValue(sb, "max.retries.transient", values.maxRetriesTransient());
+ appendKeyValue(sb, "max.pages", values.maxPages());
+ appendKeyValue(sb, "max.text.characters", values.maxTextCharacters());
+ appendKeyValue(sb, "prompt.template.file", values.promptTemplateFile());
+ appendLine(sb, "");
+
+ appendLine(sb, "# Logging");
+ appendKeyValue(sb, "log.ai.sensitive", values.logAiSensitive());
+ appendKeyValue(sb, "log.directory", values.logDirectory());
+ appendKeyValue(sb, "log.level", values.logLevel());
+ appendLine(sb, "");
+
+ appendLine(sb, "# Laufzeit");
+ appendKeyValue(sb, "runtime.lock.file", values.runtimeLockFile());
+
+ return sb.toString();
+ }
+
+ private static void appendLine(StringBuilder sb, String line) {
+ sb.append(line).append("\n");
+ }
+
+ private static void appendKeyValue(StringBuilder sb, String key, String value) {
+ sb.append(key).append("=").append(value == null ? "" : value).append("\n");
+ }
+}
diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/package-info.java
new file mode 100644
index 0000000..1fd813c
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Technical adapters wired exclusively by the Bootstrap module.
+ *
+ * This package contains adapter implementations that are not part of any other module's
+ * public contract. They are instantiated and wired by Bootstrap and injected into the
+ * appropriate ports. Adapter classes in this package may depend on both inbound and
+ * outbound module contracts, but must not introduce circular dependencies.
+ */
+package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java
new file mode 100644
index 0000000..79586c9
--- /dev/null
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/GuiConfigurationPropertiesWriterTest.java
@@ -0,0 +1,311 @@
+package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+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.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationWriteException;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+
+/**
+ * Unit tests for {@link GuiConfigurationPropertiesWriter}.
+ *
+ * Tests cover: normalized output content and order, backup rotation schema, backup
+ * non-overwrite guarantee, and atomic write behavior.
+ */
+class GuiConfigurationPropertiesWriterTest {
+
+ private final GuiConfigurationPropertiesWriter writer = new GuiConfigurationPropertiesWriter();
+
+ @TempDir
+ Path tempDir;
+
+ // =========================================================================
+ // Backup rotation
+ // =========================================================================
+
+ @Test
+ void backup_createsFirstBakWhenNoneExists() throws IOException {
+ Path file = tempDir.resolve("config.properties");
+ Files.writeString(file, "existing=content", StandardCharsets.UTF_8);
+
+ writer.createBakBackup(file);
+
+ Path bak = tempDir.resolve("config.properties.bak");
+ assertTrue(Files.exists(bak), "First backup must be created as .bak");
+ assertEquals("existing=content", Files.readString(bak, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void backup_createsNumberedBakWhenBakAlreadyExists() throws IOException {
+ Path file = tempDir.resolve("config.properties");
+ Files.writeString(file, "new=content", StandardCharsets.UTF_8);
+ Path bak = tempDir.resolve("config.properties.bak");
+ Files.writeString(bak, "old=content", StandardCharsets.UTF_8);
+
+ writer.createBakBackup(file);
+
+ Path bak1 = tempDir.resolve("config.properties.bak.1");
+ assertTrue(Files.exists(bak1), "Second backup must be created as .bak.1");
+ assertEquals("new=content", Files.readString(bak1, StandardCharsets.UTF_8));
+ // Existing .bak must not be overwritten.
+ assertEquals("old=content", Files.readString(bak, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void backup_incrementsNumberUntilFreeSlotFound() throws IOException {
+ Path file = tempDir.resolve("config.properties");
+ Files.writeString(file, "data", StandardCharsets.UTF_8);
+ Files.writeString(tempDir.resolve("config.properties.bak"), "bak", StandardCharsets.UTF_8);
+ Files.writeString(tempDir.resolve("config.properties.bak.1"), "bak1", StandardCharsets.UTF_8);
+ Files.writeString(tempDir.resolve("config.properties.bak.2"), "bak2", StandardCharsets.UTF_8);
+
+ writer.createBakBackup(file);
+
+ Path bak3 = tempDir.resolve("config.properties.bak.3");
+ assertTrue(Files.exists(bak3), "Third numbered backup must be created as .bak.3");
+ // Previous backups must remain unchanged.
+ assertEquals("bak2", Files.readString(tempDir.resolve("config.properties.bak.2"),
+ StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void backup_neverOverwritesExistingBackups() throws IOException {
+ Path file = tempDir.resolve("c.properties");
+ Files.writeString(file, "current", StandardCharsets.UTF_8);
+ Path bak = tempDir.resolve("c.properties.bak");
+ Files.writeString(bak, "precious", StandardCharsets.UTF_8);
+
+ writer.createBakBackup(file);
+
+ assertEquals("precious", Files.readString(bak, StandardCharsets.UTF_8),
+ "Existing .bak content must not be overwritten");
+ }
+
+ // =========================================================================
+ // Normalized output content
+ // =========================================================================
+
+ @Test
+ void write_newFile_createsFileWithNormalizedContent() throws IOException {
+ Path target = tempDir.resolve("application.properties");
+ GuiConfigurationValues values = buildTestValues("claude", "sk-claude", "sk-openai");
+
+ GuiConfigurationSaveResult result = writer.write(values, target);
+
+ assertEquals(target, result.savedPath());
+ assertFalse(result.hasApiKeyPreservationNote());
+ assertTrue(Files.exists(target), "Target file must exist after write");
+
+ Properties props = loadProperties(target);
+ assertEquals("claude", props.getProperty("ai.provider.active"));
+ assertEquals("https://api.anthropic.com", props.getProperty("ai.provider.claude.baseUrl"));
+ assertEquals("claude-3-5-sonnet-20241022", props.getProperty("ai.provider.claude.model"));
+ assertEquals("60", props.getProperty("ai.provider.claude.timeoutSeconds"));
+ assertEquals("sk-claude", props.getProperty("ai.provider.claude.apiKey"));
+ assertEquals("https://api.openai.com/v1", props.getProperty("ai.provider.openai-compatible.baseUrl"));
+ assertEquals("gpt-4o-mini", props.getProperty("ai.provider.openai-compatible.model"));
+ assertEquals("30", props.getProperty("ai.provider.openai-compatible.timeoutSeconds"));
+ assertEquals("sk-openai", props.getProperty("ai.provider.openai-compatible.apiKey"));
+ assertEquals("./source", props.getProperty("source.folder"));
+ assertEquals("./target", props.getProperty("target.folder"));
+ assertEquals("./db.sqlite", props.getProperty("sqlite.file"));
+ assertEquals("3", props.getProperty("max.retries.transient"));
+ assertEquals("10", props.getProperty("max.pages"));
+ assertEquals("5000", props.getProperty("max.text.characters"));
+ assertEquals("./prompt.txt", props.getProperty("prompt.template.file"));
+ assertEquals("false", props.getProperty("log.ai.sensitive"));
+ assertEquals("./logs", props.getProperty("log.directory"));
+ assertEquals("INFO", props.getProperty("log.level"));
+ assertEquals("./app.lock", props.getProperty("runtime.lock.file"));
+ }
+
+ @Test
+ void write_existingFile_createsBackupBeforeOverwriting() throws IOException {
+ Path target = tempDir.resolve("application.properties");
+ Files.writeString(target, "old=value", StandardCharsets.UTF_8);
+ GuiConfigurationValues values = buildTestValues("claude", "", "");
+
+ writer.write(values, target);
+
+ Path bak = tempDir.resolve("application.properties.bak");
+ assertTrue(Files.exists(bak), "Backup must be created when overwriting an existing file");
+ assertEquals("old=value", Files.readString(bak, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void write_noBackupCreatedForNewFile() throws IOException {
+ Path target = tempDir.resolve("new.properties");
+ GuiConfigurationValues values = buildTestValues("claude", "", "");
+
+ writer.write(values, target);
+
+ Path bak = tempDir.resolve("new.properties.bak");
+ assertFalse(Files.exists(bak), "No backup must be created when writing a new file");
+ }
+
+ @Test
+ void write_createsParentDirectoriesWhenMissing() throws IOException {
+ Path target = tempDir.resolve("nested/dir/config.properties");
+ GuiConfigurationValues values = buildTestValues("claude", "", "");
+
+ writer.write(values, target);
+
+ assertTrue(Files.exists(target), "File must be created even when parent directories are missing");
+ }
+
+ // =========================================================================
+ // Normalized property order
+ // =========================================================================
+
+ @Test
+ void buildPropertiesContent_includesExpectedSections() {
+ GuiConfigurationValues values = buildTestValues("openai-compatible", "sk-a", "sk-b");
+
+ String content = writer.buildPropertiesContent(values);
+
+ // Verify section grouping order.
+ int providerActivePos = content.indexOf("ai.provider.active=");
+ int claudePos = content.indexOf("ai.provider.claude.baseUrl=");
+ int openaiPos = content.indexOf("ai.provider.openai-compatible.baseUrl=");
+ int sourceFolderPos = content.indexOf("source.folder=");
+ int maxRetriesPos = content.indexOf("max.retries.transient=");
+ int logAiPos = content.indexOf("log.ai.sensitive=");
+ int lockPos = content.indexOf("runtime.lock.file=");
+
+ assertTrue(providerActivePos < claudePos, "ai.provider.active must appear before claude block");
+ assertTrue(claudePos < openaiPos, "Claude block must appear before openai-compatible block");
+ assertTrue(openaiPos < sourceFolderPos, "Provider blocks must appear before paths");
+ assertTrue(sourceFolderPos < maxRetriesPos, "Paths must appear before processing section");
+ assertTrue(maxRetriesPos < logAiPos, "Processing section must appear before logging section");
+ assertTrue(logAiPos < lockPos, "Logging section must appear before runtime section");
+ }
+
+ @Test
+ void buildPropertiesContent_containsGroupingComments() {
+ GuiConfigurationValues values = buildTestValues("claude", "", "");
+
+ String content = writer.buildPropertiesContent(values);
+
+ assertTrue(content.contains("# Pfade"), "Paths section must have a comment");
+ assertTrue(content.contains("# Verarbeitung"), "Processing section must have a comment");
+ assertTrue(content.contains("# Logging"), "Logging section must have a comment");
+ assertTrue(content.contains("# Laufzeit"), "Runtime section must have a comment");
+ }
+
+ @Test
+ void buildPropertiesContent_emptyValuesProduceParsableOutput() throws IOException {
+ GuiConfigurationValues values = buildTestValues("claude", "", "");
+
+ String content = writer.buildPropertiesContent(values);
+
+ Properties props = new Properties();
+ props.load(new StringReader(content));
+ // Should not throw, and values should be parseable.
+ assertEquals("claude", props.getProperty("ai.provider.active"));
+ }
+
+ // =========================================================================
+ // Threading invariant: writer must not be called from the FX thread
+ // =========================================================================
+
+ /**
+ * Verifies that {@link GuiConfigurationPropertiesWriter#write} is called from a background
+ * worker thread and not from the JavaFX Application Thread, as required by the threading
+ * contract documented on the method.
+ *
+ * The test invokes the writer directly from a named non-FX thread and captures the thread
+ * name inside the call to confirm the threading invariant. A thread named anything other than
+ * "JavaFX Application Thread" satisfies the invariant.
+ *
+ * @throws Exception if the background thread fails or times out
+ */
+ @Test
+ void write_isCalledFromWorkerThread_notFromFxApplicationThread() throws Exception {
+ Path target = tempDir.resolve("threading-test.properties");
+ GuiConfigurationValues values = buildTestValues("claude", "", "");
+
+ java.util.concurrent.atomic.AtomicReference
+ *
+ */
+public final class GuiUnsavedChangesGuard {
+
+ /**
+ * The possible responses the user can give to the protection dialog.
+ */
+ public enum Choice {
+ /** Save the current changes and then continue with the requested action. */
+ SAVE,
+ /** Discard all unsaved changes and continue with the requested action. */
+ DISCARD,
+ /** Cancel the requested action; no state change is performed. */
+ CANCEL
+ }
+
+ /**
+ * Supplies the user's choice for a given trigger label.
+ *
+ *
+ *
+ * @param triggerLabel a short label identifying the triggering action (e.g. "Neu", "Öffnen");
+ * used to give the dialog context; must not be {@code null}
+ * @param onProceed action to run when the user chose discard; must not be {@code null}
+ * @param onSave action to run when the user chose save; must not be {@code null}
+ */
+ public void askAndProceed(String triggerLabel, Runnable onProceed, Runnable onSave) {
+ Choice choice = dialogSupplier.apply(triggerLabel);
+ switch (choice) {
+ case SAVE -> onSave.run();
+ case DISCARD -> onProceed.run();
+ case CANCEL -> {
+ // No action – caller keeps the current state.
+ }
+ }
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatter.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatter.java
new file mode 100644
index 0000000..81c86a0
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiWindowTitleFormatter.java
@@ -0,0 +1,68 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
+
+/**
+ * Formats the window title string for the PDF-Umbenenner GUI editor.
+ *
+ *
+ */
+public final class GuiWindowTitleFormatter {
+
+ /** The application name shown in every window title variant. */
+ static final String APPLICATION_NAME = "PDF-Umbenenner";
+
+ /** Separator placed between the application name and the context section. */
+ static final String SEPARATOR = " \u2014 ";
+
+ /** Prefix added to the title when the editor contains unsaved changes. */
+ static final String DIRTY_PREFIX = "* ";
+
+ /** Context label used when no file has been loaded yet. */
+ static final String NEW_CONFIGURATION_LABEL = "Neue Konfiguration";
+
+ private GuiWindowTitleFormatter() {
+ // Utility class.
+ }
+
+ /**
+ * Formats the window title for the given editor state.
+ *
+ * @param editorState the current editor state; must not be {@code null}
+ * @return the formatted window title string; never {@code null}
+ */
+ public static String format(GuiConfigurationEditorState editorState) {
+ String contextPart = buildContextPart(editorState);
+ String base = APPLICATION_NAME + SEPARATOR + contextPart;
+ if (editorState.changeState() == GuiChangeState.DIRTY) {
+ return DIRTY_PREFIX + base;
+ }
+ return base;
+ }
+
+ /**
+ * Returns the context portion of the title (the part after the separator).
+ *
+ * @param editorState the current editor state; must not be {@code null}
+ * @return the context string; never {@code null}
+ */
+ private static String buildContextPart(GuiConfigurationEditorState editorState) {
+ if (editorState.isNewConfiguration()) {
+ return NEW_CONFIGURATION_LABEL;
+ }
+ String fullPath = editorState.loadedFileSnapshot()
+ .map(snapshot -> snapshot.filePath().getFileName())
+ .map(Object::toString)
+ .orElse(NEW_CONFIGURATION_LABEL);
+ return fullPath;
+ }
+}
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 8f440c8..bd3c46f 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
@@ -13,11 +13,15 @@ import org.apache.logging.log4j.Logger;
* 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}.
+ *
+ *
+ *
+ *
+ * Test scope
+ *
+ *
+ *
+ * Design
+ * Covered flows
+ *
+ *
+ *
+ * Threading and headless compatibility
+ * Scope
+ *
+ *
+ *
+ * Threading
+ * The FX Application Thread is started once for this class. All workspace interactions happen
+ * inside {@link Platform#runLater} blocks with {@link CountDownLatch} synchronization.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class GuiUnsavedChangesGuardSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ // Platform was started by another test class running in the same JVM.
+ // Verify that the FX thread is reachable before proceeding.
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Do not call Platform.exit() here because other test classes may share the platform.
+ }
+
+ // =========================================================================
+ // Dirty-marker visibility
+ // =========================================================================
+
+ /**
+ * Verifies that the dirty marker in the header is hidden when the editor is clean and
+ * becomes visible once values are changed.
+ */
+ @Test
+ @Order(1)
+ void dirtyMarker_isHiddenWhenClean_andVisibleWhenDirty() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference
+ *
+ * Normalized output order
+ * {@code
+ * # Provider
+ * ai.provider.active=...
+ * # Claude
+ * ai.provider.claude.*
+ * # OpenAI-kompatibel
+ * ai.provider.openai-compatible.*
+ * # Pfade
+ * source.folder=...
+ * target.folder=...
+ * sqlite.file=...
+ * # Verarbeitung
+ * max.retries.transient=...
+ * max.pages=...
+ * max.text.characters=...
+ * prompt.template.file=...
+ * # Logging
+ * log.ai.sensitive=...
+ * log.directory=...
+ * log.level=...
+ * # Laufzeit
+ * runtime.lock.file=...
+ * }
+ */
+public final class GuiConfigurationPropertiesWriter implements GuiConfigurationFileWriter {
+
+ private static final Logger LOG = LogManager.getLogger(GuiConfigurationPropertiesWriter.class);
+
+ /**
+ * Creates a new properties writer.
+ */
+ public GuiConfigurationPropertiesWriter() {
+ }
+
+ /**
+ * Writes the editor values to the target path as a normalized {@code .properties} file.
+ *