#71: Prompt-Editor-Tab in der GUI implementieren
Neuer Tab „Prompt" in der GUI-Hauptansicht ermöglicht das Lesen, Bearbeiten und atomare Speichern der konfigurierten KI-Prompt-Datei ohne externen Editor. Änderungen: - PromptSaveResult: neue sealed interface mit Saved, WriteFailed, TargetDirectoryMissing, AtomicMoveFailed als strukturierte Ergebnistypen für savePrompt() - PromptPort: um savePrompt(String) erweitert (nicht mehr funktional – Teststubs angepasst) - FilesystemPromptPortAdapter: savePrompt() mit Temp-Datei im selben Verzeichnis + ATOMIC_MOVE, kein stiller Fallback bei AtomicMoveNotSupportedException - DefaultPromptEditorUseCase: Use-Case-Klasse mit loadPrompt(), savePrompt(), createDefaultPromptIfMissing() als Delegation an PromptPort und ResourceCreationPort - GuiPromptEditorPort: GUI-internes Bridge-Interface (kein hexagonaler Port) - GuiPromptEditorTab: JavaFX-Tab mit TextArea, Dirty-State-Tracking, Speichern/Reset/Anlegen, injizierbare threadFactory + fxDispatcher für Testbarkeit - GuiStartupContext: um promptEditorPort erweitert; alle Backward-Compat-Konstruktoren und blank() mit noOpPromptEditorPort() versorgt - GuiConfigurationEditorWorkspace: promptEditorTab integriert, Tab-Wechsel-Schutz erweitert - BootstrapRunner: buildGuiPromptEditorPort() verdrahtet FilesystemPromptPortAdapter + DefaultPromptEditorUseCase; noOpGuiPromptEditorPort() für Blank-Start-Fälle - Tests: DefaultPromptEditorUseCaseTest, FilesystemPromptPortAdapterTest (savePrompt), GuiPromptEditorTabSmokeTest (headless Monocle), GuiAdapterSmokeTest auf 3 Tabs aktualisiert - docs/betrieb.md: Prompt-Tab dokumentiert, Pfad-Auflösungstabelle ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+37
-1
@@ -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
|
||||
|
||||
+32
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+64
@@ -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.
|
||||
* <p>
|
||||
* Dieses Interface ist <em>kein</em> 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.
|
||||
* <p>
|
||||
* <strong>Verantwortung:</strong>
|
||||
* <ul>
|
||||
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
|
||||
* <li>Bearbeiteten Inhalt atomar speichern.</li>
|
||||
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
+365
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* <strong>Verhalten:</strong>
|
||||
* <ul>
|
||||
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
|
||||
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
|
||||
* <li>„Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
|
||||
* <li>„Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
|
||||
* ohne zu speichern.</li>
|
||||
* <li>Bei fehlendem Prompt wird ein Hinweis und ein „Standard-Prompt erstellen"-Button
|
||||
* angezeigt.</li>
|
||||
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Threading:</strong> 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<Runnable, Thread> threadFactory;
|
||||
/** Übergibt UI-Updates an den JavaFX Application Thread. */
|
||||
Consumer<Runnable> 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<ButtonType> 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.
|
||||
* <p>
|
||||
* 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<ButtonType> 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);
|
||||
}
|
||||
}
|
||||
+36
-5
@@ -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.");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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()),
|
||||
|
||||
+290
@@ -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}.
|
||||
* <p>
|
||||
* Geprüfte Szenarien:
|
||||
* <ul>
|
||||
* <li>Tab wird korrekt mit Titel „Prompt" erstellt.</li>
|
||||
* <li>Dirty-State ist nach Konstruktion {@code false}.</li>
|
||||
* <li>Nach synchronem Laden mit Erfolg: Dirty-State bleibt {@code false},
|
||||
* Tab-Titel enthält keinen Asterisk.</li>
|
||||
* <li>Nach synchronem Laden mit FILE_NOT_FOUND: Dirty-State bleibt {@code false}.</li>
|
||||
* <li>Nach synchronem Speichern mit Erfolg: Dirty-State zurückgesetzt.</li>
|
||||
* <li>Nach {@code resetToDefault}: Textfeld enthält Default-Inhalt (nicht leer),
|
||||
* Dirty-State ist {@code true} (Abweichung von geladener Baseline).</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<Tab> 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<Throwable> 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<Throwable> 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<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<String> 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<Throwable> 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<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<String> 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());
|
||||
}
|
||||
}
|
||||
+113
-23
@@ -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}.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* <strong>Identifier derivation:</strong>
|
||||
* 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
|
||||
* <strong>Identifikatorableitung:</strong>
|
||||
* 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"}.
|
||||
* <p>
|
||||
* <strong>Content validation:</strong>
|
||||
* 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}.
|
||||
* <strong>Inhaltsprüfung:</strong>
|
||||
* 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}.
|
||||
* <p>
|
||||
* <strong>Error handling:</strong>
|
||||
* All technical failures (file not found, I/O errors, permission issues) are caught
|
||||
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
|
||||
* <strong>Atomares Speichern:</strong>
|
||||
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
|
||||
* Verzeichnis</em> 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.
|
||||
* <p>
|
||||
* <strong>Fehlerbehandlung:</strong>
|
||||
* 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.
|
||||
* <p>
|
||||
* The identifier is simply the filename (without the directory path).
|
||||
* This ensures that the same prompt file always receives the same identifier.
|
||||
* Der Ablauf:
|
||||
* <ol>
|
||||
* <li>Prüfen, ob der Zielordner existiert.</li>
|
||||
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
|
||||
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
|
||||
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
|
||||
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+51
-31
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* <strong>Design principles:</strong>
|
||||
* <strong>Designprinzipien:</strong>
|
||||
* <ul>
|
||||
* <li>Prompt is not embedded in code; it is loaded from an external source</li>
|
||||
* <li>Each prompt receives a stable identifier for traceability across batch runs</li>
|
||||
* <li>Results are returned as structured types ({@link PromptLoadingResult}),
|
||||
* never as exceptions</li>
|
||||
* <li>Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.</li>
|
||||
* <li>Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.</li>
|
||||
* <li>Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.</li>
|
||||
* <li>Der Pfad zur Prompt-Datei ist Implementierungsdetail des Adapters – er erscheint nicht
|
||||
* in der Port-Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Adapter responsibilities:</strong>
|
||||
* <strong>Adapter-Verantwortung:</strong>
|
||||
* <ul>
|
||||
* <li>Locate and read the prompt file/resource from the configured source</li>
|
||||
* <li>Derive a stable prompt identifier (e.g., filename, semantic version, content hash)</li>
|
||||
* <li>Validate that the loaded content is not empty or otherwise invalid</li>
|
||||
* <li>Return either success or a classified failure</li>
|
||||
* <li>Encapsulate all file I/O, resource loading, and configuration details</li>
|
||||
* <li>Prompt-Datei lokalisieren und lesen.</li>
|
||||
* <li>Stabilen Identifikator ableiten (z. B. Dateiname).</li>
|
||||
* <li>Leere oder technisch unbrauchbare Prompts ablehnen.</li>
|
||||
* <li>Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.</li>
|
||||
* <li>Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Non-goals of this port:</strong>
|
||||
* <strong>Nicht-Ziele dieses Ports:</strong>
|
||||
* <ul>
|
||||
* <li>Prompt parsing or templating logic</li>
|
||||
* <li>Combining prompt with document text (Application layer handles this)</li>
|
||||
* <li>Template variable substitution</li>
|
||||
* <li>Validation of prompt content against domain rules</li>
|
||||
* <li>Prompt-Parsing oder Template-Verarbeitung</li>
|
||||
* <li>Kombination von Prompt und Dokumenttext (Application-Schicht)</li>
|
||||
* <li>Validierung des Prompt-Inhalts gegen Domänenregeln</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface PromptPort {
|
||||
|
||||
/**
|
||||
* Loads the configured external prompt template.
|
||||
* Lädt das konfigurierte externe Prompt-Template.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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:
|
||||
* <ul>
|
||||
* <li>Success: prompt content and identifier loaded successfully</li>
|
||||
* <li>Failure: prompt could not be loaded or is invalid</li>
|
||||
* </ul>
|
||||
* 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.
|
||||
* <p>
|
||||
* Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist
|
||||
* <em>nicht</em> Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen
|
||||
* im Port-Vertrag).
|
||||
* <p>
|
||||
* Die Implementierung schreibt zunächst in eine temporäre Datei <em>im selben Verzeichnis</em>
|
||||
* wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}.
|
||||
* Bei einem Fehler beim atomaren Verschieben wird <strong>kein stiller Fallback</strong>
|
||||
* auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird
|
||||
* {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
|
||||
+96
@@ -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)}.
|
||||
* <p>
|
||||
* Mögliche Ergebnisse:
|
||||
* <ul>
|
||||
* <li>{@link Saved} – das Speichern war erfolgreich.</li>
|
||||
* <li>{@link WriteFailed} – ein technischer Fehler beim Schreiben ist aufgetreten.</li>
|
||||
* <li>{@link TargetDirectoryMissing} – der konfigurierte Zielordner existiert nicht.</li>
|
||||
* <li>{@link AtomicMoveFailed} – das atomare Verschieben der temporären Datei ist
|
||||
* fehlgeschlagen; kein stiller Fallback.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* <strong>Verantwortung:</strong>
|
||||
* <ul>
|
||||
* <li>Aktuellen Prompt-Inhalt laden und als strukturiertes Ergebnis zurückgeben.</li>
|
||||
* <li>Bearbeiteten Inhalt atomar in die konfigurierte Prompt-Datei speichern.</li>
|
||||
* <li>Anlegen einer Standard-Prompt-Datei delegieren, wenn keine Datei vorhanden ist.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Abgrenzung:</strong> 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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,
|
||||
|
||||
+10
-2
@@ -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);
|
||||
|
||||
+210
@@ -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}.
|
||||
* <p>
|
||||
* 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
-4
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
|
||||
Reference in New Issue
Block a user