diff --git a/docs/betrieb.md b/docs/betrieb.md index ebaf7cb..8327e53 100644 --- a/docs/betrieb.md +++ b/docs/betrieb.md @@ -54,17 +54,24 @@ Windows Server-Betrieb geeignet. Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung 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 -- **Validierungsoberfläche** (automatische und explizite Prüfung des Konfigurationsstands) -- **Technische Testoberfläche** (Erreichbarkeit des Providers, Pfade, SQLite-Datei, Prompt-Datei) +- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für + die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-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 -aus der GUI ist erst ab V2.1+ vorgesehen. Der headless Betrieb über den Windows Task Scheduler -bleibt der einzige Weg, PDF-Dateien automatisiert zu verarbeiten. +Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und +kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau +ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf +wird jedoch nicht technisch erkannt oder blockiert. --- diff --git a/docs/gui-bedienanleitung.md b/docs/gui-bedienanleitung.md index e39a4da..2fa2f8b 100644 --- a/docs/gui-bedienanleitung.md +++ b/docs/gui-bedienanleitung.md @@ -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 -- **Validierungsoberfläche** für den aktuellen Konfigurationsstand -- **Technische Test- und Diagnoseoberfläche** für Erreichbarkeit des Providers, - Pfadprüfungen und Ressourcenverfügbarkeit +- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische + Test-/Diagnoseoberfläche für die `.properties`-Datei. +- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit + 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 -Batch-Laufs aus der GUI ist erst ab einer späteren Ausbaustufe vorgesehen. -Ebenso gibt es keinen Historien-Tab, keine Datenbankansicht und kein Kosten-Tracking. +Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein +Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten. Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg, 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 | |---|---| -| 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 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 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 | +| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer | diff --git a/docs/specs/V2_7_-_Spezifikation.md b/docs/specs/V2_7_-_Spezifikation.md new file mode 100644 index 0000000..996eabb --- /dev/null +++ b/docs/specs/V2_7_-_Spezifikation.md @@ -0,0 +1,297 @@ +# V2.7 – GUI-Verarbeitungslauf mit Live-Verfolgung + +**Status:** Freigegeben +**Erstellt:** 2026-04-22 +**Überarbeitet:** 2026-04-22 (nach Review, finale Version) +**Autor:** Marcus (mit Claude als Mentor) + +--- + +## Ziel + +V2.7 erweitert die JavaFX-GUI um einen zweiten Tab „Verarbeitungslauf", über den der Benutzer +einen Batch-Lauf direkt aus der GUI starten und dessen Fortschritt in Echtzeit verfolgen kann. +Der bestehende headless-Betrieb über den Windows Task Scheduler bleibt unverändert erhalten. + +--- + +## Hintergrund + +### Bisheriger Zustand +- Die GUI dient in V2.0–V2.6 ausschließlich der Konfiguration und technischen Validierung +- Ein Verarbeitungslauf kann nur über die Kommandozeile bzw. eine Batch-Datei gestartet werden +- Es gibt keine Möglichkeit, den Fortschritt eines laufenden Batches live zu beobachten + +### Motivation +- Der manuelle Kommandozeilenstart ist für den Alltagsbetrieb umständlich +- Ohne Live-Anzeige ist unklar, ob und wie schnell die Verarbeitung voranschreitet +- Eine einzelne Datei wird schnell verarbeitet – eine Gesamtfortschrittsanzeige ist daher + sinnvoller als eine dateiweise Einzelanzeige + +--- + +## Zielbild + +Nach Abschluss von V2.7 kann der Benutzer: + +1. Im neuen Tab „Verarbeitungslauf" einen Batch-Lauf starten +2. Den Gesamtfortschritt über alle Dateien live verfolgen +3. Jede abgeschlossene Datei mit Ergebnis in einer Liste sehen +4. Das KI-Reasoning zu einer Datei per Klick im Seitenbereich einsehen +5. Den laufenden Batch per Soft-Stop sauber abbrechen + +--- + +## Fachliche Anforderungen + +### Neuer Tab „Verarbeitungslauf" + +- Der bestehende Tab „Konfiguration" bleibt Tab 1 – unverändert +- Tab 2 heißt **„Verarbeitungslauf"** +- Tab-Struktur war in V2.0 bereits vorbereitet + +--- + +### Layout Tab 2 + +``` +┌─────────────────────────────────────────────────────────┐ +│ [Fortschrittsbalken] 12 / 47 Dateien │ +├──────────────────────────────────┬──────────────────────┤ +│ Ergebnisliste │ Seitenbereich │ +│ (scrollbar) │ (KI-Reasoning) │ +│ │ │ +│ │ │ +├──────────────────────────────────┴──────────────────────┤ +│ Meldungs- und Zusammenfassungsbereich │ +├─────────────────────────────────────────────────────────┤ +│ [Starten] [Abbrechen] │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +### Meldungs- und Zusammenfassungsbereich + +Der untere Bereich des Tab 2 dient als **einheitlicher Meldungs- und Zusammenfassungsbereich**. +Er übernimmt zwei Rollen: + +- **Meldungsbereich** – zeigt Startfehler, Hinweise (z. B. 0 Dateien) und technische Exceptions +- **Zusammenfassung** – zeigt nach Laufende: `{X} erfolgreich, {X} fehlgeschlagen, {X} übersprungen` + +Während des Laufs ist der Bereich leer oder zeigt den letzten Statushinweis. +Es gibt in Tab 2 keinen separaten zweiten Meldungsbereich. + +--- + +### Konfigurationsquelle beim Start + +- Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der `.properties`-Datei +- Ungespeicherte Änderungen im Konfigurationseditor (Tab 1) fließen **nicht** in den Lauf ein +- Der Starten-Button prüft vor dem Lauf, ob die gespeicherte Konfiguration lauffähig ist – + nicht den aktuellen Editorzustand + +--- + +### Startvoraussetzungen und Startfehler + +Ein Lauf startet nur, wenn alle folgenden Voraussetzungen erfüllt sind: + +| Voraussetzung | Verhalten bei Fehler | +|---|---| +| Gespeicherte Konfiguration vorhanden und lauffähig | Fehlermeldung, kein Lauf | +| Quellordner vorhanden und lesbar | Fehlermeldung, kein Lauf | +| Zielordner vorhanden oder anlegbar | Fehlermeldung, kein Lauf | +| SQLite-Datei nutzbar | Fehlermeldung, kein Lauf | +| API-Key vorhanden | Fehlermeldung, kein Lauf | +| Kein anderer Verarbeitungslauf in dieser Anwendungsinstanz aktiv | Fehlermeldung, kein Lauf | + +Bei einem Startfehler: +- Erscheint eine klare Fehlermeldung im Meldungs- und Zusammenfassungsbereich +- Fortschrittsbalken und Ergebnisliste bleiben unverändert +- Starten-Button bleibt aktiv, Abbrechen-Button bleibt deaktiviert + +--- + +### Verhalten bei 0 verarbeitbaren Dateien + +- Kein technischer Fehler +- Kein Lauf im eigentlichen Sinne +- Hinweis im Meldungs- und Zusammenfassungsbereich: „Keine verarbeitbaren Dateien im Quellordner gefunden" +- Zusammenfassung: `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen` + +--- + +### Fortschrittsbalken + +- Die zu verarbeitende Dateimenge wird **einmalig beim Start** bestimmt +- Der Nenner bleibt für den gesamten Lauf **konstant** – Dateien die während des Laufs + im Quellordner auftauchen oder verschwinden, werden nicht berücksichtigt +- Gezählt werden **alle abgeschlossenen** Dateien: erfolgreich + fehlgeschlagen + übersprungen +- Daneben wird der Zählerstand angezeigt, z. B. „12 / 47 Dateien" +- Vor dem ersten Start: leer / 0 % + +--- + +### Statusmodell + +Jede Datei erhält nach Abschluss genau einen der folgenden Status: + +| Status | Icon | Bedeutung | +|---|---|---| +| Erfolgreich | ✅ | Datei wurde umbenannt, Zieldatei erzeugt | +| Fehlgeschlagen (retryable) | ⚠️ | Transienter Fehler, wird beim nächsten Lauf erneut versucht | +| Fehlgeschlagen (permanent) | ❌ | Inhaltsfehler, kein weiterer Retry | +| Übersprungen | ⏭️ | Datei war bereits verarbeitet oder wurde bewusst ausgelassen | + +Alle vier Status zählen als **abgeschlossen** im Sinne des Fortschrittsbalkens. + +--- + +### Ergebnisliste + +Jede abgeschlossene Datei erscheint als neue Zeile in der Liste. +Nach Abschluss jeder Datei erscheint **ohne manuellen Refresh** ein neuer Eintrag. +Die Liste wächst während des Laufs von oben nach unten. + +| Spalte | Erfolg | Fehler / Übersprungen | +|---|---|---| +| Status-Icon | ✅ / ⚠️ / ❌ / ⏭️ | wie links | +| Originaldateiname | Quelldateiname | Quelldateiname | +| Neuer Dateiname | Finaler Zieldateiname | `—` | +| Datum | Ermitteltes Datum | `—` | +| Dauer | Verarbeitungszeit in Sekunden | Verarbeitungszeit in Sekunden | + +- Klick auf eine Zeile zeigt Details im **Seitenbereich** +- Die Liste ist scrollbar +- Die Liste ist **nicht persistent**: bleibt nur für die Dauer des aktuellen Programmstarts +- Bei einem neuen Lauf innerhalb desselben Programmstarts wird die Liste geleert +- Nach Programmstart ist die Liste leer + +--- + +### Seitenbereich (KI-Reasoning) + +- Rechts neben der Ergebnisliste, fest im Layout verankert (kein Popup, kein Dialog) +- Zeigt nach Klick auf eine Zeile: + - Originaldateiname + - Ermittelter Titel + - Ermitteltes Datum + - KI-Reasoning (Volltext) +- Liegt für einen Eintrag kein KI-Reasoning vor (Fehler vor KI-Aufruf, übersprungen), + erscheint der Hinweistext: „Für diesen Eintrag liegt kein KI-Reasoning vor." +- Vor dem ersten Klick: Hinweistext „Datei auswählen für Details" +- Bei neuem Lauf wird der Seitenbereich geleert + +--- + +### Starten-Button + +- Startet den Verarbeitungslauf über alle Dateien im konfigurierten Quellordner +- Verwendet die **gespeicherte** Konfiguration – nicht den aktuellen Editorzustand +- Gleiches fachliches Batch-Verhalten wie der headless-Betrieb: + gleiche Anwendungslogik, gleicher Use Case, nur andere Präsentationsschicht +- Keine Dateiauswahl – alle Dateien werden verarbeitet +- Während des Laufs: deaktiviert +- Nach Abschluss oder Abbruch: wieder aktiv + +--- + +### Abbrechen-Button + +- Nur während eines laufenden Batches aktiv, sonst deaktiviert +- Verhalten: **Soft-Stop** + - Die aktuell in Bearbeitung befindliche Datei wird vollständig fertig verarbeitet + - Das Stop-Flag wird nach Abschluss jeder Datei und vor Start der nächsten Datei geprüft – + niemals mitten in einer atomaren Persistenzoperation + - Danach wird der Lauf sauber beendet, keine halbfertigen Zustände in der SQLite-Datenbank +- Nach dem Soft-Stop erscheint die Zusammenfassung im Meldungs- und Zusammenfassungsbereich + +--- + +### Konfiguration während des Laufs + +- Tab 1 „Konfiguration" wird während eines laufenden Verarbeitungslaufs **gesperrt** +- Im Konfiguration-Tab erscheint ein sichtbarer Hinweis: + „Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar" +- Nach Abschluss, Abbruch oder unerwarteter Exception wird Tab 1 wieder freigegeben + +--- + +### Verhalten bei unerwarteter technischer Exception + +Tritt während des Laufs eine unerwartete Exception auf: + +- Die GUI wechselt in einen definierten terminalen Zustand: + - Starten-Button: aktiv + - Abbrechen-Button: deaktiviert + - Tab 1: entsperrt + - Meldungs- und Zusammenfassungsbereich: Fehlermeldung sichtbar +- Es entsteht kein „hängender" UI-Zustand + +--- + +### Fenster schließen während eines laufenden Laufs + +- Schließt der Benutzer das Fenster während ein Lauf aktiv ist, + wird der Close-Request abgefangen +- Es erscheint ein Hinweisdialog mit zwei Optionen: + - **„Nicht schließen"** – Lauf läuft weiter + - **„Lauf beenden und schließen"** – Soft-Stop wird ausgelöst, + nach Abschluss der aktuellen Datei schließt die Anwendung +- Kein Hard-Abbruch ohne Benutzerentscheidung + +--- + +### Parallele Läufe + +- Pro Anwendungsinstanz ist **nur ein Verarbeitungslauf gleichzeitig** zulässig +- Ein zweiter Startversuch während ein Lauf aktiv ist wird verweigert mit der Meldung: + „Ein Verarbeitungslauf ist bereits aktiv." +- **Bekannte Einschränkung:** Ein gleichzeitiger externer headless-Lauf (Windows Task Scheduler) + wird von der GUI nicht aktiv erkannt und nicht technisch geblockt. + Der Benutzer ist selbst verantwortlich, parallele Läufe zu vermeiden. + Diese Einschränkung ist seit V2.0 dokumentiert und bleibt in V2.7 unverändert bestehen. + +--- + +## Nicht in V2.7 enthalten + +- Dateiauswahl (welche Dateien verarbeitet werden sollen) +- Einzeldatei-Fortschrittsanzeige +- Historien-Tab / SQLite-Ansicht +- Kosten-Tracking +- Automatischer Neustart nach Abschluss +- Benachrichtigungen (Windows-Tray, Toast) +- Parallelverarbeitung mehrerer Dateien +- Technisches Locking gegen externe headless-Läufe + +--- + +## Abnahmekriterien + +- [ ] Tab 2 „Verarbeitungslauf" ist in der GUI vorhanden und erreichbar +- [ ] Starten-Button verwendet ausschließlich die gespeicherte Konfiguration +- [ ] Starten-Button startet den Batch-Lauf über alle Dateien im Quellordner +- [ ] Die Dateimenge wird beim Start einmalig bestimmt; der Nenner des Fortschrittsbalkens bleibt während des gesamten Laufs konstant +- [ ] Fortschrittsbalken zählt alle abgeschlossenen Dateien (erfolgreich + fehlgeschlagen + übersprungen) +- [ ] Nach Abschluss jeder Datei erscheint ohne manuellen Refresh ein neuer Eintrag in der Ergebnisliste +- [ ] Alle fünf Spalten der Ergebnisliste sind für Erfolgsfälle korrekt befüllt +- [ ] Spalte „Neuer Dateiname" und „Datum" zeigen `—` für Fehler- und Übersprungen-Fälle +- [ ] Alle vier Status-Icons sind korrekt: ✅ ⚠️ ❌ ⏭️ +- [ ] Klick auf Zeile zeigt KI-Reasoning im Seitenbereich +- [ ] Einträge ohne KI-Reasoning zeigen den definierten Hinweistext im Seitenbereich +- [ ] Seitenbereich zeigt vor erstem Klick den Hinweistext „Datei auswählen für Details" +- [ ] Soft-Stop beendet den Lauf nach Abschluss der aktuellen Datei; keine weitere Datei wird begonnen +- [ ] Meldungs- und Zusammenfassungsbereich zeigt nach Laufende die Zusammenfassung mit korrekten Zählern +- [ ] Tab 1 ist während des Laufs gesperrt, Hinweis ist sichtbar +- [ ] Tab 1 wird nach Abschluss, Abbruch oder Exception wieder entsperrt +- [ ] Bei unerwarteter Exception wechselt die GUI in den definierten terminalen Zustand +- [ ] Ergebnisliste und Seitenbereich sind nach Programmstart leer +- [ ] Ergebnisliste und Seitenbereich werden bei neuem Lauf geleert +- [ ] Start mit nicht lauffähiger Konfiguration wird verweigert; Fehlermeldung erscheint im Meldungs- und Zusammenfassungsbereich +- [ ] Start bei leerem Quellordner erzeugt keinen Fehler; Hinweis erscheint im Meldungs- und Zusammenfassungsbereich +- [ ] Zweiter Startversuch während laufendem Lauf wird verweigert; Meldung erscheint +- [ ] Close-Request während Lauf öffnet Hinweisdialog mit zwei Optionen +- [ ] headless-Betrieb ist unverändert funktionsfähig +- [ ] `mvn clean verify` ist grün diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 3f93f0c..af6a888 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -14,6 +14,8 @@ import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; 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.GuiApiKeyMerger; 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 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. * @@ -391,12 +420,72 @@ public final class GuiConfigurationEditorWorkspace { this.unsavedChangesGuard = new GuiUnsavedChangesGuard( triggerLabel -> showUnsavedChangesDialog(triggerLabel)); + this.batchRunLauncher = effectiveContext.batchRunLauncher(); + this.batchRunTab = new GuiBatchRunTab( + () -> this.batchRunLauncher, + this::loadedConfigurationPath, + this::isSavedConfigurationReady, + this::applyBatchRunLockState); + configureRoot(); configureHeader(effectiveContext.startupNotice()); configureTabs(); configureActionBar(); configureActions(); 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. + *

