Ergaenze zweiten GUI-Tab fuer Verarbeitungslauf mit Live-Fortschritt
- Fuehrt neuen Inbound-Adapter-Subpfad batchrun/ mit Tab, Koordinator, Launcher-Port und Ergebniszeilen-Model ein; der Batch-Lauf laeuft auf einem Hintergrund-Worker, UI-Updates ausschliesslich via FX-Dispatcher. - Ergaenzt application.port.in um BatchRunProgressObserver, BatchRunCancellationToken, DocumentCompletionEvent/-Status und RunSummary; DefaultBatchRunProcessingUseCase und DocumentProcessingCoordinator melden Lauf-/Dokument-Ereignisse an den Beobachter und unterstuetzen Soft-Stop zwischen Kandidaten. - Verdrahtet BootstrapRunner so, dass die GUI den vollstaendigen Headless-Pipelinepfad (Migration, Validierung, Schema-Init, Lock, Use-Case) mit Observer und Cancellation ausfuehrt; headless-Verhalten bleibt unveraendert. - Editor-Workspace bettet den zweiten Tab ein, sperrt Tab 1 mit Hinweisbanner waehrend eines Laufs und fragt den Benutzer beim Schliessen waehrend eines laufenden Batches. - Fuegt Tests fuer Observer-Wiring, Koordinator-Lebenszyklus und Tab-Smoke-Verhalten ein; aktualisiert die GUI-Bedienanleitung und docs/betrieb.md auf den neuen Tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-8
@@ -54,17 +54,24 @@ Windows Server-Betrieb geeignet.
|
|||||||
Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung
|
Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung
|
||||||
solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig.
|
solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig.
|
||||||
|
|
||||||
### Umfang der V2.0-GUI
|
### Umfang der GUI
|
||||||
|
|
||||||
Die GUI in V2.0 dient ausschließlich als:
|
Die GUI enthält zwei Tabs:
|
||||||
|
|
||||||
- **Konfigurationseditor** für die `.properties`-Datei
|
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||||
- **Validierungsoberfläche** (automatische und explizite Prüfung des Konfigurationsstands)
|
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
||||||
- **Technische Testoberfläche** (Erreichbarkeit des Providers, Pfade, SQLite-Datei, Prompt-Datei)
|
Prompt-Datei).
|
||||||
|
- **Tab „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||||
|
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument. Der Lauf verwendet den
|
||||||
|
zuletzt gespeicherten Stand der `.properties`-Datei; ungespeicherte Änderungen im
|
||||||
|
Editor fließen nicht in den Lauf ein. Ein **Soft-Stop** über den Abbrechen-Knopf
|
||||||
|
beendet den Lauf nach Abschluss der gerade bearbeiteten Datei. Während eines
|
||||||
|
laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
|
||||||
|
|
||||||
Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines Batch-Laufs
|
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
|
||||||
aus der GUI ist erst ab V2.1+ vorgesehen. Der headless Betrieb über den Windows Task Scheduler
|
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
|
||||||
bleibt der einzige Weg, PDF-Dateien automatisiert zu verarbeiten.
|
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
|
||||||
|
wird jedoch nicht technisch erkannt oder blockiert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+75
-12
@@ -6,18 +6,17 @@ verwalten und technisch prüfen möchten.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Zweck und Scope der GUI in V2.0
|
## 1. Zweck und Scope der GUI
|
||||||
|
|
||||||
Die GUI dient in V2.0 ausschließlich als:
|
Die GUI gliedert sich in zwei feste Tabs:
|
||||||
|
|
||||||
- **Konfigurationseditor** für die `.properties`-Datei
|
- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische
|
||||||
- **Validierungsoberfläche** für den aktuellen Konfigurationsstand
|
Test-/Diagnoseoberfläche für die `.properties`-Datei.
|
||||||
- **Technische Test- und Diagnoseoberfläche** für Erreichbarkeit des Providers,
|
- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||||
Pfadprüfungen und Ressourcenverfügbarkeit
|
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
|
||||||
|
|
||||||
Die GUI enthält in V2.0 **keinen** manuellen Verarbeitungslauf. Das Starten eines
|
Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein
|
||||||
Batch-Laufs aus der GUI ist erst ab einer späteren Ausbaustufe vorgesehen.
|
Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten.
|
||||||
Ebenso gibt es keinen Historien-Tab, keine Datenbankansicht und kein Kosten-Tracking.
|
|
||||||
|
|
||||||
Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg,
|
Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg,
|
||||||
PDF-Dateien automatisiert zu verarbeiten.
|
PDF-Dateien automatisiert zu verarbeiten.
|
||||||
@@ -441,13 +440,77 @@ Die GUI wird offiziell nur unter **Windows** unterstützt.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Bekannte Einschränkungen V2.0
|
## 13. Tab „Verarbeitungslauf" (live-Verfolgung)
|
||||||
|
|
||||||
|
Der zweite Tab „Verarbeitungslauf" startet einen Batch-Lauf direkt aus der GUI und
|
||||||
|
zeigt dessen Fortschritt in Echtzeit an.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- **Fortschrittsbalken** mit Zähler (`n / m Dateien`) im Kopfbereich
|
||||||
|
- **Ergebnisliste** (scrollbar) mit einer Zeile pro abgeschlossener Datei
|
||||||
|
- **Seitenbereich** rechts neben der Liste für die KI-Begründung
|
||||||
|
- **Meldungs- und Zusammenfassungsbereich** unter der Liste
|
||||||
|
- Aktionsknöpfe **Starten** und **Abbrechen**
|
||||||
|
|
||||||
|
### Konfigurationsquelle
|
||||||
|
Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der
|
||||||
|
`.properties`-Datei. Ungespeicherte Änderungen im Konfigurations-Editor fließen **nicht**
|
||||||
|
in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
|
||||||
|
|
||||||
|
### Start und Verlauf
|
||||||
|
- Beim Start wird die Dateimenge **einmalig** bestimmt; der Nenner des Fortschrittsbalkens
|
||||||
|
bleibt während des Laufs konstant.
|
||||||
|
- Nach jeder abgeschlossenen Datei erscheint ohne manuellen Refresh eine neue Zeile mit
|
||||||
|
den fünf Spalten **Status-Icon**, **Originaldateiname**, **Neuer Dateiname**, **Datum**
|
||||||
|
und **Dauer**.
|
||||||
|
- Für Fehler- und Übersprungen-Fälle wird bei den Spalten „Neuer Dateiname" und „Datum"
|
||||||
|
ein Gedankenstrich `—` eingetragen.
|
||||||
|
- Die Status-Icons folgen: ✅ erfolgreich, ⚠️ fehlgeschlagen (retryable),
|
||||||
|
❌ fehlgeschlagen (permanent), ⏭️ übersprungen.
|
||||||
|
- Ein Klick auf eine Zeile zeigt die KI-Begründung im Seitenbereich. Liegt keine
|
||||||
|
Begründung vor, erscheint der Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning
|
||||||
|
vor.".
|
||||||
|
- Nach Laufende erscheint die Zusammenfassung `X erfolgreich, Y fehlgeschlagen,
|
||||||
|
Z übersprungen` im Meldungs- und Zusammenfassungsbereich.
|
||||||
|
|
||||||
|
### Soft-Stop
|
||||||
|
Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung
|
||||||
|
befindliche Datei wird vollständig fertig verarbeitet, anschließend wird der Lauf sauber
|
||||||
|
beendet — keine halbfertigen Zustände in der SQLite-Datenbank.
|
||||||
|
|
||||||
|
### Sperre von Tab 1 während eines Laufs
|
||||||
|
Während eines laufenden Verarbeitungslaufs ist Tab 1 „Konfiguration" gesperrt. Ein
|
||||||
|
sichtbarer Hinweis erinnert daran, dass die Konfiguration während des Laufs nicht
|
||||||
|
editierbar ist. Nach Abschluss, Abbruch oder einer unerwarteten Ausnahme wird Tab 1
|
||||||
|
automatisch wieder freigegeben.
|
||||||
|
|
||||||
|
### Fenster schließen während eines Laufs
|
||||||
|
Versucht der Benutzer das Fenster zu schließen, während ein Lauf aktiv ist, erscheint ein
|
||||||
|
Hinweisdialog mit zwei Optionen:
|
||||||
|
- **Nicht schließen** – der Lauf läuft unverändert weiter
|
||||||
|
- **Lauf beenden und schließen** – ein Soft-Stop wird ausgelöst; nach Abschluss der
|
||||||
|
aktuellen Datei schließt die Anwendung
|
||||||
|
|
||||||
|
### Grenzen und Hinweise
|
||||||
|
- Pro Anwendungsinstanz ist genau **ein** Verarbeitungslauf gleichzeitig zulässig. Ein
|
||||||
|
zweiter Startversuch während eines laufenden Laufs wird mit der Meldung „Ein
|
||||||
|
Verarbeitungslauf ist bereits aktiv." verweigert.
|
||||||
|
- Ein **gleichzeitiger externer headless Lauf** (Windows Task Scheduler) wird weder
|
||||||
|
aktiv erkannt noch technisch geblockt. Der Benutzer ist selbst verantwortlich,
|
||||||
|
parallele Läufe zu vermeiden.
|
||||||
|
- Startet der Lauf mit einem leeren Quellordner, erscheint der Hinweis „Keine
|
||||||
|
verarbeitbaren Dateien im Quellordner gefunden" und die Zusammenfassung
|
||||||
|
`0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen` wird eingetragen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Bekannte Einschränkungen V2.x
|
||||||
|
|
||||||
| Einschränkung | Erläuterung |
|
| Einschränkung | Erläuterung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Kein manueller Verarbeitungslauf | Das Starten eines Batch-Laufs aus der GUI ist erst ab V2.1+ vorgesehen |
|
|
||||||
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
|
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
|
||||||
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
|
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
|
||||||
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
|
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
|
||||||
| Keine Koordination mit parallelen headless Läufen | Läuft gleichzeitig ein headless Batch-Lauf, koordinieren sich GUI und headless Betrieb nicht. Schreibkonflikte können entstehen, wenn dieselbe `.properties`-Datei gleichzeitig über die GUI gespeichert und vom headless Lauf gelesen wird |
|
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
|
||||||
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
||||||
|
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer |
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# V2.7 – GUI-Verarbeitungslauf mit Live-Verfolgung
|
||||||
|
|
||||||
|
**Status:** Freigegeben
|
||||||
|
**Erstellt:** 2026-04-22
|
||||||
|
**Überarbeitet:** 2026-04-22 (nach Review, finale Version)
|
||||||
|
**Autor:** Marcus (mit Claude als Mentor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
V2.7 erweitert die JavaFX-GUI um einen zweiten Tab „Verarbeitungslauf", über den der Benutzer
|
||||||
|
einen Batch-Lauf direkt aus der GUI starten und dessen Fortschritt in Echtzeit verfolgen kann.
|
||||||
|
Der bestehende headless-Betrieb über den Windows Task Scheduler bleibt unverändert erhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
### Bisheriger Zustand
|
||||||
|
- Die GUI dient in V2.0–V2.6 ausschließlich der Konfiguration und technischen Validierung
|
||||||
|
- Ein Verarbeitungslauf kann nur über die Kommandozeile bzw. eine Batch-Datei gestartet werden
|
||||||
|
- Es gibt keine Möglichkeit, den Fortschritt eines laufenden Batches live zu beobachten
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
- Der manuelle Kommandozeilenstart ist für den Alltagsbetrieb umständlich
|
||||||
|
- Ohne Live-Anzeige ist unklar, ob und wie schnell die Verarbeitung voranschreitet
|
||||||
|
- Eine einzelne Datei wird schnell verarbeitet – eine Gesamtfortschrittsanzeige ist daher
|
||||||
|
sinnvoller als eine dateiweise Einzelanzeige
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Nach Abschluss von V2.7 kann der Benutzer:
|
||||||
|
|
||||||
|
1. Im neuen Tab „Verarbeitungslauf" einen Batch-Lauf starten
|
||||||
|
2. Den Gesamtfortschritt über alle Dateien live verfolgen
|
||||||
|
3. Jede abgeschlossene Datei mit Ergebnis in einer Liste sehen
|
||||||
|
4. Das KI-Reasoning zu einer Datei per Klick im Seitenbereich einsehen
|
||||||
|
5. Den laufenden Batch per Soft-Stop sauber abbrechen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fachliche Anforderungen
|
||||||
|
|
||||||
|
### Neuer Tab „Verarbeitungslauf"
|
||||||
|
|
||||||
|
- Der bestehende Tab „Konfiguration" bleibt Tab 1 – unverändert
|
||||||
|
- Tab 2 heißt **„Verarbeitungslauf"**
|
||||||
|
- Tab-Struktur war in V2.0 bereits vorbereitet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Layout Tab 2
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ [Fortschrittsbalken] 12 / 47 Dateien │
|
||||||
|
├──────────────────────────────────┬──────────────────────┤
|
||||||
|
│ Ergebnisliste │ Seitenbereich │
|
||||||
|
│ (scrollbar) │ (KI-Reasoning) │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────────────────┴──────────────────────┤
|
||||||
|
│ Meldungs- und Zusammenfassungsbereich │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ [Starten] [Abbrechen] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Meldungs- und Zusammenfassungsbereich
|
||||||
|
|
||||||
|
Der untere Bereich des Tab 2 dient als **einheitlicher Meldungs- und Zusammenfassungsbereich**.
|
||||||
|
Er übernimmt zwei Rollen:
|
||||||
|
|
||||||
|
- **Meldungsbereich** – zeigt Startfehler, Hinweise (z. B. 0 Dateien) und technische Exceptions
|
||||||
|
- **Zusammenfassung** – zeigt nach Laufende: `{X} erfolgreich, {X} fehlgeschlagen, {X} übersprungen`
|
||||||
|
|
||||||
|
Während des Laufs ist der Bereich leer oder zeigt den letzten Statushinweis.
|
||||||
|
Es gibt in Tab 2 keinen separaten zweiten Meldungsbereich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Konfigurationsquelle beim Start
|
||||||
|
|
||||||
|
- Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der `.properties`-Datei
|
||||||
|
- Ungespeicherte Änderungen im Konfigurationseditor (Tab 1) fließen **nicht** in den Lauf ein
|
||||||
|
- Der Starten-Button prüft vor dem Lauf, ob die gespeicherte Konfiguration lauffähig ist –
|
||||||
|
nicht den aktuellen Editorzustand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Startvoraussetzungen und Startfehler
|
||||||
|
|
||||||
|
Ein Lauf startet nur, wenn alle folgenden Voraussetzungen erfüllt sind:
|
||||||
|
|
||||||
|
| Voraussetzung | Verhalten bei Fehler |
|
||||||
|
|---|---|
|
||||||
|
| Gespeicherte Konfiguration vorhanden und lauffähig | Fehlermeldung, kein Lauf |
|
||||||
|
| Quellordner vorhanden und lesbar | Fehlermeldung, kein Lauf |
|
||||||
|
| Zielordner vorhanden oder anlegbar | Fehlermeldung, kein Lauf |
|
||||||
|
| SQLite-Datei nutzbar | Fehlermeldung, kein Lauf |
|
||||||
|
| API-Key vorhanden | Fehlermeldung, kein Lauf |
|
||||||
|
| Kein anderer Verarbeitungslauf in dieser Anwendungsinstanz aktiv | Fehlermeldung, kein Lauf |
|
||||||
|
|
||||||
|
Bei einem Startfehler:
|
||||||
|
- Erscheint eine klare Fehlermeldung im Meldungs- und Zusammenfassungsbereich
|
||||||
|
- Fortschrittsbalken und Ergebnisliste bleiben unverändert
|
||||||
|
- Starten-Button bleibt aktiv, Abbrechen-Button bleibt deaktiviert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten bei 0 verarbeitbaren Dateien
|
||||||
|
|
||||||
|
- Kein technischer Fehler
|
||||||
|
- Kein Lauf im eigentlichen Sinne
|
||||||
|
- Hinweis im Meldungs- und Zusammenfassungsbereich: „Keine verarbeitbaren Dateien im Quellordner gefunden"
|
||||||
|
- Zusammenfassung: `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fortschrittsbalken
|
||||||
|
|
||||||
|
- Die zu verarbeitende Dateimenge wird **einmalig beim Start** bestimmt
|
||||||
|
- Der Nenner bleibt für den gesamten Lauf **konstant** – Dateien die während des Laufs
|
||||||
|
im Quellordner auftauchen oder verschwinden, werden nicht berücksichtigt
|
||||||
|
- Gezählt werden **alle abgeschlossenen** Dateien: erfolgreich + fehlgeschlagen + übersprungen
|
||||||
|
- Daneben wird der Zählerstand angezeigt, z. B. „12 / 47 Dateien"
|
||||||
|
- Vor dem ersten Start: leer / 0 %
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Statusmodell
|
||||||
|
|
||||||
|
Jede Datei erhält nach Abschluss genau einen der folgenden Status:
|
||||||
|
|
||||||
|
| Status | Icon | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| Erfolgreich | ✅ | Datei wurde umbenannt, Zieldatei erzeugt |
|
||||||
|
| Fehlgeschlagen (retryable) | ⚠️ | Transienter Fehler, wird beim nächsten Lauf erneut versucht |
|
||||||
|
| Fehlgeschlagen (permanent) | ❌ | Inhaltsfehler, kein weiterer Retry |
|
||||||
|
| Übersprungen | ⏭️ | Datei war bereits verarbeitet oder wurde bewusst ausgelassen |
|
||||||
|
|
||||||
|
Alle vier Status zählen als **abgeschlossen** im Sinne des Fortschrittsbalkens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ergebnisliste
|
||||||
|
|
||||||
|
Jede abgeschlossene Datei erscheint als neue Zeile in der Liste.
|
||||||
|
Nach Abschluss jeder Datei erscheint **ohne manuellen Refresh** ein neuer Eintrag.
|
||||||
|
Die Liste wächst während des Laufs von oben nach unten.
|
||||||
|
|
||||||
|
| Spalte | Erfolg | Fehler / Übersprungen |
|
||||||
|
|---|---|---|
|
||||||
|
| Status-Icon | ✅ / ⚠️ / ❌ / ⏭️ | wie links |
|
||||||
|
| Originaldateiname | Quelldateiname | Quelldateiname |
|
||||||
|
| Neuer Dateiname | Finaler Zieldateiname | `—` |
|
||||||
|
| Datum | Ermitteltes Datum | `—` |
|
||||||
|
| Dauer | Verarbeitungszeit in Sekunden | Verarbeitungszeit in Sekunden |
|
||||||
|
|
||||||
|
- Klick auf eine Zeile zeigt Details im **Seitenbereich**
|
||||||
|
- Die Liste ist scrollbar
|
||||||
|
- Die Liste ist **nicht persistent**: bleibt nur für die Dauer des aktuellen Programmstarts
|
||||||
|
- Bei einem neuen Lauf innerhalb desselben Programmstarts wird die Liste geleert
|
||||||
|
- Nach Programmstart ist die Liste leer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Seitenbereich (KI-Reasoning)
|
||||||
|
|
||||||
|
- Rechts neben der Ergebnisliste, fest im Layout verankert (kein Popup, kein Dialog)
|
||||||
|
- Zeigt nach Klick auf eine Zeile:
|
||||||
|
- Originaldateiname
|
||||||
|
- Ermittelter Titel
|
||||||
|
- Ermitteltes Datum
|
||||||
|
- KI-Reasoning (Volltext)
|
||||||
|
- Liegt für einen Eintrag kein KI-Reasoning vor (Fehler vor KI-Aufruf, übersprungen),
|
||||||
|
erscheint der Hinweistext: „Für diesen Eintrag liegt kein KI-Reasoning vor."
|
||||||
|
- Vor dem ersten Klick: Hinweistext „Datei auswählen für Details"
|
||||||
|
- Bei neuem Lauf wird der Seitenbereich geleert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Starten-Button
|
||||||
|
|
||||||
|
- Startet den Verarbeitungslauf über alle Dateien im konfigurierten Quellordner
|
||||||
|
- Verwendet die **gespeicherte** Konfiguration – nicht den aktuellen Editorzustand
|
||||||
|
- Gleiches fachliches Batch-Verhalten wie der headless-Betrieb:
|
||||||
|
gleiche Anwendungslogik, gleicher Use Case, nur andere Präsentationsschicht
|
||||||
|
- Keine Dateiauswahl – alle Dateien werden verarbeitet
|
||||||
|
- Während des Laufs: deaktiviert
|
||||||
|
- Nach Abschluss oder Abbruch: wieder aktiv
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Abbrechen-Button
|
||||||
|
|
||||||
|
- Nur während eines laufenden Batches aktiv, sonst deaktiviert
|
||||||
|
- Verhalten: **Soft-Stop**
|
||||||
|
- Die aktuell in Bearbeitung befindliche Datei wird vollständig fertig verarbeitet
|
||||||
|
- Das Stop-Flag wird nach Abschluss jeder Datei und vor Start der nächsten Datei geprüft –
|
||||||
|
niemals mitten in einer atomaren Persistenzoperation
|
||||||
|
- Danach wird der Lauf sauber beendet, keine halbfertigen Zustände in der SQLite-Datenbank
|
||||||
|
- Nach dem Soft-Stop erscheint die Zusammenfassung im Meldungs- und Zusammenfassungsbereich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Konfiguration während des Laufs
|
||||||
|
|
||||||
|
- Tab 1 „Konfiguration" wird während eines laufenden Verarbeitungslaufs **gesperrt**
|
||||||
|
- Im Konfiguration-Tab erscheint ein sichtbarer Hinweis:
|
||||||
|
„Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar"
|
||||||
|
- Nach Abschluss, Abbruch oder unerwarteter Exception wird Tab 1 wieder freigegeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten bei unerwarteter technischer Exception
|
||||||
|
|
||||||
|
Tritt während des Laufs eine unerwartete Exception auf:
|
||||||
|
|
||||||
|
- Die GUI wechselt in einen definierten terminalen Zustand:
|
||||||
|
- Starten-Button: aktiv
|
||||||
|
- Abbrechen-Button: deaktiviert
|
||||||
|
- Tab 1: entsperrt
|
||||||
|
- Meldungs- und Zusammenfassungsbereich: Fehlermeldung sichtbar
|
||||||
|
- Es entsteht kein „hängender" UI-Zustand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fenster schließen während eines laufenden Laufs
|
||||||
|
|
||||||
|
- Schließt der Benutzer das Fenster während ein Lauf aktiv ist,
|
||||||
|
wird der Close-Request abgefangen
|
||||||
|
- Es erscheint ein Hinweisdialog mit zwei Optionen:
|
||||||
|
- **„Nicht schließen"** – Lauf läuft weiter
|
||||||
|
- **„Lauf beenden und schließen"** – Soft-Stop wird ausgelöst,
|
||||||
|
nach Abschluss der aktuellen Datei schließt die Anwendung
|
||||||
|
- Kein Hard-Abbruch ohne Benutzerentscheidung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Parallele Läufe
|
||||||
|
|
||||||
|
- Pro Anwendungsinstanz ist **nur ein Verarbeitungslauf gleichzeitig** zulässig
|
||||||
|
- Ein zweiter Startversuch während ein Lauf aktiv ist wird verweigert mit der Meldung:
|
||||||
|
„Ein Verarbeitungslauf ist bereits aktiv."
|
||||||
|
- **Bekannte Einschränkung:** Ein gleichzeitiger externer headless-Lauf (Windows Task Scheduler)
|
||||||
|
wird von der GUI nicht aktiv erkannt und nicht technisch geblockt.
|
||||||
|
Der Benutzer ist selbst verantwortlich, parallele Läufe zu vermeiden.
|
||||||
|
Diese Einschränkung ist seit V2.0 dokumentiert und bleibt in V2.7 unverändert bestehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V2.7 enthalten
|
||||||
|
|
||||||
|
- Dateiauswahl (welche Dateien verarbeitet werden sollen)
|
||||||
|
- Einzeldatei-Fortschrittsanzeige
|
||||||
|
- Historien-Tab / SQLite-Ansicht
|
||||||
|
- Kosten-Tracking
|
||||||
|
- Automatischer Neustart nach Abschluss
|
||||||
|
- Benachrichtigungen (Windows-Tray, Toast)
|
||||||
|
- Parallelverarbeitung mehrerer Dateien
|
||||||
|
- Technisches Locking gegen externe headless-Läufe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- [ ] Tab 2 „Verarbeitungslauf" ist in der GUI vorhanden und erreichbar
|
||||||
|
- [ ] Starten-Button verwendet ausschließlich die gespeicherte Konfiguration
|
||||||
|
- [ ] Starten-Button startet den Batch-Lauf über alle Dateien im Quellordner
|
||||||
|
- [ ] Die Dateimenge wird beim Start einmalig bestimmt; der Nenner des Fortschrittsbalkens bleibt während des gesamten Laufs konstant
|
||||||
|
- [ ] Fortschrittsbalken zählt alle abgeschlossenen Dateien (erfolgreich + fehlgeschlagen + übersprungen)
|
||||||
|
- [ ] Nach Abschluss jeder Datei erscheint ohne manuellen Refresh ein neuer Eintrag in der Ergebnisliste
|
||||||
|
- [ ] Alle fünf Spalten der Ergebnisliste sind für Erfolgsfälle korrekt befüllt
|
||||||
|
- [ ] Spalte „Neuer Dateiname" und „Datum" zeigen `—` für Fehler- und Übersprungen-Fälle
|
||||||
|
- [ ] Alle vier Status-Icons sind korrekt: ✅ ⚠️ ❌ ⏭️
|
||||||
|
- [ ] Klick auf Zeile zeigt KI-Reasoning im Seitenbereich
|
||||||
|
- [ ] Einträge ohne KI-Reasoning zeigen den definierten Hinweistext im Seitenbereich
|
||||||
|
- [ ] Seitenbereich zeigt vor erstem Klick den Hinweistext „Datei auswählen für Details"
|
||||||
|
- [ ] Soft-Stop beendet den Lauf nach Abschluss der aktuellen Datei; keine weitere Datei wird begonnen
|
||||||
|
- [ ] Meldungs- und Zusammenfassungsbereich zeigt nach Laufende die Zusammenfassung mit korrekten Zählern
|
||||||
|
- [ ] Tab 1 ist während des Laufs gesperrt, Hinweis ist sichtbar
|
||||||
|
- [ ] Tab 1 wird nach Abschluss, Abbruch oder Exception wieder entsperrt
|
||||||
|
- [ ] Bei unerwarteter Exception wechselt die GUI in den definierten terminalen Zustand
|
||||||
|
- [ ] Ergebnisliste und Seitenbereich sind nach Programmstart leer
|
||||||
|
- [ ] Ergebnisliste und Seitenbereich werden bei neuem Lauf geleert
|
||||||
|
- [ ] Start mit nicht lauffähiger Konfiguration wird verweigert; Fehlermeldung erscheint im Meldungs- und Zusammenfassungsbereich
|
||||||
|
- [ ] Start bei leerem Quellordner erzeugt keinen Fehler; Hinweis erscheint im Meldungs- und Zusammenfassungsbereich
|
||||||
|
- [ ] Zweiter Startversuch während laufendem Lauf wird verweigert; Meldung erscheint
|
||||||
|
- [ ] Close-Request während Lauf öffnet Hinweisdialog mit zwei Optionen
|
||||||
|
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||||
|
- [ ] `mvn clean verify` ist grün
|
||||||
+161
-2
@@ -14,6 +14,8 @@ import java.util.function.Supplier;
|
|||||||
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.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
|
||||||
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;
|
||||||
@@ -341,6 +343,33 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
private final EditorConfigurationValidator editorValidator = new EditorConfigurationValidator();
|
private final EditorConfigurationValidator editorValidator = new EditorConfigurationValidator();
|
||||||
private final ApiKeyResolutionPort apiKeyResolutionPort;
|
private final ApiKeyResolutionPort apiKeyResolutionPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launcher used by the processing-run tab to execute a batch run against the saved
|
||||||
|
* configuration file. Supplied by Bootstrap via the startup context.
|
||||||
|
*/
|
||||||
|
private final GuiBatchRunLauncher batchRunLauncher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the existing configuration tab.
|
||||||
|
*/
|
||||||
|
private final GuiBatchRunTab batchRunTab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hint banner shown at the top of the configuration tab while a processing run is
|
||||||
|
* active. Visible + managed state are flipped from the batch run tab's listener when
|
||||||
|
* the running flag toggles.
|
||||||
|
*/
|
||||||
|
final Label configurationLockBanner = new Label(
|
||||||
|
"Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to the configuration tab so the running-state listener can disable its
|
||||||
|
* content while a batch run is active.
|
||||||
|
*/
|
||||||
|
Tab configurationTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new workspace with the unloaded start state.
|
* Creates a new workspace with the unloaded start state.
|
||||||
*
|
*
|
||||||
@@ -391,12 +420,72 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.unsavedChangesGuard = new GuiUnsavedChangesGuard(
|
this.unsavedChangesGuard = new GuiUnsavedChangesGuard(
|
||||||
triggerLabel -> showUnsavedChangesDialog(triggerLabel));
|
triggerLabel -> showUnsavedChangesDialog(triggerLabel));
|
||||||
|
|
||||||
|
this.batchRunLauncher = effectiveContext.batchRunLauncher();
|
||||||
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
|
() -> this.batchRunLauncher,
|
||||||
|
this::loadedConfigurationPath,
|
||||||
|
this::isSavedConfigurationReady,
|
||||||
|
this::applyBatchRunLockState);
|
||||||
|
|
||||||
configureRoot();
|
configureRoot();
|
||||||
configureHeader(effectiveContext.startupNotice());
|
configureHeader(effectiveContext.startupNotice());
|
||||||
configureTabs();
|
configureTabs();
|
||||||
configureActionBar();
|
configureActionBar();
|
||||||
configureActions();
|
configureActions();
|
||||||
refreshView();
|
refreshView();
|
||||||
|
applyBatchRunLockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the processing-run tab; package-private so smoke tests can drive start/cancel
|
||||||
|
* actions and read the result table.
|
||||||
|
*/
|
||||||
|
GuiBatchRunTab batchRunTab() {
|
||||||
|
return batchRunTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently loaded configuration file path, or {@code null} when the
|
||||||
|
* editor has never loaded a file from disk. The processing-run tab uses this value to
|
||||||
|
* derive the path a run should execute against.
|
||||||
|
*/
|
||||||
|
private Path loadedConfigurationPath() {
|
||||||
|
return editorState.loadedFileSnapshot()
|
||||||
|
.map(GuiConfigurationFileSnapshot::filePath)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the editor currently has a saved configuration that can be used as
|
||||||
|
* the source of a processing run.
|
||||||
|
* <p>
|
||||||
|
* A configuration is considered run-ready when the editor was loaded from disk (i.e.
|
||||||
|
* a file snapshot exists). Unsaved editor changes are intentionally ignored — the
|
||||||
|
* run always uses the last saved state of the {@code .properties} file as required by
|
||||||
|
* the specification.
|
||||||
|
*/
|
||||||
|
private boolean isSavedConfigurationReady() {
|
||||||
|
return editorState.hasLoadedFileSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the "batch run active" UI lock state to the configuration tab and the
|
||||||
|
* action bar.
|
||||||
|
* <p>
|
||||||
|
* While a run is active the configuration editor is made non-interactive, the lock
|
||||||
|
* banner is shown at the top of Tab 1, and the main action buttons (Neu, Öffnen,
|
||||||
|
* Speichern, Speichern unter) are disabled. When the run ends, the locks are
|
||||||
|
* released and the editor returns to its normal state.
|
||||||
|
*/
|
||||||
|
void applyBatchRunLockState() {
|
||||||
|
boolean running = batchRunTab != null && batchRunTab.isRunning();
|
||||||
|
configurationLockBanner.setVisible(running);
|
||||||
|
configurationLockBanner.setManaged(running);
|
||||||
|
sectionsBox.setDisable(running);
|
||||||
|
newButton.setDisable(running);
|
||||||
|
openButton.setDisable(running);
|
||||||
|
saveButton.setDisable(running);
|
||||||
|
saveAsButton.setDisable(running);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -411,6 +500,11 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
public void installCloseRequestHandler(Stage stage) {
|
public void installCloseRequestHandler(Stage stage) {
|
||||||
stage.setOnCloseRequest(event -> {
|
stage.setOnCloseRequest(event -> {
|
||||||
|
if (batchRunTab != null && batchRunTab.isRunning()) {
|
||||||
|
event.consume();
|
||||||
|
handleCloseWhileRunRunning(stage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!editorState.isDirty()) {
|
if (!editorState.isDirty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -425,6 +519,59 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplier for the "Lauf läuft noch" dialog invoked by
|
||||||
|
* {@link #handleCloseWhileRunRunning(Stage)}. Package-private so tests can substitute
|
||||||
|
* a deterministic choice without showing a native dialog. The default implementation
|
||||||
|
* displays a blocking confirmation alert with the two options mandated by the spec.
|
||||||
|
*/
|
||||||
|
Supplier<CloseWhileRunningChoice> closeWhileRunningDialog = this::showCloseWhileRunningDialog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct outcomes of the "Lauf läuft noch" dialog.
|
||||||
|
*/
|
||||||
|
enum CloseWhileRunningChoice {
|
||||||
|
/** User chose "Nicht schließen"; the run continues and the window stays open. */
|
||||||
|
KEEP_OPEN,
|
||||||
|
/** User chose "Lauf beenden und schließen"; a soft-stop is requested. */
|
||||||
|
CANCEL_AND_CLOSE
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCloseWhileRunRunning(Stage stage) {
|
||||||
|
CloseWhileRunningChoice choice;
|
||||||
|
try {
|
||||||
|
choice = closeWhileRunningDialog.get();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warn("GUI-Editor: Fehler im Schließen-Dialog: {}", e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (choice == CloseWhileRunningChoice.CANCEL_AND_CLOSE) {
|
||||||
|
LOG.info("GUI-Editor: Soft-Stop angefordert; Fenster schließt nach Laufende.");
|
||||||
|
batchRunTab.requestCancellation();
|
||||||
|
batchRunTab.runningProperty().addListener((obs, wasRunning, running) -> {
|
||||||
|
if (!running) {
|
||||||
|
Platform.runLater(stage::close);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
LOG.info("GUI-Editor: Schließen abgebrochen; Verarbeitungslauf läuft weiter.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CloseWhileRunningChoice showCloseWhileRunningDialog() {
|
||||||
|
Alert dialog = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
dialog.setTitle("Verarbeitungslauf läuft");
|
||||||
|
dialog.setHeaderText("Es läuft aktuell ein Verarbeitungslauf.");
|
||||||
|
dialog.setContentText("Was soll geschehen?");
|
||||||
|
ButtonType keepOpen = new ButtonType("Nicht schließen");
|
||||||
|
ButtonType cancelAndClose = new ButtonType("Lauf beenden und schließen");
|
||||||
|
dialog.getButtonTypes().setAll(cancelAndClose, keepOpen);
|
||||||
|
Optional<ButtonType> result = dialog.showAndWait();
|
||||||
|
return result.filter(cancelAndClose::equals).isPresent()
|
||||||
|
? CloseWhileRunningChoice.CANCEL_AND_CLOSE
|
||||||
|
: CloseWhileRunningChoice.KEEP_OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the root node used by the JavaFX scene.
|
* Returns the root node used by the JavaFX scene.
|
||||||
*
|
*
|
||||||
@@ -946,18 +1093,30 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
private void configureTabs() {
|
private void configureTabs() {
|
||||||
Tab editorTab = new Tab("Konfiguration");
|
Tab editorTab = new Tab("Konfiguration");
|
||||||
editorTab.setClosable(false);
|
editorTab.setClosable(false);
|
||||||
|
configurationTab = editorTab;
|
||||||
|
|
||||||
sectionsBox.setSpacing(12);
|
sectionsBox.setSpacing(12);
|
||||||
sectionsBox.setFillWidth(true);
|
sectionsBox.setFillWidth(true);
|
||||||
|
|
||||||
ScrollPane scrollPane = new ScrollPane(sectionsBox);
|
configurationLockBanner.setId("configuration-lock-banner");
|
||||||
|
configurationLockBanner.setStyle(
|
||||||
|
"-fx-font-weight: bold; -fx-text-fill: #b45309; -fx-padding: 8 12 8 12;"
|
||||||
|
+ " -fx-background-color: #fef3c7; -fx-background-radius: 4;");
|
||||||
|
configurationLockBanner.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
configurationLockBanner.setVisible(false);
|
||||||
|
configurationLockBanner.setManaged(false);
|
||||||
|
|
||||||
|
VBox tabContent = new VBox(8, configurationLockBanner, sectionsBox);
|
||||||
|
VBox.setVgrow(sectionsBox, Priority.ALWAYS);
|
||||||
|
|
||||||
|
ScrollPane scrollPane = new ScrollPane(tabContent);
|
||||||
scrollPane.setFitToWidth(true);
|
scrollPane.setFitToWidth(true);
|
||||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||||
scrollPane.setPadding(new Insets(0));
|
scrollPane.setPadding(new Insets(0));
|
||||||
editorTab.setContent(scrollPane);
|
editorTab.setContent(scrollPane);
|
||||||
|
|
||||||
tabPane.getTabs().add(editorTab);
|
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
|
||||||
root.setCenter(tabPane);
|
root.setCenter(tabPane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-2
@@ -3,6 +3,8 @@ 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 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.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.out.modelcatalog.AiModelCatalogPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
@@ -41,7 +43,8 @@ public record GuiStartupContext(
|
|||||||
ProviderTechnicalTestService providerTechnicalTestService,
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
PathCheckPort pathCheckPort,
|
PathCheckPort pathCheckPort,
|
||||||
TechnicalTestOrchestrator technicalTestOrchestrator,
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
CorrectionExecutionService correctionExecutionService) {
|
CorrectionExecutionService correctionExecutionService,
|
||||||
|
GuiBatchRunLauncher batchRunLauncher) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a startup context.
|
* Creates a startup context.
|
||||||
@@ -56,6 +59,9 @@ 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
|
||||||
|
* configuration path for the processing-run tab;
|
||||||
|
* 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");
|
||||||
@@ -76,6 +82,49 @@ public record GuiStartupContext(
|
|||||||
"technicalTestOrchestrator must not be null");
|
"technicalTestOrchestrator must not be null");
|
||||||
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||||
"correctionExecutionService must not be null");
|
"correctionExecutionService must not be null");
|
||||||
|
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
|
||||||
|
"batchRunLauncher must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that fills the processing-run launcher with a
|
||||||
|
* no-op implementation.
|
||||||
|
* <p>
|
||||||
|
* 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 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}
|
||||||
|
*/
|
||||||
|
public GuiStartupContext(
|
||||||
|
GuiConfigurationEditorState initialState,
|
||||||
|
Optional<String> startupNotice,
|
||||||
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
|
rejectingBatchRunLauncher());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
|
return (configPath, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +194,9 @@ public record GuiStartupContext(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||||
|
GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
startupNotice,
|
startupNotice,
|
||||||
@@ -155,6 +207,7 @@ public record GuiStartupContext(
|
|||||||
noOpTestService,
|
noOpTestService,
|
||||||
noOpPathCheckPort,
|
noOpPathCheckPort,
|
||||||
noOpOrchestrator,
|
noOpOrchestrator,
|
||||||
noOpCorrectionService);
|
noOpCorrectionService,
|
||||||
|
noOpBatchRunLauncher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.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.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates a single batch run triggered from the JavaFX GUI.
|
||||||
|
* <p>
|
||||||
|
* The coordinator owns the background worker thread that executes the run, maintains the
|
||||||
|
* cancellation flag, and translates the
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||||
|
* callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>The batch run executes on a daemon worker thread created by
|
||||||
|
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
|
||||||
|
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
|
||||||
|
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
|
||||||
|
* {@code Control}s without taking any further precautions.</li>
|
||||||
|
* <li>{@link #requestCancellation()} sets a volatile flag that the use case polls
|
||||||
|
* between candidates (soft-stop). It never interrupts the worker thread; the
|
||||||
|
* currently-processed candidate always completes in full.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Lifecycle</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>Construct with a launcher, a thread factory and a listener.</li>
|
||||||
|
* <li>Call {@link #start(Path)} to begin a run against a configuration file.</li>
|
||||||
|
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop.</li>
|
||||||
|
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} on the
|
||||||
|
* FX thread.</li>
|
||||||
|
* <li>Start a new run only after the previous one has ended.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class GuiBatchRunCoordinator {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
|
||||||
|
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener interface invoked on the JavaFX Application Thread during a run.
|
||||||
|
*/
|
||||||
|
public interface Listener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once, after the batch use case has scanned the source folder and knows
|
||||||
|
* the total candidate count.
|
||||||
|
*
|
||||||
|
* @param runId the identifier of the run; never {@code null}
|
||||||
|
* @param totalCandidates the number of candidates detected in the source folder;
|
||||||
|
* never negative
|
||||||
|
*/
|
||||||
|
void onRunStarted(RunId runId, int totalCandidates);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once per candidate whose processing reached a terminal resolution.
|
||||||
|
*
|
||||||
|
* @param row the row describing the candidate result; never {@code null}
|
||||||
|
*/
|
||||||
|
void onDocumentCompleted(GuiBatchRunResultRow row);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once after the run has fully terminated on the worker thread.
|
||||||
|
*
|
||||||
|
* @param summary the final outcome counts; never {@code null}
|
||||||
|
* @param outcome a description of how the run terminated; never {@code null}
|
||||||
|
*/
|
||||||
|
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final GuiBatchRunLauncher launcher;
|
||||||
|
private final Function<Runnable, Thread> threadFactory;
|
||||||
|
private final Consumer<Runnable> fxDispatcher;
|
||||||
|
private final Listener listener;
|
||||||
|
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
|
||||||
|
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with the default worker-thread factory and the default
|
||||||
|
* JavaFX Application Thread dispatcher.
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
|
||||||
|
this(launcher, defaultThreadFactory(), defaultFxDispatcher(), listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with custom hooks for the worker-thread factory and the
|
||||||
|
* UI-thread dispatcher.
|
||||||
|
* <p>
|
||||||
|
* Tests use this constructor to execute batches synchronously or to verify which
|
||||||
|
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
|
||||||
|
* initialised.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap; 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,
|
||||||
|
Function<Runnable, Thread> threadFactory,
|
||||||
|
Consumer<Runnable> fxDispatcher,
|
||||||
|
Listener listener) {
|
||||||
|
this.launcher = Objects.requireNonNull(launcher, "launcher 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a run is currently active.
|
||||||
|
*
|
||||||
|
* @return {@code true} while a worker thread is processing a run
|
||||||
|
*/
|
||||||
|
public boolean isRunning() {
|
||||||
|
Thread worker = activeWorker.get();
|
||||||
|
return worker != null && worker.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new run for the supplied configuration file.
|
||||||
|
* <p>
|
||||||
|
* Immediately returns once the worker thread has been started. All further progress
|
||||||
|
* is communicated through the configured {@link Listener} on the JavaFX Application
|
||||||
|
* Thread. An attempt to start a new run while another is still active is rejected
|
||||||
|
* with {@code false} and leaves the currently running batch untouched.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file the run shall read from; 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 {@code configFilePath} is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean start(Path configFilePath) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cancellationRequested.set(false);
|
||||||
|
Runnable task = () -> executeRun(configFilePath);
|
||||||
|
Thread worker = threadFactory.apply(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.
|
||||||
|
* <p>
|
||||||
|
* 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
|
||||||
|
* this method when no run is active has no effect.
|
||||||
|
*/
|
||||||
|
public void requestCancellation() {
|
||||||
|
if (isRunning()) {
|
||||||
|
cancellationRequested.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether cancellation has been requested for the current (or last) run.
|
||||||
|
*
|
||||||
|
* @return {@code true} when a cancellation request is pending or was pending when
|
||||||
|
* the last run ended; {@code false} before the first run
|
||||||
|
*/
|
||||||
|
public boolean isCancellationRequested() {
|
||||||
|
return cancellationRequested.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeRun(Path configFilePath) {
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
||||||
|
configFilePath);
|
||||||
|
BatchRunProgressObserver observer = buildDispatchingObserver();
|
||||||
|
BatchRunCancellationToken token = cancellationRequested::get;
|
||||||
|
GuiBatchRunLaunchOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = launcher.launch(configFilePath, observer, token);
|
||||||
|
if (outcome == null) {
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Launcher hat kein Ergebnis geliefert.");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Unerwarteter technischer Fehler: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
RunSummary summary = observerSummary.get();
|
||||||
|
if (summary == null) {
|
||||||
|
summary = new RunSummary(0, 0, 0);
|
||||||
|
}
|
||||||
|
GuiBatchRunLaunchOutcome finalOutcome = outcome;
|
||||||
|
RunSummary finalSummary = summary;
|
||||||
|
activeWorker.set(null);
|
||||||
|
fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the final summary supplied by the application layer. Written on the
|
||||||
|
* worker thread; read only after the run has ended.
|
||||||
|
*/
|
||||||
|
private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();
|
||||||
|
|
||||||
|
private BatchRunProgressObserver buildDispatchingObserver() {
|
||||||
|
return new BatchRunProgressObserver() {
|
||||||
|
@Override
|
||||||
|
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
|
fxDispatcher.accept(() -> listener.onRunStarted(runId, totalCandidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||||
|
GuiBatchRunResultRow row = toRow(event);
|
||||||
|
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRunEnded(RunSummary summary) {
|
||||||
|
observerSummary.set(summary);
|
||||||
|
// No FX dispatch here: the worker thread invokes the listener's
|
||||||
|
// onRunEnded via executeRun() once the launcher has returned, ensuring
|
||||||
|
// the outcome carries the launcher's terminal verdict.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunResultRow toRow(DocumentCompletionEvent event) {
|
||||||
|
Optional<String> finalName = event.finalFileName() == null
|
||||||
|
? Optional.empty() : Optional.of(event.finalFileName());
|
||||||
|
Optional<LocalDate> date = event.resolvedDate() == null
|
||||||
|
? Optional.empty() : Optional.of(event.resolvedDate());
|
||||||
|
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
|
||||||
|
? Optional.empty() : Optional.of(event.aiReasoning());
|
||||||
|
Duration duration = event.processingDuration();
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
event.originalFileName(),
|
||||||
|
event.status(),
|
||||||
|
finalName,
|
||||||
|
date,
|
||||||
|
reasoning,
|
||||||
|
duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Function<Runnable, Thread> defaultThreadFactory() {
|
||||||
|
return task -> {
|
||||||
|
Thread thread = new Thread(task, WORKER_THREAD_NAME);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Consumer<Runnable> defaultFxDispatcher() {
|
||||||
|
return Platform::runLater;
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable result of a single batch run launched from the GUI.
|
||||||
|
* <p>
|
||||||
|
* The outcome reports to the tab whether the run finished normally, could not even be
|
||||||
|
* started (hard failure), or ended because of an unexpected exception. The GUI uses this
|
||||||
|
* to transition between its "laufend" and "bereit"/"Fehler" states.
|
||||||
|
*
|
||||||
|
* <h2>Fields</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #successfullyStarted()} — {@code true} when the launcher managed to enter
|
||||||
|
* the batch execution phase; {@code false} when the run was rejected before any
|
||||||
|
* candidate could be processed (e.g. configuration invalid, lock held, SQLite
|
||||||
|
* unavailable).</li>
|
||||||
|
* <li>{@link #batchCompletedNormally()} — {@code true} when the run returned from the
|
||||||
|
* batch use case with a normal outcome (whether empty, partial, or full). Only
|
||||||
|
* meaningful when {@link #successfullyStarted()} is also {@code true}.</li>
|
||||||
|
* <li>{@link #failureMessage()} — present when either the run could not start or an
|
||||||
|
* unexpected technical exception terminated it. Empty when the run completed
|
||||||
|
* normally.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record GuiBatchRunLaunchOutcome(
|
||||||
|
boolean successfullyStarted,
|
||||||
|
boolean batchCompletedNormally,
|
||||||
|
Optional<String> failureMessage) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor normalising the failure message holder.
|
||||||
|
*/
|
||||||
|
public GuiBatchRunLaunchOutcome {
|
||||||
|
failureMessage = failureMessage == null ? Optional.empty() : failureMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an outcome describing a run that finished normally.
|
||||||
|
*
|
||||||
|
* @return a started + completed outcome without failure message
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunLaunchOutcome completed() {
|
||||||
|
return new GuiBatchRunLaunchOutcome(true, true, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an outcome describing a run that could not start because of a hard
|
||||||
|
* configuration, persistence, or lock failure.
|
||||||
|
*
|
||||||
|
* @param failureMessage the user-visible German failure description; must not be blank
|
||||||
|
* @return a rejected-startup outcome carrying the supplied message
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunLaunchOutcome rejected(String failureMessage) {
|
||||||
|
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
|
||||||
|
if (failureMessage.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("failureMessage must not be blank");
|
||||||
|
}
|
||||||
|
return new GuiBatchRunLaunchOutcome(false, false, Optional.of(failureMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an outcome describing a run that started but ended due to an unexpected
|
||||||
|
* technical exception.
|
||||||
|
*
|
||||||
|
* @param failureMessage the user-visible German failure description; must not be blank
|
||||||
|
* @return an aborted-after-start outcome carrying the supplied message
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunLaunchOutcome failedAfterStart(String failureMessage) {
|
||||||
|
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
|
||||||
|
if (failureMessage.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("failureMessage must not be blank");
|
||||||
|
}
|
||||||
|
return new GuiBatchRunLaunchOutcome(true, false, Optional.of(failureMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound bridge implemented by Bootstrap to let the GUI execute a batch run against a
|
||||||
|
* stored configuration file.
|
||||||
|
* <p>
|
||||||
|
* The launcher performs the complete headless startup sequence (legacy migration, config
|
||||||
|
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, execution)
|
||||||
|
* for the supplied configuration path while forwarding progress callbacks and honouring
|
||||||
|
* the supplied cancellation token. It reuses the very same application ports and
|
||||||
|
* persistence pipeline as a Task-Scheduler-triggered headless run; only the presentation
|
||||||
|
* side (the GUI) differs.
|
||||||
|
*
|
||||||
|
* <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 GuiBatchRunLauncher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes exactly one batch run against the supplied configuration file.
|
||||||
|
*
|
||||||
|
* @param configFilePath path of the {@code .properties} file to run against;
|
||||||
|
* must not be {@code null}; must exist and be readable
|
||||||
|
* @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,
|
||||||
|
BatchRunProgressObserver observer,
|
||||||
|
BatchRunCancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable view model for a single row in the processing-run result list.
|
||||||
|
* <p>
|
||||||
|
* Each completed candidate becomes exactly one row. The row carries only the information
|
||||||
|
* 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
|
||||||
|
* layer.
|
||||||
|
*
|
||||||
|
* @param originalFileName the source filename as reported by the use case; never
|
||||||
|
* {@code null} or blank
|
||||||
|
* @param status the aggregated completion status; never {@code null}
|
||||||
|
* @param finalFileName the final target filename when the row represents a successful
|
||||||
|
* rename; empty otherwise
|
||||||
|
* @param resolvedDate the resolved document date when the row represents a successful
|
||||||
|
* rename; empty otherwise
|
||||||
|
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
||||||
|
* reasoning is available for this row
|
||||||
|
* @param processingDuration wall-clock duration spent on the candidate in this run;
|
||||||
|
* never {@code null} and never negative
|
||||||
|
*/
|
||||||
|
public record GuiBatchRunResultRow(
|
||||||
|
String originalFileName,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
Optional<String> finalFileName,
|
||||||
|
Optional<LocalDate> resolvedDate,
|
||||||
|
Optional<String> aiReasoning,
|
||||||
|
Duration processingDuration) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor normalising optional holders and validating mandatory fields.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException if {@code originalFileName}, {@code status} or
|
||||||
|
* {@code processingDuration} is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||||
|
* {@code processingDuration} is negative
|
||||||
|
*/
|
||||||
|
public GuiBatchRunResultRow {
|
||||||
|
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
|
||||||
|
if (originalFileName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
|
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
||||||
|
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
||||||
|
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
|
||||||
|
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||||
|
if (processingDuration.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("processingDuration must not be negative");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the status icon for this row, mirroring the specification.
|
||||||
|
*
|
||||||
|
* @return the corresponding emoji icon
|
||||||
|
*/
|
||||||
|
public String statusIcon() {
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "\u2705"; // ✅
|
||||||
|
case FAILED_RETRYABLE -> "\u26A0\uFE0F"; // ⚠️
|
||||||
|
case FAILED_PERMANENT -> "\u274C"; // ❌
|
||||||
|
case SKIPPED -> "\u23ED\uFE0F"; // ⏭️
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+532
@@ -0,0 +1,532 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ProgressBar;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TableCell;
|
||||||
|
import javafx.scene.control.TableColumn;
|
||||||
|
import javafx.scene.control.TableView;
|
||||||
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second main-tab of the JavaFX editor window: the live processing-run view.
|
||||||
|
* <p>
|
||||||
|
* The tab encapsulates all UI for starting, observing, and stopping a batch run from
|
||||||
|
* inside the GUI. It collaborates with a {@link GuiBatchRunCoordinator} which owns the
|
||||||
|
* background worker thread and forwards progress callbacks here on the JavaFX Application
|
||||||
|
* Thread.
|
||||||
|
*
|
||||||
|
* <h2>Layout</h2>
|
||||||
|
* <pre>
|
||||||
|
* ┌──────────────────────────────────────────────────────┐
|
||||||
|
* │ [Fortschrittsbalken] 12 / 47 Dateien │
|
||||||
|
* ├──────────────────────────────────┬───────────────────┤
|
||||||
|
* │ Ergebnisliste │ Seitenbereich │
|
||||||
|
* │ (TableView) │ (Reasoning) │
|
||||||
|
* ├──────────────────────────────────┴───────────────────┤
|
||||||
|
* │ Meldungs- und Zusammenfassungsbereich │
|
||||||
|
* ├──────────────────────────────────────────────────────┤
|
||||||
|
* │ [Starten] [Abbrechen] │
|
||||||
|
* └──────────────────────────────────────────────────────┘
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* All public methods of this class must be invoked on the JavaFX Application Thread. The
|
||||||
|
* class is not thread-safe; the coordinator is responsible for dispatching background
|
||||||
|
* events onto the FX thread before calling back into the tab.
|
||||||
|
*/
|
||||||
|
public final class GuiBatchRunTab {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
|
||||||
|
|
||||||
|
/** Spec: "Datei auswählen für Details". Shown in the detail pane before the first row is selected. */
|
||||||
|
static final String DETAIL_PLACEHOLDER = "Datei auswählen für Details";
|
||||||
|
|
||||||
|
/** Spec: hint shown when no AI reasoning is available for the selected row. */
|
||||||
|
static final String NO_REASONING_TEXT = "Für diesen Eintrag liegt kein KI-Reasoning vor.";
|
||||||
|
|
||||||
|
/** Spec: hint shown when the start button is pressed against an empty source folder. */
|
||||||
|
static final String EMPTY_SOURCE_FOLDER_HINT =
|
||||||
|
"Keine verarbeitbaren Dateien im Quellordner gefunden";
|
||||||
|
|
||||||
|
/** Spec: hint shown when a second start attempt is made while a run is active. */
|
||||||
|
static final String ALREADY_RUNNING_HINT = "Ein Verarbeitungslauf ist bereits aktiv.";
|
||||||
|
|
||||||
|
/** Spec: German startup error shown when the saved configuration is unusable. */
|
||||||
|
static final String NO_SAVED_CONFIGURATION_HINT =
|
||||||
|
"Bitte speichern Sie die Konfiguration, bevor ein Verarbeitungslauf gestartet wird.";
|
||||||
|
|
||||||
|
/** Icon-to-placeholder rendering for empty columns in failure and skip rows. */
|
||||||
|
static final String EMPTY_CELL_TEXT = "\u2014"; // —
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Verarbeitungslauf";
|
||||||
|
private static final double PROGRESS_BAR_MAX_WIDTH = Double.MAX_VALUE;
|
||||||
|
private static final double PROGRESS_BAR_PREF_HEIGHT = 20;
|
||||||
|
private static final double DETAIL_PANE_MIN_WIDTH = 280;
|
||||||
|
private static final double LIST_MIN_HEIGHT = 240;
|
||||||
|
private static final double DETAIL_AREA_MIN_HEIGHT = 240;
|
||||||
|
private static final int SECONDARY_SPACING = 12;
|
||||||
|
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
private final ProgressBar progressBar = new ProgressBar(0);
|
||||||
|
private final Label counterLabel = new Label("0 / 0 Dateien");
|
||||||
|
private final TableView<GuiBatchRunResultRow> resultTable = new TableView<>();
|
||||||
|
private final ObservableList<GuiBatchRunResultRow> resultItems = FXCollections.observableArrayList();
|
||||||
|
private final TextArea detailArea = new TextArea(DETAIL_PLACEHOLDER);
|
||||||
|
private final TextArea messageArea = new TextArea();
|
||||||
|
private final Button startButton = new Button("Starten");
|
||||||
|
private final Button cancelButton = new Button("Abbrechen");
|
||||||
|
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
|
||||||
|
|
||||||
|
private final Supplier<Path> configPathSupplier;
|
||||||
|
private final BooleanSupplier savedConfigurationReadyCheck;
|
||||||
|
private final Runnable onRunStateChanged;
|
||||||
|
private final GuiBatchRunCoordinator coordinator;
|
||||||
|
|
||||||
|
private int totalCandidates;
|
||||||
|
private int completedCandidates;
|
||||||
|
private int successCount;
|
||||||
|
private int failedCount;
|
||||||
|
private int skippedCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the processing-run tab and wires all UI controls.
|
||||||
|
*
|
||||||
|
* @param launcherSupplier supplier returning the active
|
||||||
|
* {@link GuiBatchRunLauncher}; called when the
|
||||||
|
* user presses "Starten"; must not be null
|
||||||
|
* @param configPathSupplier supplier returning the last saved configuration
|
||||||
|
* path to run against; may return {@code null}
|
||||||
|
* when no configuration is loaded
|
||||||
|
* @param savedConfigurationReadyCheck check invoked before each start attempt; must
|
||||||
|
* return {@code true} only when the editor state
|
||||||
|
* contains a saved configuration and no unsaved
|
||||||
|
* edit has made it unusable; must not be null
|
||||||
|
* @param onRunStateChanged callback invoked on the FX thread whenever the
|
||||||
|
* running flag flips; typically used by the
|
||||||
|
* workspace to sperren/entsperren Tab 1 and to
|
||||||
|
* rewire the close-request handler; must not be
|
||||||
|
* null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
||||||
|
Supplier<Path> configPathSupplier,
|
||||||
|
BooleanSupplier savedConfigurationReadyCheck,
|
||||||
|
Runnable onRunStateChanged) {
|
||||||
|
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
|
||||||
|
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier must not be null");
|
||||||
|
this.savedConfigurationReadyCheck = Objects.requireNonNull(
|
||||||
|
savedConfigurationReadyCheck, "savedConfigurationReadyCheck must not be null");
|
||||||
|
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
|
||||||
|
|
||||||
|
this.coordinator = new GuiBatchRunCoordinator(
|
||||||
|
(configPath, observer, token) ->
|
||||||
|
launcherSupplier.get().launch(configPath, observer, token),
|
||||||
|
new CoordinatorListener());
|
||||||
|
this.tab.setClosable(false);
|
||||||
|
this.tab.setContent(buildContent());
|
||||||
|
resetMetrics();
|
||||||
|
updateCounterLabel();
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JavaFX {@link Tab} node that hosts the processing-run view.
|
||||||
|
*
|
||||||
|
* @return the tab; never {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a read-only property that is {@code true} while a run is active.
|
||||||
|
*
|
||||||
|
* @return read-only running property
|
||||||
|
*/
|
||||||
|
public ReadOnlyBooleanProperty runningProperty() {
|
||||||
|
return runningProperty.getReadOnlyProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a run is currently in progress on the worker thread.
|
||||||
|
*
|
||||||
|
* @return {@code true} while the coordinator is processing a run
|
||||||
|
*/
|
||||||
|
public boolean isRunning() {
|
||||||
|
return coordinator.isRunning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests soft-stop cancellation of the currently running batch.
|
||||||
|
* <p>
|
||||||
|
* When no run is active the call has no effect. Cancellation is honoured between
|
||||||
|
* candidates — the currently processed candidate always finishes first.
|
||||||
|
*/
|
||||||
|
public void requestCancellation() {
|
||||||
|
coordinator.requestCancellation();
|
||||||
|
cancelButton.setDisable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Button startButton() {
|
||||||
|
return startButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Button cancelButton() {
|
||||||
|
return cancelButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
ProgressBar progressBar() {
|
||||||
|
return progressBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
TableView<GuiBatchRunResultRow> resultTable() {
|
||||||
|
return resultTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
TextArea messageArea() {
|
||||||
|
return messageArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
TextArea detailArea() {
|
||||||
|
return detailArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Label counterLabel() {
|
||||||
|
return counterLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
GuiBatchRunCoordinator coordinator() {
|
||||||
|
return coordinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BorderPane buildContent() {
|
||||||
|
BorderPane layout = new BorderPane();
|
||||||
|
layout.setPadding(new Insets(12));
|
||||||
|
|
||||||
|
layout.setTop(buildProgressHeader());
|
||||||
|
layout.setCenter(buildCenterSplit());
|
||||||
|
layout.setBottom(buildFooter());
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Region buildProgressHeader() {
|
||||||
|
progressBar.setMaxWidth(PROGRESS_BAR_MAX_WIDTH);
|
||||||
|
progressBar.setPrefHeight(PROGRESS_BAR_PREF_HEIGHT);
|
||||||
|
HBox.setHgrow(progressBar, Priority.ALWAYS);
|
||||||
|
|
||||||
|
counterLabel.setId("batch-run-counter");
|
||||||
|
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
|
||||||
|
header.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Region buildCenterSplit() {
|
||||||
|
configureResultTable();
|
||||||
|
ScrollPane tableScroll = new ScrollPane(resultTable);
|
||||||
|
tableScroll.setFitToWidth(true);
|
||||||
|
tableScroll.setFitToHeight(true);
|
||||||
|
tableScroll.setId("batch-run-result-scroll");
|
||||||
|
resultTable.setMinHeight(LIST_MIN_HEIGHT);
|
||||||
|
|
||||||
|
detailArea.setId("batch-run-detail");
|
||||||
|
detailArea.setEditable(false);
|
||||||
|
detailArea.setWrapText(true);
|
||||||
|
detailArea.setMinHeight(DETAIL_AREA_MIN_HEIGHT);
|
||||||
|
detailArea.setMinWidth(DETAIL_PANE_MIN_WIDTH);
|
||||||
|
|
||||||
|
Label detailTitle = new Label("KI-Begründung");
|
||||||
|
detailTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
VBox detailBox = new VBox(SECONDARY_SPACING / 2.0, detailTitle, detailArea);
|
||||||
|
detailBox.setPadding(new Insets(0, 0, 0, SECONDARY_SPACING));
|
||||||
|
detailBox.setMinWidth(DETAIL_PANE_MIN_WIDTH);
|
||||||
|
VBox.setVgrow(detailArea, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox centerSplit = new HBox(tableScroll, detailBox);
|
||||||
|
HBox.setHgrow(tableScroll, Priority.ALWAYS);
|
||||||
|
HBox.setHgrow(detailBox, Priority.NEVER);
|
||||||
|
return centerSplit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureResultTable() {
|
||||||
|
resultTable.setItems(resultItems);
|
||||||
|
resultTable.setId("batch-run-result-table");
|
||||||
|
resultTable.setPlaceholder(new Label("Noch kein Verarbeitungslauf gestartet."));
|
||||||
|
|
||||||
|
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status");
|
||||||
|
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
|
||||||
|
iconCol.setPrefWidth(64);
|
||||||
|
|
||||||
|
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>("Originaldateiname");
|
||||||
|
nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
|
||||||
|
nameCol.setPrefWidth(280);
|
||||||
|
|
||||||
|
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname");
|
||||||
|
newNameCol.setCellValueFactory(data -> new SimpleStringProperty(
|
||||||
|
data.getValue().finalFileName().orElse(EMPTY_CELL_TEXT)));
|
||||||
|
newNameCol.setPrefWidth(280);
|
||||||
|
|
||||||
|
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum");
|
||||||
|
dateCol.setCellValueFactory(data -> new SimpleStringProperty(
|
||||||
|
data.getValue().resolvedDate()
|
||||||
|
.map(DateTimeFormatter.ISO_LOCAL_DATE::format)
|
||||||
|
.orElse(EMPTY_CELL_TEXT)));
|
||||||
|
dateCol.setPrefWidth(100);
|
||||||
|
|
||||||
|
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>("Dauer");
|
||||||
|
durationCol.setCellValueFactory(data -> new SimpleStringProperty(
|
||||||
|
formatDuration(data.getValue().processingDuration())));
|
||||||
|
durationCol.setPrefWidth(80);
|
||||||
|
durationCol.setCellFactory(col -> new TableCell<>() {
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String value, boolean empty) {
|
||||||
|
super.updateItem(value, empty);
|
||||||
|
setText(empty || value == null ? null : value);
|
||||||
|
setStyle(empty ? null : "-fx-alignment: CENTER_RIGHT;");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
List<TableColumn<GuiBatchRunResultRow, String>> columns =
|
||||||
|
List.of(iconCol, nameCol, newNameCol, dateCol, durationCol);
|
||||||
|
resultTable.getColumns().setAll(columns);
|
||||||
|
|
||||||
|
resultTable.getSelectionModel().selectedItemProperty().addListener((obs, old, row) -> {
|
||||||
|
if (row == null) {
|
||||||
|
detailArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailArea.setText(buildDetailText(row));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
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 Region buildFooter() {
|
||||||
|
messageArea.setId("batch-run-message-area");
|
||||||
|
messageArea.setEditable(false);
|
||||||
|
messageArea.setWrapText(true);
|
||||||
|
messageArea.setPrefRowCount(3);
|
||||||
|
|
||||||
|
startButton.setId("batch-run-start");
|
||||||
|
startButton.setOnAction(event -> handleStart());
|
||||||
|
|
||||||
|
cancelButton.setId("batch-run-cancel");
|
||||||
|
cancelButton.setOnAction(event -> requestCancellation());
|
||||||
|
cancelButton.setDisable(true);
|
||||||
|
|
||||||
|
HBox buttonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
|
||||||
|
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
buttonBar.setPadding(new Insets(SECONDARY_SPACING, 0, 0, 0));
|
||||||
|
|
||||||
|
VBox footer = new VBox(SECONDARY_SPACING, messageArea, buttonBar);
|
||||||
|
return footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleStart() {
|
||||||
|
if (isRunning()) {
|
||||||
|
showMessage(ALREADY_RUNNING_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||||
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset all UI state before starting a new run.
|
||||||
|
resultItems.clear();
|
||||||
|
detailArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
messageArea.clear();
|
||||||
|
resetMetrics();
|
||||||
|
updateCounterLabel();
|
||||||
|
progressBar.setProgress(0);
|
||||||
|
|
||||||
|
boolean started = coordinator.start(configPath);
|
||||||
|
if (!started) {
|
||||||
|
showMessage(ALREADY_RUNNING_HINT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Start ausgelöst für Konfiguration {}.", configPath);
|
||||||
|
runningProperty.set(true);
|
||||||
|
notifyRunStateChanged();
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showMessage(String message) {
|
||||||
|
messageArea.setText(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendMessage(String message) {
|
||||||
|
if (messageArea.getText() == null || messageArea.getText().isBlank()) {
|
||||||
|
messageArea.setText(message);
|
||||||
|
} else {
|
||||||
|
messageArea.setText(messageArea.getText() + System.lineSeparator() + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateCounterLabel() {
|
||||||
|
counterLabel.setText(completedCandidates + " / " + totalCandidates + " Dateien");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateProgressBar() {
|
||||||
|
if (totalCandidates <= 0) {
|
||||||
|
progressBar.setProgress(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
progressBar.setProgress((double) completedCandidates / (double) totalCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateButtonStates() {
|
||||||
|
boolean running = coordinator.isRunning();
|
||||||
|
startButton.setDisable(running);
|
||||||
|
if (!running) {
|
||||||
|
cancelButton.setDisable(true);
|
||||||
|
} else {
|
||||||
|
cancelButton.setDisable(coordinator.isCancellationRequested());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetMetrics() {
|
||||||
|
totalCandidates = 0;
|
||||||
|
completedCandidates = 0;
|
||||||
|
successCount = 0;
|
||||||
|
failedCount = 0;
|
||||||
|
skippedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyRunStateChanged() {
|
||||||
|
try {
|
||||||
|
onRunStateChanged.run();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warn("GUI-Verarbeitungslauf: Listener für Laufzustand warf eine Exception: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CoordinatorListener implements GuiBatchRunCoordinator.Listener {
|
||||||
|
@Override
|
||||||
|
public void onRunStarted(RunId runId, int totalCandidatesFromObserver) {
|
||||||
|
totalCandidates = Math.max(0, totalCandidatesFromObserver);
|
||||||
|
completedCandidates = 0;
|
||||||
|
successCount = 0;
|
||||||
|
failedCount = 0;
|
||||||
|
skippedCount = 0;
|
||||||
|
updateCounterLabel();
|
||||||
|
updateProgressBar();
|
||||||
|
if (totalCandidates == 0) {
|
||||||
|
showMessage(EMPTY_SOURCE_FOLDER_HINT);
|
||||||
|
}
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: RunId={} mit {} Kandidat(en) gestartet.",
|
||||||
|
runId, totalCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
resultItems.add(row);
|
||||||
|
completedCandidates = Math.min(totalCandidates, completedCandidates + 1);
|
||||||
|
switch (row.status()) {
|
||||||
|
case SUCCESS -> successCount++;
|
||||||
|
case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
|
||||||
|
case SKIPPED -> skippedCount++;
|
||||||
|
default -> throw new IllegalStateException(
|
||||||
|
"Unerwarteter Status: " + row.status());
|
||||||
|
}
|
||||||
|
updateCounterLabel();
|
||||||
|
updateProgressBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
runningProperty.set(false);
|
||||||
|
appendSummary(summary, outcome);
|
||||||
|
updateButtonStates();
|
||||||
|
notifyRunStateChanged();
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Lauf beendet. successfullyStarted={}, completed={}, "
|
||||||
|
+ "erfolgreich={}, fehlgeschlagen={}, übersprungen={}.",
|
||||||
|
outcome.successfullyStarted(), outcome.batchCompletedNormally(),
|
||||||
|
summary.successCount(), summary.failedCount(), summary.skippedCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendSummary(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
if (!outcome.successfullyStarted()) {
|
||||||
|
outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!outcome.batchCompletedNormally()) {
|
||||||
|
outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
|
||||||
|
}
|
||||||
|
String summaryText = summary.successCount() + " erfolgreich, "
|
||||||
|
+ summary.failedCount() + " fehlgeschlagen, "
|
||||||
|
+ summary.skippedCount() + " übersprungen";
|
||||||
|
appendMessage(summaryText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classification used by {@link #updateButtonStates()} in tests. */
|
||||||
|
DocumentCompletionStatus sentinelForTests() {
|
||||||
|
return DocumentCompletionStatus.SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Inbound adapter components that drive the GUI's processing-run tab.
|
||||||
|
* <p>
|
||||||
|
* The classes in this package build the second tab of the main window, translate
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||||
|
* callbacks into JavaFX UI updates, and manage the worker thread that executes a
|
||||||
|
* single run against a stored {@code .properties} configuration.
|
||||||
|
*
|
||||||
|
* <h2>Threading contract</h2>
|
||||||
|
* <p>
|
||||||
|
* The batch run itself always executes on a dedicated background worker thread obtained
|
||||||
|
* from {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}.
|
||||||
|
* Every UI mutation (progress bar value, result rows, button states, tab sperre) is
|
||||||
|
* dispatched onto the JavaFX Application Thread via {@code Platform.runLater}. No class
|
||||||
|
* in this package mutates a JavaFX {@code Control} from the worker thread.
|
||||||
|
*
|
||||||
|
* <h2>Cancellation</h2>
|
||||||
|
* <p>
|
||||||
|
* The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag
|
||||||
|
* causes the use case to stop <em>before</em> starting the next candidate; the candidate
|
||||||
|
* currently being processed is always completed in full so the SQLite persistence remains
|
||||||
|
* consistent.
|
||||||
|
*
|
||||||
|
* <h2>Configuration source</h2>
|
||||||
|
* <p>
|
||||||
|
* A run is always started against the {@code .properties} file currently on disk (the
|
||||||
|
* last saved state of the editor). Unsaved editor content is intentionally not forwarded
|
||||||
|
* to the launcher — the run must match what a parallel headless launch would see.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
+15
-6
@@ -86,10 +86,17 @@ class GuiAdapterSmokeTest {
|
|||||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
Platform.setImplicitExit(false);
|
Platform.setImplicitExit(false);
|
||||||
CountDownLatch startLatch = new CountDownLatch(1);
|
CountDownLatch startLatch = new CountDownLatch(1);
|
||||||
Platform.startup(() -> {
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
startLatch.countDown();
|
||||||
|
});
|
||||||
|
} catch (IllegalStateException alreadyInitialised) {
|
||||||
|
// Another smoke test in the same Surefire fork already started the JavaFX
|
||||||
|
// runtime; treat the toolkit as available and proceed.
|
||||||
PLATFORM_STARTED.set(true);
|
PLATFORM_STARTED.set(true);
|
||||||
startLatch.countDown();
|
startLatch.countDown();
|
||||||
});
|
}
|
||||||
assertTrue(
|
assertTrue(
|
||||||
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
|
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
|
||||||
@@ -237,14 +244,16 @@ class GuiAdapterSmokeTest {
|
|||||||
"The 'Speichern' button must be visible");
|
"The 'Speichern' button must be visible");
|
||||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||||
"The 'Speichern unter' button must be visible");
|
"The 'Speichern unter' button must be visible");
|
||||||
assertEquals(1, workspace.tabPane().getTabs().size(),
|
assertEquals(2, workspace.tabPane().getTabs().size(),
|
||||||
"Exactly one configuration tab must be present");
|
"Configuration tab and processing-run tab must both be present");
|
||||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||||
"The single tab must use the configuration label");
|
"The first tab must use the configuration label");
|
||||||
|
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||||
|
"The second tab must host the processing-run view");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||||
String.join(",", workspace.sectionTitles()),
|
String.join(",", workspace.sectionTitles()),
|
||||||
"The single tab must expose the fixed section structure in the documented order");
|
"The configuration tab must expose the fixed section structure in the documented order");
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
fxError.set(t);
|
fxError.set(t);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+352
@@ -0,0 +1,352 @@
|
|||||||
|
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.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
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.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous tests for {@link GuiBatchRunCoordinator}.
|
||||||
|
* <p>
|
||||||
|
* These tests substitute the background worker thread and the FX dispatcher with
|
||||||
|
* in-thread runners so behaviour can be verified deterministically without a running
|
||||||
|
* JavaFX runtime.
|
||||||
|
*/
|
||||||
|
class GuiBatchRunCoordinatorTest {
|
||||||
|
|
||||||
|
private static final Path ANY_CONFIG = Paths.get("ignored.properties");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_withNullPath_throws() {
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
(path, observer, token) -> GuiBatchRunLaunchOutcome.completed(),
|
||||||
|
syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
assertFalse(coordinator.isRunning());
|
||||||
|
try {
|
||||||
|
coordinator.start(null);
|
||||||
|
} catch (NullPointerException expected) {
|
||||||
|
// expected
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Expected NullPointerException");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedRun_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.originalFileName());
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
events.add("ended:started=" + outcome.successfullyStarted()
|
||||||
|
+ ",completed=" + outcome.batchCompletedNormally()
|
||||||
|
+ ",summary=" + summary.successCount() + "/" + summary.failedCount()
|
||||||
|
+ "/" + summary.skippedCount());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
observer.onRunStarted(new RunId("run-1"), 2);
|
||||||
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"a.pdf", DocumentCompletionStatus.SUCCESS,
|
||||||
|
"2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", Duration.ofMillis(20)));
|
||||||
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"b.pdf", DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
null, null, null, Duration.ofMillis(10)));
|
||||||
|
observer.onRunEnded(new RunSummary(1, 1, 0));
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
launcher, syncThreadFactory(), syncDispatcher(), listener);
|
||||||
|
|
||||||
|
boolean started = coordinator.start(ANY_CONFIG);
|
||||||
|
assertTrue(started);
|
||||||
|
|
||||||
|
assertEquals(List.of(
|
||||||
|
"started:2",
|
||||||
|
"row:SUCCESS:a.pdf",
|
||||||
|
"row:FAILED_PERMANENT:b.pdf",
|
||||||
|
"ended:started=true,completed=true,summary=1/1/0"), events);
|
||||||
|
assertFalse(coordinator.isRunning());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startWhileRunning_returnsFalseWithoutDoubleDispatch() {
|
||||||
|
// Launcher installs a rendezvous so the first run is still "running" while we
|
||||||
|
// attempt the second start.
|
||||||
|
CountDownLatch firstRunActive = new CountDownLatch(1);
|
||||||
|
CountDownLatch releaseFirstRun = new CountDownLatch(1);
|
||||||
|
AtomicBoolean firstStarted = new AtomicBoolean();
|
||||||
|
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
firstStarted.set(true);
|
||||||
|
firstRunActive.countDown();
|
||||||
|
try {
|
||||||
|
releaseFirstRun.await(5, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
launcher,
|
||||||
|
task -> {
|
||||||
|
Thread thread = new Thread(task, "test-worker");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
Runnable::run, // direct FX dispatch
|
||||||
|
noOpListener());
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertTrue(coordinator.start(ANY_CONFIG));
|
||||||
|
assertTrue(firstRunActive.await(5, TimeUnit.SECONDS));
|
||||||
|
assertTrue(coordinator.isRunning());
|
||||||
|
assertFalse(coordinator.start(ANY_CONFIG), "second start must be rejected");
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError(e);
|
||||||
|
} finally {
|
||||||
|
releaseFirstRun.countDown();
|
||||||
|
waitUntilIdle(coordinator);
|
||||||
|
}
|
||||||
|
assertTrue(firstStarted.get());
|
||||||
|
assertFalse(coordinator.isRunning());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requestCancellation_setsFlagForLauncherToObserve() {
|
||||||
|
AtomicReference<BatchRunCancellationToken> seenToken = new AtomicReference<>();
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
seenToken.set(token);
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
launcher, syncThreadFactory(), syncDispatcher(), noOpListener());
|
||||||
|
|
||||||
|
// Start must run synchronously via our sync thread factory.
|
||||||
|
coordinator.start(ANY_CONFIG);
|
||||||
|
BatchRunCancellationToken token = seenToken.get();
|
||||||
|
assertNotNull(token);
|
||||||
|
|
||||||
|
// Token is polled by the launcher only; after the run the flag has been consumed
|
||||||
|
// and reset to false for the next run.
|
||||||
|
assertFalse(coordinator.isCancellationRequested(),
|
||||||
|
"Cancellation flag must reset back to false before the run starts");
|
||||||
|
|
||||||
|
// Starting a second run and cancelling before the launcher observes → flag true.
|
||||||
|
CountDownLatch launcherRunning = new CountDownLatch(1);
|
||||||
|
CountDownLatch cancelBeforeReturn = new CountDownLatch(1);
|
||||||
|
AtomicBoolean sawCancelled = new AtomicBoolean();
|
||||||
|
GuiBatchRunLauncher slowLauncher = (configPath, observer, token1) -> {
|
||||||
|
launcherRunning.countDown();
|
||||||
|
try {
|
||||||
|
cancelBeforeReturn.await(5, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
sawCancelled.set(token1.isCancellationRequested());
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
GuiBatchRunCoordinator coord2 = new GuiBatchRunCoordinator(
|
||||||
|
slowLauncher,
|
||||||
|
task -> {
|
||||||
|
Thread thread = new Thread(task, "test-worker-2");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
Runnable::run, noOpListener());
|
||||||
|
|
||||||
|
try {
|
||||||
|
coord2.start(ANY_CONFIG);
|
||||||
|
assertTrue(launcherRunning.await(5, TimeUnit.SECONDS));
|
||||||
|
coord2.requestCancellation();
|
||||||
|
assertTrue(coord2.isCancellationRequested());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new AssertionError(e);
|
||||||
|
} finally {
|
||||||
|
cancelBeforeReturn.countDown();
|
||||||
|
waitUntilIdle(coord2);
|
||||||
|
}
|
||||||
|
assertTrue(sawCancelled.get(), "Launcher must see isCancellationRequested=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void launcherException_yieldsFailedAfterStartOutcome() {
|
||||||
|
AtomicReference<GuiBatchRunLaunchOutcome> captured = new AtomicReference<>();
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
throw new IllegalStateException("boom");
|
||||||
|
};
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
launcher, syncThreadFactory(), syncDispatcher(),
|
||||||
|
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) {
|
||||||
|
captured.set(outcome);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
coordinator.start(ANY_CONFIG);
|
||||||
|
|
||||||
|
GuiBatchRunLaunchOutcome outcome = captured.get();
|
||||||
|
assertNotNull(outcome);
|
||||||
|
assertTrue(outcome.successfullyStarted());
|
||||||
|
assertFalse(outcome.batchCompletedNormally());
|
||||||
|
assertTrue(outcome.failureMessage().isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullLauncherResult_mapsToFailedAfterStart() {
|
||||||
|
AtomicReference<GuiBatchRunLaunchOutcome> captured = new AtomicReference<>();
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> null;
|
||||||
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
|
launcher, syncThreadFactory(), syncDispatcher(),
|
||||||
|
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) {
|
||||||
|
captured.set(outcome);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
coordinator.start(ANY_CONFIG);
|
||||||
|
|
||||||
|
GuiBatchRunLaunchOutcome outcome = captured.get();
|
||||||
|
assertNotNull(outcome);
|
||||||
|
assertTrue(outcome.successfullyStarted());
|
||||||
|
assertFalse(outcome.batchCompletedNormally());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resultRowIcons_matchSpecification() {
|
||||||
|
assertEquals("\u2705", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||||
|
assertEquals("\u26A0\uFE0F", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||||
|
assertEquals("\u274C", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
|
||||||
|
assertEquals("\u23ED\uFE0F", row(DocumentCompletionStatus.SKIPPED).statusIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void launchOutcomeFactories_populateFailureMessages() {
|
||||||
|
GuiBatchRunLaunchOutcome completed = GuiBatchRunLaunchOutcome.completed();
|
||||||
|
assertTrue(completed.successfullyStarted());
|
||||||
|
assertTrue(completed.batchCompletedNormally());
|
||||||
|
assertTrue(completed.failureMessage().isEmpty());
|
||||||
|
|
||||||
|
GuiBatchRunLaunchOutcome rejected = GuiBatchRunLaunchOutcome.rejected("nope");
|
||||||
|
assertFalse(rejected.successfullyStarted());
|
||||||
|
assertFalse(rejected.batchCompletedNormally());
|
||||||
|
assertEquals("nope", rejected.failureMessage().orElseThrow());
|
||||||
|
|
||||||
|
GuiBatchRunLaunchOutcome failedAfter = GuiBatchRunLaunchOutcome.failedAfterStart("boom");
|
||||||
|
assertTrue(failedAfter.successfullyStarted());
|
||||||
|
assertFalse(failedAfter.batchCompletedNormally());
|
||||||
|
assertEquals("boom", failedAfter.failureMessage().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
"x.pdf", status, null, null, null, Duration.ofMillis(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 java.util.function.Function<Runnable, Thread> syncThreadFactory() {
|
||||||
|
return task -> new Thread(task) {
|
||||||
|
@Override public synchronized void start() {
|
||||||
|
// Execute the task on the current thread so the test stays fully synchronous.
|
||||||
|
task.run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Consumer<Runnable> syncDispatcher() {
|
||||||
|
return Runnable::run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void waitUntilIdle(GuiBatchRunCoordinator coordinator) {
|
||||||
|
long deadline = System.currentTimeMillis() + 5_000;
|
||||||
|
while (coordinator.isRunning() && System.currentTimeMillis() < deadline) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(10);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noOpObserverAndNeverCancelled_singletonsCallableWithoutEffects() {
|
||||||
|
BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp();
|
||||||
|
noOp.onRunStarted(new RunId("x"), 0);
|
||||||
|
noOp.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"a.pdf", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||||
|
noOp.onRunEnded(new RunSummary(0, 0, 0));
|
||||||
|
assertSame(noOp, BatchRunProgressObserver.noOp());
|
||||||
|
assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested());
|
||||||
|
assertSame(BatchRunCancellationToken.neverCancelled(),
|
||||||
|
BatchRunCancellationToken.neverCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resultRow_rejectsInvalidInput() {
|
||||||
|
try {
|
||||||
|
new GuiBatchRunResultRow(" ", DocumentCompletionStatus.SUCCESS,
|
||||||
|
null, null, null, Duration.ZERO);
|
||||||
|
throw new AssertionError("expected IllegalArgumentException");
|
||||||
|
} catch (IllegalArgumentException expected) { /* ok */ }
|
||||||
|
try {
|
||||||
|
new GuiBatchRunResultRow("x.pdf", DocumentCompletionStatus.SUCCESS,
|
||||||
|
null, null, null, Duration.ofSeconds(-1));
|
||||||
|
throw new AssertionError("expected IllegalArgumentException");
|
||||||
|
} catch (IllegalArgumentException expected) { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resultRow_optionalHoldersNormaliseNullToEmpty() {
|
||||||
|
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
|
||||||
|
"x.pdf", DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
null, null, null, Duration.ZERO);
|
||||||
|
assertNull(row.finalFileName().orElse(null));
|
||||||
|
assertNull(row.resolvedDate().orElse(null));
|
||||||
|
assertNull(row.aiReasoning().orElse(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
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.time.LocalDate;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless (Monocle) smoke tests for {@link GuiBatchRunTab}. These tests drive the tab
|
||||||
|
* end-to-end via a stubbed launcher, asserting the observable UI state transitions on
|
||||||
|
* the JavaFX Application Thread.
|
||||||
|
*/
|
||||||
|
class GuiBatchRunTabSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
if (PLATFORM_STARTED.compareAndSet(false, true)) {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(latch::countDown);
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
// JavaFX is already running; reuse it.
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_withoutSavedConfiguration_showsHint() throws Exception {
|
||||||
|
onFxAndWait(tab -> {
|
||||||
|
tab.startButton().fire();
|
||||||
|
assertEquals(GuiBatchRunTab.NO_SAVED_CONFIGURATION_HINT,
|
||||||
|
tab.messageArea().getText());
|
||||||
|
assertFalse(tab.coordinator().isRunning());
|
||||||
|
}, /*savedReady*/ false, /*configPath*/ null, /*launcher*/ null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void start_withEmptyFolder_showsEmptyFolderHintAndSummary() throws Exception {
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
CountDownLatch launcherInvoked = new CountDownLatch(1);
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
observer.onRunStarted(new RunId("empty"), 0);
|
||||||
|
observer.onRunEnded(new RunSummary(0, 0, 0));
|
||||||
|
launcherInvoked.countDown();
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
|
||||||
|
CountDownLatch latch = runOnFxAndWaitUntilDone(tab -> {
|
||||||
|
try {
|
||||||
|
tab.startButton().fire();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
}
|
||||||
|
}, /*savedReady*/ true, Paths.get("ignored.properties"), launcher,
|
||||||
|
() -> !tab().coordinator().isRunning() && !tab().messageArea().getText().isBlank());
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "tab must quiesce");
|
||||||
|
if (error.get() != null) throw new AssertionError(error.get());
|
||||||
|
assertTrue(launcherInvoked.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
assertTrue(tab().messageArea().getText().contains(GuiBatchRunTab.EMPTY_SOURCE_FOLDER_HINT),
|
||||||
|
() -> "Missing empty-folder hint in: " + tab().messageArea().getText());
|
||||||
|
assertTrue(tab().messageArea().getText().contains("0 erfolgreich"),
|
||||||
|
() -> "Missing summary counters in: " + tab().messageArea().getText());
|
||||||
|
assertEquals("0 / 0 Dateien", tab().counterLabel().getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedRun_populatesListProgressBarAndSummary() throws Exception {
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
observer.onRunStarted(new RunId("run"), 3);
|
||||||
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"a.pdf", DocumentCompletionStatus.SUCCESS,
|
||||||
|
"2026-03-01 - Titel.pdf",
|
||||||
|
LocalDate.of(2026, 3, 1),
|
||||||
|
"gut begründet",
|
||||||
|
Duration.ofMillis(42)));
|
||||||
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"b.pdf", DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||||
|
null, null, null, Duration.ofMillis(10)));
|
||||||
|
observer.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"c.pdf", DocumentCompletionStatus.SKIPPED,
|
||||||
|
null, null, null, Duration.ofMillis(5)));
|
||||||
|
observer.onRunEnded(new RunSummary(1, 1, 1));
|
||||||
|
return GuiBatchRunLaunchOutcome.completed();
|
||||||
|
};
|
||||||
|
|
||||||
|
CountDownLatch latch = runOnFxAndWaitUntilDone(
|
||||||
|
tab -> tab.startButton().fire(),
|
||||||
|
true, Paths.get("ok.properties"), launcher,
|
||||||
|
() -> !tab().coordinator().isRunning() && tab().resultTable().getItems().size() == 3);
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"tab must quiesce with 3 rows");
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
assertEquals(3, tab().resultTable().getItems().size());
|
||||||
|
assertEquals("3 / 3 Dateien", tab().counterLabel().getText());
|
||||||
|
assertEquals(1.0, tab().progressBar().getProgress(), 0.001);
|
||||||
|
String messageText = tab().messageArea().getText();
|
||||||
|
assertTrue(messageText.contains("1 erfolgreich"), messageText);
|
||||||
|
assertTrue(messageText.contains("1 fehlgeschlagen"), messageText);
|
||||||
|
assertTrue(messageText.contains("1 übersprungen"), messageText);
|
||||||
|
|
||||||
|
// Clicking the first row populates the detail pane with the AI reasoning.
|
||||||
|
tab().resultTable().getSelectionModel().select(0);
|
||||||
|
String detail = tab().detailArea().getText();
|
||||||
|
assertTrue(detail.contains("a.pdf"), detail);
|
||||||
|
assertTrue(detail.contains("2026-03-01 - Titel.pdf"), detail);
|
||||||
|
assertTrue(detail.contains("gut begründet"), detail);
|
||||||
|
|
||||||
|
// Clicking a row without reasoning shows the no-reasoning placeholder.
|
||||||
|
tab().resultTable().getSelectionModel().select(1);
|
||||||
|
assertTrue(tab().detailArea().getText().contains(GuiBatchRunTab.NO_REASONING_TEXT));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detailPane_initiallyShowsPlaceholder() throws Exception {
|
||||||
|
onFxAndWait(tab -> {
|
||||||
|
assertEquals(GuiBatchRunTab.DETAIL_PLACEHOLDER, tab.detailArea().getText());
|
||||||
|
}, /*savedReady*/ false, /*configPath*/ null, /*launcher*/ null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runLauncherFailure_showsFailureMessageInTerminalState() throws Exception {
|
||||||
|
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
|
||||||
|
observer.onRunStarted(new RunId("x"), 0);
|
||||||
|
observer.onRunEnded(new RunSummary(0, 0, 0));
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected("SQLite unvailable (Testnachricht)");
|
||||||
|
};
|
||||||
|
|
||||||
|
CountDownLatch latch = runOnFxAndWaitUntilDone(
|
||||||
|
tab -> tab.startButton().fire(),
|
||||||
|
true, Paths.get("ignore.properties"), launcher,
|
||||||
|
() -> !tab().coordinator().isRunning() && !tab().messageArea().getText().isBlank());
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
String message = tab().messageArea().getText();
|
||||||
|
assertTrue(message.contains("SQLite unvailable (Testnachricht)"),
|
||||||
|
() -> "Expected failure message in: " + message);
|
||||||
|
// Start must be enabled again, cancel disabled.
|
||||||
|
assertFalse(tab().startButton().isDisabled());
|
||||||
|
assertTrue(tab().cancelButton().isDisabled());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// FX helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static final ThreadLocal<GuiBatchRunTab> CURRENT_TAB = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private GuiBatchRunTab tab() {
|
||||||
|
return CURRENT_TAB.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onFxAndWait(java.util.function.Consumer<GuiBatchRunTab> action,
|
||||||
|
boolean savedReady,
|
||||||
|
Path configPath,
|
||||||
|
GuiBatchRunLauncher launcher) throws InterruptedException {
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiBatchRunTab tab = makeTab(savedReady, configPath, launcher);
|
||||||
|
CURRENT_TAB.set(tab);
|
||||||
|
action.accept(tab);
|
||||||
|
} 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runOnFx(Runnable action) throws InterruptedException {
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try { action.run(); } catch (Throwable t) { error.set(t); }
|
||||||
|
finally { done.countDown(); }
|
||||||
|
});
|
||||||
|
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (error.get() != null) throw new AssertionError(error.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a tab on the FX thread, fires the supplied action, and returns a latch that
|
||||||
|
* is counted down as soon as the supplied predicate becomes true on a subsequent
|
||||||
|
* FX-thread tick.
|
||||||
|
*/
|
||||||
|
private CountDownLatch runOnFxAndWaitUntilDone(
|
||||||
|
java.util.function.Consumer<GuiBatchRunTab> action,
|
||||||
|
boolean savedReady,
|
||||||
|
Path configPath,
|
||||||
|
GuiBatchRunLauncher launcher,
|
||||||
|
java.util.function.BooleanSupplier quiesced) throws InterruptedException {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiBatchRunTab tab = makeTab(savedReady, configPath, launcher);
|
||||||
|
CURRENT_TAB.set(tab);
|
||||||
|
action.accept(tab);
|
||||||
|
schedulePoll(latch, quiesced);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (error.get() != null) throw new AssertionError(error.get());
|
||||||
|
return latch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void schedulePoll(CountDownLatch latch, java.util.function.BooleanSupplier predicate) {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (predicate.getAsBoolean()) {
|
||||||
|
latch.countDown();
|
||||||
|
} else {
|
||||||
|
schedulePoll(latch, predicate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunTab makeTab(boolean savedReady, Path configPath, GuiBatchRunLauncher launcher) {
|
||||||
|
GuiBatchRunLauncher effective = launcher == null
|
||||||
|
? (configPath1, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Test-Stub sollte nicht aufgerufen werden.")
|
||||||
|
: launcher;
|
||||||
|
return new GuiBatchRunTab(
|
||||||
|
() -> effective,
|
||||||
|
() -> configPath,
|
||||||
|
() -> savedReady,
|
||||||
|
() -> { /* state-change hook not needed for these tests */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound cooperative cancellation token for a running batch.
|
||||||
|
* <p>
|
||||||
|
* The application layer consults the token at safe points between candidates to decide
|
||||||
|
* whether the run should stop before starting the next candidate. The current candidate
|
||||||
|
* is always processed to completion before the token is honoured (soft-stop semantics).
|
||||||
|
* <p>
|
||||||
|
* Implementations are typically shared between an inbound adapter (which sets the
|
||||||
|
* cancellation request) and the use case (which polls it). They must be safe to read from
|
||||||
|
* the batch thread while being written concurrently by the adapter thread.
|
||||||
|
*
|
||||||
|
* <h2>Default implementation</h2>
|
||||||
|
* <p>
|
||||||
|
* Callers that do not need cancellation (e.g. the headless batch entry point) supply
|
||||||
|
* {@link #neverCancelled()} as the token.
|
||||||
|
*/
|
||||||
|
public interface BatchRunCancellationToken {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} if a cancellation has been requested and the batch should
|
||||||
|
* stop before starting the next candidate.
|
||||||
|
* <p>
|
||||||
|
* Must be cheap to call; may be polled repeatedly during a run.
|
||||||
|
*
|
||||||
|
* @return {@code true} if the run should stop as soon as practical, {@code false}
|
||||||
|
* otherwise
|
||||||
|
*/
|
||||||
|
boolean isCancellationRequested();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a singleton token that never reports a cancellation request.
|
||||||
|
*
|
||||||
|
* @return a non-null token that always returns {@code false}
|
||||||
|
*/
|
||||||
|
static BatchRunCancellationToken neverCancelled() {
|
||||||
|
return NeverCancelledBatchRunCancellationToken.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound observer port that receives progress callbacks over the life of a single
|
||||||
|
* batch run.
|
||||||
|
* <p>
|
||||||
|
* The observer is an optional collaborator that an inbound adapter (e.g. a GUI) may
|
||||||
|
* supply to follow a batch run in near real time. The callbacks never carry persistence
|
||||||
|
* details; they only describe observable events at the use-case boundary.
|
||||||
|
*
|
||||||
|
* <h2>Invocation order</h2>
|
||||||
|
* <p>
|
||||||
|
* For a single run the observer is invoked in this order:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #onRunStarted(RunId, int)} exactly once, once the total candidate count
|
||||||
|
* is known (i.e. after the source folder scan succeeded and before the first
|
||||||
|
* candidate is processed).</li>
|
||||||
|
* <li>{@link #onDocumentCompleted(DocumentCompletionEvent)} once per candidate whose
|
||||||
|
* processing reached a terminal resolution.</li>
|
||||||
|
* <li>{@link #onRunEnded(RunSummary)} exactly once after the processing loop has
|
||||||
|
* finished (normally, after a cancellation, or after a hard run-level error).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Callbacks are invoked on the thread executing the batch run. Inbound adapters that
|
||||||
|
* drive a UI must themselves dispatch any UI updates onto the appropriate UI thread
|
||||||
|
* and must not block the reporting thread.
|
||||||
|
*
|
||||||
|
* <h2>Exception handling</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must not throw checked exceptions. Runtime exceptions thrown by an
|
||||||
|
* observer are caught by the application layer and logged; they never affect the batch
|
||||||
|
* run outcome or alter persistence behaviour.
|
||||||
|
*/
|
||||||
|
public interface BatchRunProgressObserver {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once when the run has determined how many candidates will be processed.
|
||||||
|
*
|
||||||
|
* @param runId identifier of the run; never {@code null}
|
||||||
|
* @param totalCandidates total number of candidates detected in the source folder
|
||||||
|
* at scan time; never negative
|
||||||
|
*/
|
||||||
|
void onRunStarted(RunId runId, int totalCandidates);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once per candidate whose processing reached a terminal resolution.
|
||||||
|
* <p>
|
||||||
|
* The event is emitted after persistence has been attempted for the candidate, so
|
||||||
|
* observers may rely on the reported status matching the persisted attempt status
|
||||||
|
* for that candidate.
|
||||||
|
*
|
||||||
|
* @param event description of the candidate result; never {@code null}
|
||||||
|
*/
|
||||||
|
void onDocumentCompleted(DocumentCompletionEvent event);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once after the processing loop has finished, regardless of whether the
|
||||||
|
* run completed normally, was cancelled via a {@link BatchRunCancellationToken},
|
||||||
|
* or aborted due to a hard run-level error after the start callback fired.
|
||||||
|
*
|
||||||
|
* @param summary aggregated outcome counts; never {@code null}
|
||||||
|
*/
|
||||||
|
void onRunEnded(RunSummary summary);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a singleton observer that silently ignores all callbacks.
|
||||||
|
* <p>
|
||||||
|
* Used as the default observer for callers that do not need progress notifications
|
||||||
|
* (e.g. the headless batch entry point).
|
||||||
|
*
|
||||||
|
* @return a non-null no-op observer
|
||||||
|
*/
|
||||||
|
static BatchRunProgressObserver noOp() {
|
||||||
|
return NoOpBatchRunProgressObserver.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable event describing the outcome of processing exactly one candidate document.
|
||||||
|
* <p>
|
||||||
|
* Emitted by the application layer at every terminal resolution point of a candidate
|
||||||
|
* (success, retryable failure, permanent failure, skip). Observers may use this event
|
||||||
|
* to update a live progress view, write an audit record, or drive a UI list.
|
||||||
|
* <p>
|
||||||
|
* The event is deliberately decoupled from persistence types: it carries only what an
|
||||||
|
* external observer needs to display or correlate a single candidate result.
|
||||||
|
*
|
||||||
|
* @param originalFileName the source candidate's unique identifier (typically the source
|
||||||
|
* filename); never {@code null} or blank
|
||||||
|
* @param status the aggregated outcome status; never {@code null}
|
||||||
|
* @param finalFileName the final target filename, including any duplicate suffix;
|
||||||
|
* never {@code null} for {@link DocumentCompletionStatus#SUCCESS},
|
||||||
|
* always {@code null} for all other statuses
|
||||||
|
* @param resolvedDate the resolved date of the naming proposal; never {@code null}
|
||||||
|
* for {@link DocumentCompletionStatus#SUCCESS}, always {@code null}
|
||||||
|
* for skip events. May be {@code null} for failure events.
|
||||||
|
* @param aiReasoning the AI reasoning text associated with the naming proposal, if
|
||||||
|
* any is available for this candidate (may be present on success
|
||||||
|
* and on some failure paths where an AI call had previously
|
||||||
|
* produced a reasoning); {@code null} when no reasoning exists
|
||||||
|
* @param processingDuration the wall-clock duration spent on this candidate in the current
|
||||||
|
* run; never {@code null} and never negative
|
||||||
|
*/
|
||||||
|
public record DocumentCompletionEvent(
|
||||||
|
String originalFileName,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
String finalFileName,
|
||||||
|
LocalDate resolvedDate,
|
||||||
|
String aiReasoning,
|
||||||
|
Duration processingDuration) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor validating mandatory fields.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException if {@code originalFileName}, {@code status} or
|
||||||
|
* {@code processingDuration} is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||||
|
* {@code processingDuration} is negative
|
||||||
|
*/
|
||||||
|
public DocumentCompletionEvent {
|
||||||
|
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
|
||||||
|
if (originalFileName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
|
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||||
|
if (processingDuration.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("processingDuration must not be negative");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated status classification reported to
|
||||||
|
* {@link BatchRunProgressObserver#onDocumentCompleted(DocumentCompletionEvent)}
|
||||||
|
* for one processed candidate.
|
||||||
|
* <p>
|
||||||
|
* This enum collapses the finer-grained internal processing status into the four
|
||||||
|
* buckets that an observer (e.g. a GUI progress view) needs to distinguish:
|
||||||
|
* successful completion, retryable failure, permanent failure, and an explicit
|
||||||
|
* skip.
|
||||||
|
* <p>
|
||||||
|
* This classification is purely an observability concern — persistence,
|
||||||
|
* retry decisions, and all other processing rules continue to work against the
|
||||||
|
* detailed internal status.
|
||||||
|
*/
|
||||||
|
public enum DocumentCompletionStatus {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate was successfully renamed; the target copy is in place and the
|
||||||
|
* persistence is consistent.
|
||||||
|
*/
|
||||||
|
SUCCESS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate failed in the current run but will be retried in a later run
|
||||||
|
* (transient technical error, not yet at the retry limit, or a first deterministic
|
||||||
|
* content error).
|
||||||
|
*/
|
||||||
|
FAILED_RETRYABLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate failed permanently and will not be retried in later runs
|
||||||
|
* (content error recorded twice, or transient retry budget exhausted).
|
||||||
|
*/
|
||||||
|
FAILED_PERMANENT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate was skipped because it was already in a terminal state (either
|
||||||
|
* previously successful or previously finally failed).
|
||||||
|
*/
|
||||||
|
SKIPPED
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared singleton token returned by
|
||||||
|
* {@link BatchRunCancellationToken#neverCancelled()}.
|
||||||
|
* <p>
|
||||||
|
* Not intended for direct instantiation by callers.
|
||||||
|
*/
|
||||||
|
final class NeverCancelledBatchRunCancellationToken implements BatchRunCancellationToken {
|
||||||
|
|
||||||
|
static final NeverCancelledBatchRunCancellationToken INSTANCE =
|
||||||
|
new NeverCancelledBatchRunCancellationToken();
|
||||||
|
|
||||||
|
private NeverCancelledBatchRunCancellationToken() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCancellationRequested() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared singleton no-op implementation of {@link BatchRunProgressObserver}.
|
||||||
|
* <p>
|
||||||
|
* Returned by {@link BatchRunProgressObserver#noOp()}; not intended for direct
|
||||||
|
* instantiation by callers.
|
||||||
|
*/
|
||||||
|
final class NoOpBatchRunProgressObserver implements BatchRunProgressObserver {
|
||||||
|
|
||||||
|
static final NoOpBatchRunProgressObserver INSTANCE = new NoOpBatchRunProgressObserver();
|
||||||
|
|
||||||
|
private NoOpBatchRunProgressObserver() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRunEnded(RunSummary summary) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated outcome counts of a complete batch run, reported once at the end of the run
|
||||||
|
* to {@link BatchRunProgressObserver#onRunEnded(RunSummary)}.
|
||||||
|
* <p>
|
||||||
|
* The three counts are independent, non-negative and sum up to the total number of
|
||||||
|
* candidates that were processed in the run (possibly fewer than the originally detected
|
||||||
|
* candidate count if the run was cancelled mid-way).
|
||||||
|
*
|
||||||
|
* @param successCount number of candidates that completed with
|
||||||
|
* {@link DocumentCompletionStatus#SUCCESS}; must be ≥ 0
|
||||||
|
* @param failedCount number of candidates that completed with either
|
||||||
|
* {@link DocumentCompletionStatus#FAILED_RETRYABLE} or
|
||||||
|
* {@link DocumentCompletionStatus#FAILED_PERMANENT}; must be ≥ 0
|
||||||
|
* @param skippedCount number of candidates that completed with
|
||||||
|
* {@link DocumentCompletionStatus#SKIPPED}; must be ≥ 0
|
||||||
|
*/
|
||||||
|
public record RunSummary(int successCount, int failedCount, int skippedCount) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor enforcing non-negative counts.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if any count is negative
|
||||||
|
*/
|
||||||
|
public RunSummary {
|
||||||
|
if (successCount < 0 || failedCount < 0 || skippedCount < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"RunSummary counts must not be negative; was: "
|
||||||
|
+ successCount + "/" + failedCount + "/" + skippedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of candidates reflected in this summary.
|
||||||
|
*
|
||||||
|
* @return {@code successCount + failedCount + skippedCount}; never negative
|
||||||
|
*/
|
||||||
|
public int totalProcessed() {
|
||||||
|
return successCount + failedCount + skippedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -16,6 +16,18 @@
|
|||||||
* — Structured result of a batch run, designed for exit code mapping</li>
|
* — Structured result of a batch run, designed for exit code mapping</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
* Progress observation (for interactive inbound adapters):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||||
|
* — Optional observer that receives per-run and per-candidate callbacks during a run</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken}
|
||||||
|
* — Optional cooperative cancellation token polled between candidates</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.RunSummary}
|
||||||
|
* — Event and summary value types carried to the observer</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
|
* Architecture Rule: Inbound ports are independent of implementation and contain no business logic.
|
||||||
* They define "what can be done to the application". All dependencies point inward;
|
* They define "what can be done to the application". All dependencies point inward;
|
||||||
* adapters depend on ports, not vice versa.
|
* adapters depend on ports, not vice versa.
|
||||||
|
|||||||
+159
-4
@@ -1,10 +1,14 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.service;
|
package de.gecheckt.pdf.umbenenner.application.service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
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.out.DocumentKnownProcessable;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
|
||||||
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;
|
||||||
@@ -158,6 +162,20 @@ public class DocumentProcessingCoordinator {
|
|||||||
private final int maxTitleLength;
|
private final int maxTitleLength;
|
||||||
private final String activeProviderIdentifier;
|
private final String activeProviderIdentifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-run completion forwarder that is consulted by
|
||||||
|
* {@link #publishCompletion(SourceDocumentCandidate, DocumentCompletionStatus, String,
|
||||||
|
* LocalDate, String, Instant, Instant)} whenever a terminal candidate outcome is reached.
|
||||||
|
* <p>
|
||||||
|
* 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
|
||||||
|
* completion event is dropped silently — the default for headless callers.
|
||||||
|
* <p>
|
||||||
|
* Accessed from the batch thread only. Not volatile because installation and read occur
|
||||||
|
* on the same thread (the one executing the batch).
|
||||||
|
*/
|
||||||
|
private Consumer<DocumentCompletionEvent> completionForwarder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the document processing coordinator with all required ports, logger,
|
* Creates the document processing coordinator with all required ports, logger,
|
||||||
* the transient retry limit, the configured maximum base title length, and the
|
* the transient retry limit, the configured maximum base title length, and the
|
||||||
@@ -235,6 +253,25 @@ public class DocumentProcessingCoordinator {
|
|||||||
this.maxRetriesTransient = maxRetriesTransient;
|
this.maxRetriesTransient = maxRetriesTransient;
|
||||||
this.maxTitleLength = maxTitleLength;
|
this.maxTitleLength = maxTitleLength;
|
||||||
this.activeProviderIdentifier = activeProviderIdentifier;
|
this.activeProviderIdentifier = activeProviderIdentifier;
|
||||||
|
this.completionForwarder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs or removes a per-run completion forwarder.
|
||||||
|
* <p>
|
||||||
|
* When non-null, the forwarder is consulted at every terminal candidate resolution and
|
||||||
|
* receives a {@link DocumentCompletionEvent} describing the outcome. A {@code null} value
|
||||||
|
* detaches any previously installed forwarder.
|
||||||
|
* <p>
|
||||||
|
* This method is the single seam by which inbound adapters (e.g. the JavaFX GUI) attach a
|
||||||
|
* live-progress observer to the document coordinator without widening the coordinator's
|
||||||
|
* constructor surface. It must only be called from the thread that will drive the batch
|
||||||
|
* run.
|
||||||
|
*
|
||||||
|
* @param forwarder the new forwarder, or {@code null} to detach the current one
|
||||||
|
*/
|
||||||
|
public void installCompletionForwarder(Consumer<DocumentCompletionEvent> forwarder) {
|
||||||
|
this.completionForwarder = forwarder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -509,7 +546,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
|
|
||||||
return persistTargetCopySuccess(
|
return persistTargetCopySuccess(
|
||||||
candidate, fingerprint, existingRecord, context, attemptStart, now,
|
candidate, fingerprint, existingRecord, context, attemptStart, now,
|
||||||
resolvedFilename, targetFolderLocator);
|
resolvedFilename, targetFolderLocator, proposalAttempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -518,7 +555,16 @@ public class DocumentProcessingCoordinator {
|
|||||||
* If the atomic persistence fails after the copy has already been written, a
|
* If the atomic persistence fails after the copy has already been written, a
|
||||||
* best-effort rollback of the target file is attempted and
|
* best-effort rollback of the target file is attempted and
|
||||||
* {@link ProcessingStatus#FAILED_RETRYABLE} is persisted instead.
|
* {@link ProcessingStatus#FAILED_RETRYABLE} is persisted instead.
|
||||||
|
* <p>
|
||||||
|
* On successful persistence, a terminal completion event is published to the attached
|
||||||
|
* {@link BatchRunProgressObserver}; the event carries the resolved final filename,
|
||||||
|
* the date and reasoning taken from the authoritative {@code PROPOSAL_READY} attempt.
|
||||||
|
* On persistence failure the completion event is published by
|
||||||
|
* {@link #persistTransientErrorAfterPersistenceFailure}.
|
||||||
*
|
*
|
||||||
|
* @param proposalAttempt the authoritative naming-proposal attempt used to populate
|
||||||
|
* the observer event's date and reasoning fields; must not be
|
||||||
|
* {@code null}
|
||||||
* @return true if SUCCESS was persisted; false if persistence itself failed
|
* @return true if SUCCESS was persisted; false if persistence itself failed
|
||||||
*/
|
*/
|
||||||
private boolean persistTargetCopySuccess(
|
private boolean persistTargetCopySuccess(
|
||||||
@@ -529,7 +575,8 @@ public class DocumentProcessingCoordinator {
|
|||||||
Instant attemptStart,
|
Instant attemptStart,
|
||||||
Instant now,
|
Instant now,
|
||||||
String resolvedFilename,
|
String resolvedFilename,
|
||||||
String targetFolderLocator) {
|
String targetFolderLocator,
|
||||||
|
ProcessingAttempt proposalAttempt) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
||||||
@@ -550,6 +597,11 @@ 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,
|
||||||
|
resolvedFilename,
|
||||||
|
proposalAttempt.resolvedDate(),
|
||||||
|
proposalAttempt.aiReasoning(),
|
||||||
|
attemptStart, now);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (DocumentPersistenceException e) {
|
} catch (DocumentPersistenceException e) {
|
||||||
@@ -564,7 +616,8 @@ public class DocumentProcessingCoordinator {
|
|||||||
candidate, fingerprint, existingRecord, context, attemptStart,
|
candidate, fingerprint, existingRecord, context, attemptStart,
|
||||||
Instant.now(),
|
Instant.now(),
|
||||||
"Persistence failed after successful target copy (best-effort rollback attempted): "
|
"Persistence failed after successful target copy (best-effort rollback attempted): "
|
||||||
+ e.getMessage());
|
+ e.getMessage(),
|
||||||
|
proposalAttempt);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +681,10 @@ public class DocumentProcessingCoordinator {
|
|||||||
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
candidate.uniqueIdentifier(), fingerprint.sha256Hex(),
|
||||||
updatedCounters.transientErrorCount(), maxRetriesTransient);
|
updatedCounters.transientErrorCount(), maxRetriesTransient);
|
||||||
}
|
}
|
||||||
|
publishCompletion(candidate,
|
||||||
|
retryable ? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||||
|
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
null, null, null, attemptStart, now);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (DocumentPersistenceException persistEx) {
|
} catch (DocumentPersistenceException persistEx) {
|
||||||
@@ -654,7 +711,8 @@ public class DocumentProcessingCoordinator {
|
|||||||
BatchRunContext context,
|
BatchRunContext context,
|
||||||
Instant attemptStart,
|
Instant attemptStart,
|
||||||
Instant now,
|
Instant now,
|
||||||
String errorMessage) {
|
String errorMessage,
|
||||||
|
ProcessingAttempt proposalAttempt) {
|
||||||
|
|
||||||
ProcessingOutcomeTransition.ProcessingOutcome transition =
|
ProcessingOutcomeTransition.ProcessingOutcome transition =
|
||||||
ProcessingOutcomeTransition.forKnownDocument(
|
ProcessingOutcomeTransition.forKnownDocument(
|
||||||
@@ -664,6 +722,7 @@ public class DocumentProcessingCoordinator {
|
|||||||
FailureCounters updatedCounters = transition.counters();
|
FailureCounters updatedCounters = transition.counters();
|
||||||
ProcessingStatus errorStatus = transition.overallStatus();
|
ProcessingStatus errorStatus = transition.overallStatus();
|
||||||
|
|
||||||
|
boolean secondaryPersisted = false;
|
||||||
try {
|
try {
|
||||||
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
|
||||||
ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
|
ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
|
||||||
@@ -679,11 +738,28 @@ public class DocumentProcessingCoordinator {
|
|||||||
txOps.saveProcessingAttempt(errorAttempt);
|
txOps.saveProcessingAttempt(errorAttempt);
|
||||||
txOps.updateDocumentRecord(errorRecord);
|
txOps.updateDocumentRecord(errorRecord);
|
||||||
});
|
});
|
||||||
|
secondaryPersisted = true;
|
||||||
|
|
||||||
} catch (DocumentPersistenceException secondaryEx) {
|
} catch (DocumentPersistenceException secondaryEx) {
|
||||||
logger.error("Secondary persistence failure for '{}' after target copy rollback: {}",
|
logger.error("Secondary persistence failure for '{}' after target copy rollback: {}",
|
||||||
candidate.uniqueIdentifier(), secondaryEx.getMessage(), secondaryEx);
|
candidate.uniqueIdentifier(), secondaryEx.getMessage(), secondaryEx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observer notification: even when secondary persistence itself failed the candidate's
|
||||||
|
// terminal resolution in this run is still a copy/persistence failure. Emitting a single
|
||||||
|
// completion event keeps the observer in sync with the user-visible state even though
|
||||||
|
// nothing new was persisted.
|
||||||
|
String reasoning = proposalAttempt != null ? proposalAttempt.aiReasoning() : null;
|
||||||
|
publishCompletion(candidate,
|
||||||
|
transition.retryable()
|
||||||
|
? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||||
|
: DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
null, null, reasoning, attemptStart, now);
|
||||||
|
|
||||||
|
if (!secondaryPersisted) {
|
||||||
|
logger.debug("Completion for '{}' reported without secondary persistence record.",
|
||||||
|
candidate.uniqueIdentifier());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -721,6 +797,8 @@ 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,
|
||||||
|
null, null, null, attemptStart, now);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (DocumentPersistenceException e) {
|
} catch (DocumentPersistenceException e) {
|
||||||
@@ -985,6 +1063,13 @@ public class DocumentProcessingCoordinator {
|
|||||||
outcome.counters().contentErrorCount(),
|
outcome.counters().contentErrorCount(),
|
||||||
outcome.counters().transientErrorCount());
|
outcome.counters().transientErrorCount());
|
||||||
}
|
}
|
||||||
|
// Pipeline-path terminal resolutions are reported to the progress observer.
|
||||||
|
// PROPOSAL_READY is an intermediate state; the subsequent finalisation publishes
|
||||||
|
// the actual completion event (SUCCESS or transient-error failure).
|
||||||
|
if (outcome.overallStatus() != ProcessingStatus.PROPOSAL_READY) {
|
||||||
|
publishCompletion(candidate, toCompletionStatus(outcome),
|
||||||
|
null, null, null, attemptStart, now);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (DocumentPersistenceException e) {
|
} catch (DocumentPersistenceException e) {
|
||||||
@@ -1097,4 +1182,74 @@ public class DocumentProcessingCoordinator {
|
|||||||
|
|
||||||
return base + detail;
|
return base + detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Progress observer dispatch
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a single terminal completion event for the candidate to the attached
|
||||||
|
* {@link BatchRunProgressObserver}.
|
||||||
|
* <p>
|
||||||
|
* Must be called exactly once per terminal resolution of a candidate (success, retryable
|
||||||
|
* failure, permanent failure, skip). Intermediate states such as
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} must not
|
||||||
|
* produce a completion event.
|
||||||
|
* <p>
|
||||||
|
* Any runtime exception thrown by the observer is caught and logged at warn level and must
|
||||||
|
* not affect persistence or batch flow.
|
||||||
|
*
|
||||||
|
* @param candidate the candidate being reported; 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 resolvedDate the resolved date on success; may be {@code null} otherwise
|
||||||
|
* @param aiReasoning the AI reasoning when one is available for this result;
|
||||||
|
* {@code null} otherwise
|
||||||
|
* @param startInstant the moment processing of the candidate began in this run
|
||||||
|
* @param endInstant the moment the terminal resolution was reached
|
||||||
|
*/
|
||||||
|
private void publishCompletion(
|
||||||
|
SourceDocumentCandidate candidate,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
String finalFileName,
|
||||||
|
LocalDate resolvedDate,
|
||||||
|
String aiReasoning,
|
||||||
|
Instant startInstant,
|
||||||
|
Instant endInstant) {
|
||||||
|
Consumer<DocumentCompletionEvent> forwarder = completionForwarder;
|
||||||
|
if (forwarder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Duration duration = Duration.between(startInstant, endInstant);
|
||||||
|
if (duration.isNegative()) {
|
||||||
|
duration = Duration.ZERO;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
forwarder.accept(new DocumentCompletionEvent(
|
||||||
|
candidate.uniqueIdentifier(),
|
||||||
|
status,
|
||||||
|
finalFileName,
|
||||||
|
resolvedDate,
|
||||||
|
aiReasoning,
|
||||||
|
duration));
|
||||||
|
} catch (RuntimeException forwarderFailure) {
|
||||||
|
logger.warn("Progress forwarder threw while reporting completion for '{}': {}",
|
||||||
|
candidate.uniqueIdentifier(), forwarderFailure.getMessage(), forwarderFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the aggregated retryable/terminal semantics of a pipeline-path persistence outcome
|
||||||
|
* to the observer-level {@link DocumentCompletionStatus}.
|
||||||
|
* <p>
|
||||||
|
* Callers guarantee that the outcome does not represent
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY} — that
|
||||||
|
* intermediate state is never reported as a completion.
|
||||||
|
*/
|
||||||
|
private static DocumentCompletionStatus toCompletionStatus(
|
||||||
|
ProcessingOutcomeTransition.ProcessingOutcome outcome) {
|
||||||
|
return outcome.retryable()
|
||||||
|
? DocumentCompletionStatus.FAILED_RETRYABLE
|
||||||
|
: DocumentCompletionStatus.FAILED_PERMANENT;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
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.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal per-run adapter that forwards every
|
||||||
|
* {@link DocumentCompletionEvent} emitted by the
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator}
|
||||||
|
* to the configured {@link BatchRunProgressObserver}, while accumulating outcome counts
|
||||||
|
* for the run's final {@link RunSummary}.
|
||||||
|
* <p>
|
||||||
|
* Used only by {@link DefaultBatchRunProcessingUseCase} for the lifetime of a single run.
|
||||||
|
* Not thread-safe: all invocations must occur on the batch thread.
|
||||||
|
*/
|
||||||
|
final class CountingCompletionObserver implements Consumer<DocumentCompletionEvent> {
|
||||||
|
|
||||||
|
private final BatchRunProgressObserver observer;
|
||||||
|
private final ProcessingLogger logger;
|
||||||
|
private int successCount;
|
||||||
|
private int failedCount;
|
||||||
|
private int skippedCount;
|
||||||
|
|
||||||
|
CountingCompletionObserver(BatchRunProgressObserver observer, ProcessingLogger logger) {
|
||||||
|
this.observer = Objects.requireNonNull(observer, "observer must not be null");
|
||||||
|
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accept(DocumentCompletionEvent event) {
|
||||||
|
Objects.requireNonNull(event, "event must not be null");
|
||||||
|
switch (event.status()) {
|
||||||
|
case SUCCESS -> successCount++;
|
||||||
|
case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
|
||||||
|
case SKIPPED -> skippedCount++;
|
||||||
|
default -> {
|
||||||
|
// Defensive — new status values would be a programming error.
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unexpected DocumentCompletionStatus: " + event.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
observer.onDocumentCompleted(event);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.warn("Progress observer threw on onDocumentCompleted for '{}': {}",
|
||||||
|
event.originalFileName(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSummary summary() {
|
||||||
|
return new RunSummary(successCount, failedCount, skippedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the completion status counts collected so far, including the terminal
|
||||||
|
* contribution of the candidate currently being reported.
|
||||||
|
*/
|
||||||
|
int successCount() {
|
||||||
|
return successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
int failedCount() {
|
||||||
|
return failedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
int skippedCount() {
|
||||||
|
return skippedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible for tests that verify the mapping of completion statuses to summary buckets.
|
||||||
|
*/
|
||||||
|
static RunSummary summaryOf(int successCount, int failedCount, int skippedCount) {
|
||||||
|
return new RunSummary(successCount, failedCount, skippedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook to confirm the status classification. */
|
||||||
|
static DocumentCompletionStatus classify(DocumentCompletionStatus status) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
-4
@@ -5,8 +5,13 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
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.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.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.RunSummary;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
|
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;
|
||||||
@@ -80,6 +85,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
private final DocumentProcessingCoordinator documentProcessingCoordinator;
|
private final DocumentProcessingCoordinator documentProcessingCoordinator;
|
||||||
private final AiNamingService aiNamingService;
|
private final AiNamingService aiNamingService;
|
||||||
private final ProcessingLogger logger;
|
private final ProcessingLogger logger;
|
||||||
|
private final BatchRunProgressObserver progressObserver;
|
||||||
|
private final BatchRunCancellationToken cancellationToken;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the batch use case with the runtime configuration and all required ports for the flow.
|
* Creates the batch use case with the runtime configuration and all required ports for the flow.
|
||||||
@@ -112,6 +119,46 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
DocumentProcessingCoordinator documentProcessingCoordinator,
|
DocumentProcessingCoordinator documentProcessingCoordinator,
|
||||||
AiNamingService aiNamingService,
|
AiNamingService aiNamingService,
|
||||||
ProcessingLogger logger) {
|
ProcessingLogger logger) {
|
||||||
|
this(runtimeConfiguration, runLockPort, sourceDocumentCandidatesPort, pdfTextExtractionPort,
|
||||||
|
fingerprintPort, documentProcessingCoordinator, aiNamingService, logger,
|
||||||
|
BatchRunProgressObserver.noOp(), BatchRunCancellationToken.neverCancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the batch use case with a progress observer and cancellation token attached.
|
||||||
|
* <p>
|
||||||
|
* The observer is invoked on the batch thread for run start, per-candidate completion,
|
||||||
|
* and run end. The cancellation token is polled between candidates; a requested
|
||||||
|
* cancellation is honoured before starting the next candidate and never interrupts a
|
||||||
|
* candidate that is already being processed (soft-stop).
|
||||||
|
*
|
||||||
|
* @param runtimeConfiguration the runtime configuration; must not be null
|
||||||
|
* @param runLockPort run-lock port; must not be null
|
||||||
|
* @param sourceDocumentCandidatesPort candidate source port; must not be null
|
||||||
|
* @param pdfTextExtractionPort PDF text extraction port; must not be null
|
||||||
|
* @param fingerprintPort fingerprint port; must not be null
|
||||||
|
* @param documentProcessingCoordinator per-document coordinator; must not be null
|
||||||
|
* @param aiNamingService AI naming service; must not be null
|
||||||
|
* @param logger logger; must not be null
|
||||||
|
* @param progressObserver progress observer; must not be null, use
|
||||||
|
* {@link BatchRunProgressObserver#noOp()} when none is
|
||||||
|
* needed
|
||||||
|
* @param cancellationToken cancellation token; must not be null, use
|
||||||
|
* {@link BatchRunCancellationToken#neverCancelled()}
|
||||||
|
* when cancellation is not needed
|
||||||
|
* @throws NullPointerException if any parameter is null
|
||||||
|
*/
|
||||||
|
public DefaultBatchRunProcessingUseCase(
|
||||||
|
RuntimeConfiguration runtimeConfiguration,
|
||||||
|
RunLockPort runLockPort,
|
||||||
|
SourceDocumentCandidatesPort sourceDocumentCandidatesPort,
|
||||||
|
PdfTextExtractionPort pdfTextExtractionPort,
|
||||||
|
FingerprintPort fingerprintPort,
|
||||||
|
DocumentProcessingCoordinator documentProcessingCoordinator,
|
||||||
|
AiNamingService aiNamingService,
|
||||||
|
ProcessingLogger logger,
|
||||||
|
BatchRunProgressObserver progressObserver,
|
||||||
|
BatchRunCancellationToken cancellationToken) {
|
||||||
this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null");
|
this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null");
|
||||||
this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null");
|
this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null");
|
||||||
this.sourceDocumentCandidatesPort = Objects.requireNonNull(
|
this.sourceDocumentCandidatesPort = Objects.requireNonNull(
|
||||||
@@ -123,6 +170,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
documentProcessingCoordinator, "documentProcessingCoordinator must not be null");
|
documentProcessingCoordinator, "documentProcessingCoordinator must not be null");
|
||||||
this.aiNamingService = Objects.requireNonNull(aiNamingService, "aiNamingService must not be null");
|
this.aiNamingService = Objects.requireNonNull(aiNamingService, "aiNamingService must not be null");
|
||||||
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
this.logger = Objects.requireNonNull(logger, "logger must not be null");
|
||||||
|
this.progressObserver = Objects.requireNonNull(progressObserver, "progressObserver must not be null");
|
||||||
|
this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -183,16 +232,60 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
|
|||||||
}
|
}
|
||||||
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
|
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
|
||||||
|
|
||||||
for (SourceDocumentCandidate candidate : candidates) {
|
// Notify observer of the known candidate count up-front so observers can size their
|
||||||
processCandidate(candidate, context);
|
// progress bars. The count reflects the source folder at scan time and remains fixed
|
||||||
|
// for the remainder of the run (also when the run is cancelled early).
|
||||||
|
try {
|
||||||
|
progressObserver.onRunStarted(context.runId(), candidates.size());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.warn("Progress observer threw on onRunStarted: {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Batch run completed. Processed {} candidate(s). RunId: {}",
|
// Wrap the user-supplied observer so the per-run summary can be computed by counting
|
||||||
candidates.size(), context.runId());
|
// forwarded completion events.
|
||||||
|
CountingCompletionObserver forwardingObserver =
|
||||||
|
new CountingCompletionObserver(progressObserver, logger);
|
||||||
|
documentProcessingCoordinator.installCompletionForwarder(forwardingObserver);
|
||||||
|
try {
|
||||||
|
int processedCount = 0;
|
||||||
|
boolean cancelled = false;
|
||||||
|
for (SourceDocumentCandidate candidate : candidates) {
|
||||||
|
if (cancellationTokenRequested()) {
|
||||||
|
cancelled = true;
|
||||||
|
logger.info("Cancellation requested before processing next candidate. "
|
||||||
|
+ "Stopping batch run. RunId: {}, processed {}/{} candidate(s).",
|
||||||
|
context.runId(), processedCount, candidates.size());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
processCandidate(candidate, context);
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Batch run {}. Processed {} candidate(s). RunId: {}",
|
||||||
|
cancelled ? "cancelled" : "completed",
|
||||||
|
processedCount, context.runId());
|
||||||
|
} finally {
|
||||||
|
documentProcessingCoordinator.installCompletionForwarder(null);
|
||||||
|
try {
|
||||||
|
progressObserver.onRunEnded(forwardingObserver.summary());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.warn("Progress observer threw on onRunEnded: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return BatchRunOutcome.SUCCESS;
|
return BatchRunOutcome.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean cancellationTokenRequested() {
|
||||||
|
try {
|
||||||
|
return cancellationToken.isCancellationRequested();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.warn("Cancellation token threw while being polled; treating as not cancelled: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases the run lock if it was previously acquired.
|
* Releases the run lock if it was previously acquired.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
+497
@@ -0,0 +1,497 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
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.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
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.BatchRunOutcome;
|
||||||
|
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.RunSummary;
|
||||||
|
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.ClockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentUnknown;
|
||||||
|
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.FingerprintSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
|
||||||
|
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.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focused tests for the progress observer and cancellation token behaviour wired into
|
||||||
|
* {@link DefaultBatchRunProcessingUseCase} and forwarded to
|
||||||
|
* {@link DocumentProcessingCoordinator}.
|
||||||
|
*/
|
||||||
|
class BatchRunProgressObservationTest {
|
||||||
|
|
||||||
|
private static final int TEST_MAX_TITLE = 60;
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Value object invariants
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void documentCompletionEvent_rejectsBlankFilename() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||||
|
" ", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void documentCompletionEvent_rejectsNegativeDuration() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new DocumentCompletionEvent(
|
||||||
|
"x.pdf", DocumentCompletionStatus.SUCCESS, null, null, null,
|
||||||
|
Duration.ofSeconds(-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void documentCompletionEvent_carriesOptionalFields() {
|
||||||
|
DocumentCompletionEvent event = new DocumentCompletionEvent(
|
||||||
|
"x.pdf", DocumentCompletionStatus.SUCCESS, "2026-03-01 - Titel.pdf",
|
||||||
|
LocalDate.of(2026, 3, 1), "weil wichtig", Duration.ofMillis(123));
|
||||||
|
|
||||||
|
assertEquals("x.pdf", event.originalFileName());
|
||||||
|
assertEquals(DocumentCompletionStatus.SUCCESS, event.status());
|
||||||
|
assertEquals("2026-03-01 - Titel.pdf", event.finalFileName());
|
||||||
|
assertEquals(LocalDate.of(2026, 3, 1), event.resolvedDate());
|
||||||
|
assertEquals("weil wichtig", event.aiReasoning());
|
||||||
|
assertEquals(Duration.ofMillis(123), event.processingDuration());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSummary_rejectsNegativeCounts() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RunSummary(-1, 0, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, -1, 0));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RunSummary(0, 0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSummary_totalProcessedSumsCounts() {
|
||||||
|
RunSummary summary = new RunSummary(2, 3, 4);
|
||||||
|
assertEquals(9, summary.totalProcessed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noOpObserver_isSingletonAndSilent() {
|
||||||
|
BatchRunProgressObserver a = BatchRunProgressObserver.noOp();
|
||||||
|
BatchRunProgressObserver b = BatchRunProgressObserver.noOp();
|
||||||
|
assertSame(a, b);
|
||||||
|
a.onRunStarted(new RunId("r-1"), 5);
|
||||||
|
a.onDocumentCompleted(new DocumentCompletionEvent(
|
||||||
|
"x.pdf", DocumentCompletionStatus.SKIPPED, null, null, null, Duration.ZERO));
|
||||||
|
a.onRunEnded(new RunSummary(0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neverCancelledToken_isSingletonAndAlwaysFalse() {
|
||||||
|
BatchRunCancellationToken a = BatchRunCancellationToken.neverCancelled();
|
||||||
|
BatchRunCancellationToken b = BatchRunCancellationToken.neverCancelled();
|
||||||
|
assertSame(a, b);
|
||||||
|
assertFalse(a.isCancellationRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Use case lifecycle callbacks
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void useCase_emitsRunStartedAndEndedForEmptyFolder() {
|
||||||
|
RecordingObserver observer = new RecordingObserver();
|
||||||
|
CapturingCoordinator coordinator = new CapturingCoordinator();
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
new NoOpLock(), new EmptyCandidatesPort(), coordinator, observer,
|
||||||
|
BatchRunCancellationToken.neverCancelled());
|
||||||
|
|
||||||
|
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("empty"), Instant.now()));
|
||||||
|
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
assertEquals(List.of("started:0", "ended:0/0/0"), observer.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void useCase_forwardsCoordinatorCompletionEventsAndCountsThem() {
|
||||||
|
RecordingObserver observer = new RecordingObserver();
|
||||||
|
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
DocumentCompletionStatus.SKIPPED));
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
new NoOpLock(), new FixedCandidatesPort(
|
||||||
|
makeCandidate("a.pdf"),
|
||||||
|
makeCandidate("b.pdf"),
|
||||||
|
makeCandidate("c.pdf"),
|
||||||
|
makeCandidate("d.pdf")),
|
||||||
|
coordinator, observer, BatchRunCancellationToken.neverCancelled());
|
||||||
|
|
||||||
|
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("mixed"), Instant.now()));
|
||||||
|
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
// Observer must see exactly one onStarted, 4 completed events, and one onEnded.
|
||||||
|
assertEquals(6, observer.events.size(), () -> observer.events.toString());
|
||||||
|
assertEquals("started:4", observer.events.get(0));
|
||||||
|
assertEquals("ended:1/2/1", observer.events.get(observer.events.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void useCase_stopsBeforeNextCandidateWhenCancellationRequested() {
|
||||||
|
RecordingObserver observer = new RecordingObserver();
|
||||||
|
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS,
|
||||||
|
DocumentCompletionStatus.SUCCESS,
|
||||||
|
DocumentCompletionStatus.SUCCESS));
|
||||||
|
ToggleCancellationToken cancellation = new ToggleCancellationToken();
|
||||||
|
// Cancel after the first candidate has been processed.
|
||||||
|
coordinator.onBeforeReturn = () -> {
|
||||||
|
if (coordinator.invocations() == 1) {
|
||||||
|
cancellation.request();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
new NoOpLock(), new FixedCandidatesPort(
|
||||||
|
makeCandidate("a.pdf"),
|
||||||
|
makeCandidate("b.pdf"),
|
||||||
|
makeCandidate("c.pdf")),
|
||||||
|
coordinator, observer, cancellation);
|
||||||
|
|
||||||
|
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("cancel"), Instant.now()));
|
||||||
|
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
// Only the first candidate ran — cancellation is polled before the second.
|
||||||
|
assertEquals(1, coordinator.invocations());
|
||||||
|
// Observer saw the start, one completion, and the run end with (1,0,0).
|
||||||
|
assertEquals(List.of("started:3", "completed:SUCCESS:a.pdf", "ended:1/0/0"),
|
||||||
|
observer.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void useCase_isSafeWhenObserverThrowsFromCallbacks() {
|
||||||
|
ThrowingObserver observer = new ThrowingObserver();
|
||||||
|
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS));
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
|
||||||
|
coordinator, observer, BatchRunCancellationToken.neverCancelled());
|
||||||
|
|
||||||
|
// Must not bubble up — thrown runtime exceptions are isolated.
|
||||||
|
BatchRunOutcome outcome = useCase.execute(new BatchRunContext(new RunId("throw"), Instant.now()));
|
||||||
|
assertTrue(outcome.isSuccess());
|
||||||
|
assertTrue(observer.startedThrown.get(),
|
||||||
|
"onRunStarted was invoked even though it threw");
|
||||||
|
assertTrue(observer.completedThrown.get(),
|
||||||
|
"onDocumentCompleted was invoked even though it threw");
|
||||||
|
assertTrue(observer.endedThrown.get(),
|
||||||
|
"onRunEnded was invoked even though it threw");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void useCase_removesForwarderAfterRun() {
|
||||||
|
RecordingObserver observer = new RecordingObserver();
|
||||||
|
PublishingCoordinator coordinator = new PublishingCoordinator(List.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS));
|
||||||
|
DefaultBatchRunProcessingUseCase useCase = buildUseCase(
|
||||||
|
new NoOpLock(), new FixedCandidatesPort(makeCandidate("x.pdf")),
|
||||||
|
coordinator, observer, BatchRunCancellationToken.neverCancelled());
|
||||||
|
|
||||||
|
useCase.execute(new BatchRunContext(new RunId("clean"), Instant.now()));
|
||||||
|
|
||||||
|
assertNull(coordinator.currentForwarder(),
|
||||||
|
"Forwarder must be removed after the run to avoid leaking observers across runs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers / stubs
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private DefaultBatchRunProcessingUseCase buildUseCase(
|
||||||
|
RunLockPort lock,
|
||||||
|
SourceDocumentCandidatesPort candidates,
|
||||||
|
DocumentProcessingCoordinator coordinator,
|
||||||
|
BatchRunProgressObserver observer,
|
||||||
|
BatchRunCancellationToken token) {
|
||||||
|
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
||||||
|
3, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT);
|
||||||
|
return new DefaultBatchRunProcessingUseCase(
|
||||||
|
runtimeConfig,
|
||||||
|
lock,
|
||||||
|
candidates,
|
||||||
|
new PassThroughExtractionPort(),
|
||||||
|
new AlwaysSuccessFingerprintPort(),
|
||||||
|
coordinator,
|
||||||
|
buildStubAiNamingService(),
|
||||||
|
new SilentLogger(),
|
||||||
|
observer,
|
||||||
|
token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AiNamingService buildStubAiNamingService() {
|
||||||
|
AiInvocationPort stubAi = req -> {
|
||||||
|
throw new IllegalStateException("AI must not be invoked in these tests");
|
||||||
|
};
|
||||||
|
PromptPort stubPrompt = () -> new PromptLoadingSuccess(
|
||||||
|
new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
|
||||||
|
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
|
||||||
|
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
|
||||||
|
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SourceDocumentCandidate makeCandidate(String filename) {
|
||||||
|
return new SourceDocumentCandidate(filename, 1024L,
|
||||||
|
new SourceDocumentLocator("/tmp/" + filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoOpLock implements RunLockPort {
|
||||||
|
@Override public void acquire() { }
|
||||||
|
@Override public void release() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
|
||||||
|
@Override public List<SourceDocumentCandidate> loadCandidates() { return List.of(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FixedCandidatesPort implements SourceDocumentCandidatesPort {
|
||||||
|
private final List<SourceDocumentCandidate> all;
|
||||||
|
FixedCandidatesPort(SourceDocumentCandidate... items) { this.all = List.of(items); }
|
||||||
|
@Override public List<SourceDocumentCandidate> loadCandidates() { return all; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PassThroughExtractionPort implements PdfTextExtractionPort {
|
||||||
|
@Override
|
||||||
|
public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
|
||||||
|
return new PdfExtractionContentError("nicht relevant für den Beobachter-Test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AlwaysSuccessFingerprintPort implements FingerprintPort {
|
||||||
|
@Override
|
||||||
|
public FingerprintResult computeFingerprint(SourceDocumentCandidate candidate) {
|
||||||
|
String hex = String.format("%064x",
|
||||||
|
Math.abs((long) candidate.uniqueIdentifier().hashCode()));
|
||||||
|
return new FingerprintSuccess(new DocumentFingerprint(hex.substring(0, 64)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SilentLogger implements ProcessingLogger {
|
||||||
|
@Override public void info(String message, Object... args) { }
|
||||||
|
@Override public void warn(String message, Object... args) { }
|
||||||
|
@Override public void error(String message, Object... args) { }
|
||||||
|
@Override public void debug(String message, Object... args) { }
|
||||||
|
@Override public void debugSensitiveAiContent(String message, Object... args) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingObserver implements BatchRunProgressObserver {
|
||||||
|
final List<String> events = new ArrayList<>();
|
||||||
|
@Override
|
||||||
|
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
|
events.add("started:" + totalCandidates);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||||
|
events.add("completed:" + event.status() + ":" + event.originalFileName());
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onRunEnded(RunSummary summary) {
|
||||||
|
events.add("ended:" + summary.successCount() + "/"
|
||||||
|
+ summary.failedCount() + "/" + summary.skippedCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ThrowingObserver implements BatchRunProgressObserver {
|
||||||
|
final java.util.concurrent.atomic.AtomicBoolean startedThrown = new java.util.concurrent.atomic.AtomicBoolean();
|
||||||
|
final java.util.concurrent.atomic.AtomicBoolean completedThrown = new java.util.concurrent.atomic.AtomicBoolean();
|
||||||
|
final java.util.concurrent.atomic.AtomicBoolean endedThrown = new java.util.concurrent.atomic.AtomicBoolean();
|
||||||
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
|
startedThrown.set(true);
|
||||||
|
throw new IllegalStateException("boom-started");
|
||||||
|
}
|
||||||
|
@Override public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||||
|
completedThrown.set(true);
|
||||||
|
throw new IllegalStateException("boom-completed");
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary) {
|
||||||
|
endedThrown.set(true);
|
||||||
|
throw new IllegalStateException("boom-ended");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ToggleCancellationToken implements BatchRunCancellationToken {
|
||||||
|
private volatile boolean requested = false;
|
||||||
|
void request() { requested = true; }
|
||||||
|
@Override public boolean isCancellationRequested() { return requested; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinator stub that lets the use case install a forwarder but never publishes.
|
||||||
|
*/
|
||||||
|
private static class CapturingCoordinator extends DocumentProcessingCoordinator {
|
||||||
|
CapturingCoordinator() {
|
||||||
|
super(NoRecords.INSTANCE, NoAttempts.INSTANCE, NoUow.INSTANCE,
|
||||||
|
NoTargetFolder.INSTANCE, NoTargetCopy.INSTANCE,
|
||||||
|
new SilentLogger(), 3, TEST_MAX_TITLE, "stub-provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean processDeferredOutcome(
|
||||||
|
SourceDocumentCandidate candidate,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
BatchRunContext context,
|
||||||
|
Instant attemptStart,
|
||||||
|
java.util.function.Function<SourceDocumentCandidate,
|
||||||
|
de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
|
||||||
|
// Nothing to process — this stub does not trigger the forwarder.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinator stub that publishes a configurable sequence of completion events to the
|
||||||
|
* installed forwarder, one per candidate that the use case hands it.
|
||||||
|
*/
|
||||||
|
private static class PublishingCoordinator extends DocumentProcessingCoordinator {
|
||||||
|
private final List<DocumentCompletionStatus> statuses;
|
||||||
|
private Consumer<DocumentCompletionEvent> currentForwarder;
|
||||||
|
private final AtomicInteger invocations = new AtomicInteger();
|
||||||
|
Runnable onBeforeReturn = () -> { };
|
||||||
|
|
||||||
|
PublishingCoordinator(List<DocumentCompletionStatus> statuses) {
|
||||||
|
super(NoRecords.INSTANCE, NoAttempts.INSTANCE, NoUow.INSTANCE,
|
||||||
|
NoTargetFolder.INSTANCE, NoTargetCopy.INSTANCE,
|
||||||
|
new SilentLogger(), 3, TEST_MAX_TITLE, "stub-provider");
|
||||||
|
this.statuses = statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void installCompletionForwarder(Consumer<DocumentCompletionEvent> forwarder) {
|
||||||
|
this.currentForwarder = forwarder;
|
||||||
|
}
|
||||||
|
|
||||||
|
Consumer<DocumentCompletionEvent> currentForwarder() {
|
||||||
|
return currentForwarder;
|
||||||
|
}
|
||||||
|
|
||||||
|
int invocations() {
|
||||||
|
return invocations.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean processDeferredOutcome(
|
||||||
|
SourceDocumentCandidate candidate,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
BatchRunContext context,
|
||||||
|
Instant attemptStart,
|
||||||
|
java.util.function.Function<SourceDocumentCandidate,
|
||||||
|
de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> pipelineExecutor) {
|
||||||
|
int index = invocations.getAndIncrement();
|
||||||
|
if (index < statuses.size() && currentForwarder != null) {
|
||||||
|
currentForwarder.accept(new DocumentCompletionEvent(
|
||||||
|
candidate.uniqueIdentifier(),
|
||||||
|
statuses.get(index),
|
||||||
|
null, null, null, Duration.ofMillis(10)));
|
||||||
|
}
|
||||||
|
onBeforeReturn.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op repo/port stubs used only to satisfy the coordinator constructor. The tests
|
||||||
|
// never reach the underlying operations because PublishingCoordinator and
|
||||||
|
// CapturingCoordinator override the public entry point of the coordinator.
|
||||||
|
private static final class NoRecords implements DocumentRecordRepository {
|
||||||
|
static final NoRecords INSTANCE = new NoRecords();
|
||||||
|
@Override public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint f) {
|
||||||
|
return new DocumentUnknown();
|
||||||
|
}
|
||||||
|
@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) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoAttempts implements ProcessingAttemptRepository {
|
||||||
|
static final NoAttempts INSTANCE = new NoAttempts();
|
||||||
|
@Override public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { return 1; }
|
||||||
|
@Override public void save(ProcessingAttempt attempt) { }
|
||||||
|
@Override public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
@Override public ProcessingAttempt findLatestProposalReadyAttempt(
|
||||||
|
DocumentFingerprint fingerprint) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoUow implements UnitOfWorkPort {
|
||||||
|
static final NoUow INSTANCE = new NoUow();
|
||||||
|
@Override
|
||||||
|
public void executeInTransaction(
|
||||||
|
java.util.function.Consumer<TransactionOperations> operations) {
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"UnitOfWorkPort must not be called in BatchRunProgressObservationTest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoTargetFolder implements TargetFolderPort {
|
||||||
|
static final NoTargetFolder INSTANCE = new NoTargetFolder();
|
||||||
|
@Override public TargetFilenameResolutionResult resolveUniqueFilename(String baseFilename) {
|
||||||
|
return new ResolvedTargetFilename(baseFilename);
|
||||||
|
}
|
||||||
|
@Override public String getTargetFolderLocator() { return "/tmp/target"; }
|
||||||
|
@Override public void tryDeleteTargetFile(String filename) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoTargetCopy implements TargetFileCopyPort {
|
||||||
|
static final NoTargetCopy INSTANCE = new NoTargetCopy();
|
||||||
|
@Override public TargetFileCopyResult copyToTarget(
|
||||||
|
SourceDocumentLocator source, String resolvedFilename) {
|
||||||
|
return new TargetFileCopySuccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-55
@@ -21,6 +21,8 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
|
|||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.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.GuiBatchRunLauncher;
|
||||||
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;
|
||||||
@@ -324,61 +326,86 @@ public class BootstrapRunner {
|
|||||||
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
|
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
|
||||||
this.validatorFactory = StartConfigurationValidator::new;
|
this.validatorFactory = StartConfigurationValidator::new;
|
||||||
this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new;
|
this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new;
|
||||||
this.useCaseFactory = (startConfig, lock) -> {
|
this.useCaseFactory = (startConfig, lock) -> buildProductionBatchUseCase(
|
||||||
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
|
startConfig, lock,
|
||||||
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
|
||||||
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
|
||||||
|
|
||||||
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
|
|
||||||
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
|
|
||||||
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
|
|
||||||
|
|
||||||
String jdbcUrl = buildJdbcUrl(startConfig);
|
|
||||||
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
|
||||||
DocumentRecordRepository documentRecordRepository =
|
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
|
||||||
ProcessingAttemptRepository processingAttemptRepository =
|
|
||||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
|
||||||
UnitOfWorkPort unitOfWorkPort =
|
|
||||||
new SqliteUnitOfWorkAdapter(jdbcUrl);
|
|
||||||
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
|
|
||||||
DocumentProcessingCoordinator.class, aiContentSensitivity);
|
|
||||||
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
|
|
||||||
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
|
|
||||||
DocumentProcessingCoordinator documentProcessingCoordinator =
|
|
||||||
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
|
|
||||||
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
|
|
||||||
startConfig.maxRetriesTransient(),
|
|
||||||
startConfig.maxTitleLength(),
|
|
||||||
activeFamily.getIdentifier());
|
|
||||||
|
|
||||||
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
|
|
||||||
ClockPort clockPort = new SystemClockAdapter();
|
|
||||||
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength());
|
|
||||||
AiNamingService aiNamingService = new AiNamingService(
|
|
||||||
aiInvocationPort,
|
|
||||||
promptPort,
|
|
||||||
aiResponseValidator,
|
|
||||||
providerConfig.model(),
|
|
||||||
startConfig.maxTextCharacters(),
|
|
||||||
startConfig.maxTitleLength());
|
|
||||||
|
|
||||||
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
|
|
||||||
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
|
|
||||||
return new DefaultBatchRunProcessingUseCase(
|
|
||||||
runtimeConfig,
|
|
||||||
lock,
|
|
||||||
new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
|
|
||||||
new PdfTextExtractionPortAdapter(),
|
|
||||||
fingerprintPort,
|
|
||||||
documentProcessingCoordinator,
|
|
||||||
aiNamingService,
|
|
||||||
useCaseLogger);
|
|
||||||
};
|
|
||||||
this.commandFactory = SchedulerBatchCommand::new;
|
this.commandFactory = SchedulerBatchCommand::new;
|
||||||
this.guiAdapterFactory = GuiAdapter::new;
|
this.guiAdapterFactory = GuiAdapter::new;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the production batch-processing use case with the supplied progress observer and
|
||||||
|
* cancellation token.
|
||||||
|
* <p>
|
||||||
|
* Shared wiring for the headless path (where observer and token default to no-ops) and the
|
||||||
|
* GUI processing-run path (where both carry live callbacks from the UI). The method is
|
||||||
|
* intentionally factored out so a single wiring description serves both entry points.
|
||||||
|
*
|
||||||
|
* @param startConfig validated startup configuration; must not be null
|
||||||
|
* @param runLockPort acquired run-lock port; must not be null
|
||||||
|
* @param progressObserver observer forwarded into the use case; must not be null
|
||||||
|
* @param cancellationToken cancellation token forwarded into the use case; must not be null
|
||||||
|
* @return a fully wired production batch use case; never null
|
||||||
|
*/
|
||||||
|
private BatchRunProcessingUseCase buildProductionBatchUseCase(
|
||||||
|
StartConfiguration startConfig,
|
||||||
|
RunLockPort runLockPort,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
|
||||||
|
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
|
||||||
|
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
||||||
|
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
|
||||||
|
|
||||||
|
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
|
||||||
|
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
|
||||||
|
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
|
||||||
|
|
||||||
|
String jdbcUrl = buildJdbcUrl(startConfig);
|
||||||
|
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
||||||
|
DocumentRecordRepository documentRecordRepository =
|
||||||
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
|
ProcessingAttemptRepository processingAttemptRepository =
|
||||||
|
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
||||||
|
UnitOfWorkPort unitOfWorkPort =
|
||||||
|
new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
|
||||||
|
DocumentProcessingCoordinator.class, aiContentSensitivity);
|
||||||
|
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
|
||||||
|
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
|
||||||
|
DocumentProcessingCoordinator documentProcessingCoordinator =
|
||||||
|
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
|
||||||
|
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
|
||||||
|
startConfig.maxRetriesTransient(),
|
||||||
|
startConfig.maxTitleLength(),
|
||||||
|
activeFamily.getIdentifier());
|
||||||
|
|
||||||
|
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
|
||||||
|
ClockPort clockPort = new SystemClockAdapter();
|
||||||
|
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength());
|
||||||
|
AiNamingService aiNamingService = new AiNamingService(
|
||||||
|
aiInvocationPort,
|
||||||
|
promptPort,
|
||||||
|
aiResponseValidator,
|
||||||
|
providerConfig.model(),
|
||||||
|
startConfig.maxTextCharacters(),
|
||||||
|
startConfig.maxTitleLength());
|
||||||
|
|
||||||
|
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
|
||||||
|
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
|
||||||
|
return new DefaultBatchRunProcessingUseCase(
|
||||||
|
runtimeConfig,
|
||||||
|
runLockPort,
|
||||||
|
new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
|
||||||
|
new PdfTextExtractionPortAdapter(),
|
||||||
|
fingerprintPort,
|
||||||
|
documentProcessingCoordinator,
|
||||||
|
aiNamingService,
|
||||||
|
useCaseLogger,
|
||||||
|
progressObserver,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the BootstrapRunner with custom factories for testing, without a migration step.
|
* Creates the BootstrapRunner with custom factories for testing, without a migration step.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -639,6 +666,7 @@ public class BootstrapRunner {
|
|||||||
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
|
||||||
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;
|
||||||
|
|
||||||
if (configPathOverride.isEmpty()) {
|
if (configPathOverride.isEmpty()) {
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
@@ -651,7 +679,8 @@ public class BootstrapRunner {
|
|||||||
providerTechnicalTestService,
|
providerTechnicalTestService,
|
||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
technicalTestOrchestrator,
|
technicalTestOrchestrator,
|
||||||
correctionExecutionService);
|
correctionExecutionService,
|
||||||
|
batchRunLauncher);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -669,7 +698,8 @@ public class BootstrapRunner {
|
|||||||
providerTechnicalTestService,
|
providerTechnicalTestService,
|
||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
technicalTestOrchestrator,
|
technicalTestOrchestrator,
|
||||||
correctionExecutionService);
|
correctionExecutionService,
|
||||||
|
batchRunLauncher);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
@@ -677,7 +707,7 @@ 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);
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher);
|
||||||
} 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);
|
||||||
@@ -691,10 +721,96 @@ public class BootstrapRunner {
|
|||||||
providerTechnicalTestService,
|
providerTechnicalTestService,
|
||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
technicalTestOrchestrator,
|
technicalTestOrchestrator,
|
||||||
correctionExecutionService);
|
correctionExecutionService,
|
||||||
|
batchRunLauncher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes exactly one batch run triggered by the GUI's processing-run tab.
|
||||||
|
* <p>
|
||||||
|
* Mirrors the headless bootstrap pipeline: legacy migration, configuration loading and
|
||||||
|
* validation, SQLite schema initialisation, run-lock acquisition, use-case wiring, and
|
||||||
|
* execution. Forwards the supplied observer and cancellation token into the wired use
|
||||||
|
* case so the GUI receives live progress callbacks and can request a soft-stop.
|
||||||
|
* <p>
|
||||||
|
* All known hard startup failures are mapped to
|
||||||
|
* {@link GuiBatchRunLaunchOutcome#rejected(String)}. Unexpected runtime exceptions
|
||||||
|
* that escape after startup are mapped to
|
||||||
|
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} so the GUI can reach the
|
||||||
|
* defined terminal state instead of becoming stuck.
|
||||||
|
*
|
||||||
|
* @param configFilePath path to the {@code .properties} configuration; must exist on disk
|
||||||
|
* @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 run; never null
|
||||||
|
*/
|
||||||
|
GuiBatchRunLaunchOutcome launchGuiBatchRun(
|
||||||
|
Path configFilePath,
|
||||||
|
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(progressObserver, "progressObserver must not be null");
|
||||||
|
Objects.requireNonNull(cancellationToken, "cancellationToken must not be null");
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Startanforderung für Konfiguration {}.", 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();
|
||||||
|
BatchRunProcessingUseCase useCase = buildProductionBatchUseCase(
|
||||||
|
config, runLockPort, progressObserver, cancellationToken);
|
||||||
|
BatchRunOutcome outcome = useCase.execute(runContext);
|
||||||
|
runContext.setEndInstant(Instant.now());
|
||||||
|
return mapGuiRunOutcome(outcome, runContext);
|
||||||
|
} catch (ConfigurationLoadingException e) {
|
||||||
|
LOG.error("GUI-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-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-Verarbeitungslauf: SQLite-Initialisierung fehlgeschlagen: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"SQLite-Datenbank konnte nicht vorbereitet werden: " + e.getMessage());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler: {}", e.getMessage(), e);
|
||||||
|
return GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Unerwarteter Fehler im Verarbeitungslauf: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GuiBatchRunLaunchOutcome mapGuiRunOutcome(BatchRunOutcome outcome, BatchRunContext runContext) {
|
||||||
|
return switch (outcome) {
|
||||||
|
case SUCCESS -> {
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Lauf beendet. RunId: {}", runContext.runId());
|
||||||
|
yield GuiBatchRunLaunchOutcome.completed();
|
||||||
|
}
|
||||||
|
case LOCK_UNAVAILABLE -> {
|
||||||
|
LOG.warn("GUI-Verarbeitungslauf: Laufsperre bereits vergeben. RunId: {}",
|
||||||
|
runContext.runId());
|
||||||
|
yield GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Ein anderer Verarbeitungslauf hält bereits die Laufsperre.");
|
||||||
|
}
|
||||||
|
case FAILURE -> {
|
||||||
|
LOG.error("GUI-Verarbeitungslauf: Lauf mit Fehler beendet. RunId: {}",
|
||||||
|
runContext.runId());
|
||||||
|
yield GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Der Verarbeitungslauf konnte nicht erfolgreich abgeschlossen werden.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
|
* Creates the {@link AiModelCatalogPort} dispatcher for use in the GUI startup context.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
Reference in New Issue
Block a user