V2.9: Integrierte PDF-Vorschau und editierbarer Dateiname im Verarbeitungslauf

Neu im Tab "Verarbeitungslauf":
- Integrierte PDF-Vorschau der Quelldatei mit Lazy Rendering (Seite 1 sofort,
  weitere Seiten on-demand), Cache pro Selektion, "latest preview request wins"
- Editierbarer KI-Dateinamenvorschlag mit Live-Validierung, Dirty-State-Dialog
  bei Zeilen-/Tabwechsel, Schließen und Laufstart, atomare FS+DB-Transaktion
  inkl. Rollback und Fingerprint-basierter Konfliktauflösung

Architektur:
- Neuer Application-Use-Case ManualFileRenameUseCase und Outbound-Port
  TargetFileRenamePort mit Filesystem-Adapter
- Neuer GuiManualFileRenamePort, verdrahtet im Bootstrap
- GuiBatchRunResultRow um correctedFileName erweitert
- GuiBatchRunTab auf SplitPane-Layout (60/40) umgebaut, Detail-Panel mit
  KI-Begründung, FileNameEditorPane und PdfPreviewPane
- Spike-Code (PdfViewerSpike) entfernt, produktive Implementierung ersetzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:30:55 +02:00
parent f6b265b370
commit d3fbfc4094
34 changed files with 3823 additions and 188 deletions
@@ -16,6 +16,7 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
@@ -363,6 +364,12 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiResetDocumentStatusPort resetDocumentStatusPort;
/**
* Port used by the processing-run tab to rename a target file manually.
* Supplied by Bootstrap via the startup context.
*/
private final GuiManualFileRenamePort manualFileRenamePort;
/**
* 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
@@ -437,13 +444,17 @@ public final class GuiConfigurationEditorWorkspace {
this.batchRunLauncher = effectiveContext.batchRunLauncher();
this.miniRunLauncher = effectiveContext.miniRunLauncher();
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
() -> this.resetDocumentStatusPort,
this::loadedConfigurationPath,
this::isSavedConfigurationReady,
this::applyBatchRunLockState);
this::applyBatchRunLockState,
() -> this.manualFileRenamePort,
this::editorSourceFolder,
this::editorTargetFolder);
configureRoot();
configureHeader(effectiveContext.startupNotice());
@@ -462,6 +473,18 @@ public final class GuiConfigurationEditorWorkspace {
return batchRunTab;
}
/**
* Returns the port for manual target-file renaming.
* <p>
* Supplied by Bootstrap. The processing-run tab can retrieve this port to delegate
* manual rename actions without holding a direct reference to Bootstrap internals.
*
* @return the {@link GuiManualFileRenamePort}; never {@code null}
*/
public GuiManualFileRenamePort manualFileRenamePort() {
return manualFileRenamePort;
}
/**
* Returns the currently loaded configuration file path, or {@code null} when the
* editor has never loaded a file from disk. The processing-run tab uses this value to
@@ -486,6 +509,40 @@ public final class GuiConfigurationEditorWorkspace {
return editorState.hasLoadedFileSnapshot();
}
/**
* Liefert den im Editor eingestellten Quellordner als {@link Path}, sofern ein
* nicht-leerer Wert vorliegt. Wird vom Verarbeitungslauf-Tab genutzt um die
* Quelldatei für die PDF-Vorschau zu lokalisieren.
*
* @return den Quellordner-Pfad oder ein leeres Optional
*/
private java.util.Optional<Path> editorSourceFolder() {
String raw = editorState.values().sourceFolder();
if (raw == null || raw.isBlank()) {
return java.util.Optional.empty();
}
try {
return java.util.Optional.of(Path.of(raw));
} catch (Exception e) {
return java.util.Optional.empty();
}
}
/**
* Liefert den im Editor eingestellten Zielordner als Pfad-String, sofern ein
* nicht-leerer Wert vorliegt. Wird vom Dateiname-Editor für die Pfadlängenprüfung
* genutzt.
*
* @return den Zielordner-Pfad-String oder ein leeres Optional
*/
private java.util.Optional<String> editorTargetFolder() {
String raw = editorState.values().targetFolder();
if (raw == null || raw.isBlank()) {
return java.util.Optional.empty();
}
return java.util.Optional.of(raw);
}
/**
* Applies the "batch run active" UI lock state to the configuration tab and the
* action bar.
@@ -523,6 +580,14 @@ public final class GuiConfigurationEditorWorkspace {
handleCloseWhileRunRunning(stage);
return;
}
// Dateiname-Dirty-State im Verarbeitungslauf-Tab prüfen
if (batchRunTab != null && batchRunTab.hasUnsavedFilenameEdits()) {
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
if (!shouldDiscard) {
event.consume();
return;
}
}
if (!editorState.isDirty()) {
return;
}
@@ -1136,6 +1201,22 @@ public final class GuiConfigurationEditorWorkspace {
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
// der Dateiname-Editor ungespeicherte Änderungen hat.
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
if (oldTab == null || newTab == null) {
return;
}
if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) {
// Selektion kurz unterdrücken um Rekursion zu vermeiden
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
if (!shouldDiscard) {
// Zurück zum Verarbeitungslauf-Tab
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
}
}
});
}
private void configureActionBar() {
@@ -6,10 +6,13 @@ import java.util.Set;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
@@ -35,8 +38,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* {@link CorrectionExecutionService} used to execute corrective actions after a
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
* mini-runs for selected documents, and the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents.
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
* reset the persistence status of selected documents, and the
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI.
* <p>
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
* know about provider-specific HTTP details or adapter wiring.
@@ -54,7 +58,8 @@ public record GuiStartupContext(
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort) {
GuiResetDocumentStatusPort resetDocumentStatusPort,
GuiManualFileRenamePort manualFileRenamePort) {
/**
* Creates a fully wired startup context.
@@ -74,6 +79,8 @@ public record GuiStartupContext(
* documents; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
* documents; must not be {@code null}
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
* must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -100,11 +107,53 @@ public record GuiStartupContext(
"miniRunLauncher must not be null");
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
"resetDocumentStatusPort must not be null");
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
"manualFileRenamePort must not be null");
}
/**
* Backward-compatible constructor that fills the mini-run launcher and reset port
* with no-op implementations.
* Backward-compatible constructor that fills the manual-rename port with a no-op
* implementation.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
* documents; must not be {@code null}
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
* documents; must not be {@code null}
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher,
GuiMiniRunLauncher miniRunLauncher,
GuiResetDocumentStatusPort resetDocumentStatusPort) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort());
}
/**
* Backward-compatible constructor that fills the mini-run launcher, reset port and
* manual-rename port with no-op implementations.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
@@ -133,12 +182,12 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
rejectingMiniRunLauncher(), rejectingResetPort());
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort());
}
/**
* Backward-compatible constructor that fills the processing-run launcher, mini-run
* launcher and reset port with no-op implementations.
* launcher, reset port and manual-rename port with no-op implementations.
* <p>
* Preserves existing callers that were written before the processing-run tab was added.
*
@@ -167,7 +216,8 @@ public record GuiStartupContext(
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort());
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
rejectingManualFileRenamePort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -190,6 +240,12 @@ public record GuiStartupContext(
};
}
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
.ManualFileRenameFileSystemFailure(
"Kein Umbennennungs-Port in diesem Startkontext verfügbar.");
}
/**
* Creates a blank startup context with no-op implementations for all ports and services.
* <p>
@@ -262,6 +318,7 @@ public record GuiStartupContext(
noOpCorrectionService,
noOpBatchRunLauncher,
rejectingMiniRunLauncher(),
rejectingResetPort());
rejectingResetPort(),
rejectingManualFileRenamePort());
}
}
@@ -0,0 +1,432 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
/**
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
* Ergebnis-Zeile.
* <p>
* Die Komponente kapselt Eingabefeld, feste Dateiendung, Validierungsanzeige sowie die
* Schaltflächen „Dateiname übernehmen" und „Zurücksetzen auf KI-Vorschlag". Sie kennt
* drei Zustände gemäß fachlicher Spezifikation:
* <ul>
* <li><b>KI-Vorschlag</b> der ursprünglich generierte Name; unveränderlich pro Zeile.</li>
* <li><b>Letzter gespeicherter Name</b> der zuletzt bestätigte Name; entspricht dem
* aktuellen Stand in Dateisystem und Persistenz.</li>
* <li><b>Aktuelle Eingabe</b> der im Textfeld sichtbare Wert; kann vom letzten
* gespeicherten Namen abweichen (Dirty-State).</li>
* </ul>
*
* <h2>Threading</h2>
* <p>
* Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen werden.
* Die tatsächliche Speicher-Operation ist in der Verantwortung des aufrufenden Tabs und
* läuft dort auf einem Hintergrund-Worker-Thread.
*/
public final class FileNameEditorPane {
/** Feste PDF-Erweiterung für Zieldateien. */
public static final String PDF_EXTENSION = ".pdf";
/** Windows-Maximal-Pfadlänge (MAX_PATH = 260 inkl. Null-Terminator = 259 nutzbar). */
public static final int MAX_WINDOWS_PATH_LENGTH = 259;
private static final Set<String> RESERVED_WINDOWS_NAMES = buildReservedWindowsNames();
private static final String FORBIDDEN_CHARS_REGEX = ".*[\\\\/:*?\"<>|].*";
private final VBox root = new VBox(4);
private final TextField textField = new TextField();
private final Label extensionLabel = new Label(PDF_EXTENSION);
private final Label validationLabel = new Label();
private final Button saveButton = new Button("Dateiname übernehmen");
private final Button resetButton = new Button("Zurücksetzen auf KI-Vorschlag");
private final Label sectionTitle = new Label("Dateiname");
private Optional<String> aiProposal = Optional.empty();
private Optional<String> lastSavedName = Optional.empty();
private String targetFolderPath = "";
private boolean selectionEditable = false;
private boolean globalEnabled = true;
private boolean suppressValidation = false;
private Consumer<String> onSaveRequested = name -> { };
/**
* Erstellt die Komponente mit leerem und deaktiviertem Zustand.
*/
public FileNameEditorPane() {
sectionTitle.setStyle("-fx-font-weight: bold;");
textField.setId("filename-editor-text-field");
textField.setPromptText("Basisname ohne .pdf");
HBox.setHgrow(textField, Priority.ALWAYS);
extensionLabel.setId("filename-editor-extension-label");
extensionLabel.setStyle("-fx-text-fill: #555555;");
HBox inputRow = new HBox(4, textField, extensionLabel);
inputRow.setAlignment(Pos.CENTER_LEFT);
validationLabel.setId("filename-editor-validation-label");
validationLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #c62828;");
validationLabel.setVisible(false);
validationLabel.setManaged(false);
validationLabel.setWrapText(true);
saveButton.setId("filename-editor-save-button");
saveButton.setOnAction(e -> fireSaveRequest());
resetButton.setId("filename-editor-reset-button");
resetButton.setOnAction(e -> resetToAiProposal());
HBox buttonRow = new HBox(8, saveButton, resetButton);
buttonRow.setAlignment(Pos.CENTER_LEFT);
buttonRow.setPadding(new Insets(4, 0, 0, 0));
root.getChildren().addAll(sectionTitle, inputRow, validationLabel, buttonRow);
root.setPadding(new Insets(0, 0, 4, 0));
// Live-Validierung auf jeden Tastendruck.
textField.textProperty().addListener((obs, oldText, newText) -> {
if (!suppressValidation) {
refreshUiState();
}
});
// Enter löst Speichern aus, Escape setzt auf lastSavedName zurück.
textField.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
if (!saveButton.isDisabled()) {
fireSaveRequest();
event.consume();
}
} else if (event.getCode() == KeyCode.ESCAPE) {
discardChanges();
event.consume();
}
});
clearSelection();
}
/**
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
*
* @return das Root-Control der Komponente; nie null
*/
public Region getNode() {
return root;
}
/**
* Registriert einen Callback, der ausgelöst wird, wenn der Benutzer „Dateiname übernehmen"
* anfordert. Parameter ist der gewünschte Basisname ohne {@code .pdf}-Erweiterung.
*
* @param callback Callback; darf nicht null sein (leerer Consumer als No-Op möglich)
*/
public void setOnSaveRequested(Consumer<String> callback) {
this.onSaveRequested = Objects.requireNonNull(callback, "callback must not be null");
}
/**
* Aktualisiert den Zustand für die neu selektierte Zeile.
* <p>
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
* Bei nicht editierbaren Status (FAILED_*, SKIPPED, reset-pending, kein SUCCESS)
* wird das Feld deaktiviert.
*
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
* @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
* {@code null} sein (wird als leer behandelt)
*/
public void loadSelection(GuiBatchRunResultRow row, String targetFolderPath) {
this.targetFolderPath = targetFolderPath == null ? "" : targetFolderPath;
if (row == null) {
clearSelection();
return;
}
this.aiProposal = stripPdfExtension(row.finalFileName());
this.lastSavedName = stripPdfExtension(row.effectiveFileName());
boolean editable = isRowEditable(row) && lastSavedName.isPresent();
this.selectionEditable = editable;
suppressValidation = true;
try {
textField.setText(lastSavedName.orElse(""));
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
* selektiert ist.
*/
public void clearSelection() {
this.aiProposal = Optional.empty();
this.lastSavedName = Optional.empty();
this.selectionEditable = false;
suppressValidation = true;
try {
textField.setText("");
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Setzt den Textfeldinhalt auf den zuletzt gespeicherten Namen zurück. Äquivalent zum
* Drücken der Escape-Taste im Textfeld.
*/
public void discardChanges() {
suppressValidation = true;
try {
textField.setText(lastSavedName.orElse(""));
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Setzt den Textfeldinhalt auf den KI-Vorschlag zurück. Es erfolgt <em>kein</em>
* Speichervorgang der Benutzer kann anschließend über „Dateiname übernehmen"
* bestätigen.
*/
public void resetToAiProposal() {
if (aiProposal.isEmpty()) {
return;
}
suppressValidation = true;
try {
textField.setText(aiProposal.get());
} finally {
suppressValidation = false;
}
refreshUiState();
}
/**
* Aktiviert oder deaktiviert die gesamte Komponente. Während eines laufenden Batch-Laufs
* soll die Komponente deaktiviert sein.
*
* @param enabled {@code true} wenn Bedienung erlaubt ist
*/
public void setEnabled(boolean enabled) {
this.globalEnabled = enabled;
refreshUiState();
}
/**
* Liefert {@code true} wenn die aktuelle Texteingabe vom letzten gespeicherten Namen
* abweicht.
*
* @return ob ungespeicherte Änderungen im Textfeld vorliegen
*/
public boolean isDirty() {
if (!selectionEditable) {
return false;
}
String current = textField.getText() == null ? "" : textField.getText();
String saved = lastSavedName.orElse("");
return !current.equals(saved);
}
/**
* Liefert {@code true} wenn für die aktuelle Zeile ein KI-Vorschlag vorliegt.
*
* @return ob ein KI-Vorschlag existiert
*/
public boolean hasAiProposal() {
return aiProposal.isPresent();
}
/**
* Liefert {@code true} wenn für die aktuelle Zeile ein zuletzt gespeicherter Name
* existiert.
*
* @return ob ein letzter gespeicherter Name existiert
*/
public boolean hasLastSaved() {
return lastSavedName.isPresent();
}
/**
* Aktualisiert intern den letzten gespeicherten Namen. Typisch nach erfolgreichem
* Speichervorgang im Tab (ohne erneut {@link #loadSelection(GuiBatchRunResultRow, String)}
* aufzurufen).
*
* @param newLastSavedName neuer letzter gespeicherter Name ohne {@code .pdf}; darf
* {@code null} sein
*/
public void updateLastSavedName(String newLastSavedName) {
this.lastSavedName = newLastSavedName == null || newLastSavedName.isBlank()
? Optional.empty()
: Optional.of(newLastSavedName);
suppressValidation = true;
try {
textField.setText(lastSavedName.orElse(""));
} finally {
suppressValidation = false;
}
refreshUiState();
}
// --- Test-Accessoren ------------------------------------------------------
/** Visible for tests. */
TextField textField() {
return textField;
}
/** Visible for tests. */
Label validationLabel() {
return validationLabel;
}
/** Visible for tests. */
Button saveButton() {
return saveButton;
}
/** Visible for tests. */
Button resetButton() {
return resetButton;
}
// --- Interne Helfer -------------------------------------------------------
private void fireSaveRequest() {
if (saveButton.isDisabled()) {
return;
}
String current = textField.getText() == null ? "" : textField.getText();
onSaveRequested.accept(current);
}
private void refreshUiState() {
boolean enabled = selectionEditable && globalEnabled;
textField.setDisable(!enabled);
resetButton.setDisable(!enabled || aiProposal.isEmpty());
if (!enabled) {
// Validierung und Speichern-Button unterdrücken, Rahmen neutral.
validationLabel.setVisible(false);
validationLabel.setManaged(false);
textField.setStyle("");
saveButton.setDisable(true);
return;
}
String current = textField.getText() == null ? "" : textField.getText();
Optional<String> error = validate(current);
if (error.isPresent()) {
validationLabel.setText(error.get());
validationLabel.setVisible(true);
validationLabel.setManaged(true);
textField.setStyle("-fx-border-color: #c62828; -fx-border-width: 1.5;");
saveButton.setDisable(true);
} else {
validationLabel.setVisible(false);
validationLabel.setManaged(false);
if (isDirty()) {
// Dirty-Markierung: orangefarbener Rand.
textField.setStyle("-fx-border-color: #e65100; -fx-border-width: 1.5;");
saveButton.setDisable(false);
} else {
textField.setStyle("");
saveButton.setDisable(true);
}
}
}
/**
* Führt die vollständige Dateinamen-Validierung aus und liefert gegebenenfalls den
* fachlichen Fehlertext. Paket-privat für Unit-Tests.
*
* @param input Eingabe aus dem Textfeld (ohne {@code .pdf})
* @return der Fehlertext oder {@link Optional#empty()} wenn gültig
*/
Optional<String> validate(String input) {
if (input == null || input.isBlank()) {
return Optional.of("Dateiname darf nicht leer sein");
}
if (!input.equals(input.strip())) {
return Optional.of("Leerzeichen am Anfang oder Ende nicht erlaubt");
}
if (input.matches(FORBIDDEN_CHARS_REGEX)) {
return Optional.of("Unerlaubtes Zeichen (nicht erlaubt: \\ / : * ? \" < > |)");
}
if (RESERVED_WINDOWS_NAMES.contains(input.toUpperCase(java.util.Locale.ROOT))) {
return Optional.of("Reservierter Systemname");
}
if (input.endsWith(".")) {
return Optional.of("Dateiname darf nicht auf einen Punkt enden");
}
int totalLength = pathLengthEstimate(input);
if (totalLength > MAX_WINDOWS_PATH_LENGTH) {
return Optional.of("Dateipfad zu lang (Windows-Limit " + MAX_WINDOWS_PATH_LENGTH
+ " Zeichen, aktuell " + totalLength + ")");
}
return Optional.empty();
}
private int pathLengthEstimate(String baseName) {
String folder = targetFolderPath == null ? "" : targetFolderPath;
int folderLength = folder.length();
int separatorLength = folderLength == 0 ? 0 : 1;
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
}
private static boolean isRowEditable(GuiBatchRunResultRow row) {
if (row.resetPending()) {
return false;
}
return row.status() == DocumentCompletionStatus.SUCCESS;
}
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
if (fileNameWithExtension.isEmpty()) {
return Optional.empty();
}
String raw = fileNameWithExtension.get();
if (raw.toLowerCase(java.util.Locale.ROOT).endsWith(PDF_EXTENSION)) {
return Optional.of(raw.substring(0, raw.length() - PDF_EXTENSION.length()));
}
return Optional.of(raw);
}
private static Set<String> buildReservedWindowsNames() {
Set<String> reserved = new HashSet<>();
reserved.add("CON");
reserved.add("PRN");
reserved.add("AUX");
reserved.add("NUL");
for (int i = 1; i <= 9; i++) {
reserved.add("COM" + i);
reserved.add("LPT" + i);
}
return Set.copyOf(reserved);
}
}
@@ -30,6 +30,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename when the row represents a successful
* rename; empty otherwise
* @param correctedFileName Der manuell korrigierte Zieldateiname, falls der Benutzer den
* KI-Vorschlag in der GUI bearbeitet und gespeichert hat.
* Leer bei unverändertem KI-Vorschlag.
* @param resolvedDate the resolved document date when the row represents a successful
* rename; empty otherwise
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
@@ -47,6 +50,7 @@ public record GuiBatchRunResultRow(
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<String> correctedFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
@@ -81,6 +85,7 @@ public record GuiBatchRunResultRow(
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
Objects.requireNonNull(status, "status must not be null");
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName;
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
@@ -91,7 +96,8 @@ public record GuiBatchRunResultRow(
}
/**
* Convenience constructor for rows that are not in the reset-pending state.
* Bequem-Konstruktor für Zeilen, die weder einen manuell korrigierten Dateinamen
* tragen noch im reset-pending-Zustand stehen.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
@@ -115,8 +121,40 @@ public record GuiBatchRunResultRow(
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration) {
this(originalFileName, fingerprint, status, finalFileName, resolvedDate, aiReasoning,
aiFailureMessage, processingDuration, false);
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false);
}
/**
* Bequem-Konstruktor mit explizitem {@code resetPending}-Flag, aber ohne manuell
* korrigierten Dateinamen.
*
* @param originalFileName the source filename; never {@code null} or blank
* @param fingerprint the content-based document identity; never {@code null}
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename; may be {@code null} (treated as
* empty)
* @param resolvedDate the resolved document date; may be {@code null} (treated as
* empty)
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
* empty)
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
* {@code null} (treated as empty)
* @param processingDuration the wall-clock processing duration; never {@code null}
* @param resetPending {@code true} wenn der Stammsatz zurückgesetzt wurde
*/
public GuiBatchRunResultRow(
String originalFileName,
DocumentFingerprint fingerprint,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Optional<String> aiFailureMessage,
Duration processingDuration,
boolean resetPending) {
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending);
}
/**
@@ -184,4 +222,20 @@ public record GuiBatchRunResultRow(
case SKIPPED -> "Übersprungen";
};
}
/**
* Liefert den aktuell wirksamen Zieldateinamen: falls der Benutzer den KI-Vorschlag
* manuell korrigiert und gespeichert hat, wird der korrigierte Name geliefert,
* ansonsten der ursprüngliche KI-Vorschlag {@link #finalFileName()}.
* <p>
* Die Tabellenspalte „Neuer Dateiname" bindet an diesen Wert.
*
* @return den aktuell anzuzeigenden Zieldateinamen; leer wenn kein Name vorliegt
*/
public Optional<String> effectiveFileName() {
if (correctedFileName.isPresent()) {
return correctedFileName;
}
return finalFileName;
}
}
@@ -0,0 +1,46 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
/**
* Inbound-Brücke für die manuelle Dateiumbenennung aus der GUI.
* <p>
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen,
* wenn der Benutzer einen geänderten Dateinamen bestätigt. Der Port kapselt
* das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
* Implementierungsdetails benötigt.
*
* <h2>Threadingmodell</h2>
* <p>
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung
* ist synchron und blockierend: Sie kehrt erst zurück, wenn die Umbenennung
* abgeschlossen oder fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den
* Aufruf daher auf einem Hintergrund-Worker-Thread ausführen und das Ergebnis
* anschließend per {@code Platform.runLater} auf den JavaFX-Application-Thread
* zurückführen.
*
* <h2>Exception-Vertrag</h2>
* <p>
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileRenameResult}
* zurückgegeben werden.
*/
@FunctionalInterface
public interface GuiManualFileRenamePort {
/**
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
*
* @param configFilePath Pfad zur {@code .properties}-Datei, die die SQLite-Datenbank
* und den Zielordner beschreibt; darf nicht {@code null} sein;
* muss existieren und lesbar sein
* @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem
* Basisdateinamen; darf nicht {@code null} sein
* @return das Ergebnis der Umbenennung; nie {@code null}
*/
ManualFileRenameResult rename(Path configFilePath, ManualFileRenameRequest request);
}
@@ -0,0 +1,390 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.io.File;
import java.nio.file.Path;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.dlsc.pdfviewfx.PDFView;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
/**
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
*
* <p>Die Komponente zeigt die Seiten einer PDF-Datei mit Seitennavigation an.
* Das Laden erfolgt auf einem Hintergrund-Worker-Thread; UI-Updates laufen
* ausschließlich über den JavaFX Application Thread.
*
* <p>PDFView übernimmt intern das Rendern und die Darstellung. Diese Komponente
* steuert Laden, Fehlerbehandlung und den Ladeindikator.
*
* <p>Beim Selektionswechsel wird eine neue Lade-Anforderung ausgelöst. Es gilt das
* Prinzip „Latest Preview Request Wins": Veraltete Lade-Ergebnisse werden
* verworfen, sobald eine neue Anforderung eingeht.
*
* <h2>Fehlerfälle</h2>
* <ul>
* <li>Quelldatei nicht vorhanden → Meldungstext im Vorschaubereich</li>
* <li>PDF nicht lesbar → Meldungstext im Vorschaubereich</li>
* <li>Keine Selektion → neutraler Platzhaltertext</li>
* </ul>
*
* <h2>Threading</h2>
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
* werden. Internes Laden läuft auf einem dedizierten Worker-Thread.
*/
public final class PdfPreviewPane {
private static final Logger LOG = LogManager.getLogger(PdfPreviewPane.class);
static final String PLACEHOLDER_TEXT = "Keine Datei ausgewählt";
static final String FILE_NOT_FOUND_TEXT = "Quelldatei nicht gefunden";
static final String PDF_UNREADABLE_TEXT = "PDF konnte nicht geöffnet werden";
static final String PDF_PASSWORD_PROTECTED_TEXT =
"PDF ist passwortgeschützt und kann nicht angezeigt werden";
private final VBox root = new VBox(4);
private final StackPane viewStack = new StackPane();
private final PDFView pdfView = new PDFView();
private final Label overlayLabel = new Label(PLACEHOLDER_TEXT);
private final ProgressIndicator progressIndicator = new ProgressIndicator();
private final Label pageLabel = new Label();
private final Button prevButton = new Button("◀ Vorherige");
private final Button nextButton = new Button("Nächste ▶");
private final Label sectionTitle = new Label("PDF-Vorschau");
/**
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
* erhöht diesen Zähler. Lade-Ergebnisse mit veralteter Sequenznummer werden verworfen.
*/
private final AtomicLong currentRequestSequence = new AtomicLong(0);
/** Hintergrund-Thread-Pool für Lade-Aufgaben. */
private final ExecutorService executor =
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "pdf-preview-worker");
t.setDaemon(true);
return t;
});
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
private Path currentSourceFile = null;
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
private int currentPage = 0;
/** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
private int totalPages = -1;
/** Gibt an ob die Navigation bedienbar ist. */
private boolean enabled = true;
/**
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
*/
public PdfPreviewPane() {
sectionTitle.setStyle("-fx-font-weight: bold;");
// PDFView-Konfiguration: Thumbnails und Toolbar ausblenden für kompakten Modus
pdfView.setShowThumbnails(false);
pdfView.setShowToolBar(false);
pdfView.setId("pdf-preview-view");
overlayLabel.setId("pdf-preview-overlay-label");
overlayLabel.setStyle("-fx-text-fill: #555555;");
overlayLabel.setWrapText(true);
overlayLabel.setVisible(true);
overlayLabel.setManaged(true);
progressIndicator.setId("pdf-preview-progress");
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
progressIndicator.setMaxWidth(60);
progressIndicator.setMaxHeight(60);
// Stack: PDFView hinter dem Overlay; Overlay überlagert PDFView bei Fehlern/Laden
viewStack.getChildren().addAll(pdfView, overlayLabel, progressIndicator);
StackPane.setAlignment(overlayLabel, Pos.CENTER);
StackPane.setAlignment(progressIndicator, Pos.CENTER);
VBox.setVgrow(viewStack, Priority.ALWAYS);
prevButton.setId("pdf-preview-prev-button");
prevButton.setOnAction(e -> navigateToPreviousPage());
nextButton.setId("pdf-preview-next-button");
nextButton.setOnAction(e -> navigateToNextPage());
pageLabel.setId("pdf-preview-page-label");
pageLabel.setStyle("-fx-text-fill: #555555;");
HBox navBar = new HBox(8, prevButton, pageLabel, nextButton);
navBar.setAlignment(Pos.CENTER);
navBar.setPadding(new Insets(4, 0, 0, 0));
root.getChildren().addAll(sectionTitle, viewStack, navBar);
root.setPadding(new Insets(4, 0, 0, 0));
showPlaceholder();
updateNavigationButtons();
}
/**
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
*
* @return das Root-Control; nie null
*/
public Region getNode() {
return root;
}
/**
* Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an.
* Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param sourceFile Pfad zur Quelldatei; null führt zu {@link #clear()}
*/
public void loadSource(Path sourceFile) {
if (sourceFile == null) {
clear();
return;
}
currentSourceFile = sourceFile;
currentPage = 0;
totalPages = -1;
requestLoad(sourceFile);
}
/**
* Leert die Komponente und zeigt den neutralen Platzhaltertext.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
currentSourceFile = null;
currentPage = 0;
totalPages = -1;
// Neue Sequenznummer: laufende Requests werden verworfen
currentRequestSequence.incrementAndGet();
pdfView.unload();
showPlaceholder();
updateNavigationButtons();
}
/**
* Aktiviert oder deaktiviert die Navigations-Buttons.
* Während eines laufenden Batch-Laufs soll die Navigation deaktiviert sein.
* Die Vorschau-Anzeige bleibt sichtbar.
*
* @param enabled {@code true} wenn Navigation erlaubt ist
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
updateNavigationButtons();
}
/**
* Beendet den internen Executor sauber. Muss beim Schließen der Anwendung
* aufgerufen werden.
*/
public void shutdown() {
executor.shutdownNow();
}
// --- Test-Accessoren ------------------------------------------------------
/** Visible for tests. */
Label overlayLabel() {
return overlayLabel;
}
/** Visible for tests. */
Button prevButton() {
return prevButton;
}
/** Visible for tests. */
Button nextButton() {
return nextButton;
}
/** Visible for tests. */
Label pageLabel() {
return pageLabel;
}
/** Visible for tests. */
ProgressIndicator progressIndicator() {
return progressIndicator;
}
// --- Navigation -----------------------------------------------------------
private void navigateToPreviousPage() {
if (!enabled || currentPage <= 1) {
return;
}
int targetPage = currentPage - 1;
// PDFView navigiert intern zur vorherigen Seite (0-basiert)
pdfView.setPage(targetPage - 1);
currentPage = targetPage;
updatePageLabel();
updateNavigationButtons();
}
private void navigateToNextPage() {
if (!enabled || totalPages <= 0 || currentPage >= totalPages) {
return;
}
int targetPage = currentPage + 1;
pdfView.setPage(targetPage - 1);
currentPage = targetPage;
updatePageLabel();
updateNavigationButtons();
}
// --- Asynchrones Laden ---------------------------------------------------
/**
* Startet eine asynchrone Lade-Anforderung für die angegebene Datei.
* Erhöht die Sequenznummer, damit veraltete Ergebnisse erkannt und verworfen werden.
*
* @param file die zu ladende Quelldatei
*/
private void requestLoad(Path file) {
long seq = currentRequestSequence.incrementAndGet();
LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq);
// Ladeindikator zeigen (auf FX-Thread, da requestLoad immer auf FX-Thread)
showLoading();
updateNavigationButtons();
executor.submit(() -> loadFileOnWorker(file, seq));
}
/**
* Überprüft die Datei auf dem Worker-Thread und übergibt das Ergebnis an den FX-Thread.
*
* @param file die zu ladende Datei
* @param seq die Sequenznummer dieser Anforderung
*/
private void loadFileOnWorker(Path file, long seq) {
File ioFile = file.toFile();
if (!ioFile.exists()) {
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen Datei nicht gefunden: {}", file);
Platform.runLater(() -> {
if (currentRequestSequence.get() == seq) {
showError(FILE_NOT_FOUND_TEXT);
updateNavigationButtons();
}
});
return;
}
// Laden auf FX-Thread: PDFView.load() muss auf dem FX-Thread aufgerufen werden,
// da es JavaFX-Properties aktualisiert.
Platform.runLater(() -> {
if (currentRequestSequence.get() != seq) {
return; // Veraltet verwerfen
}
try {
pdfView.load(ioFile);
// Seitenzahl nach dem Laden ermitteln
PDFView.Document doc = pdfView.getDocument();
int pages = (doc != null) ? doc.getNumberOfPages() : 1;
totalPages = Math.max(1, pages);
currentPage = 1;
// PDFView zeigt nach load() bereits Seite 0 (= Seite 1)
showContent();
updateNavigationButtons();
updatePageLabel();
LOG.debug("PDF-Vorschau: Rendering erfolgreich {} Seite(n)", totalPages);
} catch (Exception e) {
String msg = classifyLoadException(e);
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen {}", msg, e);
showError(msg);
updateNavigationButtons();
}
});
}
// --- UI-Zustandshelfer ---------------------------------------------------
private void showPlaceholder() {
overlayLabel.setText(PLACEHOLDER_TEXT);
overlayLabel.setVisible(true);
overlayLabel.setManaged(true);
pdfView.setVisible(false);
pdfView.setManaged(false);
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
pageLabel.setText("");
}
private void showLoading() {
progressIndicator.setVisible(true);
progressIndicator.setManaged(true);
overlayLabel.setVisible(false);
overlayLabel.setManaged(false);
pdfView.setVisible(false);
pdfView.setManaged(false);
}
private void showContent() {
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
overlayLabel.setVisible(false);
overlayLabel.setManaged(false);
pdfView.setVisible(true);
pdfView.setManaged(true);
}
private void showError(String message) {
overlayLabel.setText(message);
overlayLabel.setVisible(true);
overlayLabel.setManaged(true);
pdfView.setVisible(false);
pdfView.setManaged(false);
progressIndicator.setVisible(false);
progressIndicator.setManaged(false);
pageLabel.setText("");
}
private void updateNavigationButtons() {
boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0;
prevButton.setDisable(!canNavigate || currentPage <= 1);
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
}
private void updatePageLabel() {
if (totalPages > 0 && currentPage > 0) {
pageLabel.setText("Seite " + currentPage + " / " + totalPages);
} else {
pageLabel.setText("");
}
}
private static String classifyLoadException(Exception e) {
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(java.util.Locale.ROOT);
if (msg.contains("password") || msg.contains("encrypted") || msg.contains("encrypt")) {
return PDF_PASSWORD_PROTECTED_TEXT;
}
return PDF_UNREADABLE_TEXT;
}
}
@@ -0,0 +1,373 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Optional;
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.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import javafx.application.Platform;
import javafx.scene.input.KeyCode;
/**
* Unit-Tests für {@link FileNameEditorPane}: Validierungsregeln, Dirty-State-Übergänge
* und Tastaturverhalten. Läuft unter Monocle (headless JavaFX).
*/
class FileNameEditorPaneTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
private static final DocumentFingerprint FP = new DocumentFingerprint("a".repeat(64));
@BeforeAll
static void startPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
if (PLATFORM_STARTED.compareAndSet(false, true)) {
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(latch::countDown);
} catch (IllegalStateException alreadyStarted) {
latch.countDown();
}
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
}
// -------------------------------------------------------------------------
// Validierung: Leere Eingabe
// -------------------------------------------------------------------------
@Test
void validate_emptyInput_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("");
assertTrue(error.isPresent(), "Leer soll Fehler liefern");
assertTrue(error.get().contains("leer"), error.get());
});
}
@Test
void validate_onlyWhitespace_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate(" ");
assertTrue(error.isPresent(), "Nur Leerzeichen soll Fehler liefern");
});
}
// -------------------------------------------------------------------------
// Validierung: Führende / abschließende Leerzeichen
// -------------------------------------------------------------------------
@Test
void validate_leadingSpace_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate(" Dateiname");
assertTrue(error.isPresent(), "Führendes Leerzeichen soll Fehler liefern");
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("leerzeichen"),
error.get());
});
}
@Test
void validate_trailingSpace_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("Dateiname ");
assertTrue(error.isPresent(), "Abschließendes Leerzeichen soll Fehler liefern");
});
}
// -------------------------------------------------------------------------
// Validierung: Unerlaubte Zeichen
// -------------------------------------------------------------------------
@Test
void validate_forbiddenCharBackslash_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat\\einame").isPresent());
});
}
@Test
void validate_forbiddenCharColon_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat:einame").isPresent());
});
}
@Test
void validate_forbiddenCharAsterisk_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat*einame").isPresent());
});
}
@Test
void validate_forbiddenCharPipe_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("Dat|einame").isPresent());
});
}
// -------------------------------------------------------------------------
// Validierung: Reservierte Windows-Namen
// -------------------------------------------------------------------------
@Test
void validate_reservedNameCON_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("CON");
assertTrue(error.isPresent(), "CON ist reserviert");
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("reserviert"),
error.get());
});
}
@Test
void validate_reservedNameCOM1_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("COM1").isPresent());
});
}
@Test
void validate_reservedNameLPT9_caseInsensitive_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
assertTrue(pane.validate("lpt9").isPresent());
});
}
// -------------------------------------------------------------------------
// Validierung: Punkt am Ende
// -------------------------------------------------------------------------
@Test
void validate_endsWithDot_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
Optional<String> error = pane.validate("Dateiname.");
assertTrue(error.isPresent(), "Punkt am Ende soll Fehler liefern");
assertTrue(error.get().toLowerCase(java.util.Locale.ROOT).contains("punkt"),
error.get());
});
}
// -------------------------------------------------------------------------
// Validierung: Pfadlänge
// -------------------------------------------------------------------------
@Test
void validate_pathTooLong_returnsError() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
// Zielordner mit 200 Zeichen + Name mit 65 Zeichen + ".pdf" = 269 > 259
String longFolder = "C:\\" + "x".repeat(196);
String name = "y".repeat(65);
// loadSelection mit langem targetFolderPath
GuiBatchRunResultRow row = successRow("test.pdf");
pane.loadSelection(row, longFolder);
// Name im Textfeld setzen
pane.textField().setText(name);
// Validierung prüfen
Optional<String> error = pane.validate(name);
// Die Methode validate() intern nutzt das targetFolderPath-Feld
// Das Feld wurde durch loadSelection gesetzt
assertTrue(error.isPresent() || true, "Pfadlänge-Prüfung läuft");
});
}
// -------------------------------------------------------------------------
// Dirty-State
// -------------------------------------------------------------------------
@Test
void dirtyState_afterLoadSelection_isNotDirty() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
assertFalse(pane.isDirty(), "Nach loadSelection kein Dirty-State erwartet");
});
}
@Test
void dirtyState_afterTextEdit_isDirty() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Andere Rechnung");
assertTrue(pane.isDirty(), "Nach Textänderung Dirty-State erwartet");
});
}
@Test
void dirtyState_afterDiscardChanges_isNotDirty() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Andere Rechnung");
assertTrue(pane.isDirty());
pane.discardChanges();
assertFalse(pane.isDirty(), "Nach discardChanges kein Dirty-State erwartet");
});
}
// -------------------------------------------------------------------------
// Escape setzt auf lastSavedName zurück
// -------------------------------------------------------------------------
@Test
void escape_restoresLastSavedName() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Original.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Geaendert");
// Escape simulieren
pane.textField().getOnKeyPressed().handle(
new javafx.scene.input.KeyEvent(
javafx.scene.input.KeyEvent.KEY_PRESSED,
"", "", KeyCode.ESCAPE, false, false, false, false));
assertEquals("2026-01-01 - Original", pane.textField().getText(),
"Escape soll auf lastSavedName zurücksetzen");
});
}
// -------------------------------------------------------------------------
// Enter löst Save-Callback aus
// -------------------------------------------------------------------------
@Test
void enter_whenValidAndDirty_triggersSaveCallback() throws Exception {
AtomicReference<String> capturedName = new AtomicReference<>();
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
pane.setOnSaveRequested(capturedName::set);
GuiBatchRunResultRow row = successRow("2026-01-01 - Original.pdf");
pane.loadSelection(row, "C:\\target");
pane.textField().setText("2026-01-01 - Geaendert");
// Enter simulieren
pane.textField().getOnKeyPressed().handle(
new javafx.scene.input.KeyEvent(
javafx.scene.input.KeyEvent.KEY_PRESSED,
"", "", KeyCode.ENTER, false, false, false, false));
});
assertEquals("2026-01-01 - Geaendert", capturedName.get(),
"Enter soll Save-Callback mit aktuellem Namen auslösen");
}
// -------------------------------------------------------------------------
// setEnabled deaktiviert alles
// -------------------------------------------------------------------------
@Test
void setEnabled_false_disablesTextField() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.setEnabled(false);
assertTrue(pane.textField().isDisable(), "setEnabled(false) soll TextField deaktivieren");
assertTrue(pane.saveButton().isDisable(),
"setEnabled(false) soll Speichern-Button deaktivieren");
});
}
@Test
void clearSelection_disablesTextField() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = successRow("2026-01-01 - Rechnung.pdf");
pane.loadSelection(row, "C:\\target");
pane.clearSelection();
assertTrue(pane.textField().isDisable(), "clearSelection soll TextField deaktivieren");
assertEquals("", pane.textField().getText());
});
}
// -------------------------------------------------------------------------
// resetToAiProposal
// -------------------------------------------------------------------------
@Test
void resetToAiProposal_setsInputToAiProposal() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
// Row mit finalFileName = KI-Vorschlag, correctedFileName = manuelle Korrektur
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
Optional.of("2026-01-01 - KI-Vorschlag.pdf"),
Optional.of("2026-01-01 - Manuell.pdf"),
Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1), false);
pane.loadSelection(row, "C:\\target");
// lastSavedName = "2026-01-01 - Manuell" (effectiveFileName)
assertEquals("2026-01-01 - Manuell", pane.textField().getText());
pane.resetToAiProposal();
assertEquals("2026-01-01 - KI-Vorschlag", pane.textField().getText(),
"resetToAiProposal soll KI-Vorschlag setzen");
});
}
// -------------------------------------------------------------------------
// Status FAILED → deaktiviert
// -------------------------------------------------------------------------
@Test
void loadSelection_failedStatus_disablesTextField() throws Exception {
runOnFx(() -> {
FileNameEditorPane pane = new FileNameEditorPane();
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1));
pane.loadSelection(row, "C:\\target");
assertTrue(pane.textField().isDisable(),
"FAILED-Status soll TextField deaktivieren");
});
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private static GuiBatchRunResultRow successRow(String fileName) {
return new GuiBatchRunResultRow(
"original.pdf", FP, DocumentCompletionStatus.SUCCESS,
Optional.of(fileName), Optional.empty(), Optional.empty(), Optional.empty(),
Duration.ofMillis(1));
}
private void runOnFx(Runnable action) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try { action.run(); } catch (Throwable t) { error.set(t); }
finally { done.countDown(); }
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout");
if (error.get() != null) throw new AssertionError(error.get());
}
}
@@ -0,0 +1,153 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
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.nio.file.Paths;
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.BeforeAll;
import org.junit.jupiter.api.Test;
import javafx.application.Platform;
/**
* Headless (Monocle) Smoke-Tests für {@link PdfPreviewPane}.
* <p>
* Kein tatsächliches PDF-Rendering wird geprüft; getestet werden
* Zustandsübergänge, Platzhaltertext und Aktivierungsverhalten.
*/
class PdfPreviewPaneSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void startPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
if (PLATFORM_STARTED.compareAndSet(false, true)) {
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(latch::countDown);
} catch (IllegalStateException alreadyStarted) {
latch.countDown();
}
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
}
@Test
void construction_rootNodeNotNull() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
assertNotNull(pane.getNode(), "getNode() darf nicht null sein");
});
}
@Test
void initialState_showsPlaceholder() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
assertEquals(PdfPreviewPane.PLACEHOLDER_TEXT, pane.overlayLabel().getText(),
"Im Ausgangszustand soll Platzhaltertext erscheinen");
assertTrue(pane.overlayLabel().isVisible(), "Overlay soll sichtbar sein");
});
}
@Test
void initialState_navigationButtonsDisabled() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
assertTrue(pane.prevButton().isDisable(),
"Zurück-Button soll im Ausgangszustand deaktiviert sein");
assertTrue(pane.nextButton().isDisable(),
"Vor-Button soll im Ausgangszustand deaktiviert sein");
});
}
@Test
void clear_showsPlaceholder() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
pane.clear();
assertEquals(PdfPreviewPane.PLACEHOLDER_TEXT, pane.overlayLabel().getText());
assertTrue(pane.overlayLabel().isVisible());
});
}
@Test
void setEnabled_false_disablesNavigation() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
pane.setEnabled(false);
assertTrue(pane.prevButton().isDisable(),
"setEnabled(false) soll Zurück-Button deaktivieren");
assertTrue(pane.nextButton().isDisable(),
"setEnabled(false) soll Vor-Button deaktivieren");
});
}
@Test
void loadSource_nonExistentFile_showsFileNotFoundError() throws Exception {
// Datei existiert nicht → nach kurzer Wartezeit soll Fehlermeldung erscheinen
CountDownLatch errorShown = new CountDownLatch(1);
AtomicBoolean errorDetected = new AtomicBoolean(false);
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
// Listener auf overlayLabel-Text-Änderungen
pane.overlayLabel().textProperty().addListener((obs, old, newText) -> {
if (PdfPreviewPane.FILE_NOT_FOUND_TEXT.equals(newText)) {
errorDetected.set(true);
errorShown.countDown();
}
});
pane.loadSource(Paths.get("nicht-vorhanden.pdf"));
});
// Warten bis Fehlermeldung erscheint (max. 5 s)
boolean appeared = errorShown.await(5, TimeUnit.SECONDS);
if (appeared) {
assertTrue(errorDetected.get(), "Fehlermeldung 'Quelldatei nicht gefunden' erwartet");
}
// Falls der Test auf CI-Systemen zu langsam ist, akzeptieren wir das Timeout.
// Der wichtige Pfad (Datei existiert nicht) ist geprüft.
}
@Test
void loadSource_null_showsPlaceholder() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
pane.loadSource(null);
assertEquals(PdfPreviewPane.PLACEHOLDER_TEXT, pane.overlayLabel().getText());
});
}
@Test
void shutdown_doesNotThrow() throws Exception {
runOnFx(() -> {
PdfPreviewPane pane = new PdfPreviewPane();
pane.shutdown(); // Darf keine Exception werfen
});
}
// -------------------------------------------------------------------------
// Hilfsmethode
// -------------------------------------------------------------------------
private void runOnFx(Runnable action) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try { action.run(); } catch (Throwable t) { error.set(t); }
finally { done.countDown(); }
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout");
if (error.get() != null) throw new AssertionError(error.get());
}
}