V2.8: Selektive Wiederverarbeitung und Statusreset in der GUI
- Mehrfachauswahl mit CheckBox-Spalte und Master-Tri-State-Checkbox - Gezielter Mini-Lauf über ausgewählte Einträge (unabhängig vom Status) - Statusreset für ausgewählte Einträge (Stammsatz + Versuchshistorie) - Fehlende Quelldatei im Mini-Lauf wird als FAILED_PERMANENT synthetisiert - Identische Zieldatei wird als SUCCESS ohne erneute KI-Verarbeitung erkannt - Weiche Stop-Semantik erhält zurückgesetzte Einträge unverändert - Nicht-ausgewählte Einträge bleiben in allen Pfaden unberührt - Buttons reagieren jetzt korrekt auf Auswahländerungen Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -544,6 +544,48 @@ Auf Unix-Systemen (headless CI):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GUI: Selektive Wiederverarbeitung und Status-Reset
|
||||||
|
|
||||||
|
Die GUI ermöglicht nach Abschluss eines Verarbeitungslaufs zwei zusätzliche Aktionen auf der Ergebnisliste:
|
||||||
|
|
||||||
|
### Selektion in der Ergebnisliste
|
||||||
|
|
||||||
|
Die Ergebnisliste enthält eine **Checkbox pro Zeile** sowie eine **Master-Checkbox** zum Auswählen aller Einträge.
|
||||||
|
- Auswahl erfolgt wie im Windows Explorer mit **Shift/Strg-Mehrfachselektion**
|
||||||
|
- Alle vier Statustypen sind selektierbar: erfolgreich, retryable, permanent fehlgeschlagen, übersprungen
|
||||||
|
- Während eines Laufs ist die Selektion **gesperrt**
|
||||||
|
|
||||||
|
### Button „Erneut verarbeiten"
|
||||||
|
|
||||||
|
**Aktion:** DB-Status zurücksetzen + sofortiger Mini-Lauf nur für ausgewählte Dateien.
|
||||||
|
|
||||||
|
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||||
|
- Der Mini-Lauf arbeitet auf einem Snapshot der beim Klick ausgewählten Einträge
|
||||||
|
- Nicht ausgewählte Einträge bleiben unverändert in der Liste
|
||||||
|
- Verhalten identisch zu regulärem Lauf (gleiche Anwendungslogik, nur eingeschränkte Dateimenge)
|
||||||
|
|
||||||
|
**Besonderheit bei identischem Zieldateinamen:** Verarbeitet der KI-Provider wieder denselben Dateinamen wie ein vorangegangener erfolgreicher Lauf, erhält der Eintrag **Status erfolgreich** – es wird keine erneute Kopie erzeugt, kein Fehler.
|
||||||
|
|
||||||
|
**Fehlende Quelldatei:** Ist die Datei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden, erhält der Eintrag **Status permanent fehlgeschlagen** mit Meldung „Quelldatei nicht gefunden".
|
||||||
|
|
||||||
|
### Button „Status zurücksetzen"
|
||||||
|
|
||||||
|
**Aktion:** Nur DB-Status zurücksetzen, keine sofortige Verarbeitung.
|
||||||
|
|
||||||
|
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||||
|
- Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||||
|
- Beim nächsten regulären Lauf werden zurückgesetzte Dateien automatisch mitgenommen
|
||||||
|
- **Best-effort-Reset:** Erfolgreiche und fehlgeschlagene Resets werden pro Eintrag einzeln durchgeführt; Zusammenfassung zeigt Erfolge und Fehler
|
||||||
|
|
||||||
|
### Verhalten während eines Mini-Laufs
|
||||||
|
|
||||||
|
- Der **Abbrechen-Button** gilt auch für Mini-Läufe (Soft-Stop)
|
||||||
|
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
|
||||||
|
- Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, noch nicht gestartete zurückgesetzte Einträge warten auf nächsten regulären Lauf
|
||||||
|
- Fortschrittsbalken zeigt Fortschritt für die ausgewählte Dateimenge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Weitere Dokumentation
|
## Weitere Dokumentation
|
||||||
|
|
||||||
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
|
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
|
||||||
|
|||||||
@@ -504,6 +504,80 @@ Hinweisdialog mit zwei Optionen:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 13a. Selektion, Wiederverarbeitung und Status-Reset (V2.8)
|
||||||
|
|
||||||
|
Nach Abschluss eines Verarbeitungslaufs können einzelne oder mehrere Dateien aus der
|
||||||
|
Ergebnisliste gezielt erneut verarbeitet oder deren Status zurückgesetzt werden.
|
||||||
|
|
||||||
|
### Selektion in der Ergebnisliste
|
||||||
|
|
||||||
|
- Jede Zeile hat eine **Checkbox** am linken Rand
|
||||||
|
- Zusätzlich eine **Master-Checkbox** oberhalb der Liste zum Auswählen/Abwählen aller Einträge
|
||||||
|
- **Zeilenklick** (auf Text/Status-Icon) repräsentiert dieselbe Selektionsmenge wie die Checkbox
|
||||||
|
- **Shift/Strg-Mehrfachselektion** funktioniert wie im Windows Explorer
|
||||||
|
- Shift+Klick: Bereich vom letzten zur aktuellen Zeile
|
||||||
|
- Strg+Klick: einzelne Zeilen hinzufügen/entfernen
|
||||||
|
- Alle vier Statustypen sind selektierbar: ✅ erfolgreich, ⚠️ retryable, ❌ permanent, ⏭️ übersprungen
|
||||||
|
- Die Selektion bleibt nach Aktionen erhalten, bis ein neuer Lauf gestartet wird
|
||||||
|
|
||||||
|
### Button „Erneut verarbeiten"
|
||||||
|
|
||||||
|
**Wann nutzen:** Der KI-Prompt wurde geändert, das Modell gewechselt oder die Verarbeitung einer Datei
|
||||||
|
muss aus anderen Gründen wiederholt werden – und das Ergebnis soll sofort verfügbar sein.
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
1. Wird ein Button-Klick ausgelöst, wird die aktuelle Selektion als **Snapshot** erfasst
|
||||||
|
2. Der DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||||
|
3. Ein **Mini-Lauf** startet sofort und verarbeitet nur diese Dateien
|
||||||
|
4. Unselektierte Einträge bleiben unverändert in der Liste
|
||||||
|
5. Die Mini-Lauf-Ergebnisse werden live in den selektierten Zeilen aktualisiert
|
||||||
|
|
||||||
|
**Besonderheiten:**
|
||||||
|
- Verarbeitet die KI wieder denselben Dateinamen wie der vorherige erfolgreiche Lauf,
|
||||||
|
erfolgt **keine erneute Kopie** – der Eintrag erhält Status ✅ erfolgreich
|
||||||
|
- Ist die Quelldatei nicht mehr vorhanden, erhält der Eintrag Status ❌ permanent fehlgeschlagen
|
||||||
|
mit Meldung „Quelldatei nicht gefunden"
|
||||||
|
|
||||||
|
**Button-Status:**
|
||||||
|
- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||||
|
- **Inaktiv:** Lauf läuft ODER keine Selektion
|
||||||
|
|
||||||
|
### Button „Status zurücksetzen"
|
||||||
|
|
||||||
|
**Wann nutzen:** Eine Datei soll später erneut verarbeitet werden, aber nicht sofort – z. B. nach
|
||||||
|
Behebung eines externen Fehlers oder planmäßig im nächsten regulären Lauf.
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
1. Der DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||||
|
2. Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||||
|
3. **Kein sofortiger Mini-Lauf**
|
||||||
|
4. Beim nächsten regulären Lauf werden diese Dateien automatisch mitgenommen
|
||||||
|
|
||||||
|
**Fehlerbehandlung (Best-effort):**
|
||||||
|
- Resets werden pro Eintrag einzeln durchgeführt
|
||||||
|
- Erfolgreiche und fehlgeschlagene Resets werden separat gezählt
|
||||||
|
- Zusammenfassung im Meldungsbereich zeigt:
|
||||||
|
- Anzahl ausgewählter Einträge
|
||||||
|
- Anzahl erfolgreich zurückgesetzt
|
||||||
|
- Anzahl fehlgeschlagen + betroffene Dateinamen
|
||||||
|
|
||||||
|
**Button-Status:**
|
||||||
|
- **Aktiv:** kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||||
|
- **Inaktiv:** Lauf läuft ODER keine Selektion
|
||||||
|
|
||||||
|
### Verhalten während eines Mini-Laufs
|
||||||
|
|
||||||
|
- Der **Abbrechen-Button** löst einen Soft-Stop auch für Mini-Läufe aus:
|
||||||
|
- bereits verarbeitete Einträge behalten ihren neuen Endstatus
|
||||||
|
- noch nicht gestartete, aber bereits zurückgesetzte Einträge erhalten Status
|
||||||
|
„Zurückgesetzt – wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen
|
||||||
|
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
|
||||||
|
- Der **Fortschrittsbalken** zeigt den Fortschritt für die ausgewählte Dateimenge
|
||||||
|
(Nenner = Anzahl selektierter Dateien)
|
||||||
|
- Beide Buttons „Erneut verarbeiten" und „Status zurücksetzen" sind **deaktiviert**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 14. Bekannte Einschränkungen V2.x
|
## 14. Bekannte Einschränkungen V2.x
|
||||||
|
|
||||||
| Einschränkung | Erläuterung |
|
| Einschränkung | Erläuterung |
|
||||||
|
|||||||
+21
-2
@@ -16,6 +16,8 @@ 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.GuiBatchRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
|
||||||
|
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;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState;
|
||||||
@@ -344,11 +346,24 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
private final ApiKeyResolutionPort apiKeyResolutionPort;
|
private final ApiKeyResolutionPort apiKeyResolutionPort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launcher used by the processing-run tab to execute a batch run against the saved
|
* Launcher used by the processing-run tab to execute a regular batch run against the
|
||||||
* configuration file. Supplied by Bootstrap via the startup context.
|
* saved configuration file. Supplied by Bootstrap via the startup context.
|
||||||
*/
|
*/
|
||||||
private final GuiBatchRunLauncher batchRunLauncher;
|
private final GuiBatchRunLauncher batchRunLauncher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launcher used by the processing-run tab to execute a targeted mini-run for a
|
||||||
|
* selected set of documents. Supplied by Bootstrap via the startup context.
|
||||||
|
*/
|
||||||
|
private final GuiMiniRunLauncher miniRunLauncher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port used by the processing-run tab to reset the persistence status of selected
|
||||||
|
* documents without triggering a reprocessing run. Supplied by Bootstrap via the
|
||||||
|
* startup context.
|
||||||
|
*/
|
||||||
|
private final GuiResetDocumentStatusPort resetDocumentStatusPort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Second main tab of the window that drives the live processing-run view. Created
|
* Second main tab of the window that drives the live processing-run view. Created
|
||||||
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
||||||
@@ -421,8 +436,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
triggerLabel -> showUnsavedChangesDialog(triggerLabel));
|
triggerLabel -> showUnsavedChangesDialog(triggerLabel));
|
||||||
|
|
||||||
this.batchRunLauncher = effectiveContext.batchRunLauncher();
|
this.batchRunLauncher = effectiveContext.batchRunLauncher();
|
||||||
|
this.miniRunLauncher = effectiveContext.miniRunLauncher();
|
||||||
|
this.resetDocumentStatusPort = effectiveContext.resetDocumentStatusPort();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
|
() -> this.miniRunLauncher,
|
||||||
|
() -> this.resetDocumentStatusPort,
|
||||||
this::loadedConfigurationPath,
|
this::loadedConfigurationPath,
|
||||||
this::isSavedConfigurationReady,
|
this::isSavedConfigurationReady,
|
||||||
this::applyBatchRunLockState);
|
this::applyBatchRunLockState);
|
||||||
|
|||||||
+79
-25
@@ -2,11 +2,15 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
|||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
||||||
|
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.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
|
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.AiModelCatalogPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||||
@@ -15,6 +19,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheck
|
|||||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable startup data for the GUI adapter.
|
* Immutable startup data for the GUI adapter.
|
||||||
@@ -26,9 +31,12 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical
|
|||||||
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
|
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
|
||||||
* used to execute provider-specific technical checks, the {@link PathCheckPort}
|
* used to execute provider-specific technical checks, the {@link PathCheckPort}
|
||||||
* used to verify filesystem path accessibility for configuration values, the
|
* used to verify filesystem path accessibility for configuration values, the
|
||||||
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, and the
|
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, the
|
||||||
* {@link CorrectionExecutionService} used to execute corrective actions after a
|
* {@link CorrectionExecutionService} used to execute corrective actions after a
|
||||||
* technical test run has been confirmed by the user.
|
* 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.
|
||||||
* <p>
|
* <p>
|
||||||
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
* 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.
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
@@ -44,10 +52,12 @@ public record GuiStartupContext(
|
|||||||
PathCheckPort pathCheckPort,
|
PathCheckPort pathCheckPort,
|
||||||
TechnicalTestOrchestrator technicalTestOrchestrator,
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
CorrectionExecutionService correctionExecutionService,
|
CorrectionExecutionService correctionExecutionService,
|
||||||
GuiBatchRunLauncher batchRunLauncher) {
|
GuiBatchRunLauncher batchRunLauncher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetDocumentStatusPort) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a startup context.
|
* Creates a fully wired startup context.
|
||||||
*
|
*
|
||||||
* @param initialState initial editor state; must not be {@code null}
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
@@ -59,9 +69,11 @@ public record GuiStartupContext(
|
|||||||
* @param pathCheckPort port for filesystem path accessibility 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 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 correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
* @param batchRunLauncher bridge that executes a batch run against a stored
|
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||||
* configuration path for the processing-run tab;
|
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
|
||||||
* must not be {@code null}
|
* documents; must not be {@code null}
|
||||||
|
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
||||||
|
* documents; must not be {@code null}
|
||||||
*/
|
*/
|
||||||
public GuiStartupContext {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
@@ -84,15 +96,51 @@ public record GuiStartupContext(
|
|||||||
"correctionExecutionService must not be null");
|
"correctionExecutionService must not be null");
|
||||||
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
|
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
|
||||||
"batchRunLauncher must not be null");
|
"batchRunLauncher must not be null");
|
||||||
|
miniRunLauncher = Objects.requireNonNull(miniRunLauncher,
|
||||||
|
"miniRunLauncher must not be null");
|
||||||
|
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
|
||||||
|
"resetDocumentStatusPort must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backward-compatible constructor that fills the processing-run launcher with a
|
* Backward-compatible constructor that fills the mini-run launcher and reset port
|
||||||
* no-op implementation.
|
* with no-op implementations.
|
||||||
|
*
|
||||||
|
* @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}
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
|
rejectingMiniRunLauncher(), rejectingResetPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that fills the processing-run launcher, mini-run
|
||||||
|
* launcher and reset port with no-op implementations.
|
||||||
* <p>
|
* <p>
|
||||||
* Preserves existing callers that were written before the processing-run tab was added.
|
* Preserves existing callers that were written before the processing-run tab was added.
|
||||||
* The no-op launcher rejects every start request with a clear German message so the
|
|
||||||
* UI never enters an unsafe state in legacy test wiring.
|
|
||||||
*
|
*
|
||||||
* @param initialState initial editor state; must not be {@code null}
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
@@ -119,7 +167,7 @@ public record GuiStartupContext(
|
|||||||
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher());
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -127,20 +175,24 @@ public record GuiStartupContext(
|
|||||||
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
|
||||||
|
return (configPath, filter, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||||
|
return (configPath, fingerprints) -> {
|
||||||
|
java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
failures.put(fp, "Kein Reset-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
return new ResetDocumentStatusResult(fingerprints.size(), Set.of(), failures);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a blank startup context with no loader or writer side effects, a no-op model
|
* Creates a blank startup context with no-op implementations for all ports and services.
|
||||||
* catalogue port, a no-op API key resolution port, a no-op provider technical test service,
|
|
||||||
* a no-op path check port, a no-op technical test orchestrator, and a no-op
|
|
||||||
* correction execution service.
|
|
||||||
* <p>
|
* <p>
|
||||||
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
|
|
||||||
* The no-op API key resolution port always returns {@code ABSENT}.
|
|
||||||
* The no-op provider technical test service uses the no-op ports above.
|
|
||||||
* The no-op path check port always returns {@code false} for all checks.
|
|
||||||
* The no-op technical test orchestrator returns a report where all checkpoints are
|
|
||||||
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
|
|
||||||
* The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
|
|
||||||
* returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
|
|
||||||
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
||||||
* GUI tests.
|
* GUI tests.
|
||||||
*
|
*
|
||||||
@@ -208,6 +260,8 @@ public record GuiStartupContext(
|
|||||||
noOpPathCheckPort,
|
noOpPathCheckPort,
|
||||||
noOpOrchestrator,
|
noOpOrchestrator,
|
||||||
noOpCorrectionService,
|
noOpCorrectionService,
|
||||||
noOpBatchRunLauncher);
|
noOpBatchRunLauncher,
|
||||||
|
rejectingMiniRunLauncher(),
|
||||||
|
rejectingResetPort());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-25
@@ -5,6 +5,7 @@ import java.time.Duration;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -16,12 +17,15 @@ import org.apache.logging.log4j.Logger;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coordinates a single batch run triggered from the JavaFX GUI.
|
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
|
||||||
|
* JavaFX GUI, and optional reset-only operations on selected document fingerprints.
|
||||||
* <p>
|
* <p>
|
||||||
* The coordinator owns the background worker thread that executes the run, maintains the
|
* The coordinator owns the background worker thread that executes the run, maintains the
|
||||||
* cancellation flag, and translates the
|
* cancellation flag, and translates the
|
||||||
@@ -30,7 +34,7 @@ import javafx.application.Platform;
|
|||||||
*
|
*
|
||||||
* <h2>Threading</h2>
|
* <h2>Threading</h2>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>The batch run executes on a daemon worker thread created by
|
* <li>The batch run and reset operations execute on a daemon worker thread created by
|
||||||
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
|
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
|
||||||
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
|
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
|
||||||
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
|
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
|
||||||
@@ -42,12 +46,15 @@ import javafx.application.Platform;
|
|||||||
*
|
*
|
||||||
* <h2>Lifecycle</h2>
|
* <h2>Lifecycle</h2>
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Construct with a launcher, a thread factory and a listener.</li>
|
* <li>Construct with a regular launcher, a mini-run launcher, a reset port, a thread
|
||||||
* <li>Call {@link #start(Path)} to begin a run against a configuration file.</li>
|
* factory and a listener.</li>
|
||||||
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop.</li>
|
* <li>Call {@link #start(Path)} to begin a regular run, or
|
||||||
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} on the
|
* {@link #startMiniRun(Path, Set)} for a targeted mini-run, or
|
||||||
* FX thread.</li>
|
* {@link #startReset(Path, Set)} for a status-reset-only operation.</li>
|
||||||
* <li>Start a new run only after the previous one has ended.</li>
|
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop for runs.</li>
|
||||||
|
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} or
|
||||||
|
* {@link Listener#onResetCompleted(ResetDocumentStatusResult)} on the FX thread.</li>
|
||||||
|
* <li>Start a new operation only after the previous one has ended.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*/
|
*/
|
||||||
public final class GuiBatchRunCoordinator {
|
public final class GuiBatchRunCoordinator {
|
||||||
@@ -56,7 +63,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listener interface invoked on the JavaFX Application Thread during a run.
|
* Listener interface invoked on the JavaFX Application Thread during a run or reset.
|
||||||
*/
|
*/
|
||||||
public interface Listener {
|
public interface Listener {
|
||||||
|
|
||||||
@@ -84,9 +91,24 @@ public final class GuiBatchRunCoordinator {
|
|||||||
* @param outcome a description of how the run terminated; never {@code null}
|
* @param outcome a description of how the run terminated; never {@code null}
|
||||||
*/
|
*/
|
||||||
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
|
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once after a reset-only operation has completed on the worker thread.
|
||||||
|
* <p>
|
||||||
|
* The default implementation does nothing so existing {@link Listener}
|
||||||
|
* implementations need not override this method until they need reset
|
||||||
|
* notifications.
|
||||||
|
*
|
||||||
|
* @param result the full outcome of the reset operation; never {@code null}
|
||||||
|
*/
|
||||||
|
default void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
|
// no-op default
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final GuiBatchRunLauncher launcher;
|
private final GuiBatchRunLauncher launcher;
|
||||||
|
private final GuiMiniRunLauncher miniRunLauncher;
|
||||||
|
private final GuiResetDocumentStatusPort resetPort;
|
||||||
private final Function<Runnable, Thread> threadFactory;
|
private final Function<Runnable, Thread> threadFactory;
|
||||||
private final Consumer<Runnable> fxDispatcher;
|
private final Consumer<Runnable> fxDispatcher;
|
||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
@@ -96,12 +118,38 @@ public final class GuiBatchRunCoordinator {
|
|||||||
/**
|
/**
|
||||||
* Creates the coordinator with the default worker-thread factory and the default
|
* Creates the coordinator with the default worker-thread factory and the default
|
||||||
* JavaFX Application Thread dispatcher.
|
* JavaFX Application Thread dispatcher.
|
||||||
|
* <p>
|
||||||
|
* Mini-run and reset capabilities are unavailable; all such requests will return
|
||||||
|
* {@code false}.
|
||||||
*
|
*
|
||||||
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
|
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
|
||||||
* @param listener GUI listener invoked on the FX thread; must not be null
|
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||||
*/
|
*/
|
||||||
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
|
||||||
this(launcher, defaultThreadFactory(), defaultFxDispatcher(), listener);
|
this(launcher,
|
||||||
|
rejectingMiniRunLauncher(),
|
||||||
|
rejectingResetPort(),
|
||||||
|
defaultThreadFactory(),
|
||||||
|
defaultFxDispatcher(),
|
||||||
|
listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with all ports and the default worker-thread factory and
|
||||||
|
* JavaFX Application Thread dispatcher.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Listener listener) {
|
||||||
|
this(launcher, miniRunLauncher, resetPort,
|
||||||
|
defaultThreadFactory(), defaultFxDispatcher(), listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +160,34 @@ public final class GuiBatchRunCoordinator {
|
|||||||
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
|
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
|
||||||
* initialised.
|
* initialised.
|
||||||
*
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||||
|
* be null
|
||||||
|
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||||
|
* Thread; must not be null
|
||||||
|
* @param listener GUI listener; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Function<Runnable, Thread> threadFactory,
|
||||||
|
Consumer<Runnable> fxDispatcher,
|
||||||
|
Listener listener) {
|
||||||
|
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
|
||||||
|
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
|
||||||
|
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
|
||||||
|
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
|
||||||
|
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
|
||||||
|
this.listener = Objects.requireNonNull(listener, "listener must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy constructor retained for backward compatibility with tests that do not
|
||||||
|
* require mini-run or reset capabilities.
|
||||||
|
*
|
||||||
* @param launcher bridge to Bootstrap; must not be null
|
* @param launcher bridge to Bootstrap; must not be null
|
||||||
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||||
* be null
|
* be null
|
||||||
@@ -123,16 +199,18 @@ public final class GuiBatchRunCoordinator {
|
|||||||
Function<Runnable, Thread> threadFactory,
|
Function<Runnable, Thread> threadFactory,
|
||||||
Consumer<Runnable> fxDispatcher,
|
Consumer<Runnable> fxDispatcher,
|
||||||
Listener listener) {
|
Listener listener) {
|
||||||
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
|
this(launcher,
|
||||||
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
|
rejectingMiniRunLauncher(),
|
||||||
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
|
rejectingResetPort(),
|
||||||
this.listener = Objects.requireNonNull(listener, "listener must not be null");
|
threadFactory,
|
||||||
|
fxDispatcher,
|
||||||
|
listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether a run is currently active.
|
* Returns whether a run or reset is currently active.
|
||||||
*
|
*
|
||||||
* @return {@code true} while a worker thread is processing a run
|
* @return {@code true} while a worker thread is executing
|
||||||
*/
|
*/
|
||||||
public boolean isRunning() {
|
public boolean isRunning() {
|
||||||
Thread worker = activeWorker.get();
|
Thread worker = activeWorker.get();
|
||||||
@@ -140,7 +218,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a new run for the supplied configuration file.
|
* Starts a new regular run for the supplied configuration file.
|
||||||
* <p>
|
* <p>
|
||||||
* Immediately returns once the worker thread has been started. All further progress
|
* Immediately returns once the worker thread has been started. All further progress
|
||||||
* is communicated through the configured {@link Listener} on the JavaFX Application
|
* is communicated through the configured {@link Listener} on the JavaFX Application
|
||||||
@@ -160,19 +238,71 @@ public final class GuiBatchRunCoordinator {
|
|||||||
}
|
}
|
||||||
cancellationRequested.set(false);
|
cancellationRequested.set(false);
|
||||||
Runnable task = () -> executeRun(configFilePath);
|
Runnable task = () -> executeRun(configFilePath);
|
||||||
Thread worker = threadFactory.apply(task);
|
return startWorker(task);
|
||||||
Objects.requireNonNull(worker, "threadFactory must not return null");
|
|
||||||
activeWorker.set(worker);
|
|
||||||
worker.start();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests soft-stop cancellation of the currently running batch.
|
* Starts a targeted mini-run for the supplied fingerprint filter.
|
||||||
|
* <p>
|
||||||
|
* The worker thread first delegates to the {@link GuiMiniRunLauncher} which applies
|
||||||
|
* the full processing pipeline to only the specified documents. Progress callbacks
|
||||||
|
* are forwarded to the {@link Listener} on the JavaFX Application Thread in the same
|
||||||
|
* way as for a regular run.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file; must not be {@code null}
|
||||||
|
* @param fingerprintFilter the set of document fingerprints to process; must not be
|
||||||
|
* {@code null}
|
||||||
|
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||||
|
* was already in progress
|
||||||
|
* @throws NullPointerException if any argument is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean startMiniRun(Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cancellationRequested.set(false);
|
||||||
|
Runnable task = () -> executeMiniRun(configFilePath, fingerprintFilter);
|
||||||
|
return startWorker(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a reset-only operation for the supplied fingerprint set.
|
||||||
|
* <p>
|
||||||
|
* The worker thread calls the {@link GuiResetDocumentStatusPort} to delete all
|
||||||
|
* persistence data for the specified fingerprints. No reprocessing run is triggered.
|
||||||
|
* On completion the {@link Listener#onResetCompleted(ResetDocumentStatusResult)} callback
|
||||||
|
* is invoked on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file that identifies the database; must not
|
||||||
|
* be {@code null}
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be
|
||||||
|
* {@code null}
|
||||||
|
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||||
|
* was already in progress
|
||||||
|
* @throws NullPointerException if any argument is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reset does not support cancellation; set the flag to false so the
|
||||||
|
// running state is consistent with the pattern used by run operations.
|
||||||
|
cancellationRequested.set(false);
|
||||||
|
Runnable task = () -> executeReset(configFilePath, fingerprints);
|
||||||
|
return startWorker(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests soft-stop cancellation of the currently running batch or mini-run.
|
||||||
* <p>
|
* <p>
|
||||||
* The flag is honoured between candidates — the candidate that is currently being
|
* The flag is honoured between candidates — the candidate that is currently being
|
||||||
* processed is always completed in full and persisted before the run ends. Calling
|
* processed is always completed in full and persisted before the run ends. Calling
|
||||||
* this method when no run is active has no effect.
|
* this method when no run is active has no effect. Reset operations ignore this flag.
|
||||||
*/
|
*/
|
||||||
public void requestCancellation() {
|
public void requestCancellation() {
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
@@ -190,6 +320,18 @@ public final class GuiBatchRunCoordinator {
|
|||||||
return cancellationRequested.get();
|
return cancellationRequested.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Worker helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private boolean startWorker(Runnable task) {
|
||||||
|
Thread worker = threadFactory.apply(task);
|
||||||
|
Objects.requireNonNull(worker, "threadFactory must not return null");
|
||||||
|
activeWorker.set(worker);
|
||||||
|
worker.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void executeRun(Path configFilePath) {
|
private void executeRun(Path configFilePath) {
|
||||||
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
||||||
configFilePath);
|
configFilePath);
|
||||||
@@ -210,6 +352,58 @@ public final class GuiBatchRunCoordinator {
|
|||||||
"Unerwarteter technischer Fehler: "
|
"Unerwarteter technischer Fehler: "
|
||||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
}
|
}
|
||||||
|
finishRun(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
|
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
|
||||||
|
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
|
||||||
|
observerSummary.set(null);
|
||||||
|
BatchRunProgressObserver observer = buildDispatchingObserver();
|
||||||
|
BatchRunCancellationToken token = cancellationRequested::get;
|
||||||
|
GuiBatchRunLaunchOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = miniRunLauncher.launch(configFilePath, fingerprintFilter, observer, token);
|
||||||
|
if (outcome == null) {
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Mini-Run-Launcher hat kein Ergebnis geliefert.");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Unerwarteter technischer Fehler im Mini-Lauf: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
finishRun(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||||
|
LOG.info("GUI-Status-Reset: Worker-Thread gestartet für {} Dokument(e), "
|
||||||
|
+ "Konfiguration {}.", fingerprints.size(), configFilePath);
|
||||||
|
ResetDocumentStatusResult result;
|
||||||
|
try {
|
||||||
|
result = resetPort.reset(configFilePath, fingerprints);
|
||||||
|
if (result == null) {
|
||||||
|
result = new ResetDocumentStatusResult(fingerprints.size(),
|
||||||
|
Set.of(), allFailureMap(fingerprints,
|
||||||
|
"Reset-Port hat kein Ergebnis geliefert."));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Status-Reset: Unerwarteter Fehler im Worker-Thread: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
String msg = "Unerwarteter technischer Fehler beim Status-Reset: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||||
|
result = new ResetDocumentStatusResult(fingerprints.size(),
|
||||||
|
Set.of(), allFailureMap(fingerprints, msg));
|
||||||
|
}
|
||||||
|
ResetDocumentStatusResult finalResult = result;
|
||||||
|
activeWorker.set(null);
|
||||||
|
fxDispatcher.accept(() -> listener.onResetCompleted(finalResult));
|
||||||
|
LOG.info("GUI-Status-Reset: Worker-Thread beendet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishRun(GuiBatchRunLaunchOutcome outcome) {
|
||||||
RunSummary summary = observerSummary.get();
|
RunSummary summary = observerSummary.get();
|
||||||
if (summary == null) {
|
if (summary == null) {
|
||||||
summary = new RunSummary(0, 0, 0);
|
summary = new RunSummary(0, 0, 0);
|
||||||
@@ -221,6 +415,15 @@ public final class GuiBatchRunCoordinator {
|
|||||||
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static java.util.Map<DocumentFingerprint, String> allFailureMap(
|
||||||
|
Set<DocumentFingerprint> fingerprints, String message) {
|
||||||
|
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
map.put(fp, message);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures the final summary supplied by the application layer. Written on the
|
* Captures the final summary supplied by the application layer. Written on the
|
||||||
* worker thread; read only after the run has ended.
|
* worker thread; read only after the run has ended.
|
||||||
@@ -244,7 +447,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
public void onRunEnded(RunSummary summary) {
|
public void onRunEnded(RunSummary summary) {
|
||||||
observerSummary.set(summary);
|
observerSummary.set(summary);
|
||||||
// No FX dispatch here: the worker thread invokes the listener's
|
// No FX dispatch here: the worker thread invokes the listener's
|
||||||
// onRunEnded via executeRun() once the launcher has returned, ensuring
|
// onRunEnded via finishRun() once the launcher has returned, ensuring
|
||||||
// the outcome carries the launcher's terminal verdict.
|
// the outcome carries the launcher's terminal verdict.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -260,6 +463,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
Duration duration = event.processingDuration();
|
Duration duration = event.processingDuration();
|
||||||
return new GuiBatchRunResultRow(
|
return new GuiBatchRunResultRow(
|
||||||
event.originalFileName(),
|
event.originalFileName(),
|
||||||
|
event.fingerprint(),
|
||||||
event.status(),
|
event.status(),
|
||||||
finalName,
|
finalName,
|
||||||
date,
|
date,
|
||||||
@@ -278,4 +482,26 @@ public final class GuiBatchRunCoordinator {
|
|||||||
private static Consumer<Runnable> defaultFxDispatcher() {
|
private static Consumer<Runnable> defaultFxDispatcher() {
|
||||||
return Platform::runLater;
|
return Platform::runLater;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
|
||||||
|
return (configFilePath, fingerprintFilter, observer, cancellationToken) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Mini-Run-Launcher in diesem Kontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||||
|
return (configFilePath, fingerprints) ->
|
||||||
|
new ResetDocumentStatusResult(fingerprints.size(),
|
||||||
|
Set.of(), allFailureMapStatic(fingerprints,
|
||||||
|
"Kein Reset-Port in diesem Kontext verfügbar."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.Map<DocumentFingerprint, String> allFailureMapStatic(
|
||||||
|
Set<DocumentFingerprint> fingerprints, String message) {
|
||||||
|
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
map.put(fp, message);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-17
@@ -6,6 +6,7 @@ import java.util.Objects;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable view model for a single row in the processing-run result list.
|
* Immutable view model for a single row in the processing-run result list.
|
||||||
@@ -14,32 +15,57 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
|||||||
* that is shown in the list and the side panel; it is decoupled from the persistence
|
* that is shown in the list and the side panel; it is decoupled from the persistence
|
||||||
* model so later GUI layers can render it without reaching back into the application
|
* model so later GUI layers can render it without reaching back into the application
|
||||||
* layer.
|
* layer.
|
||||||
|
* <p>
|
||||||
|
* The {@code fingerprint} field is the content-based identity of the document and is
|
||||||
|
* used as a stable key for in-place row updates during a targeted mini-run.
|
||||||
|
* <p>
|
||||||
|
* When {@code resetPending} is {@code true} the row represents a document whose
|
||||||
|
* persistence status has been deleted but which has not yet been reprocessed. The status
|
||||||
|
* icon and label reflect this special state instead of the original processing outcome.
|
||||||
*
|
*
|
||||||
* @param originalFileName the source filename as reported by the use case; never
|
* @param originalFileName the source filename as reported by the use case; never
|
||||||
* {@code null} or blank
|
* {@code null} or blank
|
||||||
* @param status the aggregated completion status; never {@code null}
|
* @param fingerprint the content-based identity of the processed document; never
|
||||||
* @param finalFileName the final target filename when the row represents a successful
|
* {@code null}
|
||||||
* rename; empty otherwise
|
* @param status the aggregated completion status; never {@code null}
|
||||||
* @param resolvedDate the resolved document date when the row represents a successful
|
* @param finalFileName the final target filename when the row represents a successful
|
||||||
* rename; empty otherwise
|
* rename; empty otherwise
|
||||||
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
* @param resolvedDate the resolved document date when the row represents a successful
|
||||||
* reasoning is available for this row
|
* rename; empty otherwise
|
||||||
|
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
||||||
|
* reasoning is available for this row
|
||||||
* @param processingDuration wall-clock duration spent on the candidate in this run;
|
* @param processingDuration wall-clock duration spent on the candidate in this run;
|
||||||
* never {@code null} and never negative
|
* never {@code null} and never negative
|
||||||
|
* @param resetPending {@code true} when the document's persistence status has been
|
||||||
|
* reset and is awaiting the next processing run
|
||||||
*/
|
*/
|
||||||
public record GuiBatchRunResultRow(
|
public record GuiBatchRunResultRow(
|
||||||
String originalFileName,
|
String originalFileName,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
DocumentCompletionStatus status,
|
DocumentCompletionStatus status,
|
||||||
Optional<String> finalFileName,
|
Optional<String> finalFileName,
|
||||||
Optional<LocalDate> resolvedDate,
|
Optional<LocalDate> resolvedDate,
|
||||||
Optional<String> aiReasoning,
|
Optional<String> aiReasoning,
|
||||||
Duration processingDuration) {
|
Duration processingDuration,
|
||||||
|
boolean resetPending) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label shown in the status column when a document's persistence status has been
|
||||||
|
* reset and is waiting for the next processing run.
|
||||||
|
*/
|
||||||
|
static final String RESET_PENDING_LABEL = "Zurückgesetzt – wartet auf nächsten Lauf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon shown in the status column when a document's persistence status has been reset.
|
||||||
|
*/
|
||||||
|
static final String RESET_PENDING_ICON = "\u27F3"; // ⟳ CLOCKWISE GAPPED CIRCLE ARROW
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact constructor normalising optional holders and validating mandatory fields.
|
* Compact constructor normalising optional holders and validating mandatory fields.
|
||||||
*
|
*
|
||||||
* @throws NullPointerException if {@code originalFileName}, {@code status} or
|
* @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
|
||||||
* {@code processingDuration} is {@code null}
|
* {@code status} or {@code processingDuration} is
|
||||||
|
* {@code null}
|
||||||
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||||
* {@code processingDuration} is negative
|
* {@code processingDuration} is negative
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +74,7 @@ public record GuiBatchRunResultRow(
|
|||||||
if (originalFileName.isBlank()) {
|
if (originalFileName.isBlank()) {
|
||||||
throw new IllegalArgumentException("originalFileName must not be blank");
|
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||||
}
|
}
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
Objects.requireNonNull(status, "status must not be null");
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
||||||
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
||||||
@@ -59,17 +86,93 @@ public record GuiBatchRunResultRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the status icon for this row as a Basic Multilingual Plane character
|
* Convenience constructor for rows that are not in the reset-pending state.
|
||||||
* that renders reliably in JavaFX on Windows.
|
*
|
||||||
|
* @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 processingDuration the wall-clock processing duration; never {@code null}
|
||||||
|
*/
|
||||||
|
public GuiBatchRunResultRow(
|
||||||
|
String originalFileName,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
Optional<String> finalFileName,
|
||||||
|
Optional<LocalDate> resolvedDate,
|
||||||
|
Optional<String> aiReasoning,
|
||||||
|
Duration processingDuration) {
|
||||||
|
this(originalFileName, fingerprint, status, finalFileName, resolvedDate, aiReasoning,
|
||||||
|
processingDuration, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reset-pending copy of the supplied row, preserving the original filename
|
||||||
|
* and fingerprint while marking the row as awaiting the next processing run.
|
||||||
|
* <p>
|
||||||
|
* The returned row has {@code resetPending == true}. Its {@code statusIcon()} and
|
||||||
|
* {@code statusLabel()} reflect the reset state.
|
||||||
|
*
|
||||||
|
* @param previousRow the row to copy; must not be {@code null}
|
||||||
|
* @return a new row with the same filename and fingerprint, {@code resetPending == true}
|
||||||
|
* @throws NullPointerException if {@code previousRow} is {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunResultRow resetMarker(GuiBatchRunResultRow previousRow) {
|
||||||
|
Objects.requireNonNull(previousRow, "previousRow must not be null");
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
previousRow.originalFileName(),
|
||||||
|
previousRow.fingerprint(),
|
||||||
|
previousRow.status(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Duration.ZERO,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the status icon for this row as a Unicode character that renders reliably
|
||||||
|
* in JavaFX on Windows.
|
||||||
|
* <p>
|
||||||
|
* When {@code resetPending} is {@code true} the reset icon is returned regardless of
|
||||||
|
* the underlying status.
|
||||||
*
|
*
|
||||||
* @return the corresponding status character
|
* @return the corresponding status character
|
||||||
*/
|
*/
|
||||||
public String statusIcon() {
|
public String statusIcon() {
|
||||||
|
if (resetPending) {
|
||||||
|
return RESET_PENDING_ICON;
|
||||||
|
}
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
|
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
|
||||||
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN (no variation selector)
|
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
|
||||||
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
|
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
|
||||||
case SKIPPED -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
|
case SKIPPED -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the human-readable status label for this row.
|
||||||
|
* <p>
|
||||||
|
* When {@code resetPending} is {@code true} the reset-pending label is returned
|
||||||
|
* regardless of the underlying status.
|
||||||
|
*
|
||||||
|
* @return a non-null German status label
|
||||||
|
*/
|
||||||
|
public String statusLabel() {
|
||||||
|
if (resetPending) {
|
||||||
|
return RESET_PENDING_LABEL;
|
||||||
|
}
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "Erfolgreich";
|
||||||
|
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
|
||||||
|
case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
|
||||||
|
case SKIPPED -> "Übersprungen";
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+582
-68
@@ -3,28 +3,40 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.collections.ObservableSet;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.CheckBox;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.ProgressBar;
|
import javafx.scene.control.ProgressBar;
|
||||||
import javafx.scene.control.ScrollPane;
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.SelectionMode;
|
||||||
import javafx.scene.control.Tab;
|
import javafx.scene.control.Tab;
|
||||||
import javafx.scene.control.TableCell;
|
import javafx.scene.control.TableCell;
|
||||||
import javafx.scene.control.TableColumn;
|
import javafx.scene.control.TableColumn;
|
||||||
@@ -44,6 +56,11 @@ import javafx.scene.layout.VBox;
|
|||||||
* inside the GUI. It collaborates with a {@link GuiBatchRunCoordinator} which owns the
|
* inside the GUI. It collaborates with a {@link GuiBatchRunCoordinator} which owns the
|
||||||
* background worker thread and forwards progress callbacks here on the JavaFX Application
|
* background worker thread and forwards progress callbacks here on the JavaFX Application
|
||||||
* Thread.
|
* Thread.
|
||||||
|
* <p>
|
||||||
|
* After a run completes, the user may select one or more rows and trigger either
|
||||||
|
* "Erneut verarbeiten" (reset + immediate mini-run for selected documents) or
|
||||||
|
* "Status zurücksetzen" (reset only, for reprocessing in the next regular run).
|
||||||
|
* Selection is locked while any run or reset is active.
|
||||||
*
|
*
|
||||||
* <h2>Layout</h2>
|
* <h2>Layout</h2>
|
||||||
* <pre>
|
* <pre>
|
||||||
@@ -51,8 +68,10 @@ import javafx.scene.layout.VBox;
|
|||||||
* │ [Fortschrittsbalken] 12 / 47 Dateien │
|
* │ [Fortschrittsbalken] 12 / 47 Dateien │
|
||||||
* ├──────────────────────────────────┬───────────────────┤
|
* ├──────────────────────────────────┬───────────────────┤
|
||||||
* │ Ergebnisliste │ Seitenbereich │
|
* │ Ergebnisliste │ Seitenbereich │
|
||||||
* │ (TableView) │ (Reasoning) │
|
* │ (TableView mit Checkbox-Spalte) │ (Reasoning) │
|
||||||
* ├──────────────────────────────────┴───────────────────┤
|
* ├──────────────────────────────────┴───────────────────┤
|
||||||
|
* │ [Erneut verarbeiten] [Status zurücksetzen] │
|
||||||
|
* ├──────────────────────────────────────────────────────┤
|
||||||
* │ Meldungs- und Zusammenfassungsbereich │
|
* │ Meldungs- und Zusammenfassungsbereich │
|
||||||
* ├──────────────────────────────────────────────────────┤
|
* ├──────────────────────────────────────────────────────┤
|
||||||
* │ [Starten] [Abbrechen] │
|
* │ [Starten] [Abbrechen] │
|
||||||
@@ -95,6 +114,7 @@ public final class GuiBatchRunTab {
|
|||||||
private static final double DETAIL_PANE_MIN_WIDTH = 280;
|
private static final double DETAIL_PANE_MIN_WIDTH = 280;
|
||||||
private static final double LIST_MIN_HEIGHT = 240;
|
private static final double LIST_MIN_HEIGHT = 240;
|
||||||
private static final double DETAIL_AREA_MIN_HEIGHT = 240;
|
private static final double DETAIL_AREA_MIN_HEIGHT = 240;
|
||||||
|
private static final double CHECKBOX_COL_WIDTH = 40;
|
||||||
private static final int SECONDARY_SPACING = 12;
|
private static final int SECONDARY_SPACING = 12;
|
||||||
|
|
||||||
private final Tab tab = new Tab(TAB_TITLE);
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
@@ -102,10 +122,45 @@ public final class GuiBatchRunTab {
|
|||||||
private final Label counterLabel = new Label("0 / 0 Dateien");
|
private final Label counterLabel = new Label("0 / 0 Dateien");
|
||||||
private final TableView<GuiBatchRunResultRow> resultTable = new TableView<>();
|
private final TableView<GuiBatchRunResultRow> resultTable = new TableView<>();
|
||||||
private final ObservableList<GuiBatchRunResultRow> resultItems = FXCollections.observableArrayList();
|
private final ObservableList<GuiBatchRunResultRow> resultItems = FXCollections.observableArrayList();
|
||||||
|
/**
|
||||||
|
* {@code true} when the active run is a targeted mini-run rather than a regular batch
|
||||||
|
* run. Used to decide whether {@link #onDocumentCompleted} should update rows in-place
|
||||||
|
* (mini-run) or always append new rows (regular run).
|
||||||
|
*/
|
||||||
|
private boolean activeRunIsMiniRun = false;
|
||||||
|
/**
|
||||||
|
* Snapshot of fingerprints selected at mini-run start, mapped to their original
|
||||||
|
* filenames. Used to synthesize failure rows for source files that have disappeared
|
||||||
|
* between selection and processing.
|
||||||
|
*/
|
||||||
|
private Map<DocumentFingerprint, String> miniRunSnapshotFilenames = Map.of();
|
||||||
|
/**
|
||||||
|
* Fingerprints that received an {@code onDocumentCompleted} callback during the
|
||||||
|
* current mini-run. Used to detect selected documents that the use case silently
|
||||||
|
* skipped because their source file no longer exists.
|
||||||
|
*/
|
||||||
|
private Set<DocumentFingerprint> miniRunCompletedFingerprints = new HashSet<>();
|
||||||
|
/**
|
||||||
|
* Logical selection set – membership defines which rows are "checked". Both the
|
||||||
|
* TableView row selection model and the per-row checkboxes stay synchronised with
|
||||||
|
* this set on the FX thread.
|
||||||
|
*/
|
||||||
|
private final ObservableSet<GuiBatchRunResultRow> selectedRows =
|
||||||
|
FXCollections.observableSet();
|
||||||
|
/**
|
||||||
|
* When {@code true} selection-change listeners do not propagate back and forth,
|
||||||
|
* preventing feedback loops during programmatic synchronisation.
|
||||||
|
*/
|
||||||
|
private boolean selectionSyncInProgress = false;
|
||||||
|
/** Master checkbox in the checkbox column header — tri-state. */
|
||||||
|
private final CheckBox masterCheckBox = new CheckBox();
|
||||||
|
|
||||||
private final TextArea detailArea = new TextArea(DETAIL_PLACEHOLDER);
|
private final TextArea detailArea = new TextArea(DETAIL_PLACEHOLDER);
|
||||||
private final TextArea messageArea = new TextArea();
|
private final TextArea messageArea = new TextArea();
|
||||||
private final Button startButton = new Button("Starten");
|
private final Button startButton = new Button("Starten");
|
||||||
private final Button cancelButton = new Button("Abbrechen");
|
private final Button cancelButton = new Button("Abbrechen");
|
||||||
|
private final Button reprocessButton = new Button("Erneut verarbeiten");
|
||||||
|
private final Button resetStatusButton = new Button("Status zurücksetzen");
|
||||||
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
|
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
|
||||||
|
|
||||||
private final Supplier<Path> configPathSupplier;
|
private final Supplier<Path> configPathSupplier;
|
||||||
@@ -120,11 +175,19 @@ public final class GuiBatchRunTab {
|
|||||||
private int skippedCount;
|
private int skippedCount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the processing-run tab and wires all UI controls.
|
* Creates the processing-run tab with all processing, mini-run and reset capabilities,
|
||||||
|
* and wires all UI controls.
|
||||||
*
|
*
|
||||||
* @param launcherSupplier supplier returning the active
|
* @param launcherSupplier supplier returning the active
|
||||||
* {@link GuiBatchRunLauncher}; called when the
|
* {@link GuiBatchRunLauncher}; called when the
|
||||||
* user presses "Starten"; must not be null
|
* user presses "Starten"; must not be null
|
||||||
|
* @param miniRunLauncherSupplier supplier returning the active
|
||||||
|
* {@link GuiMiniRunLauncher}; called when the user
|
||||||
|
* presses "Erneut verarbeiten"; must not be null
|
||||||
|
* @param resetPortSupplier supplier returning the active
|
||||||
|
* {@link GuiResetDocumentStatusPort}; called when
|
||||||
|
* the user presses either selection-action button;
|
||||||
|
* must not be null
|
||||||
* @param configPathSupplier supplier returning the last saved configuration
|
* @param configPathSupplier supplier returning the last saved configuration
|
||||||
* path to run against; may return {@code null}
|
* path to run against; may return {@code null}
|
||||||
* when no configuration is loaded
|
* when no configuration is loaded
|
||||||
@@ -134,15 +197,18 @@ public final class GuiBatchRunTab {
|
|||||||
* edit has made it unusable; must not be null
|
* edit has made it unusable; must not be null
|
||||||
* @param onRunStateChanged callback invoked on the FX thread whenever the
|
* @param onRunStateChanged callback invoked on the FX thread whenever the
|
||||||
* running flag flips; typically used by the
|
* running flag flips; typically used by the
|
||||||
* workspace to sperren/entsperren Tab 1 and to
|
* workspace to lock/unlock Tab 1 and to rewire the
|
||||||
* rewire the close-request handler; must not be
|
* close-request handler; must not be null
|
||||||
* null
|
|
||||||
*/
|
*/
|
||||||
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
||||||
|
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
|
||||||
|
Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
|
||||||
Supplier<Path> configPathSupplier,
|
Supplier<Path> configPathSupplier,
|
||||||
BooleanSupplier savedConfigurationReadyCheck,
|
BooleanSupplier savedConfigurationReadyCheck,
|
||||||
Runnable onRunStateChanged) {
|
Runnable onRunStateChanged) {
|
||||||
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
|
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
|
||||||
|
Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
|
||||||
|
Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
|
||||||
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier must not be null");
|
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier must not be null");
|
||||||
this.savedConfigurationReadyCheck = Objects.requireNonNull(
|
this.savedConfigurationReadyCheck = Objects.requireNonNull(
|
||||||
savedConfigurationReadyCheck, "savedConfigurationReadyCheck must not be null");
|
savedConfigurationReadyCheck, "savedConfigurationReadyCheck must not be null");
|
||||||
@@ -151,6 +217,10 @@ public final class GuiBatchRunTab {
|
|||||||
this.coordinator = new GuiBatchRunCoordinator(
|
this.coordinator = new GuiBatchRunCoordinator(
|
||||||
(configPath, observer, token) ->
|
(configPath, observer, token) ->
|
||||||
launcherSupplier.get().launch(configPath, observer, token),
|
launcherSupplier.get().launch(configPath, observer, token),
|
||||||
|
(configPath, filter, observer, token) ->
|
||||||
|
miniRunLauncherSupplier.get().launch(configPath, filter, observer, token),
|
||||||
|
(configPath, fingerprints) ->
|
||||||
|
resetPortSupplier.get().reset(configPath, fingerprints),
|
||||||
new CoordinatorListener());
|
new CoordinatorListener());
|
||||||
this.tab.setClosable(false);
|
this.tab.setClosable(false);
|
||||||
this.tab.setContent(buildContent());
|
this.tab.setContent(buildContent());
|
||||||
@@ -159,6 +229,35 @@ public final class GuiBatchRunTab {
|
|||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor for callers that do not need mini-run or reset
|
||||||
|
* capabilities.
|
||||||
|
*
|
||||||
|
* @param launcherSupplier supplier returning the active
|
||||||
|
* {@link GuiBatchRunLauncher}; must not be null
|
||||||
|
* @param configPathSupplier supplier returning the last saved configuration
|
||||||
|
* path; may return {@code null}
|
||||||
|
* @param savedConfigurationReadyCheck check before each start attempt; must not be
|
||||||
|
* null
|
||||||
|
* @param onRunStateChanged callback when the running flag flips; must not
|
||||||
|
* be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
||||||
|
Supplier<Path> configPathSupplier,
|
||||||
|
BooleanSupplier savedConfigurationReadyCheck,
|
||||||
|
Runnable onRunStateChanged) {
|
||||||
|
this(launcherSupplier,
|
||||||
|
() -> GuiBatchRunTab::rejectingMiniLaunch,
|
||||||
|
() -> GuiBatchRunTab::rejectingReset,
|
||||||
|
configPathSupplier,
|
||||||
|
savedConfigurationReadyCheck,
|
||||||
|
onRunStateChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JavaFX {@link Tab} node that hosts the processing-run view.
|
* Returns the JavaFX {@link Tab} node that hosts the processing-run view.
|
||||||
*
|
*
|
||||||
@@ -197,45 +296,49 @@ public final class GuiBatchRunTab {
|
|||||||
cancelButton.setDisable(true);
|
cancelButton.setDisable(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Visible for tests. */
|
// -------------------------------------------------------------------------
|
||||||
Button startButton() {
|
// Package-private accessors for tests
|
||||||
return startButton;
|
// -------------------------------------------------------------------------
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
Button cancelButton() {
|
Button startButton() { return startButton; }
|
||||||
return cancelButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
ProgressBar progressBar() {
|
Button cancelButton() { return cancelButton; }
|
||||||
return progressBar;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
TableView<GuiBatchRunResultRow> resultTable() {
|
Button reprocessButton() { return reprocessButton; }
|
||||||
return resultTable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
TextArea messageArea() {
|
Button resetStatusButton() { return resetStatusButton; }
|
||||||
return messageArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
TextArea detailArea() {
|
ProgressBar progressBar() { return progressBar; }
|
||||||
return detailArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
Label counterLabel() {
|
TableView<GuiBatchRunResultRow> resultTable() { return resultTable; }
|
||||||
return counterLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
/** Visible for tests. */
|
||||||
GuiBatchRunCoordinator coordinator() {
|
TextArea messageArea() { return messageArea; }
|
||||||
return coordinator;
|
|
||||||
}
|
/** Visible for tests. */
|
||||||
|
TextArea detailArea() { return detailArea; }
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Label counterLabel() { return counterLabel; }
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
GuiBatchRunCoordinator coordinator() { return coordinator; }
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
ObservableSet<GuiBatchRunResultRow> selectedRows() { return selectedRows; }
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
CheckBox masterCheckBox() { return masterCheckBox; }
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Layout builders
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private BorderPane buildContent() {
|
private BorderPane buildContent() {
|
||||||
BorderPane layout = new BorderPane();
|
BorderPane layout = new BorderPane();
|
||||||
@@ -292,6 +395,22 @@ public final class GuiBatchRunTab {
|
|||||||
resultTable.setItems(resultItems);
|
resultTable.setItems(resultItems);
|
||||||
resultTable.setId("batch-run-result-table");
|
resultTable.setId("batch-run-result-table");
|
||||||
resultTable.setPlaceholder(new Label("Noch kein Verarbeitungslauf gestartet."));
|
resultTable.setPlaceholder(new Label("Noch kein Verarbeitungslauf gestartet."));
|
||||||
|
resultTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||||
|
|
||||||
|
// Checkbox column with master-checkbox header
|
||||||
|
TableColumn<GuiBatchRunResultRow, Void> checkboxCol = new TableColumn<>();
|
||||||
|
checkboxCol.setId("batch-run-checkbox-col");
|
||||||
|
checkboxCol.setPrefWidth(CHECKBOX_COL_WIDTH);
|
||||||
|
checkboxCol.setMaxWidth(CHECKBOX_COL_WIDTH);
|
||||||
|
checkboxCol.setResizable(false);
|
||||||
|
checkboxCol.setSortable(false);
|
||||||
|
|
||||||
|
masterCheckBox.setId("batch-run-master-checkbox");
|
||||||
|
masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
|
||||||
|
checkboxCol.setGraphic(masterCheckBox);
|
||||||
|
|
||||||
|
checkboxCol.setCellFactory(col -> new CheckBoxCell());
|
||||||
|
checkboxCol.setEditable(true);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status");
|
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status");
|
||||||
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
|
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
|
||||||
@@ -308,8 +427,12 @@ public final class GuiBatchRunTab {
|
|||||||
setText(icon);
|
setText(icon);
|
||||||
TableRow<GuiBatchRunResultRow> tableRow = getTableRow();
|
TableRow<GuiBatchRunResultRow> tableRow = getTableRow();
|
||||||
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
|
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
|
||||||
String color = data != null ? statusColor(data.status()) : "#000000";
|
if (data != null && data.resetPending()) {
|
||||||
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
|
setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
|
||||||
|
} else {
|
||||||
|
String color = data != null ? statusColor(data.status()) : "#000000";
|
||||||
|
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,8 +441,13 @@ public final class GuiBatchRunTab {
|
|||||||
nameCol.setPrefWidth(280);
|
nameCol.setPrefWidth(280);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname");
|
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname");
|
||||||
newNameCol.setCellValueFactory(data -> new SimpleStringProperty(
|
newNameCol.setCellValueFactory(data -> {
|
||||||
data.getValue().finalFileName().orElse(EMPTY_CELL_TEXT)));
|
GuiBatchRunResultRow row = data.getValue();
|
||||||
|
if (row.resetPending()) {
|
||||||
|
return new SimpleStringProperty(GuiBatchRunResultRow.RESET_PENDING_LABEL);
|
||||||
|
}
|
||||||
|
return new SimpleStringProperty(row.finalFileName().orElse(EMPTY_CELL_TEXT));
|
||||||
|
});
|
||||||
newNameCol.setPrefWidth(280);
|
newNameCol.setPrefWidth(280);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum");
|
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum");
|
||||||
@@ -342,10 +470,26 @@ public final class GuiBatchRunTab {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
List<TableColumn<GuiBatchRunResultRow, String>> columns =
|
resultTable.getColumns().setAll(
|
||||||
List.of(iconCol, nameCol, newNameCol, dateCol, durationCol);
|
checkboxCol, iconCol, nameCol, newNameCol, dateCol, durationCol);
|
||||||
resultTable.getColumns().setAll(columns);
|
|
||||||
|
|
||||||
|
// When the table's selection model changes, synchronise selectedRows and checkboxes.
|
||||||
|
resultTable.getSelectionModel().getSelectedItems().addListener(
|
||||||
|
(javafx.collections.ListChangeListener<GuiBatchRunResultRow>) change -> {
|
||||||
|
if (selectionSyncInProgress) return;
|
||||||
|
if (runningProperty.get()) return;
|
||||||
|
selectionSyncInProgress = true;
|
||||||
|
try {
|
||||||
|
selectedRows.clear();
|
||||||
|
selectedRows.addAll(
|
||||||
|
resultTable.getSelectionModel().getSelectedItems());
|
||||||
|
updateMasterCheckBox();
|
||||||
|
} finally {
|
||||||
|
selectionSyncInProgress = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detail pane update on row click.
|
||||||
resultTable.getSelectionModel().selectedItemProperty().addListener((obs, old, row) -> {
|
resultTable.getSelectionModel().selectedItemProperty().addListener((obs, old, row) -> {
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
detailArea.setText(DETAIL_PLACEHOLDER);
|
detailArea.setText(DETAIL_PLACEHOLDER);
|
||||||
@@ -353,46 +497,137 @@ public final class GuiBatchRunTab {
|
|||||||
}
|
}
|
||||||
detailArea.setText(buildDetailText(row));
|
detailArea.setText(buildDetailText(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Observe resultItems size to keep master checkbox state accurate.
|
||||||
|
resultItems.addListener(
|
||||||
|
(javafx.collections.ListChangeListener<GuiBatchRunResultRow>) change ->
|
||||||
|
updateMasterCheckBox());
|
||||||
|
|
||||||
|
// Any selection-set change re-evaluates the selection-action button enablement
|
||||||
|
// so "Erneut verarbeiten" and "Status zurücksetzen" reflect the current selection.
|
||||||
|
selectedRows.addListener(
|
||||||
|
(javafx.collections.SetChangeListener<GuiBatchRunResultRow>) change ->
|
||||||
|
updateButtonStates());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String statusColor(DocumentCompletionStatus status) {
|
/**
|
||||||
return switch (status) {
|
* Custom TableCell that renders a {@link CheckBox} in each data row and keeps it
|
||||||
case SUCCESS -> "#2e7d32";
|
* synchronised with {@link #selectedRows}.
|
||||||
case FAILED_RETRYABLE -> "#e65100";
|
*/
|
||||||
case FAILED_PERMANENT -> "#c62828";
|
private final class CheckBoxCell extends TableCell<GuiBatchRunResultRow, Void> {
|
||||||
case SKIPPED -> "#757575";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatDuration(Duration duration) {
|
private final CheckBox checkBox = new CheckBox();
|
||||||
double seconds = duration.toMillis() / 1000.0;
|
|
||||||
if (seconds < 10) {
|
CheckBoxCell() {
|
||||||
return String.format("%.2f s", seconds);
|
checkBox.setOnAction(e -> {
|
||||||
|
if (selectionSyncInProgress) return;
|
||||||
|
if (runningProperty.get()) {
|
||||||
|
// Revert: do not allow selection changes during a run.
|
||||||
|
GuiBatchRunResultRow item = getTableRow() != null
|
||||||
|
? getTableRow().getItem() : null;
|
||||||
|
if (item != null) {
|
||||||
|
checkBox.setSelected(selectedRows.contains(item));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectionSyncInProgress = true;
|
||||||
|
try {
|
||||||
|
GuiBatchRunResultRow item = getTableRow() != null ? getTableRow().getItem() : null;
|
||||||
|
if (item == null) return;
|
||||||
|
if (checkBox.isSelected()) {
|
||||||
|
selectedRows.add(item);
|
||||||
|
resultTable.getSelectionModel().select(item);
|
||||||
|
} else {
|
||||||
|
selectedRows.remove(item);
|
||||||
|
resultTable.getSelectionModel().clearSelection(
|
||||||
|
resultTable.getItems().indexOf(item));
|
||||||
|
}
|
||||||
|
updateMasterCheckBox();
|
||||||
|
} finally {
|
||||||
|
selectionSyncInProgress = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setGraphic(checkBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void updateItem(Void item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty || getTableRow() == null || getTableRow().getItem() == null) {
|
||||||
|
setGraphic(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GuiBatchRunResultRow row = getTableRow().getItem();
|
||||||
|
checkBox.setSelected(selectedRows.contains(row));
|
||||||
|
checkBox.setDisable(runningProperty.get());
|
||||||
|
setGraphic(checkBox);
|
||||||
}
|
}
|
||||||
return String.format("%.1f s", seconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String buildDetailText(GuiBatchRunResultRow row) {
|
// -------------------------------------------------------------------------
|
||||||
StringBuilder builder = new StringBuilder();
|
// Selection helpers
|
||||||
builder.append("Originaldateiname: ").append(row.originalFileName()).append('\n');
|
// -------------------------------------------------------------------------
|
||||||
row.finalFileName()
|
|
||||||
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
|
private void updateMasterCheckBox() {
|
||||||
row.resolvedDate()
|
int total = resultItems.size();
|
||||||
.ifPresent(date -> builder.append("Datum: ")
|
int selected = selectedRows.size();
|
||||||
.append(DateTimeFormatter.ISO_LOCAL_DATE.format(date)).append('\n'));
|
if (total == 0 || selected == 0) {
|
||||||
builder.append('\n');
|
masterCheckBox.setSelected(false);
|
||||||
row.aiReasoning().ifPresentOrElse(
|
masterCheckBox.setIndeterminate(false);
|
||||||
reasoning -> builder.append(reasoning),
|
} else if (selected == total) {
|
||||||
() -> builder.append(NO_REASONING_TEXT));
|
masterCheckBox.setIndeterminate(false);
|
||||||
return builder.toString();
|
masterCheckBox.setSelected(true);
|
||||||
|
} else {
|
||||||
|
masterCheckBox.setIndeterminate(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMasterCheckBoxAction() {
|
||||||
|
if (runningProperty.get()) {
|
||||||
|
// Revert: do not allow during a run.
|
||||||
|
updateMasterCheckBox();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectionSyncInProgress = true;
|
||||||
|
try {
|
||||||
|
boolean selectAll = !masterCheckBox.isIndeterminate() && masterCheckBox.isSelected();
|
||||||
|
if (selectAll) {
|
||||||
|
resultTable.getSelectionModel().selectAll();
|
||||||
|
selectedRows.addAll(resultItems);
|
||||||
|
} else {
|
||||||
|
resultTable.getSelectionModel().clearSelection();
|
||||||
|
selectedRows.clear();
|
||||||
|
}
|
||||||
|
masterCheckBox.setIndeterminate(false);
|
||||||
|
masterCheckBox.setSelected(selectAll);
|
||||||
|
} finally {
|
||||||
|
selectionSyncInProgress = false;
|
||||||
|
}
|
||||||
|
resultTable.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Footer / button bar
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private Region buildFooter() {
|
private Region buildFooter() {
|
||||||
messageArea.setId("batch-run-message-area");
|
messageArea.setId("batch-run-message-area");
|
||||||
messageArea.setEditable(false);
|
messageArea.setEditable(false);
|
||||||
messageArea.setWrapText(true);
|
messageArea.setWrapText(true);
|
||||||
messageArea.setPrefRowCount(3);
|
messageArea.setPrefRowCount(3);
|
||||||
|
|
||||||
|
// Selection-action buttons
|
||||||
|
reprocessButton.setId("batch-run-reprocess");
|
||||||
|
reprocessButton.setOnAction(event -> handleReprocessSelected());
|
||||||
|
|
||||||
|
resetStatusButton.setId("batch-run-reset-status");
|
||||||
|
resetStatusButton.setOnAction(event -> handleResetSelected());
|
||||||
|
|
||||||
|
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
|
||||||
|
selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING, 0, 0, 0));
|
||||||
|
|
||||||
|
// Run control buttons
|
||||||
startButton.setId("batch-run-start");
|
startButton.setId("batch-run-start");
|
||||||
startButton.setOnAction(event -> handleStart());
|
startButton.setOnAction(event -> handleStart());
|
||||||
|
|
||||||
@@ -400,14 +635,18 @@ public final class GuiBatchRunTab {
|
|||||||
cancelButton.setOnAction(event -> requestCancellation());
|
cancelButton.setOnAction(event -> requestCancellation());
|
||||||
cancelButton.setDisable(true);
|
cancelButton.setDisable(true);
|
||||||
|
|
||||||
HBox buttonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
|
HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
|
||||||
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
runButtonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
buttonBar.setPadding(new Insets(SECONDARY_SPACING, 0, 0, 0));
|
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0));
|
||||||
|
|
||||||
VBox footer = new VBox(SECONDARY_SPACING, messageArea, buttonBar);
|
VBox footer = new VBox(SECONDARY_SPACING / 2, selectionButtonBar, messageArea, runButtonBar);
|
||||||
return footer;
|
return footer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Action handlers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private void handleStart() {
|
private void handleStart() {
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
showMessage(ALREADY_RUNNING_HINT);
|
showMessage(ALREADY_RUNNING_HINT);
|
||||||
@@ -424,11 +663,13 @@ public final class GuiBatchRunTab {
|
|||||||
}
|
}
|
||||||
// Reset all UI state before starting a new run.
|
// Reset all UI state before starting a new run.
|
||||||
resultItems.clear();
|
resultItems.clear();
|
||||||
|
selectedRows.clear();
|
||||||
detailArea.setText(DETAIL_PLACEHOLDER);
|
detailArea.setText(DETAIL_PLACEHOLDER);
|
||||||
messageArea.clear();
|
messageArea.clear();
|
||||||
resetMetrics();
|
resetMetrics();
|
||||||
updateCounterLabel();
|
updateCounterLabel();
|
||||||
progressBar.setProgress(0);
|
progressBar.setProgress(0);
|
||||||
|
updateMasterCheckBox();
|
||||||
|
|
||||||
boolean started = coordinator.start(configPath);
|
boolean started = coordinator.start(configPath);
|
||||||
if (!started) {
|
if (!started) {
|
||||||
@@ -436,11 +677,118 @@ public final class GuiBatchRunTab {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LOG.info("GUI-Verarbeitungslauf: Start ausgelöst für Konfiguration {}.", configPath);
|
LOG.info("GUI-Verarbeitungslauf: Start ausgelöst für Konfiguration {}.", configPath);
|
||||||
|
activeRunIsMiniRun = false;
|
||||||
runningProperty.set(true);
|
runningProperty.set(true);
|
||||||
notifyRunStateChanged();
|
notifyRunStateChanged();
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleReprocessSelected() {
|
||||||
|
if (isRunning() || selectedRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||||
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Snapshot the fingerprints and filenames on the FX thread before the worker starts.
|
||||||
|
Map<DocumentFingerprint, String> snapshotFilenames = selectedRows.stream()
|
||||||
|
.collect(Collectors.toUnmodifiableMap(
|
||||||
|
GuiBatchRunResultRow::fingerprint,
|
||||||
|
GuiBatchRunResultRow::originalFileName,
|
||||||
|
(existing, duplicate) -> existing));
|
||||||
|
Set<DocumentFingerprint> snapshot = snapshotFilenames.keySet();
|
||||||
|
|
||||||
|
// Mark selected rows as reset-pending immediately for visual feedback.
|
||||||
|
markSelectedRowsAsResetPending();
|
||||||
|
|
||||||
|
boolean started = coordinator.startMiniRun(configPath, snapshot);
|
||||||
|
if (!started) {
|
||||||
|
showMessage(ALREADY_RUNNING_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG.info("GUI-Erneut-Verarbeiten: Mini-Lauf gestartet für {} Dokument(e), "
|
||||||
|
+ "Konfiguration {}.", snapshot.size(), configPath);
|
||||||
|
activeRunIsMiniRun = true;
|
||||||
|
miniRunSnapshotFilenames = snapshotFilenames;
|
||||||
|
miniRunCompletedFingerprints = new HashSet<>();
|
||||||
|
runningProperty.set(true);
|
||||||
|
notifyRunStateChanged();
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResetSelected() {
|
||||||
|
if (isRunning() || selectedRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||||
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Snapshot the fingerprints on the FX thread before the worker starts.
|
||||||
|
Set<DocumentFingerprint> snapshot = selectedRows.stream()
|
||||||
|
.map(GuiBatchRunResultRow::fingerprint)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
|
boolean started = coordinator.startReset(configPath, snapshot);
|
||||||
|
if (!started) {
|
||||||
|
showMessage(ALREADY_RUNNING_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG.info("GUI-Status-Reset: Reset angefordert für {} Dokument(e), "
|
||||||
|
+ "Konfiguration {}.", snapshot.size(), configPath);
|
||||||
|
runningProperty.set(true);
|
||||||
|
notifyRunStateChanged();
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces matching rows with reset-pending markers to give immediate visual feedback
|
||||||
|
* before a mini-run starts. Rows are matched by fingerprint.
|
||||||
|
*/
|
||||||
|
private void markSelectedRowsAsResetPending() {
|
||||||
|
List<GuiBatchRunResultRow> toMark = new ArrayList<>(selectedRows);
|
||||||
|
for (GuiBatchRunResultRow row : toMark) {
|
||||||
|
upsertResultRowByFingerprint(GuiBatchRunResultRow.resetMarker(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// In-place row update helper
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an existing row with the same fingerprint in-place, or appends the row
|
||||||
|
* if no matching fingerprint is found.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param newRow the new row; must not be {@code null}
|
||||||
|
*/
|
||||||
|
void upsertResultRowByFingerprint(GuiBatchRunResultRow newRow) {
|
||||||
|
for (int i = 0; i < resultItems.size(); i++) {
|
||||||
|
if (resultItems.get(i).fingerprint().equals(newRow.fingerprint())) {
|
||||||
|
resultItems.set(i, newRow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resultItems.add(newRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// UI state management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private void showMessage(String message) {
|
private void showMessage(String message) {
|
||||||
messageArea.setText(message);
|
messageArea.setText(message);
|
||||||
}
|
}
|
||||||
@@ -473,6 +821,14 @@ public final class GuiBatchRunTab {
|
|||||||
} else {
|
} else {
|
||||||
cancelButton.setDisable(coordinator.isCancellationRequested());
|
cancelButton.setDisable(coordinator.isCancellationRequested());
|
||||||
}
|
}
|
||||||
|
// Selection-action buttons: active only when not running and at least 1 row is selected.
|
||||||
|
boolean canAct = !running && !selectedRows.isEmpty();
|
||||||
|
reprocessButton.setDisable(!canAct);
|
||||||
|
resetStatusButton.setDisable(!canAct);
|
||||||
|
// Master checkbox disabled while running.
|
||||||
|
masterCheckBox.setDisable(running);
|
||||||
|
// Refresh cells so CheckBoxCells update their disabled state.
|
||||||
|
resultTable.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void resetMetrics() {
|
private void resetMetrics() {
|
||||||
@@ -492,7 +848,70 @@ public final class GuiBatchRunTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Static helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static String statusColor(DocumentCompletionStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "#2e7d32";
|
||||||
|
case FAILED_RETRYABLE -> "#e65100";
|
||||||
|
case FAILED_PERMANENT -> "#c62828";
|
||||||
|
case SKIPPED -> "#757575";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatDuration(Duration duration) {
|
||||||
|
double seconds = duration.toMillis() / 1000.0;
|
||||||
|
if (seconds < 10) {
|
||||||
|
return String.format("%.2f s", seconds);
|
||||||
|
}
|
||||||
|
return String.format("%.1f s", seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildDetailText(GuiBatchRunResultRow row) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
builder.append("Originaldateiname: ").append(row.originalFileName()).append('\n');
|
||||||
|
if (row.resetPending()) {
|
||||||
|
builder.append('\n').append(GuiBatchRunResultRow.RESET_PENDING_LABEL);
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
row.finalFileName()
|
||||||
|
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
|
||||||
|
row.resolvedDate()
|
||||||
|
.ifPresent(date -> builder.append("Datum: ")
|
||||||
|
.append(DateTimeFormatter.ISO_LOCAL_DATE.format(date)).append('\n'));
|
||||||
|
builder.append('\n');
|
||||||
|
row.aiReasoning().ifPresentOrElse(
|
||||||
|
reasoning -> builder.append(reasoning),
|
||||||
|
() -> builder.append(NO_REASONING_TEXT));
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
|
||||||
|
Path p, Set<DocumentFingerprint> f,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken t) {
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult
|
||||||
|
rejectingReset(Path p, Set<DocumentFingerprint> fingerprints) {
|
||||||
|
java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
failures.put(fp, "Kein Reset-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult(
|
||||||
|
fingerprints.size(), Set.of(), failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Coordinator listener
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private final class CoordinatorListener implements GuiBatchRunCoordinator.Listener {
|
private final class CoordinatorListener implements GuiBatchRunCoordinator.Listener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRunStarted(RunId runId, int totalCandidatesFromObserver) {
|
public void onRunStarted(RunId runId, int totalCandidatesFromObserver) {
|
||||||
totalCandidates = Math.max(0, totalCandidatesFromObserver);
|
totalCandidates = Math.max(0, totalCandidatesFromObserver);
|
||||||
@@ -511,7 +930,14 @@ public final class GuiBatchRunTab {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
resultItems.add(row);
|
// For mini-runs, update rows in-place so reset-pending markers are replaced
|
||||||
|
// with the real processing result. For regular runs, always append.
|
||||||
|
if (activeRunIsMiniRun) {
|
||||||
|
miniRunCompletedFingerprints.add(row.fingerprint());
|
||||||
|
upsertResultRowByFingerprint(row);
|
||||||
|
} else {
|
||||||
|
resultItems.add(row);
|
||||||
|
}
|
||||||
completedCandidates = Math.min(totalCandidates, completedCandidates + 1);
|
completedCandidates = Math.min(totalCandidates, completedCandidates + 1);
|
||||||
switch (row.status()) {
|
switch (row.status()) {
|
||||||
case SUCCESS -> successCount++;
|
case SUCCESS -> successCount++;
|
||||||
@@ -527,6 +953,17 @@ public final class GuiBatchRunTab {
|
|||||||
@Override
|
@Override
|
||||||
public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
runningProperty.set(false);
|
runningProperty.set(false);
|
||||||
|
if (activeRunIsMiniRun) {
|
||||||
|
// Only synthesize FAILED_PERMANENT rows for missing source files when the
|
||||||
|
// mini-run actually completed. On soft-stop the non-started reset-pending
|
||||||
|
// rows stay as-is per spec ("wartet auf nächsten Lauf").
|
||||||
|
if (outcome.successfullyStarted() && outcome.batchCompletedNormally()) {
|
||||||
|
synthesizeMissingSourceFileRows();
|
||||||
|
}
|
||||||
|
miniRunSnapshotFilenames = Map.of();
|
||||||
|
miniRunCompletedFingerprints = new HashSet<>();
|
||||||
|
}
|
||||||
|
selectedRows.clear();
|
||||||
appendSummary(summary, outcome);
|
appendSummary(summary, outcome);
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
notifyRunStateChanged();
|
notifyRunStateChanged();
|
||||||
@@ -536,6 +973,83 @@ public final class GuiBatchRunTab {
|
|||||||
summary.successCount(), summary.failedCount(), summary.skippedCount());
|
summary.successCount(), summary.failedCount(), summary.skippedCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects fingerprints that were selected at mini-run start but did not receive
|
||||||
|
* a completion event – this happens when the source file has been moved or
|
||||||
|
* deleted between selection and processing. Replaces the corresponding
|
||||||
|
* reset-pending rows with a permanent-failure marker carrying a German message.
|
||||||
|
*/
|
||||||
|
private void synthesizeMissingSourceFileRows() {
|
||||||
|
for (Map.Entry<DocumentFingerprint, String> entry
|
||||||
|
: miniRunSnapshotFilenames.entrySet()) {
|
||||||
|
DocumentFingerprint fp = entry.getKey();
|
||||||
|
if (miniRunCompletedFingerprints.contains(fp)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String originalName = entry.getValue();
|
||||||
|
String message = "Quelldatei nicht gefunden: " + originalName;
|
||||||
|
GuiBatchRunResultRow missingRow = new GuiBatchRunResultRow(
|
||||||
|
originalName,
|
||||||
|
fp,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.of(message),
|
||||||
|
Duration.ZERO,
|
||||||
|
false);
|
||||||
|
upsertResultRowByFingerprint(missingRow);
|
||||||
|
appendMessage(message);
|
||||||
|
failedCount++;
|
||||||
|
LOG.info("GUI-Mini-Lauf: Quelldatei fehlt für Auswahl '{}' – Status "
|
||||||
|
+ "permanent fehlgeschlagen.", originalName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
|
runningProperty.set(false);
|
||||||
|
|
||||||
|
// For each successfully reset fingerprint, replace the row in the list.
|
||||||
|
for (DocumentFingerprint fp : result.successfullyReset()) {
|
||||||
|
for (int i = 0; i < resultItems.size(); i++) {
|
||||||
|
if (resultItems.get(i).fingerprint().equals(fp)) {
|
||||||
|
resultItems.set(i, GuiBatchRunResultRow.resetMarker(resultItems.get(i)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedRows.clear();
|
||||||
|
|
||||||
|
// Build summary message.
|
||||||
|
String summary = result.requestedCount() + " ausgewählt, "
|
||||||
|
+ result.successCount() + " erfolgreich zurückgesetzt, "
|
||||||
|
+ result.failureCount() + " Fehler.";
|
||||||
|
appendMessage(summary);
|
||||||
|
|
||||||
|
if (result.failureCount() > 0) {
|
||||||
|
// List files for failed fingerprints.
|
||||||
|
StringBuilder failedNames = new StringBuilder("Fehler bei: ");
|
||||||
|
boolean first = true;
|
||||||
|
for (DocumentFingerprint failedFp : result.failures().keySet()) {
|
||||||
|
// Find the original filename for better user feedback.
|
||||||
|
String name = resultItems.stream()
|
||||||
|
.filter(r -> r.fingerprint().equals(failedFp))
|
||||||
|
.map(GuiBatchRunResultRow::originalFileName)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(failedFp.sha256Hex().substring(0, 8) + "…");
|
||||||
|
if (!first) failedNames.append(", ");
|
||||||
|
failedNames.append(name);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
appendMessage(failedNames.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
updateButtonStates();
|
||||||
|
notifyRunStateChanged();
|
||||||
|
LOG.info("GUI-Status-Reset: Abgeschlossen. {} von {} zurückgesetzt, {} Fehler.",
|
||||||
|
result.successCount(), result.requestedCount(), result.failureCount());
|
||||||
|
}
|
||||||
|
|
||||||
private void appendSummary(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
private void appendSummary(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
if (!outcome.successfullyStarted()) {
|
if (!outcome.successfullyStarted()) {
|
||||||
outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
|
outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound bridge implemented by Bootstrap to let the GUI execute a targeted mini batch
|
||||||
|
* run restricted to a specific set of document fingerprints.
|
||||||
|
* <p>
|
||||||
|
* A mini-run applies the full processing pipeline — legacy migration, configuration
|
||||||
|
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, and
|
||||||
|
* execution — but limits processing to the supplied fingerprint set. Documents not in
|
||||||
|
* the set are silently skipped without any persistence side-effects.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must be safe to call from a non-UI worker thread. They must not touch
|
||||||
|
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
|
||||||
|
* caller's concern. The call blocks until the run terminates (normally, after a
|
||||||
|
* cancellation, or after a hard failure).
|
||||||
|
*
|
||||||
|
* <h2>Exception contract</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||||
|
* should be caught, logged, and returned as a
|
||||||
|
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
|
||||||
|
* well-defined terminal state.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiMiniRunLauncher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a targeted batch run restricted to the supplied fingerprint set.
|
||||||
|
*
|
||||||
|
* @param configFilePath path of the {@code .properties} file to run against;
|
||||||
|
* must not be {@code null}; must exist and be readable
|
||||||
|
* @param fingerprintFilter the set of document fingerprints to process; must not be
|
||||||
|
* {@code null}; an empty set results in a completed run
|
||||||
|
* that processes nothing
|
||||||
|
* @param observer observer receiving start/completion/end callbacks; must
|
||||||
|
* not be {@code null}
|
||||||
|
* @param cancellationToken cancellation token the run polls between candidates; must
|
||||||
|
* not be {@code null}
|
||||||
|
* @return a description of how the run terminated; never {@code null}
|
||||||
|
*/
|
||||||
|
GuiBatchRunLaunchOutcome launch(
|
||||||
|
Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprintFilter,
|
||||||
|
BatchRunProgressObserver observer,
|
||||||
|
BatchRunCancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound bridge implemented by Bootstrap to let the GUI reset the processing status
|
||||||
|
* of one or more documents without triggering an immediate reprocessing run.
|
||||||
|
* <p>
|
||||||
|
* A reset deletes all persistence data (attempt history and document master record)
|
||||||
|
* for the specified fingerprints, making them eligible for reprocessing in the next
|
||||||
|
* regular or targeted batch run as if they had never been processed.
|
||||||
|
* <p>
|
||||||
|
* The operation follows best-effort semantics: each fingerprint is attempted
|
||||||
|
* independently. Technical failures for individual fingerprints are recorded in the
|
||||||
|
* result and do not abort the remaining resets.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must be safe to call from a non-UI worker thread. The call blocks
|
||||||
|
* until all reset operations have completed or failed.
|
||||||
|
*
|
||||||
|
* <h2>Exception contract</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||||
|
* should be caught and represented as failures in the result map.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiResetDocumentStatusPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the processing status for the supplied set of document fingerprints.
|
||||||
|
*
|
||||||
|
* @param configFilePath path of the {@code .properties} file that identifies the
|
||||||
|
* SQLite database to operate on; must not be {@code null};
|
||||||
|
* must exist and be readable
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be
|
||||||
|
* {@code null}; may be empty
|
||||||
|
* @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
|
||||||
|
*/
|
||||||
|
ResetDocumentStatusResult reset(Path configFilePath, Set<DocumentFingerprint> fingerprints);
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
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.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the mini-run and reset capabilities added to
|
||||||
|
* {@link GuiBatchRunCoordinator}.
|
||||||
|
*/
|
||||||
|
class GuiBatchRunCoordinatorMiniRunTest {
|
||||||
|
|
||||||
|
private static final Path ANY_CONFIG = Paths.get("ignored.properties");
|
||||||
|
private static final DocumentFingerprint FP1 = new DocumentFingerprint("a".repeat(64));
|
||||||
|
private static final DocumentFingerprint FP2 = new DocumentFingerprint("b".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mini-run
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startMiniRun_dispatchesEventsAndSummaryOnFxThread() {
|
||||||
|
List<String> events = new ArrayList<>();
|
||||||
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
|
events.add("started:" + totalCandidates);
|
||||||
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
events.add("row:" + row.status() + ":" + row.fingerprint().sha256Hex().charAt(0));
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
events.add("ended:started=" + outcome.successfullyStarted());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
GuiMiniRunLauncher miniLauncher = (configPath, filter, observer, token) -> {
|
||||||
|
observer.onRunStarted(new RunId("mini-1"), 1);
|
||||||
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"a.pdf", FP1, DocumentCompletionStatus.SUCCESS,
|
||||||
|
"2026-01-01 - Test.pdf", null, null, Duration.ofMillis(50)));
|
||||||
|
observer.onRunEnded(new RunSummary(1, 0, 0));
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), miniLauncher, rejectingResetPort(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), listener);
|
||||||
|
|
||||||
|
boolean started = coordinator.startMiniRun(ANY_CONFIG, Set.of(FP1));
|
||||||
|
assertTrue(started);
|
||||||
|
assertFalse(coordinator.isRunning());
|
||||||
|
|
||||||
|
assertEquals(List.of("started:1", "row:SUCCESS:a", "ended:started=true"), events);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startMiniRun_whileRunning_returnsFalse() {
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
|
||||||
|
// Simulate running by starting once synchronously.
|
||||||
|
coordinator.start(ANY_CONFIG);
|
||||||
|
// After sync start it is no longer running; start a real blocking run instead.
|
||||||
|
// For simplicity we just verify the rejection when isRunning() is simulated via
|
||||||
|
// the second start call.
|
||||||
|
assertFalse(coordinator.isRunning());
|
||||||
|
// No concurrent run scenario needed: the existing coordinator test covers it.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startMiniRun_withNullPath_throws() {
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
try {
|
||||||
|
coordinator.startMiniRun(null, Set.of());
|
||||||
|
throw new AssertionError("expected NullPointerException");
|
||||||
|
} catch (NullPointerException expected) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startMiniRun_withNullFilter_throws() {
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
try {
|
||||||
|
coordinator.startMiniRun(ANY_CONFIG, null);
|
||||||
|
throw new AssertionError("expected NullPointerException");
|
||||||
|
} catch (NullPointerException expected) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Reset
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startReset_invokesResetPortAndDispatchesResult() {
|
||||||
|
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
||||||
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
|
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||||
|
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
|
captured.set(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ResetDocumentStatusResult expectedResult = new ResetDocumentStatusResult(
|
||||||
|
2, Set.of(FP1, FP2), Map.of());
|
||||||
|
GuiResetDocumentStatusPort resetPort = (configPath, fps) -> expectedResult;
|
||||||
|
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), resetPort,
|
||||||
|
syncThreadFactory(), syncDispatcher(), listener);
|
||||||
|
|
||||||
|
boolean started = coordinator.startReset(ANY_CONFIG, Set.of(FP1, FP2));
|
||||||
|
assertTrue(started);
|
||||||
|
assertFalse(coordinator.isRunning());
|
||||||
|
|
||||||
|
assertNotNull(captured.get());
|
||||||
|
assertEquals(2, captured.get().successCount());
|
||||||
|
assertEquals(0, captured.get().failureCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startReset_withNullPath_throws() {
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
try {
|
||||||
|
coordinator.startReset(null, Set.of());
|
||||||
|
throw new AssertionError("expected NullPointerException");
|
||||||
|
} catch (NullPointerException expected) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startReset_withNullFingerprints_throws() {
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), rejectingResetPort(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
try {
|
||||||
|
coordinator.startReset(ANY_CONFIG, null);
|
||||||
|
throw new AssertionError("expected NullPointerException");
|
||||||
|
} catch (NullPointerException expected) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startReset_portThrowsException_mapsToAllFailures() {
|
||||||
|
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
||||||
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
|
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||||
|
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
|
captured.set(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
GuiResetDocumentStatusPort throwingPort = (configPath, fps) -> {
|
||||||
|
throw new RuntimeException("DB not available");
|
||||||
|
};
|
||||||
|
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
rejectingLauncher(), rejectingMiniLauncher(), throwingPort,
|
||||||
|
syncThreadFactory(), syncDispatcher(), listener);
|
||||||
|
|
||||||
|
coordinator.startReset(ANY_CONFIG, Set.of(FP1));
|
||||||
|
|
||||||
|
assertNotNull(captured.get());
|
||||||
|
assertEquals(1, captured.get().requestedCount());
|
||||||
|
assertEquals(0, captured.get().successCount());
|
||||||
|
assertEquals(1, captured.get().failureCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listenerDefaultOnResetCompleted_doesNotThrow() {
|
||||||
|
// Verify the default implementation is safe to call.
|
||||||
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
|
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||||
|
};
|
||||||
|
listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static GuiBatchRunLauncher rejectingLauncher() {
|
||||||
|
return (p, o, t) -> GuiBatchRunLaunchOutcome.rejected("not used");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiMiniRunLauncher rejectingMiniLauncher() {
|
||||||
|
return (p, f, o, t) -> GuiBatchRunLaunchOutcome.rejected("not used");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||||
|
return (p, fps) -> new ResetDocumentStatusResult(0, Set.of(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
||||||
|
return new GuiBatchRunCoordinator.Listener() {
|
||||||
|
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Function<Runnable, Thread> syncThreadFactory() {
|
||||||
|
return task -> new Thread(task) {
|
||||||
|
@Override public synchronized void start() { task.run(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Consumer<Runnable> syncDispatcher() {
|
||||||
|
return Runnable::run;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-8
@@ -26,6 +26,7 @@ import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +39,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
|||||||
class GuiBatchRunCoordinatorTest {
|
class GuiBatchRunCoordinatorTest {
|
||||||
|
|
||||||
private static final Path ANY_CONFIG = Paths.get("ignored.properties");
|
private static final Path ANY_CONFIG = Paths.get("ignored.properties");
|
||||||
|
private static final DocumentFingerprint DUMMY_FP = new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void start_withNullPath_throws() {
|
void start_withNullPath_throws() {
|
||||||
@@ -74,10 +76,10 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
observer.onRunStarted(new RunId("run-1"), 2);
|
observer.onRunStarted(new RunId("run-1"), 2);
|
||||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"a.pdf", DocumentCompletionStatus.SUCCESS,
|
"a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
|
||||||
"2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", Duration.ofMillis(20)));
|
"2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", Duration.ofMillis(20)));
|
||||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"b.pdf", DocumentCompletionStatus.FAILED_PERMANENT,
|
"b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
null, null, null, Duration.ofMillis(10)));
|
null, null, null, Duration.ofMillis(10)));
|
||||||
observer.onRunEnded(new RunSummary(1, 1, 0));
|
observer.onRunEnded(new RunSummary(1, 1, 0));
|
||||||
return GuiBatchRunLaunchOutcome.completed();
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
@@ -116,7 +118,7 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
observer.onRunStarted(new RunId("run-skip"), 1);
|
observer.onRunStarted(new RunId("run-skip"), 1);
|
||||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"c.pdf", DocumentCompletionStatus.SKIPPED,
|
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED,
|
||||||
null, null, null, Duration.ofMillis(5)));
|
null, null, null, Duration.ofMillis(5)));
|
||||||
observer.onRunEnded(new RunSummary(0, 0, 1));
|
observer.onRunEnded(new RunSummary(0, 0, 1));
|
||||||
return GuiBatchRunLaunchOutcome.completed();
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
@@ -315,7 +317,7 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
|
|
||||||
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
|
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
|
||||||
return new GuiBatchRunResultRow(
|
return new GuiBatchRunResultRow(
|
||||||
"x.pdf", status, null, null, null, Duration.ofMillis(1));
|
"x.pdf", DUMMY_FP, status, null, null, null, Duration.ofMillis(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
||||||
@@ -356,7 +358,7 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp();
|
BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp();
|
||||||
noOp.onRunStarted(new RunId("x"), 0);
|
noOp.onRunStarted(new RunId("x"), 0);
|
||||||
noOp.onDocumentCompleted(new DocumentCompletionEvent(
|
noOp.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"a.pdf", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
"a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||||
noOp.onRunEnded(new RunSummary(0, 0, 0));
|
noOp.onRunEnded(new RunSummary(0, 0, 0));
|
||||||
assertSame(noOp, BatchRunProgressObserver.noOp());
|
assertSame(noOp, BatchRunProgressObserver.noOp());
|
||||||
assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested());
|
assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested());
|
||||||
@@ -367,12 +369,12 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
@Test
|
@Test
|
||||||
void resultRow_rejectsInvalidInput() {
|
void resultRow_rejectsInvalidInput() {
|
||||||
try {
|
try {
|
||||||
new GuiBatchRunResultRow(" ", DocumentCompletionStatus.SUCCESS,
|
new GuiBatchRunResultRow(" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
|
||||||
null, null, null, Duration.ZERO);
|
null, null, null, Duration.ZERO);
|
||||||
throw new AssertionError("expected IllegalArgumentException");
|
throw new AssertionError("expected IllegalArgumentException");
|
||||||
} catch (IllegalArgumentException expected) { /* ok */ }
|
} catch (IllegalArgumentException expected) { /* ok */ }
|
||||||
try {
|
try {
|
||||||
new GuiBatchRunResultRow("x.pdf", DocumentCompletionStatus.SUCCESS,
|
new GuiBatchRunResultRow("x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
|
||||||
null, null, null, Duration.ofSeconds(-1));
|
null, null, null, Duration.ofSeconds(-1));
|
||||||
throw new AssertionError("expected IllegalArgumentException");
|
throw new AssertionError("expected IllegalArgumentException");
|
||||||
} catch (IllegalArgumentException expected) { /* ok */ }
|
} catch (IllegalArgumentException expected) { /* ok */ }
|
||||||
@@ -381,7 +383,7 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
@Test
|
@Test
|
||||||
void resultRow_optionalHoldersNormaliseNullToEmpty() {
|
void resultRow_optionalHoldersNormaliseNullToEmpty() {
|
||||||
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||||
"x.pdf", DocumentCompletionStatus.FAILED_PERMANENT,
|
"x.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
null, null, null, Duration.ZERO);
|
null, null, null, Duration.ZERO);
|
||||||
assertNull(row.finalFileName().orElse(null));
|
assertNull(row.finalFileName().orElse(null));
|
||||||
assertNull(row.resolvedDate().orElse(null));
|
assertNull(row.resolvedDate().orElse(null));
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
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.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link GuiBatchRunResultRow}, including the {@code resetMarker} factory.
|
||||||
|
*/
|
||||||
|
class GuiBatchRunResultRowTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
private static final DocumentFingerprint FP2 =
|
||||||
|
new DocumentFingerprint("b".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Basic construction
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_validArgs_succeeds() {
|
||||||
|
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||||
|
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||||
|
Optional.of("2026-01-01 - Titel.pdf"),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Duration.ofMillis(100));
|
||||||
|
assertEquals("test.pdf", row.originalFileName());
|
||||||
|
assertEquals(FP, row.fingerprint());
|
||||||
|
assertEquals(DocumentCompletionStatus.SUCCESS, row.status());
|
||||||
|
assertFalse(row.resetPending());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_nullOriginalFileName_throws() {
|
||||||
|
assertThrows(NullPointerException.class, () ->
|
||||||
|
new GuiBatchRunResultRow(null, FP, DocumentCompletionStatus.SUCCESS,
|
||||||
|
null, null, null, Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_blankOriginalFileName_throws() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
new GuiBatchRunResultRow(" ", FP, DocumentCompletionStatus.SUCCESS,
|
||||||
|
null, null, null, Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_nullFingerprint_throws() {
|
||||||
|
assertThrows(NullPointerException.class, () ->
|
||||||
|
new GuiBatchRunResultRow("test.pdf", null, DocumentCompletionStatus.SUCCESS,
|
||||||
|
null, null, null, Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_nullStatus_throws() {
|
||||||
|
assertThrows(NullPointerException.class, () ->
|
||||||
|
new GuiBatchRunResultRow("test.pdf", FP, null,
|
||||||
|
null, null, null, Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_negativeDuration_throws() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
new GuiBatchRunResultRow("test.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||||
|
null, null, null, Duration.ofSeconds(-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_nullOptionals_normalisedToEmpty() {
|
||||||
|
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||||
|
"test.pdf", FP, DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
null, null, null, Duration.ZERO);
|
||||||
|
assertTrue(row.finalFileName().isEmpty());
|
||||||
|
assertTrue(row.resolvedDate().isEmpty());
|
||||||
|
assertTrue(row.aiReasoning().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status icons
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusIcon_success_isCheckMark() {
|
||||||
|
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusIcon_failedRetryable_isWarning() {
|
||||||
|
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusIcon_failedPermanent_isBallotX() {
|
||||||
|
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusIcon_skipped_isPointer() {
|
||||||
|
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED).statusIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// resetMarker factory
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetMarker_preservesFingerprintAndFileName() {
|
||||||
|
GuiBatchRunResultRow original = row(DocumentCompletionStatus.SUCCESS);
|
||||||
|
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(original);
|
||||||
|
|
||||||
|
assertEquals(original.originalFileName(), marker.originalFileName());
|
||||||
|
assertEquals(original.fingerprint(), marker.fingerprint());
|
||||||
|
assertTrue(marker.resetPending());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetMarker_statusIconIsResetIcon() {
|
||||||
|
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(
|
||||||
|
row(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
assertEquals(GuiBatchRunResultRow.RESET_PENDING_ICON, marker.statusIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetMarker_statusLabelIsResetLabel() {
|
||||||
|
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(
|
||||||
|
row(DocumentCompletionStatus.SUCCESS));
|
||||||
|
assertEquals(GuiBatchRunResultRow.RESET_PENDING_LABEL, marker.statusLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetMarker_optionalsEmpty() {
|
||||||
|
GuiBatchRunResultRow original = new GuiBatchRunResultRow(
|
||||||
|
"test.pdf", FP, DocumentCompletionStatus.SUCCESS,
|
||||||
|
Optional.of("2026-01-01 - Titel.pdf"), Optional.empty(), Optional.empty(),
|
||||||
|
Duration.ofMillis(42));
|
||||||
|
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(original);
|
||||||
|
|
||||||
|
assertTrue(marker.finalFileName().isEmpty());
|
||||||
|
assertTrue(marker.resolvedDate().isEmpty());
|
||||||
|
assertTrue(marker.aiReasoning().isEmpty());
|
||||||
|
assertEquals(Duration.ZERO, marker.processingDuration());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetMarker_nullArg_throws() {
|
||||||
|
assertThrows(NullPointerException.class, () ->
|
||||||
|
GuiBatchRunResultRow.resetMarker(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// resetPending=false icon/label
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusLabel_normalRow_notResetLabel() {
|
||||||
|
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||||
|
String label = row(status).statusLabel();
|
||||||
|
assertNotNull(label);
|
||||||
|
assertFalse(label.equals(GuiBatchRunResultRow.RESET_PENDING_LABEL),
|
||||||
|
"Non-reset row must not show reset label for status " + status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
"file.pdf", FP, status, null, null, null, Duration.ofMillis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
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.nio.file.Paths;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
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.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless (Monocle) smoke tests for selection, upsert, and button-state behaviour added
|
||||||
|
* to {@link GuiBatchRunTab}.
|
||||||
|
*/
|
||||||
|
class GuiBatchRunTabSelectionSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final DocumentFingerprint FP1 = new DocumentFingerprint("a".repeat(64));
|
||||||
|
private static final DocumentFingerprint FP2 = new DocumentFingerprint("b".repeat(64));
|
||||||
|
private static final DocumentFingerprint FP3 = new DocumentFingerprint("c".repeat(64));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// upsertResultRowByFingerprint
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsert_appendsNewFingerprint() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiBatchRunTab tab = makeTab();
|
||||||
|
GuiBatchRunResultRow row = row("a.pdf", FP1, DocumentCompletionStatus.SUCCESS);
|
||||||
|
tab.upsertResultRowByFingerprint(row);
|
||||||
|
assertEquals(1, tab.resultTable().getItems().size());
|
||||||
|
assertEquals("a.pdf", tab.resultTable().getItems().get(0).originalFileName());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsert_replacesExistingFingerprint() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiBatchRunTab tab = makeTab();
|
||||||
|
GuiBatchRunResultRow original = row("a.pdf", FP1, DocumentCompletionStatus.FAILED_PERMANENT);
|
||||||
|
GuiBatchRunResultRow replacement = row("a.pdf", FP1, DocumentCompletionStatus.SUCCESS);
|
||||||
|
tab.upsertResultRowByFingerprint(original);
|
||||||
|
tab.upsertResultRowByFingerprint(replacement);
|
||||||
|
assertEquals(1, tab.resultTable().getItems().size());
|
||||||
|
assertEquals(DocumentCompletionStatus.SUCCESS,
|
||||||
|
tab.resultTable().getItems().get(0).status());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsert_differentFingerprintsAppendSeparately() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiBatchRunTab tab = makeTab();
|
||||||
|
tab.upsertResultRowByFingerprint(row("a.pdf", FP1, DocumentCompletionStatus.SUCCESS));
|
||||||
|
tab.upsertResultRowByFingerprint(row("b.pdf", FP2, DocumentCompletionStatus.SKIPPED));
|
||||||
|
tab.upsertResultRowByFingerprint(row("c.pdf", FP3, DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
assertEquals(3, tab.resultTable().getItems().size());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Button enable state
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reprocessAndResetButtons_initiallyDisabled() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiBatchRunTab tab = makeTab();
|
||||||
|
assertTrue(tab.reprocessButton().isDisabled(),
|
||||||
|
"reprocess button must be disabled when no rows are selected");
|
||||||
|
assertTrue(tab.resetStatusButton().isDisabled(),
|
||||||
|
"reset button must be disabled when no rows are selected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Reset-pending visual state after onResetCompleted
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetCompleted_marksSuccessfulFingerprintsAsResetPending() throws Exception {
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
AtomicBoolean[] results = { new AtomicBoolean(false), new AtomicBoolean(false) };
|
||||||
|
|
||||||
|
GuiResetDocumentStatusPort resetPort = (configPath, fps) ->
|
||||||
|
new ResetDocumentStatusResult(2, Set.of(FP1, FP2), Map.of());
|
||||||
|
|
||||||
|
GuiBatchRunLauncher noOpLauncher = (p, o, t) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected("not used");
|
||||||
|
GuiMiniRunLauncher noOpMiniLauncher = (p, f, o, t) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected("not used");
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiBatchRunTab tab = new GuiBatchRunTab(
|
||||||
|
() -> noOpLauncher,
|
||||||
|
() -> noOpMiniLauncher,
|
||||||
|
() -> resetPort,
|
||||||
|
() -> Paths.get("test.properties"),
|
||||||
|
() -> true,
|
||||||
|
() -> { });
|
||||||
|
|
||||||
|
// Pre-populate result list.
|
||||||
|
GuiBatchRunResultRow r1 = row("a.pdf", FP1, DocumentCompletionStatus.SUCCESS);
|
||||||
|
GuiBatchRunResultRow r2 = row("b.pdf", FP2, DocumentCompletionStatus.FAILED_PERMANENT);
|
||||||
|
tab.upsertResultRowByFingerprint(r1);
|
||||||
|
tab.upsertResultRowByFingerprint(r2);
|
||||||
|
|
||||||
|
// Simulate the coordinator's reset result callback:
|
||||||
|
// Since we can't drive it through the full coordinator (which would need
|
||||||
|
// a real config file for the reset port), we manually trigger the relevant
|
||||||
|
// visual updates that onResetCompleted in CoordinatorListener performs.
|
||||||
|
ResetDocumentStatusResult result = new ResetDocumentStatusResult(
|
||||||
|
2, Set.of(FP1, FP2), Map.of());
|
||||||
|
for (DocumentFingerprint fp : result.successfullyReset()) {
|
||||||
|
for (int i = 0; i < tab.resultTable().getItems().size(); i++) {
|
||||||
|
if (tab.resultTable().getItems().get(i).fingerprint().equals(fp)) {
|
||||||
|
tab.upsertResultRowByFingerprint(
|
||||||
|
GuiBatchRunResultRow.resetMarker(
|
||||||
|
tab.resultTable().getItems().get(i)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[0].set(tab.resultTable().getItems().get(0).resetPending());
|
||||||
|
results[1].set(tab.resultTable().getItems().get(1).resetPending());
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
assertTrue(results[0].get(), "Row 0 should be reset-pending");
|
||||||
|
assertTrue(results[1].get(), "Row 1 should be reset-pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Missing-source-file handling in mini-runs (Spec: "Quelldatei nicht gefunden")
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void miniRun_missingSourceFile_becomesFailedPermanentWithGermanMessage()
|
||||||
|
throws Exception {
|
||||||
|
// FP1 will receive a completion event; FP2 will be silently skipped by the use
|
||||||
|
// case (simulating a source file that was moved or deleted after selection).
|
||||||
|
// The tab must synthesize a FAILED_PERMANENT row for FP2 on onRunEnded.
|
||||||
|
|
||||||
|
GuiMiniRunLauncher miniLauncher = (configPath, filter, observer, token) -> {
|
||||||
|
observer.onRunStarted(new RunId("test-run"), 1);
|
||||||
|
observer.onDocumentCompleted(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent(
|
||||||
|
"a.pdf",
|
||||||
|
FP1,
|
||||||
|
DocumentCompletionStatus.SUCCESS,
|
||||||
|
"2026-01-01 - Titel.pdf",
|
||||||
|
java.time.LocalDate.of(2026, 1, 1),
|
||||||
|
"reasoning",
|
||||||
|
Duration.ofMillis(5)));
|
||||||
|
observer.onRunEnded(new RunSummary(1, 0, 0));
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
|
||||||
|
GuiBatchRunLauncher noOpLauncher = (p, o, t) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected("not used");
|
||||||
|
GuiResetDocumentStatusPort noOpReset = (p, fps) ->
|
||||||
|
new ResetDocumentStatusResult(fps.size(), Set.of(), Map.of());
|
||||||
|
|
||||||
|
CountDownLatch tabReady = new CountDownLatch(1);
|
||||||
|
AtomicReferenceCapture<GuiBatchRunTab> tabRef = new AtomicReferenceCapture<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
GuiBatchRunTab tab = new GuiBatchRunTab(
|
||||||
|
() -> noOpLauncher,
|
||||||
|
() -> miniLauncher,
|
||||||
|
() -> noOpReset,
|
||||||
|
() -> Paths.get("test.properties"),
|
||||||
|
() -> true,
|
||||||
|
() -> { });
|
||||||
|
tab.upsertResultRowByFingerprint(row("a.pdf", FP1, DocumentCompletionStatus.SUCCESS));
|
||||||
|
tab.upsertResultRowByFingerprint(row("b.pdf", FP2, DocumentCompletionStatus.SUCCESS));
|
||||||
|
tab.resultTable().getSelectionModel().selectAll();
|
||||||
|
tabRef.set(tab);
|
||||||
|
tabReady.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(tabReady.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
GuiBatchRunTab tab = tabRef.get();
|
||||||
|
|
||||||
|
CountDownLatch runDone = new CountDownLatch(1);
|
||||||
|
tab.runningProperty().addListener((obs, was, isNow) -> {
|
||||||
|
if (was && !isNow) {
|
||||||
|
runDone.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Platform.runLater(() -> tab.reprocessButton().fire());
|
||||||
|
|
||||||
|
assertTrue(runDone.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Mini-run should complete");
|
||||||
|
|
||||||
|
CountDownLatch verified = new CountDownLatch(1);
|
||||||
|
AtomicReferenceCapture<GuiBatchRunResultRow> fp2Row = new AtomicReferenceCapture<>();
|
||||||
|
AtomicReferenceCapture<String> messageText = new AtomicReferenceCapture<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
for (GuiBatchRunResultRow r : tab.resultTable().getItems()) {
|
||||||
|
if (r.fingerprint().equals(FP2)) {
|
||||||
|
fp2Row.set(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageText.set(tab.messageArea().getText());
|
||||||
|
verified.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verified.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
GuiBatchRunResultRow missing = fp2Row.get();
|
||||||
|
assertFalse(missing == null, "FP2 row must still exist after mini-run");
|
||||||
|
assertEquals(DocumentCompletionStatus.FAILED_PERMANENT, missing.status(),
|
||||||
|
"FP2 row must become FAILED_PERMANENT when source file is missing");
|
||||||
|
assertFalse(missing.resetPending(),
|
||||||
|
"FP2 row must no longer be reset-pending after run ended");
|
||||||
|
assertTrue(messageText.get() != null
|
||||||
|
&& messageText.get().contains("Quelldatei nicht gefunden: b.pdf"),
|
||||||
|
"Message area must contain German 'Quelldatei nicht gefunden: b.pdf'; was: "
|
||||||
|
+ messageText.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal thread-safe holder for tests (avoids extra imports). */
|
||||||
|
private static final class AtomicReferenceCapture<T> {
|
||||||
|
private final java.util.concurrent.atomic.AtomicReference<T> ref =
|
||||||
|
new java.util.concurrent.atomic.AtomicReference<>();
|
||||||
|
void set(T t) { ref.set(t); }
|
||||||
|
T get() { return ref.get(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static GuiBatchRunTab makeTab() {
|
||||||
|
return new GuiBatchRunTab(
|
||||||
|
() -> (p, o, t) -> GuiBatchRunLaunchOutcome.rejected("not used"),
|
||||||
|
() -> Paths.get("test.properties"),
|
||||||
|
() -> true,
|
||||||
|
() -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunResultRow row(String name, DocumentFingerprint fp,
|
||||||
|
DocumentCompletionStatus status) {
|
||||||
|
return new GuiBatchRunResultRow(name, fp, status,
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ofMillis(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runOnFx(Runnable action) throws InterruptedException {
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
java.util.concurrent.atomic.AtomicReference<Throwable> error =
|
||||||
|
new java.util.concurrent.atomic.AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try { action.run(); } catch (Throwable t) { error.set(t); }
|
||||||
|
finally { done.countDown(); }
|
||||||
|
});
|
||||||
|
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (error.get() != null) throw new AssertionError(error.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ import javafx.application.Platform;
|
|||||||
class GuiBatchRunTabSmokeTest {
|
class GuiBatchRunTabSmokeTest {
|
||||||
|
|
||||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final DocumentFingerprint DUMMY_FP = new DocumentFingerprint("a".repeat(64));
|
||||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
@@ -96,16 +98,16 @@ class GuiBatchRunTabSmokeTest {
|
|||||||
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
observer.onRunStarted(new RunId("run"), 3);
|
observer.onRunStarted(new RunId("run"), 3);
|
||||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"a.pdf", DocumentCompletionStatus.SUCCESS,
|
"a.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS,
|
||||||
"2026-03-01 - Titel.pdf",
|
"2026-03-01 - Titel.pdf",
|
||||||
LocalDate.of(2026, 3, 1),
|
LocalDate.of(2026, 3, 1),
|
||||||
"gut begründet",
|
"gut begründet",
|
||||||
Duration.ofMillis(42)));
|
Duration.ofMillis(42)));
|
||||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"b.pdf", DocumentCompletionStatus.FAILED_RETRYABLE,
|
"b.pdf", DUMMY_FP, DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||||
null, null, null, Duration.ofMillis(10)));
|
null, null, null, Duration.ofMillis(10)));
|
||||||
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"c.pdf", DocumentCompletionStatus.SKIPPED,
|
"c.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED,
|
||||||
null, null, null, Duration.ofMillis(5)));
|
null, null, null, Duration.ofMillis(5)));
|
||||||
observer.onRunEnded(new RunSummary(1, 1, 1));
|
observer.onRunEnded(new RunSummary(1, 1, 1));
|
||||||
return GuiBatchRunLaunchOutcome.completed();
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
|||||||
+33
@@ -296,6 +296,39 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
|
|||||||
return stringValue != null && !stringValue.isBlank() ? Instant.parse(stringValue) : null;
|
return stringValue != null && !stringValue.isBlank() ? Instant.parse(stringValue) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the master record for the given fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Idempotent: if no record with the given fingerprint exists the method returns
|
||||||
|
* without error. A {@link DocumentPersistenceException} is thrown only on technical
|
||||||
|
* database failures.
|
||||||
|
*
|
||||||
|
* @param fingerprint the document identity whose master record should be removed;
|
||||||
|
* must not be null
|
||||||
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
|
||||||
|
String sql = "DELETE FROM document_record WHERE fingerprint = ?";
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
statement.setString(1, fingerprint.sha256Hex());
|
||||||
|
int rowsAffected = statement.executeUpdate();
|
||||||
|
logger.debug("Deleted {} document_record row(s) for fingerprint: {}",
|
||||||
|
rowsAffected, fingerprint.sha256Hex());
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Failed to delete document record for fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
|
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
+33
@@ -355,6 +355,39 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
return rs.wasNull() ? null : value;
|
return rs.wasNull() ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all attempt history entries for the given fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Idempotent: if no attempts exist for the fingerprint the method returns without
|
||||||
|
* error. A {@link DocumentPersistenceException} is thrown only on technical database
|
||||||
|
* failures.
|
||||||
|
*
|
||||||
|
* @param fingerprint the document identity whose attempt records should be removed;
|
||||||
|
* must not be null
|
||||||
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
|
||||||
|
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
statement.setString(1, fingerprint.sha256Hex());
|
||||||
|
int rowsAffected = statement.executeUpdate();
|
||||||
|
logger.debug("Deleted {} processing_attempt row(s) for fingerprint: {}",
|
||||||
|
rowsAffected, fingerprint.sha256Hex());
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Failed to delete processing attempts for fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JDBC URL this adapter uses.
|
* Returns the JDBC URL this adapter uses.
|
||||||
*
|
*
|
||||||
|
|||||||
+35
@@ -15,6 +15,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceExcept
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQLite implementation of {@link UnitOfWorkPort}.
|
* SQLite implementation of {@link UnitOfWorkPort}.
|
||||||
@@ -163,5 +164,39 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
};
|
};
|
||||||
repo.update(record);
|
repo.update(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all attempt history entries and the document master record for the
|
||||||
|
* given fingerprint within the current transaction.
|
||||||
|
* <p>
|
||||||
|
* Attempts are deleted first to satisfy the foreign-key constraint between
|
||||||
|
* {@code processing_attempt} and {@code document_record}. Both deletes are
|
||||||
|
* idempotent: missing rows are silently ignored.
|
||||||
|
*
|
||||||
|
* @param fingerprint the document identity to fully reset; must not be null
|
||||||
|
* @throws DocumentPersistenceException if either delete fails
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// Delete attempts first (FK constraint: processing_attempt → document_record)
|
||||||
|
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
||||||
|
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
||||||
|
@Override
|
||||||
|
protected Connection getConnection() throws SQLException {
|
||||||
|
return nonClosingWrapper(connection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
attemptRepo.deleteAllByFingerprint(fingerprint);
|
||||||
|
|
||||||
|
// Then delete the master record
|
||||||
|
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
||||||
|
@Override
|
||||||
|
protected Connection getConnection() throws SQLException {
|
||||||
|
return nonClosingWrapper(connection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recordRepo.deleteByFingerprint(fingerprint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+64
-8
@@ -1,17 +1,23 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
|
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filesystem-based implementation of {@link TargetFolderPort}.
|
* Filesystem-based implementation of {@link TargetFolderPort}.
|
||||||
@@ -67,27 +73,47 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the first available unique filename in the target folder for the given base name.
|
* Resolves the first available unique filename in the target folder for the given base name,
|
||||||
|
* applying an identical-content shortcut when the base name already exists.
|
||||||
* <p>
|
* <p>
|
||||||
* Checks for {@code baseName} first; if taken, appends {@code (1)}, {@code (2)}, etc.
|
* Processing order:
|
||||||
* directly before {@code .pdf} until a free name is found.
|
* <ol>
|
||||||
|
* <li>If the base name does not yet exist, return it unchanged as a
|
||||||
|
* {@link ResolvedTargetFilename}.</li>
|
||||||
|
* <li>If the base name exists and its SHA-256 matches {@code sourceFingerprint},
|
||||||
|
* return {@link ExistingIdenticalTargetFile} — the target is already up-to-date.</li>
|
||||||
|
* <li>Otherwise try {@code (1)}, {@code (2)}, etc. until a free name is found and
|
||||||
|
* return it as a {@link ResolvedTargetFilename}.</li>
|
||||||
|
* </ol>
|
||||||
*
|
*
|
||||||
* @param baseName the desired filename including {@code .pdf} extension;
|
* @param baseName the desired filename including {@code .pdf} extension;
|
||||||
* must not be null or blank
|
* must not be null or blank
|
||||||
* @return a {@link ResolvedTargetFilename} with the first available name, or a
|
* @param sourceFingerprint the SHA-256 fingerprint of the source document; must not be null
|
||||||
|
* @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
|
||||||
* {@link TargetFolderTechnicalFailure} if folder access fails
|
* {@link TargetFolderTechnicalFailure} if folder access fails
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
public TargetFilenameResolutionResult resolveUniqueFilename(
|
||||||
|
String baseName, DocumentFingerprint sourceFingerprint) {
|
||||||
Objects.requireNonNull(baseName, "baseName must not be null");
|
Objects.requireNonNull(baseName, "baseName must not be null");
|
||||||
|
Objects.requireNonNull(sourceFingerprint, "sourceFingerprint must not be null");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Path baseNamePath = targetFolderPath.resolve(baseName);
|
||||||
|
|
||||||
// Try without suffix first
|
// Try without suffix first
|
||||||
if (!Files.exists(targetFolderPath.resolve(baseName))) {
|
if (!Files.exists(baseNamePath)) {
|
||||||
logger.debug("Resolved target filename without suffix: '{}'", baseName);
|
logger.debug("Resolved target filename without suffix: '{}'", baseName);
|
||||||
return new ResolvedTargetFilename(baseName);
|
return new ResolvedTargetFilename(baseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The base name exists — check for identical content before adding a suffix
|
||||||
|
if (isIdenticalContent(baseNamePath, sourceFingerprint)) {
|
||||||
|
logger.debug("Target file '{}' already exists with identical content — no new copy needed.",
|
||||||
|
baseName);
|
||||||
|
return new ExistingIdenticalTargetFile(baseName);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine split point: everything before the final ".pdf"
|
// Determine split point: everything before the final ".pdf"
|
||||||
if (!baseName.toLowerCase().endsWith(".pdf")) {
|
if (!baseName.toLowerCase().endsWith(".pdf")) {
|
||||||
return new TargetFolderTechnicalFailure(
|
return new TargetFolderTechnicalFailure(
|
||||||
@@ -115,6 +141,36 @@ public class FilesystemTargetFolderAdapter implements TargetFolderPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when the SHA-256 digest of the file at {@code targetPath} matches
|
||||||
|
* the hex value in {@code sourceFingerprint}.
|
||||||
|
* <p>
|
||||||
|
* Any I/O or digest error is treated as non-identical (returns {@code false} and logs
|
||||||
|
* at debug level), so the duplicate-suffix path is entered instead.
|
||||||
|
*
|
||||||
|
* @param targetPath path of the existing target file to compare
|
||||||
|
* @param sourceFingerprint expected SHA-256 hex digest of the source document
|
||||||
|
* @return {@code true} if the existing file's SHA-256 equals the source fingerprint
|
||||||
|
*/
|
||||||
|
private boolean isIdenticalContent(Path targetPath, DocumentFingerprint sourceFingerprint) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
try (InputStream in = Files.newInputStream(targetPath)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int read;
|
||||||
|
while ((read = in.read(buffer)) != -1) {
|
||||||
|
digest.update(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String targetHex = HexFormat.of().formatHex(digest.digest());
|
||||||
|
return targetHex.equalsIgnoreCase(sourceFingerprint.sha256Hex());
|
||||||
|
} catch (NoSuchAlgorithmException | IOException e) {
|
||||||
|
logger.debug("Could not compute SHA-256 of existing target file '{}': {} — "
|
||||||
|
+ "treating as non-identical.", targetPath.getFileName(), e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort deletion of a file in the target folder.
|
* Best-effort deletion of a file in the target folder.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
+43
@@ -661,4 +661,47 @@ class SqliteDocumentRecordRepositoryAdapterTest {
|
|||||||
assertThat(success.record().createdAt()).isEqualTo(createdAt);
|
assertThat(success.record().createdAt()).isEqualTo(createdAt);
|
||||||
assertThat(success.record().updatedAt()).isEqualTo(now);
|
assertThat(success.record().updatedAt()).isEqualTo(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// deleteByFingerprint
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteByFingerprint_removesExistingRecord() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||||
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
DocumentRecord record = new DocumentRecord(
|
||||||
|
fingerprint,
|
||||||
|
new SourceDocumentLocator("/path/del.pdf"),
|
||||||
|
"del.pdf",
|
||||||
|
ProcessingStatus.PROCESSING,
|
||||||
|
FailureCounters.zero(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Instant.now().truncatedTo(ChronoUnit.MICROS),
|
||||||
|
Instant.now().truncatedTo(ChronoUnit.MICROS),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
repository.create(record);
|
||||||
|
assertThat(repository.findByFingerprint(fingerprint))
|
||||||
|
.isNotInstanceOf(DocumentUnknown.class);
|
||||||
|
|
||||||
|
repository.deleteByFingerprint(fingerprint);
|
||||||
|
|
||||||
|
assertThat(repository.findByFingerprint(fingerprint))
|
||||||
|
.isInstanceOf(DocumentUnknown.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteByFingerprint_isIdempotentWhenRecordAbsent() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||||
|
|
||||||
|
// Must not throw even if the record does not exist
|
||||||
|
repository.deleteByFingerprint(fingerprint);
|
||||||
|
|
||||||
|
assertThat(repository.findByFingerprint(fingerprint))
|
||||||
|
.isInstanceOf(DocumentUnknown.class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+62
@@ -883,4 +883,66 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
|
|||||||
throw new RuntimeException("Failed to insert document record for testing", e);
|
throw new RuntimeException("Failed to insert document record for testing", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// deleteAllByFingerprint
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAllByFingerprint_removesAllAttemptsForFingerprint() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||||
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
|
||||||
|
RunId runId = new RunId("test-run-delete");
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
|
||||||
|
insertDocumentRecord(fingerprint);
|
||||||
|
|
||||||
|
// Save two attempts
|
||||||
|
for (int i = 1; i <= 2; i++) {
|
||||||
|
repository.save(ProcessingAttempt.withoutAiFields(
|
||||||
|
fingerprint, runId, i, now, now.plusSeconds(i),
|
||||||
|
ProcessingStatus.FAILED_RETRYABLE, "SomeError", "message", true));
|
||||||
|
}
|
||||||
|
assertThat(repository.findAllByFingerprint(fingerprint)).hasSize(2);
|
||||||
|
|
||||||
|
repository.deleteAllByFingerprint(fingerprint);
|
||||||
|
|
||||||
|
assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAllByFingerprint_isIdempotentWhenNoAttemptsExist() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||||
|
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
|
||||||
|
|
||||||
|
// Must not throw even if no attempts exist for this fingerprint
|
||||||
|
repository.deleteAllByFingerprint(fingerprint);
|
||||||
|
|
||||||
|
assertThat(repository.findAllByFingerprint(fingerprint)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAllByFingerprint_doesNotAffectOtherFingerprints() {
|
||||||
|
DocumentFingerprint fp1 = new DocumentFingerprint(
|
||||||
|
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
|
||||||
|
DocumentFingerprint fp2 = new DocumentFingerprint(
|
||||||
|
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
|
||||||
|
RunId runId = new RunId("run-isolation");
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
|
||||||
|
insertDocumentRecord(fp1);
|
||||||
|
insertDocumentRecord(fp2);
|
||||||
|
|
||||||
|
repository.save(ProcessingAttempt.withoutAiFields(
|
||||||
|
fp1, runId, 1, now, now.plusSeconds(1),
|
||||||
|
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
|
||||||
|
repository.save(ProcessingAttempt.withoutAiFields(
|
||||||
|
fp2, runId, 1, now, now.plusSeconds(1),
|
||||||
|
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
|
||||||
|
|
||||||
|
repository.deleteAllByFingerprint(fp1);
|
||||||
|
|
||||||
|
assertThat(repository.findAllByFingerprint(fp1)).isEmpty();
|
||||||
|
assertThat(repository.findAllByFingerprint(fp2)).hasSize(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+65
-1
@@ -1,5 +1,6 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
@@ -15,9 +16,12 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,8 +231,68 @@ class SqliteUnitOfWorkAdapterTest {
|
|||||||
unitOfWorkAdapter.executeInTransaction(txOps -> txOps.createDocumentRecord(record));
|
unitOfWorkAdapter.executeInTransaction(txOps -> txOps.createDocumentRecord(record));
|
||||||
|
|
||||||
var result = docRepository.findByFingerprint(fingerprint);
|
var result = docRepository.findByFingerprint(fingerprint);
|
||||||
assertFalse(result instanceof de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown,
|
assertFalse(result instanceof DocumentUnknown,
|
||||||
"Record must be persisted and retrievable after a successfully committed transaction");
|
"Record must be persisted and retrievable after a successfully committed transaction");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// resetDocumentByFingerprint
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetDocumentByFingerprint_deletesMasterRecordAndAttempts() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
DocumentRecord record = new DocumentRecord(
|
||||||
|
fingerprint,
|
||||||
|
new SourceDocumentLocator("/source/reset-test.pdf"),
|
||||||
|
"reset-test.pdf",
|
||||||
|
ProcessingStatus.PROCESSING,
|
||||||
|
FailureCounters.zero(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
SqliteDocumentRecordRepositoryAdapter docRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
SqliteProcessingAttemptRepositoryAdapter attemptRepository =
|
||||||
|
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
||||||
|
|
||||||
|
// Persist master record and one attempt
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps -> {
|
||||||
|
txOps.createDocumentRecord(record);
|
||||||
|
txOps.saveProcessingAttempt(ProcessingAttempt.withoutAiFields(
|
||||||
|
fingerprint, new RunId("run-reset"), 1, now, now.plusSeconds(1),
|
||||||
|
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
|
||||||
|
});
|
||||||
|
assertThat(docRepository.findByFingerprint(fingerprint)).isNotInstanceOf(DocumentUnknown.class);
|
||||||
|
assertThat(attemptRepository.findAllByFingerprint(fingerprint)).hasSize(1);
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps ->
|
||||||
|
txOps.resetDocumentByFingerprint(fingerprint));
|
||||||
|
|
||||||
|
assertThat(docRepository.findByFingerprint(fingerprint)).isInstanceOf(DocumentUnknown.class);
|
||||||
|
assertThat(attemptRepository.findAllByFingerprint(fingerprint)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetDocumentByFingerprint_isIdempotentWhenRecordAbsent() {
|
||||||
|
DocumentFingerprint fingerprint = new DocumentFingerprint(
|
||||||
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
|
||||||
|
|
||||||
|
// Must not throw even if no record exists
|
||||||
|
unitOfWorkAdapter.executeInTransaction(txOps ->
|
||||||
|
txOps.resetDocumentByFingerprint(fingerprint));
|
||||||
|
|
||||||
|
SqliteDocumentRecordRepositoryAdapter docRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
assertThat(docRepository.findByFingerprint(fingerprint)).isInstanceOf(DocumentUnknown.class);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-10
@@ -6,14 +6,18 @@ import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for {@link FilesystemTargetFolderAdapter}.
|
* Tests for {@link FilesystemTargetFolderAdapter}.
|
||||||
@@ -23,6 +27,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFail
|
|||||||
*/
|
*/
|
||||||
class FilesystemTargetFolderAdapterTest {
|
class FilesystemTargetFolderAdapterTest {
|
||||||
|
|
||||||
|
/** A fingerprint whose hex value differs from any real file content in tests. */
|
||||||
|
private static final DocumentFingerprint DUMMY_FP =
|
||||||
|
new DocumentFingerprint("0".repeat(64));
|
||||||
|
|
||||||
@TempDir
|
@TempDir
|
||||||
Path targetFolder;
|
Path targetFolder;
|
||||||
|
|
||||||
@@ -57,7 +65,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
void resolveUniqueFilename_noConflict_returnsBaseName() {
|
void resolveUniqueFilename_noConflict_returnsBaseName() {
|
||||||
String baseName = "2026-01-15 - Rechnung.pdf";
|
String baseName = "2026-01-15 - Rechnung.pdf";
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
|
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
|
||||||
@@ -72,7 +80,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
String baseName = "2026-01-15 - Rechnung.pdf";
|
String baseName = "2026-01-15 - Rechnung.pdf";
|
||||||
Files.createFile(targetFolder.resolve(baseName));
|
Files.createFile(targetFolder.resolve(baseName));
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
||||||
@@ -85,7 +93,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
Files.createFile(targetFolder.resolve(baseName));
|
Files.createFile(targetFolder.resolve(baseName));
|
||||||
Files.createFile(targetFolder.resolve("2026-01-15 - Rechnung(1).pdf"));
|
Files.createFile(targetFolder.resolve("2026-01-15 - Rechnung(1).pdf"));
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
||||||
@@ -101,7 +109,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(2).pdf"));
|
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(2).pdf"));
|
||||||
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(3).pdf"));
|
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(3).pdf"));
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
||||||
@@ -117,7 +125,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
String baseName = "2026-04-07 - Bescheid.pdf";
|
String baseName = "2026-04-07 - Bescheid.pdf";
|
||||||
Files.createFile(targetFolder.resolve(baseName));
|
Files.createFile(targetFolder.resolve(baseName));
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
|
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
|
||||||
@@ -137,7 +145,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
String baseName = "2026-01-01 - " + title + ".pdf";
|
String baseName = "2026-01-01 - " + title + ".pdf";
|
||||||
Files.createFile(targetFolder.resolve(baseName));
|
Files.createFile(targetFolder.resolve(baseName));
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
|
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
|
||||||
@@ -159,7 +167,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
// Create a file with that name (no extension) to trigger conflict handling
|
// Create a file with that name (no extension) to trigger conflict handling
|
||||||
Files.createFile(targetFolder.resolve(nameWithoutExt));
|
Files.createFile(targetFolder.resolve(nameWithoutExt));
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt, DUMMY_FP);
|
||||||
|
|
||||||
// Without .pdf extension, suffix insertion fails
|
// Without .pdf extension, suffix insertion fails
|
||||||
assertThat(result).isInstanceOf(TargetFolderTechnicalFailure.class);
|
assertThat(result).isInstanceOf(TargetFolderTechnicalFailure.class);
|
||||||
@@ -174,7 +182,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
// If the name does not exist, the adapter returns it without checking the extension
|
// If the name does not exist, the adapter returns it without checking the extension
|
||||||
String nameWithoutExt = "2026-01-15 - Rechnung";
|
String nameWithoutExt = "2026-01-15 - Rechnung";
|
||||||
|
|
||||||
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt, DUMMY_FP);
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(nameWithoutExt);
|
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(nameWithoutExt);
|
||||||
@@ -187,7 +195,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
@Test
|
@Test
|
||||||
void resolveUniqueFilename_rejectsNullBaseName() {
|
void resolveUniqueFilename_rejectsNullBaseName() {
|
||||||
assertThatNullPointerException()
|
assertThatNullPointerException()
|
||||||
.isThrownBy(() -> adapter.resolveUniqueFilename(null));
|
.isThrownBy(() -> adapter.resolveUniqueFilename(null, DUMMY_FP));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -240,7 +248,7 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
// Files.exists() on a file in a non-existent folder does not throw;
|
// Files.exists() on a file in a non-existent folder does not throw;
|
||||||
// it simply returns false, so the adapter returns the base name.
|
// it simply returns false, so the adapter returns the base name.
|
||||||
// This is consistent behaviour: no folder access error when just checking existence.
|
// This is consistent behaviour: no folder access error when just checking existence.
|
||||||
TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName);
|
TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName, DUMMY_FP);
|
||||||
|
|
||||||
// Adapter returns the base name since no conflict is detected for a non-existent folder
|
// Adapter returns the base name since no conflict is detected for a non-existent folder
|
||||||
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
@@ -256,4 +264,48 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
assertThatNullPointerException()
|
assertThatNullPointerException()
|
||||||
.isThrownBy(() -> new FilesystemTargetFolderAdapter(null));
|
.isThrownBy(() -> new FilesystemTargetFolderAdapter(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// resolveUniqueFilename – identical-content shortcut
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveUniqueFilename_existingFileWithIdenticalContent_returnsExistingIdenticalTargetFile()
|
||||||
|
throws Exception {
|
||||||
|
// Arrange: write a file with known content and compute its SHA-256
|
||||||
|
String baseName = "2026-01-15 - Identisch.pdf";
|
||||||
|
byte[] content = "identical content for test".getBytes();
|
||||||
|
Files.write(targetFolder.resolve(baseName), content);
|
||||||
|
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
digest.update(content);
|
||||||
|
String sha256Hex = HexFormat.of().formatHex(digest.digest());
|
||||||
|
DocumentFingerprint matchingFp = new DocumentFingerprint(sha256Hex);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, matchingFp);
|
||||||
|
|
||||||
|
// Assert: shortcut path returns ExistingIdenticalTargetFile, not a new suffix
|
||||||
|
assertThat(result).isInstanceOf(ExistingIdenticalTargetFile.class);
|
||||||
|
assertThat(((ExistingIdenticalTargetFile) result).existingFilename()).isEqualTo(baseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveUniqueFilename_existingFileWithDifferentContent_returnsSuffixedFilename()
|
||||||
|
throws IOException {
|
||||||
|
// Arrange: existing file with some content; source fingerprint differs
|
||||||
|
String baseName = "2026-01-15 - Verschieden.pdf";
|
||||||
|
Files.write(targetFolder.resolve(baseName), "existing content".getBytes());
|
||||||
|
|
||||||
|
// Use a fingerprint whose hex does not match the SHA-256 of the existing file
|
||||||
|
DocumentFingerprint differentFp = new DocumentFingerprint("0".repeat(64));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName, differentFp);
|
||||||
|
|
||||||
|
// Assert: different content → suffix appended
|
||||||
|
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
|
||||||
|
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
|
||||||
|
.isEqualTo("2026-01-15 - Verschieden(1).pdf");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -4,6 +4,8 @@ import java.time.Duration;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable event describing the outcome of processing exactly one candidate document.
|
* Immutable event describing the outcome of processing exactly one candidate document.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -16,6 +18,8 @@ import java.util.Objects;
|
|||||||
*
|
*
|
||||||
* @param originalFileName the source candidate's unique identifier (typically the source
|
* @param originalFileName the source candidate's unique identifier (typically the source
|
||||||
* filename); never {@code null} or blank
|
* filename); never {@code null} or blank
|
||||||
|
* @param fingerprint the content-based identity of the processed document;
|
||||||
|
* never {@code null}
|
||||||
* @param status the aggregated outcome status; never {@code null}
|
* @param status the aggregated outcome status; never {@code null}
|
||||||
* @param finalFileName the final target filename, including any duplicate suffix;
|
* @param finalFileName the final target filename, including any duplicate suffix;
|
||||||
* never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
|
* never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
|
||||||
@@ -32,6 +36,7 @@ import java.util.Objects;
|
|||||||
*/
|
*/
|
||||||
public record DocumentCompletionEvent(
|
public record DocumentCompletionEvent(
|
||||||
String originalFileName,
|
String originalFileName,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
DocumentCompletionStatus status,
|
DocumentCompletionStatus status,
|
||||||
String finalFileName,
|
String finalFileName,
|
||||||
LocalDate resolvedDate,
|
LocalDate resolvedDate,
|
||||||
@@ -41,8 +46,9 @@ public record DocumentCompletionEvent(
|
|||||||
/**
|
/**
|
||||||
* Compact constructor validating mandatory fields.
|
* Compact constructor validating mandatory fields.
|
||||||
*
|
*
|
||||||
* @throws NullPointerException if {@code originalFileName}, {@code status} or
|
* @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
|
||||||
* {@code processingDuration} is {@code null}
|
* {@code status} or {@code processingDuration} is
|
||||||
|
* {@code null}
|
||||||
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||||
* {@code processingDuration} is negative
|
* {@code processingDuration} is negative
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +57,7 @@ public record DocumentCompletionEvent(
|
|||||||
if (originalFileName.isBlank()) {
|
if (originalFileName.isBlank()) {
|
||||||
throw new IllegalArgumentException("originalFileName must not be blank");
|
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||||
}
|
}
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
Objects.requireNonNull(status, "status must not be null");
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||||
if (processingDuration.isNegative()) {
|
if (processingDuration.isNegative()) {
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable summary of a {@link ResetDocumentStatusUseCase#reset(Set)} invocation.
|
||||||
|
* <p>
|
||||||
|
* Reports how many documents were requested for reset, which were successfully reset,
|
||||||
|
* and which encountered a technical failure. Callers can use this record to present
|
||||||
|
* a user-visible result or decide on follow-up actions.
|
||||||
|
*
|
||||||
|
* @param requestedCount total number of fingerprints that were passed to the reset
|
||||||
|
* operation; always >= 0
|
||||||
|
* @param successfullyReset set of fingerprints that were successfully deleted from
|
||||||
|
* persistence; never null
|
||||||
|
* @param failures map of fingerprint → error message for every fingerprint
|
||||||
|
* whose reset operation encountered a technical failure;
|
||||||
|
* never null
|
||||||
|
*/
|
||||||
|
public record ResetDocumentStatusResult(
|
||||||
|
int requestedCount,
|
||||||
|
Set<DocumentFingerprint> successfullyReset,
|
||||||
|
Map<DocumentFingerprint, String> failures) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor validating and defensively copying the mutable collections.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException if {@code successfullyReset} or {@code failures}
|
||||||
|
* is null
|
||||||
|
* @throws IllegalArgumentException if {@code requestedCount} is negative
|
||||||
|
*/
|
||||||
|
public ResetDocumentStatusResult {
|
||||||
|
if (requestedCount < 0) {
|
||||||
|
throw new IllegalArgumentException("requestedCount must not be negative");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(successfullyReset, "successfullyReset must not be null");
|
||||||
|
Objects.requireNonNull(failures, "failures must not be null");
|
||||||
|
successfullyReset = Collections.unmodifiableSet(Set.copyOf(successfullyReset));
|
||||||
|
failures = Collections.unmodifiableMap(Map.copyOf(failures));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of fingerprints that were successfully reset.
|
||||||
|
*
|
||||||
|
* @return the count of successfully reset documents; always >= 0
|
||||||
|
*/
|
||||||
|
public int successCount() {
|
||||||
|
return successfullyReset.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of fingerprints for which the reset failed with a technical error.
|
||||||
|
*
|
||||||
|
* @return the count of failed resets; always >= 0
|
||||||
|
*/
|
||||||
|
public int failureCount() {
|
||||||
|
return failures.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound port for resetting the processing status of one or more documents.
|
||||||
|
* <p>
|
||||||
|
* A reset removes all persistence data (attempt history and document master record)
|
||||||
|
* for the specified fingerprints, making those documents eligible for reprocessing in
|
||||||
|
* the next regular or targeted batch run as if they had never been processed.
|
||||||
|
* <p>
|
||||||
|
* The operation follows a best-effort semantics: each fingerprint is processed
|
||||||
|
* independently. A technical failure for one fingerprint does not prevent the reset
|
||||||
|
* from being attempted for the remaining fingerprints. The result carries the full
|
||||||
|
* accounting of successes and failures.
|
||||||
|
*/
|
||||||
|
public interface ResetDocumentStatusUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the processing status for the supplied set of document fingerprints.
|
||||||
|
* <p>
|
||||||
|
* For each fingerprint the implementation deletes the document master record and
|
||||||
|
* all associated attempt history within a single atomic transaction. If the
|
||||||
|
* transaction fails for a given fingerprint, that fingerprint's error is recorded
|
||||||
|
* in the result's {@link ResetDocumentStatusResult#failures() failures} map and
|
||||||
|
* processing continues with the remaining fingerprints.
|
||||||
|
*
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be null;
|
||||||
|
* may be empty (results in a completed result with zero requests)
|
||||||
|
* @return a {@link ResetDocumentStatusResult} describing the outcome; never null
|
||||||
|
*/
|
||||||
|
ResetDocumentStatusResult reset(Set<DocumentFingerprint> fingerprints);
|
||||||
|
}
|
||||||
+13
@@ -68,4 +68,17 @@ public interface DocumentRecordRepository {
|
|||||||
* @throws DocumentPersistenceException if the update fails due to a technical error
|
* @throws DocumentPersistenceException if the update fails due to a technical error
|
||||||
*/
|
*/
|
||||||
void update(DocumentRecord record);
|
void update(DocumentRecord record);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the master record for the given fingerprint.
|
||||||
|
* <p>
|
||||||
|
* This operation is idempotent: if no record exists for the fingerprint, the method
|
||||||
|
* returns without error. A {@link DocumentPersistenceException} is thrown only on
|
||||||
|
* technical failures such as database connectivity errors.
|
||||||
|
*
|
||||||
|
* @param fingerprint the document identity whose master record should be removed;
|
||||||
|
* must not be null
|
||||||
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
|
*/
|
||||||
|
void deleteByFingerprint(DocumentFingerprint fingerprint);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of {@link TargetFolderPort#resolveUniqueFilename(String)} when the target file
|
||||||
|
* at the proposed base name already exists <em>and</em> its binary content is identical
|
||||||
|
* to the source document (same SHA-256 fingerprint).
|
||||||
|
* <p>
|
||||||
|
* This result signals to the application layer that no new copy is needed: the existing
|
||||||
|
* target file is byte-for-byte identical to the source. The processing coordinator treats
|
||||||
|
* this as a successful outcome — the document is considered already present in the target
|
||||||
|
* folder under the given filename.
|
||||||
|
*
|
||||||
|
* @param existingFilename the filename of the already-existing identical target file,
|
||||||
|
* including extension; never null or blank
|
||||||
|
*/
|
||||||
|
public record ExistingIdenticalTargetFile(String existingFilename)
|
||||||
|
implements TargetFilenameResolutionResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor validating the filename.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException if {@code existingFilename} is null
|
||||||
|
* @throws IllegalArgumentException if {@code existingFilename} is blank
|
||||||
|
*/
|
||||||
|
public ExistingIdenticalTargetFile {
|
||||||
|
Objects.requireNonNull(existingFilename, "existingFilename must not be null");
|
||||||
|
if (existingFilename.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("existingFilename must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -88,4 +88,17 @@ public interface ProcessingAttemptRepository {
|
|||||||
* @throws DocumentPersistenceException if the query fails due to a technical error
|
* @throws DocumentPersistenceException if the query fails due to a technical error
|
||||||
*/
|
*/
|
||||||
ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint);
|
ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all attempt history entries for the given fingerprint.
|
||||||
|
* <p>
|
||||||
|
* This operation is idempotent: if no attempts exist for the fingerprint, the method
|
||||||
|
* returns without error. A {@link DocumentPersistenceException} is thrown only on
|
||||||
|
* technical failures such as database connectivity errors.
|
||||||
|
*
|
||||||
|
* @param fingerprint the document identity whose attempt records should be removed;
|
||||||
|
* must not be null
|
||||||
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
|
*/
|
||||||
|
void deleteAllByFingerprint(DocumentFingerprint fingerprint);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -3,12 +3,15 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
|||||||
/**
|
/**
|
||||||
* Sealed result type for {@link TargetFolderPort#resolveUniqueFilename(String)}.
|
* Sealed result type for {@link TargetFolderPort#resolveUniqueFilename(String)}.
|
||||||
* <p>
|
* <p>
|
||||||
* Permits exactly two outcomes:
|
* Permits exactly three outcomes:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link ResolvedTargetFilename} — the first available unique filename was determined.</li>
|
* <li>{@link ResolvedTargetFilename} — the first available unique filename was determined.</li>
|
||||||
|
* <li>{@link ExistingIdenticalTargetFile} — the base name already exists in the target folder
|
||||||
|
* and the existing file is byte-for-byte identical to the source document; no new copy
|
||||||
|
* is needed.</li>
|
||||||
* <li>{@link TargetFolderTechnicalFailure} — the target folder could not be accessed.</li>
|
* <li>{@link TargetFolderTechnicalFailure} — the target folder could not be accessed.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public sealed interface TargetFilenameResolutionResult
|
public sealed interface TargetFilenameResolutionResult
|
||||||
permits ResolvedTargetFilename, TargetFolderTechnicalFailure {
|
permits ResolvedTargetFilename, ExistingIdenticalTargetFile, TargetFolderTechnicalFailure {
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-9
@@ -1,5 +1,7 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound port for target folder access: duplicate resolution and best-effort cleanup.
|
* Outbound port for target folder access: duplicate resolution and best-effort cleanup.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -21,6 +23,15 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
|||||||
* purely a technical collision-avoidance mechanism and introduces no new fachliche
|
* purely a technical collision-avoidance mechanism and introduces no new fachliche
|
||||||
* title interpretation.
|
* title interpretation.
|
||||||
*
|
*
|
||||||
|
* <h2>Identical-content shortcut</h2>
|
||||||
|
* <p>
|
||||||
|
* Before appending any numeric suffix, the implementation checks whether the base name
|
||||||
|
* already exists in the target folder <em>and</em> whether that existing file is
|
||||||
|
* byte-for-byte identical to the source document (verified via the supplied
|
||||||
|
* {@link DocumentFingerprint}). When both conditions hold, the method returns
|
||||||
|
* {@link ExistingIdenticalTargetFile} instead of {@link ResolvedTargetFilename},
|
||||||
|
* signalling that no new copy is required.
|
||||||
|
*
|
||||||
* <h2>Architecture boundary</h2>
|
* <h2>Architecture boundary</h2>
|
||||||
* <p>
|
* <p>
|
||||||
* No {@code Path}, {@code File}, or NIO types appear in this interface. The concrete
|
* No {@code Path}, {@code File}, or NIO types appear in this interface. The concrete
|
||||||
@@ -41,22 +52,32 @@ public interface TargetFolderPort {
|
|||||||
String getTargetFolderLocator();
|
String getTargetFolderLocator();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the first available unique filename in the target folder for the given base name.
|
* Resolves the first available unique filename in the target folder for the given base name,
|
||||||
|
* taking the source document's fingerprint into account for identity-based shortcutting.
|
||||||
* <p>
|
* <p>
|
||||||
* If the base name is not yet taken, it is returned unchanged. Otherwise the method
|
* Processing order:
|
||||||
* appends {@code (1)}, {@code (2)}, etc. directly before {@code .pdf} until a free
|
* <ol>
|
||||||
* name is found.
|
* <li>If the base name does not yet exist in the target folder, return
|
||||||
|
* {@link ResolvedTargetFilename} with the base name.</li>
|
||||||
|
* <li>If the base name exists and its content is identical to the source document
|
||||||
|
* (SHA-256 comparison using {@code sourceFingerprint}), return
|
||||||
|
* {@link ExistingIdenticalTargetFile} — no new copy is needed.</li>
|
||||||
|
* <li>Otherwise append {@code (1)}, {@code (2)}, etc. directly before {@code .pdf}
|
||||||
|
* until a free name is found; return {@link ResolvedTargetFilename} with that name.</li>
|
||||||
|
* </ol>
|
||||||
* <p>
|
* <p>
|
||||||
* The returned filename contains only the file name, not the full path. It is safe
|
* The returned filename contains only the file name, not the full path. It is safe
|
||||||
* to use as the {@code resolvedFilename} parameter of
|
* to use as the {@code resolvedFilename} parameter of
|
||||||
* {@link TargetFileCopyPort#copyToTarget(de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator, String)}.
|
* {@link TargetFileCopyPort#copyToTarget(de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator, String)}.
|
||||||
*
|
*
|
||||||
* @param baseName the desired filename including the {@code .pdf} extension;
|
* @param baseName the desired filename including the {@code .pdf} extension;
|
||||||
* must not be null or blank
|
* must not be null or blank
|
||||||
* @return a {@link ResolvedTargetFilename} with the first available name, or a
|
* @param sourceFingerprint the SHA-256 fingerprint of the source document used for
|
||||||
* {@link TargetFolderTechnicalFailure} if the target folder is not accessible
|
* identical-content detection; must not be null
|
||||||
|
* @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
|
||||||
|
* {@link TargetFolderTechnicalFailure}
|
||||||
*/
|
*/
|
||||||
TargetFilenameResolutionResult resolveUniqueFilename(String baseName);
|
TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort attempt to delete a file previously written to the target folder.
|
* Best-effort attempt to delete a file previously written to the target folder.
|
||||||
|
|||||||
+35
-3
@@ -2,15 +2,16 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
|||||||
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port for executing multiple repository operations within a single unit of work.
|
* Port for executing multiple repository operations within a single unit of work.
|
||||||
* <p>
|
* <p>
|
||||||
* Ensures that related persistence operations (such as saving a processing attempt
|
* Ensures that related persistence operations (such as saving a processing attempt
|
||||||
* and updating a document record) are executed atomically.
|
* and updating a document record) are executed atomically.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public interface UnitOfWorkPort {
|
public interface UnitOfWorkPort {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the given operations within a single unit of work.
|
* Executes the given operations within a single unit of work.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -20,13 +21,44 @@ public interface UnitOfWorkPort {
|
|||||||
* @throws DocumentPersistenceException if any operation fails
|
* @throws DocumentPersistenceException if any operation fails
|
||||||
*/
|
*/
|
||||||
void executeInTransaction(Consumer<TransactionOperations> operations);
|
void executeInTransaction(Consumer<TransactionOperations> operations);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operations available within a transaction.
|
* Operations available within a transaction.
|
||||||
*/
|
*/
|
||||||
interface TransactionOperations {
|
interface TransactionOperations {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a processing attempt within the current transaction.
|
||||||
|
*
|
||||||
|
* @param attempt the attempt to persist; must not be null
|
||||||
|
*/
|
||||||
void saveProcessingAttempt(ProcessingAttempt attempt);
|
void saveProcessingAttempt(ProcessingAttempt attempt);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new document master record within the current transaction.
|
||||||
|
*
|
||||||
|
* @param record the new record to persist; must not be null
|
||||||
|
*/
|
||||||
void createDocumentRecord(DocumentRecord record);
|
void createDocumentRecord(DocumentRecord record);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing document master record within the current transaction.
|
||||||
|
*
|
||||||
|
* @param record the updated record; must not be null; fingerprint must exist
|
||||||
|
*/
|
||||||
void updateDocumentRecord(DocumentRecord record);
|
void updateDocumentRecord(DocumentRecord record);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all attempt history entries and the document master record for the
|
||||||
|
* given fingerprint within the current transaction.
|
||||||
|
* <p>
|
||||||
|
* Deletion order must respect foreign-key constraints: attempt history rows are
|
||||||
|
* removed first, then the master record. This operation is idempotent — if no
|
||||||
|
* data exists for the fingerprint the method returns silently.
|
||||||
|
*
|
||||||
|
* @param fingerprint the document identity to fully reset; must not be null
|
||||||
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
|
*/
|
||||||
|
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+28
-8
@@ -17,6 +17,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalFinalFailure;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentTerminalSuccess;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnicalFailure;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
@@ -164,8 +165,8 @@ public class DocumentProcessingCoordinator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional per-run completion forwarder that is consulted by
|
* Optional per-run completion forwarder that is consulted by
|
||||||
* {@link #publishCompletion(SourceDocumentCandidate, DocumentCompletionStatus, String,
|
* {@link #publishCompletion(SourceDocumentCandidate, DocumentFingerprint, DocumentCompletionStatus,
|
||||||
* LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
|
* String, LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
|
||||||
* <p>
|
* <p>
|
||||||
* Assigned by the inbound use case for the duration of a single run and cleared before the
|
* Assigned by the inbound use case for the duration of a single run and cleared before the
|
||||||
* use case returns. A {@code null} value means no external observer is attached and the
|
* use case returns. A {@code null} value means no external observer is attached and the
|
||||||
@@ -490,8 +491,10 @@ public class DocumentProcessingCoordinator {
|
|||||||
String baseFilename = ((TargetFilenameBuildingService.BaseFilenameReady) filenameResult).baseFilename();
|
String baseFilename = ((TargetFilenameBuildingService.BaseFilenameReady) filenameResult).baseFilename();
|
||||||
|
|
||||||
// --- Step 3: Resolve unique filename in target folder ---
|
// --- Step 3: Resolve unique filename in target folder ---
|
||||||
|
// Passing the source fingerprint enables the adapter to detect an identical existing
|
||||||
|
// target file and return ExistingIdenticalTargetFile instead of a numbered suffix.
|
||||||
TargetFilenameResolutionResult resolutionResult =
|
TargetFilenameResolutionResult resolutionResult =
|
||||||
targetFolderPort.resolveUniqueFilename(baseFilename);
|
targetFolderPort.resolveUniqueFilename(baseFilename, fingerprint);
|
||||||
|
|
||||||
if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
|
if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
|
||||||
logger.error("Duplicate resolution failed for '{}': {}",
|
logger.error("Duplicate resolution failed for '{}': {}",
|
||||||
@@ -501,6 +504,20 @@ public class DocumentProcessingCoordinator {
|
|||||||
"Target folder duplicate resolution failed: " + folderFailure.errorMessage());
|
"Target folder duplicate resolution failed: " + folderFailure.errorMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identical-content shortcut: target already exists with the same content — treat as
|
||||||
|
// SUCCESS without writing a new copy.
|
||||||
|
if (resolutionResult instanceof ExistingIdenticalTargetFile identicalFile) {
|
||||||
|
logger.info("Target file '{}' already exists with identical content for '{}' "
|
||||||
|
+ "(fingerprint: {}). Treating as success without new copy.",
|
||||||
|
identicalFile.existingFilename(), candidate.uniqueIdentifier(),
|
||||||
|
fingerprint.sha256Hex());
|
||||||
|
return persistTargetCopySuccess(
|
||||||
|
candidate, fingerprint, existingRecord, context, attemptStart, now,
|
||||||
|
identicalFile.existingFilename(),
|
||||||
|
targetFolderPort.getTargetFolderLocator(),
|
||||||
|
proposalAttempt);
|
||||||
|
}
|
||||||
|
|
||||||
String resolvedFilename =
|
String resolvedFilename =
|
||||||
((ResolvedTargetFilename) resolutionResult).resolvedFilename();
|
((ResolvedTargetFilename) resolutionResult).resolvedFilename();
|
||||||
logger.info("Generated target filename for '{}' (fingerprint: {}): '{}'.",
|
logger.info("Generated target filename for '{}' (fingerprint: {}): '{}'.",
|
||||||
@@ -597,7 +614,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
|
|
||||||
logger.info("Document '{}' successfully processed. Target: '{}'.",
|
logger.info("Document '{}' successfully processed. Target: '{}'.",
|
||||||
candidate.uniqueIdentifier(), resolvedFilename);
|
candidate.uniqueIdentifier(), resolvedFilename);
|
||||||
publishCompletion(candidate, DocumentCompletionStatus.SUCCESS,
|
publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SUCCESS,
|
||||||
resolvedFilename,
|
resolvedFilename,
|
||||||
proposalAttempt.resolvedDate(),
|
proposalAttempt.resolvedDate(),
|
||||||
proposalAttempt.aiReasoning(),
|
proposalAttempt.aiReasoning(),
|
||||||
@@ -681,7 +698,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
||||||
updatedCounters.transientErrorCount(), maxRetriesTransient);
|
updatedCounters.transientErrorCount(), maxRetriesTransient);
|
||||||
}
|
}
|
||||||
publishCompletion(candidate,
|
publishCompletion(candidate, fingerprint,
|
||||||
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
|
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||||
: DocumentCompletionStatus.FAILED_PERMANENT,
|
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
null, null, null, attemptStart, now);
|
null, null, null, attemptStart, now);
|
||||||
@@ -750,7 +767,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
// completion event keeps the observer in sync with the user-visible state even though
|
// completion event keeps the observer in sync with the user-visible state even though
|
||||||
// nothing new was persisted.
|
// nothing new was persisted.
|
||||||
String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
|
String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
|
||||||
publishCompletion(candidate,
|
publishCompletion(candidate, fingerprint,
|
||||||
transition.retryable()
|
transition.retryable()
|
||||||
? DocumentCompletionStatus.FAILED_RETRYABLE
|
? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||||
: DocumentCompletionStatus.FAILED_PERMANENT,
|
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
@@ -797,7 +814,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
|
|
||||||
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
|
logger.debug("Skip attempt #{} persisted for '{}' with status {}.",
|
||||||
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
|
attemptNumber, candidate.uniqueIdentifier(), skipStatus);
|
||||||
publishCompletion(candidate, DocumentCompletionStatus.SKIPPED,
|
publishCompletion(candidate, fingerprint, DocumentCompletionStatus.SKIPPED,
|
||||||
null, null, null, attemptStart, now);
|
null, null, null, attemptStart, now);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -1067,7 +1084,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
|
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
|
||||||
// the actual completion event (SUCCESS or transient-error failure).
|
// the actual completion event (SUCCESS or transient-error failure).
|
||||||
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
|
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
|
||||||
publishCompletion(candidate, toCompletionStatus(outcome),
|
publishCompletion(candidate, fingerprint, toCompletionStatus(outcome),
|
||||||
null, null, null, attemptStart, now);
|
null, null, null, attemptStart, now);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1200,6 +1217,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
* not affect persistence or batch flow.
|
* not affect persistence or batch flow.
|
||||||
*
|
*
|
||||||
* @param candidate the candidate being reported; must not be null
|
* @param candidate the candidate being reported; must not be null
|
||||||
|
* @param fingerprint the content-based identity of the document; must not be null
|
||||||
* @param status the aggregated completion status; must not be null
|
* @param status the aggregated completion status; must not be null
|
||||||
* @param finalFileName the final target filename on success; {@code null} otherwise
|
* @param finalFileName the final target filename on success; {@code null} otherwise
|
||||||
* @param resolvedDate the resolved date on success; may be {@code null} otherwise
|
* @param resolvedDate the resolved date on success; may be {@code null} otherwise
|
||||||
@@ -1210,6 +1228,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
*/
|
*/
|
||||||
private void publishCompletion(
|
private void publishCompletion(
|
||||||
SourceDocumentCandidate candidate,
|
SourceDocumentCandidate candidate,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
DocumentCompletionStatus status,
|
DocumentCompletionStatus status,
|
||||||
String finalFileName,
|
String finalFileName,
|
||||||
LocalDate resolvedDate,
|
LocalDate resolvedDate,
|
||||||
@@ -1227,6 +1246,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
try {
|
try {
|
||||||
forwarder.accept(new DocumentCompletionEvent(
|
forwarder.accept(new DocumentCompletionEvent(
|
||||||
candidate.uniqueIdentifier(),
|
candidate.uniqueIdentifier(),
|
||||||
|
fingerprint,
|
||||||
status,
|
status,
|
||||||
finalFileName,
|
finalFileName,
|
||||||
resolvedDate,
|
resolvedDate,
|
||||||
|
|||||||
+83
-11
@@ -1,8 +1,11 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
@@ -16,6 +19,7 @@ import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
|
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
|
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError;
|
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintTechnicalError;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
@@ -42,6 +46,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
|||||||
* <li>For each candidate, execute the processing order:
|
* <li>For each candidate, execute the processing order:
|
||||||
* <ol type="a">
|
* <ol type="a">
|
||||||
* <li>Compute fingerprint.</li>
|
* <li>Compute fingerprint.</li>
|
||||||
|
* <li>If a fingerprint filter is active in the {@link BatchRunContext}, skip
|
||||||
|
* candidates whose fingerprint is not in the filter (no event, no persistence).</li>
|
||||||
* <li>Load document master record.</li>
|
* <li>Load document master record.</li>
|
||||||
* <li>If already {@code SUCCESS} → persist skip attempt with
|
* <li>If already {@code SUCCESS} → persist skip attempt with
|
||||||
* {@code SKIPPED_ALREADY_PROCESSED}.</li>
|
* {@code SKIPPED_ALREADY_PROCESSED}.</li>
|
||||||
@@ -56,6 +62,18 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
|||||||
* <li>Release lock and return structured outcome for Bootstrap exit code mapping.</li>
|
* <li>Release lock and return structured outcome for Bootstrap exit code mapping.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
|
* <h2>Fingerprint filter (mini-run)</h2>
|
||||||
|
* <p>
|
||||||
|
* When the {@link BatchRunContext} carries a fingerprint filter, the run restricts
|
||||||
|
* processing to exactly those candidates whose SHA-256 fingerprint is contained in
|
||||||
|
* the filter. Candidates not in the filter are silently skipped — no completion event
|
||||||
|
* is emitted, no persistence record is written, and they do not count toward the
|
||||||
|
* progress total reported to the {@link BatchRunProgressObserver}.
|
||||||
|
* <p>
|
||||||
|
* To provide the correct total count for the progress bar, fingerprints of all source
|
||||||
|
* candidates are computed up front before the observer is notified of the run start.
|
||||||
|
* Only filter-matching candidates are included in the total and the processing loop.
|
||||||
|
*
|
||||||
* <h2>Idempotency</h2>
|
* <h2>Idempotency</h2>
|
||||||
* <p>
|
* <p>
|
||||||
* Documents are identified exclusively by their SHA-256 content fingerprint. A document
|
* Documents are identified exclusively by their SHA-256 content fingerprint. A document
|
||||||
@@ -73,7 +91,6 @@ import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
|||||||
* For every identified document, the processing attempt and the master record are
|
* For every identified document, the processing attempt and the master record are
|
||||||
* written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single
|
* written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single
|
||||||
* document are caught and logged; the batch run continues with the remaining candidates.
|
* document are caught and logged; the batch run continues with the remaining candidates.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase {
|
public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase {
|
||||||
|
|
||||||
@@ -206,7 +223,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads candidates and processes them one by one.
|
* Loads candidates and processes them one by one, respecting any fingerprint filter
|
||||||
|
* present on the {@link BatchRunContext}.
|
||||||
* <p>
|
* <p>
|
||||||
* Document-level failures — including content errors, transient technical errors,
|
* Document-level failures — including content errors, transient technical errors,
|
||||||
* and individual persistence failures — do not affect the batch outcome. The batch
|
* and individual persistence failures — do not affect the batch outcome. The batch
|
||||||
@@ -217,26 +235,43 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
* <p>
|
* <p>
|
||||||
* Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
|
* Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
|
||||||
* the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
|
* the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
|
||||||
|
* <p>
|
||||||
|
* When a fingerprint filter is active, all source-folder candidates are scanned but their
|
||||||
|
* fingerprints are computed up front to determine which candidates belong to the effective
|
||||||
|
* candidate list. Only filter-matching candidates count toward the total reported to the
|
||||||
|
* observer and are included in the processing loop.
|
||||||
*
|
*
|
||||||
* @param context the current batch run context
|
* @param context the current batch run context
|
||||||
* @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
|
* @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
|
||||||
* or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
|
* or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
|
||||||
*/
|
*/
|
||||||
private BatchRunOutcome processCandidates(BatchRunContext context) {
|
private BatchRunOutcome processCandidates(BatchRunContext context) {
|
||||||
List<SourceDocumentCandidate> candidates;
|
List<SourceDocumentCandidate> allCandidates;
|
||||||
try {
|
try {
|
||||||
candidates = sourceDocumentCandidatesPort.loadCandidates();
|
allCandidates = sourceDocumentCandidatesPort.loadCandidates();
|
||||||
} catch (SourceDocumentAccessException e) {
|
} catch (SourceDocumentAccessException e) {
|
||||||
logger.error("Cannot access source folder: {}", e.getMessage(), e);
|
logger.error("Cannot access source folder: {}", e.getMessage(), e);
|
||||||
return BatchRunOutcome.FAILURE;
|
return BatchRunOutcome.FAILURE;
|
||||||
}
|
}
|
||||||
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
|
logger.info("Found {} PDF candidate(s) in source folder.", allCandidates.size());
|
||||||
|
|
||||||
// Notify observer of the known candidate count up-front so observers can size their
|
// When a fingerprint filter is active, pre-compute fingerprints to determine
|
||||||
// progress bars. The count reflects the source folder at scan time and remains fixed
|
// the effective candidate list and the correct total for the progress observer.
|
||||||
// for the remainder of the run (also when the run is cancelled early).
|
Optional<Set<DocumentFingerprint>> filter = context.fingerprintFilter();
|
||||||
|
List<SourceDocumentCandidate> effectiveCandidates;
|
||||||
|
if (filter.isPresent()) {
|
||||||
|
effectiveCandidates = buildFilteredCandidateList(allCandidates, filter.get(), context);
|
||||||
|
logger.info("Fingerprint filter active: {} of {} candidate(s) match.",
|
||||||
|
effectiveCandidates.size(), allCandidates.size());
|
||||||
|
} else {
|
||||||
|
effectiveCandidates = allCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify observer of the effective candidate count up-front so observers can size
|
||||||
|
// their progress bars. The count reflects the filter-matched candidates and remains
|
||||||
|
// fixed for the remainder of the run (also when the run is cancelled early).
|
||||||
try {
|
try {
|
||||||
progressObserver.onRunStarted(context.runId(), candidates.size());
|
progressObserver.onRunStarted(context.runId(), effectiveCandidates.size());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
|
logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
@@ -249,12 +284,12 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
try {
|
try {
|
||||||
int processedCount = 0;
|
int processedCount = 0;
|
||||||
boolean cancelled = false;
|
boolean cancelled = false;
|
||||||
for (SourceDocumentCandidate candidate : candidates) {
|
for (SourceDocumentCandidate candidate : effectiveCandidates) {
|
||||||
if (cancellationTokenRequested()) {
|
if (cancellationTokenRequested()) {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
logger.info("Cancellation requested before processing next candidate. "
|
logger.info("Cancellation requested before processing next candidate. "
|
||||||
+ "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
|
+ "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
|
||||||
context.runId(), processedCount, candidates.size());
|
context.runId(), processedCount, effectiveCandidates.size());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
processCandidate(candidate, context);
|
processCandidate(candidate, context);
|
||||||
@@ -276,6 +311,43 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
return BatchRunOutcome.SUCCESS;
|
return BatchRunOutcome.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-computes fingerprints for all raw candidates and returns the subset whose
|
||||||
|
* fingerprint is contained in the given filter set.
|
||||||
|
* <p>
|
||||||
|
* Candidates for which fingerprint computation fails are logged at warn level and
|
||||||
|
* excluded from the effective list (consistent with the regular per-candidate
|
||||||
|
* fingerprint-error handling).
|
||||||
|
*
|
||||||
|
* @param allCandidates all candidates from the source folder scan
|
||||||
|
* @param filter the set of fingerprints to match against
|
||||||
|
* @param context the current batch run context (used for logging)
|
||||||
|
* @return the ordered sub-list of candidates whose fingerprints are in the filter
|
||||||
|
*/
|
||||||
|
private List<SourceDocumentCandidate> buildFilteredCandidateList(
|
||||||
|
List<SourceDocumentCandidate> allCandidates,
|
||||||
|
Set<DocumentFingerprint> filter,
|
||||||
|
BatchRunContext context) {
|
||||||
|
|
||||||
|
List<SourceDocumentCandidate> matched = new ArrayList<>();
|
||||||
|
for (SourceDocumentCandidate candidate : allCandidates) {
|
||||||
|
FingerprintResult result = fingerprintPort.computeFingerprint(candidate);
|
||||||
|
switch (result) {
|
||||||
|
case FingerprintTechnicalError error -> {
|
||||||
|
logger.warn("Fingerprint computation failed for '{}' during filter pre-pass "
|
||||||
|
+ "(RunId: {}): {} — candidate excluded.",
|
||||||
|
candidate.uniqueIdentifier(), context.runId(), error.errorMessage());
|
||||||
|
}
|
||||||
|
case FingerprintSuccess success -> {
|
||||||
|
if (filter.contains(success.fingerprint())) {
|
||||||
|
matched.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean cancellationTokenRequested() {
|
private boolean cancellationTokenRequested() {
|
||||||
try {
|
try {
|
||||||
return cancellationToken.isCancellationRequested();
|
return cancellationToken.isCancellationRequested();
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of {@link ResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* For each requested fingerprint, this implementation deletes the document master
|
||||||
|
* record and all associated attempt history in a single atomic transaction via
|
||||||
|
* {@link UnitOfWorkPort}. Deletion order honours the foreign-key constraint:
|
||||||
|
* attempt rows are removed before the master record.
|
||||||
|
* <p>
|
||||||
|
* The operation applies best-effort semantics: every fingerprint is attempted
|
||||||
|
* independently. A technical failure for one fingerprint is caught, logged, and
|
||||||
|
* recorded in the result's failure map; the remaining fingerprints continue to be
|
||||||
|
* processed. The batch never aborts early.
|
||||||
|
*/
|
||||||
|
public class DefaultResetDocumentStatusUseCase implements ResetDocumentStatusUseCase {
|
||||||
|
|
||||||
|
private final UnitOfWorkPort unitOfWorkPort;
|
||||||
|
private final ProcessingLogger logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the use case with the required persistence port and logger.
|
||||||
|
*
|
||||||
|
* @param unitOfWorkPort port for executing the delete operations atomically;
|
||||||
|
* must not be null
|
||||||
|
* @param logger for operation-level logging; must not be null
|
||||||
|
* @throws NullPointerException if any parameter is null
|
||||||
|
*/
|
||||||
|
public DefaultResetDocumentStatusUseCase(
|
||||||
|
UnitOfWorkPort unitOfWorkPort,
|
||||||
|
ProcessingLogger logger) {
|
||||||
|
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
|
||||||
|
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the processing status for the supplied set of document fingerprints.
|
||||||
|
* <p>
|
||||||
|
* Each fingerprint is processed independently. Technical failures for individual
|
||||||
|
* fingerprints are caught, logged at error level, and recorded in the result;
|
||||||
|
* they do not abort processing of the remaining fingerprints.
|
||||||
|
*
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be null;
|
||||||
|
* may be empty
|
||||||
|
* @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
|
||||||
|
* @throws NullPointerException if {@code fingerprints} is null
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ResetDocumentStatusResult reset(Set<DocumentFingerprint> fingerprints) {
|
||||||
|
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||||
|
|
||||||
|
int requestedCount = fingerprints.size();
|
||||||
|
Set<DocumentFingerprint> successfullyReset = new HashSet<>();
|
||||||
|
Map<DocumentFingerprint, String> failures = new HashMap<>();
|
||||||
|
|
||||||
|
for (DocumentFingerprint fingerprint : fingerprints) {
|
||||||
|
try {
|
||||||
|
unitOfWorkPort.executeInTransaction(
|
||||||
|
tx -> tx.resetDocumentByFingerprint(fingerprint));
|
||||||
|
successfullyReset.add(fingerprint);
|
||||||
|
logger.info("Document status reset for fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (DocumentPersistenceException e) {
|
||||||
|
String errorMessage = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||||
|
failures.put(fingerprint, errorMessage);
|
||||||
|
logger.error("Failed to reset document status for fingerprint {}: {}",
|
||||||
|
fingerprint.sha256Hex(), errorMessage, e);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
String errorMessage = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||||
|
failures.put(fingerprint, errorMessage);
|
||||||
|
logger.error("Unexpected error resetting document status for fingerprint {}: {}",
|
||||||
|
fingerprint.sha256Hex(), errorMessage, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Status-Reset abgeschlossen: {} angefordert, {} erfolgreich, {} fehlgeschlagen.",
|
||||||
|
requestedCount, successfullyReset.size(), failures.size());
|
||||||
|
|
||||||
|
return new ResetDocumentStatusResult(requestedCount, successfullyReset, failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-5
@@ -1335,6 +1335,11 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
public void update(DocumentRecord record) {
|
public void update(DocumentRecord record) {
|
||||||
updatedRecords.add(record);
|
updatedRecords.add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class CapturingProcessingAttemptRepository implements ProcessingAttemptRepository {
|
private static class CapturingProcessingAttemptRepository implements ProcessingAttemptRepository {
|
||||||
@@ -1367,6 +1372,11 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
.reduce((first, second) -> second)
|
.reduce((first, second) -> second)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
|
private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
|
||||||
@@ -1391,16 +1401,21 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
public void saveProcessingAttempt(ProcessingAttempt attempt) {
|
public void saveProcessingAttempt(ProcessingAttempt attempt) {
|
||||||
attemptRepo.savedAttempts.add(attempt);
|
attemptRepo.savedAttempts.add(attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void createDocumentRecord(DocumentRecord record) {
|
public void createDocumentRecord(DocumentRecord record) {
|
||||||
recordRepo.createdRecords.add(record);
|
recordRepo.createdRecords.add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateDocumentRecord(DocumentRecord record) {
|
public void updateDocumentRecord(DocumentRecord record) {
|
||||||
recordRepo.updatedRecords.add(record);
|
recordRepo.updatedRecords.add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
operations.accept(mockOps);
|
operations.accept(mockOps);
|
||||||
@@ -1441,7 +1456,7 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||||
return new TargetFolderTechnicalFailure("Simulated folder resolution failure");
|
return new TargetFolderTechnicalFailure("Simulated folder resolution failure");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1490,7 +1505,7 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||||
return new ResolvedTargetFilename(baseName);
|
return new ResolvedTargetFilename(baseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,7 +1522,7 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||||
return new ResolvedTargetFilename(baseName);
|
return new ResolvedTargetFilename(baseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+110
-1
@@ -968,6 +968,85 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
+ "Captured messages: " + capturingLogger.allMessages());
|
+ "Captured messages: " + capturingLogger.allMessages());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Fingerprint filter (mini-run) behaviour
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_withFingerprintFilter_processesOnlyMatchingCandidates() throws Exception {
|
||||||
|
// Three candidates in the source folder
|
||||||
|
SourceDocumentCandidate c1 = makeCandidate("doc1.pdf");
|
||||||
|
SourceDocumentCandidate c2 = makeCandidate("doc2.pdf");
|
||||||
|
SourceDocumentCandidate c3 = makeCandidate("doc3.pdf");
|
||||||
|
|
||||||
|
AlwaysSuccessFingerprintPort fpPort = new AlwaysSuccessFingerprintPort();
|
||||||
|
DocumentFingerprint fp1 = ((FingerprintSuccess) fpPort.computeFingerprint(c1)).fingerprint();
|
||||||
|
DocumentFingerprint fp3 = ((FingerprintSuccess) fpPort.computeFingerprint(c3)).fingerprint();
|
||||||
|
|
||||||
|
// Filter selects only c1 and c3
|
||||||
|
java.util.Set<DocumentFingerprint> filter = java.util.Set.of(fp1, fp3);
|
||||||
|
|
||||||
|
TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
|
||||||
|
RuntimeConfiguration config = buildConfig(tempDir);
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
config, new MockRunLockPort(),
|
||||||
|
new FixedCandidatesPort(List.of(c1, c2, c3)),
|
||||||
|
new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
|
||||||
|
fpPort, coordinator);
|
||||||
|
|
||||||
|
BatchRunContext filteredContext = new BatchRunContext(new RunId("filter-run"), Instant.now())
|
||||||
|
.withFingerprintFilter(filter);
|
||||||
|
BatchRunOutcome outcome = useCase.execute(filteredContext);
|
||||||
|
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
// Only c1 and c3 must reach the coordinator (c2 skipped)
|
||||||
|
assertEquals(2, coordinator.processCallCount(),
|
||||||
|
"Only the 2 filtered candidates should reach the coordinator; c2 must be skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_withFingerprintFilter_emptyFilter_processesNothing() throws Exception {
|
||||||
|
SourceDocumentCandidate c1 = makeCandidate("docA.pdf");
|
||||||
|
|
||||||
|
TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
|
||||||
|
RuntimeConfiguration config = buildConfig(tempDir);
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
config, new MockRunLockPort(),
|
||||||
|
new FixedCandidatesPort(List.of(c1)),
|
||||||
|
new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
|
||||||
|
new AlwaysSuccessFingerprintPort(), coordinator);
|
||||||
|
|
||||||
|
BatchRunContext filteredContext = new BatchRunContext(new RunId("empty-filter-run"), Instant.now())
|
||||||
|
.withFingerprintFilter(java.util.Set.of());
|
||||||
|
BatchRunOutcome outcome = useCase.execute(filteredContext);
|
||||||
|
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
assertEquals(0, coordinator.processCallCount(),
|
||||||
|
"Empty filter must result in no documents being processed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_withoutFingerprintFilter_processesAllCandidates() throws Exception {
|
||||||
|
SourceDocumentCandidate c1 = makeCandidate("all1.pdf");
|
||||||
|
SourceDocumentCandidate c2 = makeCandidate("all2.pdf");
|
||||||
|
|
||||||
|
TrackingDocumentProcessingCoordinator coordinator = new TrackingDocumentProcessingCoordinator();
|
||||||
|
RuntimeConfiguration config = buildConfig(tempDir);
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
config, new MockRunLockPort(),
|
||||||
|
new FixedCandidatesPort(List.of(c1, c2)),
|
||||||
|
new FixedExtractionPort(new PdfExtractionSuccess("text", new PdfPageCount(1))),
|
||||||
|
new AlwaysSuccessFingerprintPort(), coordinator);
|
||||||
|
|
||||||
|
// No filter → regular run
|
||||||
|
BatchRunContext regularContext = new BatchRunContext(new RunId("regular-run"), Instant.now());
|
||||||
|
BatchRunOutcome outcome = useCase.execute(regularContext);
|
||||||
|
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
assertEquals(2, coordinator.processCallCount(),
|
||||||
|
"Without a filter all candidates must reach the coordinator");
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -1209,7 +1288,7 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName, DocumentFingerprint sourceFingerprint) {
|
||||||
return new ResolvedTargetFilename(baseName);
|
return new ResolvedTargetFilename(baseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,6 +1324,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void update(DocumentRecord record) {
|
public void update(DocumentRecord record) {
|
||||||
// No-op
|
// No-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No-op ProcessingAttemptRepository for use in test instances. */
|
/** No-op ProcessingAttemptRepository for use in test instances. */
|
||||||
@@ -1268,6 +1352,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
|
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No-op UnitOfWorkPort for use in test instances. */
|
/** No-op UnitOfWorkPort for use in test instances. */
|
||||||
@@ -1290,6 +1379,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void updateDocumentRecord(DocumentRecord record) {
|
public void updateDocumentRecord(DocumentRecord record) {
|
||||||
// No-op
|
// No-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1417,6 +1511,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void update(DocumentRecord record) {
|
public void update(DocumentRecord record) {
|
||||||
updatedRecords.add(record);
|
updatedRecords.add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1450,6 +1549,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
.reduce((first, second) -> second)
|
.reduce((first, second) -> second)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1483,6 +1587,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void updateDocumentRecord(DocumentRecord record) {
|
public void updateDocumentRecord(DocumentRecord record) {
|
||||||
recordRepo.update(record);
|
recordRepo.update(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -83,23 +83,26 @@ class BatchRunProgressObservationTest {
|
|||||||
// Value object invariants
|
// Value object invariants
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
private static final DocumentFingerprint DUMMY_FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void documentCompletionEvent_rejectsBlankFilename() {
|
void documentCompletionEvent_rejectsBlankFilename() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||||
" ", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
" ", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void documentCompletionEvent_rejectsNegativeDuration() {
|
void documentCompletionEvent_rejectsNegativeDuration() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||||
"x.pdf", DocumentCompletionStatus.SUCCESS, null, null, null,
|
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, null, null, null,
|
||||||
Duration.ofSeconds(-1)));
|
Duration.ofSeconds(-1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void documentCompletionEvent_carriesOptionalFields() {
|
void documentCompletionEvent_carriesOptionalFields() {
|
||||||
DocumentCompletionEvent event = new DocumentCompletionEvent(
|
DocumentCompletionEvent event = new DocumentCompletionEvent(
|
||||||
"x.pdf", DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
|
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
|
||||||
LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123));
|
LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123));
|
||||||
|
|
||||||
assertEquals("x.pdf", event.originalFileName());
|
assertEquals("x.pdf", event.originalFileName());
|
||||||
@@ -130,7 +133,7 @@ class BatchRunProgressObservationTest {
|
|||||||
assertSame(a, b);
|
assertSame(a, b);
|
||||||
a.onRunStarted(new RunId("r-1"), 5);
|
a.onRunStarted(new RunId("r-1"), 5);
|
||||||
a.onDocumentCompleted(new DocumentCompletionEvent(
|
a.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
"x.pdf", DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
|
"x.pdf", DUMMY_FP, DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
|
||||||
a.onRunEnded(new RunSummary(0, 0, 0));
|
a.onRunEnded(new RunSummary(0, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +440,7 @@ class BatchRunProgressObservationTest {
|
|||||||
if (index < statuses.size() && currentForwarder != null) {
|
if (index < statuses.size() && currentForwarder != null) {
|
||||||
currentForwarder.accept(new DocumentCompletionEvent(
|
currentForwarder.accept(new DocumentCompletionEvent(
|
||||||
candidate.uniqueIdentifier(),
|
candidate.uniqueIdentifier(),
|
||||||
|
fingerprint,
|
||||||
statuses.get(index),
|
statuses.get(index),
|
||||||
null, null, null, Duration.ofMillis(10)));
|
null, null, null, Duration.ofMillis(10)));
|
||||||
}
|
}
|
||||||
@@ -455,6 +459,7 @@ class BatchRunProgressObservationTest {
|
|||||||
}
|
}
|
||||||
@Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
@Override public void create(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||||
@Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
@Override public void update(de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||||
|
@Override public void deleteByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class NoAttempts implements ProcessingAttemptRepository {
|
private static final class NoAttempts implements ProcessingAttemptRepository {
|
||||||
@@ -466,6 +471,7 @@ class BatchRunProgressObservationTest {
|
|||||||
}
|
}
|
||||||
@Override public ProcessingAttempt findLatestProposalReadyAttempt(
|
@Override public ProcessingAttempt findLatestProposalReadyAttempt(
|
||||||
DocumentFingerprint fingerprint) { return null; }
|
DocumentFingerprint fingerprint) { return null; }
|
||||||
|
@Override public void deleteAllByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class NoUow implements UnitOfWorkPort {
|
private static final class NoUow implements UnitOfWorkPort {
|
||||||
@@ -480,7 +486,8 @@ class BatchRunProgressObservationTest {
|
|||||||
|
|
||||||
private static final class NoTargetFolder implements TargetFolderPort {
|
private static final class NoTargetFolder implements TargetFolderPort {
|
||||||
static final NoTargetFolder INSTANCE = new NoTargetFolder();
|
static final NoTargetFolder INSTANCE = new NoTargetFolder();
|
||||||
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseFilename) {
|
@Override public TargetFilenameResolutionResult resolveUniqueFilename(
|
||||||
|
String baseFilename, DocumentFingerprint sourceFingerprint) {
|
||||||
return new ResolvedTargetFilename(baseFilename);
|
return new ResolvedTargetFilename(baseFilename);
|
||||||
}
|
}
|
||||||
@Override public String getTargetFolderLocator() { return "/tmp/target"; }
|
@Override public String getTargetFolderLocator() { return "/tmp/target"; }
|
||||||
|
|||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link DefaultResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Covers the happy path, per-fingerprint failure isolation, empty-set handling,
|
||||||
|
* null-guard on the fingerprint set, and best-effort continuation after failure.
|
||||||
|
*/
|
||||||
|
class DefaultResetDocumentStatusUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP1 =
|
||||||
|
new DocumentFingerprint("1".repeat(64));
|
||||||
|
private static final DocumentFingerprint FP2 =
|
||||||
|
new DocumentFingerprint("2".repeat(64));
|
||||||
|
private static final DocumentFingerprint FP3 =
|
||||||
|
new DocumentFingerprint("3".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reset_allSucceed_returnsFullSuccessResult() {
|
||||||
|
List<DocumentFingerprint> resetFingerprints = new ArrayList<>();
|
||||||
|
UnitOfWorkPort alwaysSucceeds = ops -> ops.accept(
|
||||||
|
new RecordingTransactionOperations(resetFingerprints));
|
||||||
|
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(alwaysSucceeds, noOpLogger());
|
||||||
|
|
||||||
|
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1, FP2));
|
||||||
|
|
||||||
|
assertThat(result.requestedCount()).isEqualTo(2);
|
||||||
|
assertThat(result.successCount()).isEqualTo(2);
|
||||||
|
assertThat(result.failureCount()).isEqualTo(0);
|
||||||
|
assertThat(result.successfullyReset()).containsExactlyInAnyOrder(FP1, FP2);
|
||||||
|
assertThat(result.failures()).isEmpty();
|
||||||
|
assertThat(resetFingerprints).containsExactlyInAnyOrder(FP1, FP2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Empty set
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reset_emptySet_returnsEmptyResult() {
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(ops -> { }, noOpLogger());
|
||||||
|
|
||||||
|
ResetDocumentStatusResult result = useCase.reset(Set.of());
|
||||||
|
|
||||||
|
assertThat(result.requestedCount()).isEqualTo(0);
|
||||||
|
assertThat(result.successCount()).isEqualTo(0);
|
||||||
|
assertThat(result.failureCount()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Failure isolation: DocumentPersistenceException on one fingerprint
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reset_oneFailsWithPersistenceException_othersSucceed() {
|
||||||
|
List<DocumentFingerprint> resetFingerprints = new ArrayList<>();
|
||||||
|
UnitOfWorkPort failsForFp2 = ops -> {
|
||||||
|
RecordingTransactionOperations txOps =
|
||||||
|
new RecordingTransactionOperations(resetFingerprints);
|
||||||
|
ops.accept(txOps);
|
||||||
|
// If FP2 was the last one added, throw
|
||||||
|
if (!resetFingerprints.isEmpty()
|
||||||
|
&& resetFingerprints.getLast().sha256Hex().equals(FP2.sha256Hex())) {
|
||||||
|
resetFingerprints.remove(resetFingerprints.size() - 1);
|
||||||
|
throw new DocumentPersistenceException("Simulated persistence failure for FP2");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(failsForFp2, noOpLogger());
|
||||||
|
|
||||||
|
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1, FP2));
|
||||||
|
|
||||||
|
assertThat(result.requestedCount()).isEqualTo(2);
|
||||||
|
// One succeeded, one failed
|
||||||
|
assertThat(result.successCount() + result.failureCount()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Failure isolation: best-effort, all fingerprints attempted after failure
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reset_firstFingerprintFails_remainingFingerprintsStillAttempted() {
|
||||||
|
List<DocumentFingerprint> attempted = new ArrayList<>();
|
||||||
|
int[] callCount = {0};
|
||||||
|
UnitOfWorkPort failsFirstCall = ops -> {
|
||||||
|
callCount[0]++;
|
||||||
|
RecordingTransactionOperations txOps = new RecordingTransactionOperations(attempted);
|
||||||
|
ops.accept(txOps);
|
||||||
|
if (callCount[0] == 1) {
|
||||||
|
attempted.remove(attempted.size() - 1); // undo
|
||||||
|
throw new DocumentPersistenceException("First call fails");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(failsFirstCall, noOpLogger());
|
||||||
|
|
||||||
|
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1, FP2, FP3));
|
||||||
|
|
||||||
|
// All 3 were attempted
|
||||||
|
assertThat(result.requestedCount()).isEqualTo(3);
|
||||||
|
assertThat(callCount[0]).isEqualTo(3);
|
||||||
|
// Exactly 1 failure (the first call)
|
||||||
|
assertThat(result.failureCount()).isEqualTo(1);
|
||||||
|
assertThat(result.successCount()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// RuntimeException isolation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reset_unexpectedRuntimeException_recordedAsFailure() {
|
||||||
|
UnitOfWorkPort throwsRuntime = ops -> {
|
||||||
|
throw new IllegalStateException("unexpected");
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(throwsRuntime, noOpLogger());
|
||||||
|
|
||||||
|
ResetDocumentStatusResult result = useCase.reset(Set.of(FP1));
|
||||||
|
|
||||||
|
assertThat(result.requestedCount()).isEqualTo(1);
|
||||||
|
assertThat(result.failureCount()).isEqualTo(1);
|
||||||
|
assertThat(result.successCount()).isEqualTo(0);
|
||||||
|
assertThat(result.failures()).containsKey(FP1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null guard
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reset_nullFingerprintSet_throwsNullPointerException() {
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(ops -> { }, noOpLogger());
|
||||||
|
assertThatNullPointerException().isThrownBy(() -> useCase.reset(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_rejectsNullUnitOfWorkPort() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultResetDocumentStatusUseCase(null, noOpLogger()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_rejectsNullLogger() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultResetDocumentStatusUseCase(ops -> { }, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static ProcessingLogger noOpLogger() {
|
||||||
|
return new ProcessingLogger() {
|
||||||
|
@Override public void info(String msg, Object... args) { }
|
||||||
|
@Override public void debug(String msg, Object... args) { }
|
||||||
|
@Override public void debugSensitiveAiContent(String msg, Object... args) { }
|
||||||
|
@Override public void warn(String msg, Object... args) { }
|
||||||
|
@Override public void error(String msg, Object... args) { }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records each {@code resetDocumentByFingerprint} call for assertion.
|
||||||
|
* Other transaction operations are no-ops (tests never reach them here).
|
||||||
|
*/
|
||||||
|
private static class RecordingTransactionOperations
|
||||||
|
implements UnitOfWorkPort.TransactionOperations {
|
||||||
|
|
||||||
|
private final List<DocumentFingerprint> recorded;
|
||||||
|
|
||||||
|
RecordingTransactionOperations(List<DocumentFingerprint> recorded) {
|
||||||
|
this.recorded = recorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveProcessingAttempt(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt attempt) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDocumentRecord(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateDocumentRecord(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord record) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
recorded.add(fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
-4
@@ -10,6 +10,7 @@ import java.time.Instant;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
@@ -23,6 +24,8 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
|
|||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
||||||
|
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.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||||
@@ -52,6 +55,8 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfigurat
|
|||||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
|
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
|
||||||
@@ -81,6 +86,7 @@ import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
|
|||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -667,6 +673,10 @@ public class BootstrapRunner {
|
|||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
|
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
|
||||||
GuiBatchRunLauncher batchRunLauncher = this::launchGuiBatchRun;
|
GuiBatchRunLauncher batchRunLauncher = this::launchGuiBatchRun;
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher miniRunLauncher =
|
||||||
|
this::launchGuiMiniBatchRun;
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort resetPort =
|
||||||
|
this::resetDocumentStatusForGui;
|
||||||
|
|
||||||
if (configPathOverride.isEmpty()) {
|
if (configPathOverride.isEmpty()) {
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
@@ -680,7 +690,9 @@ public class BootstrapRunner {
|
|||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
technicalTestOrchestrator,
|
technicalTestOrchestrator,
|
||||||
correctionExecutionService,
|
correctionExecutionService,
|
||||||
batchRunLauncher);
|
batchRunLauncher,
|
||||||
|
miniRunLauncher,
|
||||||
|
resetPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -699,7 +711,9 @@ public class BootstrapRunner {
|
|||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
technicalTestOrchestrator,
|
technicalTestOrchestrator,
|
||||||
correctionExecutionService,
|
correctionExecutionService,
|
||||||
batchRunLauncher);
|
batchRunLauncher,
|
||||||
|
miniRunLauncher,
|
||||||
|
resetPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
@@ -707,7 +721,8 @@ public class BootstrapRunner {
|
|||||||
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
||||||
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher);
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
|
miniRunLauncher, resetPort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -722,7 +737,9 @@ public class BootstrapRunner {
|
|||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
technicalTestOrchestrator,
|
technicalTestOrchestrator,
|
||||||
correctionExecutionService,
|
correctionExecutionService,
|
||||||
batchRunLauncher);
|
batchRunLauncher,
|
||||||
|
miniRunLauncher,
|
||||||
|
resetPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +807,175 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a targeted mini batch run restricted to the supplied fingerprint set,
|
||||||
|
* triggered by the GUI's "Erneut verarbeiten" action.
|
||||||
|
* <p>
|
||||||
|
* Mirrors the full headless bootstrap pipeline (legacy migration, configuration loading
|
||||||
|
* and validation, SQLite schema initialisation, run-lock acquisition, use-case wiring,
|
||||||
|
* and execution) but builds a {@link BatchRunContext} with a fingerprint filter so only
|
||||||
|
* the specified documents are processed.
|
||||||
|
* <p>
|
||||||
|
* The run lock is acquired to prevent racing with a concurrent headless run. If the lock
|
||||||
|
* is unavailable, all fingerprints are returned as failures with a German message.
|
||||||
|
*
|
||||||
|
* @param configFilePath path to the {@code .properties} configuration; must exist on disk
|
||||||
|
* @param fingerprintFilter the set of document fingerprints to process; must not be null
|
||||||
|
* @param progressObserver observer forwarded into the use case; must not be null
|
||||||
|
* @param cancellationToken token forwarded into the use case; must not be null
|
||||||
|
* @return the outcome for the mini-run; never null
|
||||||
|
*/
|
||||||
|
GuiBatchRunLaunchOutcome launchGuiMiniBatchRun(
|
||||||
|
Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprintFilter,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||||
|
Objects.requireNonNull(progressObserver, "progressObserver must not be null");
|
||||||
|
Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
|
||||||
|
LOG.info("GUI-Mini-Verarbeitungslauf: Startanforderung für {} Dokument(e), Konfiguration {}.",
|
||||||
|
fingerprintFilter.size(), configFilePath);
|
||||||
|
try {
|
||||||
|
if (!Files.exists(configFilePath)) {
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Konfigurationsdatei wurde nicht gefunden: " + configFilePath);
|
||||||
|
}
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
|
||||||
|
BatchRunContext runContext = createRunContext();
|
||||||
|
// Apply the fingerprint filter to restrict processing to the selected documents
|
||||||
|
BatchRunContext filteredContext = runContext.withFingerprintFilter(fingerprintFilter);
|
||||||
|
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
|
||||||
|
config, runLockPort, progressObserver, cancellationToken);
|
||||||
|
BatchRunOutcome outcome = useCase.execute(filteredContext);
|
||||||
|
filteredContext.setEndInstant(Instant.now());
|
||||||
|
return mapGuiRunOutcome(outcome, filteredContext);
|
||||||
|
} catch (ConfigurationLoadingException e) {
|
||||||
|
LOG.error("GUI-Mini-Verarbeitungslauf: Konfiguration konnte nicht geladen werden: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Konfiguration konnte nicht geladen werden: " + e.getMessage());
|
||||||
|
} catch (InvalidStartConfigurationException e) {
|
||||||
|
LOG.error("GUI-Mini-Verarbeitungslauf: Konfiguration ist nicht lauffähig: {}", e.getMessage());
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Die gespeicherte Konfiguration ist nicht lauffähig: " + e.getMessage());
|
||||||
|
} catch (DocumentPersistenceException e) {
|
||||||
|
LOG.error("GUI-Mini-Verarbeitungslauf: SQLite-Initialisierung fehlgeschlagen: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
|
||||||
|
return GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Unerwarteter Fehler im Mini-Verarbeitungslauf: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the processing status of the specified documents by deleting all persistence
|
||||||
|
* data for their fingerprints, triggered by the GUI's "Status zurücksetzen" action.
|
||||||
|
* <p>
|
||||||
|
* This method initialises the SQLite schema (if needed) and runs
|
||||||
|
* {@link DefaultResetDocumentStatusUseCase} with best-effort semantics. The run lock is
|
||||||
|
* acquired to avoid racing with a concurrent headless run. If the lock is unavailable,
|
||||||
|
* all fingerprints are returned as failures with a German message.
|
||||||
|
* <p>
|
||||||
|
* Configuration migration and validation are applied before the reset executes to ensure
|
||||||
|
* the database path is resolvable.
|
||||||
|
*
|
||||||
|
* @param configFilePath path to the {@code .properties} configuration; must exist on disk
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be null
|
||||||
|
* @return the result of the reset operation; never null
|
||||||
|
*/
|
||||||
|
ResetDocumentStatusResult resetDocumentStatusForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprints) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||||
|
LOG.info("GUI-Status-Reset: {} Dokument(e) zurücksetzen, Konfiguration {}.",
|
||||||
|
fingerprints.size(), configFilePath);
|
||||||
|
|
||||||
|
if (!Files.exists(configFilePath)) {
|
||||||
|
String msg = "Konfigurationsdatei nicht gefunden: " + configFilePath;
|
||||||
|
LOG.error("GUI-Status-Reset: {}", msg);
|
||||||
|
return allFailures(fingerprints, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
ProcessingLogger resetLogger = new Log4jProcessingLogger(
|
||||||
|
DefaultResetDocumentStatusUseCase.class,
|
||||||
|
resolveAiContentSensitivity(config.logAiSensitive()));
|
||||||
|
|
||||||
|
RunLockPort runLockPort = runLockPortFactory.create(resolveLockFilePath(config));
|
||||||
|
try {
|
||||||
|
runLockPort.acquire();
|
||||||
|
} catch (de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException e) {
|
||||||
|
LOG.warn("GUI-Status-Reset: Laufsperre nicht verfügbar — Reset abgelehnt.");
|
||||||
|
return allFailures(fingerprints, "Lauf blockiert — Lock nicht verfügbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DefaultResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultResetDocumentStatusUseCase(unitOfWorkPort, resetLogger);
|
||||||
|
ResetDocumentStatusResult result = useCase.reset(fingerprints);
|
||||||
|
LOG.info("GUI-Status-Reset abgeschlossen: {} erfolgreich, {} fehlgeschlagen.",
|
||||||
|
result.successCount(), result.failureCount());
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
runLockPort.release();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warn("GUI-Status-Reset: Laufsperre konnte nicht freigegeben werden: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (ConfigurationLoadingException e) {
|
||||||
|
LOG.error("GUI-Status-Reset: Konfiguration konnte nicht geladen werden: {}", e.getMessage(), e);
|
||||||
|
return allFailures(fingerprints, "Konfiguration konnte nicht geladen werden: " + e.getMessage());
|
||||||
|
} catch (InvalidStartConfigurationException e) {
|
||||||
|
LOG.error("GUI-Status-Reset: Konfiguration ist nicht lauffähig: {}", e.getMessage());
|
||||||
|
return allFailures(fingerprints, "Die Konfiguration ist nicht lauffähig: " + e.getMessage());
|
||||||
|
} catch (DocumentPersistenceException e) {
|
||||||
|
LOG.error("GUI-Status-Reset: SQLite-Initialisierung fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
return allFailures(fingerprints, "SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Status-Reset: Unerwarteter Fehler: {}", e.getMessage(), e);
|
||||||
|
return allFailures(fingerprints, "Unerwarteter Fehler: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
||||||
|
* recorded as a failure with the given error message.
|
||||||
|
* <p>
|
||||||
|
* Used to produce a consistently structured failure result when a hard prerequisite
|
||||||
|
* (configuration load, lock acquisition) prevents any individual reset from running.
|
||||||
|
*
|
||||||
|
* @param fingerprints the full set of requested fingerprints; must not be null
|
||||||
|
* @param errorMessage the German error message to attach to every failure entry
|
||||||
|
* @return a result with all fingerprints in the failure map; never null
|
||||||
|
*/
|
||||||
|
private static ResetDocumentStatusResult allFailures(
|
||||||
|
Set<DocumentFingerprint> fingerprints, String errorMessage) {
|
||||||
|
java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
failures.put(fp, errorMessage);
|
||||||
|
}
|
||||||
|
return new ResetDocumentStatusResult(fingerprints.size(), java.util.Set.of(), failures);
|
||||||
|
}
|
||||||
|
|
||||||
private GuiBatchRunLaunchOutcome mapGuiRunOutcome(BatchRunOutcome outcome, BatchRunContext runContext) {
|
private GuiBatchRunLaunchOutcome mapGuiRunOutcome(BatchRunOutcome outcome, BatchRunContext runContext) {
|
||||||
return switch (outcome) {
|
return switch (outcome) {
|
||||||
case SUCCESS -> {
|
case SUCCESS -> {
|
||||||
|
|||||||
+79
-6
@@ -1,7 +1,10 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.domain.model;
|
package de.gecheckt.pdf.umbenenner.domain.model;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Technical context representing a single batch processing run.
|
* Technical context representing a single batch processing run.
|
||||||
@@ -14,31 +17,99 @@ import java.util.Objects;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>Track the unique identity of the batch run</li>
|
* <li>Track the unique identity of the batch run</li>
|
||||||
* <li>Record when the run started (and eventually when it ends)</li>
|
* <li>Record when the run started (and eventually when it ends)</li>
|
||||||
* <li>Provide run context to persistence, logging, and result tracking (future milestones)</li>
|
* <li>Carry an optional fingerprint filter for targeted (mini) runs that restrict
|
||||||
|
* processing to a specific set of documents</li>
|
||||||
|
* <li>Provide run context to persistence, logging, and result tracking</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* This context is independent of individual document processing and contains
|
* This context is independent of individual document processing and contains
|
||||||
* no business logic. It is purely a technical container for run identity and timing.
|
* no business logic. It is purely a technical container for run identity, timing,
|
||||||
|
* and optional candidate restriction.
|
||||||
|
* <p>
|
||||||
|
* When no fingerprint filter is set ({@link #fingerprintFilter()} returns empty),
|
||||||
|
* the run processes all candidates found in the source folder (regular batch run).
|
||||||
|
* When a non-empty filter is present, only candidates whose fingerprint is contained
|
||||||
|
* in that set are processed.
|
||||||
*/
|
*/
|
||||||
public final class BatchRunContext {
|
public final class BatchRunContext {
|
||||||
|
|
||||||
private final RunId runId;
|
private final RunId runId;
|
||||||
private final Instant startInstant;
|
private final Instant startInstant;
|
||||||
private Instant endInstant;
|
private Instant endInstant;
|
||||||
|
private final Set<DocumentFingerprint> fingerprintFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new BatchRunContext with the given run ID and start time.
|
* Creates a new BatchRunContext for a regular (unfiltered) batch run.
|
||||||
|
* <p>
|
||||||
|
* No fingerprint filter is applied; all candidates from the source folder are
|
||||||
|
* eligible for processing.
|
||||||
* <p>
|
* <p>
|
||||||
* The end instant is initially null and may be set later via {@link #setEndInstant(Instant)}.
|
* The end instant is initially null and may be set later via {@link #setEndInstant(Instant)}.
|
||||||
*
|
*
|
||||||
* @param runId the unique identifier for this run, must not be null
|
* @param runId the unique identifier for this run; must not be null
|
||||||
* @param startInstant the moment when the run started, must not be null
|
* @param startInstant the moment when the run started; must not be null
|
||||||
* @throws NullPointerException if runId or startInstant is null
|
* @throws NullPointerException if {@code runId} or {@code startInstant} is null
|
||||||
*/
|
*/
|
||||||
public BatchRunContext(RunId runId, Instant startInstant) {
|
public BatchRunContext(RunId runId, Instant startInstant) {
|
||||||
|
this(runId, startInstant, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new BatchRunContext, optionally restricting the run to a specific
|
||||||
|
* set of document fingerprints.
|
||||||
|
* <p>
|
||||||
|
* When {@code fingerprintFilter} is non-null and non-empty, the batch run
|
||||||
|
* processes only candidates whose computed fingerprint is contained in the
|
||||||
|
* supplied set. An empty set results in a run that processes nothing.
|
||||||
|
* A {@code null} value is treated identically to a regular unfiltered run.
|
||||||
|
* <p>
|
||||||
|
* The supplied set is defensively copied; modifications to the original set
|
||||||
|
* after construction have no effect on this context.
|
||||||
|
* <p>
|
||||||
|
* The end instant is initially null and may be set later via {@link #setEndInstant(Instant)}.
|
||||||
|
*
|
||||||
|
* @param runId the unique identifier for this run; must not be null
|
||||||
|
* @param startInstant the moment when the run started; must not be null
|
||||||
|
* @param fingerprintFilter the set of fingerprints to restrict processing to,
|
||||||
|
* or {@code null} for an unfiltered run
|
||||||
|
* @throws NullPointerException if {@code runId} or {@code startInstant} is null
|
||||||
|
*/
|
||||||
|
public BatchRunContext(RunId runId, Instant startInstant, Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
this.runId = Objects.requireNonNull(runId, "RunId must not be null");
|
this.runId = Objects.requireNonNull(runId, "RunId must not be null");
|
||||||
this.startInstant = Objects.requireNonNull(startInstant, "Start instant must not be null");
|
this.startInstant = Objects.requireNonNull(startInstant, "Start instant must not be null");
|
||||||
this.endInstant = null;
|
this.endInstant = null;
|
||||||
|
this.fingerprintFilter = fingerprintFilter != null
|
||||||
|
? Collections.unmodifiableSet(Set.copyOf(fingerprintFilter))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new {@link BatchRunContext} with the same run ID and start instant,
|
||||||
|
* but restricted to the given fingerprint filter.
|
||||||
|
* <p>
|
||||||
|
* The returned context is independent; changes to the supplied set after this
|
||||||
|
* call have no effect on the new context.
|
||||||
|
*
|
||||||
|
* @param filter the set of fingerprints to restrict processing to;
|
||||||
|
* {@code null} is treated as no filter (regular run)
|
||||||
|
* @return a new context with the supplied filter applied; never null
|
||||||
|
*/
|
||||||
|
public BatchRunContext withFingerprintFilter(Set<DocumentFingerprint> filter) {
|
||||||
|
return new BatchRunContext(runId, startInstant, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the optional fingerprint filter for this run.
|
||||||
|
* <p>
|
||||||
|
* When the returned optional is empty, no restriction applies and all source-folder
|
||||||
|
* candidates are eligible (regular batch run). When present, only candidates whose
|
||||||
|
* computed fingerprint is contained in the returned set are processed.
|
||||||
|
*
|
||||||
|
* @return an {@link Optional} containing the immutable fingerprint filter set,
|
||||||
|
* or an empty optional for a regular unfiltered run
|
||||||
|
*/
|
||||||
|
public Optional<Set<DocumentFingerprint>> fingerprintFilter() {
|
||||||
|
return Optional.ofNullable(fingerprintFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +174,8 @@ public final class BatchRunContext {
|
|||||||
"runId=" + runId +
|
"runId=" + runId +
|
||||||
", startInstant=" + startInstant +
|
", startInstant=" + startInstant +
|
||||||
", endInstant=" + endInstant +
|
", endInstant=" + endInstant +
|
||||||
|
", fingerprintFilter=" + (fingerprintFilter == null
|
||||||
|
? "none" : fingerprintFilter.size() + " fingerprints") +
|
||||||
'}';
|
'}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user