+ * 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. + *

+ * 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) { stage.setOnCloseRequest(event -> { + if (batchRunTab != null && batchRunTab.isRunning()) { + event.consume(); + handleCloseWhileRunRunning(stage); + return; + } if (!editorState.isDirty()) { 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 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 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. * @@ -946,18 +1093,30 @@ public final class GuiConfigurationEditorWorkspace { private void configureTabs() { Tab editorTab = new Tab("Konfiguration"); editorTab.setClosable(false); + configurationTab = editorTab; sectionsBox.setSpacing(12); 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.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); scrollPane.setPadding(new Insets(0)); editorTab.setContent(scrollPane); - tabPane.getTabs().add(editorTab); + tabPane.getTabs().setAll(editorTab, batchRunTab.tab()); root.setCenter(tabPane); } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 256de73..c3954a8 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -3,6 +3,8 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui; import java.util.Objects; 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.GuiConfigurationEditorStateFactory; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort; @@ -41,7 +43,8 @@ public record GuiStartupContext( ProviderTechnicalTestService providerTechnicalTestService, PathCheckPort pathCheckPort, TechnicalTestOrchestrator technicalTestOrchestrator, - CorrectionExecutionService correctionExecutionService) { + CorrectionExecutionService correctionExecutionService, + GuiBatchRunLauncher batchRunLauncher) { /** * 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 technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null} * @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null} + * @param batchRunLauncher bridge that executes a batch run against a stored + * configuration path for the processing-run tab; + * must not be {@code null} */ public GuiStartupContext { initialState = Objects.requireNonNull(initialState, "initialState must not be null"); @@ -76,6 +82,49 @@ public record GuiStartupContext( "technicalTestOrchestrator must not be null"); correctionExecutionService = Objects.requireNonNull(correctionExecutionService, "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. + *

+ * 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 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); + GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) -> + GuiBatchRunLaunchOutcome.rejected( + "Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar."); return new GuiStartupContext( GuiConfigurationEditorStateFactory.createBlankStartState(), startupNotice, @@ -155,6 +207,7 @@ public record GuiStartupContext( noOpTestService, noOpPathCheckPort, noOpOrchestrator, - noOpCorrectionService); + noOpCorrectionService, + noOpBatchRunLauncher); } } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java new file mode 100644 index 0000000..e2d8a65 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinator.java @@ -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. + *

+ * 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. + * + *

Threading

+ * + * + *

Lifecycle

+ *
    + *
  1. Construct with a launcher, a thread factory and a listener.
  2. + *
  3. Call {@link #start(Path)} to begin a run against a configuration file.
  4. + *
  5. Optionally call {@link #requestCancellation()} to trigger soft-stop.
  6. + *
  7. Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} on the + * FX thread.
  8. + *
  9. Start a new run only after the previous one has ended.
  10. + *
+ */ +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 threadFactory; + private final Consumer fxDispatcher; + private final Listener listener; + private final AtomicReference 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. + *

+ * 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 threadFactory, + Consumer 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. + *

+ * 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. + *

+ * 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 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 finalName = event.finalFileName() == null + ? Optional.empty() : Optional.of(event.finalFileName()); + Optional date = event.resolvedDate() == null + ? Optional.empty() : Optional.of(event.resolvedDate()); + Optional 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 defaultThreadFactory() { + return task -> { + Thread thread = new Thread(task, WORKER_THREAD_NAME); + thread.setDaemon(true); + return thread; + }; + } + + private static Consumer defaultFxDispatcher() { + return Platform::runLater; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunLaunchOutcome.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunLaunchOutcome.java new file mode 100644 index 0000000..0a072a0 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunLaunchOutcome.java @@ -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. + *

+ * 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. + * + *

Fields

+ *
    + *
  • {@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).
  • + *
  • {@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}.
  • + *
  • {@link #failureMessage()} — present when either the run could not start or an + * unexpected technical exception terminated it. Empty when the run completed + * normally.
  • + *
+ */ +public record GuiBatchRunLaunchOutcome( + boolean successfullyStarted, + boolean batchCompletedNormally, + Optional 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)); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunLauncher.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunLauncher.java new file mode 100644 index 0000000..270c78a --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunLauncher.java @@ -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. + *

+ * 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. + * + *

Threading

+ *

+ * 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). + * + *

Exception contract

+ *

+ * 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); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java new file mode 100644 index 0000000..6845116 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunResultRow.java @@ -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. + *

+ * 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 finalFileName, + Optional resolvedDate, + Optional 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"; // ⏭️ + }; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java new file mode 100644 index 0000000..31e95f9 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTab.java @@ -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. + *

+ * 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. + * + *

Layout

+ *
+ *   ┌──────────────────────────────────────────────────────┐
+ *   │ [Fortschrittsbalken]                12 / 47 Dateien  │
+ *   ├──────────────────────────────────┬───────────────────┤
+ *   │ Ergebnisliste                    │ Seitenbereich     │
+ *   │ (TableView)                      │ (Reasoning)       │
+ *   ├──────────────────────────────────┴───────────────────┤
+ *   │ Meldungs- und Zusammenfassungsbereich                │
+ *   ├──────────────────────────────────────────────────────┤
+ *   │ [Starten]  [Abbrechen]                               │
+ *   └──────────────────────────────────────────────────────┘
+ * 
+ * + *

Threading

+ *

+ * 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 resultTable = new TableView<>(); + private final ObservableList 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 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 launcherSupplier, + Supplier 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. + *

+ * 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 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 iconCol = new TableColumn<>("Status"); + iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon())); + iconCol.setPrefWidth(64); + + TableColumn nameCol = new TableColumn<>("Originaldateiname"); + nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName())); + nameCol.setPrefWidth(280); + + TableColumn newNameCol = new TableColumn<>("Neuer Dateiname"); + newNameCol.setCellValueFactory(data -> new SimpleStringProperty( + data.getValue().finalFileName().orElse(EMPTY_CELL_TEXT))); + newNameCol.setPrefWidth(280); + + TableColumn 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 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> 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; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/package-info.java new file mode 100644 index 0000000..7483319 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/package-info.java @@ -0,0 +1,30 @@ +/** + * Inbound adapter components that drive the GUI's processing-run tab. + *

+ * 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. + * + *

Threading contract

+ *

+ * 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. + * + *

Cancellation

+ *

+ * The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag + * causes the use case to stop before starting the next candidate; the candidate + * currently being processed is always completed in full so the SQLite persistence remains + * consistent. + * + *

Configuration source

+ *

+ * 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; diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java index e5b6c64..a3a1e8c 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java @@ -86,10 +86,17 @@ class GuiAdapterSmokeTest { static void setUpJavaFxPlatform() throws InterruptedException { Platform.setImplicitExit(false); CountDownLatch startLatch = new CountDownLatch(1); - Platform.startup(() -> { + try { + Platform.startup(() -> { + PLATFORM_STARTED.set(true); + startLatch.countDown(); + }); + } catch (IllegalStateException alreadyInitialised) { + // Another smoke test in the same Surefire fork already started the JavaFX + // runtime; treat the toolkit as available and proceed. PLATFORM_STARTED.set(true); startLatch.countDown(); - }); + } assertTrue( startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless"); @@ -237,14 +244,16 @@ class GuiAdapterSmokeTest { "The 'Speichern' button must be visible"); assertEquals("Speichern unter", workspace.saveAsButton().getText(), "The 'Speichern unter' button must be visible"); - assertEquals(1, workspace.tabPane().getTabs().size(), - "Exactly one configuration tab must be present"); + assertEquals(2, workspace.tabPane().getTabs().size(), + "Configuration tab and processing-run tab must both be present"); 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( "Pfade,Provider,Verarbeitungslimits,Tests,Meldungen", 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) { fxError.set(t); } finally { diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java new file mode 100644 index 0000000..2f8b94c --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunCoordinatorTest.java @@ -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}. + *

+ * 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 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 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 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 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 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 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)); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java new file mode 100644 index 0000000..4a11fce --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/GuiBatchRunTabSmokeTest.java @@ -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 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 CURRENT_TAB = new ThreadLocal<>(); + + private GuiBatchRunTab tab() { + return CURRENT_TAB.get(); + } + + private void onFxAndWait(java.util.function.Consumer action, + boolean savedReady, + Path configPath, + GuiBatchRunLauncher launcher) throws InterruptedException { + CountDownLatch done = new CountDownLatch(1); + AtomicReference 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 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 action, + boolean savedReady, + Path configPath, + GuiBatchRunLauncher launcher, + java.util.function.BooleanSupplier quiesced) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference 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 */ }); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunCancellationToken.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunCancellationToken.java new file mode 100644 index 0000000..1e91282 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunCancellationToken.java @@ -0,0 +1,40 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +/** + * Inbound cooperative cancellation token for a running batch. + *

+ * 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). + *

+ * 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. + * + *

Default implementation

+ *

+ * 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. + *

+ * 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; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunProgressObserver.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunProgressObserver.java new file mode 100644 index 0000000..564aa80 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/BatchRunProgressObserver.java @@ -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. + *

+ * 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. + * + *

Invocation order

+ *

+ * For a single run the observer is invoked in this order: + *

    + *
  1. {@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).
  2. + *
  3. {@link #onDocumentCompleted(DocumentCompletionEvent)} once per candidate whose + * processing reached a terminal resolution.
  4. + *
  5. {@link #onRunEnded(RunSummary)} exactly once after the processing loop has + * finished (normally, after a cancellation, or after a hard run-level error).
  6. + *
+ * + *

Threading

+ *

+ * 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. + * + *

Exception handling

+ *

+ * 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. + *

+ * 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. + *

+ * 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; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java new file mode 100644 index 0000000..b429a11 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionEvent.java @@ -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. + *

+ * 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. + *

+ * 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"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionStatus.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionStatus.java new file mode 100644 index 0000000..3a31dcb --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/DocumentCompletionStatus.java @@ -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. + *

+ * 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. + *

+ * 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 +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NeverCancelledBatchRunCancellationToken.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NeverCancelledBatchRunCancellationToken.java new file mode 100644 index 0000000..1b92b76 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NeverCancelledBatchRunCancellationToken.java @@ -0,0 +1,21 @@ +package de.gecheckt.pdf.umbenenner.application.port.in; + +/** + * Shared singleton token returned by + * {@link BatchRunCancellationToken#neverCancelled()}. + *

+ * 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; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NoOpBatchRunProgressObserver.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NoOpBatchRunProgressObserver.java new file mode 100644 index 0000000..6b1a421 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/NoOpBatchRunProgressObserver.java @@ -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}. + *

+ * 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 + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunSummary.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunSummary.java new file mode 100644 index 0000000..0f06b9c --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/RunSummary.java @@ -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)}. + *

+ * The three counts are independent, non-negative and sum up to the total number of + * candidates that were processed in the run (possibly fewer than the originally detected + * candidate count if the run was cancelled mid-way). + * + * @param successCount number of candidates that completed with + * {@link DocumentCompletionStatus#SUCCESS}; must be ≥ 0 + * @param failedCount number of candidates that completed with either + * {@link DocumentCompletionStatus#FAILED_RETRYABLE} or + * {@link DocumentCompletionStatus#FAILED_PERMANENT}; must be ≥ 0 + * @param skippedCount number of candidates that completed with + * {@link DocumentCompletionStatus#SKIPPED}; must be ≥ 0 + */ +public record RunSummary(int successCount, int failedCount, int skippedCount) { + + /** + * Compact constructor enforcing non-negative counts. + * + * @throws IllegalArgumentException if any count is negative + */ + public RunSummary { + if (successCount < 0 || failedCount < 0 || skippedCount < 0) { + throw new IllegalArgumentException( + "RunSummary counts must not be negative; was: " + + successCount + "/" + failedCount + "/" + skippedCount); + } + } + + /** + * Returns the total number of candidates reflected in this summary. + * + * @return {@code successCount + failedCount + skippedCount}; never negative + */ + public int totalProcessed() { + return successCount + failedCount + skippedCount; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java index 148f1a1..9306486 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/package-info.java @@ -16,6 +16,18 @@ * — Structured result of a batch run, designed for exit code mapping * *

+ * Progress observation (for interactive inbound adapters): + *

    + *
  • {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver} + * — Optional observer that receives per-run and per-candidate callbacks during a run
  • + *
  • {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken} + * — Optional cooperative cancellation token polled between candidates
  • + *
  • {@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
  • + *
+ *

* 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; * adapters depend on ports, not vice versa. diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index 549bb5f..7a02126 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -1,10 +1,14 @@ package de.gecheckt.pdf.umbenenner.application.service; +import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; import java.util.Objects; import java.util.function.Consumer; 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.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; @@ -158,6 +162,20 @@ public class DocumentProcessingCoordinator { private final int maxTitleLength; 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. + *

+ * 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. + *

+ * Accessed from the batch thread only. Not volatile because installation and read occur + * on the same thread (the one executing the batch). + */ + private Consumer completionForwarder; + /** * Creates the document processing coordinator with all required ports, logger, * the transient retry limit, the configured maximum base title length, and the @@ -235,6 +253,25 @@ public class DocumentProcessingCoordinator { this.maxRetriesTransient = maxRetriesTransient; this.maxTitleLength = maxTitleLength; this.activeProviderIdentifier = activeProviderIdentifier; + this.completionForwarder = null; + } + + /** + * Installs or removes a per-run completion forwarder. + *

+ * 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. + *

+ * 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 forwarder) { + this.completionForwarder = forwarder; } /** @@ -509,7 +546,7 @@ public class DocumentProcessingCoordinator { return persistTargetCopySuccess( 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 * best-effort rollback of the target file is attempted and * {@link ProcessingStatus#FAILED_RETRYABLE} is persisted instead. + *

+ * 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 */ private boolean persistTargetCopySuccess( @@ -529,7 +575,8 @@ public class DocumentProcessingCoordinator { Instant attemptStart, Instant now, String resolvedFilename, - String targetFolderLocator) { + String targetFolderLocator, + ProcessingAttempt proposalAttempt) { try { int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint); @@ -550,6 +597,11 @@ public class DocumentProcessingCoordinator { logger.info("Document '{}' successfully processed. Target: '{}'.", candidate.uniqueIdentifier(), resolvedFilename); + publishCompletion(candidate, DocumentCompletionStatus.SUCCESS, + resolvedFilename, + proposalAttempt.resolvedDate(), + proposalAttempt.aiReasoning(), + attemptStart, now); return true; } catch (DocumentPersistenceException e) { @@ -564,7 +616,8 @@ public class DocumentProcessingCoordinator { candidate, fingerprint, existingRecord, context, attemptStart, Instant.now(), "Persistence failed after successful target copy (best-effort rollback attempted): " - + e.getMessage()); + + e.getMessage(), + proposalAttempt); return false; } } @@ -628,6 +681,10 @@ public class DocumentProcessingCoordinator { candidate.uniqueIdentifier(), fingerprint.sha256Hex(), updatedCounters.transientErrorCount(), maxRetriesTransient); } + publishCompletion(candidate, + retryable ? DocumentCompletionStatus.FAILED_RETRYABLE + : DocumentCompletionStatus.FAILED_PERMANENT, + null, null, null, attemptStart, now); return true; } catch (DocumentPersistenceException persistEx) { @@ -654,7 +711,8 @@ public class DocumentProcessingCoordinator { BatchRunContext context, Instant attemptStart, Instant now, - String errorMessage) { + String errorMessage, + ProcessingAttempt proposalAttempt) { ProcessingOutcomeTransition.ProcessingOutcome transition = ProcessingOutcomeTransition.forKnownDocument( @@ -664,6 +722,7 @@ public class DocumentProcessingCoordinator { FailureCounters updatedCounters = transition.counters(); ProcessingStatus errorStatus = transition.overallStatus(); + boolean secondaryPersisted = false; try { int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint); ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields( @@ -679,11 +738,28 @@ public class DocumentProcessingCoordinator { txOps.saveProcessingAttempt(errorAttempt); txOps.updateDocumentRecord(errorRecord); }); + secondaryPersisted = true; } catch (DocumentPersistenceException secondaryEx) { logger.error("Secondary persistence failure for '{}' after target copy rollback: {}", 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 {}.", attemptNumber, candidate.uniqueIdentifier(), skipStatus); + publishCompletion(candidate, DocumentCompletionStatus.SKIPPED, + null, null, null, attemptStart, now); return true; } catch (DocumentPersistenceException e) { @@ -985,6 +1063,13 @@ public class DocumentProcessingCoordinator { outcome.counters().contentErrorCount(), 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; } catch (DocumentPersistenceException e) { @@ -1097,4 +1182,74 @@ public class DocumentProcessingCoordinator { return base + detail; } + + // ========================================================================= + // Progress observer dispatch + // ========================================================================= + + /** + * Publishes a single terminal completion event for the candidate to the attached + * {@link BatchRunProgressObserver}. + *

+ * 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. + *

+ * 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 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}. + *

+ * 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; + } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/CountingCompletionObserver.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/CountingCompletionObserver.java new file mode 100644 index 0000000..d89043b --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/CountingCompletionObserver.java @@ -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}. + *

+ * 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 { + + 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; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java index 60595b4..e07ff2d 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java @@ -5,8 +5,13 @@ import java.util.List; import java.util.Objects; 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.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.FingerprintResult; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess; @@ -80,6 +85,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa private final DocumentProcessingCoordinator documentProcessingCoordinator; private final AiNamingService aiNamingService; 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. @@ -112,6 +119,46 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa DocumentProcessingCoordinator documentProcessingCoordinator, AiNamingService aiNamingService, 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. + *

+ * 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.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null"); this.sourceDocumentCandidatesPort = Objects.requireNonNull( @@ -123,6 +170,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa documentProcessingCoordinator, "documentProcessingCoordinator 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.progressObserver = Objects.requireNonNull(progressObserver, "progressObserver must not be null"); + this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken must not be null"); } @Override @@ -183,16 +232,60 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa } logger.info("Found {} PDF candidate(s) in source folder.", candidates.size()); - for (SourceDocumentCandidate candidate : candidates) { - processCandidate(candidate, context); + // Notify observer of the known candidate count up-front so observers can size their + // 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: {}", - candidates.size(), context.runId()); + // Wrap the user-supplied observer so the per-run summary can be computed by counting + // 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; } + 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. *

diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java new file mode 100644 index 0000000..d71a7ec --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProgressObservationTest.java @@ -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 loadCandidates() { return List.of(); } + } + + private static final class FixedCandidatesPort implements SourceDocumentCandidatesPort { + private final List all; + FixedCandidatesPort(SourceDocumentCandidate... items) { this.all = List.of(items); } + @Override public List 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 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 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 statuses; + private Consumer currentForwarder; + private final AtomicInteger invocations = new AtomicInteger(); + Runnable onBeforeReturn = () -> { }; + + PublishingCoordinator(List 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 forwarder) { + this.currentForwarder = forwarder; + } + + Consumer currentForwarder() { + return currentForwarder; + } + + int invocations() { + return invocations.get(); + } + + @Override + public boolean processDeferredOutcome( + SourceDocumentCandidate candidate, + DocumentFingerprint fingerprint, + BatchRunContext context, + Instant attemptStart, + java.util.function.Function 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 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 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(); + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index f8043ce..4457972 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -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.GuiConfigurationLoadException; 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.GuiConfigurationEditorStateFactory; import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot; @@ -324,61 +326,86 @@ public class BootstrapRunner { this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.validatorFactory = StartConfigurationValidator::new; this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new; - this.useCaseFactory = (startConfig, lock) -> { - AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive()); - RuntimeConfiguration runtimeConfig = new RuntimeConfiguration( - startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); - - AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily(); - ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration(); - AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig); - - String jdbcUrl = buildJdbcUrl(startConfig); - FingerprintPort fingerprintPort = new Sha256FingerprintAdapter(); - DocumentRecordRepository documentRecordRepository = - new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); - ProcessingAttemptRepository processingAttemptRepository = - new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); - UnitOfWorkPort unitOfWorkPort = - new SqliteUnitOfWorkAdapter(jdbcUrl); - ProcessingLogger coordinatorLogger = new Log4jProcessingLogger( - DocumentProcessingCoordinator.class, aiContentSensitivity); - TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder()); - TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder()); - DocumentProcessingCoordinator documentProcessingCoordinator = - new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, - unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger, - startConfig.maxRetriesTransient(), - startConfig.maxTitleLength(), - activeFamily.getIdentifier()); - - PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); - ClockPort clockPort = new SystemClockAdapter(); - AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength()); - AiNamingService aiNamingService = new AiNamingService( - aiInvocationPort, - promptPort, - aiResponseValidator, - providerConfig.model(), - startConfig.maxTextCharacters(), - startConfig.maxTitleLength()); - - ProcessingLogger useCaseLogger = new Log4jProcessingLogger( - DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); - return new DefaultBatchRunProcessingUseCase( - runtimeConfig, - lock, - new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()), - new PdfTextExtractionPortAdapter(), - fingerprintPort, - documentProcessingCoordinator, - aiNamingService, - useCaseLogger); - }; + 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. + *

