#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:
+32
-1
@@ -414,6 +414,13 @@ public final class GuiConfigurationEditorWorkspace {
|
||||
*/
|
||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||
|
||||
/**
|
||||
* Bridge-Port zum Prompt-Editor-Use-Case. Wird vom {@link GuiPromptEditorTab} genutzt,
|
||||
* um den Prompt-Inhalt zu laden, zu speichern und eine Standard-Prompt-Datei anzulegen.
|
||||
* Supplied by Bootstrap via the startup context.
|
||||
*/
|
||||
private final GuiPromptEditorPort promptEditorPort;
|
||||
|
||||
/**
|
||||
* Second main tab of the window that drives the live processing-run view. Created
|
||||
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
||||
@@ -421,6 +428,12 @@ public final class GuiConfigurationEditorWorkspace {
|
||||
*/
|
||||
private final GuiBatchRunTab batchRunTab;
|
||||
|
||||
/**
|
||||
* Dritter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
|
||||
* und in den {@link #tabPane} eingehängt.
|
||||
*/
|
||||
private final GuiPromptEditorTab promptEditorTab;
|
||||
|
||||
/**
|
||||
* Hint banner shown at the top of the configuration tab while a processing run is
|
||||
* active. Visible + managed state are flipped from the batch run tab's listener when
|
||||
@@ -491,6 +504,7 @@ public final class GuiConfigurationEditorWorkspace {
|
||||
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
||||
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
||||
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
||||
this.promptEditorPort = effectiveContext.promptEditorPort();
|
||||
this.batchRunTab = new GuiBatchRunTab(
|
||||
() -> this.batchRunLauncher,
|
||||
() -> this.miniRunLauncher,
|
||||
@@ -504,6 +518,17 @@ public final class GuiConfigurationEditorWorkspace {
|
||||
this::editorSourceFolder,
|
||||
this::editorTargetFolder);
|
||||
|
||||
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
|
||||
int maxTitleLength;
|
||||
try {
|
||||
maxTitleLength = Integer.parseInt(
|
||||
effectiveContext.initialState().values().maxTitleLength().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
maxTitleLength = 60;
|
||||
}
|
||||
this.promptEditorTab = new GuiPromptEditorTab(
|
||||
this.promptEditorPort, configuredPromptPath, maxTitleLength);
|
||||
|
||||
configureRoot();
|
||||
configureHeader(effectiveContext.startupNotice());
|
||||
configureTabs();
|
||||
@@ -1271,11 +1296,12 @@ public final class GuiConfigurationEditorWorkspace {
|
||||
scrollPane.setPadding(new Insets(0));
|
||||
editorTab.setContent(scrollPane);
|
||||
|
||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
|
||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), promptEditorTab.tab());
|
||||
root.setCenter(tabPane);
|
||||
|
||||
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
||||
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
||||
// Gleiches gilt für den Prompt-Tab.
|
||||
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
||||
if (oldTab == null || newTab == null) {
|
||||
return;
|
||||
@@ -1287,6 +1313,11 @@ public final class GuiConfigurationEditorWorkspace {
|
||||
// Zurück zum Verarbeitungslauf-Tab
|
||||
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
||||
}
|
||||
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
|
||||
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
|
||||
if (!shouldDiscard) {
|
||||
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
|
||||
/**
|
||||
* GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen
|
||||
* Use-Case in der Application-Schicht.
|
||||
* <p>
|
||||
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||
* Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte
|
||||
* Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem
|
||||
* zugreift.
|
||||
* <p>
|
||||
* <strong>Verantwortung:</strong>
|
||||
* <ul>
|
||||
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
|
||||
* <li>Bearbeiteten Inhalt atomar speichern.</li>
|
||||
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}.
|
||||
* Das GUI-Modul kennt ausschließlich den Interface-Typ.
|
||||
*/
|
||||
public interface GuiPromptEditorPort {
|
||||
|
||||
/**
|
||||
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle.
|
||||
* <p>
|
||||
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||
*
|
||||
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
|
||||
* oder einem klassifizierten Fehler; nie {@code null}
|
||||
*/
|
||||
PromptLoadingResult loadCurrentPrompt();
|
||||
|
||||
/**
|
||||
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||
* <p>
|
||||
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||
*
|
||||
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code content} null ist
|
||||
*/
|
||||
PromptSaveResult save(String content);
|
||||
|
||||
/**
|
||||
* Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist.
|
||||
* <p>
|
||||
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||
*
|
||||
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
|
||||
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code suggestion} null ist
|
||||
*/
|
||||
CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion);
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Font;
|
||||
|
||||
/**
|
||||
* Tab „Prompt" im Hauptfenster des GUI-Adapters.
|
||||
* <p>
|
||||
* Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei
|
||||
* direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen.
|
||||
* <p>
|
||||
* <strong>Verhalten:</strong>
|
||||
* <ul>
|
||||
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
|
||||
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
|
||||
* <li>„Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
|
||||
* <li>„Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
|
||||
* ohne zu speichern.</li>
|
||||
* <li>Bei fehlendem Prompt wird ein Hinweis und ein „Standard-Prompt erstellen"-Button
|
||||
* angezeigt.</li>
|
||||
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Threading:</strong> Alle blockierenden Operationen (Laden, Speichern,
|
||||
* Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
|
||||
* ausschließlich via {@code Platform.runLater}.
|
||||
*/
|
||||
public class GuiPromptEditorTab {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
|
||||
|
||||
private static final String TAB_TITLE = "Prompt";
|
||||
private static final String TAB_TITLE_DIRTY = "Prompt *";
|
||||
|
||||
private final GuiPromptEditorPort promptEditorPort;
|
||||
/** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
|
||||
private final String configuredPromptPath;
|
||||
/** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
|
||||
private final int maxTitleLength;
|
||||
|
||||
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
|
||||
/** Erzeugt Worker-Threads für blockierende Operationen. */
|
||||
Function<Runnable, Thread> threadFactory;
|
||||
/** Übergibt UI-Updates an den JavaFX Application Thread. */
|
||||
Consumer<Runnable> fxDispatcher;
|
||||
|
||||
private final Tab tab = new Tab(TAB_TITLE);
|
||||
private final TextArea textArea = new TextArea();
|
||||
private final Label statusLabel = new Label();
|
||||
private final Button saveButton = new Button("Speichern");
|
||||
private final Button resetButton = new Button("Auf Standard zurücksetzen");
|
||||
private final Button createDefaultButton = new Button("Standard-Prompt erstellen");
|
||||
|
||||
/** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */
|
||||
private boolean dirty = false;
|
||||
/** Zuletzt aus der Datei geladener Inhalt (Baseline). */
|
||||
private String loadedContent = null;
|
||||
|
||||
/**
|
||||
* Erstellt den Prompt-Editor-Tab.
|
||||
*
|
||||
* @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein
|
||||
* @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile);
|
||||
* darf nicht {@code null} sein
|
||||
* @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt
|
||||
* @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist
|
||||
*/
|
||||
public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort,
|
||||
String configuredPromptPath,
|
||||
int maxTitleLength) {
|
||||
this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||
this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null");
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
// Standard-Implementierungen für den Produktionsbetrieb
|
||||
this.threadFactory = runnable -> {
|
||||
Thread t = new Thread(runnable, "gui-prompt-editor");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
};
|
||||
this.fxDispatcher = Platform::runLater;
|
||||
buildTab();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann.
|
||||
*
|
||||
* @return das Tab; nie {@code null}
|
||||
*/
|
||||
public Tab tab() {
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält.
|
||||
*
|
||||
* @return {@code true}, wenn Dirty-State aktiv ist
|
||||
*/
|
||||
public boolean hasDirtyContent() {
|
||||
return dirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
|
||||
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
|
||||
*
|
||||
* @return {@code true} zum Verwerfen, {@code false} zum Abbrechen
|
||||
*/
|
||||
public boolean confirmDiscardIfDirty() {
|
||||
if (!dirty) {
|
||||
return true;
|
||||
}
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
alert.setTitle("Ungespeicherte Änderungen");
|
||||
alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen.");
|
||||
alert.setContentText("Möchten Sie die Änderungen verwerfen?");
|
||||
alert.getButtonTypes().setAll(
|
||||
new ButtonType("Verwerfen"),
|
||||
ButtonType.CANCEL);
|
||||
Optional<ButtonType> result = alert.showAndWait();
|
||||
return result.isPresent() && result.get().getText().equals("Verwerfen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an.
|
||||
* <p>
|
||||
* Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
|
||||
* läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
|
||||
*/
|
||||
public void loadPromptAsync() {
|
||||
setStatus("Lade Prompt-Datei ...");
|
||||
saveButton.setDisable(true);
|
||||
resetButton.setDisable(true);
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
|
||||
Thread worker = threadFactory.apply(() -> {
|
||||
var result = promptEditorPort.loadCurrentPrompt();
|
||||
fxDispatcher.accept(() -> applyLoadResult(result));
|
||||
});
|
||||
worker.start();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private Aufbau
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void buildTab() {
|
||||
tab.setClosable(false);
|
||||
|
||||
// TextArea – monospace Font für bessere Lesbarkeit
|
||||
textArea.setWrapText(true);
|
||||
textArea.setFont(Font.font("Monospace", 13));
|
||||
textArea.setPrefRowCount(20);
|
||||
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||
|
||||
// Dirty-State-Tracking
|
||||
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (loadedContent != null) {
|
||||
boolean nowDirty = !newVal.equals(loadedContent);
|
||||
if (nowDirty != dirty) {
|
||||
dirty = nowDirty;
|
||||
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Status-Label
|
||||
statusLabel.setWrapText(true);
|
||||
statusLabel.setStyle("-fx-text-fill: #555555;");
|
||||
|
||||
// Buttons verdrahten
|
||||
saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8)."));
|
||||
saveButton.setOnAction(e -> requestSave());
|
||||
|
||||
resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern."));
|
||||
resetButton.setOnAction(e -> resetToDefault());
|
||||
|
||||
createDefaultButton.setTooltip(new Tooltip(
|
||||
"Standard-Prompt-Datei am konfigurierten Pfad anlegen."));
|
||||
createDefaultButton.setOnAction(e -> requestCreateDefault());
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
|
||||
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
|
||||
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
||||
buttonBar.setPadding(new Insets(6, 0, 0, 0));
|
||||
|
||||
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
|
||||
content.setPadding(new Insets(12));
|
||||
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||
|
||||
BorderPane root = new BorderPane(content);
|
||||
tab.setContent(root);
|
||||
|
||||
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
|
||||
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
|
||||
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
|
||||
loadPromptAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
|
||||
if (result instanceof PromptLoadingSuccess success) {
|
||||
loadedContent = success.promptContent();
|
||||
textArea.setText(loadedContent);
|
||||
textArea.setEditable(true);
|
||||
saveButton.setDisable(false);
|
||||
resetButton.setDisable(false);
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
|
||||
dirty = false;
|
||||
tab.setText(TAB_TITLE);
|
||||
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
|
||||
success.promptIdentifier().identifier());
|
||||
} else if (result instanceof PromptLoadingFailure failure) {
|
||||
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
|
||||
if (fileNotFound) {
|
||||
// Datei fehlt – Hinweis und Anlegen-Button anzeigen
|
||||
loadedContent = null;
|
||||
textArea.setEditable(false);
|
||||
textArea.clear();
|
||||
saveButton.setDisable(true);
|
||||
resetButton.setDisable(false);
|
||||
createDefaultButton.setVisible(true);
|
||||
createDefaultButton.setManaged(true);
|
||||
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
|
||||
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
|
||||
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
|
||||
} else {
|
||||
// Anderer Fehler (I/O, leer usw.)
|
||||
loadedContent = null;
|
||||
textArea.setEditable(false);
|
||||
textArea.clear();
|
||||
saveButton.setDisable(true);
|
||||
resetButton.setDisable(false);
|
||||
createDefaultButton.setVisible(false);
|
||||
createDefaultButton.setManaged(false);
|
||||
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
|
||||
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
|
||||
failure.failureReason(), failure.failureMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requestSave() {
|
||||
String currentText = textArea.getText();
|
||||
|
||||
// Leerer Prompt: Bestätigungsdialog
|
||||
if (currentText.trim().isEmpty()) {
|
||||
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
confirm.setTitle("Leerer Prompt");
|
||||
confirm.setHeaderText("Der Prompt ist leer.");
|
||||
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
|
||||
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
Optional<ButtonType> choice = confirm.showAndWait();
|
||||
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus("Speichere ...");
|
||||
saveButton.setDisable(true);
|
||||
|
||||
Thread worker = threadFactory.apply(() -> {
|
||||
PromptSaveResult result = promptEditorPort.save(currentText);
|
||||
fxDispatcher.accept(() -> applySaveResult(result, currentText));
|
||||
});
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void applySaveResult(PromptSaveResult result, String savedContent) {
|
||||
saveButton.setDisable(false);
|
||||
if (result instanceof PromptSaveResult.Saved saved) {
|
||||
loadedContent = savedContent;
|
||||
dirty = false;
|
||||
tab.setText(TAB_TITLE);
|
||||
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
|
||||
textArea.setEditable(true);
|
||||
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
|
||||
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
|
||||
setStatus("Fehler: " + missing.message());
|
||||
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Ordner fehlt: {}", missing.message());
|
||||
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
|
||||
setStatus("Fehler beim Schreiben: " + failed.message());
|
||||
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Schreibfehler: {}", failed.message());
|
||||
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
|
||||
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
|
||||
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
|
||||
}
|
||||
}
|
||||
|
||||
void resetToDefault() {
|
||||
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
|
||||
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
|
||||
textArea.setText(defaultContent);
|
||||
textArea.setEditable(true);
|
||||
saveButton.setDisable(false);
|
||||
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
|
||||
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
|
||||
}
|
||||
|
||||
private void requestCreateDefault() {
|
||||
createDefaultButton.setDisable(true);
|
||||
setStatus("Lege Standard-Prompt-Datei an ...");
|
||||
|
||||
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
|
||||
configuredPromptPath,
|
||||
"Standard-Prompt-Datei anlegen",
|
||||
maxTitleLength);
|
||||
|
||||
Thread worker = threadFactory.apply(() -> {
|
||||
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
|
||||
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
|
||||
});
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
|
||||
createDefaultButton.setDisable(false);
|
||||
if (outcome instanceof CorrectionOutcome.Applied applied) {
|
||||
setStatus(applied.message() + " Lade Inhalt ...");
|
||||
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
|
||||
// Inhalt sofort neu laden
|
||||
loadPromptAsync();
|
||||
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
|
||||
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
|
||||
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
|
||||
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
|
||||
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
|
||||
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
|
||||
}
|
||||
}
|
||||
|
||||
private void setStatus(String message) {
|
||||
statusLabel.setText(message);
|
||||
}
|
||||
}
|
||||
+36
-5
@@ -67,7 +67,8 @@ public record GuiStartupContext(
|
||||
GuiManualFileRenamePort manualFileRenamePort,
|
||||
GuiManualFileCopyPort manualFileCopyPort,
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||
String applicationVersion) {
|
||||
String applicationVersion,
|
||||
GuiPromptEditorPort promptEditorPort) {
|
||||
|
||||
/**
|
||||
* Creates a fully wired startup context.
|
||||
@@ -96,6 +97,8 @@ public record GuiStartupContext(
|
||||
* for skipped documents; must not be {@code null}
|
||||
* @param applicationVersion resolved application version string shown in the status
|
||||
* bar; {@code null} defaults to {@code "dev"}
|
||||
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||
* {@code null} sein
|
||||
*/
|
||||
public GuiStartupContext {
|
||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||
@@ -130,6 +133,7 @@ public record GuiStartupContext(
|
||||
"historicalDocumentContextPort must not be null");
|
||||
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +175,7 @@ public record GuiStartupContext(
|
||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(), "dev");
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,7 +211,7 @@ public record GuiStartupContext(
|
||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(), "dev");
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +247,7 @@ public record GuiStartupContext(
|
||||
technicalTestOrchestrator, correctionExecutionService,
|
||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(), "dev");
|
||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort());
|
||||
}
|
||||
|
||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||
@@ -358,6 +362,33 @@ public record GuiStartupContext(
|
||||
rejectingManualFileRenamePort(),
|
||||
rejectingManualFileCopyPort(),
|
||||
noOpHistoricalDocumentContextPort(),
|
||||
"dev");
|
||||
"dev",
|
||||
noOpPromptEditorPort());
|
||||
}
|
||||
|
||||
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||
return new GuiPromptEditorPort() {
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||
"NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
createDefaultPromptIfMissing(
|
||||
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(
|
||||
suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -244,12 +244,14 @@ class GuiAdapterSmokeTest {
|
||||
"The 'Speichern' button must be visible");
|
||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||
"The 'Speichern unter' button must be visible");
|
||||
assertEquals(2, workspace.tabPane().getTabs().size(),
|
||||
"Configuration tab and processing-run tab must both be present");
|
||||
assertEquals(3, workspace.tabPane().getTabs().size(),
|
||||
"Configuration tab, processing-run tab and prompt editor tab must all be present");
|
||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||
"The first tab must use the configuration label");
|
||||
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||
"The second tab must host the processing-run view");
|
||||
assertEquals("Prompt", workspace.tabPane().getTabs().get(2).getText(),
|
||||
"The third tab must host the prompt editor");
|
||||
assertEquals(
|
||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||
String.join(",", workspace.sectionTitles()),
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Tab;
|
||||
|
||||
/**
|
||||
* Monocle-basierte Headless-Smoke-Tests für {@link GuiPromptEditorTab}.
|
||||
* <p>
|
||||
* Geprüfte Szenarien:
|
||||
* <ul>
|
||||
* <li>Tab wird korrekt mit Titel „Prompt" erstellt.</li>
|
||||
* <li>Dirty-State ist nach Konstruktion {@code false}.</li>
|
||||
* <li>Nach synchronem Laden mit Erfolg: Dirty-State bleibt {@code false},
|
||||
* Tab-Titel enthält keinen Asterisk.</li>
|
||||
* <li>Nach synchronem Laden mit FILE_NOT_FOUND: Dirty-State bleibt {@code false}.</li>
|
||||
* <li>Nach synchronem Speichern mit Erfolg: Dirty-State zurückgesetzt.</li>
|
||||
* <li>Nach {@code resetToDefault}: Textfeld enthält Default-Inhalt (nicht leer),
|
||||
* Dirty-State ist {@code true} (Abweichung von geladener Baseline).</li>
|
||||
* </ul>
|
||||
*/
|
||||
class GuiPromptEditorTabSmokeTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
latch.countDown();
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform muss innerhalb des Timeouts starten");
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
verifyLatch.countDown();
|
||||
});
|
||||
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Gemeinsame Platform – kein Platform.exit().
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hilfsklassen
|
||||
// =========================================================================
|
||||
|
||||
/** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */
|
||||
private static class SyncPromptEditorPort implements GuiPromptEditorPort {
|
||||
PromptLoadingResult loadResult = new PromptLoadingSuccess(
|
||||
new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt");
|
||||
PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt");
|
||||
|
||||
@Override
|
||||
public PromptLoadingResult loadCurrentPrompt() {
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromptSaveResult save(String content) {
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CorrectionOutcome createDefaultPromptIfMissing(
|
||||
CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs:
|
||||
* threadFactory führt den Runnable inline aus (vor worker.start()),
|
||||
* fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater).
|
||||
* Damit sind alle Operationen aus Testsicht vollständig synchron.
|
||||
*/
|
||||
private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) {
|
||||
GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60);
|
||||
// Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op).
|
||||
tab.threadFactory = runnable -> {
|
||||
runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf
|
||||
return new Thread(); // Dummy-Thread; worker.start() beendet sofort
|
||||
};
|
||||
// UI-Updates synchron im selben Thread
|
||||
tab.fxDispatcher = Runnable::run;
|
||||
return tab;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void tab_shouldBeCreatedWithTitlePrompt() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<Tab> tabRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
tabRef.set(editorTab.tab());
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (fxError.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||
}
|
||||
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
|
||||
assertEquals("Prompt", tabRef.get().getText(), "Tab-Titel muss 'Prompt' sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_shouldBeFalse_afterConstruction() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
dirtyRef.set(editorTab.hasDirtyContent());
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (fxError.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||
}
|
||||
assertFalse(dirtyRef.get(), "Dirty-State muss nach Konstruktion false sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_shouldBeFalse_afterSuccessfulLoad() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
// Laden synchron auslösen (fxDispatcher = Runnable::run)
|
||||
editorTab.loadPromptAsync();
|
||||
dirtyRef.set(editorTab.hasDirtyContent());
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (fxError.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||
}
|
||||
assertFalse(dirtyRef.get(), "Dirty-State muss nach erfolgreichem Laden false sein");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tabTitle_shouldNotContainAsterisk_afterSuccessfulLoad() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
editorTab.loadPromptAsync();
|
||||
titleRef.set(editorTab.tab().getText());
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (fxError.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||
}
|
||||
assertFalse(titleRef.get().contains("*"),
|
||||
"Tab-Titel darf nach erfolgreichem Laden keinen Asterisk enthalten");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyState_shouldBeFalse_whenLoadReturnsFileNotFound() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
port.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht gefunden");
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
editorTab.loadPromptAsync();
|
||||
dirtyRef.set(editorTab.hasDirtyContent());
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (fxError.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||
}
|
||||
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||
editorTab.loadPromptAsync();
|
||||
// Direkte TextArea-Manipulation simuliert Benutzer-Eingabe
|
||||
// Über Reflection auf das private textArea-Feld zugreifen ist unerwünscht.
|
||||
// Stattdessen: resetToDefault() setzt einen anderen Inhalt als den geladenen,
|
||||
// was den Dirty-State auslöst.
|
||||
editorTab.resetToDefault();
|
||||
titleRef.set(editorTab.tab().getText());
|
||||
} catch (Throwable t) {
|
||||
fxError.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
if (fxError.get() != null) {
|
||||
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||
}
|
||||
// Nach resetToDefault() wird der Default-Inhalt gesetzt.
|
||||
// Falls dieser vom geladenen Inhalt abweicht, entsteht ein Dirty-State.
|
||||
// Da Stub-Inhalt != Default-Template, muss Asterisk vorhanden sein.
|
||||
assertTrue(titleRef.get().contains("*"),
|
||||
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
|
||||
+ titleRef.get());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user