diff --git a/docs/betrieb.md b/docs/betrieb.md index c667531..48ac901 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext. ### Umfang der GUI -Die GUI enthält zwei Tabs: +Die GUI enthält drei Tabs: - **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei, @@ -75,6 +75,13 @@ Die GUI enthält zwei Tabs: ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop** über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei. Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin. +- **Tab „Prompt"** – Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt + aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel). + Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`). + Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage, + ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein + „Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch + geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus. Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau @@ -292,6 +299,35 @@ Die Anwendung ergänzt den Prompt automatisch um: - einen Dokumenttext-Abschnitt - eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date` +### Prompt-Pfad-Auflösung je Betriebsart + +Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst, +wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab: + +| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert | +|---|---|---| +| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` | +| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` | +| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` | +| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen | + +> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad** +> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis +> immer eindeutig auffindbar – insbesondere beim Start über den Windows Task Scheduler, +> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann. + +### Bearbeitung über den GUI-Prompt-Tab + +Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und +gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn +das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem +Fall wird kein stiller Fallback durchgeführt). + +Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert +war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"), +aktualisiert sich der Prompt-Tab nicht automatisch – in diesem Fall sollte die GUI neu gestartet +oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden. + --- ## Zielformat 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 5e0f9f5..b820ef6 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 @@ -414,6 +414,13 @@ public final class GuiConfigurationEditorWorkspace { */ private final GuiHistoricalDocumentContextPort historicalDocumentContextPort; + /** + * Bridge-Port zum Prompt-Editor-Use-Case. Wird vom {@link GuiPromptEditorTab} genutzt, + * um den Prompt-Inhalt zu laden, zu speichern und eine Standard-Prompt-Datei anzulegen. + * Supplied by Bootstrap via the startup context. + */ + private final GuiPromptEditorPort promptEditorPort; + /** * Second main tab of the window that drives the live processing-run view. Created * during workspace construction and wired into the shared {@link #tabPane} alongside @@ -421,6 +428,12 @@ public final class GuiConfigurationEditorWorkspace { */ private final GuiBatchRunTab batchRunTab; + /** + * Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt + * und in den {@link #tabPane} eingehängt. + */ + private final GuiPromptEditorTab promptEditorTab; + /** * Hint banner shown at the top of the configuration tab while a processing run is * active. Visible + managed state are flipped from the batch run tab's listener when @@ -491,6 +504,7 @@ public final class GuiConfigurationEditorWorkspace { this.manualFileRenamePort = effectiveContext.manualFileRenamePort(); this.manualFileCopyPort = effectiveContext.manualFileCopyPort(); this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort(); + this.promptEditorPort = effectiveContext.promptEditorPort(); this.batchRunTab = new GuiBatchRunTab( () -> this.batchRunLauncher, () -> this.miniRunLauncher, @@ -504,6 +518,17 @@ public final class GuiConfigurationEditorWorkspace { this::editorSourceFolder, this::editorTargetFolder); + String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile(); + int maxTitleLength; + try { + maxTitleLength = Integer.parseInt( + effectiveContext.initialState().values().maxTitleLength().trim()); + } catch (NumberFormatException e) { + maxTitleLength = 60; + } + this.promptEditorTab = new GuiPromptEditorTab( + this.promptEditorPort, configuredPromptPath, maxTitleLength); + configureRoot(); configureHeader(effectiveContext.startupNotice()); configureTabs(); @@ -1271,11 +1296,12 @@ public final class GuiConfigurationEditorWorkspace { scrollPane.setPadding(new Insets(0)); editorTab.setContent(scrollPane); - tabPane.getTabs().setAll(editorTab, batchRunTab.tab()); + tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab()); root.setCenter(tabPane); // Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob // der Dateiname-Editor ungespeicherte Änderungen hat. + // Gleiches gilt für den Prompt-Tab. tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> { if (oldTab == null || newTab == null) { return; @@ -1287,6 +1313,11 @@ public final class GuiConfigurationEditorWorkspace { // Zurück zum Verarbeitungslauf-Tab Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab)); } + } else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) { + boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty(); + if (!shouldDiscard) { + Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab)); + } } }); } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorPort.java new file mode 100644 index 0000000..358f6a2 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorPort.java @@ -0,0 +1,64 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; + +/** + * GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen + * Use-Case in der Application-Schicht. + *