+ * Shared wiring for the headless path (where observer and token default to no-ops) and the + * GUI processing-run path (where both carry live callbacks from the UI). The method is + * intentionally factored out so a single wiring description serves both entry points. + * + * @param startConfig validated startup configuration; must not be null + * @param runLockPort acquired run-lock port; must not be null + * @param progressObserver observer forwarded into the use case; must not be null + * @param cancellationToken cancellation token forwarded into the use case; must not be null + * @return a fully wired production batch use case; never null + */ + private BatchRunProcessingUseCase buildProductionBatchUseCase( + StartConfiguration startConfig, + RunLockPort runLockPort, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver, + de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) { + AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive()); + RuntimeConfiguration runtimeConfig = new RuntimeConfiguration( + startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); + + AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily(); + ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration(); + AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig); + + String jdbcUrl = buildJdbcUrl(startConfig); + FingerprintPort fingerprintPort = new Sha256FingerprintAdapter(); + DocumentRecordRepository documentRecordRepository = + new SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + ProcessingAttemptRepository processingAttemptRepository = + new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); + UnitOfWorkPort unitOfWorkPort = + new SqliteUnitOfWorkAdapter(jdbcUrl); + ProcessingLogger coordinatorLogger = new Log4jProcessingLogger( + DocumentProcessingCoordinator.class, aiContentSensitivity); + TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder()); + TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder()); + DocumentProcessingCoordinator documentProcessingCoordinator = + new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, + unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger, + startConfig.maxRetriesTransient(), + startConfig.maxTitleLength(), + activeFamily.getIdentifier()); + + PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); + ClockPort clockPort = new SystemClockAdapter(); + AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort, startConfig.maxTitleLength()); + AiNamingService aiNamingService = new AiNamingService( + aiInvocationPort, + promptPort, + aiResponseValidator, + providerConfig.model(), + startConfig.maxTextCharacters(), + startConfig.maxTitleLength()); + + ProcessingLogger useCaseLogger = new Log4jProcessingLogger( + DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); + return new DefaultBatchRunProcessingUseCase( + runtimeConfig, + runLockPort, + new SourceDocumentCandidatesPortAdapter(startConfig.sourceFolder()), + new PdfTextExtractionPortAdapter(), + fingerprintPort, + documentProcessingCoordinator, + aiNamingService, + useCaseLogger, + progressObserver, + cancellationToken); + } + /** * Creates the BootstrapRunner with custom factories for testing, without a migration step. *

@@ -639,6 +666,7 @@ public class BootstrapRunner { de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService = new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter()); + GuiBatchRunLauncher batchRunLauncher = this::launchGuiBatchRun; if (configPathOverride.isEmpty()) { return new GuiStartupContext( @@ -651,7 +679,8 @@ public class BootstrapRunner { providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, - correctionExecutionService); + correctionExecutionService, + batchRunLauncher); } Path configPath = Paths.get(configPathOverride.get()); @@ -669,7 +698,8 @@ public class BootstrapRunner { providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, - correctionExecutionService); + correctionExecutionService, + batchRunLauncher); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); @@ -677,7 +707,7 @@ public class BootstrapRunner { GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath); return new GuiStartupContext(loadedState, Optional.empty(), loader, writer, modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, - technicalTestOrchestrator, correctionExecutionService); + technicalTestOrchestrator, correctionExecutionService, batchRunLauncher); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -691,10 +721,96 @@ public class BootstrapRunner { providerTechnicalTestService, pathCheckPort, technicalTestOrchestrator, - correctionExecutionService); + correctionExecutionService, + batchRunLauncher); } } + /** + * Executes exactly one batch run triggered by the GUI's processing-run tab. + *

+ * 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. + *

+ * 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. *