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:
2026-04-22 15:29:06 +02:00
parent eacc205865
commit f4cfb5cbc0
27 changed files with 3621 additions and 93 deletions
+15 -8
View File
@@ -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
View File
@@ -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 |
+297
View File
@@ -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.0V2.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
@@ -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);
} }
@@ -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);
} }
} }
@@ -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;
}
}
@@ -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));
}
}
@@ -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);
}
@@ -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"; // ⏭️
};
}
}
@@ -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;
}
}
@@ -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;
@@ -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);
try {
Platform.startup(() -> { Platform.startup(() -> {
PLATFORM_STARTED.set(true); PLATFORM_STARTED.set(true);
startLatch.countDown(); 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);
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 {
@@ -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));
}
}
@@ -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 */ });
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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
}
@@ -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;
}
}
@@ -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
}
}
@@ -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 &ge; 0
* @param failedCount number of candidates that completed with either
* {@link DocumentCompletionStatus#FAILED_RETRYABLE} or
* {@link DocumentCompletionStatus#FAILED_PERMANENT}; must be &ge; 0
* @param skippedCount number of candidates that completed with
* {@link DocumentCompletionStatus#SKIPPED}; must be &ge; 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;
}
}
@@ -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.
@@ -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;
}
} }
@@ -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;
}
}
@@ -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>
@@ -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();
}
}
}
@@ -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,7 +326,33 @@ 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(
startConfig, lock,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
this.commandFactory = SchedulerBatchCommand::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()); AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration( RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
@@ -367,16 +395,15 @@ public class BootstrapRunner {
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
return new DefaultBatchRunProcessingUseCase( return new DefaultBatchRunProcessingUseCase(
runtimeConfig, runtimeConfig,
lock, runLockPort,
new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()), new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()),
new PdfTextExtractionPortAdapter(), new PdfTextExtractionPortAdapter(),
fingerprintPort, fingerprintPort,
documentProcessingCoordinator, documentProcessingCoordinator,
aiNamingService, aiNamingService,
useCaseLogger); useCaseLogger,
}; progressObserver,
this.commandFactory = SchedulerBatchCommand::new; cancellationToken);
this.guiAdapterFactory = GuiAdapter::new;
} }
/** /**
@@ -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>