+ * Dieses Interface ist kein hexagonaler Outbound-Port der Application-Schicht. + * Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte + * Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf + * {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem + * zugreift. + *

+ * Verantwortung: + *

+ *

+ * Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}. + * Das GUI-Modul kennt ausschließlich den Interface-Typ. + */ +public interface GuiPromptEditorPort { + + /** + * Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle. + *

+ * Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via + * {@code Platform.runLater} in den JavaFX Application Thread übergeben. + * + * @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg, + * oder einem klassifizierten Fehler; nie {@code null} + */ + PromptLoadingResult loadCurrentPrompt(); + + /** + * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei. + *

+ * Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via + * {@code Platform.runLater} in den JavaFX Application Thread übergeben. + * + * @param content der zu speichernde Inhalt; darf nicht {@code null} sein + * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null} + * @throws NullPointerException wenn {@code content} null ist + */ + PromptSaveResult save(String content); + + /** + * Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist. + *

+ * Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via + * {@code Platform.runLater} in den JavaFX Application Thread übergeben. + * + * @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein + * @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null} + * @throws NullPointerException wenn {@code suggestion} null ist + */ + CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTab.java new file mode 100644 index 0000000..94560e7 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTab.java @@ -0,0 +1,365 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TextArea; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; + +/** + * Tab „Prompt" im Hauptfenster des GUI-Adapters. + *

+ * Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei + * direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen. + *

+ * Verhalten: + *

+ *

+ * Threading: Alle blockierenden Operationen (Laden, Speichern, + * Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen + * ausschließlich via {@code Platform.runLater}. + */ +public class GuiPromptEditorTab { + + private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class); + + private static final String TAB_TITLE = "Prompt"; + private static final String TAB_TITLE_DIRTY = "Prompt *"; + + private final GuiPromptEditorPort promptEditorPort; + /** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */ + private final String configuredPromptPath; + /** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */ + private final int maxTitleLength; + + // Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime) + /** Erzeugt Worker-Threads für blockierende Operationen. */ + Function threadFactory; + /** Übergibt UI-Updates an den JavaFX Application Thread. */ + Consumer fxDispatcher; + + private final Tab tab = new Tab(TAB_TITLE); + private final TextArea textArea = new TextArea(); + private final Label statusLabel = new Label(); + private final Button saveButton = new Button("Speichern"); + private final Button resetButton = new Button("Auf Standard zurücksetzen"); + private final Button createDefaultButton = new Button("Standard-Prompt erstellen"); + + /** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */ + private boolean dirty = false; + /** Zuletzt aus der Datei geladener Inhalt (Baseline). */ + private String loadedContent = null; + + /** + * Erstellt den Prompt-Editor-Tab. + * + * @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein + * @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile); + * darf nicht {@code null} sein + * @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt + * @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist + */ + public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort, + String configuredPromptPath, + int maxTitleLength) { + this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null"); + this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null"); + this.maxTitleLength = maxTitleLength; + // Standard-Implementierungen für den Produktionsbetrieb + this.threadFactory = runnable -> { + Thread t = new Thread(runnable, "gui-prompt-editor"); + t.setDaemon(true); + return t; + }; + this.fxDispatcher = Platform::runLater; + buildTab(); + } + + /** + * Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann. + * + * @return das Tab; nie {@code null} + */ + public Tab tab() { + return tab; + } + + /** + * Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält. + * + * @return {@code true}, wenn Dirty-State aktiv ist + */ + public boolean hasDirtyContent() { + return dirty; + } + + /** + * Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind. + * Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen. + * + * @return {@code true} zum Verwerfen, {@code false} zum Abbrechen + */ + public boolean confirmDiscardIfDirty() { + if (!dirty) { + return true; + } + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Ungespeicherte Änderungen"); + alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen."); + alert.setContentText("Möchten Sie die Änderungen verwerfen?"); + alert.getButtonTypes().setAll( + new ButtonType("Verwerfen"), + ButtonType.CANCEL); + Optional result = alert.showAndWait(); + return result.isPresent() && result.get().getText().equals("Verwerfen"); + } + + /** + * Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an. + *

+ * Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation + * läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}. + */ + public void loadPromptAsync() { + setStatus("Lade Prompt-Datei ..."); + saveButton.setDisable(true); + resetButton.setDisable(true); + createDefaultButton.setVisible(false); + createDefaultButton.setManaged(false); + + Thread worker = threadFactory.apply(() -> { + var result = promptEditorPort.loadCurrentPrompt(); + fxDispatcher.accept(() -> applyLoadResult(result)); + }); + worker.start(); + } + + // ------------------------------------------------------------------------- + // Private Aufbau + // ------------------------------------------------------------------------- + + private void buildTab() { + tab.setClosable(false); + + // TextArea – monospace Font für bessere Lesbarkeit + textArea.setWrapText(true); + textArea.setFont(Font.font("Monospace", 13)); + textArea.setPrefRowCount(20); + VBox.setVgrow(textArea, Priority.ALWAYS); + + // Dirty-State-Tracking + textArea.textProperty().addListener((obs, oldVal, newVal) -> { + if (loadedContent != null) { + boolean nowDirty = !newVal.equals(loadedContent); + if (nowDirty != dirty) { + dirty = nowDirty; + tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE); + } + } + }); + + // Status-Label + statusLabel.setWrapText(true); + statusLabel.setStyle("-fx-text-fill: #555555;"); + + // Buttons verdrahten + saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8).")); + saveButton.setOnAction(e -> requestSave()); + + resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.")); + resetButton.setOnAction(e -> resetToDefault()); + + createDefaultButton.setTooltip(new Tooltip( + "Standard-Prompt-Datei am konfigurierten Pfad anlegen.")); + createDefaultButton.setOnAction(e -> requestCreateDefault()); + createDefaultButton.setVisible(false); + createDefaultButton.setManaged(false); + + HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton); + buttonBar.setAlignment(Pos.CENTER_LEFT); + buttonBar.setPadding(new Insets(6, 0, 0, 0)); + + VBox content = new VBox(6, textArea, statusLabel, buttonBar); + content.setPadding(new Insets(12)); + VBox.setVgrow(textArea, Priority.ALWAYS); + + BorderPane root = new BorderPane(content); + tab.setContent(root); + + // Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden) + tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> { + if (Boolean.TRUE.equals(isSelected) && loadedContent == null) { + loadPromptAsync(); + } + }); + } + + private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) { + if (result instanceof PromptLoadingSuccess success) { + loadedContent = success.promptContent(); + textArea.setText(loadedContent); + textArea.setEditable(true); + saveButton.setDisable(false); + resetButton.setDisable(false); + createDefaultButton.setVisible(false); + createDefaultButton.setManaged(false); + setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier()); + dirty = false; + tab.setText(TAB_TITLE); + LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).", + success.promptIdentifier().identifier()); + } else if (result instanceof PromptLoadingFailure failure) { + boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason()); + if (fileNotFound) { + // Datei fehlt – Hinweis und Anlegen-Button anzeigen + loadedContent = null; + textArea.setEditable(false); + textArea.clear(); + saveButton.setDisable(true); + resetButton.setDisable(false); + createDefaultButton.setVisible(true); + createDefaultButton.setManaged(true); + setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder " + + "konfigurieren Sie den Pfad im Konfigurationstab."); + LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden."); + } else { + // Anderer Fehler (I/O, leer usw.) + loadedContent = null; + textArea.setEditable(false); + textArea.clear(); + saveButton.setDisable(true); + resetButton.setDisable(false); + createDefaultButton.setVisible(false); + createDefaultButton.setManaged(false); + setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage()); + LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}", + failure.failureReason(), failure.failureMessage()); + } + } + } + + private void requestSave() { + String currentText = textArea.getText(); + + // Leerer Prompt: Bestätigungsdialog + if (currentText.trim().isEmpty()) { + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.setTitle("Leerer Prompt"); + confirm.setHeaderText("Der Prompt ist leer."); + confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?"); + confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL); + Optional choice = confirm.showAndWait(); + if (choice.isEmpty() || choice.get() != ButtonType.OK) { + return; + } + } + + setStatus("Speichere ..."); + saveButton.setDisable(true); + + Thread worker = threadFactory.apply(() -> { + PromptSaveResult result = promptEditorPort.save(currentText); + fxDispatcher.accept(() -> applySaveResult(result, currentText)); + }); + worker.start(); + } + + private void applySaveResult(PromptSaveResult result, String savedContent) { + saveButton.setDisable(false); + if (result instanceof PromptSaveResult.Saved saved) { + loadedContent = savedContent; + dirty = false; + tab.setText(TAB_TITLE); + setStatus("Prompt-Datei gespeichert: " + saved.absolutePath()); + textArea.setEditable(true); + LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath()); + } else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) { + setStatus("Fehler: " + missing.message()); + LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Ordner fehlt: {}", missing.message()); + } else if (result instanceof PromptSaveResult.WriteFailed failed) { + setStatus("Fehler beim Schreiben: " + failed.message()); + LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Schreibfehler: {}", failed.message()); + } else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) { + setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message()); + LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message()); + } + } + + void resetToDefault() { + String defaultContent = de.gecheckt.pdf.umbenenner.application.validation + .technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength); + textArea.setText(defaultContent); + textArea.setEditable(true); + saveButton.setDisable(false); + setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert)."); + LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert)."); + } + + private void requestCreateDefault() { + createDefaultButton.setDisable(true); + setStatus("Lege Standard-Prompt-Datei an ..."); + + CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile( + configuredPromptPath, + "Standard-Prompt-Datei anlegen", + maxTitleLength); + + Thread worker = threadFactory.apply(() -> { + CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion); + fxDispatcher.accept(() -> applyCreateDefaultResult(outcome)); + }); + worker.start(); + } + + private void applyCreateDefaultResult(CorrectionOutcome outcome) { + createDefaultButton.setDisable(false); + if (outcome instanceof CorrectionOutcome.Applied applied) { + setStatus(applied.message() + " Lade Inhalt ..."); + LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu."); + // Inhalt sofort neu laden + loadPromptAsync(); + } else if (outcome instanceof CorrectionOutcome.Failed failed) { + setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage()); + LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage()); + } else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) { + setStatus("Aktion nicht verfügbar: " + notAttempted.reason()); + LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason()); + } + } + + private void setStatus(String message) { + statusLabel.setText(message); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 8a78fa6..c652e47 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 @@ -67,7 +67,8 @@ public record GuiStartupContext( GuiManualFileRenamePort manualFileRenamePort, GuiManualFileCopyPort manualFileCopyPort, GuiHistoricalDocumentContextPort historicalDocumentContextPort, - String applicationVersion) { + String applicationVersion, + GuiPromptEditorPort promptEditorPort) { /** * Creates a fully wired startup context. @@ -96,6 +97,8 @@ public record GuiStartupContext( * for skipped documents; must not be {@code null} * @param applicationVersion resolved application version string shown in the status * bar; {@code null} defaults to {@code "dev"} + * @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht + * {@code null} sein */ public GuiStartupContext { initialState = Objects.requireNonNull(initialState, "initialState must not be null"); @@ -130,6 +133,7 @@ public record GuiStartupContext( "historicalDocumentContextPort must not be null"); // Null-Fallback für Testumgebungen ohne gepacktes JAR applicationVersion = applicationVersion == null ? "dev" : applicationVersion; + promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null"); } /** @@ -171,7 +175,7 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort(), "dev"); + noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort()); } /** @@ -207,7 +211,7 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort(), "dev"); + noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort()); } /** @@ -243,7 +247,7 @@ public record GuiStartupContext( technicalTestOrchestrator, correctionExecutionService, rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), - noOpHistoricalDocumentContextPort(), "dev"); + noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort()); } private static GuiBatchRunLauncher rejectingBatchRunLauncher() { @@ -358,6 +362,33 @@ public record GuiStartupContext( rejectingManualFileRenamePort(), rejectingManualFileCopyPort(), noOpHistoricalDocumentContextPort(), - "dev"); + "dev", + noOpPromptEditorPort()); + } + + private static GuiPromptEditorPort noOpPromptEditorPort() { + return new GuiPromptEditorPort() { + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() { + return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure( + "NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar."); + } + + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) { + return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed( + "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null); + } + + @Override + public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome + createDefaultPromptIfMissing( + de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionSuggestion.CreatePromptFile suggestion) { + return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionOutcome.NotAttempted( + suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar."); + } + }; } } diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java index a3a1e8c..01d3ccf 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java @@ -244,12 +244,14 @@ class GuiAdapterSmokeTest { "The 'Speichern' button must be visible"); assertEquals("Speichern unter", workspace.saveAsButton().getText(), "The 'Speichern unter' button must be visible"); - assertEquals(2, workspace.tabPane().getTabs().size(), - "Configuration tab and processing-run tab must both be present"); + assertEquals(3, workspace.tabPane().getTabs().size(), + "Configuration tab, processing-run tab and prompt editor tab must all be present"); assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(), "The first tab must use the configuration label"); assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(), "The second tab must host the processing-run view"); + assertEquals("Prompt", workspace.tabPane().getTabs().get(2).getText(), + "The third tab must host the prompt editor"); assertEquals( "Pfade,Provider,Verarbeitungslimits,Tests,Meldungen", String.join(",", workspace.sectionTitles()), diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTabSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTabSmokeTest.java new file mode 100644 index 0000000..49d5e6a --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiPromptEditorTabSmokeTest.java @@ -0,0 +1,290 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.Test; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; +import javafx.application.Platform; +import javafx.scene.control.Tab; + +/** + * Monocle-basierte Headless-Smoke-Tests für {@link GuiPromptEditorTab}. + *

