#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
|
### 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
|
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||||
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
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**
|
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.
|
ü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.
|
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
|
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
|
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
|
- einen Dokumenttext-Abschnitt
|
||||||
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
|
- 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
|
## Zielformat
|
||||||
|
|||||||
+32
-1
@@ -414,6 +414,13 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
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
|
* 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
|
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
||||||
@@ -421,6 +428,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiBatchRunTab batchRunTab;
|
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
|
* 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
|
* 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.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
||||||
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
||||||
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
||||||
|
this.promptEditorPort = effectiveContext.promptEditorPort();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
() -> this.miniRunLauncher,
|
() -> this.miniRunLauncher,
|
||||||
@@ -504,6 +518,17 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this::editorSourceFolder,
|
this::editorSourceFolder,
|
||||||
this::editorTargetFolder);
|
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();
|
configureRoot();
|
||||||
configureHeader(effectiveContext.startupNotice());
|
configureHeader(effectiveContext.startupNotice());
|
||||||
configureTabs();
|
configureTabs();
|
||||||
@@ -1271,11 +1296,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
scrollPane.setPadding(new Insets(0));
|
scrollPane.setPadding(new Insets(0));
|
||||||
editorTab.setContent(scrollPane);
|
editorTab.setContent(scrollPane);
|
||||||
|
|
||||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
|
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab());
|
||||||
root.setCenter(tabPane);
|
root.setCenter(tabPane);
|
||||||
|
|
||||||
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
||||||
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
||||||
|
// Gleiches gilt für den Prompt-Tab.
|
||||||
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
||||||
if (oldTab == null || newTab == null) {
|
if (oldTab == null || newTab == null) {
|
||||||
return;
|
return;
|
||||||
@@ -1287,6 +1313,11 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
// Zurück zum Verarbeitungslauf-Tab
|
// Zurück zum Verarbeitungslauf-Tab
|
||||||
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
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,
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
GuiManualFileCopyPort manualFileCopyPort,
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
String applicationVersion) {
|
String applicationVersion,
|
||||||
|
GuiPromptEditorPort promptEditorPort) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -96,6 +97,8 @@ public record GuiStartupContext(
|
|||||||
* for skipped documents; must not be {@code null}
|
* for skipped documents; must not be {@code null}
|
||||||
* @param applicationVersion resolved application version string shown in the status
|
* @param applicationVersion resolved application version string shown in the status
|
||||||
* bar; {@code null} defaults to {@code "dev"}
|
* bar; {@code null} defaults to {@code "dev"}
|
||||||
|
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
*/
|
*/
|
||||||
public GuiStartupContext {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
@@ -130,6 +133,7 @@ public record GuiStartupContext(
|
|||||||
"historicalDocumentContextPort must not be null");
|
"historicalDocumentContextPort must not be null");
|
||||||
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||||
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||||
|
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +175,7 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev");
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,7 +211,7 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev");
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,7 +247,7 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev");
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -358,6 +362,33 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileRenamePort(),
|
rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(),
|
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");
|
"The 'Speichern' button must be visible");
|
||||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||||
"The 'Speichern unter' button must be visible");
|
"The 'Speichern unter' button must be visible");
|
||||||
assertEquals(2, workspace.tabPane().getTabs().size(),
|
assertEquals(3, workspace.tabPane().getTabs().size(),
|
||||||
"Configuration tab and processing-run tab must both be present");
|
"Configuration tab, processing-run tab and prompt editor tab must all be present");
|
||||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||||
"The first tab must use the configuration label");
|
"The first tab must use the configuration label");
|
||||||
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||||
"The second tab must host the processing-run view");
|
"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(
|
assertEquals(
|
||||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||||
String.join(",", workspace.sectionTitles()),
|
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.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
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.PromptLoadingResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
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.PromptPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filesystem-based implementation of {@link PromptPort}.
|
* Dateisystembasierte Implementierung von {@link PromptPort}.
|
||||||
* <p>
|
* <p>
|
||||||
* Loads prompt templates from an external file on disk and derives a stable identifier
|
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
|
||||||
* from the filename. Ensures that empty or technically unusable prompts are rejected.
|
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
|
||||||
|
* unbrauchbare Prompts abgelehnt werden.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Identifier derivation:</strong>
|
* <strong>Identifikatorableitung:</strong>
|
||||||
* The stable prompt identifier is derived from the filename of the prompt file.
|
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
|
||||||
* This ensures deterministic, reproducible identification across batch runs.
|
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
|
||||||
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
|
|
||||||
* {@code "prompt_de_v2.txt"}.
|
* {@code "prompt_de_v2.txt"}.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Content validation:</strong>
|
* <strong>Inhaltsprüfung:</strong>
|
||||||
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
|
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
|
||||||
* An empty prompt (or one containing only whitespace) is considered technically unusable
|
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
|
||||||
* and results in a {@link PromptLoadingFailure}.
|
* {@link PromptLoadingFailure}.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Error handling:</strong>
|
* <strong>Atomares Speichern:</strong>
|
||||||
* All technical failures (file not found, I/O errors, permission issues) are caught
|
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
|
||||||
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
|
* 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 {
|
public class FilesystemPromptPortAdapter implements PromptPort {
|
||||||
|
|
||||||
@@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
private final Path promptFilePath;
|
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
|
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
|
||||||
* @throws NullPointerException if promptFilePath is null
|
* @throws NullPointerException wenn {@code promptFilePath} null ist
|
||||||
*/
|
*/
|
||||||
public FilesystemPromptPortAdapter(Path promptFilePath) {
|
public FilesystemPromptPortAdapter(Path promptFilePath) {
|
||||||
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
|
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
|
@Override
|
||||||
public PromptLoadingResult loadPrompt() {
|
public PromptLoadingResult loadPrompt() {
|
||||||
try {
|
try {
|
||||||
@@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PromptIdentifier identifier = deriveIdentifier();
|
PromptIdentifier identifier = deriveIdentifier();
|
||||||
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
|
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
|
||||||
return new PromptLoadingSuccess(identifier, trimmedContent);
|
return new PromptLoadingSuccess(identifier, trimmedContent);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} 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(
|
return new PromptLoadingFailure(
|
||||||
"IO_ERROR",
|
"IO_ERROR",
|
||||||
"Failed to read prompt file: " + e.getMessage());
|
"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>
|
* <p>
|
||||||
* The identifier is simply the filename (without the directory path).
|
* Der Ablauf:
|
||||||
* This ensures that the same prompt file always receives the same identifier.
|
* <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() {
|
private PromptIdentifier deriveIdentifier() {
|
||||||
String filename = promptFilePath.getFileName().toString();
|
String filename = promptFilePath.getFileName().toString();
|
||||||
return new PromptIdentifier(filename);
|
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.PromptLoadingFailure;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
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.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for {@link FilesystemPromptPortAdapter}.
|
* Unit tests for {@link FilesystemPromptPortAdapter}.
|
||||||
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
|
|||||||
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
|
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
|
||||||
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
|
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;
|
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>
|
* <p>
|
||||||
* This interface abstracts the loading of prompt content from external sources
|
* Dieses Interface abstrahiert den Zugriff auf die Prompt-Datei und erlaubt der
|
||||||
* (files, resources, databases, etc.), allowing the Application layer to remain
|
* Application-Schicht, unabhängig vom konkreten Speichermedium zu bleiben.
|
||||||
* independent of how or where prompts are stored.
|
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Design principles:</strong>
|
* <strong>Designprinzipien:</strong>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Prompt is not embedded in code; it is loaded from an external source</li>
|
* <li>Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.</li>
|
||||||
* <li>Each prompt receives a stable identifier for traceability across batch runs</li>
|
* <li>Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.</li>
|
||||||
* <li>Results are returned as structured types ({@link PromptLoadingResult}),
|
* <li>Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.</li>
|
||||||
* never as 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>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Adapter responsibilities:</strong>
|
* <strong>Adapter-Verantwortung:</strong>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Locate and read the prompt file/resource from the configured source</li>
|
* <li>Prompt-Datei lokalisieren und lesen.</li>
|
||||||
* <li>Derive a stable prompt identifier (e.g., filename, semantic version, content hash)</li>
|
* <li>Stabilen Identifikator ableiten (z. B. Dateiname).</li>
|
||||||
* <li>Validate that the loaded content is not empty or otherwise invalid</li>
|
* <li>Leere oder technisch unbrauchbare Prompts ablehnen.</li>
|
||||||
* <li>Return either success or a classified failure</li>
|
* <li>Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.</li>
|
||||||
* <li>Encapsulate all file I/O, resource loading, and configuration details</li>
|
* <li>Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Non-goals of this port:</strong>
|
* <strong>Nicht-Ziele dieses Ports:</strong>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Prompt parsing or templating logic</li>
|
* <li>Prompt-Parsing oder Template-Verarbeitung</li>
|
||||||
* <li>Combining prompt with document text (Application layer handles this)</li>
|
* <li>Kombination von Prompt und Dokumenttext (Application-Schicht)</li>
|
||||||
* <li>Template variable substitution</li>
|
* <li>Validierung des Prompt-Inhalts gegen Domänenregeln</li>
|
||||||
* <li>Validation of prompt content against domain rules</li>
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public interface PromptPort {
|
public interface PromptPort {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the configured external prompt template.
|
* Lädt das konfigurierte externe Prompt-Template.
|
||||||
* <p>
|
* <p>
|
||||||
* This method is called once per batch run to obtain the current prompt.
|
* Diese Methode wird einmal pro Verarbeitungslauf aufgerufen, um den aktuellen Prompt zu laden.
|
||||||
* The prompt content and its stable identifier are returned together.
|
* Inhalt und stabiler Identifikator werden gemeinsam zurückgegeben.
|
||||||
* <p>
|
* <p>
|
||||||
* If loading fails for any reason (file not found, I/O error, content validation),
|
* Bei einem technischen Fehler (Datei nicht gefunden, I/O-Fehler, leerer Inhalt) wird
|
||||||
* a {@link PromptLoadingFailure} is returned rather than throwing an exception.
|
* {@link PromptLoadingFailure} zurückgegeben – keine Exception wird geworfen.
|
||||||
*
|
|
||||||
* @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>
|
|
||||||
*
|
*
|
||||||
|
* @return {@link PromptLoadingResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
* @see PromptLoadingSuccess
|
* @see PromptLoadingSuccess
|
||||||
* @see PromptLoadingFailure
|
* @see PromptLoadingFailure
|
||||||
*/
|
*/
|
||||||
PromptLoadingResult loadPrompt();
|
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() {
|
private static AiNamingService buildStubAiNamingService() {
|
||||||
AiInvocationPort stubAiPort = request ->
|
AiInvocationPort stubAiPort = request ->
|
||||||
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
|
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
|
||||||
PromptPort stubPromptPort = () ->
|
PromptPort stubPromptPort = new PromptPort() {
|
||||||
new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
|
@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;
|
ClockPort stubClock = () -> java.time.Instant.EPOCH;
|
||||||
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
|
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
|
||||||
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
|
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
|
||||||
|
|||||||
+10
-2
@@ -279,8 +279,16 @@ class BatchRunProgressObservationTest {
|
|||||||
AiInvocationPort stubAi = req -> {
|
AiInvocationPort stubAi = req -> {
|
||||||
throw new IllegalStateException("AI must not be invoked in these tests");
|
throw new IllegalStateException("AI must not be invoked in these tests");
|
||||||
};
|
};
|
||||||
PromptPort stubPrompt = () -> new PromptLoadingSuccess(
|
PromptPort stubPrompt = new PromptPort() {
|
||||||
new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
|
@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");
|
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
|
||||||
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
|
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
|
||||||
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, 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.GuiConfigurationFileLoader;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
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.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.GuiStartupContext;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
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.DefaultBatchRunProcessingUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
|
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.DefaultResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
@@ -827,7 +829,8 @@ public class BootstrapRunner {
|
|||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort,
|
historicalDocumentContextPort,
|
||||||
applicationVersion);
|
applicationVersion,
|
||||||
|
noOpGuiPromptEditorPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -852,17 +855,20 @@ public class BootstrapRunner {
|
|||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort,
|
historicalDocumentContextPort,
|
||||||
applicationVersion);
|
applicationVersion,
|
||||||
|
noOpGuiPromptEditorPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
try {
|
try {
|
||||||
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
||||||
|
GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort(
|
||||||
|
loadedState.values().promptTemplateFile());
|
||||||
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
||||||
historicalDocumentContextPort, applicationVersion);
|
historicalDocumentContextPort, applicationVersion, promptEditorPort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -883,10 +889,87 @@ public class BootstrapRunner {
|
|||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort,
|
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.
|
* Executes exactly one batch run triggered by the GUI's processing-run tab.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
Reference in New Issue
Block a user