#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:
2026-04-30 13:13:47 +02:00
parent 4f5ce4c750
commit 5d5dee0bbf
16 changed files with 1638 additions and 71 deletions
+37 -1
View File
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
### Umfang der GUI
Die GUI enthält zwei Tabs:
Die GUI enthält drei Tabs:
- **Tab „Konfiguration"** Editor, Validierungs- und technische Testoberfläche für
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
@@ -75,6 +75,13 @@ Die GUI enthält zwei Tabs:
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
- **Tab „Prompt"** Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage,
ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein
„Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch
geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus.
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
@@ -292,6 +299,35 @@ Die Anwendung ergänzt den Prompt automatisch um:
- einen Dokumenttext-Abschnitt
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
### Prompt-Pfad-Auflösung je Betriebsart
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|---|---|---|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
> immer eindeutig auffindbar insbesondere beim Start über den Windows Task Scheduler,
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
### Bearbeitung über den GUI-Prompt-Tab
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
Fall wird kein stiller Fallback durchgeführt).
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
aktualisiert sich der Prompt-Tab nicht automatisch in diesem Fall sollte die GUI neu gestartet
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
---
## Zielformat
@@ -414,6 +414,13 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
/**
* Bridge-Port zum Prompt-Editor-Use-Case. Wird vom {@link GuiPromptEditorTab} genutzt,
* um den Prompt-Inhalt zu laden, zu speichern und eine Standard-Prompt-Datei anzulegen.
* Supplied by Bootstrap via the startup context.
*/
private final GuiPromptEditorPort promptEditorPort;
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -421,6 +428,12 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiBatchRunTab batchRunTab;
/**
* Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
* und in den {@link #tabPane} eingehängt.
*/
private final GuiPromptEditorTab promptEditorTab;
/**
* Hint banner shown at the top of the configuration tab while a processing run is
* active. Visible + managed state are flipped from the batch run tab's listener when
@@ -491,6 +504,7 @@ public final class GuiConfigurationEditorWorkspace {
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.promptEditorPort = effectiveContext.promptEditorPort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -504,6 +518,17 @@ public final class GuiConfigurationEditorWorkspace {
this::editorSourceFolder,
this::editorTargetFolder);
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
int maxTitleLength;
try {
maxTitleLength = Integer.parseInt(
effectiveContext.initialState().values().maxTitleLength().trim());
} catch (NumberFormatException e) {
maxTitleLength = 60;
}
this.promptEditorTab = new GuiPromptEditorTab(
this.promptEditorPort, configuredPromptPath, maxTitleLength);
configureRoot();
configureHeader(effectiveContext.startupNotice());
configureTabs();
@@ -1271,11 +1296,12 @@ public final class GuiConfigurationEditorWorkspace {
scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane);
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab());
root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
// der Dateiname-Editor ungespeicherte Änderungen hat.
// Gleiches gilt für den Prompt-Tab.
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
if (oldTab == null || newTab == null) {
return;
@@ -1287,6 +1313,11 @@ public final class GuiConfigurationEditorWorkspace {
// Zurück zum Verarbeitungslauf-Tab
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
}
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
if (!shouldDiscard) {
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
}
}
});
}
@@ -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);
}
@@ -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);
}
}
@@ -67,7 +67,8 @@ public record GuiStartupContext(
GuiManualFileRenamePort manualFileRenamePort,
GuiManualFileCopyPort manualFileCopyPort,
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
String applicationVersion) {
String applicationVersion,
GuiPromptEditorPort promptEditorPort) {
/**
* Creates a fully wired startup context.
@@ -96,6 +97,8 @@ public record GuiStartupContext(
* for skipped documents; must not be {@code null}
* @param applicationVersion resolved application version string shown in the status
* bar; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
* {@code null} sein
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -130,6 +133,7 @@ public record GuiStartupContext(
"historicalDocumentContextPort must not be null");
// Null-Fallback für Testumgebungen ohne gepacktes JAR
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
}
/**
@@ -171,7 +175,7 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev");
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
}
/**
@@ -207,7 +211,7 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev");
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
}
/**
@@ -243,7 +247,7 @@ public record GuiStartupContext(
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev");
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -358,6 +362,33 @@ public record GuiStartupContext(
rejectingManualFileRenamePort(),
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(),
"dev");
"dev",
noOpPromptEditorPort());
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(
suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
}
};
}
}
@@ -244,12 +244,14 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(2, workspace.tabPane().getTabs().size(),
"Configuration tab and processing-run tab must both be present");
assertEquals(3, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab and prompt editor tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(2).getText(),
"The third tab must host the prompt editor");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
@@ -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());
}
}
@@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -13,28 +16,36 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
/**
* Filesystem-based implementation of {@link PromptPort}.
* Dateisystembasierte Implementierung von {@link PromptPort}.
* <p>
* Loads prompt templates from an external file on disk and derives a stable identifier
* from the filename. Ensures that empty or technically unusable prompts are rejected.
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
* unbrauchbare Prompts abgelehnt werden.
* <p>
* <strong>Identifier derivation:</strong>
* The stable prompt identifier is derived from the filename of the prompt file.
* This ensures deterministic, reproducible identification across batch runs.
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
* <strong>Identifikatorableitung:</strong>
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
* {@code "prompt_de_v2.txt"}.
* <p>
* <strong>Content validation:</strong>
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
* An empty prompt (or one containing only whitespace) is considered technically unusable
* and results in a {@link PromptLoadingFailure}.
* <strong>Inhaltsprüfung:</strong>
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
* {@link PromptLoadingFailure}.
* <p>
* <strong>Error handling:</strong>
* All technical failures (file not found, I/O errors, permission issues) are caught
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
* <strong>Atomares Speichern:</strong>
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
* Verzeichnis</em> wie die Zieldatei und verschiebt diese danach atomar via
* {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
* Fallback auf nicht-atomares Schreiben durchgeführt.
* <p>
* <strong>Fehlerbehandlung:</strong>
* Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
* werden abgefangen und als strukturierte Ergebnistypen zurückgegeben keine Exceptions
* werden propagiert.
*/
public class FilesystemPromptPortAdapter implements PromptPort {
@@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort {
private final Path promptFilePath;
/**
* Creates the adapter with the configured prompt file path.
* Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
*
* @param promptFilePath the path to the prompt template file; must not be null
* @throws NullPointerException if promptFilePath is null
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
* @throws NullPointerException wenn {@code promptFilePath} null ist
*/
public FilesystemPromptPortAdapter(Path promptFilePath) {
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
}
/**
* Lädt das konfigurierte Prompt-Template aus der Datei.
*
* @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
* nie {@code null}
*/
@Override
public PromptLoadingResult loadPrompt() {
try {
@@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
PromptIdentifier identifier = deriveIdentifier();
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
return new PromptLoadingSuccess(identifier, trimmedContent);
} catch (IOException e) {
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
return new PromptLoadingFailure(
"IO_ERROR",
"Failed to read prompt file: " + e.getMessage());
@@ -83,15 +100,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
}
/**
* Derives a stable prompt identifier from the filename.
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* The identifier is simply the filename (without the directory path).
* This ensures that the same prompt file always receives the same identifier.
* Der Ablauf:
* <ol>
* <li>Prüfen, ob der Zielordner existiert.</li>
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
* </ol>
* <p>
* Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
*
* @return a stable PromptIdentifier based on the filename
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
*/
@Override
public PromptSaveResult savePrompt(String content) {
Objects.requireNonNull(content, "content must not be null");
Path targetDir = promptFilePath.getParent();
if (targetDir == null || !Files.isDirectory(targetDir)) {
String message = "Zielordner der Prompt-Datei existiert nicht: "
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
LOG.warn("Prompt speichern fehlgeschlagen: {}", message);
return new PromptSaveResult.TargetDirectoryMissing(message);
}
// Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
// (nicht im System-Temp ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
try {
Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
beräumeTempDatei(tempFile);
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
return new PromptSaveResult.WriteFailed(message, e);
}
// Atomares Verschieben kein stiller Fallback auf nicht-atomares Move
try {
Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
} catch (AtomicMoveNotSupportedException e) {
beräumeTempDatei(tempFile);
String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
return new PromptSaveResult.AtomicMoveFailed(message);
} catch (IOException e) {
beräumeTempDatei(tempFile);
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
return new PromptSaveResult.AtomicMoveFailed(message);
}
}
/**
* Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
* <p>
* Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
*
* @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
*/
private PromptIdentifier deriveIdentifier() {
String filename = promptFilePath.getFileName().toString();
return new PromptIdentifier(filename);
}
/**
* Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
*
* @param tempFile die zu löschende temporäre Datei
*/
private void beräumeTempDatei(Path tempFile) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ex) {
LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
}
}
}
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
/**
* Unit tests for {@link FilesystemPromptPortAdapter}.
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
}
// -------------------------------------------------------------------------
// savePrompt tests
// -------------------------------------------------------------------------
@Test
void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_save.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Mein Prompt-Inhalt";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
assertThat(saved.absolutePath()).contains("prompt_save.txt");
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_umlaut.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Ärger mit Überschriften und Schluß";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_lineendings.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
// When
PromptSaveResult result = adapter.savePrompt(content);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
byte[] raw = Files.readAllBytes(promptFile);
assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
}
@Test
void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_overwrite.txt");
Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
adapter = new FilesystemPromptPortAdapter(promptFile);
String newContent = "Neuer Inhalt";
// When
PromptSaveResult result = adapter.savePrompt(newContent);
// Then
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
}
@Test
void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
// Given
Path nonExistentDir = tempDir.resolve("missing-subdir");
Path promptFile = nonExistentDir.resolve("prompt.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptSaveResult result = adapter.savePrompt("Inhalt");
// Then
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
assertThat(missing.message()).contains("missing-subdir");
}
@Test
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_null.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When & Then
assertThatThrownBy(() -> adapter.savePrompt(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("content must not be null");
}
@Test
void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
// Given Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
Path nonExistentDir = tempDir.resolve("ghost-dir");
Path promptFile = nonExistentDir.resolve("prompt.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
// When
PromptSaveResult result = adapter.savePrompt("Inhalt");
// Then
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
// Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
assertThat(nonExistentDir).doesNotExist();
}
@Test
void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
// Given
Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
adapter = new FilesystemPromptPortAdapter(promptFile);
String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
// When
PromptSaveResult saveResult = adapter.savePrompt(content);
PromptLoadingResult loadResult = adapter.loadPrompt();
// Then
assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
// loadPrompt trims the content; trim the expected too
assertThat(success.promptContent()).isEqualTo(content.trim());
}
}
@@ -1,56 +1,76 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound port for loading external prompt templates.
* Outbound-Port zum Laden und Speichern des externen Prompt-Templates.
* <p>
* This interface abstracts the loading of prompt content from external sources
* (files, resources, databases, etc.), allowing the Application layer to remain
* independent of how or where prompts are stored.
* Dieses Interface abstrahiert den Zugriff auf die Prompt-Datei und erlaubt der
* Application-Schicht, unabhängig vom konkreten Speichermedium zu bleiben.
* <p>
* <strong>Design principles:</strong>
* <strong>Designprinzipien:</strong>
* <ul>
* <li>Prompt is not embedded in code; it is loaded from an external source</li>
* <li>Each prompt receives a stable identifier for traceability across batch runs</li>
* <li>Results are returned as structured types ({@link PromptLoadingResult}),
* never as exceptions</li>
* <li>Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.</li>
* <li>Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.</li>
* <li>Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.</li>
* <li>Der Pfad zur Prompt-Datei ist Implementierungsdetail des Adapters er erscheint nicht
* in der Port-Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen).</li>
* </ul>
* <p>
* <strong>Adapter responsibilities:</strong>
* <strong>Adapter-Verantwortung:</strong>
* <ul>
* <li>Locate and read the prompt file/resource from the configured source</li>
* <li>Derive a stable prompt identifier (e.g., filename, semantic version, content hash)</li>
* <li>Validate that the loaded content is not empty or otherwise invalid</li>
* <li>Return either success or a classified failure</li>
* <li>Encapsulate all file I/O, resource loading, and configuration details</li>
* <li>Prompt-Datei lokalisieren und lesen.</li>
* <li>Stabilen Identifikator ableiten (z. B. Dateiname).</li>
* <li>Leere oder technisch unbrauchbare Prompts ablehnen.</li>
* <li>Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.</li>
* <li>Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.</li>
* </ul>
* <p>
* <strong>Non-goals of this port:</strong>
* <strong>Nicht-Ziele dieses Ports:</strong>
* <ul>
* <li>Prompt parsing or templating logic</li>
* <li>Combining prompt with document text (Application layer handles this)</li>
* <li>Template variable substitution</li>
* <li>Validation of prompt content against domain rules</li>
* <li>Prompt-Parsing oder Template-Verarbeitung</li>
* <li>Kombination von Prompt und Dokumenttext (Application-Schicht)</li>
* <li>Validierung des Prompt-Inhalts gegen Domänenregeln</li>
* </ul>
*/
public interface PromptPort {
/**
* Loads the configured external prompt template.
* Lädt das konfigurierte externe Prompt-Template.
* <p>
* This method is called once per batch run to obtain the current prompt.
* The prompt content and its stable identifier are returned together.
* Diese Methode wird einmal pro Verarbeitungslauf aufgerufen, um den aktuellen Prompt zu laden.
* Inhalt und stabiler Identifikator werden gemeinsam zurückgegeben.
* <p>
* If loading fails for any reason (file not found, I/O error, content validation),
* a {@link PromptLoadingFailure} is returned rather than throwing an exception.
*
* @return a {@link PromptLoadingResult} encoding either:
* <ul>
* <li>Success: prompt content and identifier loaded successfully</li>
* <li>Failure: prompt could not be loaded or is invalid</li>
* </ul>
* Bei einem technischen Fehler (Datei nicht gefunden, I/O-Fehler, leerer Inhalt) wird
* {@link PromptLoadingFailure} zurückgegeben keine Exception wird geworfen.
*
* @return {@link PromptLoadingResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @see PromptLoadingSuccess
* @see PromptLoadingFailure
*/
PromptLoadingResult loadPrompt();
/**
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
* <p>
* Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist
* <em>nicht</em> Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen
* im Port-Vertrag).
* <p>
* Die Implementierung schreibt zunächst in eine temporäre Datei <em>im selben Verzeichnis</em>
* wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}.
* Bei einem Fehler beim atomaren Verschieben wird <strong>kein stiller Fallback</strong>
* auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird
* {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben.
* <p>
* Zeichenkodierung: UTF-8. Zeilenenden werden unverändert übernommen.
*
* @param content der zu speichernde Prompt-Inhalt; darf leer sein (Entscheidung liegt
* beim Aufrufer, ob ein leerer Prompt erwünscht ist)
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
* @throws NullPointerException wenn {@code content} null ist
* @see PromptSaveResult.Saved
* @see PromptSaveResult.WriteFailed
* @see PromptSaveResult.TargetDirectoryMissing
* @see PromptSaveResult.AtomicMoveFailed
*/
PromptSaveResult savePrompt(String content);
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -1062,8 +1062,16 @@ class BatchRunProcessingUseCaseTest {
private static AiNamingService buildStubAiNamingService() {
AiInvocationPort stubAiPort = request ->
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
PromptPort stubPromptPort = () ->
new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
PromptPort stubPromptPort = new PromptPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
}
};
ClockPort stubClock = () -> java.time.Instant.EPOCH;
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
@@ -279,8 +279,16 @@ class BatchRunProgressObservationTest {
AiInvocationPort stubAi = req -> {
throw new IllegalStateException("AI must not be invoked in these tests");
};
PromptPort stubPrompt = () -> new PromptLoadingSuccess(
new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
PromptPort stubPrompt = new PromptPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
}
};
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
@@ -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.");
}
}
}
@@ -21,6 +21,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiPromptEditorPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
@@ -89,6 +90,7 @@ import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordina
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
@@ -827,7 +829,8 @@ public class BootstrapRunner {
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort,
applicationVersion);
applicationVersion,
noOpGuiPromptEditorPort());
}
Path configPath = Paths.get(configPathOverride.get());
@@ -852,17 +855,20 @@ public class BootstrapRunner {
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort,
applicationVersion);
applicationVersion,
noOpGuiPromptEditorPort());
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
try {
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort(
loadedState.values().promptTemplateFile());
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
historicalDocumentContextPort, applicationVersion);
historicalDocumentContextPort, applicationVersion, promptEditorPort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -883,10 +889,87 @@ public class BootstrapRunner {
manualRenamePort,
manualCopyPort,
historicalDocumentContextPort,
applicationVersion);
applicationVersion,
noOpGuiPromptEditorPort());
}
}
/**
* Erzeugt einen vollständig verdrahteten {@link GuiPromptEditorPort} für den angegebenen
* Prompt-Dateipfad.
* <p>
* Kombiniert {@link FilesystemPromptPortAdapter} und
* {@link de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter}
* in einem {@link DefaultPromptEditorUseCase}. Der zurückgegebene Port delegiert alle
* drei Operationen (Laden, Speichern, Standard-Anlegen) an den Use-Case.
*
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
* @return vollständig verdrahteter Port; nie {@code null}
*/
private GuiPromptEditorPort buildGuiPromptEditorPort(String promptFilePath) {
FilesystemPromptPortAdapter promptPortAdapter =
new FilesystemPromptPortAdapter(Paths.get(promptFilePath));
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort
resourceCreationPort =
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter();
DefaultPromptEditorUseCase useCase = new DefaultPromptEditorUseCase(
promptPortAdapter, resourceCreationPort);
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return useCase.loadPrompt();
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return useCase.savePrompt(content);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return useCase.createDefaultPromptIfMissing(suggestion);
}
};
}
/**
* Gibt einen No-Op-{@link GuiPromptEditorPort} zurück, der alle Operationen als
* nicht verfügbar meldet.
* <p>
* Wird eingesetzt, wenn beim GUI-Start noch keine Konfiguration geladen ist und daher
* kein Prompt-Dateipfad bekannt ist.
*
* @return no-op Port; nie {@code null}
*/
private static GuiPromptEditorPort noOpGuiPromptEditorPort() {
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_OP", "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.", null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(
suggestion, "Kein Prompt-Pfad konfiguriert.");
}
};
}
/**
* Executes exactly one batch run triggered by the GUI's processing-run tab.
* <p>