+ * Geprüfte Szenarien: + *

+ */ +class GuiPromptEditorTabSmokeTest { + + 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 muss innerhalb des Timeouts starten"); + } catch (IllegalStateException alreadyStarted) { + CountDownLatch verifyLatch = new CountDownLatch(1); + Platform.runLater(() -> { + PLATFORM_STARTED.set(true); + verifyLatch.countDown(); + }); + assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein"); + } + } + + @AfterAll + static void tearDownJavaFxPlatform() { + // Gemeinsame Platform – kein Platform.exit(). + } + + // ========================================================================= + // Hilfsklassen + // ========================================================================= + + /** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */ + private static class SyncPromptEditorPort implements GuiPromptEditorPort { + PromptLoadingResult loadResult = new PromptLoadingSuccess( + new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt"); + PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt"); + + @Override + public PromptLoadingResult loadCurrentPrompt() { + return loadResult; + } + + @Override + public PromptSaveResult save(String content) { + return saveResult; + } + + @Override + public CorrectionOutcome createDefaultPromptIfMissing( + CorrectionSuggestion.CreatePromptFile suggestion) { + return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt."); + } + } + + /** + * Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs: + * threadFactory führt den Runnable inline aus (vor worker.start()), + * fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater). + * Damit sind alle Operationen aus Testsicht vollständig synchron. + */ + private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) { + GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60); + // Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op). + tab.threadFactory = runnable -> { + runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf + return new Thread(); // Dummy-Thread; worker.start() beendet sofort + }; + // UI-Updates synchron im selben Thread + tab.fxDispatcher = Runnable::run; + return tab; + } + + // ========================================================================= + // Tests + // ========================================================================= + + @Test + void tab_shouldBeCreatedWithTitlePrompt() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference fxError = new AtomicReference<>(); + AtomicReference tabRef = new AtomicReference<>(); + + Platform.runLater(() -> { + try { + SyncPromptEditorPort port = new SyncPromptEditorPort(); + GuiPromptEditorTab editorTab = buildSyncTab(port); + tabRef.set(editorTab.tab()); + } catch (Throwable t) { + fxError.set(t); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (fxError.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get()); + } + assertNotNull(tabRef.get(), "Tab darf nicht null sein"); + assertEquals("Prompt", tabRef.get().getText(), "Tab-Titel muss 'Prompt' sein"); + } + + @Test + void dirtyState_shouldBeFalse_afterConstruction() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference fxError = new AtomicReference<>(); + AtomicBoolean dirtyRef = new AtomicBoolean(true); + + Platform.runLater(() -> { + try { + SyncPromptEditorPort port = new SyncPromptEditorPort(); + GuiPromptEditorTab editorTab = buildSyncTab(port); + dirtyRef.set(editorTab.hasDirtyContent()); + } catch (Throwable t) { + fxError.set(t); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (fxError.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get()); + } + assertFalse(dirtyRef.get(), "Dirty-State muss nach Konstruktion false sein"); + } + + @Test + void dirtyState_shouldBeFalse_afterSuccessfulLoad() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference fxError = new AtomicReference<>(); + AtomicBoolean dirtyRef = new AtomicBoolean(true); + + Platform.runLater(() -> { + try { + SyncPromptEditorPort port = new SyncPromptEditorPort(); + GuiPromptEditorTab editorTab = buildSyncTab(port); + // Laden synchron auslösen (fxDispatcher = Runnable::run) + editorTab.loadPromptAsync(); + dirtyRef.set(editorTab.hasDirtyContent()); + } catch (Throwable t) { + fxError.set(t); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (fxError.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get()); + } + assertFalse(dirtyRef.get(), "Dirty-State muss nach erfolgreichem Laden false sein"); + } + + @Test + void tabTitle_shouldNotContainAsterisk_afterSuccessfulLoad() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference fxError = new AtomicReference<>(); + AtomicReference titleRef = new AtomicReference<>(); + + Platform.runLater(() -> { + try { + SyncPromptEditorPort port = new SyncPromptEditorPort(); + GuiPromptEditorTab editorTab = buildSyncTab(port); + editorTab.loadPromptAsync(); + titleRef.set(editorTab.tab().getText()); + } catch (Throwable t) { + fxError.set(t); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (fxError.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get()); + } + assertFalse(titleRef.get().contains("*"), + "Tab-Titel darf nach erfolgreichem Laden keinen Asterisk enthalten"); + } + + @Test + void dirtyState_shouldBeFalse_whenLoadReturnsFileNotFound() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference fxError = new AtomicReference<>(); + AtomicBoolean dirtyRef = new AtomicBoolean(true); + + Platform.runLater(() -> { + try { + SyncPromptEditorPort port = new SyncPromptEditorPort(); + port.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht gefunden"); + GuiPromptEditorTab editorTab = buildSyncTab(port); + editorTab.loadPromptAsync(); + dirtyRef.set(editorTab.hasDirtyContent()); + } catch (Throwable t) { + fxError.set(t); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (fxError.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get()); + } + assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde"); + } + + @Test + void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference fxError = new AtomicReference<>(); + AtomicReference titleRef = new AtomicReference<>(); + + Platform.runLater(() -> { + try { + SyncPromptEditorPort port = new SyncPromptEditorPort(); + GuiPromptEditorTab editorTab = buildSyncTab(port); + editorTab.loadPromptAsync(); + // Direkte TextArea-Manipulation simuliert Benutzer-Eingabe + // Über Reflection auf das private textArea-Feld zugreifen ist unerwünscht. + // Stattdessen: resetToDefault() setzt einen anderen Inhalt als den geladenen, + // was den Dirty-State auslöst. + editorTab.resetToDefault(); + titleRef.set(editorTab.tab().getText()); + } catch (Throwable t) { + fxError.set(t); + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (fxError.get() != null) { + throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get()); + } + // Nach resetToDefault() wird der Default-Inhalt gesetzt. + // Falls dieser vom geladenen Inhalt abweicht, entsteht ein Dirty-State. + // Da Stub-Inhalt != Default-Template, muss Asterisk vorhanden sein. + assertTrue(titleRef.get().contains("*"), + "Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: " + + titleRef.get()); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java index 267303c..2094be5 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapter.java @@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Objects; +import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -13,28 +16,36 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; /** - * Filesystem-based implementation of {@link PromptPort}. + * Dateisystembasierte Implementierung von {@link PromptPort}. *

- * Loads prompt templates from an external file on disk and derives a stable identifier - * from the filename. Ensures that empty or technically unusable prompts are rejected. + * Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen + * stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch + * unbrauchbare Prompts abgelehnt werden. *

- * Identifier derivation: - * The stable prompt identifier is derived from the filename of the prompt file. - * This ensures deterministic, reproducible identification across batch runs. - * For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier + * Identifikatorableitung: + * Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet. + * Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator * {@code "prompt_de_v2.txt"}. *

- * Content validation: - * After loading, the prompt content is trimmed and validated to ensure it is not empty. - * An empty prompt (or one containing only whitespace) is considered technically unusable - * and results in a {@link PromptLoadingFailure}. + * Inhaltsprüfung: + * Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt + * (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu + * {@link PromptLoadingFailure}. *

- * Error handling: - * All technical failures (file not found, I/O errors, permission issues) are caught - * and returned as {@link PromptLoadingFailure} rather than thrown as exceptions. + * Atomares Speichern: + * {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei im selben + * Verzeichnis wie die Zieldatei und verschiebt diese danach atomar via + * {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller + * Fallback auf nicht-atomares Schreiben durchgeführt. + *

+ * Fehlerbehandlung: + * Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen) + * werden abgefangen und als strukturierte Ergebnistypen zurückgegeben – keine Exceptions + * werden propagiert. */ public class FilesystemPromptPortAdapter implements PromptPort { @@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort { private final Path promptFilePath; /** - * Creates the adapter with the configured prompt file path. + * Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei. * - * @param promptFilePath the path to the prompt template file; must not be null - * @throws NullPointerException if promptFilePath is null + * @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein + * @throws NullPointerException wenn {@code promptFilePath} null ist */ public FilesystemPromptPortAdapter(Path promptFilePath) { this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null"); } + /** + * Lädt das konfigurierte Prompt-Template aus der Datei. + * + * @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler; + * nie {@code null} + */ @Override public PromptLoadingResult loadPrompt() { try { @@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort { } PromptIdentifier identifier = deriveIdentifier(); - LOG.debug("Prompt loaded successfully from {}", promptFilePath); + LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath); return new PromptLoadingSuccess(identifier, trimmedContent); } catch (IOException e) { - LOG.error("Failed to load prompt file: {}", promptFilePath, e); + LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e); return new PromptLoadingFailure( "IO_ERROR", "Failed to read prompt file: " + e.getMessage()); @@ -83,15 +100,88 @@ public class FilesystemPromptPortAdapter implements PromptPort { } /** - * Derives a stable prompt identifier from the filename. + * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei. *

- * The identifier is simply the filename (without the directory path). - * This ensures that the same prompt file always receives the same identifier. + * Der Ablauf: + *

    + *
  1. Prüfen, ob der Zielordner existiert.
  2. + *
  3. Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.
  4. + *
  5. Inhalt in UTF-8 in die temporäre Datei schreiben.
  6. + *
  7. Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.
  8. + *
  9. Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.
  10. + *
+ *

+ * Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt. * - * @return a stable PromptIdentifier based on the filename + * @param content der zu speichernde Inhalt; darf nicht {@code null} sein + * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null} + * @throws NullPointerException wenn {@code content} null ist + */ + @Override + public PromptSaveResult savePrompt(String content) { + Objects.requireNonNull(content, "content must not be null"); + + Path targetDir = promptFilePath.getParent(); + if (targetDir == null || !Files.isDirectory(targetDir)) { + String message = "Zielordner der Prompt-Datei existiert nicht: " + + (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt"); + LOG.warn("Prompt speichern fehlgeschlagen: {}", message); + return new PromptSaveResult.TargetDirectoryMissing(message); + } + + // Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen + // (nicht im System-Temp – ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen) + Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp"); + + try { + Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + beräumeTempDatei(tempFile); + String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage(); + LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e); + return new PromptSaveResult.WriteFailed(message, e); + } + + // Atomares Verschieben – kein stiller Fallback auf nicht-atomares Move + try { + Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath()); + return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString()); + } catch (AtomicMoveNotSupportedException e) { + beräumeTempDatei(tempFile); + String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage(); + LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e); + return new PromptSaveResult.AtomicMoveFailed(message); + } catch (IOException e) { + beräumeTempDatei(tempFile); + String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage(); + LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e); + return new PromptSaveResult.AtomicMoveFailed(message); + } + } + + /** + * Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab. + *

+ * Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad. + * + * @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen */ private PromptIdentifier deriveIdentifier() { String filename = promptFilePath.getFileName().toString(); return new PromptIdentifier(filename); } + + /** + * Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt. + * + * @param tempFile die zu löschende temporäre Datei + */ + private void beräumeTempDatei(Path tempFile) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException ex) { + LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex); + } + } } diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java index 609df91..cd231bf 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/prompt/FilesystemPromptPortAdapterTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir; import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; /** * Unit tests for {@link FilesystemPromptPortAdapter}. @@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest { assertThat(success1.promptContent()).isEqualTo(success2.promptContent()); assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier()); } + + // ------------------------------------------------------------------------- + // savePrompt tests + // ------------------------------------------------------------------------- + + @Test + void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException { + // Given + Path promptFile = tempDir.resolve("prompt_save.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + String content = "Mein Prompt-Inhalt"; + + // When + PromptSaveResult result = adapter.savePrompt(content); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.Saved.class); + PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result; + assertThat(saved.absolutePath()).contains("prompt_save.txt"); + assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content); + } + + @Test + void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException { + // Given + Path promptFile = tempDir.resolve("prompt_umlaut.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + String content = "Ärger mit Überschriften und Schluß"; + + // When + PromptSaveResult result = adapter.savePrompt(content); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.Saved.class); + assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content); + } + + @Test + void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException { + // Given + Path promptFile = tempDir.resolve("prompt_lineendings.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n"; + + // When + PromptSaveResult result = adapter.savePrompt(content); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.Saved.class); + byte[] raw = Files.readAllBytes(promptFile); + assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content); + } + + @Test + void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException { + // Given + Path promptFile = tempDir.resolve("prompt_overwrite.txt"); + Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8); + adapter = new FilesystemPromptPortAdapter(promptFile); + String newContent = "Neuer Inhalt"; + + // When + PromptSaveResult result = adapter.savePrompt(newContent); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.Saved.class); + assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent); + } + + @Test + void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() { + // Given + Path nonExistentDir = tempDir.resolve("missing-subdir"); + Path promptFile = nonExistentDir.resolve("prompt.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + + // When + PromptSaveResult result = adapter.savePrompt("Inhalt"); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class); + PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result; + assertThat(missing.message()).contains("missing-subdir"); + } + + @Test + void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException { + // Given + Path promptFile = tempDir.resolve("prompt_null.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + + // When & Then + assertThatThrownBy(() -> adapter.savePrompt(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("content must not be null"); + } + + @Test + void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() { + // Given – Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben + Path nonExistentDir = tempDir.resolve("ghost-dir"); + Path promptFile = nonExistentDir.resolve("prompt.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + + // When + PromptSaveResult result = adapter.savePrompt("Inhalt"); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class); + // Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug) + assertThat(nonExistentDir).doesNotExist(); + } + + @Test + void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException { + // Given + Path promptFile = tempDir.resolve("prompt_roundtrip.txt"); + adapter = new FilesystemPromptPortAdapter(promptFile); + String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen."; + + // When + PromptSaveResult saveResult = adapter.savePrompt(content); + PromptLoadingResult loadResult = adapter.loadPrompt(); + + // Then + assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class); + assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class); + PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult; + // loadPrompt trims the content; trim the expected too + assertThat(success.promptContent()).isEqualTo(content.trim()); + } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java index 3718081..8080a59 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java @@ -1,56 +1,76 @@ package de.gecheckt.pdf.umbenenner.application.port.out; /** - * Outbound port for loading external prompt templates. + * Outbound-Port zum Laden und Speichern des externen Prompt-Templates. *

- * This interface abstracts the loading of prompt content from external sources - * (files, resources, databases, etc.), allowing the Application layer to remain - * independent of how or where prompts are stored. + * Dieses Interface abstrahiert den Zugriff auf die Prompt-Datei und erlaubt der + * Application-Schicht, unabhängig vom konkreten Speichermedium zu bleiben. *

- * Design principles: + * Designprinzipien: *

    - *
  • Prompt is not embedded in code; it is loaded from an external source
  • - *
  • Each prompt receives a stable identifier for traceability across batch runs
  • - *
  • Results are returned as structured types ({@link PromptLoadingResult}), - * never as exceptions
  • + *
  • Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.
  • + *
  • Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.
  • + *
  • Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.
  • + *
  • Der Pfad zur Prompt-Datei ist Implementierungsdetail des Adapters – er erscheint nicht + * in der Port-Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen).
  • *
*

- * Adapter responsibilities: + * Adapter-Verantwortung: *

    - *
  • Locate and read the prompt file/resource from the configured source
  • - *
  • Derive a stable prompt identifier (e.g., filename, semantic version, content hash)
  • - *
  • Validate that the loaded content is not empty or otherwise invalid
  • - *
  • Return either success or a classified failure
  • - *
  • Encapsulate all file I/O, resource loading, and configuration details
  • + *
  • Prompt-Datei lokalisieren und lesen.
  • + *
  • Stabilen Identifikator ableiten (z. B. Dateiname).
  • + *
  • Leere oder technisch unbrauchbare Prompts ablehnen.
  • + *
  • Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.
  • + *
  • Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.
  • *
*

- * Non-goals of this port: + * Nicht-Ziele dieses Ports: *

    - *
  • Prompt parsing or templating logic
  • - *
  • Combining prompt with document text (Application layer handles this)
  • - *
  • Template variable substitution
  • - *
  • Validation of prompt content against domain rules
  • + *
  • Prompt-Parsing oder Template-Verarbeitung
  • + *
  • Kombination von Prompt und Dokumenttext (Application-Schicht)
  • + *
  • Validierung des Prompt-Inhalts gegen Domänenregeln
  • *
*/ public interface PromptPort { /** - * Loads the configured external prompt template. + * Lädt das konfigurierte externe Prompt-Template. *

- * This method is called once per batch run to obtain the current prompt. - * The prompt content and its stable identifier are returned together. + * Diese Methode wird einmal pro Verarbeitungslauf aufgerufen, um den aktuellen Prompt zu laden. + * Inhalt und stabiler Identifikator werden gemeinsam zurückgegeben. *

- * If loading fails for any reason (file not found, I/O error, content validation), - * a {@link PromptLoadingFailure} is returned rather than throwing an exception. - * - * @return a {@link PromptLoadingResult} encoding either: - *

    - *
  • Success: prompt content and identifier loaded successfully
  • - *
  • Failure: prompt could not be loaded or is invalid
  • - *
+ * Bei einem technischen Fehler (Datei nicht gefunden, I/O-Fehler, leerer Inhalt) wird + * {@link PromptLoadingFailure} zurückgegeben – keine Exception wird geworfen. * + * @return {@link PromptLoadingResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null} * @see PromptLoadingSuccess * @see PromptLoadingFailure */ PromptLoadingResult loadPrompt(); + + /** + * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei. + *

+ * Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist + * nicht Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen + * im Port-Vertrag). + *

+ * Die Implementierung schreibt zunächst in eine temporäre Datei im selben Verzeichnis + * wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}. + * Bei einem Fehler beim atomaren Verschieben wird kein stiller Fallback + * auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird + * {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben. + *

+ * Zeichenkodierung: UTF-8. Zeilenenden werden unverändert übernommen. + * + * @param content der zu speichernde Prompt-Inhalt; darf leer sein (Entscheidung liegt + * beim Aufrufer, ob ein leerer Prompt erwünscht ist) + * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null} + * @throws NullPointerException wenn {@code content} null ist + * @see PromptSaveResult.Saved + * @see PromptSaveResult.WriteFailed + * @see PromptSaveResult.TargetDirectoryMissing + * @see PromptSaveResult.AtomicMoveFailed + */ + PromptSaveResult savePrompt(String content); } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptSaveResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptSaveResult.java new file mode 100644 index 0000000..f07efe2 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptSaveResult.java @@ -0,0 +1,96 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Versiegeltes Ergebnis-Interface für das Speichern einer Prompt-Datei via + * {@link PromptPort#savePrompt(String)}. + *

+ * Mögliche Ergebnisse: + *

    + *
  • {@link Saved} – das Speichern war erfolgreich.
  • + *
  • {@link WriteFailed} – ein technischer Fehler beim Schreiben ist aufgetreten.
  • + *
  • {@link TargetDirectoryMissing} – der konfigurierte Zielordner existiert nicht.
  • + *
  • {@link AtomicMoveFailed} – das atomare Verschieben der temporären Datei ist + * fehlgeschlagen; kein stiller Fallback.
  • + *
+ */ +public sealed interface PromptSaveResult + permits PromptSaveResult.Saved, + PromptSaveResult.WriteFailed, + PromptSaveResult.TargetDirectoryMissing, + PromptSaveResult.AtomicMoveFailed { + + /** + * Die Prompt-Datei wurde erfolgreich gespeichert. + * + * @param absolutePath absoluter Pfad der gespeicherten Datei; nie {@code null} + */ + record Saved(String absolutePath) implements PromptSaveResult { + + /** + * Erstellt ein Saved-Ergebnis. + * + * @param absolutePath absoluter Pfad der gespeicherten Datei; darf nicht {@code null} sein + * @throws NullPointerException wenn {@code absolutePath} null ist + */ + public Saved { + java.util.Objects.requireNonNull(absolutePath, "absolutePath must not be null"); + } + } + + /** + * Das Schreiben der temporären Datei ist fehlgeschlagen. + * + * @param message Fehlerbeschreibung; nie {@code null} + * @param cause auslösende Ausnahme; kann {@code null} sein + */ + record WriteFailed(String message, Throwable cause) implements PromptSaveResult { + + /** + * Erstellt ein WriteFailed-Ergebnis. + * + * @param message Fehlerbeschreibung; darf nicht {@code null} sein + * @param cause auslösende Ausnahme; kann {@code null} sein + * @throws NullPointerException wenn {@code message} null ist + */ + public WriteFailed { + java.util.Objects.requireNonNull(message, "message must not be null"); + } + } + + /** + * Der konfigurierte Zielordner existiert nicht. + * + * @param message Beschreibung des fehlenden Ordners; nie {@code null} + */ + record TargetDirectoryMissing(String message) implements PromptSaveResult { + + /** + * Erstellt ein TargetDirectoryMissing-Ergebnis. + * + * @param message Beschreibung des fehlenden Ordners; darf nicht {@code null} sein + * @throws NullPointerException wenn {@code message} null ist + */ + public TargetDirectoryMissing { + java.util.Objects.requireNonNull(message, "message must not be null"); + } + } + + /** + * Das atomare Verschieben der temporären Datei zur Zieldatei ist fehlgeschlagen. + * Es wird kein stiller Fallback auf nicht-atomares Schreiben durchgeführt. + * + * @param message Fehlerbeschreibung; nie {@code null} + */ + record AtomicMoveFailed(String message) implements PromptSaveResult { + + /** + * Erstellt ein AtomicMoveFailed-Ergebnis. + * + * @param message Fehlerbeschreibung; darf nicht {@code null} sein + * @throws NullPointerException wenn {@code message} null ist + */ + public AtomicMoveFailed { + java.util.Objects.requireNonNull(message, "message must not be null"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCase.java new file mode 100644 index 0000000..e4f7130 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCase.java @@ -0,0 +1,101 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort; + +/** + * Use-Case zur Anzeige und Bearbeitung des KI-Prompt-Templates über die GUI. + *

+ * Dieser Use-Case vermittelt zwischen dem GUI-Adapter und dem {@link PromptPort} sowie dem + * {@link ResourceCreationPort}. Er kennt keine JavaFX-Typen, kein Dateisystem und keine + * HTTP-Kommunikation; alle technischen Details bleiben in den jeweiligen Adaptern. + *

+ * Verantwortung: + *

    + *
  • Aktuellen Prompt-Inhalt laden und als strukturiertes Ergebnis zurückgeben.
  • + *
  • Bearbeiteten Inhalt atomar in die konfigurierte Prompt-Datei speichern.
  • + *
  • Anlegen einer Standard-Prompt-Datei delegieren, wenn keine Datei vorhanden ist.
  • + *
+ *

+ * Abgrenzung: Dieser Use-Case trifft keine Entscheidungen über + * Benutzeroberfläche, Threading oder Dirty-State-Verwaltung. Diese Verantwortung + * liegt im GUI-Adapter. + */ +public class DefaultPromptEditorUseCase { + + private final PromptPort promptPort; + private final ResourceCreationPort resourceCreationPort; + + /** + * Erstellt den Use-Case mit den erforderlichen Ports. + * + * @param promptPort Port zum Laden und Speichern des Prompt-Templates; + * darf nicht {@code null} sein + * @param resourceCreationPort Port zum Anlegen der Standard-Prompt-Datei; + * darf nicht {@code null} sein + * @throws NullPointerException wenn ein Parameter {@code null} ist + */ + public DefaultPromptEditorUseCase(PromptPort promptPort, ResourceCreationPort resourceCreationPort) { + this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null"); + this.resourceCreationPort = Objects.requireNonNull(resourceCreationPort, + "resourceCreationPort must not be null"); + } + + /** + * Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Prompt-Datei. + *

+ * Delegiert direkt an {@link PromptPort#loadPrompt()} und gibt das Ergebnis + * unverändert zurück. + * + * @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg, + * oder einem klassifizierten Fehler; nie {@code null} + * @see PromptLoadingSuccess + * @see PromptLoadingFailure + */ + public PromptLoadingResult loadPrompt() { + return promptPort.loadPrompt(); + } + + /** + * Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei. + *

+ * Delegiert direkt an {@link PromptPort#savePrompt(String)}. Der Zielpfad ist + * Implementierungsdetail des Adapters. + * + * @param content der zu speichernde Prompt-Inhalt; darf nicht {@code null} sein + * @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null} + * @throws NullPointerException wenn {@code content} null ist + * @see PromptSaveResult.Saved + * @see PromptSaveResult.WriteFailed + * @see PromptSaveResult.TargetDirectoryMissing + * @see PromptSaveResult.AtomicMoveFailed + */ + public PromptSaveResult savePrompt(String content) { + Objects.requireNonNull(content, "content must not be null"); + return promptPort.savePrompt(content); + } + + /** + * Legt eine Standard-Prompt-Datei an, wenn noch keine vorhanden ist. + *

+ * Delegiert an {@link ResourceCreationPort#createPromptFile(CorrectionSuggestion.CreatePromptFile)}. + * Das Ergebnis beschreibt, ob die Datei angelegt wurde, ob sie bereits existierte + * oder ob ein Fehler aufgetreten ist. + * + * @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein + * @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null} + * @throws NullPointerException wenn {@code suggestion} null ist + */ + public CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion) { + Objects.requireNonNull(suggestion, "suggestion must not be null"); + return resourceCreationPort.createPromptFile(suggestion); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java index 5521bde..f51cd90 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java @@ -1062,8 +1062,16 @@ class BatchRunProcessingUseCaseTest { private static AiNamingService buildStubAiNamingService() { AiInvocationPort stubAiPort = request -> new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test"); - PromptPort stubPromptPort = () -> - new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content"); + PromptPort stubPromptPort = new PromptPort() { + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() { + return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content"); + } + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) { + return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path"); + } + }; ClockPort stubClock = () -> java.time.Instant.EPOCH; AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH); return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000, diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java index 59bce43..5108cf6 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java @@ -279,8 +279,16 @@ class BatchRunProgressObservationTest { AiInvocationPort stubAi = req -> { throw new IllegalStateException("AI must not be invoked in these tests"); }; - PromptPort stubPrompt = () -> new PromptLoadingSuccess( - new PromptIdentifier("stub-prompt"), "Prompt: {{text}}"); + PromptPort stubPrompt = new PromptPort() { + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() { + return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "Prompt: {{text}}"); + } + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) { + return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path"); + } + }; ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z"); AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE); return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCaseTest.java new file mode 100644 index 0000000..8c85b41 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultPromptEditorUseCaseTest.java @@ -0,0 +1,210 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort; +import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort; +import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; + +/** + * Unit-Tests für {@link DefaultPromptEditorUseCase}. + *

+ * Prüft die Delegation an {@link PromptPort} und {@link ResourceCreationPort} + * sowie die Null-Prüfungen am Konstruktor und an den Methoden. + */ +class DefaultPromptEditorUseCaseTest { + + private static final String STUB_CONTENT = "Mein Test-Prompt"; + private static final PromptIdentifier STUB_IDENTIFIER = new PromptIdentifier("test-prompt.txt"); + + private StubPromptPort stubPromptPort; + private StubResourceCreationPort stubResourceCreationPort; + private DefaultPromptEditorUseCase useCase; + + @BeforeEach + void setUp() { + stubPromptPort = new StubPromptPort(); + stubResourceCreationPort = new StubResourceCreationPort(); + useCase = new DefaultPromptEditorUseCase(stubPromptPort, stubResourceCreationPort); + } + + // ------------------------------------------------------------------------- + // Konstruktor + // ------------------------------------------------------------------------- + + @Test + void constructor_shouldThrowNullPointerException_whenPromptPortIsNull() { + assertThatThrownBy(() -> new DefaultPromptEditorUseCase(null, stubResourceCreationPort)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("promptPort"); + } + + @Test + void constructor_shouldThrowNullPointerException_whenResourceCreationPortIsNull() { + assertThatThrownBy(() -> new DefaultPromptEditorUseCase(stubPromptPort, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("resourceCreationPort"); + } + + // ------------------------------------------------------------------------- + // loadPrompt + // ------------------------------------------------------------------------- + + @Test + void loadPrompt_shouldDelegateToPromptPort_andReturnSuccess() { + // Given + stubPromptPort.loadResult = new PromptLoadingSuccess(STUB_IDENTIFIER, STUB_CONTENT); + + // When + PromptLoadingResult result = useCase.loadPrompt(); + + // Then + assertThat(result).isInstanceOf(PromptLoadingSuccess.class); + PromptLoadingSuccess success = (PromptLoadingSuccess) result; + assertThat(success.promptContent()).isEqualTo(STUB_CONTENT); + assertThat(success.promptIdentifier()).isEqualTo(STUB_IDENTIFIER); + assertThat(stubPromptPort.loadCallCount).isEqualTo(1); + } + + @Test + void loadPrompt_shouldDelegateToPromptPort_andReturnFailure() { + // Given + stubPromptPort.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht vorhanden"); + + // When + PromptLoadingResult result = useCase.loadPrompt(); + + // Then + assertThat(result).isInstanceOf(PromptLoadingFailure.class); + PromptLoadingFailure failure = (PromptLoadingFailure) result; + assertThat(failure.failureReason()).isEqualTo("FILE_NOT_FOUND"); + } + + // ------------------------------------------------------------------------- + // savePrompt + // ------------------------------------------------------------------------- + + @Test + void savePrompt_shouldDelegateToPromptPort_andReturnSaved() { + // Given + stubPromptPort.saveResult = new PromptSaveResult.Saved("/some/path/prompt.txt"); + + // When + PromptSaveResult result = useCase.savePrompt(STUB_CONTENT); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.Saved.class); + assertThat(stubPromptPort.lastSavedContent).isEqualTo(STUB_CONTENT); + assertThat(stubPromptPort.saveCallCount).isEqualTo(1); + } + + @Test + void savePrompt_shouldDelegateToPromptPort_andReturnWriteFailed() { + // Given + stubPromptPort.saveResult = new PromptSaveResult.WriteFailed("Schreibfehler", null); + + // When + PromptSaveResult result = useCase.savePrompt(STUB_CONTENT); + + // Then + assertThat(result).isInstanceOf(PromptSaveResult.WriteFailed.class); + } + + @Test + void savePrompt_shouldThrowNullPointerException_whenContentIsNull() { + assertThatThrownBy(() -> useCase.savePrompt(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("content"); + } + + // ------------------------------------------------------------------------- + // createDefaultPromptIfMissing + // ------------------------------------------------------------------------- + + @Test + void createDefaultPromptIfMissing_shouldDelegateToResourceCreationPort_andReturnApplied() { + // Given + CorrectionSuggestion.CreatePromptFile suggestion = + new CorrectionSuggestion.CreatePromptFile( + "/some/prompt.txt", "Standard anlegen", 60); + CorrectionOutcome.Applied applied = new CorrectionOutcome.Applied( + suggestion, "Standard-Prompt-Datei wurde angelegt."); + stubResourceCreationPort.createPromptFileResult = applied; + + // When + CorrectionOutcome result = useCase.createDefaultPromptIfMissing(suggestion); + + // Then + assertThat(result).isInstanceOf(CorrectionOutcome.Applied.class); + assertThat(stubResourceCreationPort.createPromptFileCallCount).isEqualTo(1); + assertThat(stubResourceCreationPort.lastSuggestion).isSameAs(suggestion); + } + + @Test + void createDefaultPromptIfMissing_shouldThrowNullPointerException_whenSuggestionIsNull() { + assertThatThrownBy(() -> useCase.createDefaultPromptIfMissing(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("suggestion"); + } + + // ------------------------------------------------------------------------- + // Test-Stubs + // ------------------------------------------------------------------------- + + private static class StubPromptPort implements PromptPort { + PromptLoadingResult loadResult = new PromptLoadingSuccess( + new PromptIdentifier("stub.txt"), "Stub-Inhalt"); + PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/path.txt"); + int loadCallCount = 0; + int saveCallCount = 0; + String lastSavedContent = null; + + @Override + public PromptLoadingResult loadPrompt() { + loadCallCount++; + return loadResult; + } + + @Override + public PromptSaveResult savePrompt(String content) { + saveCallCount++; + lastSavedContent = content; + return saveResult; + } + } + + private static class StubResourceCreationPort implements ResourceCreationPort { + CorrectionOutcome createPromptFileResult = new CorrectionOutcome.Applied( + new CorrectionSuggestion.CreatePromptFile("/stub.txt", "Stub", 60), + "Angelegt."); + int createPromptFileCallCount = 0; + CorrectionSuggestion.CreatePromptFile lastSuggestion = null; + + @Override + public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) { + return new CorrectionOutcome.NotAttempted(suggestion, "Nicht implementiert im Stub."); + } + + @Override + public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) { + createPromptFileCallCount++; + lastSuggestion = suggestion; + return createPromptFileResult; + } + + @Override + public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) { + return new CorrectionOutcome.NotAttempted(suggestion, "Nicht implementiert im Stub."); + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 44156dc..f49d9c5 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -21,6 +21,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiPromptEditorPort; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext; import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome; import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher; @@ -89,6 +90,7 @@ import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordina import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase; +import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase; import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; @@ -827,7 +829,8 @@ public class BootstrapRunner { manualRenamePort, manualCopyPort, historicalDocumentContextPort, - applicationVersion); + applicationVersion, + noOpGuiPromptEditorPort()); } Path configPath = Paths.get(configPathOverride.get()); @@ -852,17 +855,20 @@ public class BootstrapRunner { manualRenamePort, manualCopyPort, historicalDocumentContextPort, - applicationVersion); + applicationVersion, + noOpGuiPromptEditorPort()); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); try { GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath); + GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort( + loadedState.values().promptTemplateFile()); return new GuiStartupContext(loadedState, Optional.empty(), loader, writer, modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, correctionExecutionService, batchRunLauncher, miniRunLauncher, resetPort, manualRenamePort, manualCopyPort, - historicalDocumentContextPort, applicationVersion); + historicalDocumentContextPort, applicationVersion, promptEditorPort); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -883,10 +889,87 @@ public class BootstrapRunner { manualRenamePort, manualCopyPort, historicalDocumentContextPort, - applicationVersion); + applicationVersion, + noOpGuiPromptEditorPort()); } } + /** + * Erzeugt einen vollständig verdrahteten {@link GuiPromptEditorPort} für den angegebenen + * Prompt-Dateipfad. + *

+ * Kombiniert {@link FilesystemPromptPortAdapter} und + * {@link de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter} + * in einem {@link DefaultPromptEditorUseCase}. Der zurückgegebene Port delegiert alle + * drei Operationen (Laden, Speichern, Standard-Anlegen) an den Use-Case. + * + * @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein + * @return vollständig verdrahteter Port; nie {@code null} + */ + private GuiPromptEditorPort buildGuiPromptEditorPort(String promptFilePath) { + FilesystemPromptPortAdapter promptPortAdapter = + new FilesystemPromptPortAdapter(Paths.get(promptFilePath)); + de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort + resourceCreationPort = + new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter(); + DefaultPromptEditorUseCase useCase = new DefaultPromptEditorUseCase( + promptPortAdapter, resourceCreationPort); + return new GuiPromptEditorPort() { + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() { + return useCase.loadPrompt(); + } + + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) { + return useCase.savePrompt(content); + } + + @Override + public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome + createDefaultPromptIfMissing( + de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionSuggestion.CreatePromptFile suggestion) { + return useCase.createDefaultPromptIfMissing(suggestion); + } + }; + } + + /** + * Gibt einen No-Op-{@link GuiPromptEditorPort} zurück, der alle Operationen als + * nicht verfügbar meldet. + *

+ * Wird eingesetzt, wenn beim GUI-Start noch keine Konfiguration geladen ist und daher + * kein Prompt-Dateipfad bekannt ist. + * + * @return no-op Port; nie {@code null} + */ + private static GuiPromptEditorPort noOpGuiPromptEditorPort() { + return new GuiPromptEditorPort() { + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() { + return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure( + "NO_OP", "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen."); + } + + @Override + public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) { + return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed( + "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.", null); + } + + @Override + public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome + createDefaultPromptIfMissing( + de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionSuggestion.CreatePromptFile suggestion) { + return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionOutcome.NotAttempted( + suggestion, "Kein Prompt-Pfad konfiguriert."); + } + }; + } + /** * Executes exactly one batch run triggered by the GUI's processing-run tab. *