Compare commits
3 Commits
bbb5c4da3a
...
016da8318d
| Author | SHA1 | Date | |
|---|---|---|---|
| 016da8318d | |||
| 1bb7a42735 | |||
| aa067a3165 |
@@ -4,14 +4,21 @@ Ein lokal gestartetes Java-Programm zur KI-gestützten Umbenennung bereits OCR-v
|
|||||||
|
|
||||||
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
|
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
|
||||||
|
|
||||||
|
> **V2.0:** Die Anwendung enthält ab V2.0 eine lokale JavaFX-Desktop-GUI als Standardstart.
|
||||||
|
> Die GUI dient der Konfiguration, Validierung und technischen Diagnose.
|
||||||
|
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
|
||||||
|
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
|
||||||
|
|
||||||
## Zielbild
|
## Zielbild
|
||||||
|
|
||||||
Der PDF-Umbenenner ist bewusst als schlanke Batch-Anwendung ausgelegt:
|
Der PDF-Umbenenner ist als schlanke, lokal gestartete Anwendung ausgelegt:
|
||||||
|
|
||||||
- **Java 21**
|
- **Java 21**
|
||||||
- **Maven Multi-Module**
|
- **Maven Multi-Module**
|
||||||
- **ausführbares Standalone-JAR**
|
- **ausführbares Standalone-JAR** (ein gemeinsames JAR für GUI und headless)
|
||||||
- **lokaler Start**, z. B. über den **Windows Task Scheduler**
|
- **GUI-Standardstart** ab V2.0 (JavaFX-Desktop, offiziell Windows)
|
||||||
|
- **headless Betrieb** über `--headless`, z. B. für den **Windows Task Scheduler**
|
||||||
|
- **`--config <pfad>`** für GUI und headless
|
||||||
- **kein Webserver**
|
- **kein Webserver**
|
||||||
- **kein Applikationsserver**
|
- **kein Applikationsserver**
|
||||||
- **keine Dauerlauf-Anwendung**
|
- **keine Dauerlauf-Anwendung**
|
||||||
@@ -86,6 +93,7 @@ Das Projekt ist strikt nach **Ports and Adapters / Hexagonal Architecture** aufg
|
|||||||
- `pdf-umbenenner-domain`
|
- `pdf-umbenenner-domain`
|
||||||
- `pdf-umbenenner-application`
|
- `pdf-umbenenner-application`
|
||||||
- `pdf-umbenenner-adapter-in-cli`
|
- `pdf-umbenenner-adapter-in-cli`
|
||||||
|
- `pdf-umbenenner-adapter-in-gui`
|
||||||
- `pdf-umbenenner-adapter-out`
|
- `pdf-umbenenner-adapter-out`
|
||||||
- `pdf-umbenenner-bootstrap`
|
- `pdf-umbenenner-bootstrap`
|
||||||
|
|
||||||
@@ -135,13 +143,30 @@ Unter Windows:
|
|||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
Das ausführbare Artefakt wird im Bootstrap-Modul erzeugt. Der Start erfolgt als normales Java-Programm:
|
Das ausführbare Artefakt wird im Bootstrap-Modul erzeugt.
|
||||||
|
|
||||||
|
**GUI-Standardstart** (öffnet die JavaFX-Desktop-Oberfläche):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -jar <bootstrap-jar>.jar
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
Die konkrete JAR-Datei hängt vom aktuellen Build-Stand ab.
|
**headless Betrieb** (Batch-/Scheduler-Start ohne GUI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
**Explizite Konfigurationsdatei** (für GUI und headless):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --config C:\Pfad\zur\config.properties
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless --config C:\Pfad\zur\config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
Das JAR ist das einzige Distributionsartefakt und enthält JavaFX für den GUI-Start bereits
|
||||||
|
integriert. Ausführliche Build-, Packaging- und Starthinweise sowie Informationen zur JavaFX-
|
||||||
|
Integration und zum headless Betrieb befinden sich in [`docs/betrieb.md`](docs/betrieb.md).
|
||||||
|
|
||||||
## Logging, Status und Nachvollziehbarkeit
|
## Logging, Status und Nachvollziehbarkeit
|
||||||
|
|
||||||
@@ -160,7 +185,10 @@ Die maßgeblichen Dokumente sind:
|
|||||||
- `CLAUDE.md`
|
- `CLAUDE.md`
|
||||||
- `docs/specs/technik-und-architektur.md`
|
- `docs/specs/technik-und-architektur.md`
|
||||||
- `docs/specs/fachliche-anforderungen.md`
|
- `docs/specs/fachliche-anforderungen.md`
|
||||||
- `docs/specs/meilensteine.md`
|
- `docs/specs/meilensteine-v2_0.md`
|
||||||
|
- `docs/betrieb.md`
|
||||||
|
- `docs/gui-bedienanleitung.md`
|
||||||
|
- `docs/freigabe-v2_0.md`
|
||||||
- `docs/workpackages/...`
|
- `docs/workpackages/...`
|
||||||
|
|
||||||
Empfohlene Leserichtung:
|
Empfohlene Leserichtung:
|
||||||
@@ -169,7 +197,9 @@ Empfohlene Leserichtung:
|
|||||||
2. technische Zielarchitektur
|
2. technische Zielarchitektur
|
||||||
3. fachliche Anforderungen
|
3. fachliche Anforderungen
|
||||||
4. Meilensteine
|
4. Meilensteine
|
||||||
5. aktives Arbeitspaket
|
5. `docs/betrieb.md` für Betriebs- und Startdetails
|
||||||
|
6. `docs/gui-bedienanleitung.md` für die GUI-Bedienung
|
||||||
|
7. aktives Arbeitspaket
|
||||||
|
|
||||||
## Entwicklungsleitplanken
|
## Entwicklungsleitplanken
|
||||||
|
|
||||||
@@ -181,14 +211,16 @@ Empfohlene Leserichtung:
|
|||||||
|
|
||||||
## Status des Projekts
|
## Status des Projekts
|
||||||
|
|
||||||
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand baut auf einem vollständig implementierten Kern für:
|
Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der aktuelle Produktstand (V2.0) baut auf einem vollständig implementierten Kern für:
|
||||||
|
|
||||||
- Konfiguration und Startvalidierung
|
- Konfiguration und Startvalidierung
|
||||||
- Quellordner-Scan und PDF-Textauslese
|
- Quellordner-Scan und PDF-Textauslese
|
||||||
- Fingerprint, SQLite-Persistenz und Idempotenz
|
- Fingerprint, SQLite-Persistenz und Idempotenz
|
||||||
- KI-Integration für Benennungsvorschläge
|
- KI-Integration für Benennungsvorschläge (OpenAI-kompatibel und Anthropic Claude)
|
||||||
- Dateinamensbildung und Zielkopie
|
- Dateinamensbildung und Zielkopie
|
||||||
- Retry-Logik, Logging und betriebliche Robustheit
|
- Retry-Logik, Logging und betriebliche Robustheit
|
||||||
|
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
|
||||||
|
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
|
||||||
|
|
||||||
## Lizenz / Nutzung
|
## Lizenz / Nutzung
|
||||||
|
|
||||||
|
|||||||
@@ -173,3 +173,145 @@ Spezifikationen (technik-und-architektur.md, fachliche-anforderungen.md, CLAUDE.
|
|||||||
vollständig umgesetzt und durch automatisierte Tests abgesichert. Der Maven-Build ist fehlerfrei.
|
vollständig umgesetzt und durch automatisierte Tests abgesichert. Der Maven-Build ist fehlerfrei.
|
||||||
Die CLAUDE.md-Naming-Convention-Regel (kein M1–M8, kein AP-xxx im Produktions- oder Testcode)
|
Die CLAUDE.md-Naming-Convention-Regel (kein M1–M8, kein AP-xxx im Produktions- oder Testcode)
|
||||||
ist vollständig eingehalten. Keine bekannten spezifikationsrelevanten Blocker sind offen.
|
ist vollständig eingehalten. Keine bekannten spezifikationsrelevanten Blocker sind offen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# V2.0-Gesamtstand – Integrierte Prüfung (Stand 2026-04-20)
|
||||||
|
|
||||||
|
**Prüfgrundlage:** Vollständiger Maven-Reactor-Build mit allen Tests (clean verify, `-DskipPitest=true`),
|
||||||
|
Code-Review gegen die verbindliche Spec-Trias (technik-und-architektur.md, fachliche-anforderungen.md,
|
||||||
|
CLAUDE.md), Sichtprüfung Dokumentation und Konfigurationsbeispiele.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build- und Testergebnisse
|
||||||
|
|
||||||
|
**Ausgeführtes Kommando:**
|
||||||
|
```
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gesamtergebnis: BUILD SUCCESS**
|
||||||
|
**Gesamtlaufzeit:** 01:18 min
|
||||||
|
**Datum:** 2026-04-20
|
||||||
|
|
||||||
|
| Modul | Tests | Failures | Errors | Skipped |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` | 147 | 0 | 0 | 0 |
|
||||||
|
| **Gesamt** | **1.398** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
Alle Module bauen erfolgreich. Alle Tests bestehen. Das ausführbare Shade-JAR wird erzeugt und
|
||||||
|
enthält JavaFX (Win-Classifier), alle Module, PDFBox, SQLite-JDBC und Log4j2.
|
||||||
|
|
||||||
|
**Warnungen im Build:**
|
||||||
|
- `WARNING: A Java agent has been loaded dynamically (byte-buddy-agent)` – dies ist ein bekannter
|
||||||
|
Mockito/ByteBuddy-Hinweis, der in Tests auftritt. Kein funktionaler Defekt. Tritt seit V1.1 auf
|
||||||
|
und gilt als akzeptiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prüfpunkte gegen die V2.0-Spezifikation (1–20)
|
||||||
|
|
||||||
|
| Nr. | Prüfpunkt | Status | Begründung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | GUI ist Standardstart | **erfüllt** | `PdfUmbenennerApplication.main()` → `BootstrapRunner.run()` → `StartupMode.GUI` als Default; `--headless` erforderlich für Batch-Pfad. Korrekt implementiert und getestet (`BootstrapRunnerStartupDispatchTest`). |
|
||||||
|
| 2 | `--headless` erhalten, `--config` für beide Startarten | **erfüllt** | `CliArgumentParser` parst beide Optionen korrekt. `BootstrapRunnerConfigPathSemanticsTest` (10 Tests) prüft GUI/headless-Semantik für vorhandene und fehlende `--config`-Pfade. |
|
||||||
|
| 3 | `.properties` als einzige Konfigurationswahrheit | **erfüllt** | `PropertiesConfigurationPortAdapter` und `GuiConfigurationPropertiesWriter` schreiben/lesen ausschließlich `.properties`. Keine zweite Konfigurationswelt. |
|
||||||
|
| 4 | Zwei Provider (Claude, OpenAI-kompatibel), genau einer aktiv | **erfüllt** | `AiProviderSelector` wählt anhand `ai.provider.active`. `AiModelCatalogDispatcher` unterstützt beide Familien. Konfigurationsbeispiel zeigt beide Blöcke. Provider-Identifikator in Versuchshistorie persistiert (`ProviderIdentifierE2ETest`, 5 Tests). |
|
||||||
|
| 5 | Hexagonale Architektur (keine JavaFX in Domain/Application) | **erfüllt** | Grep-Prüfung: kein `import javafx` in `domain` oder `application`. `adapter-in-cli` und `adapter-out` ebenfalls JavaFX-frei. JavaFX ausschließlich in `adapter-in-gui`. |
|
||||||
|
| 6 | Threadingmodell (Worker für I/O, Platform.runLater für UI) | **erfüllt** | `GuiConfigurationEditorWorkspace`, `GuiModelCatalogCoordinator`, `GuiCorrectionDialogCoordinator`, `GuiTechnicalTestCoordinator` nutzen alle explizit `Platform.runLater` für UI-Updates. Worker-Thread-Trennung ist im Code nachweisbar. |
|
||||||
|
| 7 | Naming-Regel (keine M/AP/V-Bezeichner in Code) | **erfüllt** | Grep auf `M[0-9]+`, `AP-[0-9]+`, `V[12]\.[0-9]` in allen `src/main/java` und `src/test/java` liefert keine Treffer für neue V2.0-Module (`adapter-in-gui`, `bootstrap`-Erweiterungen). |
|
||||||
|
| 8 | JavaDoc-Standard | **erfüllt** | Alle neu hinzugefügten öffentlichen Klassen und Methoden haben Klassen- und Methoden-JavaDoc. `BootstrapRunner` und `PdfUmbenennerApplication` vollständig dokumentiert. `package-info.java` vorhanden in neuen Packages. |
|
||||||
|
| 9 | Dirty-State, Schutzdialog, Validierung, technische Tests | **erfüllt** | `GuiDirtyStateTest`, `GuiUnsavedChangesGuardSmokeTest`, `GuiValidateActionSmokeTest`, `GuiTechnicalTestCoordinatorSmokeTest` belegen die Kernpfade. `GuiUnsavedChangesGuard` kapselt die Dirty-State-Logik. |
|
||||||
|
| 10 | Meldungsbereich mit vier Stufen, feldnahe Fehlermeldungen | **erfüllt** | `GuiMessageSeverity` (INFO, HINWEIS, WARNUNG, FEHLER), `GuiMessageEntry` und `GuiFieldFinding` implementieren die Anforderungen. `GuiMessageAreaSmokeTest` und `GuiMessageEntryTest` prüfen sie. |
|
||||||
|
| 11 | Modellabruf, Manueller Modellfallback, Verwerfen-Regel | **erfüllt** | `GuiModelCatalogCoordinator` implementiert automatischen Abruf bei Providerwechsel, ComboBox vs. Textfeld-Umschaltung, und Verwerfen-Regel bei Listenwechsel. `GuiModelCatalogSmokeTest` prüft die Kernpfade. |
|
||||||
|
| 12 | Korrekturhilfen mit gesammeltem Bestätigungsdialog | **erfüllt** | `GuiCorrectionDialogCoordinator` sammelt Korrekturen und steuert den Bestätigungsdialog. `ConfirmationDialogContent` kapselt die Dialog-Inhalte. `GuiCorrectionDialogCoordinatorSmokeTest` prüft den Ablauf. |
|
||||||
|
| 13 | Automatische Prompt-Erzeugung | **erfüllt** | `DefaultPromptTemplate.defaultContent()` in `application`-Schicht; `FilesystemResourceCreationAdapter` erzeugt die Datei. `DefaultPromptTemplateTest` und `FilesystemResourceCreationAdapterTest` prüfen Inhalt und Erzeugung. Beispiel in `docs/examples/prompt.txt` konsistent. |
|
||||||
|
| 14 | Windows-Pfade und gemappte Laufwerke | **erfüllt** | `FilesystemPathCheckAdapter` akzeptiert Windows-Laufwerksbuchstaben. `FilesystemPathCheckAdapterTest` (28 Tests) enthält Windows-Pfad-Szenarien. Dokumentation in `betrieb.md` und `gui-bedienanleitung.md` ausdrücklich erwähnt. |
|
||||||
|
| 15 | Legacy-Migration mit `.bak`-Sicherung | **erfüllt** | `LegacyConfigurationMigrator` in `adapter-out`; GUI-Pfad ruft `detectedLegacyConfiguration` + `migrateConfigurationIfNeeded` in `BootstrapRunner` auf. `GuiConfigurationPropertiesWriterTest` prüft Backup-Schema. |
|
||||||
|
| 16 | Keine neuen Provider über Claude/OpenAI-kompatibel hinaus | **erfüllt** | Codebase enthält ausschließlich `ClaudeAiInvocationAdapter` und `OpenAiCompatibleAiInvocationAdapter`. Kein dritter Provider. |
|
||||||
|
| 17 | Keine neuen Distributionsformate (EXE/Installer) | **erfüllt** | `pom.xml` des Bootstrap-Moduls nutzt ausschließlich `maven-shade-plugin`. Kein `launch4j`, kein `jpackage`, kein Installer. |
|
||||||
|
| 18 | Kein manueller Verarbeitungslauf aus GUI | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
|
||||||
|
| 19 | Keine DB-/Historienanzeige | **erfüllt** | Kein SQLite-Lesepfad aus `adapter-in-gui`. Kein Historien-Tab. Kein Ergebnis-Browser. |
|
||||||
|
| 20 | Keine fachlichen Änderungen an Kernverarbeitung | **erfüllt** | `DefaultBatchRunProcessingUseCase`, `DocumentProcessingCoordinator`, `AiNamingService`, `AiResponseValidator` sind gegenüber dem V1.1-Freigabestand unverändert. E2E-Tests (`BatchRunEndToEndTest`, 11 Szenarien) sind alle grün. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dokumentations-Vollständigkeitsprüfung
|
||||||
|
|
||||||
|
| Dokument | Status | Bewertung |
|
||||||
|
|---|---|---|
|
||||||
|
| `docs/betrieb.md` | vollständig | Alle V2.0-Themen abgedeckt: GUI-Standardstart, `--headless`, `--config`-Semantik für beide Modi, Plattformhinweis Windows, gemappte Laufwerke, GUI-Umfangsbegrenzung, Build- und Packaging-Abschnitt, JavaFX-Integration, headless ohne JavaFX-Initialisierung |
|
||||||
|
| `docs/gui-bedienanleitung.md` | vollständig | Alle AP-002-Themen abgedeckt: Startzustände (Abschnitte 2.1–2.4), alle 7 Aktionen (Abschnitte 4.1–4.7), vier Meldungsstufen (Abschnitt 3.2), feldnahe Fehlermeldungen (Abschnitt 5), Provider-Bedienung und Modellabruf (Abschnitt 7), API-Key-Auflösungsreihenfolge (Abschnitt 10), Dirty-State (Abschnitt 8), `.bak`-Sicherung und Legacy-Migration (Abschnitt 9), Windows-Hinweise (Abschnitt 12), bekannte Einschränkungen (Abschnitt 13) |
|
||||||
|
| `docs/examples/application.properties` | vollständig und konsistent | Alle Parameter des V2.0-Schemas vorhanden (beide Provider-Blöcke, alle Pflicht- und Optionalparameter). Kommentare zu Warnschwellen für `max.text.characters` enthalten. Default-Provider `claude` gesetzt (alphabetisch erster). Konsistent mit `GuiConfigurationTemplateFactory`. |
|
||||||
|
| `docs/examples/prompt.txt` | vollständig und konsistent | Deutschsprachiger Standardprompt. Inhaltlich identisch mit dem, was `DefaultPromptTemplate.defaultContent()` erzeugt (durch `FilesystemResourceCreationAdapterTest` nachgewiesen). JSON-Schema-Anforderungen (title, reasoning, date optional) abgebildet. |
|
||||||
|
| `README.md` | vollständig | V2.0-Hinweis im Header, GUI-Standardstart dokumentiert, `--headless` und `--config`-Beispiele vorhanden, Modul `pdf-umbenenner-adapter-in-gui` aufgelistet, Verweis auf `betrieb.md` und `gui-bedienanleitung.md`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release-Blocker
|
||||||
|
|
||||||
|
**Keine Release-Blocker identifiziert.**
|
||||||
|
|
||||||
|
Der vollständige Maven-Reactor-Build ist grün (1.398 Tests, 0 Failures, 0 Errors, 0 Skipped).
|
||||||
|
Alle 20 Prüfpunkte gegen die Spec-Trias sind als erfüllt bewertet. Die Dokumentation ist
|
||||||
|
vollständig und konsistent mit dem Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht blockierende Restpunkte
|
||||||
|
|
||||||
|
#### R1 – ByteBuddy-Agent-Warnung bei Tests
|
||||||
|
|
||||||
|
**Thema:** Testqualität / Laufzeitwarnung
|
||||||
|
**Befund:** Beim Build erscheint wiederholt `WARNING: A Java agent has been loaded dynamically
|
||||||
|
(byte-buddy-agent-1.14.12.jar)`. Der Hinweis stammt von Mockito und tritt seit dem V1.1-Stand auf.
|
||||||
|
Er ist nicht neu, betrifft nur die Testlaufzeit und hat keinen funktionalen Einfluss auf das
|
||||||
|
produzierte Artefakt.
|
||||||
|
**Bewertung:** Kein Handlungsbedarf. Mit `-XX:+EnableDynamicAgentLoading` unterdrückbar, aber
|
||||||
|
keine Pflicht für V2.0.
|
||||||
|
|
||||||
|
#### R2 – GUI-Tests ohne echten JavaFX-Rendering-Pfad
|
||||||
|
|
||||||
|
**Thema:** Testtiefe GUI
|
||||||
|
**Befund:** Die GUI-Tests (`GuiAdapterSmokeTest`, `GuiEditorRegressionSmokeTest` usw.) laufen
|
||||||
|
unter headless JavaFX (Monocle) und prüfen View-Modell-Logik, Zustandsübergänge und
|
||||||
|
Koordinatoren-Verhalten. Das visuelle Rendering der Oberfläche (Farbgebung der Meldungspräfixe,
|
||||||
|
Layout-Details) ist nicht automatisiert geprüft. Dies entspricht der in CLAUDE.md definierten
|
||||||
|
GUI-Teststrategie (kein TestFX über Monocle hinaus) und ist keine Abweichung vom Ziel.
|
||||||
|
**Bewertung:** Kein Handlungsbedarf. Entspricht der Teststrategie-Vorgabe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewusst außerhalb V2.0 liegende Themen (V2.1+)
|
||||||
|
|
||||||
|
Die folgenden Themen wurden im V2.0-Umfang nachweislich **nicht** implementiert und sind
|
||||||
|
ausdrücklich für spätere Ausbaustufen vorgesehen:
|
||||||
|
|
||||||
|
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+)
|
||||||
|
- **DB-/Historienansicht** in der GUI (V2.x+)
|
||||||
|
- **Kosten-Tracking** und Token-/Preisberechnung (V2.x+)
|
||||||
|
- **EXE-Wrapper / Installer** (V3+)
|
||||||
|
- **Weitere KI-Provider** über Claude und OpenAI-kompatibel hinaus (V3+)
|
||||||
|
- **Automatischer Fallback zwischen Providern** (V3+)
|
||||||
|
- **Profilverwaltung mit mehreren Konfigurationen je Provider** (V3+)
|
||||||
|
- **Plattformübergreifender offizieller GUI-Support** (V3+)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gesamtbewertung V2.0-Stand
|
||||||
|
|
||||||
|
| Klassifikation | Anzahl | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| Release-Blocker | **0** | – |
|
||||||
|
| Nicht blockierende Restpunkte | **2** | R1 ByteBuddy-Warnung, R2 Testtiefe GUI-Rendering |
|
||||||
|
| Bewusst außerhalb V2.0 | **8** | Manueller Lauf, Historienansicht, Kosten-Tracking, EXE, weitere Provider, Fallback, Profile, Cross-Platform |
|
||||||
|
|
||||||
|
**Build:** ERFOLGREICH · 1.398 Tests · 0 Failures · 0 Errors · Laufzeit 01:18 min
|
||||||
|
**Alle 20 Spezifikations-Prüfpunkte:** erfüllt
|
||||||
|
**Dokumentation:** vollständig und konsistent
|
||||||
|
|||||||
+187
-11
@@ -8,10 +8,70 @@ und legt eine Kopie im konfigurierten Zielordner ab. Die Quelldatei bleibt unver
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Startmodi und Betriebsmodell (V2.0)
|
||||||
|
|
||||||
|
Ab V2.0 enthält die Anwendung zwei Startmodi in **einem gemeinsamen ausführbaren JAR**:
|
||||||
|
|
||||||
|
| Startmodus | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **GUI-Start** (Standard) | Öffnet die JavaFX-Desktop-GUI. Wird verwendet, wenn kein `--headless` angegeben ist. |
|
||||||
|
| **headless Betrieb** | Klassischer Batch-/Scheduler-Betrieb ohne grafische Oberfläche. Wird über `--headless` aktiviert. |
|
||||||
|
|
||||||
|
### CLI-Optionen
|
||||||
|
|
||||||
|
| Option | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| *(keine Argumente)* | GUI-Standardstart |
|
||||||
|
| `--headless` | Aktiviert den headless Batch-Betrieb (wie vor V2.0) |
|
||||||
|
| `--config <pfad>` | Zeigt explizit auf eine `.properties`-Konfigurationsdatei (für GUI und headless) |
|
||||||
|
|
||||||
|
`--config` und `--headless` können kombiniert werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
java -jar pdf-umbenenner-bootstrap-*.jar --headless --config C:\Pfad\zur\config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verhalten bei fehlender oder ungültiger `--config`-Datei
|
||||||
|
|
||||||
|
| Startmodus | Datei nicht vorhanden | Datei vorhanden, aber ungültig |
|
||||||
|
|---|---|---|
|
||||||
|
| **headless** | Harter Startfehler, Exit-Code `1`, kein Fallback | Harter Startfehler, Exit-Code `1` |
|
||||||
|
| **GUI** | Fehlermeldung in der GUI, danach Verhalten wie ohne `--config` (Willkommenstext) | Fehlermeldung in der GUI, Konfiguration nicht geladen |
|
||||||
|
|
||||||
|
Im headless Betrieb ist ein nicht vorhandener `--config`-Pfad ein **harter Startfehler**. Ein stiller
|
||||||
|
Fallback auf das Default-Verhalten ist in diesem Fall ausdrücklich unzulässig.
|
||||||
|
|
||||||
|
### Verhalten bei GUI-Startfehlern
|
||||||
|
|
||||||
|
Tritt vor der erfolgreichen Anzeige der grafischen Oberfläche ein nicht behebbarer Fehler auf
|
||||||
|
(z. B. fehlende JavaFX-Laufzeit, Bootstrap-Fehler), beendet sich die Anwendung mit Exit-Code `1`.
|
||||||
|
|
||||||
|
### Plattform und Laufwerksbuchstaben
|
||||||
|
|
||||||
|
Die GUI wird **offiziell nur unter Windows** unterstützt. Der headless Betrieb bleibt für den
|
||||||
|
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
|
||||||
|
|
||||||
|
Die GUI in V2.0 dient ausschließlich als:
|
||||||
|
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Java 21 (JRE oder JDK)
|
- Java 21 (JRE oder JDK)
|
||||||
- Zugang zu einem OpenAI-kompatiblen KI-Dienst (API-Schlüssel erforderlich)
|
- Zugang zu einem KI-Dienst (API-Schlüssel erforderlich; unterstützte Provider: OpenAI-kompatibel, Anthropic Claude)
|
||||||
- Quellordner mit OCR-verarbeiteten PDF-Dateien
|
- Quellordner mit OCR-verarbeiteten PDF-Dateien
|
||||||
- Schreibzugriff auf Zielordner und Datenbankverzeichnis
|
- Schreibzugriff auf Zielordner und Datenbankverzeichnis
|
||||||
|
|
||||||
@@ -26,16 +86,24 @@ Das ausführbare JAR wird durch den Maven-Build im Verzeichnis
|
|||||||
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Anwendung liest die Konfiguration aus `config/application.properties` relativ zum
|
Ohne weitere Argumente öffnet sich die **GUI** (Standardstart ab V2.0).
|
||||||
|
|
||||||
|
Für den **headless Betrieb** (Batch-/Scheduler-Start):
|
||||||
|
|
||||||
|
```
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung liest die Konfiguration standardmäßig aus `config/application.properties` relativ zum
|
||||||
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
|
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
|
||||||
|
|
||||||
### Start über Windows Task Scheduler
|
### Start über Windows Task Scheduler
|
||||||
|
|
||||||
Empfohlene Startsequenz für den Windows Task Scheduler:
|
Empfohlene Startsequenz für den headless Betrieb über den Windows Task Scheduler:
|
||||||
|
|
||||||
1. Aktion: Programm/Skript starten
|
1. Aktion: Programm/Skript starten
|
||||||
2. Programm: `java`
|
2. Programm: `java`
|
||||||
3. Argumente: `-jar C:\Pfad\zur\Installation\pdf-umbenenner-bootstrap\target\pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar`
|
3. Argumente: `-jar C:\Pfad\zur\Installation\pdf-umbenenner-bootstrap\target\pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless`
|
||||||
4. Starten in: `C:\Pfad\zur\Installation` (muss das Verzeichnis mit `config\application.properties` und `config\prompts\` enthalten)
|
4. Starten in: `C:\Pfad\zur\Installation` (muss das Verzeichnis mit `config\application.properties` und `config\prompts\` enthalten)
|
||||||
|
|
||||||
> **Hinweis:** Das „Starten in"-Verzeichnis ist das Arbeitsverzeichnis der Anwendung.
|
> **Hinweis:** Das „Starten in"-Verzeichnis ist das Arbeitsverzeichnis der Anwendung.
|
||||||
@@ -43,11 +111,26 @@ Empfohlene Startsequenz für den Windows Task Scheduler:
|
|||||||
> `config/prompts/` müssen relativ zu diesem Verzeichnis erreichbar sein. Der JAR-Pfad
|
> `config/prompts/` müssen relativ zu diesem Verzeichnis erreichbar sein. Der JAR-Pfad
|
||||||
> in den Argumenten muss absolut oder relativ zum Starten-in-Verzeichnis korrekt angegeben sein.
|
> in den Argumenten muss absolut oder relativ zum Starten-in-Verzeichnis korrekt angegeben sein.
|
||||||
|
|
||||||
|
Alternativ kann über `--config` ein expliziter Konfigurationspfad angegeben werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
java -jar ... --headless --config S:\Betrieb\meine-config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Wichtig:** Zeigt `--config` auf eine nicht vorhandene Datei, bricht die Anwendung mit Exit-Code `1` ab.
|
||||||
|
> Es findet kein stiller Fallback auf `config/application.properties` statt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Die Konfiguration wird aus `config/application.properties` geladen.
|
Die Konfiguration wird aus `config/application.properties` geladen.
|
||||||
|
|
||||||
|
Ein vollständiges Konfigurationsbeispiel mit allen unterstützten Parametern,
|
||||||
|
realistischen Windows-Pfaden und erklärenden Kommentaren liegt unter:
|
||||||
|
|
||||||
|
- [`docs/examples/application.properties`](examples/application.properties)
|
||||||
|
|
||||||
Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
||||||
|
|
||||||
- `config/application-local.example.properties`
|
- `config/application-local.example.properties`
|
||||||
@@ -155,16 +238,17 @@ Der Dateiname der Prompt-Datei dient als Prompt-Identifikator in der Versuchshis
|
|||||||
(SQLite) und ermöglicht so die Nachvollziehbarkeit, welche Prompt-Version für welchen
|
(SQLite) und ermöglicht so die Nachvollziehbarkeit, welche Prompt-Version für welchen
|
||||||
Verarbeitungsversuch verwendet wurde.
|
Verarbeitungsversuch verwendet wurde.
|
||||||
|
|
||||||
Eine Vorlage befindet sich in `config/prompts/template.txt` und kann direkt verwendet oder
|
Eine angepasste Vorlage befindet sich in `config/prompts/template.txt` und kann direkt
|
||||||
an den jeweiligen KI-Dienst angepasst werden.
|
verwendet oder an den jeweiligen KI-Dienst angepasst werden.
|
||||||
|
|
||||||
|
Fehlt die konfigurierte Prompt-Datei, erzeugt die GUI beim Ausführen der technischen Tests
|
||||||
|
automatisch eine deutsche Standardvorlage am konfigurierten Pfad. Ein Beispiel dieser
|
||||||
|
Standardvorlage liegt unter [`docs/examples/prompt.txt`](examples/prompt.txt).
|
||||||
|
|
||||||
Die Anwendung ergänzt den Prompt automatisch um:
|
Die Anwendung ergänzt den Prompt automatisch um:
|
||||||
- einen Dokumenttext-Abschnitt
|
- einen Dokumenttext-Abschnitt
|
||||||
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
|
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
|
||||||
|
|
||||||
Der Prompt in `template.txt` muss deshalb **keine** JSON-Formatanweisung enthalten –
|
|
||||||
nur den inhaltlichen Auftrag an die KI.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Zielformat
|
## Zielformat
|
||||||
@@ -279,11 +363,103 @@ Sie muss nicht manuell verwaltet werden – das Schema wird beim Start automatis
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Build und Packaging
|
||||||
|
|
||||||
|
### Gemeinsames ausführbares JAR
|
||||||
|
|
||||||
|
Die gesamte Anwendung wird als **ein einziges ausführbares JAR** ausgeliefert, das GUI-Start
|
||||||
|
und headless Batch-Betrieb vereint. Eine separate JavaFX-Installation ist nicht erforderlich.
|
||||||
|
|
||||||
|
Das JAR wird vom Maven-Shade-Plugin im Modul `pdf-umbenenner-bootstrap` erzeugt.
|
||||||
|
Nach einem erfolgreichen Build liegt es unter:
|
||||||
|
|
||||||
|
```
|
||||||
|
pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieses JAR enthält alle Abhängigkeiten inklusive der JavaFX-Plattformbibliotheken
|
||||||
|
für Windows (Classifier `win`). Die nativen JavaFX-DLLs werden beim GUI-Start
|
||||||
|
von JavaFX selbst in ein temporäres Verzeichnis extrahiert.
|
||||||
|
|
||||||
|
### Integrierte JavaFX-Laufzeit
|
||||||
|
|
||||||
|
JavaFX ist als Maven-Dependency im Modul `pdf-umbenenner-adapter-in-gui` mit
|
||||||
|
Windows-Classifier deklariert (`javafx-base:win`, `javafx-graphics:win`,
|
||||||
|
`javafx-controls:win`). Das Shade-JAR schließt diese Bibliotheken ein, sodass
|
||||||
|
der GUI-Start ohne separate JavaFX-Installation auf dem Zielsystem funktioniert.
|
||||||
|
|
||||||
|
Nur das Modul `pdf-umbenenner-adapter-in-gui` hängt direkt von JavaFX ab.
|
||||||
|
Die Module `domain`, `application`, `adapter-in-cli` und `adapter-out` sind
|
||||||
|
vollständig JavaFX-frei.
|
||||||
|
|
||||||
|
### Headless-Start ohne JavaFX-Initialisierung
|
||||||
|
|
||||||
|
Beim headless Start (`--headless`) wird JavaFX **nicht** initialisiert. Der
|
||||||
|
`GuiAdapter` wird nur dann instanziiert und gestartet, wenn der Startmodus GUI ist.
|
||||||
|
JavaFX-Klassen sind zwar im Shade-JAR enthalten, werden im headless Pfad jedoch
|
||||||
|
nicht geladen. Headless läuft damit auch auf Windows Server-Systemen ohne
|
||||||
|
JavaFX-fähige Grafiklaufzeit.
|
||||||
|
|
||||||
|
### Keine EXE, kein Installer
|
||||||
|
|
||||||
|
In V2.0 wird ausschließlich das JAR als Distributionsartefakt ausgeliefert.
|
||||||
|
EXE-Wrapper und Installer sind bewusst nicht Bestandteil von V2.0.
|
||||||
|
|
||||||
|
### Build-Kommandos
|
||||||
|
|
||||||
|
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf Unix-Systemen (headless CI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw clean verify
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nur das ausführbare JAR erzeugen** (überspringt Tests):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean package -pl pdf-umbenenner-bootstrap --also-make -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selektiver Reactor-Build** (ohne Coverage-Modul, z. B. während der Entwicklung):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technische Hinweise zum Shade-JAR
|
||||||
|
|
||||||
|
- Signaturdateien (`*.SF`, `*.DSA`, `*.RSA`) signierter JARs (u. a. JavaFX) werden
|
||||||
|
beim Shading entfernt, da sie im zusammengeführten JAR ungültig wären.
|
||||||
|
- JPMS-Moduldeskriptoren (`module-info.class`) werden entfernt, da JavaFX als
|
||||||
|
modulares Framework mit dem nicht-modularen Fat-JAR-Modell kollidieren würde.
|
||||||
|
- `META-INF/services`-Einträge aus allen Abhängigkeiten werden durch den
|
||||||
|
`ServicesResourceTransformer` zusammengeführt statt überschrieben.
|
||||||
|
- Der Main-Class-Eintrag im Manifest verweist auf
|
||||||
|
`de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication`.
|
||||||
|
Diese Klasse erweitert bewusst **nicht** `javafx.application.Application`,
|
||||||
|
um den JavaFX-Modul-System-Launcher-Check zu umgehen, der Fat-JAR-Ausführung
|
||||||
|
blockieren würde. Der GUI-Pfad ruft `Application.launch(...)` explizit auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weitere Dokumentation
|
||||||
|
|
||||||
|
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Systemgrenzen
|
## Systemgrenzen
|
||||||
|
|
||||||
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
|
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
|
||||||
- Keine eingebaute OCR-Funktion
|
- Keine eingebaute OCR-Funktion
|
||||||
- Kein Web-UI, keine REST-API, keine interaktive Bedienung
|
- Kein Web-UI, keine REST-API
|
||||||
- Kein interner Scheduler – der Start erfolgt extern (z. B. Windows Task Scheduler)
|
- Die GUI (V2.0) dient der Konfiguration, Validierung und technischen Diagnose – **kein** manueller Verarbeitungslauf aus der GUI
|
||||||
|
- Kein interner Scheduler – der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`)
|
||||||
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
|
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
|
||||||
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
|
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
|
||||||
|
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# PDF Umbenenner – vollstaendiges Konfigurationsbeispiel (V2.0)
|
||||||
|
#
|
||||||
|
# Diese Datei zeigt alle unterstuetzten Konfigurationsparameter mit realistischen
|
||||||
|
# Windows-Pfaden und erklaerenden Kommentaren.
|
||||||
|
#
|
||||||
|
# Fuer den produktiven Einsatz: Datei nach config/application.properties kopieren
|
||||||
|
# und Werte anpassen. Der headless Batch-Betrieb liest standardmaessig
|
||||||
|
# config/application.properties relativ zum Arbeitsverzeichnis.
|
||||||
|
#
|
||||||
|
# Die GUI schlaegt beim "Speichern unter" denselben Pfad vor.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pfade
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden.
|
||||||
|
# Der Ordner muss vorhanden und lesbar sein.
|
||||||
|
# Beispiel: gemapptes Netzlaufwerk (wird ausdruecklich unterstuetzt)
|
||||||
|
source.folder=S:\\Eingang
|
||||||
|
|
||||||
|
# Zielordner: Ordner, in den die umbenannten Kopien abgelegt werden.
|
||||||
|
# Wird automatisch angelegt, wenn er noch nicht existiert (Schreibzugriff erforderlich).
|
||||||
|
target.folder=S:\\Archiv
|
||||||
|
|
||||||
|
# SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie.
|
||||||
|
# Das uebergeordnete Verzeichnis muss vorhanden sein.
|
||||||
|
sqlite.file=S:\\Archiv\\pdf-umbenenner.db
|
||||||
|
|
||||||
|
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
|
||||||
|
# in der Versuchshistorie und ermoeg licht die Nachvollziehbarkeit der verwendeten
|
||||||
|
# Prompt-Version. Fehlt die Datei, kann die GUI sie automatisch anlegen (deutsche
|
||||||
|
# Standardvorlage). Ein Beispiel der Standardvorlage liegt unter docs/examples/prompt.txt.
|
||||||
|
prompt.template.file=S:\\Archiv\\prompt.txt
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Aktiver KI-Provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Genau ein Provider ist aktiv. Kein automatischer Fallback, keine parallele Nutzung.
|
||||||
|
# Erlaubte Werte: claude, openai-compatible
|
||||||
|
#
|
||||||
|
# Hinweis: Die GUI-Standardvorlage ("Neu") setzt standardmaessig "claude" als aktiven
|
||||||
|
# Provider, weil Claude alphabetisch der erste unterstuetzte Provider ist.
|
||||||
|
ai.provider.active=claude
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider: Anthropic Claude
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wird verwendet, wenn ai.provider.active=claude gesetzt ist.
|
||||||
|
|
||||||
|
# Basis-URL des Anthropic-Dienstes (Standard: https://api.anthropic.com)
|
||||||
|
ai.provider.claude.baseUrl=https://api.anthropic.com
|
||||||
|
|
||||||
|
# Modellname (z. B. claude-3-5-sonnet-20241022)
|
||||||
|
ai.provider.claude.model=claude-3-5-sonnet-20241022
|
||||||
|
|
||||||
|
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||||
|
ai.provider.claude.timeoutSeconds=60
|
||||||
|
|
||||||
|
# API-Schluessel fuer Anthropic.
|
||||||
|
# Vorrangreihenfolge: Umgebungsvariable ANTHROPIC_API_KEY > dieser Wert.
|
||||||
|
# Das Feld darf leer bleiben, wenn die Umgebungsvariable gesetzt ist.
|
||||||
|
ai.provider.claude.apiKey=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider: OpenAI-kompatibel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wird verwendet, wenn ai.provider.active=openai-compatible gesetzt ist.
|
||||||
|
# Geeignet fuer OpenAI selbst und jeden API-kompatiblen Drittanbieter.
|
||||||
|
|
||||||
|
# Basis-URL des KI-Dienstes (ohne Pfadsuffix wie /chat/completions).
|
||||||
|
ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# Modellname (z. B. gpt-4o-mini)
|
||||||
|
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||||
|
|
||||||
|
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||||
|
ai.provider.openai-compatible.timeoutSeconds=30
|
||||||
|
|
||||||
|
# API-Schluessel fuer OpenAI-kompatible Dienste.
|
||||||
|
# Vorrangreihenfolge: OPENAI_COMPATIBLE_API_KEY (Umgebungsvariable) >
|
||||||
|
# PDF_UMBENENNER_API_KEY (veraltete Umgebungsvariable, weiterhin akzeptiert) >
|
||||||
|
# ai.provider.openai-compatible.apiKey (dieser Wert)
|
||||||
|
# Das Feld darf leer bleiben, wenn die Umgebungsvariable gesetzt ist.
|
||||||
|
ai.provider.openai-compatible.apiKey=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verarbeitungslimits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument.
|
||||||
|
# Muss eine ganze Zahl >= 1 sein. Wert 0 ist ungueltige Konfiguration.
|
||||||
|
max.retries.transient=3
|
||||||
|
|
||||||
|
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
|
||||||
|
# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf).
|
||||||
|
max.pages=10
|
||||||
|
|
||||||
|
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
|
||||||
|
# Werte bis 1000: unkritisch.
|
||||||
|
# Werte 1001-3000: erhoehte KI-Kosten moeglich (Warnung in der GUI).
|
||||||
|
# Werte ab 3001: deutlich erhoehte KI-Kosten moeglich (starke Warnung in der GUI).
|
||||||
|
# Standardvorlage der GUI: 5000.
|
||||||
|
max.text.characters=5000
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Optionale Parameter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Lock-Datei fuer den Startschutz (verhindert parallele Instanzen).
|
||||||
|
# Ohne Konfiguration: pdf-umbenenner.lock im Arbeitsverzeichnis.
|
||||||
|
runtime.lock.file=S:\\Archiv\\pdf-umbenenner.lock
|
||||||
|
|
||||||
|
# Log-Verzeichnis. Ohne Konfiguration: ./logs/ im Arbeitsverzeichnis.
|
||||||
|
log.directory=S:\\Archiv\\logs
|
||||||
|
|
||||||
|
# Log-Level (DEBUG, INFO, WARN, ERROR). Standard: INFO.
|
||||||
|
log.level=INFO
|
||||||
|
|
||||||
|
# Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben.
|
||||||
|
# Erlaubte Werte: true oder false. Standard: false (geschuetzt).
|
||||||
|
# Die KI-Rohantwort wird unabhaengig davon immer in der SQLite-Datenbank gespeichert.
|
||||||
|
log.ai.sensitive=false
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
|
||||||
|
Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
|
||||||
|
einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
|
||||||
|
|
||||||
|
Antworte ausschließlich mit einem validen JSON-Objekt im folgenden Schema:
|
||||||
|
{
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"title": "Kurztitel auf Deutsch",
|
||||||
|
"reasoning": "Kurze Begründung auf Deutsch"
|
||||||
|
}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Das Feld "title" ist verpflichtend.
|
||||||
|
- Das Feld "reasoning" ist verpflichtend.
|
||||||
|
- Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
|
||||||
|
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
|
||||||
|
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
|
||||||
|
- Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
|
||||||
|
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
|
||||||
|
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||||
|
- Eigennamen bleiben unverändert.
|
||||||
|
- Umlaute und ß sind erlaubt.
|
||||||
|
- Kein Text außerhalb des JSON-Objekts.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# V2.0-Freigabe
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Git-Commit (HEAD, zum Zeitpunkt der Prüfung): `1bb7a427357c73039c09a8e1bfe351dee54df765`
|
||||||
|
- Datum der Prüfung: 2026-04-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Vollständiger Maven-Reactor-Build (`clean verify`, alle 6 Module, `-DskipPitest=true`) | **ERFOLGREICH** |
|
||||||
|
| Unit-Tests gesamt | **1.398 Tests, 0 Failures, 0 Errors, 0 Skipped** |
|
||||||
|
| Integrations-/Smoke-Tests (`ExecutableJarSmokeTestIT`, Bootstrap) | **5 Tests, alle grün** |
|
||||||
|
| Shaded-JAR erzeugt unter `pdf-umbenenner-bootstrap/target/` | **ja** |
|
||||||
|
| Konfigurations- und Dokumentationsbeispiele auf Konsistenz geprüft | **ja** |
|
||||||
|
| Bedienanleitung (`gui-bedienanleitung.md`) gegen reales GUI-Verhalten stichprobengeprüft | **ja – ein während der Prüfung identifizierter Befund wurde unmittelbar korrigiert (siehe R3)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build- und Test-Ergebnisse
|
||||||
|
|
||||||
|
Ausgeführtes Kommando:
|
||||||
|
```
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gesamtergebnis: BUILD SUCCESS**
|
||||||
|
**Laufzeit: 01:15 min**
|
||||||
|
|
||||||
|
| Modul | Tests | Failures | Errors | Skipped |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` (Unit) | 147 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` (IT) | 5 | 0 | 0 | 0 |
|
||||||
|
| **Gesamt** | **1.403** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
Erzeugtes Shaded-JAR: `pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar`
|
||||||
|
Größe: ca. 28 MB (enthält JavaFX für Windows, PDFBox, SQLite-JDBC, Log4j2 und alle Module)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene nicht blockierende Restpunkte
|
||||||
|
|
||||||
|
Die folgenden Restpunkte wurden in der integrierten Gesamtprüfung dokumentiert und
|
||||||
|
gelten als freigabekompatibel.
|
||||||
|
|
||||||
|
### R1 – ByteBuddy-Agent-Warnung bei Tests
|
||||||
|
|
||||||
|
Beim Build erscheint wiederholt `WARNING: A Java agent has been loaded dynamically
|
||||||
|
(byte-buddy-agent-1.14.12.jar)`. Der Hinweis stammt von Mockito und tritt seit dem V1.1-Stand
|
||||||
|
auf. Kein funktionaler Einfluss. Mit `-XX:+EnableDynamicAgentLoading` unterdrückbar, aber keine
|
||||||
|
Pflicht für V2.0.
|
||||||
|
|
||||||
|
### R2 – GUI-Tests ohne echten JavaFX-Rendering-Pfad
|
||||||
|
|
||||||
|
Die GUI-Tests laufen unter headless JavaFX (Monocle) und prüfen View-Modell-Logik,
|
||||||
|
Zustandsübergänge und Koordinatoren-Verhalten. Das visuelle Rendering (Farbgebung der
|
||||||
|
Meldungspräfixe, Layout-Details) ist nicht automatisiert geprüft. Dies entspricht der in
|
||||||
|
CLAUDE.md definierten GUI-Teststrategie (kein TestFX über Monocle hinaus).
|
||||||
|
|
||||||
|
### R3 – Bedienanleitung: Legacy-Umgebungsvariable für OpenAI-kompatibel (behoben)
|
||||||
|
|
||||||
|
**Ursprünglicher Befund:** Abschnitt 10 der `gui-bedienanleitung.md` beschrieb
|
||||||
|
`OPENAI_COMPATIBLE_API_KEY` fälschlich als Legacy-Umgebungsvariable. Tatsächlich ist
|
||||||
|
`OPENAI_COMPATIBLE_API_KEY` die primäre providerspezifische Umgebungsvariable, und die
|
||||||
|
echte Legacy-Umgebungsvariable lautet `PDF_UMBENENNER_API_KEY`.
|
||||||
|
|
||||||
|
**Status:** Korrigiert. Abschnitt 10 benennt jetzt `OPENAI_COMPATIBLE_API_KEY` als
|
||||||
|
primäre Variable und `PDF_UMBENENNER_API_KEY` als Legacy-Variable und hält fest, dass
|
||||||
|
Claude keine Legacy-Variable hat.
|
||||||
|
|
||||||
|
**Kein Release-Blocker und im V2.0-Freigabestand nicht mehr offen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V2.0 ist nach Prüfung fehlerfrei buildbar, vollständig nach Spezifikation umgesetzt
|
||||||
|
und als freigabefähig einzustufen. Keine Release-Blocker. Die zwei nicht blockierenden
|
||||||
|
Restpunkte (R1, R2) sind dokumentiert und können außerhalb des V2.0-Scopes adressiert
|
||||||
|
werden; R3 wurde während der finalen Prüfung unmittelbar behoben.
|
||||||
|
|
||||||
|
Der vollständige Maven-Reactor-Build ist grün (1.403 Tests, 0 Failures, 0 Errors,
|
||||||
|
0 Skipped). Alle in der integrierten Gesamtprüfung definierten Spezifikations-
|
||||||
|
Prüfpunkte gegen die Spec-Trias sind als erfüllt bestätigt. Die Dokumentation ist
|
||||||
|
vollständig und konsistent mit dem Code.
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
# GUI-Bedienanleitung – PDF-Umbenenner V2.0
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie richtet sich an
|
||||||
|
Endbenutzer und Betreuer, die die Konfiguration der Anwendung über die grafische Oberfläche
|
||||||
|
verwalten und technisch prüfen möchten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zweck und Scope der GUI in V2.0
|
||||||
|
|
||||||
|
Die GUI dient in V2.0 ausschließlich als:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Der headless Batch-/Scheduler-Betrieb über `--headless` bleibt der einzige Weg,
|
||||||
|
PDF-Dateien automatisiert zu verarbeiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Startzustände
|
||||||
|
|
||||||
|
### 2.1 GUI-Standardstart
|
||||||
|
|
||||||
|
Wird die Anwendung ohne CLI-Argumente gestartet, öffnet sich die JavaFX-Desktop-GUI.
|
||||||
|
Es wird keine Konfigurationsdatei automatisch geladen.
|
||||||
|
|
||||||
|
Stattdessen zeigt die GUI einen deutschen Willkommenstext mit dem Hinweis, über
|
||||||
|
„Neu" eine Standardvorlage zu erzeugen oder über „Öffnen" eine bestehende
|
||||||
|
`.properties`-Datei zu laden.
|
||||||
|
|
||||||
|
### 2.2 Start mit `--config <pfad>` (gültige Datei)
|
||||||
|
|
||||||
|
Wird beim Start eine gültige `.properties`-Datei über `--config <pfad>` angegeben,
|
||||||
|
lädt die GUI diese Datei automatisch und zeigt den Inhalt im Editor an. Der Pfad
|
||||||
|
wird im Header angezeigt.
|
||||||
|
|
||||||
|
### 2.3 Start mit `--config <pfad>` (ungültiger oder nicht vorhandener Pfad)
|
||||||
|
|
||||||
|
Zeigt der angegebene Pfad auf eine nicht vorhandene oder nicht lesbare Datei,
|
||||||
|
erscheint eine Fehlermeldung im zentralen Meldungsbereich. Danach verhält sich die
|
||||||
|
GUI so, als wäre `--config` nicht angegeben worden: Es erscheint der Willkommenstext,
|
||||||
|
und der Benutzer kann manuell eine Datei öffnen oder eine neue Konfiguration anlegen.
|
||||||
|
|
||||||
|
Im Unterschied zum headless Betrieb ist dies kein harter Startfehler – die GUI
|
||||||
|
bleibt vollständig bedienbar.
|
||||||
|
|
||||||
|
### 2.4 GUI-Startfehler vor Anzeige der Oberfläche
|
||||||
|
|
||||||
|
Tritt vor der erfolgreichen Anzeige der grafischen Oberfläche ein nicht behebbarer
|
||||||
|
Fehler auf (z. B. fehlende JavaFX-Laufzeit, schwerwiegender Bootstrap-Fehler),
|
||||||
|
beendet sich die Anwendung mit Exit-Code `1`. In diesem Fall wird keine Oberfläche
|
||||||
|
angezeigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Header und Meldungsbereich
|
||||||
|
|
||||||
|
### 3.1 Header
|
||||||
|
|
||||||
|
Der Header oben im Fenster zeigt den Pfad der aktuell geladenen `.properties`-Datei.
|
||||||
|
Ist keine Datei geladen, bleibt der Pfadbereich leer oder zeigt einen entsprechenden
|
||||||
|
Hinweis.
|
||||||
|
|
||||||
|
Sobald ungespeicherte Änderungen vorliegen, erscheinen zwei visuelle Markierungen:
|
||||||
|
|
||||||
|
- ein kleines **„geändert"**-Label im Header
|
||||||
|
- ein führendes **`*`** im Fenstertitel
|
||||||
|
|
||||||
|
Diese Markierungen verschwinden, sobald die Datei gespeichert wurde oder Änderungen
|
||||||
|
verworfen werden.
|
||||||
|
|
||||||
|
### 3.2 Zentraler Meldungsbereich
|
||||||
|
|
||||||
|
Am unteren Ende der GUI befindet sich ein großer, nicht editierbarer
|
||||||
|
Meldungsbereich. Er ist dauerhaft sichtbar und zeigt Ergebnisse von
|
||||||
|
Validierungen, technischen Tests, Migrationsmeldungen und sonstige
|
||||||
|
Statusinformationen.
|
||||||
|
|
||||||
|
Der Meldungsbereich verwendet vier feste Stufen:
|
||||||
|
|
||||||
|
| Stufe | Präfix | Farbe des Präfix |
|
||||||
|
|-------|--------|------------------|
|
||||||
|
| **Info** | `Info:` | Blau |
|
||||||
|
| **Hinweis** | `Hinweis:` | Grün |
|
||||||
|
| **Warnung** | `Warnung:` | Orange |
|
||||||
|
| **Fehler** | `Fehler:` | Rot |
|
||||||
|
|
||||||
|
Nur das Präfix am Zeilenanfang wird farbig dargestellt. Der eigentliche
|
||||||
|
Meldungstext derselben Zeile ist immer schwarz. Die vier Stufen dienen
|
||||||
|
ausschließlich der visuellen Einordnung; sie verhindern das Speichern nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Aktionen
|
||||||
|
|
||||||
|
### 4.1 Neu
|
||||||
|
|
||||||
|
Erzeugt eine neue Konfiguration aus der internen Standardvorlage. Die Vorlage
|
||||||
|
enthält sinnvolle Standardwerte und beide bekannten Provider-Blöcke (Claude und
|
||||||
|
OpenAI-kompatibel). Standardmäßig ist der alphabetisch erste Provider (Claude)
|
||||||
|
als aktiver Provider vorbelegt.
|
||||||
|
|
||||||
|
Sind zum Zeitpunkt der Aktion ungespeicherte Änderungen vorhanden, erscheint
|
||||||
|
der Schutzdialog (siehe Abschnitt 8).
|
||||||
|
|
||||||
|
### 4.2 Öffnen
|
||||||
|
|
||||||
|
Öffnet einen nativen Dateidialog gefiltert auf `*.properties`-Dateien. Die
|
||||||
|
ausgewählte Datei wird geladen und im Editor angezeigt.
|
||||||
|
|
||||||
|
Enthält die Datei das ältere Legacy-Format (flache `api.*`-Schlüssel), wird sie
|
||||||
|
automatisch ins aktuelle Mehrprovider-Schema migriert. Vor der Migration wird eine
|
||||||
|
`.bak`-Sicherung der Originaldatei angelegt (siehe Abschnitt 9). Die durchgeführte
|
||||||
|
Migration wird im zentralen Meldungsbereich sichtbar gemeldet.
|
||||||
|
|
||||||
|
Sind zum Zeitpunkt der Aktion ungespeicherte Änderungen vorhanden, erscheint
|
||||||
|
der Schutzdialog (siehe Abschnitt 8).
|
||||||
|
|
||||||
|
### 4.3 Speichern
|
||||||
|
|
||||||
|
Schreibt den aktuellen Editorstand in die zuletzt geladene oder gespeicherte Datei.
|
||||||
|
|
||||||
|
Ist die Konfiguration neu und wurde noch nie gespeichert, verhält sich „Speichern"
|
||||||
|
wie „Speichern unter": Es wird ein Dateidialog geöffnet und ein Speicherpfad
|
||||||
|
erfragt.
|
||||||
|
|
||||||
|
### 4.4 Speichern unter
|
||||||
|
|
||||||
|
Öffnet einen nativen Dateidialog gefiltert auf `*.properties`-Dateien. Als
|
||||||
|
Vorschlagspfad wird `config/application.properties` relativ zum aktuellen
|
||||||
|
Arbeitsverzeichnis verwendet. Damit ist die gespeicherte Datei ohne weitere
|
||||||
|
Schritte für den nächsten headless Batch-Lauf nutzbar.
|
||||||
|
|
||||||
|
Zeigt der Dialog auf eine bereits existierende Datei, erscheint eine
|
||||||
|
Rückfrage „Datei überschreiben?". Bei Bestätigung wird vor dem Überschreiben
|
||||||
|
automatisch eine `.bak`-Sicherung angelegt (siehe Abschnitt 9).
|
||||||
|
|
||||||
|
Kommentare und Schlüsselreihenfolge der `.properties`-Datei werden beim
|
||||||
|
Speichern normalisiert.
|
||||||
|
|
||||||
|
### 4.5 Validieren
|
||||||
|
|
||||||
|
Führt eine explizite, nicht-schreibende Gesamtprüfung des aktuellen Editorzustands
|
||||||
|
durch. Die Prüfung läuft lokal ohne Netzwerkzugriff und umfasst dieselben
|
||||||
|
Regelprüfungen wie die automatische Hintergrundvalidierung, kann aber zusätzliche
|
||||||
|
lokale Prüfpunkte zusammenführen.
|
||||||
|
|
||||||
|
Die Prüfung arbeitet auf dem **aktuellen GUI-Zustand**, also auch auf ungespeicherten
|
||||||
|
Änderungen. Die Datei wird dabei **nicht** implizit gespeichert.
|
||||||
|
|
||||||
|
Ergebnisse erscheinen im zentralen Meldungsbereich und als feldnahe
|
||||||
|
Fehlermeldungen direkt unter den betroffenen Eingabefeldern.
|
||||||
|
|
||||||
|
### 4.6 Technische Tests ausführen
|
||||||
|
|
||||||
|
Führt eine umfassende technische Prüfung des aktuellen Editorzustands durch,
|
||||||
|
einschließlich provider-naher Tests mit Netzwerkzugriff. Alle Prüfpunkte werden
|
||||||
|
vollständig und gesammelt durchlaufen; die Aktion bricht bei einem Einzelfehler
|
||||||
|
nicht ab.
|
||||||
|
|
||||||
|
Geprüft werden unter anderem:
|
||||||
|
|
||||||
|
- `.properties`-Datei und Provider-Konfiguration
|
||||||
|
- Erreichbarkeit von Base-URL bzw. Endpoint
|
||||||
|
- Vorhandensein und technische Akzeptanz des API-Keys
|
||||||
|
- Abrufbarkeit der Modellliste
|
||||||
|
- Plausibilität des gewählten Modells
|
||||||
|
- Vorhandensein und Lesbarkeit der Prompt-Datei
|
||||||
|
- Quellordner (vorhanden und lesbar)
|
||||||
|
- Zielordner (vorhanden oder anlegbar und beschreibbar)
|
||||||
|
- SQLite-Datei bzw. -Pfad
|
||||||
|
|
||||||
|
Die Tests arbeiten auf dem **aktuellen GUI-Zustand** ohne implizites Speichern.
|
||||||
|
Wenn ungespeicherte Änderungen vorliegen, ist ein entsprechender Hinweis
|
||||||
|
zweckmäßig.
|
||||||
|
|
||||||
|
Bestimmte Befunde können durch korrigierende Maßnahmen behoben werden (z. B.
|
||||||
|
Zielordner anlegen, SQLite-Datei anlegen, fehlende Prompt-Datei mit einem
|
||||||
|
deutschen Standardinhalt erzeugen). Diese Korrekturen erfolgen **nicht** still im
|
||||||
|
Hintergrund, sondern erst nach Bestätigung eines gesammelten Bestätigungsdialogs
|
||||||
|
(„Folgende Korrekturen werden durchgeführt … Fortfahren?").
|
||||||
|
|
||||||
|
Die automatisch erzeugte Prompt-Datei enthält einen deutschen Standardprompt,
|
||||||
|
der ohne weitere Anpassung funktioniert. Ein Beispiel dieses Standardprompts liegt
|
||||||
|
unter [`docs/examples/prompt.txt`](../docs/examples/prompt.txt).
|
||||||
|
|
||||||
|
Nicht automatisch korrigierbar sind insbesondere: falscher API-Key,
|
||||||
|
unerreichbare Base-URL, nicht verfügbare Modellliste, sonstige externe
|
||||||
|
technische Fehler.
|
||||||
|
|
||||||
|
### 4.7 Modelle neu laden
|
||||||
|
|
||||||
|
Ruft die Modellliste des aktuell ausgewählten Providers erneut über einen
|
||||||
|
Hintergrund-Worker-Thread ab. Der gleiche Abruf wird auch automatisch bei jedem
|
||||||
|
Providerwechsel ausgelöst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Feldnahe Fehlermeldungen
|
||||||
|
|
||||||
|
Direkt unter bestimmten Eingabefeldern kann die GUI kleine, rote,
|
||||||
|
deutschsprachige Hinweise einblenden, wenn der eingetragene Wert fehlerhaft oder
|
||||||
|
riskant ist. Diese feldnahen Meldungen ergänzen den zentralen Meldungsbereich
|
||||||
|
und ersetzen ihn nicht.
|
||||||
|
|
||||||
|
Feldnahe Meldungen erscheinen nach einer Validierung oder nach dem Ausführen der
|
||||||
|
technischen Tests. Sie verschwinden, sobald der Fehler behoben und eine neue
|
||||||
|
Prüfung durchgeführt wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Automatische Hintergrundvalidierung
|
||||||
|
|
||||||
|
Eine geladene Konfiguration wird sofort beim Öffnen geprüft. Während der
|
||||||
|
Bearbeitung aktualisiert die Validierung ihre Ergebnisse kontinuierlich im
|
||||||
|
Hintergrund. Ergebnisse werden im zentralen Meldungsbereich und als feldnahe
|
||||||
|
Hinweise angezeigt.
|
||||||
|
|
||||||
|
Die automatische Validierung unterscheidet:
|
||||||
|
|
||||||
|
- **Fehler:** Der Konfigurationsstand ist nicht lauffähig.
|
||||||
|
- **Warnungen:** Die Einstellung ist technisch akzeptabel, aber riskant oder
|
||||||
|
unüblich. Beispiele: sehr hohe `max.text.characters`-Werte, ungewöhnliche
|
||||||
|
Timeouts.
|
||||||
|
- **Hinweise:** Informationen ohne Handlungsbedarf.
|
||||||
|
|
||||||
|
Warnungen und Hinweise verhindern das Speichern nicht. Vor dem Speichern eines
|
||||||
|
als **nicht lauffähig** markierten Stands erscheint jedoch eine deutlich sichtbare
|
||||||
|
Warnung im zentralen Meldungsbereich, die ausdrücklich auf mögliche Auswirkungen
|
||||||
|
auf den nächsten headless Lauf hinweist. Speichern ist dennoch erlaubt.
|
||||||
|
|
||||||
|
Wirtschaftliche Warnschwellen für `max.text.characters`:
|
||||||
|
|
||||||
|
| Wertebereich | Bewertung |
|
||||||
|
|---|---|
|
||||||
|
| bis 1.000 | unkritisch |
|
||||||
|
| 1.001 – 3.000 | Warnung |
|
||||||
|
| ab 3.001 | starke Warnung |
|
||||||
|
|
||||||
|
`max.pages` wird als Plausibilitäts- und Performance-Hinweis behandelt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Provider-Bedienung und Modellabruf
|
||||||
|
|
||||||
|
### 7.1 Provider-ComboBox
|
||||||
|
|
||||||
|
Im Bereich „Provider" befindet sich eine ComboBox zur Auswahl des aktiven
|
||||||
|
Providers. In V2.0 stehen zwei Einträge zur Verfügung:
|
||||||
|
|
||||||
|
- **Claude**
|
||||||
|
- **OpenAI-kompatibel**
|
||||||
|
|
||||||
|
Nur der aktuell ausgewählte Provider-Bereich ist im Formular sichtbar. Der
|
||||||
|
verdeckte Provider-Block behält seine Daten; ein Providerwechsel löscht die
|
||||||
|
Konfiguration des anderen Blocks nicht.
|
||||||
|
|
||||||
|
### 7.2 Automatischer Modellabruf
|
||||||
|
|
||||||
|
Bei jedem Providerwechsel startet der Modellabruf automatisch auf einem
|
||||||
|
Hintergrund-Worker-Thread. Wird die Modellliste erfolgreich geladen, erscheint
|
||||||
|
eine nicht editierbare ComboBox mit den verfügbaren Modellen. Das erste Modell
|
||||||
|
der Liste ist automatisch vorbelegt.
|
||||||
|
|
||||||
|
Kann keine Modellliste abgerufen werden (z. B. wegen fehlendem oder falschem
|
||||||
|
API-Key, unerreichbarem Endpoint), erscheint statt der ComboBox ein leeres
|
||||||
|
Texteingabefeld. In diesem Fall muss der Modellname manuell eingetragen werden.
|
||||||
|
|
||||||
|
Wurde zuvor ein Modellname manuell eingetragen und wird später eine echte
|
||||||
|
Modellliste geladen, in der dieser Wert nicht vorkommt, wird der manuell
|
||||||
|
eingetragene Modellname verworfen. Es wird dann das erste Modell der Liste
|
||||||
|
vorbelegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Dirty-State und Schutzdialoge
|
||||||
|
|
||||||
|
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
|
||||||
|
Editor als „dirty" (ungespeicherte Änderungen). Zwei visuelle Markierungen
|
||||||
|
zeigen diesen Zustand an:
|
||||||
|
|
||||||
|
- Ein **`*`**-Präfix im Fenstertitel
|
||||||
|
- Ein kleines **„geändert"**-Label im Header
|
||||||
|
|
||||||
|
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
|
||||||
|
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
|
||||||
|
Schutzdialog mit drei Optionen:
|
||||||
|
|
||||||
|
| Option | Wirkung |
|
||||||
|
|--------|---------|
|
||||||
|
| **Speichern** | Speichert die Änderungen und führt die Aktion danach aus |
|
||||||
|
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
|
||||||
|
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
|
||||||
|
|
||||||
|
### 9.1 Sicherung beim Überschreiben
|
||||||
|
|
||||||
|
Bevor eine bestehende `.properties`-Datei überschrieben wird, legt die GUI
|
||||||
|
automatisch eine Sicherungskopie an:
|
||||||
|
|
||||||
|
- Standardfall: `<dateiname>.bak` (im selben Verzeichnis)
|
||||||
|
- Falls `.bak` bereits existiert: `<dateiname>.bak.1`, `.bak.2`, …
|
||||||
|
- Bestehende Sicherungen werden **niemals überschrieben**.
|
||||||
|
|
||||||
|
Dieses Schema gilt sowohl für „Speichern unter" bei existierendem Ziel als auch
|
||||||
|
für die Legacy-Migration beim Öffnen.
|
||||||
|
|
||||||
|
### 9.2 Legacy-Migration
|
||||||
|
|
||||||
|
Ältere `.properties`-Dateien, die noch die flachen Schlüssel `api.baseUrl`,
|
||||||
|
`api.model`, `api.timeoutSeconds` und `api.key` verwenden, erkennt die GUI beim
|
||||||
|
Öffnen automatisch als Legacy-Format. Sie führt folgende Schritte durch:
|
||||||
|
|
||||||
|
1. `.bak`-Sicherung der Originaldatei anlegen (nach dem Schema aus 9.1)
|
||||||
|
2. Inhalt ins aktuelle Mehrprovider-Schema überführen:
|
||||||
|
- Legacy-Werte werden dem Namensraum `openai-compatible` zugeordnet
|
||||||
|
- `ai.provider.active=openai-compatible` wird ergänzt
|
||||||
|
3. Die migrierte Konfiguration wird im Editor angezeigt
|
||||||
|
|
||||||
|
Die durchgeführte Migration wird im zentralen Meldungsbereich sichtbar gemeldet,
|
||||||
|
damit der Benutzer die Änderung nachvollziehen kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. API-Key-Auflösungsreihenfolge
|
||||||
|
|
||||||
|
Der API-Key eines Providers wird in folgender Priorität aufgelöst:
|
||||||
|
|
||||||
|
1. **Providerspezifische Umgebungsvariable** (höchste Priorität)
|
||||||
|
2. Für **OpenAI-kompatibel** zusätzlich: Legacy-Umgebungsvariable
|
||||||
|
`PDF_UMBENENNER_API_KEY` (aus dem früheren Einzelprovider-Stand)
|
||||||
|
3. **Property-Wert** in der `.properties`-Datei
|
||||||
|
|
||||||
|
| Provider | Providerspezifische Umgebungsvariable |
|
||||||
|
|---|---|
|
||||||
|
| Claude | `ANTHROPIC_API_KEY` |
|
||||||
|
| OpenAI-kompatibel | `OPENAI_COMPATIBLE_API_KEY` |
|
||||||
|
|
||||||
|
Für **OpenAI-kompatibel** wird zusätzlich die Legacy-Variable
|
||||||
|
`PDF_UMBENENNER_API_KEY` akzeptiert, falls die providerspezifische Variable
|
||||||
|
nicht gesetzt ist. Für Claude gibt es keine Legacy-Variable.
|
||||||
|
|
||||||
|
Die GUI zeigt unterhalb des API-Key-Felds die Herkunft des aktuell wirksamen
|
||||||
|
Schlüssels an. Greift eine Umgebungsvariable, erscheint ein Hinweis mit dem
|
||||||
|
Namen der Variablen (z. B. „Aktuell wirksam aus Umgebungsvariable
|
||||||
|
`ANTHROPIC_API_KEY`"). Ist kein Schlüssel aus keiner Quelle verfügbar, erscheint
|
||||||
|
eine Warnung.
|
||||||
|
|
||||||
|
Das API-Key-Feld ist ein normales, unmaskiertes Textfeld. Ein leeres Feld entfernt
|
||||||
|
den vorhandenen Property-Wert **nicht** stillschweigend, solange keine
|
||||||
|
Umgebungsvariable greift. In diesem Fall bleibt der bestehende Property-Wert
|
||||||
|
erhalten, und die GUI zeigt eine deutliche Warnung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Pfadfelder und Datei-/Ordnerdialoge
|
||||||
|
|
||||||
|
Für die folgenden Konfigurationswerte stellt die GUI je ein Texteingabefeld
|
||||||
|
sowie einen kleinen Button zum Öffnen des nativen Dialogs bereit:
|
||||||
|
|
||||||
|
| Feld | Dialog-Typ |
|
||||||
|
|------|------------|
|
||||||
|
| Quellordner | Ordnerdialog |
|
||||||
|
| Zielordner | Ordnerdialog |
|
||||||
|
| SQLite-Datei | Dateidialog |
|
||||||
|
| Prompt-Datei | Dateidialog |
|
||||||
|
|
||||||
|
Pfade können auch direkt ins Texteingabefeld eingegeben werden, ohne den Dialog
|
||||||
|
zu nutzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Windows-Hinweise zu gemappten Laufwerken
|
||||||
|
|
||||||
|
Die GUI sowie alle zugehörigen Pfadprüfungen akzeptieren Windows-Laufwerksbuchstaben
|
||||||
|
wie `S:\`, `H:\` oder `C:\`. Gemappte Netzlaufwerke werden ausdrücklich unterstützt
|
||||||
|
und werden nicht allein wegen eines dahinterliegenden UNC-Pfads abgelehnt.
|
||||||
|
|
||||||
|
UNC-Pfade (z. B. `\\server\freigabe\pfad\`) werden ebenfalls akzeptiert, sind aber
|
||||||
|
nicht das primäre Format für den GUI-Betrieb.
|
||||||
|
|
||||||
|
Die GUI wird offiziell nur unter **Windows** unterstützt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Bekannte Einschränkungen V2.0
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
||||||
@@ -62,6 +62,11 @@
|
|||||||
<artifactId>mockito-junit-jupiter</artifactId>
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<!--
|
<!--
|
||||||
Monocle: headless JavaFX platform for GUI smoke tests.
|
Monocle: headless JavaFX platform for GUI smoke tests.
|
||||||
Provides the Glass platform implementation that runs JavaFX without a
|
Provides the Glass platform implementation that runs JavaFX without a
|
||||||
|
|||||||
+920
-83
File diff suppressed because it is too large
Load Diff
+287
@@ -0,0 +1,287 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionReport;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koordiniert den gesammelten Bestätigungsdialog und die anschließende Ausführung
|
||||||
|
* schreibender Korrekturmaßnahmen nach einem technischen Gesamttest.
|
||||||
|
* <p>
|
||||||
|
* Der Koordinator empfängt einen {@link TechnicalTestReport}, prüft ob korrigierbare
|
||||||
|
* Befunde vorliegen, leitet daraus einen {@link CorrectionPlan} ab, zeigt dem Benutzer
|
||||||
|
* einen gesammelten Bestätigungsdialog und führt die Korrekturen bei Bestätigung über
|
||||||
|
* den {@link CorrectionExecutionService} auf einem Hintergrund-Worker-Thread aus.
|
||||||
|
* Ergebnisse werden in die geteilte {@code pendingMessages}-Liste eingehängt.
|
||||||
|
*
|
||||||
|
* <h2>Ablauf</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>Bericht erhält: prüfen ob {@code hasCorrectableFindings()}.</li>
|
||||||
|
* <li>Wenn keine korrigierbaren Befunde: kein Dialog, keine Aktion.</li>
|
||||||
|
* <li>Wenn korrigierbare Befunde: {@link CorrectionPlan} ableiten.</li>
|
||||||
|
* <li>Dialog auf FX-Thread anzeigen.</li>
|
||||||
|
* <li>Bei Bestätigung: Korrekturen auf Worker-Thread ausführen.</li>
|
||||||
|
* <li>Ergebnisse via {@code Platform.runLater} auf FX-Thread zurückführen.</li>
|
||||||
|
* <li>Meldungen in {@code pendingMessages} einhängen (Replace-Semantik).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h2>Threading-Kontrakt</h2>
|
||||||
|
* <p>
|
||||||
|
* {@link #offerCorrections(TechnicalTestReport)} muss auf dem JavaFX Application Thread
|
||||||
|
* aufgerufen werden. I/O (Ausführung der Korrekturen) läuft auf einem dedizierten
|
||||||
|
* Daemon-Hintergrund-Thread. UI-Updates erfolgen ausschließlich via
|
||||||
|
* {@code Platform.runLater}.
|
||||||
|
*
|
||||||
|
* <h2>Keine stillen Korrekturen</h2>
|
||||||
|
* <p>
|
||||||
|
* Ohne ausdrückliche Benutzerbestätigung werden keine schreibenden Änderungen ausgeführt.
|
||||||
|
* Bei Dialog-Abbruch bleibt der Zustand unverändert.
|
||||||
|
*
|
||||||
|
* <h2>Anti-Scope</h2>
|
||||||
|
* <p>
|
||||||
|
* Dieser Koordinator führt keine Provider-nahen Korrekturen, keine Änderung fachlich
|
||||||
|
* riskanter Werte und keine automatischen Laufstarts durch.
|
||||||
|
*/
|
||||||
|
public final class GuiCorrectionDialogCoordinator {
|
||||||
|
|
||||||
|
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
|
||||||
|
static final String SOURCE_TAG = "Korrekturen";
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiCorrectionDialogCoordinator.class);
|
||||||
|
|
||||||
|
private final CorrectionExecutionService correctionExecutionService;
|
||||||
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
|
private final Consumer<Void> refreshCallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funktion, die dem Benutzer den Bestätigungsdialog zeigt.
|
||||||
|
* <p>
|
||||||
|
* Erhält den {@link ConfirmationDialogContent} und gibt {@code true} zurück, wenn der
|
||||||
|
* Benutzer „Fortfahren" wählt, andernfalls {@code false}. Standard: echter Alert.
|
||||||
|
* Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
Function<ConfirmationDialogContent, Boolean> dialogSupplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory für den Hintergrund-Worker-Thread.
|
||||||
|
* Standard: Daemon-Thread namens {@code gui-correction-worker}.
|
||||||
|
* Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
Function<Runnable, Thread> correctionThreadFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbraucher zur Rückführung des Ergebnisses auf den FX-Thread.
|
||||||
|
* Standard: {@code Platform.runLater}. Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
java.util.function.Consumer<Runnable> resultDelivery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Koordinator.
|
||||||
|
*
|
||||||
|
* @param correctionExecutionService Service für die Ausführung von Korrekturen; darf nicht {@code null} sein
|
||||||
|
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||||
|
* @param refreshCallback Callback nach Anwendung der Ergebnisse (z. B. View-Aktualisierung);
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public GuiCorrectionDialogCoordinator(CorrectionExecutionService correctionExecutionService,
|
||||||
|
List<GuiMessageEntry> pendingMessages,
|
||||||
|
Consumer<Void> refreshCallback) {
|
||||||
|
this.correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||||
|
"correctionExecutionService must not be null");
|
||||||
|
this.pendingMessages = Objects.requireNonNull(pendingMessages,
|
||||||
|
"pendingMessages must not be null");
|
||||||
|
this.refreshCallback = Objects.requireNonNull(refreshCallback,
|
||||||
|
"refreshCallback must not be null");
|
||||||
|
|
||||||
|
this.dialogSupplier = this::showConfirmationDialog;
|
||||||
|
this.correctionThreadFactory = task -> {
|
||||||
|
Thread t = new Thread(task, "gui-correction-worker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
this.resultDelivery = Platform::runLater;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Bericht auf korrigierbare Befunde, zeigt bei Bedarf den Bestätigungsdialog
|
||||||
|
* und führt die Korrekturen nach Bestätigung asynchron aus.
|
||||||
|
* <p>
|
||||||
|
* Wenn der Bericht keine korrigierbaren Befunde enthält ({@code hasCorrectableFindings()
|
||||||
|
* == false}), wird kein Dialog angezeigt und keine Aktion ausgeführt.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void offerCorrections(TechnicalTestReport report) {
|
||||||
|
Objects.requireNonNull(report, "report must not be null");
|
||||||
|
|
||||||
|
if (!report.hasCorrectableFindings()) {
|
||||||
|
LOG.debug("Gesamttest: Keine korrigierbaren Befunde – kein Bestätigungsdialog.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CorrectionPlan plan = report.deriveCorrectionPlan();
|
||||||
|
if (!plan.hasCorrections()) {
|
||||||
|
LOG.debug("Gesamttest: Korrekturplan ist leer – kein Bestätigungsdialog.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Gesamttest: {} korrigierbare Befunde. Bestätigungsdialog wird angezeigt.", plan.size());
|
||||||
|
|
||||||
|
ConfirmationDialogContent dialogContent = ConfirmationDialogContent.fromPlan(plan);
|
||||||
|
boolean confirmed = dialogSupplier.apply(dialogContent);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen abgelehnt. Keine Änderungen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen bestätigt. Ausführung startet.");
|
||||||
|
Runnable task = () -> {
|
||||||
|
CorrectionExecutionReport executionReport = correctionExecutionService.execute(plan);
|
||||||
|
resultDelivery.accept(() -> {
|
||||||
|
applyResult(executionReport);
|
||||||
|
refreshCallback.accept(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Thread worker = correctionThreadFactory.apply(task);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet das Ergebnis der Korrekturausführung auf die geteilte Nachrichtenliste an.
|
||||||
|
* <p>
|
||||||
|
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jedes
|
||||||
|
* {@link CorrectionOutcome} einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
|
||||||
|
* angehängt.
|
||||||
|
* <p>
|
||||||
|
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||||
|
*
|
||||||
|
* @param report das Ausführungsergebnis; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void applyResult(CorrectionExecutionReport report) {
|
||||||
|
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
|
||||||
|
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
|
||||||
|
|
||||||
|
long appliedCount = 0;
|
||||||
|
long failedCount = 0;
|
||||||
|
long notAttemptedCount = 0;
|
||||||
|
|
||||||
|
for (CorrectionOutcome outcome : report.outcomes()) {
|
||||||
|
switch (outcome) {
|
||||||
|
case CorrectionOutcome.Applied applied -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
"Korrektur angewendet: " + applied.suggestion().descriptionForUser()
|
||||||
|
+ " – " + applied.message(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
appliedCount++;
|
||||||
|
LOG.info("Korrektur angewendet: {} – {}", applied.suggestion().descriptionForUser(),
|
||||||
|
applied.message());
|
||||||
|
}
|
||||||
|
case CorrectionOutcome.Failed failed -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.ERROR,
|
||||||
|
"Korrektur fehlgeschlagen: " + failed.suggestion().descriptionForUser()
|
||||||
|
+ " (" + failed.errorMessage() + ")",
|
||||||
|
SOURCE_TAG));
|
||||||
|
failedCount++;
|
||||||
|
LOG.warn("Korrektur fehlgeschlagen: {} – {}", failed.suggestion().descriptionForUser(),
|
||||||
|
failed.errorMessage());
|
||||||
|
}
|
||||||
|
case CorrectionOutcome.NotAttempted notAttempted -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.HINT,
|
||||||
|
"Korrektur nicht durchgeführt: " + notAttempted.suggestion().descriptionForUser()
|
||||||
|
+ " – " + notAttempted.reason(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
notAttemptedCount++;
|
||||||
|
LOG.info("Korrektur nicht durchgeführt: {} – {}", notAttempted.suggestion().descriptionForUser(),
|
||||||
|
notAttempted.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusammenfassung
|
||||||
|
String summary = "Korrekturausführung abgeschlossen: "
|
||||||
|
+ appliedCount + " angewendet, "
|
||||||
|
+ failedCount + " fehlgeschlagen, "
|
||||||
|
+ notAttemptedCount + " nicht versucht.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
|
||||||
|
LOG.info("Korrekturausführung abgeschlossen: {} angewendet, {} fehlgeschlagen, {} nicht versucht.",
|
||||||
|
appliedCount, failedCount, notAttemptedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt den echten JavaFX-Bestätigungsdialog und gibt die Benutzerentscheidung zurück.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden. Standard-Fokus liegt auf
|
||||||
|
* dem Abbrechen-Button (kein versehentliches Bestätigen durch Enter).
|
||||||
|
*
|
||||||
|
* @param content der Dialoginhalt; darf nicht {@code null} sein
|
||||||
|
* @return {@code true} wenn der Benutzer „Fortfahren" wählt, sonst {@code false}
|
||||||
|
*/
|
||||||
|
private boolean showConfirmationDialog(ConfirmationDialogContent content) {
|
||||||
|
javafx.scene.control.ButtonType proceedButton =
|
||||||
|
new javafx.scene.control.ButtonType("Fortfahren",
|
||||||
|
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
|
||||||
|
javafx.scene.control.ButtonType cancelButton =
|
||||||
|
new javafx.scene.control.ButtonType("Abbrechen",
|
||||||
|
javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||||
|
|
||||||
|
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
|
||||||
|
javafx.scene.control.Alert.AlertType.CONFIRMATION);
|
||||||
|
alert.setTitle(content.title());
|
||||||
|
alert.setHeaderText(content.introText());
|
||||||
|
|
||||||
|
StringBuilder correctionListText = new StringBuilder();
|
||||||
|
for (String line : content.correctionLines()) {
|
||||||
|
correctionListText.append("• ").append(line).append("\n");
|
||||||
|
}
|
||||||
|
correctionListText.append("\nFortfahren?");
|
||||||
|
alert.setContentText(correctionListText.toString());
|
||||||
|
|
||||||
|
alert.getButtonTypes().setAll(proceedButton, cancelButton);
|
||||||
|
|
||||||
|
// Standard-Fokus: Abbrechen
|
||||||
|
javafx.scene.Node cancelNode = alert.getDialogPane().lookupButton(cancelButton);
|
||||||
|
if (cancelNode instanceof javafx.scene.control.Button btn) {
|
||||||
|
btn.setDefaultButton(true);
|
||||||
|
}
|
||||||
|
javafx.scene.Node proceedNode = alert.getDialogPane().lookupButton(proceedButton);
|
||||||
|
if (proceedNode instanceof javafx.scene.control.Button btn) {
|
||||||
|
btn.setDefaultButton(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.Optional<javafx.scene.control.ButtonType> result = alert.showAndWait();
|
||||||
|
return result.isPresent() && result.get() == proceedButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
|
||||||
|
* <p>
|
||||||
|
* Ausschließlich für Tests gedacht.
|
||||||
|
*
|
||||||
|
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
|
||||||
|
*/
|
||||||
|
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||||
|
return List.copyOf(pendingMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates asynchronous model catalogue retrieval for the GUI provider section.
|
||||||
|
* <p>
|
||||||
|
* This coordinator is responsible for:
|
||||||
|
* <ul>
|
||||||
|
* <li>Triggering a background HTTP call via {@link AiModelCatalogPort} on a dedicated
|
||||||
|
* daemon thread named {@code gui-model-catalog}.</li>
|
||||||
|
* <li>Returning the result to the JavaFX Application Thread via {@code Platform.runLater}.</li>
|
||||||
|
* <li>Updating the per-provider {@link GuiModelFieldContainer} to show either a
|
||||||
|
* non-editable {@code ComboBox} (success) or a manual text field (all other cases).</li>
|
||||||
|
* <li>Appending a {@link GuiMessageEntry} to the supplied pending-messages list for each
|
||||||
|
* completed retrieval attempt, so later GUI layers can display the result.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
|
||||||
|
* executor without spinning a real OS thread.
|
||||||
|
* <p>
|
||||||
|
* This class is not thread-safe by itself. All methods intended to mutate GUI state must be
|
||||||
|
* called on the JavaFX Application Thread. Background threads only interact through
|
||||||
|
* {@code Platform.runLater}.
|
||||||
|
*/
|
||||||
|
public final class GuiModelCatalogCoordinator {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
|
||||||
|
|
||||||
|
/** Default timeout used when no timeout is configured in the provider state. */
|
||||||
|
static final int DEFAULT_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
|
private final AiModelCatalogPort modelCatalogPort;
|
||||||
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for the background worker thread. Package-private to allow test substitution.
|
||||||
|
* The default creates a daemon thread named {@code gui-model-catalog}.
|
||||||
|
*/
|
||||||
|
Function<Runnable, Thread> modelCatalogThreadFactory;
|
||||||
|
|
||||||
|
/** Per-provider field containers; populated by the workspace when it builds provider blocks. */
|
||||||
|
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumer that delivers the retrieval result. In production this wraps the call in
|
||||||
|
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
|
||||||
|
* is applied immediately on the worker thread without needing an FX queue drain.
|
||||||
|
* Package-private to allow test substitution.
|
||||||
|
*/
|
||||||
|
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional callback invoked on the JavaFX Application Thread after each retrieval result has
|
||||||
|
* been applied. The workspace uses this hook to refresh the central message area and field-error
|
||||||
|
* labels without coupling the coordinator to the workspace implementation.
|
||||||
|
* Package-private to allow substitution in tests.
|
||||||
|
*/
|
||||||
|
Runnable postResultCallback = () -> { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a coordinator backed by the given catalogue port and shared message list.
|
||||||
|
*
|
||||||
|
* @param modelCatalogPort port used for background HTTP calls; must not be {@code null}
|
||||||
|
* @param pendingMessages mutable list to append result messages to; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelCatalogCoordinator(AiModelCatalogPort modelCatalogPort,
|
||||||
|
List<GuiMessageEntry> pendingMessages) {
|
||||||
|
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
|
||||||
|
"modelCatalogPort must not be null");
|
||||||
|
this.pendingMessages = Objects.requireNonNull(pendingMessages,
|
||||||
|
"pendingMessages must not be null");
|
||||||
|
this.modelCatalogThreadFactory = task -> {
|
||||||
|
Thread t = new Thread(task, "gui-model-catalog");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a {@link GuiModelFieldContainer} for the given provider family.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread before the first retrieval is triggered.
|
||||||
|
*
|
||||||
|
* @param family the provider family this container belongs to; must not be {@code null}
|
||||||
|
* @param container the container to register; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public void registerFieldContainer(AiProviderFamily family, GuiModelFieldContainer container) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
Objects.requireNonNull(container, "container must not be null");
|
||||||
|
fieldContainers.put(family, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an asynchronous model catalogue retrieval for the given provider family.
|
||||||
|
* <p>
|
||||||
|
* The retrieval is performed on a background worker thread. The result is delivered back
|
||||||
|
* to the JavaFX Application Thread via {@code Platform.runLater}. The registered
|
||||||
|
* {@link GuiModelFieldContainer} for the provider is updated accordingly, and a
|
||||||
|
* {@link GuiMessageEntry} is appended to the pending-messages list.
|
||||||
|
* <p>
|
||||||
|
* If no field container is registered for the provider, the call is a no-op.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param family the provider family to retrieve models for; must not be {@code null}
|
||||||
|
* @param providerState the current editor state for the provider; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public void triggerModelRetrieval(AiProviderFamily family,
|
||||||
|
GuiProviderConfigurationState providerState) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
Objects.requireNonNull(providerState, "providerState must not be null");
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = fieldContainers.get(family);
|
||||||
|
if (container == null) {
|
||||||
|
LOG.debug("GUI-Modellabruf: Kein Feld-Container für Provider '{}' registriert – übersprungen.",
|
||||||
|
family.getIdentifier());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current manual value before starting the background call.
|
||||||
|
String previousManualValue = container.currentModelValue();
|
||||||
|
|
||||||
|
// Build the request from the current editor state.
|
||||||
|
ModelCatalogRequest request = buildRequest(family, providerState);
|
||||||
|
|
||||||
|
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet.",
|
||||||
|
family.getIdentifier());
|
||||||
|
|
||||||
|
Runnable task = () -> {
|
||||||
|
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
|
||||||
|
resultDelivery.accept(() -> {
|
||||||
|
applyResult(family, container, result, previousManualValue);
|
||||||
|
postResultCallback.run();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Thread worker = modelCatalogThreadFactory.apply(task);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the result of a completed model catalogue retrieval to the field container and
|
||||||
|
* appends a message entry to the pending-messages list.
|
||||||
|
* <p>
|
||||||
|
* Must only be called on the JavaFX Application Thread (via {@code Platform.runLater}).
|
||||||
|
*
|
||||||
|
* @param family the provider family that was queried; must not be {@code null}
|
||||||
|
* @param container the field container to update; must not be {@code null}
|
||||||
|
* @param result the retrieval result; must not be {@code null}
|
||||||
|
* @param previousManualValue the model value that was in the text field before the call
|
||||||
|
*/
|
||||||
|
private void applyResult(AiProviderFamily family,
|
||||||
|
GuiModelFieldContainer container,
|
||||||
|
ModelCatalogResult result,
|
||||||
|
String previousManualValue) {
|
||||||
|
// Remove any previous message entries from an earlier retrieval so messages do not
|
||||||
|
// accumulate across repeated triggers of the same retrieval action.
|
||||||
|
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
|
||||||
|
|
||||||
|
String displayName = displayNameFor(family);
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case ModelCatalogResult.Success success -> {
|
||||||
|
List<String> models = success.models();
|
||||||
|
container.applyModelList(models, previousManualValue);
|
||||||
|
String message = "Modellliste für " + displayName + " geladen ("
|
||||||
|
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
|
||||||
|
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||||
|
}
|
||||||
|
case ModelCatalogResult.EmptyList emptyList -> {
|
||||||
|
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
|
String message = "Provider " + displayName
|
||||||
|
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
|
||||||
|
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||||
|
}
|
||||||
|
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
|
||||||
|
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
|
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
|
||||||
|
+ ". Manuelle Eingabe aktiv.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
|
||||||
|
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
||||||
|
}
|
||||||
|
case ModelCatalogResult.TechnicalFailure failure -> {
|
||||||
|
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||||
|
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
|
||||||
|
+ "). Manuelle Eingabe aktiv.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
|
||||||
|
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
|
||||||
|
message, failure.errorDetail(), family.getIdentifier());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link ModelCatalogRequest} from the current provider editor state.
|
||||||
|
* <p>
|
||||||
|
* Missing or blank values are passed as {@code Optional.empty()} so the adapter can apply
|
||||||
|
* its own defaults or return {@link ModelCatalogResult.IncompleteConfiguration} if required
|
||||||
|
* values are absent.
|
||||||
|
*
|
||||||
|
* @param family the target provider family; must not be {@code null}
|
||||||
|
* @param providerState the current provider editor state; must not be {@code null}
|
||||||
|
* @return a new request; never {@code null}
|
||||||
|
*/
|
||||||
|
private static ModelCatalogRequest buildRequest(AiProviderFamily family,
|
||||||
|
GuiProviderConfigurationState providerState) {
|
||||||
|
Optional<String> baseUrl = Optional.ofNullable(providerState.baseUrl())
|
||||||
|
.filter(s -> !s.isBlank());
|
||||||
|
Optional<String> apiKey = Optional.ofNullable(providerState.apiKey())
|
||||||
|
.map(keyState -> keyState.propertyValue())
|
||||||
|
.filter(s -> !s.isBlank());
|
||||||
|
|
||||||
|
int timeout = DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
String timeoutStr = providerState.timeoutSeconds();
|
||||||
|
if (timeoutStr != null && !timeoutStr.isBlank()) {
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(timeoutStr.trim());
|
||||||
|
if (parsed > 0) {
|
||||||
|
timeout = parsed;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// Use default.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ModelCatalogRequest(family.getIdentifier(), baseUrl, apiKey, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable display name for the given provider family.
|
||||||
|
*
|
||||||
|
* @param family the provider family; must not be {@code null}
|
||||||
|
* @return the display name; never {@code null}
|
||||||
|
*/
|
||||||
|
private static String displayNameFor(AiProviderFamily family) {
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> "Claude";
|
||||||
|
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an unmodifiable snapshot of the pending messages collected so far.
|
||||||
|
* <p>
|
||||||
|
* This method is intended for tests that need to inspect the message list after
|
||||||
|
* a retrieval completes.
|
||||||
|
*
|
||||||
|
* @return unmodifiable list of pending messages; never {@code null}
|
||||||
|
*/
|
||||||
|
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||||
|
return List.copyOf(pendingMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-5
@@ -5,18 +5,45 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable startup data for the GUI adapter.
|
* Immutable startup data for the GUI adapter.
|
||||||
* <p>
|
* <p>
|
||||||
* Carries the initial editor state, the optional startup notice, the file-loading callback
|
* Carries the initial editor state, the optional startup notice, the file-loading callback,
|
||||||
* and the file-writing callback that the workspace uses for native save actions.
|
* the file-writing callback that the workspace uses for native save actions, the
|
||||||
|
* {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, the
|
||||||
|
* {@link ApiKeyResolutionPort} used by the editor validation to determine the effective
|
||||||
|
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
|
||||||
|
* used to execute provider-specific technical checks, the {@link PathCheckPort}
|
||||||
|
* used to verify filesystem path accessibility for configuration values, the
|
||||||
|
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, and the
|
||||||
|
* {@link CorrectionExecutionService} used to execute corrective actions after a
|
||||||
|
* technical test run has been confirmed by the user.
|
||||||
|
* <p>
|
||||||
|
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
||||||
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
*/
|
*/
|
||||||
public record GuiStartupContext(
|
public record GuiStartupContext(
|
||||||
GuiConfigurationEditorState initialState,
|
GuiConfigurationEditorState initialState,
|
||||||
Optional<String> startupNotice,
|
Optional<String> startupNotice,
|
||||||
GuiConfigurationFileLoader configurationFileLoader,
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
GuiConfigurationFileWriter configurationFileWriter) {
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a startup context.
|
* Creates a startup context.
|
||||||
@@ -25,6 +52,12 @@ public record GuiStartupContext(
|
|||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
* @param configurationFileWriter file-writing 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 {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
@@ -33,19 +66,97 @@ public record GuiStartupContext(
|
|||||||
"configurationFileLoader must not be null");
|
"configurationFileLoader must not be null");
|
||||||
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
|
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
|
||||||
"configurationFileWriter must not be null");
|
"configurationFileWriter must not be null");
|
||||||
|
modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
|
||||||
|
"modelCatalogPort must not be null");
|
||||||
|
apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
|
||||||
|
"apiKeyResolutionPort must not be null");
|
||||||
|
providerTechnicalTestService = Objects.requireNonNull(providerTechnicalTestService,
|
||||||
|
"providerTechnicalTestService must not be null");
|
||||||
|
pathCheckPort = Objects.requireNonNull(pathCheckPort,
|
||||||
|
"pathCheckPort must not be null");
|
||||||
|
technicalTestOrchestrator = Objects.requireNonNull(technicalTestOrchestrator,
|
||||||
|
"technicalTestOrchestrator must not be null");
|
||||||
|
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||||
|
"correctionExecutionService must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a blank startup context with no loader or writer side effects.
|
* Creates a blank startup context with no loader or writer side effects, a no-op model
|
||||||
|
* catalogue port, a no-op API key resolution port, a no-op provider technical test service,
|
||||||
|
* a no-op path check port, a no-op technical test orchestrator, and a no-op
|
||||||
|
* correction execution service.
|
||||||
|
* <p>
|
||||||
|
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
|
||||||
|
* The no-op API key resolution port always returns {@code ABSENT}.
|
||||||
|
* The no-op provider technical test service uses the no-op ports above.
|
||||||
|
* The no-op path check port always returns {@code false} for all checks.
|
||||||
|
* The no-op technical test orchestrator returns a report where all checkpoints are
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
|
||||||
|
* The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
|
||||||
|
* returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
|
||||||
|
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
||||||
|
* GUI tests.
|
||||||
*
|
*
|
||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
* @return a startup context for the unloaded editor start
|
* @return a startup context for the unloaded editor start
|
||||||
*/
|
*/
|
||||||
public static GuiStartupContext blank(Optional<String> startupNotice) {
|
public static GuiStartupContext blank(Optional<String> startupNotice) {
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort noOpCatalogPort =
|
||||||
|
request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
request.providerIdentifier(),
|
||||||
|
"Kein Modellkatalog in diesem Startkontext verfügbar.");
|
||||||
|
ApiKeyResolutionPort noOpApiKeyPort = (family, propertyValue) -> EffectiveApiKeyDescriptor.absent();
|
||||||
|
ProviderTechnicalTestService noOpTestService =
|
||||||
|
new ProviderTechnicalTestService(noOpCatalogPort, noOpApiKeyPort);
|
||||||
|
PathCheckPort noOpPathCheckPort = new PathCheckPort() {
|
||||||
|
@Override
|
||||||
|
public boolean isDirectoryReadable(String path) { return false; }
|
||||||
|
@Override
|
||||||
|
public boolean isDirectoryWritableOrCreatable(String path) { return false; }
|
||||||
|
@Override
|
||||||
|
public boolean isFileReadable(String path) { return false; }
|
||||||
|
@Override
|
||||||
|
public boolean isSqlitePathUsable(String path) { return false; }
|
||||||
|
};
|
||||||
|
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
noOpPathCheckPort,
|
||||||
|
noOpTestService);
|
||||||
|
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreateDirectory suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
startupNotice,
|
startupNotice,
|
||||||
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
(values, path) -> GuiConfigurationSaveResult.saved(path));
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
noOpCatalogPort,
|
||||||
|
noOpApiKeyPort,
|
||||||
|
noOpTestService,
|
||||||
|
noOpPathCheckPort,
|
||||||
|
noOpOrchestrator,
|
||||||
|
noOpCorrectionService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestRequest;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koordiniert die asynchrone Ausführung der Aktion „Technische Tests ausführen"
|
||||||
|
* für den GUI-Konfigurationseditor.
|
||||||
|
* <p>
|
||||||
|
* Dieser Koordinator ist verantwortlich für:
|
||||||
|
* <ul>
|
||||||
|
* <li>Lesen des aktuellen GUI-Editorzustands (via {@link Supplier}).</li>
|
||||||
|
* <li>Aufbau eines {@link TechnicalTestRequest} aus dem aktuellen Zustand.</li>
|
||||||
|
* <li>Ausführung des {@link TechnicalTestOrchestrator} auf einem dedizierten
|
||||||
|
* Daemon-Hinterground-Thread namens {@code gui-technical-test}.</li>
|
||||||
|
* <li>Rückführung des {@link TechnicalTestReport} auf den JavaFX Application Thread
|
||||||
|
* via {@code Platform.runLater}.</li>
|
||||||
|
* <li>Einhängen der Ergebnisse als {@link GuiMessageEntry}-Einträge in die geteilte
|
||||||
|
* {@code pendingMessages}-Liste (Quelle: „Technische-Tests").</li>
|
||||||
|
* <li>Ersetzen vorheriger Test-Einträge (Replace-Semantik) bei jedem neuen Aufruf.</li>
|
||||||
|
* <li>Weitergabe des vollständigen Berichts an den {@code postResultCallback}, damit
|
||||||
|
* spätere Arbeitsschritte (z. B. Korrekturhilfen) auf das Ergebnis zugreifen können.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading-Kontrakt:</strong> {@link #triggerTechnicalTests()} darf nur auf dem
|
||||||
|
* JavaFX Application Thread aufgerufen werden. Hintergrund-Worker-Threads dürfen nur über
|
||||||
|
* den injizierten {@code resultDelivery}-Verbraucher mit der UI interagieren, der in der
|
||||||
|
* Produktion {@code Platform.runLater} kapselt.
|
||||||
|
* <p>
|
||||||
|
* <strong>Kein implizites Speichern:</strong> Der Koordinator liest den aktuellen GUI-Zustand
|
||||||
|
* und führt den Test aus, ohne die Konfigurationsdatei zu schreiben oder den Dirty-Zustand
|
||||||
|
* des Editors zu ändern.
|
||||||
|
* <p>
|
||||||
|
* <strong>Anti-Scope:</strong> Dieser Koordinator führt keine schreibenden Korrekturen durch.
|
||||||
|
* Korrekturvorschläge werden als Bestandteil des {@link TechnicalTestReport} zurückgegeben,
|
||||||
|
* sind aber nicht ausführbar. Die Ausführung ist einem späteren Arbeitsschritt vorbehalten.
|
||||||
|
* <p>
|
||||||
|
* Die Worker-Thread-Factory und die Result-Delivery-Funktion sind injizierbar, damit Tests
|
||||||
|
* deterministisch ohne echten Hintergrund-Thread laufen können.
|
||||||
|
*/
|
||||||
|
public final class GuiTechnicalTestCoordinator {
|
||||||
|
|
||||||
|
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
|
||||||
|
static final String SOURCE_TAG = "Technische-Tests";
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiTechnicalTestCoordinator.class);
|
||||||
|
|
||||||
|
private final TechnicalTestOrchestrator orchestrator;
|
||||||
|
private final Supplier<EditorValidationInput> inputProvider;
|
||||||
|
private final Supplier<String> configFilePathProvider;
|
||||||
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
|
private final Consumer<TechnicalTestReport> postResultCallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory für den Hintergrund-Worker-Thread. Paket-privat für Test-Substitution.
|
||||||
|
* Standard: Daemon-Thread namens {@code gui-technical-test}.
|
||||||
|
*/
|
||||||
|
Function<Runnable, Thread> testThreadFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbraucher zur Rückführung des Ergebnisses. In der Produktion kapselt er {@code Platform.runLater}.
|
||||||
|
* In Tests kann er durch einen direkten Aufruf ersetzt werden, damit das Ergebnis sofort
|
||||||
|
* auf dem Worker-Thread angewendet wird, ohne die FX-Warteschlange zu entwässern.
|
||||||
|
* Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Koordinator.
|
||||||
|
*
|
||||||
|
* @param orchestrator Orchestrator für den vollständigen Gesamttest; darf nicht {@code null} sein
|
||||||
|
* @param inputProvider Lieferant des aktuellen {@link EditorValidationInput}; darf nicht {@code null} sein
|
||||||
|
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
||||||
|
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||||
|
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
||||||
|
Supplier<EditorValidationInput> inputProvider,
|
||||||
|
Supplier<String> configFilePathProvider,
|
||||||
|
List<GuiMessageEntry> pendingMessages,
|
||||||
|
Consumer<TechnicalTestReport> postResultCallback) {
|
||||||
|
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
||||||
|
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
|
||||||
|
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
|
||||||
|
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
|
||||||
|
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
||||||
|
this.testThreadFactory = task -> {
|
||||||
|
Thread t = new Thread(task, "gui-technical-test");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus.
|
||||||
|
* <p>
|
||||||
|
* Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
|
||||||
|
* {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
|
||||||
|
* einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
|
||||||
|
* JavaFX Application Thread zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Der Konfigurationsdateipfad wird genutzt, um bei fehlender Prompt-Datei-Konfiguration
|
||||||
|
* einen sinnvollen Standardpfad ({@code <config-parent>/prompt.txt}) zu bestimmen.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void triggerTechnicalTests() {
|
||||||
|
EditorValidationInput input = inputProvider.get();
|
||||||
|
String configFilePath = configFilePathProvider.get();
|
||||||
|
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
|
||||||
|
|
||||||
|
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
||||||
|
|
||||||
|
Runnable task = () -> {
|
||||||
|
TechnicalTestReport report = orchestrator.run(request);
|
||||||
|
resultDelivery.accept(() -> {
|
||||||
|
applyResult(report);
|
||||||
|
postResultCallback.accept(report);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Thread worker = testThreadFactory.apply(task);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
|
||||||
|
* <p>
|
||||||
|
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jeden
|
||||||
|
* Checkpoint-Ergebnis einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
|
||||||
|
* angehängt.
|
||||||
|
* <p>
|
||||||
|
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||||
|
*
|
||||||
|
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void applyResult(TechnicalTestReport report) {
|
||||||
|
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
|
||||||
|
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
|
||||||
|
|
||||||
|
long successCount = 0;
|
||||||
|
long failureErrorCount = 0;
|
||||||
|
long failureWarnCount = 0;
|
||||||
|
long notApplicableCount = 0;
|
||||||
|
|
||||||
|
for (CheckpointResult result : report.results()) {
|
||||||
|
String label = labelFor(result.checkpointId());
|
||||||
|
switch (result) {
|
||||||
|
case CheckpointResult.Success success -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
label + ": OK – " + success.message(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
successCount++;
|
||||||
|
LOG.info("GUI-Gesamttest: {} → OK", label);
|
||||||
|
}
|
||||||
|
case CheckpointResult.Failure failure -> {
|
||||||
|
GuiMessageSeverity severity = failure.severity() ==
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity.ERROR
|
||||||
|
? GuiMessageSeverity.ERROR : GuiMessageSeverity.WARNING;
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
severity,
|
||||||
|
label + ": " + failure.message(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
if (severity == GuiMessageSeverity.ERROR) {
|
||||||
|
failureErrorCount++;
|
||||||
|
LOG.warn("GUI-Gesamttest: {} → FEHLER: {}", label, failure.message());
|
||||||
|
} else {
|
||||||
|
failureWarnCount++;
|
||||||
|
LOG.warn("GUI-Gesamttest: {} → WARNUNG: {}", label, failure.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case CheckpointResult.NotApplicable notApplicable -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.HINT,
|
||||||
|
label + ": nicht anwendbar – " + notApplicable.reason(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
notApplicableCount++;
|
||||||
|
LOG.info("GUI-Gesamttest: {} → nicht anwendbar: {}", label, notApplicable.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusammenfassung
|
||||||
|
long totalFindings = failureErrorCount + failureWarnCount;
|
||||||
|
String summary = buildSummaryMessage(report.results().size(),
|
||||||
|
successCount, failureErrorCount, failureWarnCount, notApplicableCount);
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
|
||||||
|
LOG.info("GUI-Gesamttest abgeschlossen. {} Befunde ({} Erfolg, {} Fehler, {} Warnung, {} nicht anwendbar).",
|
||||||
|
totalFindings, successCount, failureErrorCount, failureWarnCount, notApplicableCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das deutsche Label für einen Prüfpunkt zurück.
|
||||||
|
*
|
||||||
|
* @param id der Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @return das deutsche Label; nie {@code null}
|
||||||
|
*/
|
||||||
|
static String labelFor(CheckpointId id) {
|
||||||
|
return switch (id) {
|
||||||
|
case CONFIGURATION_BASIC_VALIDATION -> "Konfiguration grundsätzlich validierbar";
|
||||||
|
case PROVIDER_CONFIGURATION -> "Provider-Konfiguration prüfbar";
|
||||||
|
case BASE_URL_REACHABLE -> "Basis-URL/Endpoint erreichbar";
|
||||||
|
case API_KEY_PRESENT -> "API-Schlüssel vorhanden";
|
||||||
|
case API_KEY_ACCEPTED -> "API-Schlüssel technisch akzeptiert";
|
||||||
|
case MODEL_LIST_AVAILABLE -> "Modellliste abrufbar";
|
||||||
|
case SELECTED_MODEL_PLAUSIBLE -> "Ausgewähltes Modell plausibel";
|
||||||
|
case PROMPT_FILE_PRESENT -> "Prompt-Datei vorhanden und lesbar";
|
||||||
|
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
||||||
|
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
||||||
|
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die deutsche Zusammenfassungsmeldung des Gesamttests.
|
||||||
|
*
|
||||||
|
* @param total Gesamtzahl der Prüfpunkte
|
||||||
|
* @param successCount Anzahl der erfolgreichen Prüfpunkte
|
||||||
|
* @param errorCount Anzahl der fehlgeschlagenen Prüfpunkte (Schweregrad ERROR)
|
||||||
|
* @param warningCount Anzahl der Warnungs-Prüfpunkte
|
||||||
|
* @param notApplicable Anzahl der nicht-anwendbaren Prüfpunkte
|
||||||
|
* @return deutsche Zusammenfassungsmeldung; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static String buildSummaryMessage(long total, long successCount, long errorCount,
|
||||||
|
long warningCount, long notApplicable) {
|
||||||
|
long findings = errorCount + warningCount;
|
||||||
|
String base = "Gesamttest abgeschlossen. " + total + " Prüfpunkte: "
|
||||||
|
+ successCount + " Erfolg, "
|
||||||
|
+ errorCount + " Fehler, "
|
||||||
|
+ warningCount + " Warnung, "
|
||||||
|
+ notApplicable + " nicht anwendbar.";
|
||||||
|
if (findings == 0) {
|
||||||
|
return base + " Keine Befunde.";
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
|
||||||
|
* <p>
|
||||||
|
* Ausschließlich für Tests gedacht.
|
||||||
|
*
|
||||||
|
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
|
||||||
|
*/
|
||||||
|
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||||
|
return List.copyOf(pendingMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import javafx.util.StringConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A JavaFX {@link StringConverter} that maps {@link AiProviderFamily} constants to
|
||||||
|
* German display labels and back.
|
||||||
|
* <p>
|
||||||
|
* Used by the provider selection {@code ComboBox} to show human-readable German names
|
||||||
|
* while keeping the underlying model type-safe. The reverse conversion
|
||||||
|
* ({@link #fromString(String)}) supports the same label strings produced by
|
||||||
|
* {@link #toString(AiProviderFamily)} so that a ComboBox configured as non-editable
|
||||||
|
* can still convert its selected text back to the enum constant when needed.
|
||||||
|
* <p>
|
||||||
|
* Returns {@code null} for inputs that do not match any known constant to signal an
|
||||||
|
* unrecognised display label.
|
||||||
|
*/
|
||||||
|
public final class AiProviderFamilyStringConverter extends StringConverter<AiProviderFamily> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new converter instance.
|
||||||
|
*/
|
||||||
|
public AiProviderFamilyStringConverter() {
|
||||||
|
// Default constructor.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the German display label for the given provider family.
|
||||||
|
*
|
||||||
|
* @param family the provider family to convert; may be {@code null}
|
||||||
|
* @return the German display label, or an empty string when {@code family} is {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String toString(AiProviderFamily family) {
|
||||||
|
if (family == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> "Claude";
|
||||||
|
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a German display label back to its {@link AiProviderFamily} constant.
|
||||||
|
*
|
||||||
|
* @param label the display label as produced by {@link #toString(AiProviderFamily)};
|
||||||
|
* may be {@code null}
|
||||||
|
* @return the matching constant, or {@code null} when the label is not recognised
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public AiProviderFamily fromString(String label) {
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (label) {
|
||||||
|
case "Claude" -> AiProviderFamily.CLAUDE;
|
||||||
|
case "OpenAI-kompatibel" -> AiProviderFamily.OPENAI_COMPATIBLE;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhalt des gesammelten Bestätigungsdialogs für schreibende Korrekturmaßnahmen.
|
||||||
|
* <p>
|
||||||
|
* Bevor schreibende Korrekturen aus einem {@link CorrectionPlan} ausgeführt werden,
|
||||||
|
* zeigt die GUI diesen Inhalt in einem einmaligen Bestätigungsdialog. Der Benutzer
|
||||||
|
* kann die Korrekturen bestätigen oder ablehnen; ohne Bestätigung werden keine
|
||||||
|
* Änderungen vorgenommen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record liegt bewusst im GUI-Modul, da er ausschließlich für die
|
||||||
|
* Darstellung im Bestätigungsdialog der JavaFX-Oberfläche genutzt wird. Er enthält
|
||||||
|
* selbst keine JavaFX-Typen und kann auf beliebigen Threads erzeugt werden.
|
||||||
|
* <p>
|
||||||
|
* Die Beschreibungszeilen ({@link #correctionLines}) entsprechen den
|
||||||
|
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der im Plan enthaltenen
|
||||||
|
* Vorschläge in Reihenfolge.
|
||||||
|
*
|
||||||
|
* @param title deutscher Dialogtitel; nie {@code null}
|
||||||
|
* @param introText einleitender deutschsprachiger Text; nie {@code null}
|
||||||
|
* @param correctionLines Liste der deutschen Beschreibungszeilen, eine pro Korrekturmaßnahme;
|
||||||
|
* nie {@code null}
|
||||||
|
*/
|
||||||
|
public record ConfirmationDialogContent(
|
||||||
|
String title,
|
||||||
|
String introText,
|
||||||
|
List<String> correctionLines) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Bestätigungsdialog-Inhalt.
|
||||||
|
*
|
||||||
|
* @param title Dialogtitel; darf nicht {@code null} sein
|
||||||
|
* @param introText einleitender Text; darf nicht {@code null} sein
|
||||||
|
* @param correctionLines Beschreibungszeilen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public ConfirmationDialogContent {
|
||||||
|
Objects.requireNonNull(title, "title must not be null");
|
||||||
|
Objects.requireNonNull(introText, "introText must not be null");
|
||||||
|
Objects.requireNonNull(correctionLines, "correctionLines must not be null");
|
||||||
|
correctionLines = List.copyOf(correctionLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den Bestätigungsdialog-Inhalt aus einem {@link CorrectionPlan}.
|
||||||
|
* <p>
|
||||||
|
* Die Beschreibungszeilen werden aus den
|
||||||
|
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der Vorschläge im Plan
|
||||||
|
* in Reihenfolge übernommen.
|
||||||
|
*
|
||||||
|
* @param plan Korrekturplan; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Dialoginhalt; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code plan} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static ConfirmationDialogContent fromPlan(CorrectionPlan plan) {
|
||||||
|
Objects.requireNonNull(plan, "plan must not be null");
|
||||||
|
List<String> lines = plan.suggestions().stream()
|
||||||
|
.map(CorrectionSuggestion::descriptionForUser)
|
||||||
|
.toList();
|
||||||
|
return new ConfirmationDialogContent(
|
||||||
|
"Korrekturen bestätigen",
|
||||||
|
"Folgende technische Korrekturen werden durchgeführt:",
|
||||||
|
lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob der Dialoginhalt mindestens eine Beschreibungszeile enthält.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens eine Korrekturmaßnahme beschrieben ist
|
||||||
|
*/
|
||||||
|
public boolean hasCorrections() {
|
||||||
|
return !correctionLines.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the result of validating the current editor state in the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Each validation run produces one immutable result containing all findings split into two
|
||||||
|
* complementary views:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code messages} – a consolidated list of {@link GuiMessageEntry} objects that feed
|
||||||
|
* the central message area.</li>
|
||||||
|
* <li>{@code fieldFindings} – field-specific {@link GuiFieldFinding} objects that are
|
||||||
|
* rendered directly below the affected input fields.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* A single root cause may appear in both lists: once as a central message (with full context)
|
||||||
|
* and once as a compact field finding (with a short, field-specific description).
|
||||||
|
* <p>
|
||||||
|
* The {@code evaluatedAt} timestamp records when the validation ran; the GUI may use it to
|
||||||
|
* determine whether a displayed result is still current.
|
||||||
|
* <p>
|
||||||
|
* This record contains no JavaFX references and can be created and inspected on any thread.
|
||||||
|
*
|
||||||
|
* @param messages consolidated list of message entries for the central message area;
|
||||||
|
* never {@code null}
|
||||||
|
* @param fieldFindings list of field-level findings; never {@code null}
|
||||||
|
* @param evaluatedAt instant at which the validation was performed; never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiEditorValidationResult(
|
||||||
|
List<GuiMessageEntry> messages,
|
||||||
|
List<GuiFieldFinding> fieldFindings,
|
||||||
|
Instant evaluatedAt) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new validation result.
|
||||||
|
*
|
||||||
|
* @param messages central message entries; must not be {@code null}
|
||||||
|
* @param fieldFindings field-level findings; must not be {@code null}
|
||||||
|
* @param evaluatedAt validation timestamp; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiEditorValidationResult {
|
||||||
|
Objects.requireNonNull(messages, "messages must not be null");
|
||||||
|
Objects.requireNonNull(fieldFindings, "fieldFindings must not be null");
|
||||||
|
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
|
||||||
|
messages = List.copyOf(messages);
|
||||||
|
fieldFindings = List.copyOf(fieldFindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an empty validation result representing the state before any validation has run.
|
||||||
|
* <p>
|
||||||
|
* Callers must not interpret an empty result as "no errors found"; they should wait for
|
||||||
|
* a non-empty result from the first actual validation run.
|
||||||
|
*
|
||||||
|
* @return an empty result with the current instant as timestamp; never {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiEditorValidationResult empty() {
|
||||||
|
return new GuiEditorValidationResult(List.of(), List.of(), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when at least one message or field finding has severity
|
||||||
|
* {@link GuiMessageSeverity#ERROR}.
|
||||||
|
* <p>
|
||||||
|
* A result with errors indicates that the current editor state is not operational and
|
||||||
|
* should not be relied upon to start a processing run without correction.
|
||||||
|
*
|
||||||
|
* @return {@code true} when at least one error is present
|
||||||
|
*/
|
||||||
|
public boolean hasErrors() {
|
||||||
|
boolean messageError = messages.stream()
|
||||||
|
.anyMatch(m -> m.severity() == GuiMessageSeverity.ERROR);
|
||||||
|
boolean fieldError = fieldFindings.stream()
|
||||||
|
.anyMatch(f -> f.severity() == GuiMessageSeverity.ERROR);
|
||||||
|
return messageError || fieldError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when the findings list contains at least one finding for the
|
||||||
|
* requested field key, regardless of severity.
|
||||||
|
*
|
||||||
|
* @param fieldKey the property key to look up; must not be {@code null}
|
||||||
|
* @return {@code true} when at least one finding refers to the requested field
|
||||||
|
*/
|
||||||
|
public boolean hasFieldFindingFor(String fieldKey) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return fieldFindings.stream().anyMatch(f -> f.fieldKey().equals(fieldKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a field-level validation finding that is displayed directly below the affected
|
||||||
|
* input field in the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Field-level findings complement the central message area: the central area shows all findings
|
||||||
|
* in a consolidated list while this type carries the finding directly to the specific field it
|
||||||
|
* relates to, making it easier for the user to identify and correct the problem.
|
||||||
|
* <p>
|
||||||
|
* The {@code fieldKey} uses the property key as defined in the {@code .properties} file
|
||||||
|
* (e.g., {@code "source.folder"}, {@code "ai.provider.openai-compatible.apiKey"}).
|
||||||
|
* Using the property key as the field identifier keeps the validation model stable and independent
|
||||||
|
* of GUI layout changes.
|
||||||
|
* <p>
|
||||||
|
* Field-level findings are always rendered as small red German-language text directly beneath
|
||||||
|
* the affected control. Findings with severity {@link GuiMessageSeverity#INFO} or
|
||||||
|
* {@link GuiMessageSeverity#HINT} may also be shown field-near when the context is helpful.
|
||||||
|
* <p>
|
||||||
|
* This record contains no JavaFX references and is safe to create on any thread.
|
||||||
|
*
|
||||||
|
* @param fieldKey the property key identifying the affected configuration field; never {@code null}
|
||||||
|
* @param severity the severity of this finding; never {@code null}
|
||||||
|
* @param text short, German-language description of the problem; never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiFieldFinding(
|
||||||
|
String fieldKey,
|
||||||
|
GuiMessageSeverity severity,
|
||||||
|
String text) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new field-level finding.
|
||||||
|
*
|
||||||
|
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||||
|
* @param severity severity of the finding; must not be {@code null}
|
||||||
|
* @param text German-language problem description; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiFieldFinding {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
|
Objects.requireNonNull(text, "text must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an error-severity field finding.
|
||||||
|
*
|
||||||
|
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||||
|
* @param text German-language problem description; must not be {@code null}
|
||||||
|
* @return a new finding with severity {@link GuiMessageSeverity#ERROR}
|
||||||
|
*/
|
||||||
|
public static GuiFieldFinding error(String fieldKey, String text) {
|
||||||
|
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.ERROR, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a warning-severity field finding.
|
||||||
|
*
|
||||||
|
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||||
|
* @param text German-language problem description; must not be {@code null}
|
||||||
|
* @return a new finding with severity {@link GuiMessageSeverity#WARNING}
|
||||||
|
*/
|
||||||
|
public static GuiFieldFinding warning(String fieldKey, String text) {
|
||||||
|
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.WARNING, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the model identifier entered manually by the user when no remote model list
|
||||||
|
* is available for the active provider.
|
||||||
|
* <p>
|
||||||
|
* This record captures both the provider context and the user-supplied model name so that
|
||||||
|
* later GUI layers can decide whether the value is still applicable after a provider change
|
||||||
|
* or a successful remote list retrieval.
|
||||||
|
* <p>
|
||||||
|
* A manually entered model name is discarded when a remote model list is subsequently loaded
|
||||||
|
* and the previously entered value does not appear in that list.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider for which the model was entered;
|
||||||
|
* never {@code null}
|
||||||
|
* @param modelName model identifier as typed by the user; never {@code null},
|
||||||
|
* but may be blank when the user has not yet entered anything
|
||||||
|
*/
|
||||||
|
public record GuiManualModelEntry(
|
||||||
|
String providerIdentifier,
|
||||||
|
String modelName) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new manual model entry.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||||
|
* @param modelName model name as entered by the user; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiManualModelEntry {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
Objects.requireNonNull(modelName, "modelName must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the model name is non-blank, i.e. whether the user has entered something.
|
||||||
|
*
|
||||||
|
* @return {@code true} when the model name contains at least one non-whitespace character
|
||||||
|
*/
|
||||||
|
public boolean hasModelName() {
|
||||||
|
return !modelName.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single entry in the central message area of the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Each entry carries a severity level, the message text, an optional source label that
|
||||||
|
* identifies the subsystem that produced the message (e.g., "Modellabruf", "Validierung"),
|
||||||
|
* and a timestamp. The GUI renders the {@link GuiMessageSeverity#getPrefixLabel() prefix} of
|
||||||
|
* the severity in colour while the message text itself remains black.
|
||||||
|
* <p>
|
||||||
|
* Instances are immutable and contain no JavaFX references; they are safe to create on
|
||||||
|
* background threads and pass to the JavaFX Application Thread via {@code Platform.runLater}.
|
||||||
|
*
|
||||||
|
* @param severity the severity of this message; never {@code null}
|
||||||
|
* @param text the message text; never {@code null}
|
||||||
|
* @param source optional label identifying the origin subsystem; empty when not applicable
|
||||||
|
* @param timestamp the instant at which the message was created; never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiMessageEntry(
|
||||||
|
GuiMessageSeverity severity,
|
||||||
|
String text,
|
||||||
|
Optional<String> source,
|
||||||
|
Instant timestamp) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new message entry.
|
||||||
|
*
|
||||||
|
* @param severity severity level; must not be {@code null}
|
||||||
|
* @param text message text; must not be {@code null}
|
||||||
|
* @param source optional source label; {@code null} is treated as {@link Optional#empty()}
|
||||||
|
* @param timestamp creation timestamp; must not be {@code null}
|
||||||
|
* @throws NullPointerException if {@code severity}, {@code text}, or {@code timestamp} is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiMessageEntry {
|
||||||
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
|
Objects.requireNonNull(text, "text must not be null");
|
||||||
|
Objects.requireNonNull(timestamp, "timestamp must not be null");
|
||||||
|
source = source == null ? Optional.empty() : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a message entry without a source label, using the current instant as timestamp.
|
||||||
|
*
|
||||||
|
* @param severity severity level; must not be {@code null}
|
||||||
|
* @param text message text; must not be {@code null}
|
||||||
|
* @return a new entry; never {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiMessageEntry of(GuiMessageSeverity severity, String text) {
|
||||||
|
return new GuiMessageEntry(severity, text, Optional.empty(), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a message entry with a source label, using the current instant as timestamp.
|
||||||
|
*
|
||||||
|
* @param severity severity level; must not be {@code null}
|
||||||
|
* @param text message text; must not be {@code null}
|
||||||
|
* @param source source subsystem label; must not be {@code null}
|
||||||
|
* @return a new entry; never {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiMessageEntry of(GuiMessageSeverity severity, String text, String source) {
|
||||||
|
Objects.requireNonNull(source, "source must not be null");
|
||||||
|
return new GuiMessageEntry(severity, text, Optional.of(source), Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the four fixed severity levels for messages displayed in the central message area
|
||||||
|
* of the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Each level carries a German-language prefix string that is displayed in colour at the start
|
||||||
|
* of each message line. The remainder of the message text is always rendered in black,
|
||||||
|
* regardless of severity.
|
||||||
|
* <p>
|
||||||
|
* The colour hints in this enum are expressed as CSS colour strings to avoid a compile-time
|
||||||
|
* dependency on JavaFX. Rendering code in the GUI layer must convert the hint to a
|
||||||
|
* {@code javafx.scene.paint.Color} or equivalent.
|
||||||
|
* <p>
|
||||||
|
* Severity levels ordered from least to most critical:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #INFO}</li>
|
||||||
|
* <li>{@link #HINT}</li>
|
||||||
|
* <li>{@link #WARNING}</li>
|
||||||
|
* <li>{@link #ERROR}</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* This enum contains no JavaFX references and is safe to use in unit-tested view-model code.
|
||||||
|
*/
|
||||||
|
public enum GuiMessageSeverity {
|
||||||
|
|
||||||
|
/** Neutral informational message, no action required. */
|
||||||
|
INFO("Info:", "#1565c0"),
|
||||||
|
|
||||||
|
/** Helpful hint that the user may want to act on. */
|
||||||
|
HINT("Hinweis:", "#558b2f"),
|
||||||
|
|
||||||
|
/** Configuration value is technically acceptable but risky or unusual. */
|
||||||
|
WARNING("Warnung:", "#e65100"),
|
||||||
|
|
||||||
|
/** Configuration value is invalid or the state is not operational. */
|
||||||
|
ERROR("Fehler:", "#b71c1c");
|
||||||
|
|
||||||
|
private final String prefixLabel;
|
||||||
|
private final String prefixCssColour;
|
||||||
|
|
||||||
|
GuiMessageSeverity(String prefixLabel, String prefixCssColour) {
|
||||||
|
this.prefixLabel = prefixLabel;
|
||||||
|
this.prefixCssColour = prefixCssColour;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the German-language prefix label shown at the start of each message line of this severity.
|
||||||
|
* <p>
|
||||||
|
* Only the prefix is rendered in colour; the remaining message text is always black.
|
||||||
|
*
|
||||||
|
* @return the prefix label; never {@code null}
|
||||||
|
*/
|
||||||
|
public String getPrefixLabel() {
|
||||||
|
return prefixLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a CSS colour string hint that the GUI layer uses to render the prefix in the
|
||||||
|
* appropriate colour.
|
||||||
|
* <p>
|
||||||
|
* The returned value is a CSS hex colour (e.g., {@code "#b71c1c"}) that can be passed to
|
||||||
|
* {@code Color.web()} in JavaFX. The GUI layer is responsible for this conversion; this
|
||||||
|
* enum itself contains no JavaFX dependency.
|
||||||
|
*
|
||||||
|
* @return a CSS colour hint string; never {@code null}
|
||||||
|
*/
|
||||||
|
public String getPrefixCssColour() {
|
||||||
|
return prefixCssColour;
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
||||||
|
* for model identifier input, depending on the current {@link GuiModelSource}.
|
||||||
|
* <p>
|
||||||
|
* When the model source is {@link GuiModelSource#LIST_REMOTE_SUCCESS}, a non-editable
|
||||||
|
* {@code ComboBox} is shown, pre-populated with the remote list and with the first model
|
||||||
|
* pre-selected. In all other cases (including {@link GuiModelSource#NOT_YET_LOADED}) the
|
||||||
|
* manual text field is shown, which may be empty or disabled depending on the source state.
|
||||||
|
* <p>
|
||||||
|
* Exactly one child is {@code visible} and {@code managed} at any time. The other child is
|
||||||
|
* kept in the scene graph with both flags set to {@code false} so that no blank space appears.
|
||||||
|
* <p>
|
||||||
|
* This class contains JavaFX references and must only be used on the JavaFX Application Thread.
|
||||||
|
*/
|
||||||
|
public final class GuiModelFieldContainer extends StackPane {
|
||||||
|
|
||||||
|
private final ComboBox<String> comboBox;
|
||||||
|
private final TextField textField;
|
||||||
|
private final Consumer<String> onModelChange;
|
||||||
|
|
||||||
|
private GuiModelSource currentSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard flag that suppresses the change callback while the text field value is being set
|
||||||
|
* programmatically via {@link #setTextFieldValue(String)}. The callback must still fire on
|
||||||
|
* genuine user edits, so the guard is scoped tightly around the programmatic write only.
|
||||||
|
*/
|
||||||
|
private boolean programmaticTextFieldSet = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new model field container.
|
||||||
|
*
|
||||||
|
* @param initialModelValue the initial model text shown in the text field; may be blank
|
||||||
|
* @param onModelChange callback invoked on every model-value change; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelFieldContainer(String initialModelValue, Consumer<String> onModelChange) {
|
||||||
|
this.onModelChange = Objects.requireNonNull(onModelChange, "onModelChange must not be null");
|
||||||
|
this.currentSource = GuiModelSource.NOT_YET_LOADED;
|
||||||
|
|
||||||
|
this.textField = new TextField(initialModelValue == null ? "" : initialModelValue);
|
||||||
|
this.textField.textProperty().addListener((obs, oldText, newText) -> {
|
||||||
|
if (!programmaticTextFieldSet && !newText.equals(oldText)) {
|
||||||
|
onModelChange.accept(newText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.comboBox = new ComboBox<>();
|
||||||
|
this.comboBox.setEditable(false);
|
||||||
|
this.comboBox.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||||
|
if (newVal != null && !newVal.equals(oldVal)) {
|
||||||
|
onModelChange.accept(newVal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state: show text field (NOT_YET_LOADED → manual input)
|
||||||
|
applyVisibility(false);
|
||||||
|
getChildren().addAll(comboBox, textField);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently displayed model value.
|
||||||
|
* <p>
|
||||||
|
* When the {@code ComboBox} is active, returns the selected item. When the text field
|
||||||
|
* is active, returns the text field content. Never returns {@code null}.
|
||||||
|
*
|
||||||
|
* @return the current model value; never {@code null}
|
||||||
|
*/
|
||||||
|
public String currentModelValue() {
|
||||||
|
if (currentSource == GuiModelSource.LIST_REMOTE_SUCCESS) {
|
||||||
|
String val = comboBox.getValue();
|
||||||
|
return val == null ? "" : val;
|
||||||
|
}
|
||||||
|
return textField.getText() == null ? "" : textField.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current model source state.
|
||||||
|
*
|
||||||
|
* @return the current {@link GuiModelSource}; never {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelSource currentSource() {
|
||||||
|
return currentSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a successful model list and switches to the non-editable {@link ComboBox}.
|
||||||
|
* <p>
|
||||||
|
* If the previously active manual text value is present in the new list it is kept as the
|
||||||
|
* selection; otherwise the first model in the list is pre-selected and the former manual
|
||||||
|
* value is discarded.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param models non-empty list of model identifiers; must not be {@code null} or empty
|
||||||
|
* @param previousManualValue the model text that was in the text field before this call;
|
||||||
|
* used to decide whether to preserve the selection
|
||||||
|
* @throws IllegalArgumentException if {@code models} is empty
|
||||||
|
*/
|
||||||
|
public void applyModelList(List<String> models, String previousManualValue) {
|
||||||
|
Objects.requireNonNull(models, "models must not be null");
|
||||||
|
if (models.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("models must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
comboBox.getItems().setAll(models);
|
||||||
|
|
||||||
|
// Preserve the previous value only when it appears in the new list.
|
||||||
|
String previous = previousManualValue == null ? "" : previousManualValue;
|
||||||
|
if (!previous.isBlank() && models.contains(previous)) {
|
||||||
|
comboBox.setValue(previous);
|
||||||
|
} else {
|
||||||
|
comboBox.setValue(models.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSource = GuiModelSource.LIST_REMOTE_SUCCESS;
|
||||||
|
applyVisibility(true);
|
||||||
|
// Notify the callback about the newly selected value.
|
||||||
|
String selected = comboBox.getValue();
|
||||||
|
if (selected != null) {
|
||||||
|
onModelChange.accept(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the manual text field with the given fallback source state.
|
||||||
|
* <p>
|
||||||
|
* The text field retains whatever value it currently holds (or the value set programmatically
|
||||||
|
* via {@link #setTextFieldValue(String)}). Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param source the non-success source state; must not be {@link GuiModelSource#LIST_REMOTE_SUCCESS}
|
||||||
|
*/
|
||||||
|
public void applyManualFallback(GuiModelSource source) {
|
||||||
|
Objects.requireNonNull(source, "source must not be null");
|
||||||
|
if (source == GuiModelSource.LIST_REMOTE_SUCCESS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"applyManualFallback must not be called with LIST_REMOTE_SUCCESS");
|
||||||
|
}
|
||||||
|
currentSource = source;
|
||||||
|
applyVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically sets the text field value without triggering the change callback.
|
||||||
|
* <p>
|
||||||
|
* Useful for restoring a saved model value after a provider switch. Must be called on the
|
||||||
|
* JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param value the new text field value; {@code null} is treated as an empty string
|
||||||
|
*/
|
||||||
|
public void setTextFieldValue(String value) {
|
||||||
|
programmaticTextFieldSet = true;
|
||||||
|
try {
|
||||||
|
textField.setText(value == null ? "" : value);
|
||||||
|
} finally {
|
||||||
|
programmaticTextFieldSet = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JavaFX node that represents this container and can be added to the scene graph.
|
||||||
|
*
|
||||||
|
* @return {@code this} container; never {@code null}
|
||||||
|
*/
|
||||||
|
public Node asNode() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies visibility to the ComboBox and TextField based on whether the list is active.
|
||||||
|
*
|
||||||
|
* @param listActive {@code true} to show the ComboBox, {@code false} to show the TextField
|
||||||
|
*/
|
||||||
|
private void applyVisibility(boolean listActive) {
|
||||||
|
comboBox.setVisible(listActive);
|
||||||
|
comboBox.setManaged(listActive);
|
||||||
|
textField.setVisible(!listActive);
|
||||||
|
textField.setManaged(!listActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the origin of the currently displayed model value in the GUI model selection area.
|
||||||
|
* <p>
|
||||||
|
* The GUI uses this enum to decide which control to show for the model input:
|
||||||
|
* <ul>
|
||||||
|
* <li>When the source is {@link #LIST_REMOTE_SUCCESS}, a non-editable {@code ComboBox}
|
||||||
|
* is shown, pre-populated with the remote list.</li>
|
||||||
|
* <li>When the source is {@link #LIST_UNAVAILABLE_MANUAL_INPUT} or
|
||||||
|
* {@link #LIST_FAILED_MANUAL_INPUT}, a plain text input field is shown instead,
|
||||||
|
* allowing the user to enter the model name manually.</li>
|
||||||
|
* <li>{@link #NOT_YET_LOADED} represents the initial state before the first retrieval
|
||||||
|
* attempt; the GUI should render a loading indicator or show the text field
|
||||||
|
* in a disabled/pending state.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* This enum is intentionally free of JavaFX references so it can be used in unit-tested
|
||||||
|
* view-model code without starting a JavaFX runtime.
|
||||||
|
*/
|
||||||
|
public enum GuiModelSource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model list was successfully retrieved from the remote provider endpoint.
|
||||||
|
* <p>
|
||||||
|
* A non-editable {@code ComboBox} is displayed, pre-selecting the first available model.
|
||||||
|
*/
|
||||||
|
LIST_REMOTE_SUCCESS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No model list is available because the provider does not expose a model catalogue endpoint
|
||||||
|
* or because the configuration was incomplete.
|
||||||
|
* <p>
|
||||||
|
* A manual text input field is shown and the user must enter the model identifier by hand.
|
||||||
|
*/
|
||||||
|
LIST_UNAVAILABLE_MANUAL_INPUT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A technical error occurred while retrieving the model list (e.g., HTTP error, timeout,
|
||||||
|
* authentication failure).
|
||||||
|
* <p>
|
||||||
|
* A manual text input field is shown so the user can still supply a model name; the GUI
|
||||||
|
* also reports the failure in the central message area.
|
||||||
|
*/
|
||||||
|
LIST_FAILED_MANUAL_INPUT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial state before the first model retrieval attempt has been made.
|
||||||
|
* <p>
|
||||||
|
* The GUI should indicate that a retrieval is pending and must not present the manual
|
||||||
|
* input field as the definitive fallback until at least one retrieval attempt has completed.
|
||||||
|
*/
|
||||||
|
NOT_YET_LOADED
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents which provider section is currently visible in the GUI and preserves
|
||||||
|
* the editable configuration state of the section that is currently hidden.
|
||||||
|
* <p>
|
||||||
|
* The GUI shows exactly one provider section at a time. When the user switches the
|
||||||
|
* active provider, the previously visible section must not lose its field values.
|
||||||
|
* This record captures the current display context as an immutable snapshot so that
|
||||||
|
* view-model code can reason about visibility and data preservation without touching
|
||||||
|
* JavaFX nodes directly.
|
||||||
|
* <p>
|
||||||
|
* Instances of this record contain no JavaFX references and are safe to create and
|
||||||
|
* inspect from any thread, including unit-test threads.
|
||||||
|
*
|
||||||
|
* @param visibleProvider the provider family whose configuration section is
|
||||||
|
* currently rendered; never {@code null}
|
||||||
|
* @param visibleProviderState the editable configuration state currently displayed;
|
||||||
|
* never {@code null}
|
||||||
|
* @param hiddenProviderState the editable configuration state of the provider that
|
||||||
|
* is not shown, preserved here so it is not lost on switch;
|
||||||
|
* never {@code null}
|
||||||
|
* @param hiddenProvider the provider family whose section is currently hidden;
|
||||||
|
* never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily visibleProvider,
|
||||||
|
GuiProviderConfigurationState visibleProviderState,
|
||||||
|
AiProviderFamily hiddenProvider,
|
||||||
|
GuiProviderConfigurationState hiddenProviderState) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new visible-provider section snapshot.
|
||||||
|
*
|
||||||
|
* @param visibleProvider provider whose section is shown; must not be {@code null}
|
||||||
|
* @param visibleProviderState configuration state of the visible provider; must not be {@code null}
|
||||||
|
* @param hiddenProvider provider whose section is hidden; must not be {@code null}
|
||||||
|
* @param hiddenProviderState configuration state of the hidden provider; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code visibleProvider} and {@code hiddenProvider} are equal
|
||||||
|
*/
|
||||||
|
public GuiVisibleProviderSection {
|
||||||
|
Objects.requireNonNull(visibleProvider, "visibleProvider must not be null");
|
||||||
|
Objects.requireNonNull(visibleProviderState, "visibleProviderState must not be null");
|
||||||
|
Objects.requireNonNull(hiddenProvider, "hiddenProvider must not be null");
|
||||||
|
Objects.requireNonNull(hiddenProviderState, "hiddenProviderState must not be null");
|
||||||
|
if (visibleProvider == hiddenProvider) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"visibleProvider and hiddenProvider must be different, but both are: " + visibleProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configuration state for the requested provider family.
|
||||||
|
*
|
||||||
|
* @param family the provider family to retrieve the state for; must not be {@code null}
|
||||||
|
* @return the state for the requested provider
|
||||||
|
* @throws IllegalArgumentException if the requested family is neither the visible nor the hidden provider
|
||||||
|
*/
|
||||||
|
public GuiProviderConfigurationState stateFor(AiProviderFamily family) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
if (family == visibleProvider) {
|
||||||
|
return visibleProviderState;
|
||||||
|
}
|
||||||
|
if (family == hiddenProvider) {
|
||||||
|
return hiddenProviderState;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown provider family: " + family);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the visible and hidden providers swapped, preserving both states.
|
||||||
|
* <p>
|
||||||
|
* The previously hidden provider becomes visible and the previously visible provider
|
||||||
|
* moves to hidden. No field values are lost during the switch.
|
||||||
|
*
|
||||||
|
* @return a new section snapshot with providers and their states swapped
|
||||||
|
*/
|
||||||
|
public GuiVisibleProviderSection switchProvider() {
|
||||||
|
return new GuiVisibleProviderSection(hiddenProvider, hiddenProviderState,
|
||||||
|
visibleProvider, visibleProviderState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with a different configuration state for the visible provider.
|
||||||
|
*
|
||||||
|
* @param newState the updated configuration state; must not be {@code null}
|
||||||
|
* @return a new section snapshot with the visible provider's state replaced
|
||||||
|
*/
|
||||||
|
public GuiVisibleProviderSection withVisibleProviderState(GuiProviderConfigurationState newState) {
|
||||||
|
Objects.requireNonNull(newState, "newState must not be null");
|
||||||
|
return new GuiVisibleProviderSection(visibleProvider, newState, hiddenProvider, hiddenProviderState);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-6
@@ -1,12 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* Editor state and template model for the JavaFX configuration editor.
|
* Editor state and view-model types for the JavaFX configuration editor.
|
||||||
* <p>
|
* <p>
|
||||||
* This package contains the GUI-side representation of configuration data that can be edited
|
* This package contains the GUI-side representation of configuration data that can be edited
|
||||||
* independently from file I/O and validation. It separates loaded file snapshots, baseline
|
* independently from file I/O and validation. It covers:
|
||||||
* editor values, current editor values, provider-specific API key state, and the derived
|
* <ul>
|
||||||
* dirty-state view used by the GUI.
|
* <li>Loaded file snapshots, baseline editor values, current editor values and the derived
|
||||||
|
* dirty-state view ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues}).</li>
|
||||||
|
* <li>Provider-specific configuration state and API-key state
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState}).</li>
|
||||||
|
* <li>Provider section visibility and state preservation across provider switches
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection}).</li>
|
||||||
|
* <li>Model source classification, manual model entry, and the JavaFX model field container
|
||||||
|
* that switches between a non-editable {@code ComboBox} and a text field depending on
|
||||||
|
* retrieval outcome
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiManualModelEntry},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}).</li>
|
||||||
|
* <li>Message severity, central message entries and field-level validation findings
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiFieldFinding}).</li>
|
||||||
|
* <li>The consolidated validation result that feeds both the central message area and
|
||||||
|
* field-near error display
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).</li>
|
||||||
|
* <li>The confirmation dialog content for collected write-corrective actions
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent}).</li>
|
||||||
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* The classes in this package are intentionally free of JavaFX controls so they can be reused
|
* Most classes in this package are intentionally free of JavaFX controls so they can be used
|
||||||
* by later GUI layers without coupling the model to a particular layout implementation.
|
* in unit-tested view-model code without starting a JavaFX runtime. The exception is
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}, which
|
||||||
|
* extends a JavaFX {@code StackPane} and must be used only on the JavaFX Application Thread.
|
||||||
|
* <p>
|
||||||
|
* Types that are not GUI-specific (API-key origin provenance, model catalogue results and
|
||||||
|
* the corresponding port contract) live in
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog} to keep the
|
||||||
|
* Application module free of GUI dependencies while allowing future non-GUI consumers
|
||||||
|
* to reuse these types without depending on this adapter module.
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|||||||
+29
-1
@@ -395,7 +395,35 @@ class GuiAdapterSmokeTest {
|
|||||||
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
testWriter);
|
testWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
|
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
|
||||||
workspaceRef.set(workspace);
|
workspaceRef.set(workspace);
|
||||||
|
|
||||||
|
|||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-basierte headless Smoke-Tests für {@link GuiCorrectionDialogCoordinator}.
|
||||||
|
* <p>
|
||||||
|
* Prüft folgende Szenarien:
|
||||||
|
* <ul>
|
||||||
|
* <li>Bericht mit korrigierbaren Befunden → Dialog wird angefragt, bei Bestätigung
|
||||||
|
* werden Korrekturen ausgeführt, Meldungen erscheinen.</li>
|
||||||
|
* <li>Bericht ohne korrigierbare Befunde → kein Dialog, keine Korrekturen.</li>
|
||||||
|
* <li>Bei Dialog-Abbruch → keine Korrekturen, keine Meldungen mit Source „Korrekturen".</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Der {@code correctionThreadFactory} wird auf synchrone Ausführung umgestellt und
|
||||||
|
* {@code resultDelivery} auf direkten Aufruf, damit Ergebnisse sofort nach
|
||||||
|
* {@code offerCorrections()} verfügbar sind.
|
||||||
|
*/
|
||||||
|
class GuiCorrectionDialogCoordinatorSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform – do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: report with correctable findings → dialog shown, corrections applied
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke-Test: Bei korrigierbaren Befunden und Dialog-Bestätigung werden Korrekturen
|
||||||
|
* ausgeführt und als Meldungen eingehängt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void offerCorrections_withCorrectableFindings_dialogConfirmed_appliesCorrectionAndAddsMessages()
|
||||||
|
throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
|
||||||
|
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||||
|
service, messages, true /* confirm */);
|
||||||
|
|
||||||
|
TechnicalTestReport report = buildReportWithCorrectableFinding();
|
||||||
|
coordinator.offerCorrections(report);
|
||||||
|
|
||||||
|
assertTrue(correctionExecuted.get(),
|
||||||
|
"Korrektur muss nach Bestätigung ausgeführt worden sein");
|
||||||
|
|
||||||
|
long correctionEntries = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
assertTrue(correctionEntries > 0,
|
||||||
|
"Nach Ausführung müssen Meldungen mit Source '"
|
||||||
|
+ GuiCorrectionDialogCoordinator.SOURCE_TAG + "' vorhanden sein");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: report without correctable findings → no dialog, no corrections
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke-Test: Bericht ohne korrigierbare Befunde → kein Dialog, keine Korrekturen.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void offerCorrections_withoutCorrectableFindings_noDialogNoCorrections() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
AtomicBoolean dialogShown = new AtomicBoolean(false);
|
||||||
|
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
|
||||||
|
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||||
|
service, messages, true /* confirm */);
|
||||||
|
coordinator.dialogSupplier = content -> {
|
||||||
|
dialogShown.set(true);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Report with NO correctable findings (all succeed)
|
||||||
|
TechnicalTestReport report = buildReportWithNoCorrectableFindings();
|
||||||
|
coordinator.offerCorrections(report);
|
||||||
|
|
||||||
|
assertFalse(dialogShown.get(), "Kein Dialog darf angezeigt werden wenn keine Korrekturen möglich");
|
||||||
|
assertFalse(correctionExecuted.get(), "Keine Korrektur darf ausgeführt werden");
|
||||||
|
assertTrue(messages.isEmpty(), "Keine Meldungen dürfen hinzugefügt werden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: dialog cancelled → no corrections, no messages with SOURCE_TAG
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke-Test: Dialog-Abbruch → keine Korrekturen, keine Meldungen mit Source-Tag.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void offerCorrections_dialogCancelled_noCorrectionsNoMessages() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
|
||||||
|
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||||
|
service, messages, false /* cancel */);
|
||||||
|
|
||||||
|
TechnicalTestReport report = buildReportWithCorrectableFinding();
|
||||||
|
coordinator.offerCorrections(report);
|
||||||
|
|
||||||
|
assertFalse(correctionExecuted.get(),
|
||||||
|
"Bei Dialog-Abbruch dürfen keine Korrekturen ausgeführt werden");
|
||||||
|
|
||||||
|
long correctionEntries = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
assertEquals(0, correctionEntries,
|
||||||
|
"Bei Dialog-Abbruch dürfen keine Meldungen mit Source '"
|
||||||
|
+ GuiCorrectionDialogCoordinator.SOURCE_TAG + "' hinzugefügt werden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: replace semantics – second run replaces previous SOURCE_TAG entries
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke-Test: Beim zweiten Aufruf werden vorherige SOURCE_TAG-Einträge ersetzt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void offerCorrections_calledTwice_replacesPreviousMessages() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
AtomicBoolean ignored = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
CorrectionExecutionService service = buildServiceThatTracksExecution(ignored);
|
||||||
|
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||||
|
service, messages, true);
|
||||||
|
|
||||||
|
TechnicalTestReport report = buildReportWithCorrectableFinding();
|
||||||
|
coordinator.offerCorrections(report);
|
||||||
|
long countAfterFirst = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
coordinator.offerCorrections(report);
|
||||||
|
long countAfterSecond = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assertEquals(countAfterFirst, countAfterSecond,
|
||||||
|
"Zweiter Aufruf muss vorherige Einträge ersetzen (Replace-Semantik)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut einen synchron laufenden {@link GuiCorrectionDialogCoordinator}.
|
||||||
|
* <p>
|
||||||
|
* Thread-Factory läuft inline; resultDelivery ist direkter Aufruf; dialogSupplier
|
||||||
|
* gibt den festen {@code confirm}-Wert zurück.
|
||||||
|
*/
|
||||||
|
private static GuiCorrectionDialogCoordinator buildSyncCoordinator(
|
||||||
|
CorrectionExecutionService service,
|
||||||
|
List<GuiMessageEntry> messages,
|
||||||
|
boolean confirm) {
|
||||||
|
|
||||||
|
GuiCorrectionDialogCoordinator coordinator = new GuiCorrectionDialogCoordinator(
|
||||||
|
service,
|
||||||
|
messages,
|
||||||
|
ignored -> { /* no-op refresh */ });
|
||||||
|
|
||||||
|
coordinator.dialogSupplier = content -> confirm;
|
||||||
|
coordinator.correctionThreadFactory = task -> new Thread(task, "sync-correction-thread") {
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
run(); // inline, synchronous
|
||||||
|
}
|
||||||
|
};
|
||||||
|
coordinator.resultDelivery = Runnable::run; // direct call, no FX queue
|
||||||
|
|
||||||
|
return coordinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut einen {@link CorrectionExecutionService}, der {@code correctionExecuted} auf {@code true}
|
||||||
|
* setzt, wenn eine Korrektur aufgerufen wird.
|
||||||
|
*/
|
||||||
|
private static CorrectionExecutionService buildServiceThatTracksExecution(
|
||||||
|
AtomicBoolean correctionExecuted) {
|
||||||
|
ResourceCreationPort trackingPort = new ResourceCreationPort() {
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
|
||||||
|
correctionExecuted.set(true);
|
||||||
|
return new CorrectionOutcome.Applied(s, "Angelegt");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
|
||||||
|
correctionExecuted.set(true);
|
||||||
|
return new CorrectionOutcome.Applied(s, "Erzeugt");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
|
||||||
|
correctionExecuted.set(true);
|
||||||
|
return new CorrectionOutcome.Applied(s, "Vorbereitet");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new CorrectionExecutionService(trackingPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut einen Bericht mit einem korrigierbaren Fehler-Befund. */
|
||||||
|
private static TechnicalTestReport buildReportWithCorrectableFinding() {
|
||||||
|
CorrectionSuggestion.CreateDirectory suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory("C:/test/target", "Zielordner anlegen: C:/test/target");
|
||||||
|
CheckpointResult.Failure failure = new CheckpointResult.Failure(
|
||||||
|
CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Zielordner nicht vorhanden",
|
||||||
|
Optional.of(suggestion));
|
||||||
|
return new TechnicalTestReport(List.of(failure), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut einen Bericht ohne korrigierbare Befunde (alles erfolgreich). */
|
||||||
|
private static TechnicalTestReport buildReportWithNoCorrectableFindings() {
|
||||||
|
CheckpointResult.Success success = new CheckpointResult.Success(
|
||||||
|
CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
"Zielordner vorhanden");
|
||||||
|
return new TechnicalTestReport(List.of(success), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
java.util.concurrent.atomic.AtomicReference<Throwable> error =
|
||||||
|
new java.util.concurrent.atomic.AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t != null) {
|
||||||
|
if (t instanceof Exception ex) throw ex;
|
||||||
|
throw new AssertionError("Unexpected error", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-1
@@ -322,7 +322,35 @@ class GuiEditorFieldBindingTest {
|
|||||||
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
capturingWriter);
|
capturingWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
|
||||||
ws.requestNewConfiguration();
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
|||||||
+148
-2
@@ -2,6 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
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 static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -115,7 +116,35 @@ class GuiEditorIntegrationTest {
|
|||||||
|
|
||||||
GuiConfigurationEditorState loadedState = fileLoader.load(configFile);
|
GuiConfigurationEditorState loadedState = fileLoader.load(configFile);
|
||||||
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
|
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
|
||||||
GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter);
|
GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
@@ -237,7 +266,35 @@ class GuiEditorIntegrationTest {
|
|||||||
blankState,
|
blankState,
|
||||||
Optional.of(notice),
|
Optional.of(notice),
|
||||||
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
noOpWriter);
|
noOpWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
@@ -267,6 +324,95 @@ class GuiEditorIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GUI startup with a non-existent --config path: startup notice rendered in header
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that when the workspace is constructed with a startup notice (as Bootstrap does
|
||||||
|
* when {@code --config} points to a non-existent file), the notice text is rendered in the
|
||||||
|
* visible header status label.
|
||||||
|
* <p>
|
||||||
|
* This complements {@link #guiStartup_withNonExistentConfigPath_usesBlankStateAndCarriesStartupNotice}
|
||||||
|
* which only verifies the blank editor state. This test verifies the user-visible side: the
|
||||||
|
* notice must be rendered so the user can read it.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void guiStartup_withNonExistentConfigPath_noticeIsRenderedInHeaderStatusLabel()
|
||||||
|
throws Exception {
|
||||||
|
String notice = "Konfigurationsdatei nicht gefunden: /no/such/file.properties\n"
|
||||||
|
+ "Die GUI startet ohne Konfigurationsdatei.";
|
||||||
|
GuiConfigurationEditorState blankState = GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||||
|
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
|
||||||
|
GuiStartupContext context = new GuiStartupContext(
|
||||||
|
blankState,
|
||||||
|
Optional.of(notice),
|
||||||
|
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
|
noOpWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
|
||||||
|
|
||||||
|
// The startup notice must be rendered in the visible header status label.
|
||||||
|
javafx.scene.control.Label noticeLabel = workspace.statusNoticeLabel();
|
||||||
|
assertNotNull(noticeLabel, "Header status label must not be null");
|
||||||
|
assertTrue(noticeLabel.isVisible(),
|
||||||
|
"Header status label must be visible when a startup notice is present");
|
||||||
|
assertTrue(noticeLabel.isManaged(),
|
||||||
|
"Header status label must be managed when a startup notice is present");
|
||||||
|
assertFalse(noticeLabel.getText().isBlank(),
|
||||||
|
"Header status label text must not be blank when startup notice is present");
|
||||||
|
assertTrue(noticeLabel.getText().contains("Konfigurationsdatei nicht gefunden"),
|
||||||
|
"Header status label must contain the notice text; got: " + noticeLabel.getText());
|
||||||
|
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
if (error.get() != null) {
|
||||||
|
throw new AssertionError("FX thread threw an exception", error.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// --config path resolution: static helper (no FX thread needed)
|
// --config path resolution: static helper (no FX thread needed)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+145
-5
@@ -186,7 +186,35 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
GuiConfigurationFileLoader loader = buildSnapshotLoader();
|
GuiConfigurationFileLoader loader = buildSnapshotLoader();
|
||||||
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
|
GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path);
|
||||||
GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState();
|
GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||||
GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter);
|
GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
@@ -297,7 +325,35 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
stateWithFile,
|
stateWithFile,
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
capturingWriter);
|
capturingWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
CountDownLatch setupLatch = new CountDownLatch(1);
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
@@ -393,7 +449,35 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
capturingWriter);
|
capturingWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
CountDownLatch setupLatch = new CountDownLatch(1);
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
@@ -493,7 +577,35 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
capturingWriter);
|
capturingWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
CountDownLatch setupLatch = new CountDownLatch(1);
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
@@ -564,7 +676,35 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
trackingWriter);
|
trackingWriter,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
CountDownLatch setupLatch = new CountDownLatch(1);
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
|
|||||||
+508
@@ -0,0 +1,508 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
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.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Properties;
|
||||||
|
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.BooleanSupplier;
|
||||||
|
|
||||||
|
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.GuiConfigurationSaveResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-based headless smoke tests for the automatic editor validation.
|
||||||
|
* <p>
|
||||||
|
* These tests verify that the workspace triggers validation automatically when the editor
|
||||||
|
* state changes (via {@code applyEditorState} and {@code updateValues}) and that the
|
||||||
|
* {@link GuiEditorValidationResult} returned by {@code lastValidationResult()} reflects the
|
||||||
|
* current editor state.
|
||||||
|
*
|
||||||
|
* <h2>Covered scenarios</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Opening an incomplete configuration (missing active provider) produces ERROR findings
|
||||||
|
* in {@code lastValidationResult} after the file is loaded.</li>
|
||||||
|
* <li>Opening an incomplete configuration populates {@code pendingFieldFindings} with a
|
||||||
|
* finding for {@code ai.provider.active}.</li>
|
||||||
|
* <li>After {@code requestNewConfiguration}: template values replace blank values, validation
|
||||||
|
* re-runs, {@code ai.provider.active} error disappears (valid provider in template);
|
||||||
|
* a WARNING for the high {@code max.text.characters} value (5000) is present.</li>
|
||||||
|
* <li>Changing a field via direct state update + re-applying state updates the validation
|
||||||
|
* result with new findings.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
|
||||||
|
* The {@code openConfigurationFile} method uses a background thread internally; tests that use
|
||||||
|
* it await file-load completion via a polling helper before verifying results.
|
||||||
|
* The Monocle headless configuration is activated by the Surefire JVM arguments.
|
||||||
|
*/
|
||||||
|
class GuiEditorValidationSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform – do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: opening an incomplete configuration produces ERROR findings
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: when a properties file with an unknown (or empty) active-provider value is
|
||||||
|
* opened via {@link GuiConfigurationEditorWorkspace#openConfigurationFile}, the workspace
|
||||||
|
* calls {@code applyEditorState} after loading and runs validation automatically.
|
||||||
|
* <p>
|
||||||
|
* The resulting {@code lastValidationResult} must contain at least one ERROR because the
|
||||||
|
* active-provider field is empty.
|
||||||
|
*
|
||||||
|
* @param tempDir JUnit-provided temporary directory
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void openingIncompleteConfiguration_validationRunsAndProducesErrors(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
// Write a properties file with an empty active provider.
|
||||||
|
Path configFile = tempDir.resolve("incomplete.properties");
|
||||||
|
writePropertiesFile(configFile, "" /* empty active provider */);
|
||||||
|
|
||||||
|
GuiConfigurationFileLoader loader = buildSnapshotLoader();
|
||||||
|
GuiConfigurationEditorState blankState =
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
blankState, Optional.empty(), loader,
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) ->
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
|
||||||
|
// Create workspace and trigger file load on the FX thread.
|
||||||
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
wsRef.set(ws);
|
||||||
|
ws.openConfigurationFile(configFile);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
setupLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Setup must complete within timeout");
|
||||||
|
rethrow(error);
|
||||||
|
|
||||||
|
// Wait for the background loader thread to apply the state.
|
||||||
|
waitFor(() -> {
|
||||||
|
AtomicBoolean ready = new AtomicBoolean(false);
|
||||||
|
CountDownLatch check = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
|
||||||
|
ready.set(true);
|
||||||
|
}
|
||||||
|
check.countDown();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
check.await(2, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return ready.get();
|
||||||
|
}, FX_TIMEOUT_SECONDS);
|
||||||
|
|
||||||
|
// Verify validation result on the FX thread.
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||||
|
|
||||||
|
assertNotNull(result, "lastValidationResult must never be null");
|
||||||
|
assertTrue(result.hasErrors(),
|
||||||
|
"Loading a config with empty active provider must produce ERROR findings");
|
||||||
|
assertTrue(result.hasFieldFindingFor("ai.provider.active"),
|
||||||
|
"pendingFieldFindings must contain a finding for 'ai.provider.active'"
|
||||||
|
+ " when the active provider is empty in the loaded file");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
verifyLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Verify latch must complete within timeout");
|
||||||
|
rethrow(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: changing a field updates the validation result
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: when the active provider is changed from a valid value to an empty string via
|
||||||
|
* a direct state update followed by {@code requestNewConfiguration} (which calls
|
||||||
|
* {@code applyEditorState} and triggers {@code runEditorValidation}), the
|
||||||
|
* {@code lastValidationResult} is updated with findings that reflect the new state.
|
||||||
|
* <p>
|
||||||
|
* More concretely, this test demonstrates the field-change→re-validation flow by:
|
||||||
|
* <ol>
|
||||||
|
* <li>Starting with the standard template (valid provider → no provider error).</li>
|
||||||
|
* <li>Loading a file that has an empty provider (produces a provider ERROR).</li>
|
||||||
|
* <li>Verifying that {@code lastValidationResult} changed from "no error" to "error" as
|
||||||
|
* the result of loading the file with invalid values.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param tempDir JUnit-provided temporary directory
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void changingField_revalidatesAndUpdatesLastValidationResult(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
Path invalidConfig = tempDir.resolve("invalid-provider.properties");
|
||||||
|
writePropertiesFile(invalidConfig, "" /* empty active provider */);
|
||||||
|
|
||||||
|
GuiConfigurationFileLoader loader = buildSnapshotLoader();
|
||||||
|
GuiConfigurationEditorState blankState =
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
blankState, Optional.empty(), loader,
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) ->
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
|
||||||
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
wsRef.set(ws);
|
||||||
|
// Step 1: apply template – validation runs with valid values.
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
setupLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Setup timeout");
|
||||||
|
rethrow(error);
|
||||||
|
|
||||||
|
// Confirm valid state after template.
|
||||||
|
CountDownLatch checkValidLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
assertFalse(ws.lastValidationResult().hasFieldFindingFor("ai.provider.active"),
|
||||||
|
"After 'Neu' with valid template the active-provider field must have no error");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
checkValidLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(checkValidLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Check timeout");
|
||||||
|
rethrow(error);
|
||||||
|
|
||||||
|
// Step 2: trigger field change by loading an invalid config file.
|
||||||
|
CountDownLatch loadLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
wsRef.get().openConfigurationFile(invalidConfig);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
loadLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(loadLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Load trigger timeout");
|
||||||
|
rethrow(error);
|
||||||
|
|
||||||
|
// Wait for background loader.
|
||||||
|
waitFor(() -> {
|
||||||
|
AtomicBoolean ready = new AtomicBoolean(false);
|
||||||
|
CountDownLatch check = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
|
||||||
|
ready.set(true);
|
||||||
|
}
|
||||||
|
check.countDown();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
check.await(2, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return ready.get();
|
||||||
|
}, FX_TIMEOUT_SECONDS);
|
||||||
|
|
||||||
|
// Verify: invalid provider is now detected.
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||||
|
assertTrue(result.hasErrors(),
|
||||||
|
"After loading a config with empty active provider, result must have errors");
|
||||||
|
assertTrue(result.hasFieldFindingFor("ai.provider.active"),
|
||||||
|
"After loading invalid config, active-provider finding must be present");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
verifyLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Verify timeout");
|
||||||
|
rethrow(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: standard template validation – WARNING for max.text.characters
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after {@code requestNewConfiguration}, the standard template values are active
|
||||||
|
* and validation runs. The template sets {@code max.text.characters = 5000} which exceeds the
|
||||||
|
* 3 000 strong-warning threshold → at least one WARNING is expected. The template also sets
|
||||||
|
* a valid active provider → no ERROR for that field.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void requestNewConfiguration_triggersValidation_templateProducesWarningForHighCharLimit()
|
||||||
|
throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws =
|
||||||
|
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||||
|
assertNotNull(result, "lastValidationResult must not be null after 'Neu'");
|
||||||
|
|
||||||
|
// Template has valid provider → no field finding for ai.provider.active.
|
||||||
|
assertFalse(result.hasFieldFindingFor("ai.provider.active"),
|
||||||
|
"Standard template has a valid provider; 'ai.provider.active' must have"
|
||||||
|
+ " no field finding");
|
||||||
|
|
||||||
|
// Template max.text.characters = 5000 (>3000) → at least one WARNING.
|
||||||
|
boolean hasWarningOrAbove = result.messages().stream()
|
||||||
|
.anyMatch(m -> m.severity() == GuiMessageSeverity.WARNING
|
||||||
|
|| m.severity() == GuiMessageSeverity.ERROR);
|
||||||
|
assertTrue(hasWarningOrAbove,
|
||||||
|
"Standard template with max.text.characters=5000 must produce at least"
|
||||||
|
+ " one WARNING in the validation messages");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: pendingFieldFindings updated by applyEditorState
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after {@code requestNewConfiguration}, the {@code pendingFieldFindings} list is
|
||||||
|
* updated and the template's valid provider is not flagged.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void requestNewConfiguration_pendingFieldFindings_noProviderError() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws =
|
||||||
|
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
assertNotNull(ws.pendingFieldFindings, "pendingFieldFindings must never be null");
|
||||||
|
assertFalse(ws.pendingFieldFindings.stream()
|
||||||
|
.anyMatch(f -> "ai.provider.active".equals(f.fieldKey())),
|
||||||
|
"Standard template has a valid provider; no field finding expected for"
|
||||||
|
+ " 'ai.provider.active'");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static GuiConfigurationFileLoader buildSnapshotLoader() {
|
||||||
|
return path -> {
|
||||||
|
try {
|
||||||
|
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.load(new StringReader(content));
|
||||||
|
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
|
||||||
|
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(
|
||||||
|
snapshot, Optional.empty());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new GuiConfigurationLoadException("Failed to load " + path, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePropertiesFile(Path path, String activeProvider) throws IOException {
|
||||||
|
String content = "source.folder=./work/source\n"
|
||||||
|
+ "target.folder=./work/target\n"
|
||||||
|
+ "ai.provider.active=" + activeProvider + "\n"
|
||||||
|
+ "sqlite.file=./work/test.db\n"
|
||||||
|
+ "max.retries.transient=3\n"
|
||||||
|
+ "max.pages=10\n"
|
||||||
|
+ "max.text.characters=500\n"
|
||||||
|
+ "prompt.template.file=./config/prompt.txt\n";
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
rethrow(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t instanceof Exception ex) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Unexpected error", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
|
||||||
|
throws InterruptedException {
|
||||||
|
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
|
||||||
|
while (!condition.getAsBoolean()) {
|
||||||
|
assertTrue(System.currentTimeMillis() < deadline,
|
||||||
|
"Condition was not met within the timeout");
|
||||||
|
Thread.sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+814
@@ -0,0 +1,814 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
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.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Properties;
|
||||||
|
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.BooleanSupplier;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-based headless smoke tests for the central message area, field-level error labels
|
||||||
|
* and API-key origin display introduced in the message-area integration step.
|
||||||
|
*
|
||||||
|
* <h2>Covered scenarios</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>After opening an incomplete configuration, ERROR entries are visible in the central
|
||||||
|
* message area (non-zero child count in {@code messagesAreaBox}).</li>
|
||||||
|
* <li>The first child of an ERROR row is a coloured {@link Text} prefix node; the second
|
||||||
|
* child (body) carries black fill.</li>
|
||||||
|
* <li>After opening a configuration with a missing source folder, the field-level error label
|
||||||
|
* registered for {@code source.folder} is visible.</li>
|
||||||
|
* <li>After the standard template is applied via {@code requestNewConfiguration()}, the
|
||||||
|
* {@code source.folder} error label is hidden.</li>
|
||||||
|
* <li>The WARNING threshold for {@code max.text.characters} (1001–3000) appears in the
|
||||||
|
* central message area with a WARNING-coloured prefix.</li>
|
||||||
|
* <li>After synchronous model-catalogue retrieval, the central message area is updated via the
|
||||||
|
* post-result callback.</li>
|
||||||
|
* <li>When the API-key resolution port reports an ENV-variable origin for Claude, the
|
||||||
|
* api-key origin label is visible and references the variable name.</li>
|
||||||
|
* <li>The field-error label for {@code ai.provider.active} is registered and shown when the
|
||||||
|
* active provider is empty.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* All workspace interactions run on the JavaFX Application Thread via {@link Platform#runLater}.
|
||||||
|
* Model-catalogue retrieval is made synchronous via the coordinator's injectable factories so no
|
||||||
|
* real background threads are used and results are delivered inline.
|
||||||
|
*/
|
||||||
|
class GuiMessageAreaSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform — do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: central message area has ERROR entries for incomplete config
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after opening a properties file with an empty active-provider value, the
|
||||||
|
* central {@code messagesAreaBox} contains at least one row, and at least one row has an
|
||||||
|
* ERROR-coloured prefix node.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void incompleteConfig_messagesAreaContainsErrorRow(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("incomplete.properties");
|
||||||
|
writePropertiesFile(configFile, "" /* empty active provider */, "500");
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
assertFalse(ws.messagesAreaBox.getChildren().isEmpty(),
|
||||||
|
"Central message area must not be empty after loading an incomplete configuration");
|
||||||
|
|
||||||
|
boolean foundErrorRow = ws.messagesAreaBox.getChildren().stream()
|
||||||
|
.filter(n -> n instanceof TextFlow)
|
||||||
|
.map(n -> (TextFlow) n)
|
||||||
|
.anyMatch(tf -> {
|
||||||
|
if (tf.getChildren().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object first = tf.getChildren().get(0);
|
||||||
|
if (first instanceof Text t) {
|
||||||
|
return t.getStyle().contains(GuiMessageSeverity.ERROR.getPrefixCssColour());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assertTrue(foundErrorRow,
|
||||||
|
"At least one TextFlow row must have an ERROR-coloured prefix node");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: the body-text node of any message row must carry the black fill style.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void messageRow_bodyTextIsBlack(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("incomplete2.properties");
|
||||||
|
writePropertiesFile(configFile, "" /* empty active provider */, "500");
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
Optional<TextFlow> anyRow = ws.messagesAreaBox.getChildren().stream()
|
||||||
|
.filter(n -> n instanceof TextFlow)
|
||||||
|
.map(n -> (TextFlow) n)
|
||||||
|
.filter(tf -> tf.getChildren().size() >= 2)
|
||||||
|
.findFirst();
|
||||||
|
assertTrue(anyRow.isPresent(), "Expected at least one TextFlow with two children");
|
||||||
|
Object bodyNode = anyRow.get().getChildren().get(1);
|
||||||
|
assertTrue(bodyNode instanceof Text, "Second child of a message row must be a Text node");
|
||||||
|
String bodyStyle = ((Text) bodyNode).getStyle();
|
||||||
|
// Body must be explicitly styled black or have no colour override at all.
|
||||||
|
assertTrue(bodyStyle.contains("black") || bodyStyle.contains("#000000")
|
||||||
|
|| bodyStyle.contains("000"),
|
||||||
|
"Body text must be rendered in black; style: " + bodyStyle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: field-level error label for source.folder
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: when a configuration with a blank source folder is opened, the field-error label
|
||||||
|
* for {@code source.folder} is visible and non-blank.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void blankSourceFolder_fieldErrorLabelVisible(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("nosrc.properties");
|
||||||
|
writePropertiesFileBlankSourceFolder(configFile);
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
Label errorLabel = ws.fieldErrorLabels.get("source.folder");
|
||||||
|
assertNotNull(errorLabel,
|
||||||
|
"A field-error label must be registered for 'source.folder'");
|
||||||
|
assertTrue(errorLabel.isVisible(),
|
||||||
|
"source.folder error label must be visible when the field is blank");
|
||||||
|
assertFalse(errorLabel.getText().isBlank(),
|
||||||
|
"source.folder error label must carry a non-blank error text");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after applying the standard template via {@code requestNewConfiguration()}, the
|
||||||
|
* source.folder error label is hidden because the template supplies a non-blank value.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void standardTemplate_sourceFolderErrorLabelHidden() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
Label errorLabel = ws.fieldErrorLabels.get("source.folder");
|
||||||
|
assertNotNull(errorLabel,
|
||||||
|
"A field-error label must be registered for 'source.folder' after 'Neu'");
|
||||||
|
assertFalse(errorLabel.isVisible(),
|
||||||
|
"source.folder error label must be hidden when the template provides a non-blank value");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: WARNING for max.text.characters between 1001 and 3000
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: loading a config with {@code max.text.characters = 1500} (between 1001 and 3000)
|
||||||
|
* must produce at least one WARNING entry in the central message area.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void maxTextCharacters_warningThreshold_warningInMessages(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("warning-chars.properties");
|
||||||
|
writePropertiesFile(configFile, "claude", "1500");
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
boolean hasWarning = ws.messagesAreaBox.getChildren().stream()
|
||||||
|
.filter(n -> n instanceof TextFlow)
|
||||||
|
.map(n -> (TextFlow) n)
|
||||||
|
.anyMatch(tf -> {
|
||||||
|
if (tf.getChildren().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object first = tf.getChildren().get(0);
|
||||||
|
if (first instanceof Text t) {
|
||||||
|
return t.getStyle().contains(GuiMessageSeverity.WARNING.getPrefixCssColour());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assertTrue(hasWarning,
|
||||||
|
"max.text.characters=1500 must produce at least one WARNING row in the message area");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: model-catalogue result updates the message area via postResultCallback
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after a synchronous (inline) model-catalogue retrieval that returns an
|
||||||
|
* {@link ModelCatalogResult.IncompleteConfiguration} result, the central message area is
|
||||||
|
* updated and contains the coordinator's message with source "Modellabruf".
|
||||||
|
* <p>
|
||||||
|
* Both the thread factory and the result-delivery mechanism are replaced with synchronous
|
||||||
|
* implementations so the entire retrieval+delivery cycle completes within the FX thread call.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void modelCatalogResult_updatesMessageArea() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Make retrieval fully synchronous: run the task inline and deliver result inline.
|
||||||
|
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
// Execute the task inline on the calling thread instead of starting a new thread.
|
||||||
|
this.run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
|
||||||
|
|
||||||
|
// Trigger retrieval for Claude — stub port returns IncompleteConfiguration.
|
||||||
|
ws.modelCatalogCoordinator.triggerModelRetrieval(
|
||||||
|
AiProviderFamily.CLAUDE,
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState.blank());
|
||||||
|
|
||||||
|
// The post-result callback must have called refreshMessagesArea().
|
||||||
|
assertFalse(ws.messagesAreaBox.getChildren().isEmpty(),
|
||||||
|
"messagesAreaBox must not be empty after model-catalogue result was applied");
|
||||||
|
|
||||||
|
boolean hasModelCatalogEntry = ws.pendingMessages.stream()
|
||||||
|
.anyMatch(m -> m.source().isPresent()
|
||||||
|
&& "Modellabruf".equals(m.source().get()));
|
||||||
|
assertTrue(hasModelCatalogEntry,
|
||||||
|
"pendingMessages must contain at least one entry from source 'Modellabruf'"
|
||||||
|
+ " after retrieval");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: API-key ENV-variable origin label
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: when the API-key resolution port reports that the Claude key comes from an
|
||||||
|
* environment variable, the api-key origin label below the Claude API-key field is visible
|
||||||
|
* and references the variable name or the concept of an environment variable.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void apiKeyFromEnvVariable_originLabelVisible() throws Exception {
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
|
Optional.empty(),
|
||||||
|
path -> { throw new GuiConfigurationLoadException("not used in test", null); },
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> {
|
||||||
|
if (family == AiProviderFamily.CLAUDE) {
|
||||||
|
return EffectiveApiKeyDescriptor.fromProviderEnvVar("CLAUDE_API_KEY");
|
||||||
|
}
|
||||||
|
return EffectiveApiKeyDescriptor.absent();
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
Label originLabel = ws.apiKeyOriginLabels.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(originLabel,
|
||||||
|
"An api-key origin label must be registered for AiProviderFamily.CLAUDE");
|
||||||
|
assertTrue(originLabel.isVisible(),
|
||||||
|
"Claude api-key origin label must be visible when key comes from ENV-variable");
|
||||||
|
String labelText = originLabel.getText();
|
||||||
|
assertTrue(labelText.contains("CLAUDE_API_KEY")
|
||||||
|
|| labelText.contains("Umgebungsvariable"),
|
||||||
|
"Claude api-key origin label must reference the ENV-variable name or type;"
|
||||||
|
+ " got: " + labelText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: INFO-coloured prefix in the message area (model-catalogue success)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after a successful model-catalogue retrieval (stub returns Success), the central
|
||||||
|
* message area must contain at least one row whose prefix node carries the INFO colour.
|
||||||
|
* <p>
|
||||||
|
* This verifies that the INFO severity level is rendered with its defined CSS colour and not
|
||||||
|
* accidentally displayed with the ERROR or WARNING colour.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void successfulModelRetrieval_messagesAreaContainsInfoRow() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
|
Optional.empty(),
|
||||||
|
path -> { throw new GuiConfigurationLoadException("not used in test", null); },
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.Success(
|
||||||
|
req.providerIdentifier(),
|
||||||
|
java.util.List.of("claude-3-5-sonnet"),
|
||||||
|
java.time.Instant.now()),
|
||||||
|
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
// Make retrieval synchronous.
|
||||||
|
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
this.run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
|
||||||
|
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Trigger retrieval so an INFO message is added.
|
||||||
|
ws.modelCatalogCoordinator.triggerModelRetrieval(
|
||||||
|
AiProviderFamily.CLAUDE,
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor
|
||||||
|
.GuiProviderConfigurationState.blank());
|
||||||
|
|
||||||
|
boolean foundInfoRow = ws.messagesAreaBox.getChildren().stream()
|
||||||
|
.filter(n -> n instanceof TextFlow)
|
||||||
|
.map(n -> (TextFlow) n)
|
||||||
|
.anyMatch(tf -> {
|
||||||
|
if (tf.getChildren().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object first = tf.getChildren().get(0);
|
||||||
|
if (first instanceof Text t) {
|
||||||
|
return t.getStyle().contains(GuiMessageSeverity.INFO.getPrefixCssColour());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assertTrue(foundInfoRow,
|
||||||
|
"After successful model retrieval at least one TextFlow row must have an"
|
||||||
|
+ " INFO-coloured prefix node");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: HINT-coloured prefix in the message area (empty model list)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after a model-catalogue retrieval that returns {@code EmptyList}, the central
|
||||||
|
* message area must contain at least one row whose prefix node carries the HINT colour.
|
||||||
|
* <p>
|
||||||
|
* This verifies that the HINT severity level is correctly propagated from the coordinator to
|
||||||
|
* the rendered message area.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void emptyModelList_messagesAreaContainsHintRow() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
|
Optional.empty(),
|
||||||
|
path -> { throw new GuiConfigurationLoadException("not used in test", null); },
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.EmptyList(
|
||||||
|
req.providerIdentifier(), java.time.Instant.now()),
|
||||||
|
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) {
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
this.run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
|
||||||
|
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
ws.modelCatalogCoordinator.triggerModelRetrieval(
|
||||||
|
AiProviderFamily.CLAUDE,
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor
|
||||||
|
.GuiProviderConfigurationState.blank());
|
||||||
|
|
||||||
|
boolean foundHintRow = ws.messagesAreaBox.getChildren().stream()
|
||||||
|
.filter(n -> n instanceof TextFlow)
|
||||||
|
.map(n -> (TextFlow) n)
|
||||||
|
.anyMatch(tf -> {
|
||||||
|
if (tf.getChildren().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object first = tf.getChildren().get(0);
|
||||||
|
if (first instanceof Text t) {
|
||||||
|
return t.getStyle().contains(GuiMessageSeverity.HINT.getPrefixCssColour());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assertTrue(foundHintRow,
|
||||||
|
"After EmptyList model retrieval at least one TextFlow row must have a"
|
||||||
|
+ " HINT-coloured prefix node");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: strong WARNING for max.text.characters > 3000
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: loading a config with {@code max.text.characters = 3001} (above the 3000 strong
|
||||||
|
* warning threshold) must produce at least one WARNING entry in the central message area.
|
||||||
|
* <p>
|
||||||
|
* This verifies the upper threshold of the economic warning logic: values strictly above 3000
|
||||||
|
* trigger the strong warning level.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void maxTextCharacters_strongWarningThreshold_warningInMessages(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("strong-warning-chars.properties");
|
||||||
|
writePropertiesFile(configFile, "claude", "3001");
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
boolean hasWarning = ws.messagesAreaBox.getChildren().stream()
|
||||||
|
.filter(n -> n instanceof TextFlow)
|
||||||
|
.map(n -> (TextFlow) n)
|
||||||
|
.anyMatch(tf -> {
|
||||||
|
if (tf.getChildren().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object first = tf.getChildren().get(0);
|
||||||
|
if (first instanceof Text t) {
|
||||||
|
return t.getStyle().contains(GuiMessageSeverity.WARNING.getPrefixCssColour());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assertTrue(hasWarning,
|
||||||
|
"max.text.characters=3001 must produce at least one WARNING row in the"
|
||||||
|
+ " message area (strong-warning threshold)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: max.pages > 100 produces no ERROR field-finding (HINT only)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: loading a config with {@code max.pages = 101} must not produce an ERROR
|
||||||
|
* field-finding for the {@code max.pages} key. High page counts are treated as
|
||||||
|
* plausibility/performance hints and must never block the configuration from being
|
||||||
|
* considered operational from the editor's perspective.
|
||||||
|
* <p>
|
||||||
|
* This complements the unit-level validation tests by verifying the finding is correctly
|
||||||
|
* mapped through the workspace pipeline and not accidentally escalated to ERROR.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void maxPages_over100_noErrorFieldFinding(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("highpages.properties");
|
||||||
|
writePropertiesFileWithMaxPages(configFile, "claude", "500", "101");
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
// The field-finding for max.pages must not be ERROR.
|
||||||
|
boolean hasErrorFindingForMaxPages = ws.pendingFieldFindings.stream()
|
||||||
|
.anyMatch(f -> "max.pages".equals(f.fieldKey())
|
||||||
|
&& f.severity() == GuiMessageSeverity.ERROR);
|
||||||
|
assertFalse(hasErrorFindingForMaxPages,
|
||||||
|
"max.pages=101 must not produce an ERROR field-finding; high page limits"
|
||||||
|
+ " are treated as plausibility hints only");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: ai.provider.active field-error label is registered and shown
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: when the active provider is empty, the field-error label for
|
||||||
|
* {@code ai.provider.active} must be registered and visible.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void incompleteConfig_activeProviderFieldErrorLabelVisible(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
Path configFile = tempDir.resolve("noprovider.properties");
|
||||||
|
writePropertiesFile(configFile, "" /* empty */, "500");
|
||||||
|
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||||
|
openConfigAndWait(configFile, wsRef);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
Label errorLabel = ws.fieldErrorLabels.get("ai.provider.active");
|
||||||
|
assertNotNull(errorLabel,
|
||||||
|
"A field-error label must be registered for 'ai.provider.active'");
|
||||||
|
assertTrue(errorLabel.isVisible(),
|
||||||
|
"'ai.provider.active' error label must be visible when provider is empty");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens {@code configFile} in a freshly created workspace and waits for the background loader
|
||||||
|
* to complete. The workspace reference is stored in {@code wsRef}.
|
||||||
|
*/
|
||||||
|
private static void openConfigAndWait(Path configFile,
|
||||||
|
AtomicReference<GuiConfigurationEditorWorkspace> wsRef)
|
||||||
|
throws Exception {
|
||||||
|
GuiConfigurationFileLoader loader = buildSnapshotLoader();
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
|
Optional.empty(),
|
||||||
|
loader,
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
CountDownLatch setupLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
wsRef.set(ws);
|
||||||
|
ws.openConfigurationFile(configFile);
|
||||||
|
} finally {
|
||||||
|
setupLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(setupLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Setup must complete within timeout");
|
||||||
|
|
||||||
|
waitFor(() -> {
|
||||||
|
AtomicBoolean ready = new AtomicBoolean(false);
|
||||||
|
CountDownLatch check = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = wsRef.get();
|
||||||
|
if (ws != null && ws.editorState().hasLoadedFileSnapshot()) {
|
||||||
|
ready.set(true);
|
||||||
|
}
|
||||||
|
check.countDown();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
check.await(2, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return ready.get();
|
||||||
|
}, FX_TIMEOUT_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiConfigurationFileLoader buildSnapshotLoader() {
|
||||||
|
return path -> {
|
||||||
|
try {
|
||||||
|
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.load(new StringReader(content));
|
||||||
|
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(path, props);
|
||||||
|
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(
|
||||||
|
snapshot, Optional.empty());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new GuiConfigurationLoadException("Failed to load " + path, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePropertiesFile(Path path, String activeProvider,
|
||||||
|
String maxTextCharacters) throws IOException {
|
||||||
|
String content = "source.folder=./work/source\n"
|
||||||
|
+ "target.folder=./work/target\n"
|
||||||
|
+ "ai.provider.active=" + activeProvider + "\n"
|
||||||
|
+ "sqlite.file=./work/test.db\n"
|
||||||
|
+ "max.retries.transient=3\n"
|
||||||
|
+ "max.pages=10\n"
|
||||||
|
+ "max.text.characters=" + maxTextCharacters + "\n"
|
||||||
|
+ "prompt.template.file=./config/prompt.txt\n";
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePropertiesFileWithMaxPages(Path path, String activeProvider,
|
||||||
|
String maxTextCharacters,
|
||||||
|
String maxPages) throws IOException {
|
||||||
|
String content = "source.folder=./work/source\n"
|
||||||
|
+ "target.folder=./work/target\n"
|
||||||
|
+ "ai.provider.active=" + activeProvider + "\n"
|
||||||
|
+ "sqlite.file=./work/test.db\n"
|
||||||
|
+ "max.retries.transient=3\n"
|
||||||
|
+ "max.pages=" + maxPages + "\n"
|
||||||
|
+ "max.text.characters=" + maxTextCharacters + "\n"
|
||||||
|
+ "prompt.template.file=./config/prompt.txt\n";
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePropertiesFileBlankSourceFolder(Path path) throws IOException {
|
||||||
|
String content = "source.folder=\n"
|
||||||
|
+ "target.folder=./work/target\n"
|
||||||
|
+ "ai.provider.active=claude\n"
|
||||||
|
+ "sqlite.file=./work/test.db\n"
|
||||||
|
+ "max.retries.transient=3\n"
|
||||||
|
+ "max.pages=10\n"
|
||||||
|
+ "max.text.characters=500\n"
|
||||||
|
+ "prompt.template.file=./config/prompt.txt\n";
|
||||||
|
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
rethrow(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t instanceof Exception ex) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Unexpected error", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void waitFor(BooleanSupplier condition, long timeoutSeconds)
|
||||||
|
throws InterruptedException {
|
||||||
|
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
|
||||||
|
while (!condition.getAsBoolean()) {
|
||||||
|
assertTrue(System.currentTimeMillis() < deadline,
|
||||||
|
"Condition was not met within the timeout");
|
||||||
|
Thread.sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+665
@@ -0,0 +1,665 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
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.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Order;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke tests for the automatic model catalogue retrieval, the "Modelle neu laden" button,
|
||||||
|
* and the ComboBox/TextField switching behaviour in the provider section of the editor workspace.
|
||||||
|
*
|
||||||
|
* <p>All tests run on the JavaFX Application Thread under Monocle headless. The model catalogue
|
||||||
|
* port is replaced with a synchronous stub so no real HTTP calls are made and the tests are
|
||||||
|
* fully deterministic.
|
||||||
|
*
|
||||||
|
* <p>The coordinator's thread factory and result-delivery mechanism are both replaced with
|
||||||
|
* synchronous implementations so retrieval and result application happen inline on the calling
|
||||||
|
* thread (the FX thread in these tests). This avoids any async boundary and makes assertions
|
||||||
|
* immediately consistent after each trigger call.
|
||||||
|
*/
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class GuiModelCatalogSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// JavaFX Platform lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform — do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: Success result → ComboBox shown, first model pre-selected
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the model catalogue port returns a {@link ModelCatalogResult.Success}, the provider
|
||||||
|
* block's model field must switch to a non-editable ComboBox pre-selecting the first model.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
void successResult_comboBoxIsShownWithFirstModelSelected() throws Exception {
|
||||||
|
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container, "Claude model field container must be present");
|
||||||
|
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
|
||||||
|
"Source must be LIST_REMOTE_SUCCESS after successful retrieval");
|
||||||
|
assertEquals("claude-3-5-sonnet", container.currentModelValue(),
|
||||||
|
"First model must be pre-selected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: EmptyList result → TextField shown
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the model catalogue port returns {@link ModelCatalogResult.EmptyList}, the provider
|
||||||
|
* block's model field must show the manual text field.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
void emptyListResult_textFieldIsShown() throws Exception {
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container, "Claude model field container must be present");
|
||||||
|
assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
|
||||||
|
"Source must be LIST_UNAVAILABLE_MANUAL_INPUT for EmptyList result");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: IncompleteConfiguration result → TextField shown
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the model catalogue port returns {@link ModelCatalogResult.IncompleteConfiguration},
|
||||||
|
* the provider block's model field must show the manual text field.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
void incompleteConfigResult_textFieldIsShown() throws Exception {
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "API-Key fehlt.");
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container, "Claude model field container must be present");
|
||||||
|
assertEquals(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT, container.currentSource(),
|
||||||
|
"Source must be LIST_UNAVAILABLE_MANUAL_INPUT for IncompleteConfiguration");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: TechnicalFailure result → TextField shown with FAILED state
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the model catalogue port returns {@link ModelCatalogResult.TechnicalFailure},
|
||||||
|
* the provider block's model field must show the manual text field in the failed state.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
void technicalFailureResult_textFieldIsShownWithFailedState() throws Exception {
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "HTTP_ERROR",
|
||||||
|
"Status 503");
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container, "Claude model field container must be present");
|
||||||
|
assertEquals(GuiModelSource.LIST_FAILED_MANUAL_INPUT, container.currentSource(),
|
||||||
|
"Source must be LIST_FAILED_MANUAL_INPUT for TechnicalFailure");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: Manual value discarded when not in new list
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a manual model name is present in the text field and a subsequent successful
|
||||||
|
* retrieval returns a list that does NOT contain that name, the value must be discarded
|
||||||
|
* and the first item in the new list must be selected.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
void successResult_manualValueDiscardedWhenNotInList() throws Exception {
|
||||||
|
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Pre-set a manual value not present in the incoming list.
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container);
|
||||||
|
container.setTextFieldValue("my-custom-model");
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
|
||||||
|
"Source must be LIST_REMOTE_SUCCESS after successful retrieval");
|
||||||
|
assertEquals("claude-3-5-sonnet", container.currentModelValue(),
|
||||||
|
"Manual value not in list must be discarded; first list item selected");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: Manual value preserved when present in new list
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a manual model name is present in the text field and a subsequent successful
|
||||||
|
* retrieval returns a list that DOES contain that name, the selection must be preserved.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
void successResult_manualValuePreservedWhenInList() throws Exception {
|
||||||
|
List<String> models = List.of("claude-3-5-sonnet", "claude-3-haiku");
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container);
|
||||||
|
// Pre-set a value that IS in the incoming list.
|
||||||
|
container.setTextFieldValue("claude-3-haiku");
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
|
||||||
|
"Source must be LIST_REMOTE_SUCCESS");
|
||||||
|
assertEquals("claude-3-haiku", container.currentModelValue(),
|
||||||
|
"Manual value present in list must be preserved as selection");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: Provider switch triggers automatic model retrieval
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching the provider ComboBox must automatically trigger a model retrieval for the
|
||||||
|
* newly selected provider without requiring the user to press "Modelle neu laden".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
void providerSwitch_triggersAutomaticModelRetrieval() throws Exception {
|
||||||
|
List<String> openAiModels = List.of("gpt-4o", "gpt-4-turbo");
|
||||||
|
AiModelCatalogPort stub = req -> {
|
||||||
|
if (AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier().equals(req.providerIdentifier())) {
|
||||||
|
return new ModelCatalogResult.Success(req.providerIdentifier(), openAiModels,
|
||||||
|
Instant.now());
|
||||||
|
}
|
||||||
|
return new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
|
||||||
|
"Test-Stub: kein Claude-Abruf in diesem Test.");
|
||||||
|
};
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Switch the provider ComboBox from Claude to OpenAI; the listener auto-triggers retrieval.
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
assertNotNull(comboBox, "Provider ComboBox must be present");
|
||||||
|
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
// Because resultDelivery is synchronous, retrieval and result application happened inline.
|
||||||
|
|
||||||
|
GuiModelFieldContainer openAiContainer =
|
||||||
|
ws.modelFieldContainers.get(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
assertNotNull(openAiContainer, "OpenAI model field container must be present");
|
||||||
|
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, openAiContainer.currentSource(),
|
||||||
|
"OpenAI model source must be LIST_REMOTE_SUCCESS after automatic retrieval on switch");
|
||||||
|
assertEquals("gpt-4o", openAiContainer.currentModelValue(),
|
||||||
|
"First OpenAI model must be pre-selected after automatic retrieval");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: "Modelle neu laden" button triggers retrieval
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pressing the "Modelle neu laden" button must trigger the same retrieval path as the
|
||||||
|
* automatic trigger on provider switch.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(8)
|
||||||
|
void reloadModelsButton_triggersModelRetrieval() throws Exception {
|
||||||
|
List<String> models = List.of("claude-opus-4");
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
Button reloadButton = findNodeById(ws, "modelle-neu-laden-button", Button.class);
|
||||||
|
assertNotNull(reloadButton, "\"Modelle neu laden\" button must be present in the scene graph");
|
||||||
|
|
||||||
|
reloadButton.fire();
|
||||||
|
// Because resultDelivery is synchronous, result is applied immediately.
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = ws.modelFieldContainers.get(AiProviderFamily.CLAUDE);
|
||||||
|
assertNotNull(container, "Claude model field container must be present");
|
||||||
|
assertEquals(GuiModelSource.LIST_REMOTE_SUCCESS, container.currentSource(),
|
||||||
|
"Source must be LIST_REMOTE_SUCCESS after pressing \"Modelle neu laden\"");
|
||||||
|
assertEquals("claude-opus-4", container.currentModelValue(),
|
||||||
|
"Model returned by stub must be selected after reload");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: pendingMessages list receives entry after each retrieval
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After each model catalogue retrieval a {@link GuiMessageEntry} must be appended to
|
||||||
|
* {@link GuiConfigurationEditorWorkspace#pendingMessages}, regardless of the result type.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(9)
|
||||||
|
void pendingMessages_entryAppendedAfterEachRetrieval() throws Exception {
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.TechnicalFailure(req.providerIdentifier(), "TIMEOUT",
|
||||||
|
"Zeitüberschreitung");
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
int before = ws.pendingMessages.size();
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
assertEquals(before + 1, ws.pendingMessages.size(),
|
||||||
|
"Exactly one message entry must be appended after retrieval");
|
||||||
|
GuiMessageEntry entry = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
|
||||||
|
assertEquals(GuiMessageSeverity.ERROR, entry.severity(),
|
||||||
|
"TechnicalFailure must produce an ERROR message entry");
|
||||||
|
assertTrue(entry.source().isPresent(), "Message must have a source label");
|
||||||
|
assertEquals("Modellabruf", entry.source().get(),
|
||||||
|
"Message source must be \"Modellabruf\"");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: Success pendingMessage has INFO severity
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A successful model list retrieval must append a message entry with {@link GuiMessageSeverity#INFO}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(10)
|
||||||
|
void pendingMessages_successProducesInfoEntry() throws Exception {
|
||||||
|
List<String> models = List.of("claude-3-5-sonnet");
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
|
||||||
|
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
|
||||||
|
assertEquals(GuiMessageSeverity.INFO, last.severity(),
|
||||||
|
"Successful retrieval must produce an INFO message");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: IncompleteConfiguration pendingMessage has WARNING severity
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link ModelCatalogResult.IncompleteConfiguration} result must append a
|
||||||
|
* {@link GuiMessageSeverity#WARNING} entry.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(11)
|
||||||
|
void pendingMessages_incompleteConfigProducesWarningEntry() throws Exception {
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(),
|
||||||
|
"Kein API-Key.");
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
|
||||||
|
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
|
||||||
|
assertEquals(GuiMessageSeverity.WARNING, last.severity(),
|
||||||
|
"IncompleteConfiguration must produce a WARNING message");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: EmptyList pendingMessage has HINT severity
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link ModelCatalogResult.EmptyList} result must append a
|
||||||
|
* {@link GuiMessageSeverity#HINT} entry.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(12)
|
||||||
|
void pendingMessages_emptyListProducesHintEntry() throws Exception {
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.EmptyList(req.providerIdentifier(), Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
assertFalse(ws.pendingMessages.isEmpty(), "pendingMessages must not be empty");
|
||||||
|
GuiMessageEntry last = ws.pendingMessages.get(ws.pendingMessages.size() - 1);
|
||||||
|
assertEquals(GuiMessageSeverity.HINT, last.severity(),
|
||||||
|
"EmptyList must produce a HINT message");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test: repeated retrieval replaces previous message entry, not accumulates
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggering model retrieval twice must not accumulate two "Modellabruf" entries in
|
||||||
|
* {@code pendingMessages}. The second trigger must replace the entry from the first trigger
|
||||||
|
* so that exactly one entry with source "Modellabruf" is present after both calls.
|
||||||
|
* <p>
|
||||||
|
* This verifies the fix that removes old "Modellabruf" entries at the start of
|
||||||
|
* {@code applyResult} before appending the new one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(13)
|
||||||
|
void pendingMessages_repeatedRetrieval_replacesNotAccumulates() throws Exception {
|
||||||
|
List<String> models = List.of("claude-3-5-sonnet");
|
||||||
|
AiModelCatalogPort stub = req ->
|
||||||
|
new ModelCatalogResult.Success(req.providerIdentifier(), models, Instant.now());
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = createWorkspaceWithStub(stub);
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Trigger retrieval twice for the same provider.
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
triggerRetrieval(ws, AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
long modellabrufCount = ws.pendingMessages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& "Modellabruf".equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
assertEquals(1L, modellabrufCount,
|
||||||
|
"After two retrieval triggers, exactly one 'Modellabruf' entry must remain in"
|
||||||
|
+ " pendingMessages (replace semantics, not accumulate)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers: workspace creation with stub
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a workspace whose model catalogue coordinator is backed by the given stub port.
|
||||||
|
* Both the thread factory and result delivery are replaced with synchronous implementations
|
||||||
|
* so retrieval and result application happen inline without any async boundary.
|
||||||
|
*
|
||||||
|
* @param stub the stub port returning deterministic results; must not be {@code null}
|
||||||
|
* @return a workspace ready for testing; never {@code null}
|
||||||
|
*/
|
||||||
|
private static GuiConfigurationEditorWorkspace createWorkspaceWithStub(AiModelCatalogPort stub) {
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
|
||||||
|
.createBlankStartState(),
|
||||||
|
Optional.empty(),
|
||||||
|
path -> de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory
|
||||||
|
.createBlankStartState(),
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
stub,
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
// Synchronous thread factory: run the task directly instead of starting an OS thread.
|
||||||
|
ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(task, "gui-model-catalog-test") {
|
||||||
|
@Override
|
||||||
|
public synchronized void start() {
|
||||||
|
run(); // run synchronously on the calling thread
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Synchronous result delivery: execute the callback directly instead of via Platform.runLater.
|
||||||
|
ws.modelCatalogCoordinator.resultDelivery = Runnable::run;
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers model retrieval for the given family using the current editor state.
|
||||||
|
* Because the coordinator uses synchronous delivery, the result is applied immediately.
|
||||||
|
*
|
||||||
|
* @param ws the workspace to trigger retrieval on; must not be {@code null}
|
||||||
|
* @param family the provider family to retrieve models for; must not be {@code null}
|
||||||
|
*/
|
||||||
|
private static void triggerRetrieval(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState pState =
|
||||||
|
Optional.ofNullable(ws.editorState().values().providerConfiguration(family))
|
||||||
|
.orElse(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState
|
||||||
|
.blank());
|
||||||
|
ws.modelCatalogCoordinator.triggerModelRetrieval(family, pState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers: scene graph traversal
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
|
||||||
|
return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static <T extends Node> T findNodeById(GuiConfigurationEditorWorkspace ws,
|
||||||
|
String id, Class<T> type) {
|
||||||
|
return (T) findNodeByIdDeep(ws.tabPane, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Node findNodeByIdDeep(Node root, String id) {
|
||||||
|
if (id.equals(root.getId())) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
if (root instanceof javafx.scene.control.ScrollPane sp) {
|
||||||
|
Node content = sp.getContent();
|
||||||
|
if (content != null) {
|
||||||
|
Node found = findNodeByIdDeep(content, id);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
|
||||||
|
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
|
||||||
|
if (tab.getContent() != null) {
|
||||||
|
Node found = findNodeByIdDeep(tab.getContent(), id);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (root instanceof javafx.scene.Parent parent) {
|
||||||
|
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||||
|
Node found = findNodeByIdDeep(child, id);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Node findNodeDeep(Node root, Class<?> nodeType) {
|
||||||
|
if (nodeType.isInstance(root)) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
if (root instanceof javafx.scene.control.ScrollPane sp) {
|
||||||
|
Node content = sp.getContent();
|
||||||
|
if (content != null) {
|
||||||
|
Node found = findNodeDeep(content, nodeType);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
|
||||||
|
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
|
||||||
|
if (tab.getContent() != null) {
|
||||||
|
Node found = findNodeDeep(tab.getContent(), nodeType);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (root instanceof javafx.scene.Parent parent) {
|
||||||
|
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||||
|
Node found = findNodeDeep(child, nodeType);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Threading helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
if (error.get() != null) {
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t instanceof Exception e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Unexpected error on FX thread", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+452
@@ -0,0 +1,452 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
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.util.Optional;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationTemplateFactory;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Order;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke tests for the provider selection ComboBox, provider block visibility management
|
||||||
|
* and state preservation on provider switch.
|
||||||
|
*
|
||||||
|
* <p>All tests run on the JavaFX Application Thread under Monocle headless. The tests verify:
|
||||||
|
* <ul>
|
||||||
|
* <li>Initial ComboBox selection matches the active provider from the editor state.</li>
|
||||||
|
* <li>Only the active provider block is visible; the other is not visible and not managed.</li>
|
||||||
|
* <li>After a provider switch the previously hidden provider's data is still intact in the
|
||||||
|
* editor state (no data loss on switch).</li>
|
||||||
|
* <li>After a provider switch the {@code ai.provider.active} value is updated correctly.</li>
|
||||||
|
* <li>The provider ComboBox is not editable.</li>
|
||||||
|
* <li>{@link GuiVisibleProviderSection} correctly reflects the visible/hidden split.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class GuiProviderSelectionSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform — do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Initial state: ComboBox selects the active provider
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After loading the standard template (active provider: Claude) the provider ComboBox
|
||||||
|
* must pre-select Claude and the Claude block must be visible.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
void afterNew_comboBoxSelectsClaudeAndClaudeBlockIsVisible() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Standard template uses Claude as active provider.
|
||||||
|
assertEquals(AiProviderFamily.CLAUDE.getIdentifier(),
|
||||||
|
ws.editorState().values().activeProviderFamily(),
|
||||||
|
"Precondition: standard template active provider must be Claude");
|
||||||
|
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
assertNotNull(comboBox, "Provider ComboBox must be present in the section");
|
||||||
|
assertEquals(AiProviderFamily.CLAUDE, comboBox.getValue(),
|
||||||
|
"ComboBox must pre-select Claude when active provider is Claude");
|
||||||
|
|
||||||
|
// Exactly one block must be visible.
|
||||||
|
VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
|
||||||
|
VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
assertNotNull(claudeBlock, "Claude block must exist in the section");
|
||||||
|
assertNotNull(openaiBlock, "OpenAI block must exist in the section");
|
||||||
|
|
||||||
|
assertTrue(claudeBlock.isVisible(), "Claude block must be visible");
|
||||||
|
assertTrue(claudeBlock.isManaged(), "Claude block must be managed");
|
||||||
|
assertFalse(openaiBlock.isVisible(), "OpenAI block must not be visible");
|
||||||
|
assertFalse(openaiBlock.isManaged(), "OpenAI block must not be managed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ComboBox is not editable
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider ComboBox must not be editable so the user cannot type arbitrary text
|
||||||
|
* into the selection field.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
void providerComboBox_isNotEditable() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
assertNotNull(comboBox, "Provider ComboBox must be present");
|
||||||
|
assertFalse(comboBox.isEditable(), "Provider ComboBox must not be editable");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider switch: visibility toggles correctly
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After switching from Claude to OpenAI-kompatibel the OpenAI block must become visible
|
||||||
|
* and managed, and the Claude block must become invisible and unmanaged.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
void switchToOpenAi_openAiBlockBecomesVisibleClaudeBlockHides() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
assertNotNull(comboBox, "Provider ComboBox must be present");
|
||||||
|
assertEquals(AiProviderFamily.CLAUDE, comboBox.getValue(),
|
||||||
|
"Precondition: Claude must be pre-selected");
|
||||||
|
|
||||||
|
// Simulate user switching the ComboBox to OpenAI-compatible.
|
||||||
|
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
|
||||||
|
VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
|
||||||
|
VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
|
||||||
|
assertFalse(claudeBlock.isVisible(), "Claude block must be hidden after switch to OpenAI");
|
||||||
|
assertFalse(claudeBlock.isManaged(), "Claude block must be unmanaged after switch to OpenAI");
|
||||||
|
assertTrue(openaiBlock.isVisible(), "OpenAI block must be visible after switch");
|
||||||
|
assertTrue(openaiBlock.isManaged(), "OpenAI block must be managed after switch");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider switch: ai.provider.active is updated
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After switching the provider ComboBox to OpenAI-compatible the {@code ai.provider.active}
|
||||||
|
* value in the editor state must be updated to the OpenAI-compatible identifier.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
void switchToOpenAi_activeProviderValueUpdatedInEditorState() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
|
||||||
|
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE.getIdentifier(),
|
||||||
|
ws.editorState().values().activeProviderFamily(),
|
||||||
|
"ai.provider.active must reflect the newly selected provider");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider switch: hidden provider data is preserved
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After switching the provider the previously hidden provider's configuration data must
|
||||||
|
* remain intact in the editor state.
|
||||||
|
* <p>
|
||||||
|
* This test explicitly sets a distinct model name on the Claude provider, switches to
|
||||||
|
* OpenAI-compatible, and then verifies the Claude model name is still present in the
|
||||||
|
* editor state after the switch.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
void switchProvider_hiddenProviderDataIsPreserved() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Record the original OpenAI model from the standard template.
|
||||||
|
String originalOpenAiModel = ws.editorState().values()
|
||||||
|
.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model();
|
||||||
|
|
||||||
|
// Set a distinctive model name on the Claude provider (visible at this point).
|
||||||
|
GuiProviderConfigurationState currentClaude =
|
||||||
|
ws.editorState().values().providerConfiguration(AiProviderFamily.CLAUDE);
|
||||||
|
String distinctiveClaudeModel = "claude-test-model-preserved";
|
||||||
|
GuiProviderConfigurationState updatedClaude = new GuiProviderConfigurationState(
|
||||||
|
currentClaude.baseUrl(),
|
||||||
|
distinctiveClaudeModel,
|
||||||
|
currentClaude.timeoutSeconds(),
|
||||||
|
currentClaude.apiKey());
|
||||||
|
ws.editorState = ws.editorState().withValues(
|
||||||
|
ws.editorState().values().withProviderConfiguration(AiProviderFamily.CLAUDE, updatedClaude));
|
||||||
|
|
||||||
|
// Switch from Claude to OpenAI-compatible.
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
|
||||||
|
// Claude's model must still be present in the editor state after the switch.
|
||||||
|
String claudeModelAfterSwitch = ws.editorState().values()
|
||||||
|
.providerConfiguration(AiProviderFamily.CLAUDE).model();
|
||||||
|
assertEquals(distinctiveClaudeModel, claudeModelAfterSwitch,
|
||||||
|
"Claude model must not be lost when switching to OpenAI-compatible");
|
||||||
|
|
||||||
|
// OpenAI model must also be untouched.
|
||||||
|
String openAiModelAfterSwitch = ws.editorState().values()
|
||||||
|
.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE).model();
|
||||||
|
assertEquals(originalOpenAiModel, openAiModelAfterSwitch,
|
||||||
|
"OpenAI model must remain unchanged after switch");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider switch: switch back restores first provider visibility
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching to OpenAI and then back to Claude must restore the Claude block as visible
|
||||||
|
* and hide the OpenAI block again. The {@code ai.provider.active} value must reflect Claude.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
void switchBackToClaude_claudeBlockVisibleActiveProviderUpdated() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
ComboBox<AiProviderFamily> comboBox = findProviderComboBox(ws);
|
||||||
|
comboBox.setValue(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
comboBox.setValue(AiProviderFamily.CLAUDE);
|
||||||
|
|
||||||
|
VBox claudeBlock = findProviderBlock(ws, AiProviderFamily.CLAUDE);
|
||||||
|
VBox openaiBlock = findProviderBlock(ws, AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
|
||||||
|
assertTrue(claudeBlock.isVisible(), "Claude block must be visible after switching back");
|
||||||
|
assertTrue(claudeBlock.isManaged(), "Claude block must be managed after switching back");
|
||||||
|
assertFalse(openaiBlock.isVisible(), "OpenAI block must be hidden after switching back");
|
||||||
|
assertFalse(openaiBlock.isManaged(), "OpenAI block must be unmanaged after switching back");
|
||||||
|
|
||||||
|
assertEquals(AiProviderFamily.CLAUDE.getIdentifier(),
|
||||||
|
ws.editorState().values().activeProviderFamily(),
|
||||||
|
"ai.provider.active must reflect Claude after switching back");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// visibleProviderSection reflects current state
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After loading the standard template {@link GuiConfigurationEditorWorkspace#visibleProviderSection}
|
||||||
|
* must reflect Claude as the visible provider.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
void afterNew_visibleProviderSectionReflectsClaudeAsVisible() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
GuiVisibleProviderSection section = ws.visibleProviderSection;
|
||||||
|
assertNotNull(section, "visibleProviderSection must not be null after loading a configuration");
|
||||||
|
assertEquals(AiProviderFamily.CLAUDE, section.visibleProvider(),
|
||||||
|
"Visible provider in the section snapshot must be Claude");
|
||||||
|
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, section.hiddenProvider(),
|
||||||
|
"Hidden provider in the section snapshot must be OpenAI-compatible");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper: find provider ComboBox and blocks inside the workspace
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates the provider {@link ComboBox} by traversing the workspace scene graph.
|
||||||
|
* Uses a deep traversal that also visits {@link javafx.scene.control.ScrollPane} viewport
|
||||||
|
* content and {@link javafx.scene.control.TabPane} tab content nodes, which are not
|
||||||
|
* accessible via {@code getChildrenUnmodifiable()} on their parent containers.
|
||||||
|
*
|
||||||
|
* @param ws the workspace whose root is searched; must not be {@code null}
|
||||||
|
* @return the ComboBox, or {@code null} when not found
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static ComboBox<AiProviderFamily> findProviderComboBox(GuiConfigurationEditorWorkspace ws) {
|
||||||
|
return (ComboBox<AiProviderFamily>) findNodeDeep(ws.tabPane, ComboBox.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates the provider block {@link VBox} for the given family.
|
||||||
|
* <p>
|
||||||
|
* Provider blocks are identified by the presence of {@code -fx-border-color: #c8c8c8}
|
||||||
|
* in their inline style. Claude is the first block, OpenAI-compatible the second.
|
||||||
|
*
|
||||||
|
* @param ws the workspace to search; must not be {@code null}
|
||||||
|
* @param family the provider family to locate; must not be {@code null}
|
||||||
|
* @return the block VBox, or {@code null} when not found
|
||||||
|
*/
|
||||||
|
private static VBox findProviderBlock(GuiConfigurationEditorWorkspace ws, AiProviderFamily family) {
|
||||||
|
java.util.List<VBox> blocks = collectProviderBlocks(ws.tabPane);
|
||||||
|
if (family == AiProviderFamily.CLAUDE) {
|
||||||
|
return blocks.isEmpty() ? null : blocks.get(0);
|
||||||
|
} else {
|
||||||
|
return blocks.size() < 2 ? null : blocks.get(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects all provider block VBoxes identified by the provider-block inline style.
|
||||||
|
*
|
||||||
|
* @param root the starting node; must not be {@code null}
|
||||||
|
* @return ordered list of provider block VBoxes
|
||||||
|
*/
|
||||||
|
private static java.util.List<VBox> collectProviderBlocks(Node root) {
|
||||||
|
java.util.List<VBox> result = new java.util.ArrayList<>();
|
||||||
|
collectProviderBlocksInto(root, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectProviderBlocksInto(Node node, java.util.List<VBox> result) {
|
||||||
|
if (node instanceof VBox vbox) {
|
||||||
|
String style = vbox.getStyle();
|
||||||
|
if (style != null && style.contains("-fx-border-color: #c8c8c8")) {
|
||||||
|
result.add(vbox);
|
||||||
|
// Do NOT recurse into provider blocks themselves to avoid nested matches.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node instanceof javafx.scene.control.ScrollPane sp) {
|
||||||
|
Node content = sp.getContent();
|
||||||
|
if (content != null) {
|
||||||
|
collectProviderBlocksInto(content, result);
|
||||||
|
}
|
||||||
|
} else if (node instanceof javafx.scene.control.TabPane tabPane) {
|
||||||
|
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
|
||||||
|
if (tab.getContent() != null) {
|
||||||
|
collectProviderBlocksInto(tab.getContent(), result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node instanceof javafx.scene.Parent parent) {
|
||||||
|
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||||
|
collectProviderBlocksInto(child, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the first node of the requested type using a deep traversal that visits
|
||||||
|
* {@link javafx.scene.control.ScrollPane} and {@link javafx.scene.control.TabPane} content.
|
||||||
|
*
|
||||||
|
* @param root the starting node; must not be {@code null}
|
||||||
|
* @param nodeType the type to search for; must not be {@code null}
|
||||||
|
* @return the first matching node, or {@code null} when not found
|
||||||
|
*/
|
||||||
|
private static Node findNodeDeep(Node root, Class<?> nodeType) {
|
||||||
|
if (nodeType.isInstance(root)) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
if (root instanceof javafx.scene.control.ScrollPane sp) {
|
||||||
|
Node content = sp.getContent();
|
||||||
|
if (content != null) {
|
||||||
|
Node found = findNodeDeep(content, nodeType);
|
||||||
|
if (found != null) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (root instanceof javafx.scene.control.TabPane tabPane) {
|
||||||
|
for (javafx.scene.control.Tab tab : tabPane.getTabs()) {
|
||||||
|
if (tab.getContent() != null) {
|
||||||
|
Node found = findNodeDeep(tab.getContent(), nodeType);
|
||||||
|
if (found != null) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (root instanceof javafx.scene.Parent parent) {
|
||||||
|
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||||
|
Node found = findNodeDeep(child, nodeType);
|
||||||
|
if (found != null) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Threading helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
if (error.get() != null) {
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t instanceof Exception e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Unexpected error on FX thread", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import 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.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-based headless smoke tests for the "Technische Tests ausführen" button
|
||||||
|
* and {@link GuiTechnicalTestCoordinator}.
|
||||||
|
* <p>
|
||||||
|
* Verifies the following scenarios:
|
||||||
|
* <ul>
|
||||||
|
* <li>The "Technische Tests ausführen" button is findable by CSS ID
|
||||||
|
* {@code technical-tests-button}.</li>
|
||||||
|
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
||||||
|
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
|
||||||
|
* <li>A second trigger replaces the previous test entries (replace semantics).</li>
|
||||||
|
* <li>The post-result callback is invoked after the result is applied.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* The coordinator's {@code testThreadFactory} is overridden to run the task inline
|
||||||
|
* (on the calling thread) and {@code resultDelivery} is overridden with a direct
|
||||||
|
* synchronous call, so results are available immediately after
|
||||||
|
* {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()} returns.
|
||||||
|
*/
|
||||||
|
class GuiTechnicalTestCoordinatorSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform – do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: button is findable by CSS ID
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after constructing a workspace, the "Technische Tests ausführen" button
|
||||||
|
* exists and carries the CSS ID {@code technical-tests-button}.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void technicalTestsButton_hasCssId() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws =
|
||||||
|
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
Button btn = ws.technicalTestsButton;
|
||||||
|
assertNotNull(btn, "technicalTestsButton must not be null");
|
||||||
|
assertEquals("technical-tests-button", btn.getId(),
|
||||||
|
"technicalTestsButton must carry CSS ID 'technical-tests-button'");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: triggering populates pendingMessages with SOURCE_TAG entries
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: triggering the coordinator synchronously populates
|
||||||
|
* {@code pendingMessages} with at least one entry tagged with the source
|
||||||
|
* {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void trigger_populatesPendingMessagesWithSourceTag() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||||
|
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
|
||||||
|
long taggedCount = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assertTrue(taggedCount > 0,
|
||||||
|
"Triggering 'Technische Tests ausführen' must add entries tagged '"
|
||||||
|
+ GuiTechnicalTestCoordinator.SOURCE_TAG + "'");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: 11 checkpoint entries + 1 summary = 12 entries total
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
|
||||||
|
* 11 (one per checkpoint) plus 1 summary entry = 12.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||||
|
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
|
||||||
|
long taggedCount = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// 11 checkpoint entries + 1 summary entry = 12
|
||||||
|
assertEquals(12, taggedCount,
|
||||||
|
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: replace semantics – second trigger replaces previous entries
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: triggering the coordinator twice replaces the previous SOURCE_TAG
|
||||||
|
* entries; the count remains the same as after a single trigger.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void trigger_twice_replacesPreviousTestEntries() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||||
|
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
long countAfterFirst = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
long countAfterSecond = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assertEquals(countAfterFirst, countAfterSecond,
|
||||||
|
"Second trigger must replace (not append) the previous test entries");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: post-result callback is invoked
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: the post-result callback is invoked after the result is applied.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void trigger_postResultCallbackIsInvoked() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
AtomicBoolean callbackInvoked = new AtomicBoolean(false);
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(
|
||||||
|
messages, report -> callbackInvoked.set(true));
|
||||||
|
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
|
||||||
|
assertTrue(callbackInvoked.get(),
|
||||||
|
"The post-result callback must be invoked after the technical tests finish");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: input supplier is consulted at trigger time — reflects current (unsaved) state
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: the coordinator must read the current editor state at trigger time via the
|
||||||
|
* injected {@link java.util.function.Supplier}, not from a cached snapshot. This verifies
|
||||||
|
* that unsaved changes are always reflected in the technical test results.
|
||||||
|
*
|
||||||
|
* <p>The test wires the coordinator with a mutable {@link AtomicReference} as input supplier.
|
||||||
|
* Before the first trigger the supplier returns valid input; before the second trigger the
|
||||||
|
* supplier is updated to return input with an empty active-provider (which causes failures).
|
||||||
|
* After the second trigger the message list must contain ERROR entries, proving that the
|
||||||
|
* coordinator forwarded the new, unsaved input value rather than the previous one.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void trigger_readsCurrentInputFromSupplier_unsavedChangesAreReflected() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
// Mutable supplier: starts with valid input, will be swapped before second trigger.
|
||||||
|
AtomicReference<EditorValidationInput> currentInput = new AtomicReference<>(
|
||||||
|
new EditorValidationInput(
|
||||||
|
"claude",
|
||||||
|
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"https://api.openai.com", "gpt-4", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent()));
|
||||||
|
|
||||||
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
|
new EditorConfigurationValidator(),
|
||||||
|
noOpPathCheckPort(),
|
||||||
|
noOpProviderService());
|
||||||
|
|
||||||
|
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||||
|
orchestrator,
|
||||||
|
currentInput::get, // always reads the current reference
|
||||||
|
() -> "",
|
||||||
|
messages,
|
||||||
|
report -> { });
|
||||||
|
|
||||||
|
coordinator.testThreadFactory = task -> new Thread(task, "sync-test-thread") {
|
||||||
|
@Override public void start() { run(); }
|
||||||
|
};
|
||||||
|
coordinator.resultDelivery = Runnable::run;
|
||||||
|
|
||||||
|
// First trigger with valid input — all failures are from path checks (no-op port),
|
||||||
|
// but no CONFIGURATION_BASIC_VALIDATION error because activeProvider is valid.
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
long errorCountFirst = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.filter(m -> m.severity() == GuiMessageSeverity.ERROR)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// Simulate unsaved edit: replace input with one having an empty active-provider.
|
||||||
|
currentInput.set(new EditorValidationInput(
|
||||||
|
"", // empty active provider → validation error in block 1
|
||||||
|
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"https://api.openai.com", "gpt-4", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent()));
|
||||||
|
|
||||||
|
// Second trigger with the updated (unsaved) input.
|
||||||
|
coordinator.triggerTechnicalTests();
|
||||||
|
long errorCountSecond = messages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
|
.filter(m -> m.severity() == GuiMessageSeverity.ERROR)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// After the change, there must be at least as many errors as before,
|
||||||
|
// because the empty active-provider introduces additional validation errors.
|
||||||
|
assertTrue(errorCountSecond >= errorCountFirst,
|
||||||
|
"Second trigger with empty active-provider must produce at least as many "
|
||||||
|
+ "errors as the first trigger, proving the supplier is read at trigger time");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: workspace button is wired to coordinator
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after constructing a workspace, the button is not null and
|
||||||
|
* the coordinator is not null.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void workspace_buttonAndCoordinatorAreWired() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws =
|
||||||
|
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
assertNotNull(ws.technicalTestsButton,
|
||||||
|
"technicalTestsButton must not be null");
|
||||||
|
assertNotNull(ws.technicalTestCoordinator,
|
||||||
|
"technicalTestCoordinator must not be null");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** No-op {@link PathCheckPort}: all checks return {@code false}. */
|
||||||
|
private static PathCheckPort noOpPathCheckPort() {
|
||||||
|
return new PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op {@link ProviderTechnicalTestService}. */
|
||||||
|
private static ProviderTechnicalTestService noOpProviderService() {
|
||||||
|
return new ProviderTechnicalTestService(
|
||||||
|
req -> new ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam, pv) -> EffectiveApiKeyDescriptor.absent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link GuiTechnicalTestCoordinator} that runs synchronously (inline thread
|
||||||
|
* factory, direct result delivery) so results are available immediately after
|
||||||
|
* {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()} returns.
|
||||||
|
*
|
||||||
|
* @param messages mutable list to collect message entries
|
||||||
|
* @param postResultCallback callback to invoke after result is applied
|
||||||
|
* @return synchronously-wired coordinator
|
||||||
|
*/
|
||||||
|
private static GuiTechnicalTestCoordinator buildSyncCoordinator(
|
||||||
|
List<GuiMessageEntry> messages,
|
||||||
|
java.util.function.Consumer<TechnicalTestReport> postResultCallback) {
|
||||||
|
|
||||||
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
|
new EditorConfigurationValidator(),
|
||||||
|
noOpPathCheckPort(),
|
||||||
|
noOpProviderService());
|
||||||
|
|
||||||
|
EditorValidationInput blankInput = new EditorValidationInput(
|
||||||
|
"claude",
|
||||||
|
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||||
|
"3", "10", "2000",
|
||||||
|
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"https://api.openai.com", "gpt-4", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||||
|
orchestrator,
|
||||||
|
() -> blankInput,
|
||||||
|
() -> "",
|
||||||
|
messages,
|
||||||
|
postResultCallback);
|
||||||
|
|
||||||
|
// Override thread factory to run task inline (synchronous, no real thread spawn)
|
||||||
|
coordinator.testThreadFactory = task -> new Thread(task, "sync-test-thread") {
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
// Run the task inline instead of starting a new thread
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Override result delivery to be synchronous (direct call)
|
||||||
|
coordinator.resultDelivery = Runnable::run;
|
||||||
|
|
||||||
|
return coordinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
rethrow(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t instanceof Exception ex) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Unexpected error", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-2
@@ -789,7 +789,35 @@ class GuiUnsavedChangesGuardSmokeTest {
|
|||||||
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
writer);
|
writer,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context);
|
||||||
ws.requestNewConfiguration();
|
ws.requestNewConfiguration();
|
||||||
return ws;
|
return ws;
|
||||||
@@ -806,7 +834,35 @@ class GuiUnsavedChangesGuardSmokeTest {
|
|||||||
stateWithFile,
|
stateWithFile,
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
|
||||||
writer);
|
writer,
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
return new GuiConfigurationEditorWorkspace(context);
|
return new GuiConfigurationEditorWorkspace(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+422
@@ -0,0 +1,422 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
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.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import 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.GuiEditorValidationResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-based headless smoke tests for the explicit "Validieren" action.
|
||||||
|
* <p>
|
||||||
|
* Verifies that clicking the "Validieren" button in the "Tests" section executes
|
||||||
|
* the in-memory validation against the current editor state, reports the findings
|
||||||
|
* via the central message area, and neither writes any file nor triggers remote calls.
|
||||||
|
*
|
||||||
|
* <h2>Covered scenarios</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Clicking "Validieren" with an incomplete configuration produces ERROR findings and
|
||||||
|
* an INFO message reporting the finding count.</li>
|
||||||
|
* <li>Clicking "Validieren" with a valid template configuration produces no ERRORs and
|
||||||
|
* an INFO message reporting "Keine Befunde." or a zero count.</li>
|
||||||
|
* <li>Clicking "Validieren" twice replaces the previous action-confirmation INFO message
|
||||||
|
* (replace semantics; the message appears exactly once).</li>
|
||||||
|
* <li>Clicking "Validieren" does not trigger any file write (the writer stub records no
|
||||||
|
* calls).</li>
|
||||||
|
* <li>The button is findable by its CSS ID {@code validate-button}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
|
||||||
|
* The Monocle headless configuration is activated by the Surefire JVM arguments.
|
||||||
|
*/
|
||||||
|
class GuiValidateActionSmokeTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
/** Source tag used by the validation action INFO message. */
|
||||||
|
private static final String ACTION_SOURCE = "Validierung-Aktion";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX Platform must start within timeout");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Existing JavaFX Platform must be reachable within timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Shared platform – do not call Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: button is findable by CSS ID
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after constructing a workspace, the "Validieren" button exists
|
||||||
|
* and carries the CSS ID {@code validate-button}.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validateButton_hasCssId() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws =
|
||||||
|
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||||
|
Button btn = ws.validateButton;
|
||||||
|
assertNotNull(btn, "validateButton must not be null");
|
||||||
|
assertEquals("validate-button", btn.getId(),
|
||||||
|
"validateButton must carry CSS ID 'validate-button'");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: incomplete configuration → ERROR findings + INFO message with count
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after clicking "Validieren" on a workspace whose editor state has
|
||||||
|
* an empty active-provider value, the last validation result contains at least one
|
||||||
|
* ERROR and the central message area contains an INFO message with source
|
||||||
|
* "Validierung-Aktion" that reports the number of findings.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validateAction_incompleteConfiguration_producesErrorsAndInfoMessage() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||||
|
|
||||||
|
// Force an incomplete state: start with blank (no active provider).
|
||||||
|
// The blank start state already has an empty active provider → errors expected.
|
||||||
|
|
||||||
|
ws.validateButton.fire();
|
||||||
|
|
||||||
|
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||||
|
assertTrue(result.hasErrors(),
|
||||||
|
"Clicking Validieren on incomplete config must produce ERROR findings");
|
||||||
|
|
||||||
|
List<GuiMessageEntry> actionMessages = ws.pendingMessages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& ACTION_SOURCE.equals(m.source().get()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
assertEquals(1, actionMessages.size(),
|
||||||
|
"Exactly one action-confirmation INFO message must be present");
|
||||||
|
GuiMessageEntry msg = actionMessages.get(0);
|
||||||
|
assertEquals(GuiMessageSeverity.INFO, msg.severity(),
|
||||||
|
"Action-confirmation message must have INFO severity");
|
||||||
|
assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||||
|
"Action-confirmation message text must start with expected prefix");
|
||||||
|
assertFalse(msg.text().contains("Keine Befunde"),
|
||||||
|
"With errors present the message must NOT say 'Keine Befunde'");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: valid configuration → no ERRORs + INFO message "Keine Befunde."
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: after clicking "Validieren" on a workspace with the standard template
|
||||||
|
* values loaded, the last validation result contains no ERRORs and the action-confirmation
|
||||||
|
* message either contains "Keine Befunde." or a zero finding count.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validateAction_validTemplate_noErrorsAndNoBefundeMessage() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
ws.validateButton.fire();
|
||||||
|
|
||||||
|
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||||
|
assertFalse(result.hasErrors(),
|
||||||
|
"Clicking Validieren on standard template must produce no ERROR findings");
|
||||||
|
|
||||||
|
List<GuiMessageEntry> actionMessages = ws.pendingMessages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& ACTION_SOURCE.equals(m.source().get()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
assertEquals(1, actionMessages.size(),
|
||||||
|
"Exactly one action-confirmation INFO message must be present");
|
||||||
|
GuiMessageEntry msg = actionMessages.get(0);
|
||||||
|
assertEquals(GuiMessageSeverity.INFO, msg.severity(),
|
||||||
|
"Action-confirmation message must have INFO severity");
|
||||||
|
// Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0.
|
||||||
|
assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||||
|
"Action-confirmation message text must start with expected prefix");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: "Validieren" with unsaved (dirty) editor state — validates current content
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: when the editor holds unsaved changes that introduce a validation error,
|
||||||
|
* clicking "Validieren" must reflect the <em>current, modified (dirty)</em> editor state,
|
||||||
|
* not the previously saved or baseline state.
|
||||||
|
*
|
||||||
|
* <p>The test loads the standard template (which has a valid active-provider value and
|
||||||
|
* produces no errors), then directly overwrites the in-memory editor state with an
|
||||||
|
* equivalent state whose active-provider value is cleared. This mimics a user editing a
|
||||||
|
* field without saving. A subsequent click on "Validieren" must produce ERROR findings
|
||||||
|
* that originate from the unsaved change — proving that validation is always based on the
|
||||||
|
* current editor content.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validateAction_withUnsavedDirtyChange_validatesCurrentState() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||||
|
|
||||||
|
// Load the template so the baseline is valid (no errors expected initially).
|
||||||
|
ws.requestNewConfiguration();
|
||||||
|
|
||||||
|
// Confirm: template produces no errors.
|
||||||
|
ws.validateButton.fire();
|
||||||
|
assertFalse(ws.lastValidationResult().hasErrors(),
|
||||||
|
"Standard template must produce no errors before the dirty change");
|
||||||
|
|
||||||
|
// Simulate an unsaved edit: replace the editor state with a version that has
|
||||||
|
// an empty active-provider value. The baseline remains the template values,
|
||||||
|
// so isDirty() returns true. The workspace field is package-private.
|
||||||
|
GuiConfigurationEditorState currentState = ws.editorState;
|
||||||
|
GuiConfigurationEditorState dirtyState = currentState.withValues(
|
||||||
|
currentState.values().withActiveProviderFamily(""));
|
||||||
|
ws.editorState = dirtyState;
|
||||||
|
|
||||||
|
// Confirm the editor is now dirty (sanity check).
|
||||||
|
assertTrue(ws.editorState.isDirty(),
|
||||||
|
"Editor state must be dirty after the unsaved change");
|
||||||
|
|
||||||
|
// Click "Validieren" again — must validate the dirty (unsaved) state.
|
||||||
|
ws.validateButton.fire();
|
||||||
|
|
||||||
|
assertTrue(ws.lastValidationResult().hasErrors(),
|
||||||
|
"Validieren must produce ERROR findings reflecting the unsaved dirty state, "
|
||||||
|
+ "not the previously clean baseline state");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: clicking twice → message appears exactly once (replace semantics)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
|
||||||
|
* INFO message in the message list (the second click replaces the first).
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validateAction_clickedTwice_infoMessageAppearsExactlyOnce() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||||
|
|
||||||
|
ws.validateButton.fire();
|
||||||
|
ws.validateButton.fire();
|
||||||
|
|
||||||
|
long count = ws.pendingMessages.stream()
|
||||||
|
.filter(m -> m.source().isPresent()
|
||||||
|
&& ACTION_SOURCE.equals(m.source().get()))
|
||||||
|
.count();
|
||||||
|
assertEquals(1, count,
|
||||||
|
"After two clicks the action-confirmation INFO message must appear exactly once");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scenario: clicking Validieren does not trigger a file write
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: clicking "Validieren" must not invoke the configuration file writer.
|
||||||
|
* The writer stub records whether it was called; it must remain uncalled after
|
||||||
|
* the button is fired.
|
||||||
|
*
|
||||||
|
* @throws Exception if the FX thread task fails or times out
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void validateAction_doesNotTriggerFileWrite() throws Exception {
|
||||||
|
runOnFx(() -> {
|
||||||
|
AtomicBoolean writerCalled = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
GuiConfigurationEditorState blankState =
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||||
|
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
blankState,
|
||||||
|
Optional.empty(),
|
||||||
|
path -> blankState, // no-op loader
|
||||||
|
(values, path) -> {
|
||||||
|
writerCalled.set(true);
|
||||||
|
return GuiConfigurationSaveResult.saved(path);
|
||||||
|
},
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) ->
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
ws.validateButton.fire();
|
||||||
|
|
||||||
|
assertFalse(writerCalled.get(),
|
||||||
|
"Clicking Validieren must not invoke the file writer");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a workspace with no-op loader/writer and absent API-key resolution,
|
||||||
|
* suitable for in-memory validation tests.
|
||||||
|
*/
|
||||||
|
private static GuiConfigurationEditorWorkspace buildWorkspace() {
|
||||||
|
GuiConfigurationEditorState blankState =
|
||||||
|
GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||||
|
|
||||||
|
GuiStartupContext ctx = new GuiStartupContext(
|
||||||
|
blankState,
|
||||||
|
Optional.empty(),
|
||||||
|
path -> blankState,
|
||||||
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
req.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(family, propertyValue) ->
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.EffectiveApiKeyDescriptor.absent(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||||
|
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||||
|
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||||
|
@Override public boolean isFileReadable(String p) { return false; }
|
||||||
|
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||||
|
},
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new GuiConfigurationEditorWorkspace(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX task must complete within timeout");
|
||||||
|
rethrow(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
|
||||||
|
Throwable t = error.get();
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t instanceof Exception ex) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
throw new AssertionError("Unexpected error", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface ThrowingRunnable {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link ConfirmationDialogContent}.
|
||||||
|
*/
|
||||||
|
class ConfirmationDialogContentTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromPlan_extractsDescriptionsInOrder() {
|
||||||
|
var s1 = new CorrectionSuggestion.CreateDirectory("/path/a", "Zielordner anlegen");
|
||||||
|
var s2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen");
|
||||||
|
var plan = new CorrectionPlan(List.of(s1, s2));
|
||||||
|
|
||||||
|
var content = ConfirmationDialogContent.fromPlan(plan);
|
||||||
|
|
||||||
|
assertThat(content.correctionLines()).containsExactly(
|
||||||
|
"Zielordner anlegen",
|
||||||
|
"Prompt-Datei erzeugen");
|
||||||
|
assertThat(content.title()).isNotBlank();
|
||||||
|
assertThat(content.introText()).isNotBlank();
|
||||||
|
assertThat(content.hasCorrections()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromPlan_emptyPlan_hasNoCorrections() {
|
||||||
|
var content = ConfirmationDialogContent.fromPlan(CorrectionPlan.empty());
|
||||||
|
assertThat(content.hasCorrections()).isFalse();
|
||||||
|
assertThat(content.correctionLines()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromPlan_nullPlanThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> ConfirmationDialogContent.fromPlan(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void correctionLinesAreImmutable() {
|
||||||
|
var content = new ConfirmationDialogContent("Titel", "Intro", new ArrayList<>(List.of("Zeile 1")));
|
||||||
|
assertThat(content.correctionLines()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullTitleThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ConfirmationDialogContent(null, "intro", List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullIntroTextThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ConfirmationDialogContent("title", null, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullCorrectionLinesThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ConfirmationDialogContent("title", "intro", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equality() {
|
||||||
|
var a = new ConfirmationDialogContent("T", "I", List.of("Z1"));
|
||||||
|
var b = new ConfirmationDialogContent("T", "I", List.of("Z1"));
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiEditorValidationResult}.
|
||||||
|
*/
|
||||||
|
class GuiEditorValidationResultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void empty_producesResultWithNoFindingsAndCurrentTimestamp() {
|
||||||
|
var result = GuiEditorValidationResult.empty();
|
||||||
|
|
||||||
|
assertThat(result.messages()).isEmpty();
|
||||||
|
assertThat(result.fieldFindings()).isEmpty();
|
||||||
|
assertThat(result.evaluatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasErrors_falseWhenNoMessages() {
|
||||||
|
var result = GuiEditorValidationResult.empty();
|
||||||
|
assertThat(result.hasErrors()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasErrors_trueWhenMessageWithErrorSeverity() {
|
||||||
|
var messages = List.of(GuiMessageEntry.of(GuiMessageSeverity.ERROR, "API-Key fehlt"));
|
||||||
|
var result = new GuiEditorValidationResult(messages, List.of(), Instant.now());
|
||||||
|
|
||||||
|
assertThat(result.hasErrors()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasErrors_trueWhenFieldFindingWithErrorSeverity() {
|
||||||
|
var fieldFindings = List.of(GuiFieldFinding.error("source.folder", "Pflichtfeld fehlt"));
|
||||||
|
var result = new GuiEditorValidationResult(List.of(), fieldFindings, Instant.now());
|
||||||
|
|
||||||
|
assertThat(result.hasErrors()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasErrors_falseWhenOnlyWarnings() {
|
||||||
|
var messages = List.of(GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Hohe Zeichenzahl"));
|
||||||
|
var fieldFindings = List.of(GuiFieldFinding.warning("max.text.characters", "Warnung"));
|
||||||
|
var result = new GuiEditorValidationResult(messages, fieldFindings, Instant.now());
|
||||||
|
|
||||||
|
assertThat(result.hasErrors()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasFieldFindingFor_trueWhenFindingExists() {
|
||||||
|
var fieldFindings = List.of(GuiFieldFinding.error("source.folder", "Pflichtfeld fehlt"));
|
||||||
|
var result = new GuiEditorValidationResult(List.of(), fieldFindings, Instant.now());
|
||||||
|
|
||||||
|
assertThat(result.hasFieldFindingFor("source.folder")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasFieldFindingFor_falseWhenFindingAbsent() {
|
||||||
|
var result = GuiEditorValidationResult.empty();
|
||||||
|
assertThat(result.hasFieldFindingFor("source.folder")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void messages_isDefensiveCopy() {
|
||||||
|
var mutableMessages = new java.util.ArrayList<>(
|
||||||
|
List.of(GuiMessageEntry.of(GuiMessageSeverity.INFO, "info")));
|
||||||
|
var result = new GuiEditorValidationResult(mutableMessages, List.of(), Instant.now());
|
||||||
|
mutableMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, "error"));
|
||||||
|
|
||||||
|
assertThat(result.messages()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fieldFindings_isDefensiveCopy() {
|
||||||
|
var mutableFindings = new java.util.ArrayList<>(
|
||||||
|
List.of(GuiFieldFinding.error("f", "t")));
|
||||||
|
var result = new GuiEditorValidationResult(List.of(), mutableFindings, Instant.now());
|
||||||
|
mutableFindings.add(GuiFieldFinding.warning("g", "t2"));
|
||||||
|
|
||||||
|
assertThat(result.fieldFindings()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullMessages() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiEditorValidationResult(null, List.of(), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullFieldFindings() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiEditorValidationResult(List.of(), null, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullTimestamp() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiEditorValidationResult(List.of(), List.of(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasFieldFindingFor_rejectsNullFieldKey() {
|
||||||
|
var result = GuiEditorValidationResult.empty();
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> result.hasFieldFindingFor(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiFieldFinding}.
|
||||||
|
*/
|
||||||
|
class GuiFieldFindingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesAllFields() {
|
||||||
|
var finding = new GuiFieldFinding("source.folder", GuiMessageSeverity.ERROR, "Pflichtfeld fehlt");
|
||||||
|
|
||||||
|
assertThat(finding.fieldKey()).isEqualTo("source.folder");
|
||||||
|
assertThat(finding.severity()).isEqualTo(GuiMessageSeverity.ERROR);
|
||||||
|
assertThat(finding.text()).isEqualTo("Pflichtfeld fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorFactory_createsFindingWithErrorSeverity() {
|
||||||
|
var finding = GuiFieldFinding.error("target.folder", "Ordner nicht vorhanden");
|
||||||
|
|
||||||
|
assertThat(finding.severity()).isEqualTo(GuiMessageSeverity.ERROR);
|
||||||
|
assertThat(finding.fieldKey()).isEqualTo("target.folder");
|
||||||
|
assertThat(finding.text()).isEqualTo("Ordner nicht vorhanden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void warningFactory_createsFindingWithWarningSeverity() {
|
||||||
|
var finding = GuiFieldFinding.warning("max.text.characters", "Sehr hohe Zeichenzahl konfiguriert");
|
||||||
|
|
||||||
|
assertThat(finding.severity()).isEqualTo(GuiMessageSeverity.WARNING);
|
||||||
|
assertThat(finding.fieldKey()).isEqualTo("max.text.characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullFieldKey() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiFieldFinding(null, GuiMessageSeverity.ERROR, "text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullSeverity() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiFieldFinding("field", null, "text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullText() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiFieldFinding("field", GuiMessageSeverity.ERROR, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equality_basedOnAllFields() {
|
||||||
|
var a = GuiFieldFinding.error("source.folder", "fehlt");
|
||||||
|
var b = GuiFieldFinding.error("source.folder", "fehlt");
|
||||||
|
var c = GuiFieldFinding.error("target.folder", "fehlt");
|
||||||
|
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
assertThat(a).isNotEqualTo(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiManualModelEntry}.
|
||||||
|
*/
|
||||||
|
class GuiManualModelEntryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesProviderIdentifierAndModelName() {
|
||||||
|
var entry = new GuiManualModelEntry("claude", "claude-3-5-sonnet");
|
||||||
|
|
||||||
|
assertThat(entry.providerIdentifier()).isEqualTo("claude");
|
||||||
|
assertThat(entry.modelName()).isEqualTo("claude-3-5-sonnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasModelName_trueWhenNonBlank() {
|
||||||
|
assertThat(new GuiManualModelEntry("claude", "some-model").hasModelName()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasModelName_falseWhenBlank() {
|
||||||
|
assertThat(new GuiManualModelEntry("claude", "").hasModelName()).isFalse();
|
||||||
|
assertThat(new GuiManualModelEntry("claude", " ").hasModelName()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullProviderIdentifier() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiManualModelEntry(null, "model"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullModelName() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiManualModelEntry("claude", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equality_basedOnAllFields() {
|
||||||
|
var a = new GuiManualModelEntry("claude", "model-x");
|
||||||
|
var b = new GuiManualModelEntry("claude", "model-x");
|
||||||
|
var c = new GuiManualModelEntry("openai-compatible", "model-x");
|
||||||
|
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
assertThat(a).isNotEqualTo(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiMessageEntry}.
|
||||||
|
*/
|
||||||
|
class GuiMessageEntryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullConstructor_storesAllFields() {
|
||||||
|
var now = Instant.now();
|
||||||
|
var entry = new GuiMessageEntry(
|
||||||
|
GuiMessageSeverity.ERROR,
|
||||||
|
"Quellordner fehlt",
|
||||||
|
Optional.of("Validierung"),
|
||||||
|
now);
|
||||||
|
|
||||||
|
assertThat(entry.severity()).isEqualTo(GuiMessageSeverity.ERROR);
|
||||||
|
assertThat(entry.text()).isEqualTo("Quellordner fehlt");
|
||||||
|
assertThat(entry.source()).contains("Validierung");
|
||||||
|
assertThat(entry.timestamp()).isEqualTo(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullSourceBecomesEmpty() {
|
||||||
|
var entry = new GuiMessageEntry(GuiMessageSeverity.INFO, "text", null, Instant.now());
|
||||||
|
assertThat(entry.source()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void factoryOf_withoutSource_hasEmptySource() {
|
||||||
|
var entry = GuiMessageEntry.of(GuiMessageSeverity.INFO, "Konfiguration geladen");
|
||||||
|
assertThat(entry.source()).isEmpty();
|
||||||
|
assertThat(entry.severity()).isEqualTo(GuiMessageSeverity.INFO);
|
||||||
|
assertThat(entry.text()).isEqualTo("Konfiguration geladen");
|
||||||
|
assertThat(entry.timestamp()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void factoryOf_withSource_storesSource() {
|
||||||
|
var entry = GuiMessageEntry.of(GuiMessageSeverity.WARNING, "Lange Zeichenzahl", "Validierung");
|
||||||
|
assertThat(entry.source()).contains("Validierung");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullSeverity() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiMessageEntry(null, "text", Optional.empty(), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullText() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiMessageEntry(GuiMessageSeverity.INFO, null, Optional.empty(), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullTimestamp() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiMessageEntry(GuiMessageSeverity.INFO, "text", Optional.empty(), null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiMessageSeverity}.
|
||||||
|
*/
|
||||||
|
class GuiMessageSeverityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allValuesHaveGermanPrefix() {
|
||||||
|
assertThat(GuiMessageSeverity.INFO.getPrefixLabel()).isEqualTo("Info:");
|
||||||
|
assertThat(GuiMessageSeverity.HINT.getPrefixLabel()).isEqualTo("Hinweis:");
|
||||||
|
assertThat(GuiMessageSeverity.WARNING.getPrefixLabel()).isEqualTo("Warnung:");
|
||||||
|
assertThat(GuiMessageSeverity.ERROR.getPrefixLabel()).isEqualTo("Fehler:");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allValuesHaveCssColour() {
|
||||||
|
for (GuiMessageSeverity severity : GuiMessageSeverity.values()) {
|
||||||
|
assertThat(severity.getPrefixCssColour())
|
||||||
|
.as("CSS colour for %s must be a hex colour string", severity)
|
||||||
|
.isNotBlank()
|
||||||
|
.startsWith("#");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allFourValuesPresent() {
|
||||||
|
assertThat(GuiMessageSeverity.values()).containsExactly(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
GuiMessageSeverity.HINT,
|
||||||
|
GuiMessageSeverity.WARNING,
|
||||||
|
GuiMessageSeverity.ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiModelSource}.
|
||||||
|
*/
|
||||||
|
class GuiModelSourceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allValuesPresent() {
|
||||||
|
assertThat(GuiModelSource.values()).containsExactlyInAnyOrder(
|
||||||
|
GuiModelSource.LIST_REMOTE_SUCCESS,
|
||||||
|
GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT,
|
||||||
|
GuiModelSource.LIST_FAILED_MANUAL_INPUT,
|
||||||
|
GuiModelSource.NOT_YET_LOADED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumLookupByName() {
|
||||||
|
assertThat(GuiModelSource.valueOf("LIST_REMOTE_SUCCESS")).isEqualTo(GuiModelSource.LIST_REMOTE_SUCCESS);
|
||||||
|
assertThat(GuiModelSource.valueOf("LIST_UNAVAILABLE_MANUAL_INPUT")).isEqualTo(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
|
assertThat(GuiModelSource.valueOf("LIST_FAILED_MANUAL_INPUT")).isEqualTo(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||||
|
assertThat(GuiModelSource.valueOf("NOT_YET_LOADED")).isEqualTo(GuiModelSource.NOT_YET_LOADED);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link GuiVisibleProviderSection}.
|
||||||
|
*/
|
||||||
|
class GuiVisibleProviderSectionTest {
|
||||||
|
|
||||||
|
private static final GuiProviderConfigurationState CLAUDE_STATE =
|
||||||
|
new GuiProviderConfigurationState("https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
GuiProviderApiKeyState.unresolved("claude-key"));
|
||||||
|
|
||||||
|
private static final GuiProviderConfigurationState OPENAI_STATE =
|
||||||
|
new GuiProviderConfigurationState("https://api.openai.com", "gpt-4o", "60",
|
||||||
|
GuiProviderApiKeyState.unresolved("openai-key"));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesAllFields() {
|
||||||
|
var section = new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
|
||||||
|
|
||||||
|
assertThat(section.visibleProvider()).isEqualTo(AiProviderFamily.CLAUDE);
|
||||||
|
assertThat(section.visibleProviderState()).isEqualTo(CLAUDE_STATE);
|
||||||
|
assertThat(section.hiddenProvider()).isEqualTo(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
assertThat(section.hiddenProviderState()).isEqualTo(OPENAI_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stateFor_returnsVisibleProviderState() {
|
||||||
|
var section = new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
|
||||||
|
|
||||||
|
assertThat(section.stateFor(AiProviderFamily.CLAUDE)).isEqualTo(CLAUDE_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stateFor_returnsHiddenProviderState() {
|
||||||
|
var section = new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
|
||||||
|
|
||||||
|
assertThat(section.stateFor(AiProviderFamily.OPENAI_COMPATIBLE)).isEqualTo(OPENAI_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void switchProvider_swapsVisibleAndHiddenWithoutLosingValues() {
|
||||||
|
var section = new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
|
||||||
|
|
||||||
|
var switched = section.switchProvider();
|
||||||
|
|
||||||
|
assertThat(switched.visibleProvider()).isEqualTo(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
assertThat(switched.visibleProviderState()).isEqualTo(OPENAI_STATE);
|
||||||
|
assertThat(switched.hiddenProvider()).isEqualTo(AiProviderFamily.CLAUDE);
|
||||||
|
assertThat(switched.hiddenProviderState()).isEqualTo(CLAUDE_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withVisibleProviderState_replacesOnlyVisibleState() {
|
||||||
|
var section = new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
|
||||||
|
|
||||||
|
var updatedState = GuiProviderConfigurationState.blank();
|
||||||
|
var updated = section.withVisibleProviderState(updatedState);
|
||||||
|
|
||||||
|
assertThat(updated.visibleProviderState()).isEqualTo(updatedState);
|
||||||
|
assertThat(updated.hiddenProviderState()).isEqualTo(OPENAI_STATE); // unchanged
|
||||||
|
assertThat(updated.visibleProvider()).isEqualTo(AiProviderFamily.CLAUDE);
|
||||||
|
assertThat(updated.hiddenProvider()).isEqualTo(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSameVisibleAndHiddenProvider() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.CLAUDE, OPENAI_STATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullVisibleProvider() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiVisibleProviderSection(
|
||||||
|
null, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullHiddenProvider() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
null, OPENAI_STATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stateFor_rejectsUnknownFamily() {
|
||||||
|
var section = new GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily.CLAUDE, CLAUDE_STATE,
|
||||||
|
AiProviderFamily.OPENAI_COMPATIBLE, OPENAI_STATE);
|
||||||
|
|
||||||
|
// Use a mock/non-existing provider — since we only have 2 values and both are used,
|
||||||
|
// we can test with a null check instead to verify the guard runs
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> section.stateFor(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter implementing {@link AiModelCatalogPort} for the native Anthropic Claude provider family.
|
||||||
|
* <p>
|
||||||
|
* Fetches the list of available Claude models from the Anthropic models endpoint
|
||||||
|
* ({@code GET {baseUrl}/v1/models}). Authentication uses the Anthropic-specific
|
||||||
|
* {@code x-api-key} header together with the {@code anthropic-version} header.
|
||||||
|
* <p>
|
||||||
|
* <strong>Default base URL:</strong> When the request carries no base URL,
|
||||||
|
* {@code https://api.anthropic.com} is used automatically.
|
||||||
|
* <p>
|
||||||
|
* <strong>Error handling:</strong> All expected error conditions (missing API key,
|
||||||
|
* HTTP errors, timeouts, parse failures) are returned as specific
|
||||||
|
* {@link ModelCatalogResult} sub-types. No exception is thrown to the caller.
|
||||||
|
* <p>
|
||||||
|
* <strong>Thread safety:</strong> This adapter is stateless. All configuration
|
||||||
|
* values are read from the {@link ModelCatalogRequest} at call time. Multiple
|
||||||
|
* threads may call {@link #fetchAvailableModels(ModelCatalogRequest)} concurrently
|
||||||
|
* without synchronisation.
|
||||||
|
* <p>
|
||||||
|
* <strong>Non-goals:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Retry logic — the caller is responsible for retry decisions.</li>
|
||||||
|
* <li>Caching — a fresh HTTP call is made on every invocation.</li>
|
||||||
|
* <li>Shared implementation with the OpenAI-compatible adapter.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
|
||||||
|
|
||||||
|
/** Anthropic models list endpoint path. */
|
||||||
|
private static final String MODELS_ENDPOINT = "/v1/models";
|
||||||
|
|
||||||
|
/** Default base URL for the Anthropic API, applied when the request carries no base URL. */
|
||||||
|
static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
|
||||||
|
|
||||||
|
/** Anthropic-specific authentication header. */
|
||||||
|
private static final String API_KEY_HEADER = "x-api-key";
|
||||||
|
|
||||||
|
/** Required Anthropic API version header name. */
|
||||||
|
private static final String ANTHROPIC_VERSION_HEADER = "anthropic-version";
|
||||||
|
|
||||||
|
/** Required Anthropic API version header value. */
|
||||||
|
private static final String ANTHROPIC_VERSION_VALUE = "2023-06-01";
|
||||||
|
|
||||||
|
private static final String PROVIDER_ID = "claude";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new stateless Claude model catalogue adapter.
|
||||||
|
* <p>
|
||||||
|
* No configuration is held in the instance. All request parameters are
|
||||||
|
* supplied per call via {@link ModelCatalogRequest}.
|
||||||
|
*/
|
||||||
|
public ClaudeModelCatalogAdapter() {
|
||||||
|
// stateless — no fields to initialise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of available Claude models from the Anthropic models endpoint.
|
||||||
|
* <p>
|
||||||
|
* The adapter:
|
||||||
|
* <ol>
|
||||||
|
* <li>Validates that the request carries a non-blank API key.</li>
|
||||||
|
* <li>Resolves the base URL (falls back to {@value #DEFAULT_BASE_URL}).</li>
|
||||||
|
* <li>Sends {@code GET {baseUrl}/v1/models} with Anthropic authentication headers.</li>
|
||||||
|
* <li>Maps HTTP 200 + non-empty {@code data} array to {@link ModelCatalogResult.Success}.</li>
|
||||||
|
* <li>Maps HTTP 200 + empty array to {@link ModelCatalogResult.EmptyList}.</li>
|
||||||
|
* <li>Maps HTTP 401 / 403 to {@link ModelCatalogResult.TechnicalFailure} with
|
||||||
|
* {@code AUTHENTICATION_FAILED}.</li>
|
||||||
|
* <li>Maps HTTP 404 to {@code ENDPOINT_NOT_FOUND}.</li>
|
||||||
|
* <li>Maps HTTP 5xx to {@code SERVER_ERROR}.</li>
|
||||||
|
* <li>Maps timeouts and connection failures to {@code CONNECTION_FAILURE}.</li>
|
||||||
|
* <li>Maps unparseable responses to {@code INVALID_RESPONSE}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param request all values needed to contact the provider; must not be {@code null}
|
||||||
|
* @return a non-{@code null} result encoding the outcome
|
||||||
|
* @throws NullPointerException if {@code request} is {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
|
||||||
|
java.util.Objects.requireNonNull(request, "request must not be null");
|
||||||
|
|
||||||
|
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
|
||||||
|
LOG.warn("Claude model catalogue: API key is missing – cannot fetch model list");
|
||||||
|
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
String apiKey = request.apiKey().get();
|
||||||
|
String baseUrl = request.baseUrl()
|
||||||
|
.filter(u -> !u.isBlank())
|
||||||
|
.orElse(DEFAULT_BASE_URL);
|
||||||
|
|
||||||
|
URI endpoint = buildEndpointUri(baseUrl);
|
||||||
|
HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(request.timeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
LOG.info("Claude model catalogue: fetching models from {}", endpoint);
|
||||||
|
try {
|
||||||
|
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
|
||||||
|
.header(API_KEY_HEADER, apiKey)
|
||||||
|
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
|
||||||
|
.GET()
|
||||||
|
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(httpRequest,
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
return handleResponse(response);
|
||||||
|
|
||||||
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
|
LOG.warn("Claude model catalogue: request timed out – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (java.net.ConnectException e) {
|
||||||
|
LOG.warn("Claude model catalogue: connection failed – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
|
} catch (java.net.UnknownHostException e) {
|
||||||
|
LOG.warn("Claude model catalogue: hostname not resolvable – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
LOG.warn("Claude model catalogue: IO error – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
LOG.warn("Claude model catalogue: request interrupted");
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Modellabruf wurde unterbrochen.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Claude model catalogue: unexpected error", e);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
||||||
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the HTTP response to the appropriate {@link ModelCatalogResult} sub-type.
|
||||||
|
*
|
||||||
|
* @param response the HTTP response from the Anthropic models endpoint
|
||||||
|
* @return the mapped result; never {@code null}
|
||||||
|
*/
|
||||||
|
private ModelCatalogResult handleResponse(HttpResponse<String> response) {
|
||||||
|
int status = response.statusCode();
|
||||||
|
|
||||||
|
if (status == 401 || status == 403) {
|
||||||
|
LOG.warn("Claude model catalogue: authentication failed – HTTP {}", status);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "AUTHENTICATION_FAILED",
|
||||||
|
"Authentifizierung fehlgeschlagen (HTTP " + status + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == 404) {
|
||||||
|
LOG.warn("Claude model catalogue: endpoint not found – HTTP 404");
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "ENDPOINT_NOT_FOUND",
|
||||||
|
"Endpunkt nicht gefunden (HTTP 404)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
LOG.warn("Claude model catalogue: server error – HTTP {}", status);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "SERVER_ERROR",
|
||||||
|
"Serverfehler beim Modellabruf (HTTP " + status + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != 200) {
|
||||||
|
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
||||||
|
"Unerwarteter HTTP-Status: " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseModelsResponse(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the JSON response body from the Anthropic models endpoint.
|
||||||
|
* <p>
|
||||||
|
* Expects a top-level {@code data} array where each element has an {@code id} field.
|
||||||
|
*
|
||||||
|
* @param responseBody the raw JSON response body
|
||||||
|
* @return {@link ModelCatalogResult.Success} or {@link ModelCatalogResult.EmptyList}
|
||||||
|
* on parse success; {@link ModelCatalogResult.TechnicalFailure} with
|
||||||
|
* {@code INVALID_RESPONSE} on parse failure
|
||||||
|
*/
|
||||||
|
private ModelCatalogResult parseModelsResponse(String responseBody) {
|
||||||
|
try {
|
||||||
|
JSONObject json = new JSONObject(responseBody);
|
||||||
|
JSONArray dataArray = json.getJSONArray("data");
|
||||||
|
|
||||||
|
List<String> modelIds = new ArrayList<>();
|
||||||
|
for (int i = 0; i < dataArray.length(); i++) {
|
||||||
|
JSONObject entry = dataArray.getJSONObject(i);
|
||||||
|
String id = entry.optString("id", "").trim();
|
||||||
|
if (!id.isEmpty()) {
|
||||||
|
modelIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelIds.isEmpty()) {
|
||||||
|
LOG.warn("Claude model catalogue: provider returned empty model list");
|
||||||
|
return new ModelCatalogResult.EmptyList(PROVIDER_ID, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Claude model catalogue: loaded {} model(s)", modelIds.size());
|
||||||
|
return new ModelCatalogResult.Success(PROVIDER_ID, modelIds, Instant.now());
|
||||||
|
|
||||||
|
} catch (JSONException e) {
|
||||||
|
LOG.warn("Claude model catalogue: response could not be parsed – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "INVALID_RESPONSE",
|
||||||
|
"Antwort konnte nicht verarbeitet werden: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full endpoint URI for the Anthropic models endpoint.
|
||||||
|
*
|
||||||
|
* @param baseUrl the resolved base URL (never blank)
|
||||||
|
* @return the complete endpoint URI
|
||||||
|
*/
|
||||||
|
private URI buildEndpointUri(String baseUrl) {
|
||||||
|
URI base = URI.create(baseUrl);
|
||||||
|
String path = base.getPath().replaceAll("/$", "") + MODELS_ENDPOINT;
|
||||||
|
return URI.create(base.getScheme() + "://"
|
||||||
|
+ base.getHost()
|
||||||
|
+ (base.getPort() > 0 ? ":" + base.getPort() : "")
|
||||||
|
+ path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-private factory method for test injection of a custom {@link HttpClient}.
|
||||||
|
* <p>
|
||||||
|
* <strong>For testing only.</strong> Allows tests to provide a mock or stub client
|
||||||
|
* without network access while exercising the full request-building and response-mapping
|
||||||
|
* logic.
|
||||||
|
*
|
||||||
|
* @param request the model catalogue request
|
||||||
|
* @param httpClient the HTTP client to use instead of creating a new one
|
||||||
|
* @return the mapped result
|
||||||
|
*/
|
||||||
|
ModelCatalogResult fetchAvailableModelsWithClient(ModelCatalogRequest request, HttpClient httpClient) {
|
||||||
|
java.util.Objects.requireNonNull(request, "request must not be null");
|
||||||
|
java.util.Objects.requireNonNull(httpClient, "httpClient must not be null");
|
||||||
|
|
||||||
|
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
|
||||||
|
LOG.warn("Claude model catalogue: API key is missing – cannot fetch model list");
|
||||||
|
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
String apiKey = request.apiKey().get();
|
||||||
|
String baseUrl = request.baseUrl()
|
||||||
|
.filter(u -> !u.isBlank())
|
||||||
|
.orElse(DEFAULT_BASE_URL);
|
||||||
|
|
||||||
|
URI endpoint = buildEndpointUri(baseUrl);
|
||||||
|
|
||||||
|
LOG.info("Claude model catalogue: fetching models from {} (test client)", endpoint);
|
||||||
|
try {
|
||||||
|
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
|
||||||
|
.header(API_KEY_HEADER, apiKey)
|
||||||
|
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
|
||||||
|
.GET()
|
||||||
|
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(httpRequest,
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
return handleResponse(response);
|
||||||
|
|
||||||
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (java.net.ConnectException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
|
} catch (java.net.UnknownHostException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Modellabruf wurde unterbrochen.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Claude model catalogue: unexpected error", e);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
||||||
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter implementing {@link AiModelCatalogPort} for the OpenAI-compatible provider family.
|
||||||
|
* <p>
|
||||||
|
* Fetches the list of available models from the OpenAI-compatible models endpoint
|
||||||
|
* ({@code GET {baseUrl}/v1/models}). Authentication uses the standard
|
||||||
|
* {@code Authorization: Bearer <apiKey>} header.
|
||||||
|
* <p>
|
||||||
|
* <strong>Default base URL:</strong> When the request carries no base URL,
|
||||||
|
* {@code https://api.openai.com} is used automatically.
|
||||||
|
* <p>
|
||||||
|
* <strong>Error handling:</strong> All expected error conditions (missing API key,
|
||||||
|
* HTTP errors, timeouts, parse failures) are returned as specific
|
||||||
|
* {@link ModelCatalogResult} sub-types. No exception is thrown to the caller.
|
||||||
|
* <p>
|
||||||
|
* <strong>Thread safety:</strong> This adapter is stateless. All configuration
|
||||||
|
* values are read from the {@link ModelCatalogRequest} at call time. Multiple
|
||||||
|
* threads may call {@link #fetchAvailableModels(ModelCatalogRequest)} concurrently
|
||||||
|
* without synchronisation.
|
||||||
|
* <p>
|
||||||
|
* <strong>Non-goals:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Retry logic — the caller is responsible for retry decisions.</li>
|
||||||
|
* <li>Caching — a fresh HTTP call is made on every invocation.</li>
|
||||||
|
* <li>Shared implementation with the Claude adapter.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
|
||||||
|
|
||||||
|
/** OpenAI-compatible models list endpoint path. */
|
||||||
|
private static final String MODELS_ENDPOINT = "/v1/models";
|
||||||
|
|
||||||
|
/** Default base URL for the OpenAI API, applied when the request carries no base URL. */
|
||||||
|
static final String DEFAULT_BASE_URL = "https://api.openai.com";
|
||||||
|
|
||||||
|
/** Standard OAuth2 bearer authorization header name. */
|
||||||
|
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||||
|
|
||||||
|
/** Bearer token prefix for the Authorization header. */
|
||||||
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
private static final String PROVIDER_ID = "openai-compatible";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new stateless OpenAI-compatible model catalogue adapter.
|
||||||
|
* <p>
|
||||||
|
* No configuration is held in the instance. All request parameters are
|
||||||
|
* supplied per call via {@link ModelCatalogRequest}.
|
||||||
|
*/
|
||||||
|
public OpenAiCompatibleModelCatalogAdapter() {
|
||||||
|
// stateless — no fields to initialise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of available models from the OpenAI-compatible models endpoint.
|
||||||
|
* <p>
|
||||||
|
* The adapter:
|
||||||
|
* <ol>
|
||||||
|
* <li>Validates that the request carries a non-blank API key.</li>
|
||||||
|
* <li>Resolves the base URL (falls back to {@value #DEFAULT_BASE_URL}).</li>
|
||||||
|
* <li>Sends {@code GET {baseUrl}/v1/models} with {@code Authorization: Bearer} header.</li>
|
||||||
|
* <li>Maps HTTP 200 + non-empty {@code data} array to {@link ModelCatalogResult.Success}.</li>
|
||||||
|
* <li>Maps HTTP 200 + empty array to {@link ModelCatalogResult.EmptyList}.</li>
|
||||||
|
* <li>Maps HTTP 401 / 403 to {@link ModelCatalogResult.TechnicalFailure} with
|
||||||
|
* {@code AUTHENTICATION_FAILED}.</li>
|
||||||
|
* <li>Maps HTTP 404 to {@code ENDPOINT_NOT_FOUND}.</li>
|
||||||
|
* <li>Maps HTTP 5xx to {@code SERVER_ERROR}.</li>
|
||||||
|
* <li>Maps timeouts and connection failures to {@code CONNECTION_FAILURE}.</li>
|
||||||
|
* <li>Maps unparseable responses to {@code INVALID_RESPONSE}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param request all values needed to contact the provider; must not be {@code null}
|
||||||
|
* @return a non-{@code null} result encoding the outcome
|
||||||
|
* @throws NullPointerException if {@code request} is {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
|
||||||
|
java.util.Objects.requireNonNull(request, "request must not be null");
|
||||||
|
|
||||||
|
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: API key is missing – cannot fetch model list");
|
||||||
|
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
String apiKey = request.apiKey().get();
|
||||||
|
String baseUrl = request.baseUrl()
|
||||||
|
.filter(u -> !u.isBlank())
|
||||||
|
.orElse(DEFAULT_BASE_URL);
|
||||||
|
|
||||||
|
URI endpoint = buildEndpointUri(baseUrl);
|
||||||
|
HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(request.timeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
LOG.info("OpenAI-compatible model catalogue: fetching models from {}", endpoint);
|
||||||
|
try {
|
||||||
|
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
|
||||||
|
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
|
||||||
|
.GET()
|
||||||
|
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(httpRequest,
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
return handleResponse(response);
|
||||||
|
|
||||||
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: request timed out – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (java.net.ConnectException e) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: connection failed – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
|
} catch (java.net.UnknownHostException e) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: IO error – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: request interrupted");
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Modellabruf wurde unterbrochen.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
||||||
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the HTTP response to the appropriate {@link ModelCatalogResult} sub-type.
|
||||||
|
*
|
||||||
|
* @param response the HTTP response from the OpenAI-compatible models endpoint
|
||||||
|
* @return the mapped result; never {@code null}
|
||||||
|
*/
|
||||||
|
private ModelCatalogResult handleResponse(HttpResponse<String> response) {
|
||||||
|
int status = response.statusCode();
|
||||||
|
|
||||||
|
if (status == 401 || status == 403) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: authentication failed – HTTP {}", status);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "AUTHENTICATION_FAILED",
|
||||||
|
"Authentifizierung fehlgeschlagen (HTTP " + status + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == 404) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: endpoint not found – HTTP 404");
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "ENDPOINT_NOT_FOUND",
|
||||||
|
"Endpunkt nicht gefunden (HTTP 404)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: server error – HTTP {}", status);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "SERVER_ERROR",
|
||||||
|
"Serverfehler beim Modellabruf (HTTP " + status + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != 200) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
||||||
|
"Unerwarteter HTTP-Status: " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseModelsResponse(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the JSON response body from the OpenAI-compatible models endpoint.
|
||||||
|
* <p>
|
||||||
|
* Expects a top-level {@code data} array where each element has an {@code id} field.
|
||||||
|
*
|
||||||
|
* @param responseBody the raw JSON response body
|
||||||
|
* @return {@link ModelCatalogResult.Success} or {@link ModelCatalogResult.EmptyList}
|
||||||
|
* on parse success; {@link ModelCatalogResult.TechnicalFailure} with
|
||||||
|
* {@code INVALID_RESPONSE} on parse failure
|
||||||
|
*/
|
||||||
|
private ModelCatalogResult parseModelsResponse(String responseBody) {
|
||||||
|
try {
|
||||||
|
JSONObject json = new JSONObject(responseBody);
|
||||||
|
JSONArray dataArray = json.getJSONArray("data");
|
||||||
|
|
||||||
|
List<String> modelIds = new ArrayList<>();
|
||||||
|
for (int i = 0; i < dataArray.length(); i++) {
|
||||||
|
JSONObject entry = dataArray.getJSONObject(i);
|
||||||
|
String id = entry.optString("id", "").trim();
|
||||||
|
if (!id.isEmpty()) {
|
||||||
|
modelIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelIds.isEmpty()) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: provider returned empty model list");
|
||||||
|
return new ModelCatalogResult.EmptyList(PROVIDER_ID, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("OpenAI-compatible model catalogue: loaded {} model(s)", modelIds.size());
|
||||||
|
return new ModelCatalogResult.Success(PROVIDER_ID, modelIds, Instant.now());
|
||||||
|
|
||||||
|
} catch (JSONException e) {
|
||||||
|
LOG.warn("OpenAI-compatible model catalogue: response could not be parsed – {}", e.getMessage());
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "INVALID_RESPONSE",
|
||||||
|
"Antwort konnte nicht verarbeitet werden: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full endpoint URI for the OpenAI-compatible models endpoint.
|
||||||
|
*
|
||||||
|
* @param baseUrl the resolved base URL (never blank)
|
||||||
|
* @return the complete endpoint URI
|
||||||
|
*/
|
||||||
|
private URI buildEndpointUri(String baseUrl) {
|
||||||
|
URI base = URI.create(baseUrl);
|
||||||
|
String path = base.getPath().replaceAll("/$", "") + MODELS_ENDPOINT;
|
||||||
|
return URI.create(base.getScheme() + "://"
|
||||||
|
+ base.getHost()
|
||||||
|
+ (base.getPort() > 0 ? ":" + base.getPort() : "")
|
||||||
|
+ path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-private factory method for test injection of a custom {@link HttpClient}.
|
||||||
|
* <p>
|
||||||
|
* <strong>For testing only.</strong> Allows tests to provide a mock or stub client
|
||||||
|
* without network access while exercising the full request-building and response-mapping
|
||||||
|
* logic.
|
||||||
|
*
|
||||||
|
* @param request the model catalogue request
|
||||||
|
* @param httpClient the HTTP client to use instead of creating a new one
|
||||||
|
* @return the mapped result
|
||||||
|
*/
|
||||||
|
ModelCatalogResult fetchAvailableModelsWithClient(ModelCatalogRequest request, HttpClient httpClient) {
|
||||||
|
java.util.Objects.requireNonNull(request, "request must not be null");
|
||||||
|
java.util.Objects.requireNonNull(httpClient, "httpClient must not be null");
|
||||||
|
|
||||||
|
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
|
||||||
|
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
String apiKey = request.apiKey().get();
|
||||||
|
String baseUrl = request.baseUrl()
|
||||||
|
.filter(u -> !u.isBlank())
|
||||||
|
.orElse(DEFAULT_BASE_URL);
|
||||||
|
|
||||||
|
URI endpoint = buildEndpointUri(baseUrl);
|
||||||
|
|
||||||
|
LOG.info("OpenAI-compatible model catalogue: fetching models from {} (test client)", endpoint);
|
||||||
|
try {
|
||||||
|
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
|
||||||
|
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
|
||||||
|
.GET()
|
||||||
|
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(httpRequest,
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
return handleResponse(response);
|
||||||
|
|
||||||
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (java.net.ConnectException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
|
} catch (java.net.UnknownHostException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
||||||
|
"Modellabruf wurde unterbrochen.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
|
||||||
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
||||||
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Adapter implementations for the {@code AiModelCatalogPort} outbound port.
|
||||||
|
* <p>
|
||||||
|
* This package contains one concrete adapter per supported AI provider family.
|
||||||
|
* Each adapter translates a {@code ModelCatalogRequest} into a provider-specific
|
||||||
|
* HTTP request, maps the response to a {@code ModelCatalogResult} sub-type, and
|
||||||
|
* never throws checked or runtime exceptions for expected error conditions.
|
||||||
|
* <p>
|
||||||
|
* Provider-specific HTTP details (endpoints, authentication schemes, response
|
||||||
|
* structures) are encapsulated entirely within the respective adapter class.
|
||||||
|
* The Application and GUI layers remain free of any provider knowledge.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateisystem-basierte Implementierung von {@link PathCheckPort}.
|
||||||
|
* <p>
|
||||||
|
* Prüft die Zugänglichkeit von Pfaden für Quellordner, Zielordner, SQLite-Datei
|
||||||
|
* und Prompt-Datei ausschließlich lesend. Es werden keinerlei Dateien, Ordner oder
|
||||||
|
* andere Ressourcen angelegt, verändert oder gelöscht.
|
||||||
|
*
|
||||||
|
* <h2>Windows- und Netzlaufwerk-Unterstützung</h2>
|
||||||
|
* <p>
|
||||||
|
* Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
|
||||||
|
* akzeptiert. Solche Pfade werden nicht allein deshalb abgelehnt, weil dahinter technisch
|
||||||
|
* ein UNC-Pfad stehen könnte. Maßgeblich ist, dass Windows den Pfad als gültig bereitstellt.
|
||||||
|
* UNC-Pfade ({@code \\server\share\...}) werden ebenfalls akzeptiert, sofern das
|
||||||
|
* Betriebssystem sie direkt auflösen kann. Es findet keine Umdeutung zwischen gemappten
|
||||||
|
* Laufwerksbuchstaben und UNC-Pfaden statt.
|
||||||
|
* <p>
|
||||||
|
* Die Implementierung nutzt {@link Paths#get(String)}, {@link Files#exists(Path, java.nio.file.LinkOption...)},
|
||||||
|
* {@link Files#isReadable(Path)} und {@link Files#isWritable(Path)}, die unter Windows
|
||||||
|
* gemappte Laufwerke korrekt respektieren.
|
||||||
|
*
|
||||||
|
* <h2>Thread-Safety</h2>
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist zustandslos und damit thread-safe. Jede Methode kann gleichzeitig
|
||||||
|
* von mehreren Threads aufgerufen werden. Der Aufrufer ist dafür verantwortlich, die
|
||||||
|
* Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
|
||||||
|
* blockierend sein kann.
|
||||||
|
*
|
||||||
|
* <h2>Fehlerbehandlung</h2>
|
||||||
|
* <p>
|
||||||
|
* Erwartete Fehlerbedingungen (Pfad nicht vorhanden, keine Leseberechtigung) werden
|
||||||
|
* als {@code boolean}-Rückgabewert kommuniziert. Unerwartete technische Fehler werden
|
||||||
|
* geloggt und als {@code false} zurückgegeben.
|
||||||
|
*/
|
||||||
|
public class FilesystemPathCheckAdapter implements PathCheckPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(FilesystemPathCheckAdapter.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen {@code FilesystemPathCheckAdapter}.
|
||||||
|
*/
|
||||||
|
public FilesystemPathCheckAdapter() {
|
||||||
|
// stateless — kein Zustand zu initialisieren
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
|
||||||
|
* kein Verzeichnis oder nicht lesbar ist.
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn der Ordner existiert und gelesen werden kann
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isDirectoryReadable(String path) {
|
||||||
|
LOG.debug("Prüfe Ordner auf Lesbarkeit: {}", path);
|
||||||
|
Path resolved = toPath(path);
|
||||||
|
if (resolved == null) {
|
||||||
|
LOG.warn("Ordner-Lesbarkeit: ungültiger Pfad: {}", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean result = Files.exists(resolved)
|
||||||
|
&& Files.isDirectory(resolved)
|
||||||
|
&& Files.isReadable(resolved);
|
||||||
|
if (result) {
|
||||||
|
LOG.debug("Ordner lesbar: {}", resolved);
|
||||||
|
} else {
|
||||||
|
LOG.warn("Ordner nicht lesbar oder nicht vorhanden: {}", resolved);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
|
||||||
|
* oder ob dieser Ordner technisch anlegbar wäre.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code true} zurück, wenn:
|
||||||
|
* <ul>
|
||||||
|
* <li>der Ordner existiert und schreibbar ist, oder</li>
|
||||||
|
* <li>der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und
|
||||||
|
* schreibbar ist (anlegbar).</li>
|
||||||
|
* </ul>
|
||||||
|
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, der Ordner
|
||||||
|
* existiert aber nicht schreibbar ist, oder weder der Ordner noch ein schreibbarer
|
||||||
|
* Elternpfad vorhanden ist.
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isDirectoryWritableOrCreatable(String path) {
|
||||||
|
LOG.debug("Prüfe Ordner auf Schreibbarkeit oder Anlegbarkeit: {}", path);
|
||||||
|
Path resolved = toPath(path);
|
||||||
|
if (resolved == null) {
|
||||||
|
LOG.warn("Ordner-Schreibbarkeit: ungültiger Pfad: {}", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Files.exists(resolved)) {
|
||||||
|
boolean writable = Files.isDirectory(resolved) && Files.isWritable(resolved);
|
||||||
|
if (writable) {
|
||||||
|
LOG.debug("Ordner vorhanden und schreibbar: {}", resolved);
|
||||||
|
} else {
|
||||||
|
LOG.warn("Ordner vorhanden, aber nicht schreibbar: {}", resolved);
|
||||||
|
}
|
||||||
|
return writable;
|
||||||
|
}
|
||||||
|
// Ordner existiert nicht — prüfen ob Elternpfad schreibbar ist
|
||||||
|
Path parent = resolved.getParent();
|
||||||
|
if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
|
||||||
|
LOG.debug("Ordner nicht vorhanden, aber anlegbar (Elternpfad schreibbar): {}", resolved);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
LOG.warn("Ordner nicht vorhanden und nicht anlegbar: {}", resolved);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
|
||||||
|
* kein reguläres File oder nicht lesbar ist.
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn die Datei existiert und gelesen werden kann
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isFileReadable(String path) {
|
||||||
|
LOG.debug("Prüfe Datei auf Lesbarkeit: {}", path);
|
||||||
|
Path resolved = toPath(path);
|
||||||
|
if (resolved == null) {
|
||||||
|
LOG.warn("Datei-Lesbarkeit: ungültiger Pfad: {}", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean result = Files.exists(resolved)
|
||||||
|
&& Files.isRegularFile(resolved)
|
||||||
|
&& Files.isReadable(resolved);
|
||||||
|
if (result) {
|
||||||
|
LOG.debug("Datei lesbar: {}", resolved);
|
||||||
|
} else {
|
||||||
|
LOG.warn("Datei nicht lesbar oder nicht vorhanden: {}", resolved);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code true} zurück, wenn:
|
||||||
|
* <ul>
|
||||||
|
* <li>die Datei existiert, les- und schreibbar ist, oder</li>
|
||||||
|
* <li>die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden
|
||||||
|
* und schreibbar ist (Datei wäre anlegbar).</li>
|
||||||
|
* </ul>
|
||||||
|
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, die Datei
|
||||||
|
* existiert aber nicht nutzbar ist, oder weder die Datei noch ein beschreibbarer
|
||||||
|
* Elternordner vorhanden ist.
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isSqlitePathUsable(String path) {
|
||||||
|
LOG.debug("Prüfe SQLite-Pfad auf Nutzbarkeit: {}", path);
|
||||||
|
Path resolved = toPath(path);
|
||||||
|
if (resolved == null) {
|
||||||
|
LOG.warn("SQLite-Pfad: ungültiger Pfad: {}", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Files.exists(resolved)) {
|
||||||
|
boolean usable = Files.isRegularFile(resolved)
|
||||||
|
&& Files.isReadable(resolved)
|
||||||
|
&& Files.isWritable(resolved);
|
||||||
|
if (usable) {
|
||||||
|
LOG.debug("SQLite-Datei vorhanden und nutzbar: {}", resolved);
|
||||||
|
} else {
|
||||||
|
LOG.warn("SQLite-Datei vorhanden, aber nicht les- und schreibbar: {}", resolved);
|
||||||
|
}
|
||||||
|
return usable;
|
||||||
|
}
|
||||||
|
// Datei existiert nicht — prüfen ob Elternordner schreibbar ist
|
||||||
|
Path parent = resolved.getParent();
|
||||||
|
if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
|
||||||
|
LOG.debug("SQLite-Datei nicht vorhanden, aber anlegbar (Elternordner schreibbar): {}", resolved);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
LOG.warn("SQLite-Pfad nicht nutzbar und nicht anlegbar: {}", resolved);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist
|
||||||
|
* (z. B. wegen ungültiger Zeichen auf Windows). Keine Ausnahme wird geworfen.
|
||||||
|
*
|
||||||
|
* @param path der zu konvertierende Pfad-String
|
||||||
|
* @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
|
||||||
|
*/
|
||||||
|
private static Path toPath(String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Paths.get(path);
|
||||||
|
} catch (InvalidPathException e) {
|
||||||
|
LOG.warn("Pfad nicht parsebar: '{}' — {}", path, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Adapter für Dateisystem-basierte Pfadprüfungen.
|
||||||
|
* <p>
|
||||||
|
* Dieses Paket enthält die konkrete Implementierung des {@code PathCheckPort} auf Basis
|
||||||
|
* der JDK-NIO-Dateisystem-API. Es unterstützt ausdrücklich Windows-Pfade mit gemappten
|
||||||
|
* Laufwerksbuchstaben (z. B. {@code S:\}, {@code H:\}) sowie UNC-Pfade.
|
||||||
|
* <p>
|
||||||
|
* Alle Klassen in diesem Paket sind rein lesend und nehmen keinerlei schreibende
|
||||||
|
* Änderungen am Dateisystem vor.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.FileAlreadyExistsException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateisystem-basierte Implementierung von {@link ResourceCreationPort}.
|
||||||
|
* <p>
|
||||||
|
* Führt schreibende technische Korrekturmaßnahmen durch: Ordner anlegen,
|
||||||
|
* SQLite-Elternordner vorbereiten und Prompt-Dateien mit übergebenem Inhalt erzeugen.
|
||||||
|
* Alle Methoden sind idempotent, sofern die Ziel-Ressource bereits vorhanden ist.
|
||||||
|
*
|
||||||
|
* <h2>Windows- und Netzlaufwerk-Unterstützung</h2>
|
||||||
|
* <p>
|
||||||
|
* Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
|
||||||
|
* akzeptiert. Die Implementierung nutzt ausschließlich {@link Paths#get(String)} und
|
||||||
|
* {@link Files}-Methoden, die unter Windows gemappte Laufwerke korrekt respektieren.
|
||||||
|
*
|
||||||
|
* <h2>Thread-Safety</h2>
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist zustandslos und thread-safe. Der Aufrufer ist verantwortlich dafür,
|
||||||
|
* Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
|
||||||
|
* blockierend sein kann.
|
||||||
|
*
|
||||||
|
* <h2>Fehlerbehandlung</h2>
|
||||||
|
* <p>
|
||||||
|
* Jede Methode fängt alle technischen Ausnahmen und gibt ein entsprechendes
|
||||||
|
* {@link CorrectionOutcome.Failed}-Ergebnis zurück. Es werden keine geprüften
|
||||||
|
* Ausnahmen an den Aufrufer weitergegeben.
|
||||||
|
*/
|
||||||
|
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen {@code FilesystemResourceCreationAdapter}.
|
||||||
|
*/
|
||||||
|
public FilesystemResourceCreationAdapter() {
|
||||||
|
// zustandslos — kein Zustand zu initialisieren
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
|
||||||
|
* <p>
|
||||||
|
* Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} zurückgegeben
|
||||||
|
* (idempotente Ausführung). Die Aktion wird mit Zielpfad geloggt.
|
||||||
|
*
|
||||||
|
* @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis der Ausführung; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
|
||||||
|
Path path = toPath(suggestion.path());
|
||||||
|
if (path == null) {
|
||||||
|
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||||
|
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
if (Files.isDirectory(path)) {
|
||||||
|
LOG.info("Ordner bereits vorhanden (kein Anlegen nötig): {}", path);
|
||||||
|
return new CorrectionOutcome.Applied(suggestion,
|
||||||
|
"Ordner bereits vorhanden: " + path.toAbsolutePath());
|
||||||
|
} else {
|
||||||
|
String msg = "Pfad existiert bereits als Datei (kein Ordner): " + path.toAbsolutePath();
|
||||||
|
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Files.createDirectories(path);
|
||||||
|
LOG.info("Ordner erfolgreich angelegt: {}", path.toAbsolutePath());
|
||||||
|
return new CorrectionOutcome.Applied(suggestion,
|
||||||
|
"Ordner angelegt: " + path.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
String msg = "Ordner konnte nicht angelegt werden: " + e.getMessage();
|
||||||
|
LOG.warn("Ordner anlegen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine neue Prompt-Datei mit dem übergebenen Inhalt.
|
||||||
|
* <p>
|
||||||
|
* Die Datei wird nur erzeugt, wenn sie noch nicht existiert. Falls die Datei bereits
|
||||||
|
* vorhanden ist, wird {@link CorrectionOutcome.NotAttempted} zurückgegeben (kein
|
||||||
|
* stilles Überschreiben). Der Inhalt wird als UTF-8-Text geschrieben.
|
||||||
|
* Die Aktion wird mit Zielpfad geloggt.
|
||||||
|
* <p>
|
||||||
|
* Der Inhalt der erzeugten Datei wird von {@link DefaultPromptTemplate#defaultContent()} geliefert.
|
||||||
|
* Es handelt sich um einen deutschen Standardprompt, der ohne weitere Anpassung funktioniert.
|
||||||
|
*
|
||||||
|
* @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis der Ausführung; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
Path path = toPath(suggestion.path());
|
||||||
|
if (path == null) {
|
||||||
|
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||||
|
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
String msg = "Prompt-Datei bereits vorhanden – kein Überschreiben: " + path.toAbsolutePath();
|
||||||
|
LOG.info("Prompt-Datei erzeugen: Datei bereits vorhanden, wird nicht überschrieben: {}", path);
|
||||||
|
return new CorrectionOutcome.NotAttempted(suggestion, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elternordner sicherstellen
|
||||||
|
Path parent = path.getParent();
|
||||||
|
if (parent != null && !Files.exists(parent)) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
LOG.info("Prompt-Datei: Elternordner angelegt: {}", parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.writeString(path, DefaultPromptTemplate.defaultContent(), StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||||
|
LOG.info("Prompt-Datei erfolgreich erzeugt: {}", path.toAbsolutePath());
|
||||||
|
return new CorrectionOutcome.Applied(suggestion,
|
||||||
|
"Prompt-Datei erzeugt: " + path.toAbsolutePath());
|
||||||
|
} catch (FileAlreadyExistsException e) {
|
||||||
|
String msg = "Prompt-Datei bereits vorhanden – kein Überschreiben: " + path.toAbsolutePath();
|
||||||
|
LOG.info("Prompt-Datei erzeugen: race condition – Datei bereits vorhanden: {}", path);
|
||||||
|
return new CorrectionOutcome.NotAttempted(suggestion, msg);
|
||||||
|
} catch (IOException e) {
|
||||||
|
String msg = "Prompt-Datei konnte nicht erzeugt werden: " + e.getMessage();
|
||||||
|
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser fehlt.
|
||||||
|
* <p>
|
||||||
|
* Legt den Elternordner der SQLite-Datei mit allen fehlenden Zwischenordnern an,
|
||||||
|
* falls er noch nicht vorhanden ist. Die SQLite-Datei selbst wird nicht erzeugt;
|
||||||
|
* das übernimmt das JDBC-Layer beim ersten Datenbankzugriff. Die Aktion wird geloggt.
|
||||||
|
*
|
||||||
|
* @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis der Ausführung; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||||
|
Path path = toPath(suggestion.path());
|
||||||
|
if (path == null) {
|
||||||
|
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||||
|
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path parent = path.getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
// Datei liegt direkt im Wurzelverzeichnis — kein Elternordner anlegbar
|
||||||
|
LOG.info("SQLite-Pfad: kein Elternordner vorhanden (Wurzelpfad): {}", path);
|
||||||
|
return new CorrectionOutcome.Applied(suggestion,
|
||||||
|
"SQLite-Pfad liegt im Wurzelverzeichnis, kein Ordner anzulegen: " + path.toAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Files.exists(parent)) {
|
||||||
|
LOG.info("SQLite-Elternordner bereits vorhanden: {}", parent);
|
||||||
|
return new CorrectionOutcome.Applied(suggestion,
|
||||||
|
"SQLite-Elternordner bereits vorhanden: " + parent.toAbsolutePath());
|
||||||
|
}
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
LOG.info("SQLite-Elternordner erfolgreich angelegt: {}", parent.toAbsolutePath());
|
||||||
|
return new CorrectionOutcome.Applied(suggestion,
|
||||||
|
"SQLite-Elternordner angelegt: " + parent.toAbsolutePath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
String msg = "SQLite-Elternordner konnte nicht angelegt werden: " + e.getMessage();
|
||||||
|
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {} — {}", parent, e.getMessage(), e);
|
||||||
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist.
|
||||||
|
*
|
||||||
|
* @param pathString der zu konvertierende Pfad-String
|
||||||
|
* @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
|
||||||
|
*/
|
||||||
|
private static Path toPath(String pathString) {
|
||||||
|
if (pathString == null || pathString.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Paths.get(pathString);
|
||||||
|
} catch (InvalidPathException e) {
|
||||||
|
LOG.warn("Pfad nicht parsebar: '{}' — {}", pathString, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Adapter für schreibende technische Korrekturmaßnahmen am Dateisystem.
|
||||||
|
* <p>
|
||||||
|
* Implementiert den {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort}
|
||||||
|
* über direkten Dateisystemzugriff. Alle Operationen sind schreibend und dürfen nur nach
|
||||||
|
* ausdrücklicher Benutzerbestätigung eines
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan} aufgerufen werden.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementierung des {@link ApiKeyResolutionPort}, die Umgebungsvariablen aus der
|
||||||
|
* Systemprozessumgebung liest und den effektiven API-Key-Herkunftsdeskriptor zurückgibt.
|
||||||
|
* <p>
|
||||||
|
* Die Vorrangregel lautet:
|
||||||
|
* <ol>
|
||||||
|
* <li>Providerspezifische Umgebungsvariable ({@code ANTHROPIC_API_KEY} für Claude,
|
||||||
|
* {@code OPENAI_COMPATIBLE_API_KEY} für OpenAI-kompatibel)</li>
|
||||||
|
* <li>Bei {@code openai-compatible}: Legacy-Variable {@code PDF_UMBENENNER_API_KEY}</li>
|
||||||
|
* <li>Property-Wert aus dem Editor-Feld ({@code propertyValue})</li>
|
||||||
|
* <li>{@code ABSENT}, wenn keine Quelle einen Wert liefert</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Verwendet dieselben ENV-Variablen-Namen wie der bestehende headless Konfigurationspfad, so dass
|
||||||
|
* GUI-Validierung und headless Bootstrap identisch auflösen.
|
||||||
|
* <p>
|
||||||
|
* Für Tests kann der Umgebungslookup injiziert werden, ohne den echten Prozess-Umgebungszustand
|
||||||
|
* zu verändern.
|
||||||
|
*/
|
||||||
|
public class EnvironmentApiKeyResolutionAdapter implements ApiKeyResolutionPort {
|
||||||
|
|
||||||
|
/** Providerspezifische Umgebungsvariable für den Claude-Provider. */
|
||||||
|
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
|
||||||
|
|
||||||
|
/** Providerspezifische Umgebungsvariable für den OpenAI-kompatiblen Provider. */
|
||||||
|
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
|
||||||
|
|
||||||
|
/** Legacy-Umgebungsvariable für den OpenAI-kompatiblen Provider (Rückwärtskompatibilität). */
|
||||||
|
static final String ENV_LEGACY_OPENAI_API_KEY = "PDF_UMBENENNER_API_KEY";
|
||||||
|
|
||||||
|
private final Function<String, String> environmentLookup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Adapter, der die echte Prozessumgebung liest.
|
||||||
|
*/
|
||||||
|
public EnvironmentApiKeyResolutionAdapter() {
|
||||||
|
this(System::getenv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Adapter mit einem injizierten Umgebungslookup.
|
||||||
|
* <p>
|
||||||
|
* Dieser Konstruktor erlaubt deterministisches Testen ohne Änderungen am echten
|
||||||
|
* Prozess-Umgebungszustand.
|
||||||
|
*
|
||||||
|
* @param environmentLookup Funktion zum Lesen von Umgebungsvariablen; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
EnvironmentApiKeyResolutionAdapter(Function<String, String> environmentLookup) {
|
||||||
|
this.environmentLookup = Objects.requireNonNull(environmentLookup,
|
||||||
|
"environmentLookup must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
|
||||||
|
*
|
||||||
|
* @param family die Provider-Familie; darf nicht {@code null} sein
|
||||||
|
* @param propertyValue aktueller Property-Wert aus dem Editor; darf nicht {@code null} sein
|
||||||
|
* @return der Herkunftsdeskriptor; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
Objects.requireNonNull(propertyValue, "propertyValue must not be null");
|
||||||
|
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> resolveClaude(propertyValue);
|
||||||
|
case OPENAI_COMPATIBLE -> resolveOpenAiCompatible(propertyValue);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private EffectiveApiKeyDescriptor resolveClaude(String propertyValue) {
|
||||||
|
String envValue = environmentLookup.apply(ENV_CLAUDE_API_KEY);
|
||||||
|
if (isPresent(envValue)) {
|
||||||
|
return EffectiveApiKeyDescriptor.fromProviderEnvVar(ENV_CLAUDE_API_KEY);
|
||||||
|
}
|
||||||
|
if (isPresent(propertyValue)) {
|
||||||
|
return EffectiveApiKeyDescriptor.fromPropertyFile();
|
||||||
|
}
|
||||||
|
return EffectiveApiKeyDescriptor.absent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EffectiveApiKeyDescriptor resolveOpenAiCompatible(String propertyValue) {
|
||||||
|
String primaryEnv = environmentLookup.apply(ENV_OPENAI_API_KEY);
|
||||||
|
if (isPresent(primaryEnv)) {
|
||||||
|
return EffectiveApiKeyDescriptor.fromProviderEnvVar(ENV_OPENAI_API_KEY);
|
||||||
|
}
|
||||||
|
String legacyEnv = environmentLookup.apply(ENV_LEGACY_OPENAI_API_KEY);
|
||||||
|
if (isPresent(legacyEnv)) {
|
||||||
|
return EffectiveApiKeyDescriptor.fromLegacyEnvVar(ENV_LEGACY_OPENAI_API_KEY);
|
||||||
|
}
|
||||||
|
if (isPresent(propertyValue)) {
|
||||||
|
return EffectiveApiKeyDescriptor.fromPropertyFile();
|
||||||
|
}
|
||||||
|
return EffectiveApiKeyDescriptor.absent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPresent(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Adapter-Out-Implementierungen für Validierungsinfrastruktur.
|
||||||
|
* <p>
|
||||||
|
* Dieses Package enthält konkrete Implementierungen der Outbound-Ports,
|
||||||
|
* die für die editornahe Konfigurationsvalidierung benötigt werden, insbesondere
|
||||||
|
* die Auflösung der API-Key-Herkunft aus Umgebungsvariablen.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link ClaudeModelCatalogAdapter}.
|
||||||
|
* <p>
|
||||||
|
* All tests inject a mock {@link HttpClient} via the package-private
|
||||||
|
* {@code fetchAvailableModelsWithClient} method to avoid network access.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ClaudeModelCatalogAdapterTest {
|
||||||
|
|
||||||
|
private static final String API_KEY = "test-api-key";
|
||||||
|
private static final String BASE_URL = "http://localhost:9999";
|
||||||
|
private static final int TIMEOUT = 5;
|
||||||
|
private static final String PROVIDER_ID = "claude";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HttpClient httpClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private HttpResponse<String> httpResponse;
|
||||||
|
|
||||||
|
private ClaudeModelCatalogAdapter adapter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new ClaudeModelCatalogAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 200 with non-empty model list returns Success")
|
||||||
|
void fetchModels_http200WithModels_returnsSuccess() throws Exception {
|
||||||
|
String responseBody = """
|
||||||
|
{"data":[{"id":"claude-3-opus","type":"model"},{"id":"claude-3-sonnet","type":"model"}]}
|
||||||
|
""";
|
||||||
|
doReturn(200).when(httpResponse).statusCode();
|
||||||
|
doReturn(responseBody).when(httpResponse).body();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.Success.class);
|
||||||
|
ModelCatalogResult.Success success = (ModelCatalogResult.Success) result;
|
||||||
|
assertThat(success.models()).containsExactly("claude-3-opus", "claude-3-sonnet");
|
||||||
|
assertThat(success.providerIdentifier()).isEqualTo(PROVIDER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 200 with empty data array returns EmptyList")
|
||||||
|
void fetchModels_http200EmptyDataArray_returnsEmptyList() throws Exception {
|
||||||
|
String responseBody = """
|
||||||
|
{"data":[]}
|
||||||
|
""";
|
||||||
|
doReturn(200).when(httpResponse).statusCode();
|
||||||
|
doReturn(responseBody).when(httpResponse).body();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.EmptyList.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 401 returns TechnicalFailure with AUTHENTICATION_FAILED")
|
||||||
|
void fetchModels_http401_returnsAuthenticationFailed() throws Exception {
|
||||||
|
doReturn(401).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
ModelCatalogResult.TechnicalFailure failure = (ModelCatalogResult.TechnicalFailure) result;
|
||||||
|
assertThat(failure.errorCategory()).isEqualTo("AUTHENTICATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 403 returns TechnicalFailure with AUTHENTICATION_FAILED")
|
||||||
|
void fetchModels_http403_returnsAuthenticationFailed() throws Exception {
|
||||||
|
doReturn(403).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("AUTHENTICATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 404 returns TechnicalFailure with ENDPOINT_NOT_FOUND")
|
||||||
|
void fetchModels_http404_returnsEndpointNotFound() throws Exception {
|
||||||
|
doReturn(404).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("ENDPOINT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 500 returns TechnicalFailure with SERVER_ERROR")
|
||||||
|
void fetchModels_http500_returnsServerError() throws Exception {
|
||||||
|
doReturn(500).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("SERVER_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectException returns TechnicalFailure with CONNECTION_FAILURE")
|
||||||
|
void fetchModels_connectException_returnsConnectionFailure() throws Exception {
|
||||||
|
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
|
||||||
|
.thenThrow(new ConnectException("connection refused"));
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("CONNECTION_FAILURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Unparseable JSON response returns TechnicalFailure with INVALID_RESPONSE")
|
||||||
|
void fetchModels_invalidJson_returnsInvalidResponse() throws Exception {
|
||||||
|
doReturn(200).when(httpResponse).statusCode();
|
||||||
|
doReturn("this is not json at all!!!").when(httpResponse).body();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("INVALID_RESPONSE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Missing API key returns IncompleteConfiguration")
|
||||||
|
void fetchModels_missingApiKey_returnsIncompleteConfiguration() {
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.empty(), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
|
||||||
|
ModelCatalogResult.IncompleteConfiguration incomplete =
|
||||||
|
(ModelCatalogResult.IncompleteConfiguration) result;
|
||||||
|
assertThat(incomplete.missingReason()).containsIgnoringCase("API-Schlüssel");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Blank API key returns IncompleteConfiguration")
|
||||||
|
void fetchModels_blankApiKey_returnsIncompleteConfiguration() {
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(" "), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("No base URL falls back to Anthropic default")
|
||||||
|
void fetchModels_noBaseUrl_usesDefault() {
|
||||||
|
// Verify that no exception is thrown when building the URI with no base URL.
|
||||||
|
// The adapter should fall back to https://api.anthropic.com without crashing.
|
||||||
|
// (We don't send a real HTTP request here; we just verify the adapter doesn't throw
|
||||||
|
// during the URI construction phase before the send call.)
|
||||||
|
assertThat(ClaudeModelCatalogAdapter.DEFAULT_BASE_URL).isEqualTo("https://api.anthropic.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("UnknownHostException returns TechnicalFailure with CONNECTION_FAILURE")
|
||||||
|
void fetchModels_unknownHost_returnsConnectionFailure() throws Exception {
|
||||||
|
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
|
||||||
|
.thenThrow(new UnknownHostException("unknown host"));
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("CONNECTION_FAILURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HttpTimeoutException returns TechnicalFailure with CONNECTION_FAILURE")
|
||||||
|
void fetchModels_httpTimeout_returnsConnectionFailure() throws Exception {
|
||||||
|
// The adapter groups HTTP timeouts together with other connection failures under
|
||||||
|
// the CONNECTION_FAILURE category (no separate TIMEOUT category).
|
||||||
|
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
|
||||||
|
.thenThrow(new HttpTimeoutException("request timed out"));
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("CONNECTION_FAILURE");
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link OpenAiCompatibleModelCatalogAdapter}.
|
||||||
|
* <p>
|
||||||
|
* All tests inject a mock {@link HttpClient} via the package-private
|
||||||
|
* {@code fetchAvailableModelsWithClient} method to avoid network access.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class OpenAiCompatibleModelCatalogAdapterTest {
|
||||||
|
|
||||||
|
private static final String API_KEY = "test-openai-key";
|
||||||
|
private static final String BASE_URL = "http://localhost:8888";
|
||||||
|
private static final int TIMEOUT = 5;
|
||||||
|
private static final String PROVIDER_ID = "openai-compatible";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HttpClient httpClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private HttpResponse<String> httpResponse;
|
||||||
|
|
||||||
|
private OpenAiCompatibleModelCatalogAdapter adapter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new OpenAiCompatibleModelCatalogAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 200 with non-empty model list returns Success")
|
||||||
|
void fetchModels_http200WithModels_returnsSuccess() throws Exception {
|
||||||
|
String responseBody = """
|
||||||
|
{"object":"list","data":[{"id":"gpt-4o","object":"model"},{"id":"gpt-4","object":"model"}]}
|
||||||
|
""";
|
||||||
|
doReturn(200).when(httpResponse).statusCode();
|
||||||
|
doReturn(responseBody).when(httpResponse).body();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.Success.class);
|
||||||
|
ModelCatalogResult.Success success = (ModelCatalogResult.Success) result;
|
||||||
|
assertThat(success.models()).containsExactly("gpt-4o", "gpt-4");
|
||||||
|
assertThat(success.providerIdentifier()).isEqualTo(PROVIDER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 200 with empty data array returns EmptyList")
|
||||||
|
void fetchModels_http200EmptyDataArray_returnsEmptyList() throws Exception {
|
||||||
|
String responseBody = """
|
||||||
|
{"object":"list","data":[]}
|
||||||
|
""";
|
||||||
|
doReturn(200).when(httpResponse).statusCode();
|
||||||
|
doReturn(responseBody).when(httpResponse).body();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.EmptyList.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 401 returns TechnicalFailure with AUTHENTICATION_FAILED")
|
||||||
|
void fetchModels_http401_returnsAuthenticationFailed() throws Exception {
|
||||||
|
doReturn(401).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("AUTHENTICATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 403 returns TechnicalFailure with AUTHENTICATION_FAILED")
|
||||||
|
void fetchModels_http403_returnsAuthenticationFailed() throws Exception {
|
||||||
|
doReturn(403).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("AUTHENTICATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 404 returns TechnicalFailure with ENDPOINT_NOT_FOUND")
|
||||||
|
void fetchModels_http404_returnsEndpointNotFound() throws Exception {
|
||||||
|
doReturn(404).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("ENDPOINT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP 500 returns TechnicalFailure with SERVER_ERROR")
|
||||||
|
void fetchModels_http500_returnsServerError() throws Exception {
|
||||||
|
doReturn(500).when(httpResponse).statusCode();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("SERVER_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ConnectException returns TechnicalFailure with CONNECTION_FAILURE")
|
||||||
|
void fetchModels_connectException_returnsConnectionFailure() throws Exception {
|
||||||
|
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
|
||||||
|
.thenThrow(new ConnectException("connection refused"));
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("CONNECTION_FAILURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Unparseable JSON response returns TechnicalFailure with INVALID_RESPONSE")
|
||||||
|
void fetchModels_invalidJson_returnsInvalidResponse() throws Exception {
|
||||||
|
doReturn(200).when(httpResponse).statusCode();
|
||||||
|
doReturn("not-a-json-body").when(httpResponse).body();
|
||||||
|
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("INVALID_RESPONSE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Missing API key returns IncompleteConfiguration")
|
||||||
|
void fetchModels_missingApiKey_returnsIncompleteConfiguration() {
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.empty(), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
|
||||||
|
ModelCatalogResult.IncompleteConfiguration incomplete =
|
||||||
|
(ModelCatalogResult.IncompleteConfiguration) result;
|
||||||
|
assertThat(incomplete.missingReason()).containsIgnoringCase("API-Schlüssel");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Blank API key returns IncompleteConfiguration")
|
||||||
|
void fetchModels_blankApiKey_returnsIncompleteConfiguration() {
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(" "), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("No base URL falls back to OpenAI default")
|
||||||
|
void fetchModels_noBaseUrl_usesDefault() {
|
||||||
|
assertThat(OpenAiCompatibleModelCatalogAdapter.DEFAULT_BASE_URL)
|
||||||
|
.isEqualTo("https://api.openai.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("UnknownHostException returns TechnicalFailure with CONNECTION_FAILURE")
|
||||||
|
void fetchModels_unknownHost_returnsConnectionFailure() throws Exception {
|
||||||
|
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
|
||||||
|
.thenThrow(new UnknownHostException("unknown host"));
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("CONNECTION_FAILURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HttpTimeoutException returns TechnicalFailure with CONNECTION_FAILURE")
|
||||||
|
void fetchModels_httpTimeout_returnsConnectionFailure() throws Exception {
|
||||||
|
// The adapter groups HTTP timeouts together with other connection failures under
|
||||||
|
// the CONNECTION_FAILURE category (no separate TIMEOUT category).
|
||||||
|
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
|
||||||
|
.thenThrow(new HttpTimeoutException("request timed out"));
|
||||||
|
|
||||||
|
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
|
||||||
|
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
|
||||||
|
|
||||||
|
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
|
||||||
|
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
|
||||||
|
.isEqualTo("CONNECTION_FAILURE");
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledOnOs;
|
||||||
|
import org.junit.jupiter.api.condition.OS;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link FilesystemPathCheckAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Prüft alle vier Methoden des Ports unter realen Dateisystem-Bedingungen mit
|
||||||
|
* {@link TempDir}. Windows-spezifische Tests werden auf Nicht-Windows-Systemen
|
||||||
|
* automatisch übersprungen.
|
||||||
|
*/
|
||||||
|
class FilesystemPathCheckAdapterTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private FilesystemPathCheckAdapter adapter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adapter = new FilesystemPathCheckAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// isDirectoryReadable
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryReadable_existingReadableDirectory_returnsTrue() {
|
||||||
|
assertTrue(adapter.isDirectoryReadable(tempDir.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryReadable_nonExistentPath_returnsFalse() {
|
||||||
|
Path absent = tempDir.resolve("does-not-exist");
|
||||||
|
assertFalse(adapter.isDirectoryReadable(absent.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryReadable_existingFile_returnsFalse() throws IOException {
|
||||||
|
Path file = Files.createFile(tempDir.resolve("some-file.txt"));
|
||||||
|
assertFalse(adapter.isDirectoryReadable(file.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryReadable_emptyString_returnsFalse() {
|
||||||
|
assertFalse(adapter.isDirectoryReadable(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryReadable_nullValue_returnsFalse() {
|
||||||
|
assertFalse(adapter.isDirectoryReadable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void isDirectoryReadable_invalidWindowsCharacters_returnsFalse() {
|
||||||
|
// Zeichen wie '<', '>', '?' sind auf Windows in Pfaden unzulässig
|
||||||
|
assertFalse(adapter.isDirectoryReadable("C:\\invalid<path>?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// isDirectoryWritableOrCreatable
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryWritableOrCreatable_existingWritableDirectory_returnsTrue() {
|
||||||
|
assertTrue(adapter.isDirectoryWritableOrCreatable(tempDir.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryWritableOrCreatable_nonExistentDirectoryWithWritableParent_returnsTrue() {
|
||||||
|
Path newDir = tempDir.resolve("new-sub-dir");
|
||||||
|
assertTrue(adapter.isDirectoryWritableOrCreatable(newDir.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryWritableOrCreatable_nonExistentDirectoryAndNonExistentParent_returnsFalse() {
|
||||||
|
Path deepAbsent = tempDir.resolve("ghost").resolve("deeply").resolve("nested");
|
||||||
|
assertFalse(adapter.isDirectoryWritableOrCreatable(deepAbsent.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryWritableOrCreatable_emptyString_returnsFalse() {
|
||||||
|
assertFalse(adapter.isDirectoryWritableOrCreatable(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isDirectoryWritableOrCreatable_nullValue_returnsFalse() {
|
||||||
|
assertFalse(adapter.isDirectoryWritableOrCreatable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void isDirectoryWritableOrCreatable_invalidWindowsCharacters_returnsFalse() {
|
||||||
|
assertFalse(adapter.isDirectoryWritableOrCreatable("C:\\invalid<path>?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// isFileReadable
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isFileReadable_existingReadableFile_returnsTrue() throws IOException {
|
||||||
|
Path file = Files.createFile(tempDir.resolve("readable.txt"));
|
||||||
|
assertTrue(adapter.isFileReadable(file.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isFileReadable_nonExistentFile_returnsFalse() {
|
||||||
|
Path absent = tempDir.resolve("missing.txt");
|
||||||
|
assertFalse(adapter.isFileReadable(absent.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isFileReadable_existingDirectory_returnsFalse() {
|
||||||
|
assertFalse(adapter.isFileReadable(tempDir.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isFileReadable_emptyString_returnsFalse() {
|
||||||
|
assertFalse(adapter.isFileReadable(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isFileReadable_nullValue_returnsFalse() {
|
||||||
|
assertFalse(adapter.isFileReadable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void isFileReadable_invalidWindowsCharacters_returnsFalse() {
|
||||||
|
assertFalse(adapter.isFileReadable("C:\\invalid<file>?.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// isSqlitePathUsable
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSqlitePathUsable_existingWritableFile_returnsTrue() throws IOException {
|
||||||
|
Path db = Files.createFile(tempDir.resolve("test.db"));
|
||||||
|
assertTrue(adapter.isSqlitePathUsable(db.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSqlitePathUsable_nonExistentFileWithWritableParentDir_returnsTrue() {
|
||||||
|
Path newDb = tempDir.resolve("new.db");
|
||||||
|
assertTrue(adapter.isSqlitePathUsable(newDb.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSqlitePathUsable_nonExistentFileAndNonExistentParentDir_returnsFalse() {
|
||||||
|
Path deepAbsent = tempDir.resolve("ghost").resolve("sub.db");
|
||||||
|
assertFalse(adapter.isSqlitePathUsable(deepAbsent.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSqlitePathUsable_existingDirectory_returnsFalse() {
|
||||||
|
// Ein Verzeichnis ist kein gültiger SQLite-Dateipfad
|
||||||
|
assertFalse(adapter.isSqlitePathUsable(tempDir.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSqlitePathUsable_emptyString_returnsFalse() {
|
||||||
|
assertFalse(adapter.isSqlitePathUsable(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSqlitePathUsable_nullValue_returnsFalse() {
|
||||||
|
assertFalse(adapter.isSqlitePathUsable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void isSqlitePathUsable_invalidWindowsCharacters_returnsFalse() {
|
||||||
|
assertFalse(adapter.isSqlitePathUsable("C:\\invalid<db>?.db"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Windows-Pfad-Semantik (Syntaxprüfung, kein echtes Laufwerk erforderlich)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass Pfade mit gemapptem Laufwerksbuchstaben syntaktisch akzeptiert
|
||||||
|
* werden (kein sofortiger Syntaxfehler). Das Ergebnis ist {@code false}, weil das
|
||||||
|
* Laufwerk in dieser Testumgebung nicht existiert — aber es darf nicht wegen des
|
||||||
|
* Laufwerksbuchstabens allein abgelehnt werden.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void windowsMappedDriveSyntax_isAcceptedByAdapter() {
|
||||||
|
// Ein Pfad mit gemapptem Laufwerksbuchstaben darf nicht wegen der Syntax abgelehnt
|
||||||
|
// werden. Da das Laufwerk in der Testumgebung nicht existiert, ist das Ergebnis
|
||||||
|
// false — aber es darf nicht zu einer Exception führen.
|
||||||
|
assertFalse(adapter.isDirectoryReadable("S:\\nonexistent-in-test"));
|
||||||
|
assertFalse(adapter.isDirectoryWritableOrCreatable("H:\\nonexistent-in-test"));
|
||||||
|
assertFalse(adapter.isFileReadable("X:\\nonexistent-in-test\\file.txt"));
|
||||||
|
assertFalse(adapter.isSqlitePathUsable("Z:\\nonexistent-in-test\\db.db"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass UNC-Pfade syntaktisch akzeptiert werden.
|
||||||
|
* Das Ergebnis ist {@code false}, weil der Server nicht existiert.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void windowsUncPathSyntax_isAcceptedByAdapter() {
|
||||||
|
assertFalse(adapter.isDirectoryReadable("\\\\nonexistent-server\\share\\folder"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass der Adapter auf dem lokalen temporären Verzeichnis korrekt
|
||||||
|
* arbeitet — dieses ist plattformübergreifend immer vorhanden.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void tmpDirIsReadableAndWritableOrCreatable() {
|
||||||
|
String tmpDir = System.getProperty("java.io.tmpdir");
|
||||||
|
assumeTrue(tmpDir != null && !tmpDir.isBlank(), "java.io.tmpdir must be set");
|
||||||
|
assertTrue(adapter.isDirectoryReadable(tmpDir),
|
||||||
|
"java.io.tmpdir must be readable: " + tmpDir);
|
||||||
|
assertTrue(adapter.isDirectoryWritableOrCreatable(tmpDir),
|
||||||
|
"java.io.tmpdir must be writable: " + tmpDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
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.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link FilesystemResourceCreationAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Prüft die drei Kernmethoden auf Erfolgs-, Idempotenz- und Fehlerfälle.
|
||||||
|
*/
|
||||||
|
class FilesystemResourceCreationAdapterTest {
|
||||||
|
|
||||||
|
private final FilesystemResourceCreationAdapter adapter = new FilesystemResourceCreationAdapter();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// createDirectory
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_nonExistent_returnsApplied(@TempDir Path tempDir) {
|
||||||
|
Path newDir = tempDir.resolve("neu");
|
||||||
|
CorrectionSuggestion.CreateDirectory suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory(newDir.toString(), "Zielordner anlegen");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
|
||||||
|
"Neues Verzeichnis muss Applied zurückgeben");
|
||||||
|
assertTrue(Files.isDirectory(newDir), "Verzeichnis muss nach dem Anlegen existieren");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_nestedNonExistent_returnsApplied(@TempDir Path tempDir) {
|
||||||
|
Path nestedDir = tempDir.resolve("a").resolve("b").resolve("c");
|
||||||
|
CorrectionSuggestion.CreateDirectory suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory(nestedDir.toString(), "Tiefer Ordner");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||||
|
assertTrue(Files.isDirectory(nestedDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_alreadyExists_returnsApplied(@TempDir Path tempDir) {
|
||||||
|
// tempDir exists already — should be idempotent
|
||||||
|
CorrectionSuggestion.CreateDirectory suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory(tempDir.toString(), "Ordner vorhanden");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
|
||||||
|
"Bereits vorhandener Ordner muss Applied zurückgeben (idempotent)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_existingFileAtPath_returnsFailed(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path filePath = tempDir.resolve("existingFile.txt");
|
||||||
|
Files.createFile(filePath);
|
||||||
|
CorrectionSuggestion.CreateDirectory suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory(filePath.toString(), "Datei statt Ordner");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Failed.class, outcome,
|
||||||
|
"Pfad zeigt auf Datei — muss Failed zurückgeben");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// prepareSqlitePath
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareSqlitePath_nonExistentParent_createsParentAndReturnsApplied(@TempDir Path tempDir) {
|
||||||
|
Path sqliteFile = tempDir.resolve("data").resolve("db.sqlite");
|
||||||
|
CorrectionSuggestion.PrepareSqlitePath suggestion =
|
||||||
|
new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "SQLite-Pfad vorbereiten");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||||
|
assertTrue(Files.isDirectory(sqliteFile.getParent()),
|
||||||
|
"Elternordner muss nach prepareSqlitePath existieren");
|
||||||
|
assertFalse(Files.exists(sqliteFile),
|
||||||
|
"SQLite-Datei selbst darf NICHT angelegt werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareSqlitePath_existingParent_returnsApplied(@TempDir Path tempDir) {
|
||||||
|
// tempDir already exists — parent is tempDir itself
|
||||||
|
Path sqliteFile = tempDir.resolve("existing.sqlite");
|
||||||
|
CorrectionSuggestion.PrepareSqlitePath suggestion =
|
||||||
|
new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "Vorhandener Parent");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
|
||||||
|
"Bereits vorhandener Elternordner muss Applied zurückgeben (idempotent)");
|
||||||
|
assertFalse(Files.exists(sqliteFile),
|
||||||
|
"SQLite-Datei selbst darf NICHT angelegt werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// createPromptFile
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPromptFile_nonExistent_createsFileAndReturnsApplied(@TempDir Path tempDir) {
|
||||||
|
Path promptFile = tempDir.resolve("prompt.txt");
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||||
|
assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPromptFile_alreadyExists_returnsNotAttempted(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path promptFile = tempDir.resolve("existing_prompt.txt");
|
||||||
|
Files.createFile(promptFile);
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Datei vorhanden");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.NotAttempted.class, outcome,
|
||||||
|
"Bereits vorhandene Datei darf nicht überschrieben werden — NotAttempted erwartet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPromptFile_nonExistentParent_createsParentAndFile(@TempDir Path tempDir) {
|
||||||
|
Path promptFile = tempDir.resolve("subdir").resolve("prompt.txt");
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt in Unterordner");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||||
|
assertTrue(Files.exists(promptFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPromptFile_nonExistent_contentMatchesDefaultPromptTemplate(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path promptFile = tempDir.resolve("prompt.txt");
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
|
||||||
|
|
||||||
|
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||||
|
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||||
|
assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
|
||||||
|
String writtenContent = Files.readString(promptFile, StandardCharsets.UTF_8);
|
||||||
|
String expectedContent = DefaultPromptTemplate.defaultContent();
|
||||||
|
// Der geschriebene Inhalt muss dem deutschen Standard-Prompt entsprechen
|
||||||
|
assertTrue(writtenContent.contains("Titel"),
|
||||||
|
"Geschriebener Inhalt muss deutschen Standard-Prompt enthalten");
|
||||||
|
assertTrue(writtenContent.equals(expectedContent),
|
||||||
|
"Geschriebener Inhalt muss exakt DefaultPromptTemplate.defaultContent() entsprechen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Ungültige Pfade
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_blankPath_returnsFailed() {
|
||||||
|
CorrectionSuggestion.CreateDirectory suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory("C:/valid-placeholder", "Dummy");
|
||||||
|
|
||||||
|
// Simulate invalid path behavior by using an adapter that receives an unusual path.
|
||||||
|
// Here we just verify a valid path works — blank path is caught by CorrectionSuggestion constructor.
|
||||||
|
assertNotNull(suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link EnvironmentApiKeyResolutionAdapter}.
|
||||||
|
* <p>
|
||||||
|
* The environment lookup is injected via the package-private constructor so that tests
|
||||||
|
* are deterministic and do not depend on real process environment variables.
|
||||||
|
*/
|
||||||
|
class EnvironmentApiKeyResolutionAdapterTest {
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static EnvironmentApiKeyResolutionAdapter adapterWith(Map<String, String> env) {
|
||||||
|
Function<String, String> lookup = env::get;
|
||||||
|
return new EnvironmentApiKeyResolutionAdapter(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Claude – provider env var takes precedence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claude_envVarPresent_returnsFromProviderEnvVar() {
|
||||||
|
Map<String, String> env = Map.of(
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, "sk-ant-test");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.CLAUDE, "");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
|
||||||
|
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY,
|
||||||
|
result.envVarName().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claude_envVarPresentAndPropertyAlsoPresent_envVarWins() {
|
||||||
|
Map<String, String> env = Map.of(
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, "sk-ant-env");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.CLAUDE, "sk-ant-property");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Claude – property file fallback
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claude_noEnvVar_propertyPresent_returnsFromPropertyFile() {
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
|
||||||
|
.resolve(AiProviderFamily.CLAUDE, "sk-ant-property");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Claude – absent
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claude_noEnvVar_noProperty_returnsAbsent() {
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
|
||||||
|
.resolve(AiProviderFamily.CLAUDE, "");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claude_noEnvVar_blankPropertyOnly_returnsAbsent() {
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
|
||||||
|
.resolve(AiProviderFamily.CLAUDE, " ");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OpenAI-compatible – primary env var takes precedence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_primaryEnvVarPresent_returnsFromProviderEnvVar() {
|
||||||
|
Map<String, String> env = Map.of(
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "sk-openai-test");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
|
||||||
|
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY,
|
||||||
|
result.envVarName().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_primaryEnvVarAndLegacyBothPresent_primaryWins() {
|
||||||
|
Map<String, String> env = Map.of(
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "sk-primary",
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
|
||||||
|
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY,
|
||||||
|
result.envVarName().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OpenAI-compatible – legacy env var
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_noPrimaryEnvVar_legacyPresent_returnsFromLegacyEnvVar() {
|
||||||
|
Map<String, String> env = Map.of(
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy-key");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_LEGACY_ENV_VAR, result.origin());
|
||||||
|
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY,
|
||||||
|
result.envVarName().orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_noPrimaryEnvVar_legacyPresentAndPropertyAlsoPresent_legacyWins() {
|
||||||
|
Map<String, String> env = Map.of(
|
||||||
|
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-property");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_LEGACY_ENV_VAR, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OpenAI-compatible – property file fallback
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_noEnvVars_propertyPresent_returnsFromPropertyFile() {
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-openai-property");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OpenAI-compatible – absent
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_noEnvVars_noProperty_returnsAbsent() {
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_noEnvVars_blankProperty_returnsAbsent() {
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, " ");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Blank env value treated as absent
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claude_envVarPresentButBlank_treatedAsAbsent_fallsBackToProperty() {
|
||||||
|
Map<String, String> env = new HashMap<>();
|
||||||
|
env.put(EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, " ");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.CLAUDE, "sk-prop");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openai_primaryEnvVarBlank_legacyAbsent_propertyPresent_returnsFromPropertyFile() {
|
||||||
|
Map<String, String> env = new HashMap<>();
|
||||||
|
env.put(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "");
|
||||||
|
|
||||||
|
EffectiveApiKeyDescriptor result = adapterWith(env)
|
||||||
|
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-prop");
|
||||||
|
|
||||||
|
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound port for retrieving the list of available AI models from a provider endpoint.
|
||||||
|
* <p>
|
||||||
|
* This port is used exclusively by the GUI layer to populate the model selection control.
|
||||||
|
* The headless batch path does not call this port; the model name is read directly from
|
||||||
|
* the configuration file in that path.
|
||||||
|
* <p>
|
||||||
|
* <strong>Infrastructure neutrality:</strong> The port signature contains no HTTP client,
|
||||||
|
* no JSON type, and no JavaFX reference. Implementations in {@code pdf-umbenenner-adapter-out}
|
||||||
|
* translate the {@link ModelCatalogRequest} into provider-specific HTTP calls and map the
|
||||||
|
* response back to a {@link ModelCatalogResult}.
|
||||||
|
* <p>
|
||||||
|
* <strong>Error handling contract:</strong> Implementations must never throw exceptions for
|
||||||
|
* expected error conditions such as missing configuration values, authentication failures, or
|
||||||
|
* unreachable endpoints. All such conditions are encoded as specific {@link ModelCatalogResult}
|
||||||
|
* sub-types so the GUI can display them without crash handling.
|
||||||
|
* <p>
|
||||||
|
* <strong>Blocking behaviour:</strong> Implementations are expected to block the calling thread
|
||||||
|
* until the result is available or the configured timeout expires. The GUI adapter must therefore
|
||||||
|
* invoke this port on a background worker thread and must not call it on the JavaFX Application
|
||||||
|
* Thread.
|
||||||
|
*/
|
||||||
|
public interface AiModelCatalogPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of available model identifiers for the provider described by the request.
|
||||||
|
* <p>
|
||||||
|
* The method always returns a non-{@code null} {@link ModelCatalogResult}. The caller
|
||||||
|
* uses pattern matching over the sealed type hierarchy to distinguish success, empty list,
|
||||||
|
* incomplete configuration, and technical failure without catching exceptions.
|
||||||
|
*
|
||||||
|
* @param request all information required to contact the provider endpoint;
|
||||||
|
* must not be {@code null}
|
||||||
|
* @return the result of the catalogue retrieval; never {@code null}
|
||||||
|
* @throws NullPointerException if {@code request} is {@code null}
|
||||||
|
*/
|
||||||
|
ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request);
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the origin of the effective API key value for a provider.
|
||||||
|
* <p>
|
||||||
|
* The resolution order is defined in the application configuration rules:
|
||||||
|
* <ol>
|
||||||
|
* <li>A provider-specific environment variable takes the highest precedence.</li>
|
||||||
|
* <li>For the {@code openai-compatible} provider family a legacy environment variable
|
||||||
|
* is evaluated as a secondary fallback for backward compatibility.</li>
|
||||||
|
* <li>The property value from the {@code .properties} file is used when no
|
||||||
|
* environment variable is present.</li>
|
||||||
|
* <li>{@link #ABSENT} is returned when none of the above sources supplies a value.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* This enum is placed in the Application module because the resolution order is a fachliche
|
||||||
|
* rule that applies independently of the GUI. Bootstrap uses the same precedence, and
|
||||||
|
* future diagnostic components outside the GUI may need to report key provenance without
|
||||||
|
* depending on GUI-layer types.
|
||||||
|
*/
|
||||||
|
public enum ApiKeyOrigin {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective key comes from the provider-specific environment variable
|
||||||
|
* (e.g., {@code ANTHROPIC_API_KEY} for Claude or {@code OPENAI_API_KEY} for
|
||||||
|
* OpenAI-compatible providers).
|
||||||
|
*/
|
||||||
|
FROM_PROVIDER_ENV_VAR,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective key comes from the legacy environment variable accepted for
|
||||||
|
* backward compatibility.
|
||||||
|
* <p>
|
||||||
|
* This origin applies only to the {@code openai-compatible} provider family. The
|
||||||
|
* legacy variable name is defined by the adapter and is not fixed in this enum.
|
||||||
|
*/
|
||||||
|
FROM_LEGACY_ENV_VAR,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective key comes from the property value stored in the {@code .properties} file.
|
||||||
|
*/
|
||||||
|
FROM_PROPERTY_FILE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No API key value was found in any of the supported sources.
|
||||||
|
* <p>
|
||||||
|
* The provider configuration is incomplete; the batch run cannot start and the GUI
|
||||||
|
* must indicate that the key is missing.
|
||||||
|
*/
|
||||||
|
ABSENT
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the provenance of the effective API key for a provider, including the name of the
|
||||||
|
* environment variable that supplies the key when applicable.
|
||||||
|
* <p>
|
||||||
|
* The GUI uses this record to display provenance information to the user (e.g., "Schlüssel
|
||||||
|
* kommt aus Umgebungsvariable ANTHROPIC_API_KEY") without coupling the display logic to the
|
||||||
|
* concrete variable names.
|
||||||
|
* <p>
|
||||||
|
* The {@code envVarName} field is present when the origin is
|
||||||
|
* {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} or {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR};
|
||||||
|
* it is absent for {@link ApiKeyOrigin#FROM_PROPERTY_FILE} and {@link ApiKeyOrigin#ABSENT}.
|
||||||
|
*
|
||||||
|
* @param origin the source from which the effective key value comes; never {@code null}
|
||||||
|
* @param envVarName the name of the environment variable that provides the key when applicable;
|
||||||
|
* empty when the origin is {@code FROM_PROPERTY_FILE} or {@code ABSENT}
|
||||||
|
*/
|
||||||
|
public record EffectiveApiKeyDescriptor(
|
||||||
|
ApiKeyOrigin origin,
|
||||||
|
Optional<String> envVarName) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new descriptor.
|
||||||
|
*
|
||||||
|
* @param origin key origin; must not be {@code null}
|
||||||
|
* @param envVarName optional environment variable name; {@code null} becomes empty
|
||||||
|
* @throws NullPointerException if {@code origin} is {@code null}
|
||||||
|
*/
|
||||||
|
public EffectiveApiKeyDescriptor {
|
||||||
|
Objects.requireNonNull(origin, "origin must not be null");
|
||||||
|
envVarName = envVarName == null ? Optional.empty() : envVarName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a descriptor for a key that comes from a provider-specific environment variable.
|
||||||
|
*
|
||||||
|
* @param variableName the name of the environment variable; must not be {@code null}
|
||||||
|
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR}
|
||||||
|
* @throws NullPointerException if {@code variableName} is {@code null}
|
||||||
|
*/
|
||||||
|
public static EffectiveApiKeyDescriptor fromProviderEnvVar(String variableName) {
|
||||||
|
Objects.requireNonNull(variableName, "variableName must not be null");
|
||||||
|
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
|
||||||
|
Optional.of(variableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a descriptor for a key that comes from the legacy environment variable.
|
||||||
|
*
|
||||||
|
* @param variableName the name of the legacy environment variable; must not be {@code null}
|
||||||
|
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
|
||||||
|
* @throws NullPointerException if {@code variableName} is {@code null}
|
||||||
|
*/
|
||||||
|
public static EffectiveApiKeyDescriptor fromLegacyEnvVar(String variableName) {
|
||||||
|
Objects.requireNonNull(variableName, "variableName must not be null");
|
||||||
|
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
|
||||||
|
Optional.of(variableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a descriptor for a key that comes from the properties file.
|
||||||
|
*
|
||||||
|
* @return a new descriptor with origin {@link ApiKeyOrigin#FROM_PROPERTY_FILE}
|
||||||
|
*/
|
||||||
|
public static EffectiveApiKeyDescriptor fromPropertyFile() {
|
||||||
|
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a descriptor indicating that no key value is available.
|
||||||
|
*
|
||||||
|
* @return a new descriptor with origin {@link ApiKeyOrigin#ABSENT}
|
||||||
|
*/
|
||||||
|
public static EffectiveApiKeyDescriptor absent() {
|
||||||
|
return new EffectiveApiKeyDescriptor(ApiKeyOrigin.ABSENT, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when the effective key comes from any environment variable.
|
||||||
|
*
|
||||||
|
* @return {@code true} for {@link ApiKeyOrigin#FROM_PROVIDER_ENV_VAR} and
|
||||||
|
* {@link ApiKeyOrigin#FROM_LEGACY_ENV_VAR}
|
||||||
|
*/
|
||||||
|
public boolean isFromEnvironmentVariable() {
|
||||||
|
return origin == ApiKeyOrigin.FROM_PROVIDER_ENV_VAR
|
||||||
|
|| origin == ApiKeyOrigin.FROM_LEGACY_ENV_VAR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when no key is available from any source.
|
||||||
|
*
|
||||||
|
* @return {@code true} for {@link ApiKeyOrigin#ABSENT}
|
||||||
|
*/
|
||||||
|
public boolean isAbsent() {
|
||||||
|
return origin == ApiKeyOrigin.ABSENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries all information needed by an {@link AiModelCatalogPort} implementation to fetch
|
||||||
|
* the list of available models from a provider endpoint.
|
||||||
|
* <p>
|
||||||
|
* This record is infrastructure-neutral: it contains no HTTP client, no JSON type, and no
|
||||||
|
* JavaFX reference. The adapter translates the values into provider-specific request structures.
|
||||||
|
* <p>
|
||||||
|
* The {@code apiKey} field is {@code Optional}: when the key is absent the adapter is expected
|
||||||
|
* to perform the request without authentication and return an appropriate
|
||||||
|
* {@link ModelCatalogResult} sub-type (typically
|
||||||
|
* {@link ModelCatalogResult.IncompleteConfiguration}) rather than throwing an exception.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier string of the target provider family as used in the
|
||||||
|
* {@code ai.provider.active} configuration property
|
||||||
|
* (e.g., {@code "claude"} or {@code "openai-compatible"});
|
||||||
|
* must not be {@code null}
|
||||||
|
* @param baseUrl optional base URL of the provider endpoint; when absent the adapter
|
||||||
|
* must apply its own built-in default (e.g., for Claude)
|
||||||
|
* @param apiKey optional API key; when absent the adapter must not fabricate a key
|
||||||
|
* @param timeoutSeconds HTTP timeout in seconds; must be a positive integer
|
||||||
|
*/
|
||||||
|
public record ModelCatalogRequest(
|
||||||
|
String providerIdentifier,
|
||||||
|
Optional<String> baseUrl,
|
||||||
|
Optional<String> apiKey,
|
||||||
|
int timeoutSeconds) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new model catalogue request.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier string of the target provider family; must not be {@code null}
|
||||||
|
* @param baseUrl optional base URL; {@code null} is treated as {@link Optional#empty()}
|
||||||
|
* @param apiKey optional API key; {@code null} is treated as {@link Optional#empty()}
|
||||||
|
* @param timeoutSeconds HTTP timeout in seconds; must be positive
|
||||||
|
* @throws NullPointerException if {@code providerIdentifier} is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code timeoutSeconds} is not positive
|
||||||
|
*/
|
||||||
|
public ModelCatalogRequest {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
if (timeoutSeconds <= 0) {
|
||||||
|
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
|
||||||
|
}
|
||||||
|
baseUrl = baseUrl == null ? Optional.empty() : baseUrl;
|
||||||
|
apiKey = apiKey == null ? Optional.empty() : apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed result type for a model catalogue retrieval operation performed via
|
||||||
|
* {@link AiModelCatalogPort}.
|
||||||
|
* <p>
|
||||||
|
* Each permitted sub-type represents one distinct outcome:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Success} – the provider returned a non-empty list of model identifiers.</li>
|
||||||
|
* <li>{@link EmptyList} – the provider responded successfully but returned no models.</li>
|
||||||
|
* <li>{@link IncompleteConfiguration} – the request could not be sent because a required
|
||||||
|
* configuration value (e.g., API key, base URL) was missing.</li>
|
||||||
|
* <li>{@link TechnicalFailure} – the HTTP call, authentication, or response parsing failed.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* The GUI adapter uses this result directly; no separate GUI-layer translation type is needed
|
||||||
|
* because the structure matches the GUI's display needs without containing any JavaFX reference.
|
||||||
|
* This design decision is documented here to avoid introducing a redundant mapping layer.
|
||||||
|
* <p>
|
||||||
|
* Callers are expected to use pattern-matching {@code switch} expressions over all permitted types
|
||||||
|
* to ensure exhaustive handling as new sub-types might be added in future expansions.
|
||||||
|
*/
|
||||||
|
public sealed interface ModelCatalogResult
|
||||||
|
permits ModelCatalogResult.Success,
|
||||||
|
ModelCatalogResult.EmptyList,
|
||||||
|
ModelCatalogResult.IncompleteConfiguration,
|
||||||
|
ModelCatalogResult.TechnicalFailure {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider returned a non-empty list of available model identifiers.
|
||||||
|
* <p>
|
||||||
|
* The list is guaranteed to contain at least one entry. Callers may safely use
|
||||||
|
* the first element as a default selection.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider that returned the list; never {@code null}
|
||||||
|
* @param models non-empty, ordered list of model identifier strings; never {@code null}
|
||||||
|
* @param loadedAt timestamp when the list was successfully retrieved; never {@code null}
|
||||||
|
*/
|
||||||
|
record Success(
|
||||||
|
String providerIdentifier,
|
||||||
|
List<String> models,
|
||||||
|
Instant loadedAt) implements ModelCatalogResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a successful model catalogue result.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||||
|
* @param models list of model identifiers; must not be {@code null} or empty
|
||||||
|
* @param loadedAt retrieval timestamp; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code models} is empty
|
||||||
|
*/
|
||||||
|
public Success {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
Objects.requireNonNull(models, "models must not be null");
|
||||||
|
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||||
|
if (models.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"models must not be empty; use EmptyList for an empty response");
|
||||||
|
}
|
||||||
|
models = List.copyOf(models);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider responded successfully but returned no model identifiers.
|
||||||
|
* <p>
|
||||||
|
* This case is distinct from {@link TechnicalFailure}: the HTTP exchange succeeded and the
|
||||||
|
* response was parseable, but the list of models was empty. The GUI should fall back to
|
||||||
|
* manual text input.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; never {@code null}
|
||||||
|
* @param loadedAt timestamp of the (technically successful) response; never {@code null}
|
||||||
|
*/
|
||||||
|
record EmptyList(
|
||||||
|
String providerIdentifier,
|
||||||
|
Instant loadedAt) implements ModelCatalogResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an empty-list result.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||||
|
* @param loadedAt retrieval timestamp; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public EmptyList {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model catalogue request could not be sent because a required configuration value
|
||||||
|
* was absent.
|
||||||
|
* <p>
|
||||||
|
* Typical causes: missing API key, missing base URL for a provider family that requires one.
|
||||||
|
* The adapter must not throw an exception for this case; it must return this result type
|
||||||
|
* instead so the GUI can display a user-friendly hint without crashing.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider for which configuration is incomplete;
|
||||||
|
* never {@code null}
|
||||||
|
* @param missingReason human-readable description of which configuration value is missing;
|
||||||
|
* never {@code null}
|
||||||
|
*/
|
||||||
|
record IncompleteConfiguration(
|
||||||
|
String providerIdentifier,
|
||||||
|
String missingReason) implements ModelCatalogResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an incomplete-configuration result.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||||
|
* @param missingReason description of the missing value; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public IncompleteConfiguration {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
Objects.requireNonNull(missingReason, "missingReason must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A technical error occurred during the model catalogue retrieval.
|
||||||
|
* <p>
|
||||||
|
* Covers HTTP errors, authentication failures, network timeouts, and response parsing
|
||||||
|
* failures. The adapter classifies the error into a short category string (e.g.,
|
||||||
|
* {@code "HTTP_ERROR"}, {@code "AUTH_FAILURE"}, {@code "TIMEOUT"}, {@code "PARSE_ERROR"})
|
||||||
|
* and provides a human-readable detail message.
|
||||||
|
* <p>
|
||||||
|
* This result does not trigger a retry; the GUI offers an explicit
|
||||||
|
* "reload models" action that the user can invoke after fixing the underlying issue.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider that was contacted; never {@code null}
|
||||||
|
* @param errorCategory short, stable category string for programmatic discrimination;
|
||||||
|
* never {@code null}
|
||||||
|
* @param errorDetail human-readable error description; never {@code null}
|
||||||
|
*/
|
||||||
|
record TechnicalFailure(
|
||||||
|
String providerIdentifier,
|
||||||
|
String errorCategory,
|
||||||
|
String errorDetail) implements ModelCatalogResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a technical-failure result.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||||
|
* @param errorCategory short category string; must not be {@code null}
|
||||||
|
* @param errorDetail human-readable detail; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public TechnicalFailure {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
Objects.requireNonNull(errorCategory, "errorCategory must not be null");
|
||||||
|
Objects.requireNonNull(errorDetail, "errorDetail must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Contracts and result types for the provider-dependent model catalogue retrieval.
|
||||||
|
* <p>
|
||||||
|
* This package defines the outbound port {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort}
|
||||||
|
* that the GUI adapter uses to fetch the list of available AI models from the active provider.
|
||||||
|
* All types in this package are infrastructure-neutral: they contain no HTTP, JSON, or JavaFX
|
||||||
|
* references.
|
||||||
|
* <p>
|
||||||
|
* The GUI adapter is the sole consumer of this port in the current implementation scope;
|
||||||
|
* the headless batch path does not perform model catalogue lookups.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port für die Auflösung der API-Key-Herkunft pro Provider-Familie.
|
||||||
|
* <p>
|
||||||
|
* Gibt zurück, aus welcher Quelle der effektive API-Schlüssel für einen angegebenen Provider
|
||||||
|
* stammt. Die Vorrangregel lautet:
|
||||||
|
* <ol>
|
||||||
|
* <li>Providerspezifische Umgebungsvariable (höchste Priorität)</li>
|
||||||
|
* <li>Bei {@code openai-compatible}: zusätzlich die Legacy-Umgebungsvariable als Fallback</li>
|
||||||
|
* <li>Property-Wert aus der {@code .properties}-Datei</li>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT}
|
||||||
|
* wenn keine Quelle einen Wert liefert</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Der Port kennt keine Datei-Inhalte und kein Property-Parsing; er liest ausschließlich
|
||||||
|
* Umgebungsvariablen. Der Property-Wert wird von der GUI als separates Eingabeargument übergeben
|
||||||
|
* und vom Validator mit dem Port-Ergebnis kombiniert.
|
||||||
|
* <p>
|
||||||
|
* Implementierungen dieses Ports liegen im Adapter-Out-Modul.
|
||||||
|
*/
|
||||||
|
public interface ApiKeyResolutionPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
|
||||||
|
* <p>
|
||||||
|
* Gibt nur dann {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#FROM_PROPERTY_FILE}
|
||||||
|
* zurück, wenn {@code propertyValue} nicht leer ist und keine Umgebungsvariable greift.
|
||||||
|
* Gibt {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin#ABSENT} zurück,
|
||||||
|
* wenn weder Umgebungsvariable noch {@code propertyValue} einen Wert liefern.
|
||||||
|
*
|
||||||
|
* @param family die Provider-Familie; darf nicht {@code null} sein
|
||||||
|
* @param propertyValue aktueller Property-Wert aus dem Editor (kann leer sein); darf nicht {@code null} sein
|
||||||
|
* @return der Descriptor für die effektive Schlüsselherkunft; nie {@code null}
|
||||||
|
*/
|
||||||
|
EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue);
|
||||||
|
}
|
||||||
+345
@@ -0,0 +1,345 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentraler Validierungsbaustein für den aktuellen Editorzustand des Konfigurationseditors.
|
||||||
|
* <p>
|
||||||
|
* Dieser Validator arbeitet ausschließlich auf den übergebenen String-Werten des
|
||||||
|
* {@link EditorValidationInput}, ohne Dateisystemzugriffe, Datenbankroundtrips oder
|
||||||
|
* Netzwerkkommunikation. Er erzeugt Befunde der Stufen Fehler, Warnung, Hinweis und Info.
|
||||||
|
* <p>
|
||||||
|
* Folgende Prüfungen sind ausdrücklich ausgeschlossen (gehören in spätere technische Gesamtprüfungen):
|
||||||
|
* <ul>
|
||||||
|
* <li>Pfad-Existenzprüfungen (Quellordner, Zielordner, SQLite-Datei, Prompt-Datei)</li>
|
||||||
|
* <li>SQLite-Roundtrips</li>
|
||||||
|
* <li>Netzwerkverbindungen (Modellabruf, API-Erreichbarkeit)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* API-Key-Vorrangregel (fachlich verbindlich):
|
||||||
|
* <ol>
|
||||||
|
* <li>Providerspezifische Umgebungsvariable</li>
|
||||||
|
* <li>Bei {@code openai-compatible}: Legacy-Umgebungsvariable als Fallback</li>
|
||||||
|
* <li>Property-Wert aus der Datei</li>
|
||||||
|
* <li>ABSENT, wenn keine Quelle einen Wert liefert</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Warnlogik für {@code max.text.characters}:
|
||||||
|
* <ul>
|
||||||
|
* <li>1–1.000: unkritisch (kein Befund)</li>
|
||||||
|
* <li>1.001–3.000: Warnung</li>
|
||||||
|
* <li>ab 3.001: starke Warnung</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class EditorConfigurationValidator {
|
||||||
|
|
||||||
|
// Property-Schlüssel als Konstanten (identisch mit den .properties-Schlüsseln)
|
||||||
|
static final String FIELD_ACTIVE_PROVIDER = "ai.provider.active";
|
||||||
|
static final String FIELD_SOURCE_FOLDER = "source.folder";
|
||||||
|
static final String FIELD_TARGET_FOLDER = "target.folder";
|
||||||
|
static final String FIELD_SQLITE_FILE = "sqlite.file";
|
||||||
|
static final String FIELD_PROMPT_FILE = "prompt.template.file";
|
||||||
|
static final String FIELD_MAX_RETRIES = "max.retries.transient";
|
||||||
|
static final String FIELD_MAX_PAGES = "max.pages";
|
||||||
|
static final String FIELD_MAX_CHARS = "max.text.characters";
|
||||||
|
|
||||||
|
static final String FIELD_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
||||||
|
static final String FIELD_CLAUDE_MODEL = "ai.provider.claude.model";
|
||||||
|
static final String FIELD_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
|
||||||
|
static final String FIELD_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
|
||||||
|
|
||||||
|
static final String FIELD_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
|
||||||
|
static final String FIELD_OPENAI_MODEL = "ai.provider.openai-compatible.model";
|
||||||
|
static final String FIELD_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
|
||||||
|
static final String FIELD_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
|
||||||
|
|
||||||
|
private static final int MAX_CHARS_WARNING_THRESHOLD = 1_000;
|
||||||
|
private static final int MAX_CHARS_STRONG_WARNING_THRESHOLD = 3_000;
|
||||||
|
private static final int MAX_PAGES_HINT_THRESHOLD = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Instanz des Validators.
|
||||||
|
* <p>
|
||||||
|
* Dieser Validator benötigt keine Abhängigkeiten; alle Prüfungen sind rein in-memory.
|
||||||
|
*/
|
||||||
|
public EditorConfigurationValidator() {
|
||||||
|
// Kein State nötig; alle Prüfungen arbeiten auf dem übergebenen Input.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert den aktuellen Editorzustand und liefert einen Befund-Bericht.
|
||||||
|
* <p>
|
||||||
|
* Die Methode ist schnell (keine I/O) und darf auf dem JavaFX Application Thread
|
||||||
|
* aufgerufen werden. Der zurückgegebene Bericht ist immutable.
|
||||||
|
*
|
||||||
|
* @param input der aktuelle Editorzustand; darf nicht {@code null} sein
|
||||||
|
* @return der Validierungsbericht mit allen gefundenen Befunden; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code input} {@code null} ist
|
||||||
|
*/
|
||||||
|
public EditorValidationReport validate(EditorValidationInput input) {
|
||||||
|
Objects.requireNonNull(input, "input must not be null");
|
||||||
|
|
||||||
|
List<EditorValidationFinding> findings = new ArrayList<>();
|
||||||
|
|
||||||
|
validateActiveProvider(input, findings);
|
||||||
|
validateRequiredPaths(input, findings);
|
||||||
|
validateNumericLimits(input, findings);
|
||||||
|
validateActiveProviderFields(input, findings);
|
||||||
|
|
||||||
|
return new EditorValidationReport(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Aktiver Provider
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void validateActiveProvider(EditorValidationInput input,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
String identifier = input.activeProviderIdentifier();
|
||||||
|
if (identifier.isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
|
||||||
|
"Es muss ein aktiver Provider ausgewählt sein."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<AiProviderFamily> resolved = AiProviderFamily.fromIdentifier(identifier);
|
||||||
|
if (resolved.isEmpty()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_ACTIVE_PROVIDER,
|
||||||
|
"Der angegebene Provider '" + identifier + "' ist nicht bekannt. "
|
||||||
|
+ "Erlaubt sind: 'claude' und 'openai-compatible'."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Pflichtpfade
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void validateRequiredPaths(EditorValidationInput input,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
if (input.sourceFolder().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_SOURCE_FOLDER,
|
||||||
|
"Quellordner darf nicht leer sein."));
|
||||||
|
}
|
||||||
|
if (input.targetFolder().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_TARGET_FOLDER,
|
||||||
|
"Zielordner darf nicht leer sein."));
|
||||||
|
}
|
||||||
|
if (input.sqliteFile().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_SQLITE_FILE,
|
||||||
|
"SQLite-Datei darf nicht leer sein."));
|
||||||
|
}
|
||||||
|
if (input.promptTemplateFile().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_PROMPT_FILE,
|
||||||
|
"Prompt-Datei darf nicht leer sein."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Numerische Limits
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void validateNumericLimits(EditorValidationInput input,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
validateMaxRetriesTransient(input.maxRetriesTransient(), findings);
|
||||||
|
validateMaxPages(input.maxPages(), findings);
|
||||||
|
validateMaxTextCharacters(input.maxTextCharacters(), findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMaxRetriesTransient(String rawValue, List<EditorValidationFinding> findings) {
|
||||||
|
if (rawValue.isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||||
|
"Maximale transiente Retries darf nicht leer sein."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(rawValue.strip());
|
||||||
|
if (value < 1) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||||
|
"Maximale transiente Retries muss mindestens 1 sein (aktuell: " + value + "). "
|
||||||
|
+ "Der Wert 0 ist unzulässig."));
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_RETRIES,
|
||||||
|
"Maximale transiente Retries muss eine ganze Zahl >= 1 sein."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMaxPages(String rawValue, List<EditorValidationFinding> findings) {
|
||||||
|
if (rawValue.isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||||
|
"Maximale Seitenzahl darf nicht leer sein."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(rawValue.strip());
|
||||||
|
if (value <= 0) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||||
|
"Maximale Seitenzahl muss positiv sein (aktuell: " + value + ")."));
|
||||||
|
} else if (value > MAX_PAGES_HINT_THRESHOLD) {
|
||||||
|
findings.add(EditorValidationFinding.hint(FIELD_MAX_PAGES,
|
||||||
|
"Plausibilitätshinweis: Über " + MAX_PAGES_HINT_THRESHOLD
|
||||||
|
+ " Seiten je Datei könnten die Verarbeitung verlangsamen (aktuell: " + value + ")."));
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_PAGES,
|
||||||
|
"Maximale Seitenzahl muss eine positive ganze Zahl sein."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMaxTextCharacters(String rawValue, List<EditorValidationFinding> findings) {
|
||||||
|
if (rawValue.isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||||
|
"Maximale Zeichenzahl darf nicht leer sein."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(rawValue.strip());
|
||||||
|
if (value <= 0) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||||
|
"Maximale Zeichenzahl muss positiv sein (aktuell: " + value + ")."));
|
||||||
|
} else if (value > MAX_CHARS_STRONG_WARNING_THRESHOLD) {
|
||||||
|
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
|
||||||
|
"Stark erhöhte Zeichenmenge: " + value + " Zeichen können zu riskant hohen "
|
||||||
|
+ "API-Kosten je Verarbeitungsaufruf führen."));
|
||||||
|
} else if (value > MAX_CHARS_WARNING_THRESHOLD) {
|
||||||
|
findings.add(EditorValidationFinding.warning(FIELD_MAX_CHARS,
|
||||||
|
"Erhöhte Zeichenmenge: " + value + " Zeichen. Beachten Sie mögliche Auswirkungen "
|
||||||
|
+ "auf die API-Kosten je Verarbeitungsaufruf."));
|
||||||
|
}
|
||||||
|
// 1–1000: unkritisch, kein Befund
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_MAX_CHARS,
|
||||||
|
"Maximale Zeichenzahl muss eine positive ganze Zahl sein."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Aktiver Provider – providerabhängige Felder
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void validateActiveProviderFields(EditorValidationInput input,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
Optional<AiProviderFamily> resolvedProvider =
|
||||||
|
AiProviderFamily.fromIdentifier(input.activeProviderIdentifier());
|
||||||
|
if (resolvedProvider.isEmpty()) {
|
||||||
|
// Provider unbekannt – feldspezifische Provider-Prüfungen nicht möglich
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiProviderFamily family = resolvedProvider.get();
|
||||||
|
switch (family) {
|
||||||
|
case CLAUDE -> validateClaudeFields(input, findings);
|
||||||
|
case OPENAI_COMPATIBLE -> validateOpenAiFields(input, findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateClaudeFields(EditorValidationInput input,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
// Basis-URL: leer ist Warnung (Default-URL wird verwendet)
|
||||||
|
if (input.claudeBaseUrl().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.warning(FIELD_CLAUDE_BASE_URL,
|
||||||
|
"Basis-URL nicht gesetzt – es wird die Standard-URL verwendet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modell: Pflichtfeld
|
||||||
|
if (input.claudeModel().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_CLAUDE_MODEL,
|
||||||
|
"Modellname darf nicht leer sein."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
validateTimeoutField(input.claudeTimeoutSeconds(), FIELD_CLAUDE_TIMEOUT, findings);
|
||||||
|
|
||||||
|
// API-Key
|
||||||
|
validateApiKeyFindings(input.claudeApiKeyDescriptor(), FIELD_CLAUDE_API_KEY, findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateOpenAiFields(EditorValidationInput input,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
// Basis-URL: leer ist Warnung
|
||||||
|
if (input.openaiBaseUrl().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.warning(FIELD_OPENAI_BASE_URL,
|
||||||
|
"Basis-URL nicht gesetzt – es wird die Standard-URL verwendet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modell: Pflichtfeld
|
||||||
|
if (input.openaiModel().isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(FIELD_OPENAI_MODEL,
|
||||||
|
"Modellname darf nicht leer sein."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
validateTimeoutField(input.openaiTimeoutSeconds(), FIELD_OPENAI_TIMEOUT, findings);
|
||||||
|
|
||||||
|
// API-Key
|
||||||
|
validateApiKeyFindings(input.openaiApiKeyDescriptor(), FIELD_OPENAI_API_KEY, findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateTimeoutField(String rawValue, String fieldKey,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
if (rawValue.isBlank()) {
|
||||||
|
findings.add(EditorValidationFinding.error(fieldKey,
|
||||||
|
"Timeout-Wert darf nicht leer sein."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(rawValue.strip());
|
||||||
|
if (value <= 0) {
|
||||||
|
findings.add(EditorValidationFinding.error(fieldKey,
|
||||||
|
"Timeout-Wert muss positiv sein (aktuell: " + value + ")."));
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
findings.add(EditorValidationFinding.error(fieldKey,
|
||||||
|
"Timeout-Wert muss eine positive ganze Zahl sein."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt API-Key-Befunde gemäß der Vorrangregel.
|
||||||
|
* <p>
|
||||||
|
* Vorrangregel:
|
||||||
|
* <ol>
|
||||||
|
* <li>ENV-Variable aktiv: INFO-Befund mit Variablenname</li>
|
||||||
|
* <li>Property-Wert in Datei, keine ENV: normal (kein Befund)</li>
|
||||||
|
* <li>Leeres Property-Feld und keine ENV: WARNING</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @param descriptor Herkunft des effektiven API-Schlüssels
|
||||||
|
* @param fieldKey Property-Schlüssel des API-Key-Felds
|
||||||
|
* @param findings Zielliste für neue Befunde
|
||||||
|
*/
|
||||||
|
private void validateApiKeyFindings(EffectiveApiKeyDescriptor descriptor,
|
||||||
|
String fieldKey,
|
||||||
|
List<EditorValidationFinding> findings) {
|
||||||
|
ApiKeyOrigin origin = descriptor.origin();
|
||||||
|
|
||||||
|
switch (origin) {
|
||||||
|
case FROM_PROVIDER_ENV_VAR -> {
|
||||||
|
String varName = descriptor.envVarName().orElse("unbekannte ENV-Variable");
|
||||||
|
findings.add(EditorValidationFinding.info(fieldKey,
|
||||||
|
"API-Schlüssel stammt aus Umgebungsvariable " + varName
|
||||||
|
+ " (hat Vorrang vor dem Datei-Wert)."));
|
||||||
|
}
|
||||||
|
case FROM_LEGACY_ENV_VAR -> {
|
||||||
|
String varName = descriptor.envVarName().orElse("unbekannte Legacy-ENV-Variable");
|
||||||
|
findings.add(EditorValidationFinding.info(fieldKey,
|
||||||
|
"API-Schlüssel stammt aus Legacy-Umgebungsvariable " + varName
|
||||||
|
+ " (hat Vorrang vor dem Datei-Wert)."));
|
||||||
|
}
|
||||||
|
case FROM_PROPERTY_FILE -> {
|
||||||
|
// Property-Wert vorhanden, kein ENV-Override: alles in Ordnung, kein Befund
|
||||||
|
}
|
||||||
|
case ABSENT -> {
|
||||||
|
findings.add(EditorValidationFinding.warning(fieldKey,
|
||||||
|
"Kein API-Schlüssel hinterlegt. Ohne Schlüssel kann der Provider "
|
||||||
|
+ "nicht genutzt werden."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Validierungsbefund für den aktuellen Editorzustand.
|
||||||
|
* <p>
|
||||||
|
* Ein Befund beschreibt eine erkannte Auffälligkeit in der Konfiguration mit einem
|
||||||
|
* Schweregrad, einem Feldbezug und einer deutschen Beschreibung. Das {@code fieldKey}-Feld
|
||||||
|
* verwendet denselben Property-Schlüssel wie die {@code .properties}-Datei
|
||||||
|
* (z. B. {@code "source.folder"} oder {@code "ai.provider.openai-compatible.apiKey"}).
|
||||||
|
* <p>
|
||||||
|
* Ein Befund kann entweder feld-spezifisch sein (mit gesetztem {@code fieldKey}) oder
|
||||||
|
* allgemein (mit leerem {@code fieldKey}), wenn er sich nicht auf ein einzelnes Feld bezieht.
|
||||||
|
* <p>
|
||||||
|
* Befunde sind immutable und enthalten keine JavaFX-Typen.
|
||||||
|
*
|
||||||
|
* @param fieldKey optionaler Property-Schlüssel des betroffenen Felds; leer wenn feld-unabhängig
|
||||||
|
* @param severity Schweregrad des Befunds; nie {@code null}
|
||||||
|
* @param message deutschsprachige Beschreibung; nie {@code null}
|
||||||
|
*/
|
||||||
|
public record EditorValidationFinding(
|
||||||
|
Optional<String> fieldKey,
|
||||||
|
EditorValidationSeverity severity,
|
||||||
|
String message) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Validierungsbefund.
|
||||||
|
*
|
||||||
|
* @param fieldKey optionaler Property-Schlüssel; {@code null} wird zu {@link Optional#empty()}
|
||||||
|
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||||
|
* @param message deutschsprachige Beschreibung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code severity} oder {@code message} {@code null} sind
|
||||||
|
*/
|
||||||
|
public EditorValidationFinding {
|
||||||
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
|
Objects.requireNonNull(message, "message must not be null");
|
||||||
|
fieldKey = fieldKey == null ? Optional.empty() : fieldKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen feldbezogenen Fehler-Befund.
|
||||||
|
*
|
||||||
|
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||||
|
* @param message deutschsprachige Fehlerbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#ERROR}
|
||||||
|
*/
|
||||||
|
public static EditorValidationFinding error(String fieldKey, String message) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen feldbezogenen Warn-Befund.
|
||||||
|
*
|
||||||
|
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||||
|
* @param message deutschsprachige Warnbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#WARNING}
|
||||||
|
*/
|
||||||
|
public static EditorValidationFinding warning(String fieldKey, String message) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.WARNING, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen feldbezogenen Hinweis-Befund.
|
||||||
|
*
|
||||||
|
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||||
|
* @param message deutschsprachige Hinweisbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#HINT}
|
||||||
|
*/
|
||||||
|
public static EditorValidationFinding hint(String fieldKey, String message) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.HINT, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen allgemeinen Informationsbefund ohne Feldbezug.
|
||||||
|
*
|
||||||
|
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
|
||||||
|
*/
|
||||||
|
public static EditorValidationFinding info(String message) {
|
||||||
|
return new EditorValidationFinding(Optional.empty(), EditorValidationSeverity.INFO, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen feldbezogenen Informationsbefund.
|
||||||
|
*
|
||||||
|
* @param fieldKey Property-Schlüssel des betroffenen Felds; darf nicht {@code null} sein
|
||||||
|
* @param message deutschsprachige Informationsbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Befund mit Schweregrad {@link EditorValidationSeverity#INFO}
|
||||||
|
*/
|
||||||
|
public static EditorValidationFinding info(String fieldKey, String message) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return new EditorValidationFinding(Optional.of(fieldKey), EditorValidationSeverity.INFO, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob dieser Befund feld-spezifisch ist.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn ein {@code fieldKey} gesetzt ist
|
||||||
|
*/
|
||||||
|
public boolean hasFieldKey() {
|
||||||
|
return fieldKey.isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingabedaten für den {@link EditorConfigurationValidator}.
|
||||||
|
* <p>
|
||||||
|
* Enthält den gesamten aktuellen Editorzustand als String-Werte, so wie sie im Editor
|
||||||
|
* vorliegen – ohne Parsing, Typumwandlung oder Existenzprüfungen. Der Validator bewertet
|
||||||
|
* ausschließlich diese Werte.
|
||||||
|
* <p>
|
||||||
|
* Pfad-Existenzprüfungen und Roundtrips (SQLite, Prompt-Datei, Netzwerk) sind explizit
|
||||||
|
* ausgeschlossen und gehören in spätere technische Gesamtprüfungen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
||||||
|
*
|
||||||
|
* @param activeProviderIdentifier Rohtextwert von {@code ai.provider.active}
|
||||||
|
* @param sourceFolder Rohtextwert von {@code source.folder}
|
||||||
|
* @param targetFolder Rohtextwert von {@code target.folder}
|
||||||
|
* @param sqliteFile Rohtextwert von {@code sqlite.file}
|
||||||
|
* @param promptTemplateFile Rohtextwert von {@code prompt.template.file}
|
||||||
|
* @param maxRetriesTransient Rohtextwert von {@code max.retries.transient}
|
||||||
|
* @param maxPages Rohtextwert von {@code max.pages}
|
||||||
|
* @param maxTextCharacters Rohtextwert von {@code max.text.characters}
|
||||||
|
* @param claudeBaseUrl Rohtextwert der Claude-Basis-URL
|
||||||
|
* @param claudeModel Rohtextwert des Claude-Modellnamens
|
||||||
|
* @param claudeTimeoutSeconds Rohtextwert des Claude-Timeouts
|
||||||
|
* @param claudeApiKeyDescriptor API-Key-Herkunft für den Claude-Provider; nie {@code null}
|
||||||
|
* @param openaiBaseUrl Rohtextwert der OpenAI-kompatiblen Basis-URL
|
||||||
|
* @param openaiModel Rohtextwert des OpenAI-kompatiblen Modellnamens
|
||||||
|
* @param openaiTimeoutSeconds Rohtextwert des OpenAI-kompatiblen Timeouts
|
||||||
|
* @param openaiApiKeyDescriptor API-Key-Herkunft für den OpenAI-kompatiblen Provider; nie {@code null}
|
||||||
|
*/
|
||||||
|
public record EditorValidationInput(
|
||||||
|
String activeProviderIdentifier,
|
||||||
|
String sourceFolder,
|
||||||
|
String targetFolder,
|
||||||
|
String sqliteFile,
|
||||||
|
String promptTemplateFile,
|
||||||
|
String maxRetriesTransient,
|
||||||
|
String maxPages,
|
||||||
|
String maxTextCharacters,
|
||||||
|
String claudeBaseUrl,
|
||||||
|
String claudeModel,
|
||||||
|
String claudeTimeoutSeconds,
|
||||||
|
EffectiveApiKeyDescriptor claudeApiKeyDescriptor,
|
||||||
|
String openaiBaseUrl,
|
||||||
|
String openaiModel,
|
||||||
|
String openaiTimeoutSeconds,
|
||||||
|
EffectiveApiKeyDescriptor openaiApiKeyDescriptor) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Eingabe für den Validator.
|
||||||
|
*
|
||||||
|
* @param activeProviderIdentifier aktiver Provider-Bezeichner; {@code null} wird zu leerem String
|
||||||
|
* @param sourceFolder Quellordner-Pfad; {@code null} wird zu leerem String
|
||||||
|
* @param targetFolder Zielordner-Pfad; {@code null} wird zu leerem String
|
||||||
|
* @param sqliteFile SQLite-Dateipfad; {@code null} wird zu leerem String
|
||||||
|
* @param promptTemplateFile Prompt-Dateipfad; {@code null} wird zu leerem String
|
||||||
|
* @param maxRetriesTransient max. transiente Retries; {@code null} wird zu leerem String
|
||||||
|
* @param maxPages max. Seitenzahl; {@code null} wird zu leerem String
|
||||||
|
* @param maxTextCharacters max. Zeichenzahl; {@code null} wird zu leerem String
|
||||||
|
* @param claudeBaseUrl Claude-Basis-URL; {@code null} wird zu leerem String
|
||||||
|
* @param claudeModel Claude-Modellname; {@code null} wird zu leerem String
|
||||||
|
* @param claudeTimeoutSeconds Claude-Timeout; {@code null} wird zu leerem String
|
||||||
|
* @param claudeApiKeyDescriptor Claude-API-Key-Herkunft; darf nicht {@code null} sein
|
||||||
|
* @param openaiBaseUrl OpenAI-Basis-URL; {@code null} wird zu leerem String
|
||||||
|
* @param openaiModel OpenAI-Modellname; {@code null} wird zu leerem String
|
||||||
|
* @param openaiTimeoutSeconds OpenAI-Timeout; {@code null} wird zu leerem String
|
||||||
|
* @param openaiApiKeyDescriptor OpenAI-API-Key-Herkunft; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code claudeApiKeyDescriptor} oder {@code openaiApiKeyDescriptor} {@code null} sind
|
||||||
|
*/
|
||||||
|
public EditorValidationInput {
|
||||||
|
activeProviderIdentifier = normalizeText(activeProviderIdentifier);
|
||||||
|
sourceFolder = normalizeText(sourceFolder);
|
||||||
|
targetFolder = normalizeText(targetFolder);
|
||||||
|
sqliteFile = normalizeText(sqliteFile);
|
||||||
|
promptTemplateFile = normalizeText(promptTemplateFile);
|
||||||
|
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
||||||
|
maxPages = normalizeText(maxPages);
|
||||||
|
maxTextCharacters = normalizeText(maxTextCharacters);
|
||||||
|
claudeBaseUrl = normalizeText(claudeBaseUrl);
|
||||||
|
claudeModel = normalizeText(claudeModel);
|
||||||
|
claudeTimeoutSeconds = normalizeText(claudeTimeoutSeconds);
|
||||||
|
claudeApiKeyDescriptor = Objects.requireNonNull(claudeApiKeyDescriptor,
|
||||||
|
"claudeApiKeyDescriptor must not be null");
|
||||||
|
openaiBaseUrl = normalizeText(openaiBaseUrl);
|
||||||
|
openaiModel = normalizeText(openaiModel);
|
||||||
|
openaiTimeoutSeconds = normalizeText(openaiTimeoutSeconds);
|
||||||
|
openaiApiKeyDescriptor = Objects.requireNonNull(openaiApiKeyDescriptor,
|
||||||
|
"openaiApiKeyDescriptor must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeText(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer editornahen Konfigurationsvalidierung.
|
||||||
|
* <p>
|
||||||
|
* Enthält alle Befunde, die der {@link EditorConfigurationValidator} aus dem aktuellen
|
||||||
|
* Editorzustand abgeleitet hat. Das Ergebnis ist immutable.
|
||||||
|
* <p>
|
||||||
|
* Befunde können über {@link #findings()} als vollständige Liste abgerufen werden.
|
||||||
|
* Feldbezogene Befunde lassen sich über {@link EditorValidationFinding#hasFieldKey()} filtern.
|
||||||
|
*
|
||||||
|
* @param findings alle Validierungsbefunde; nie {@code null}
|
||||||
|
*/
|
||||||
|
public record EditorValidationReport(List<EditorValidationFinding> findings) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Validierungsergebnis.
|
||||||
|
*
|
||||||
|
* @param findings Befundliste; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code findings} {@code null} ist
|
||||||
|
*/
|
||||||
|
public EditorValidationReport {
|
||||||
|
Objects.requireNonNull(findings, "findings must not be null");
|
||||||
|
findings = List.copyOf(findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein leeres Ergebnis ohne Befunde.
|
||||||
|
*
|
||||||
|
* @return ein leeres Ergebnis; nie {@code null}
|
||||||
|
*/
|
||||||
|
public static EditorValidationReport empty() {
|
||||||
|
return new EditorValidationReport(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob mindestens ein Befund mit Schweregrad {@link EditorValidationSeverity#ERROR} vorhanden ist.
|
||||||
|
* <p>
|
||||||
|
* Wenn {@code true}, gilt die Konfiguration als nicht lauffähig. Das Speichern ist jedoch
|
||||||
|
* trotzdem erlaubt.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein Fehler-Befund vorliegt
|
||||||
|
*/
|
||||||
|
public boolean hasErrors() {
|
||||||
|
return findings.stream().anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle Befunde zurück, die sich auf das angegebene Feld beziehen.
|
||||||
|
*
|
||||||
|
* @param fieldKey Property-Schlüssel des gesuchten Felds; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der feldbezogenen Befunde; nie {@code null}
|
||||||
|
*/
|
||||||
|
public List<EditorValidationFinding> findingsFor(String fieldKey) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return findings.stream()
|
||||||
|
.filter(f -> f.fieldKey().isPresent() && f.fieldKey().get().equals(fieldKey))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schweregrade für Befunde der editornahen Konfigurationsvalidierung.
|
||||||
|
* <p>
|
||||||
|
* Die Reihenfolge entspricht aufsteigender Kritikalität:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #INFO} – neutraler Informationshinweis, keine Handlung erforderlich</li>
|
||||||
|
* <li>{@link #HINT} – nützlicher Hinweis, den der Benutzer berücksichtigen sollte</li>
|
||||||
|
* <li>{@link #WARNING} – riskante, aber formal zulässige Einstellung</li>
|
||||||
|
* <li>{@link #ERROR} – ungültige oder fehlende Pflichtangabe, Konfiguration nicht lauffähig</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Warnungen und Hinweise verhindern das Speichern nicht. Fehler markieren den Stand als
|
||||||
|
* nicht lauffähig, erlauben aber ebenfalls das Speichern.
|
||||||
|
*/
|
||||||
|
public enum EditorValidationSeverity {
|
||||||
|
|
||||||
|
/** Neutraler Informationshinweis. */
|
||||||
|
INFO,
|
||||||
|
|
||||||
|
/** Nützlicher Hinweis, den der Benutzer beachten sollte. */
|
||||||
|
HINT,
|
||||||
|
|
||||||
|
/** Riskante, aber formal zulässige Einstellung. */
|
||||||
|
WARNING,
|
||||||
|
|
||||||
|
/** Ungültige oder fehlende Pflichtangabe – Konfiguration ist nicht lauffähig. */
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Editornahe Validierungskomponenten für den Konfigurationseditor.
|
||||||
|
* <p>
|
||||||
|
* Dieses Package enthält den zentralen {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator},
|
||||||
|
* der den aktuellen Editorzustand gegen fachliche und technische Regeln prüft und
|
||||||
|
* Befunde der Stufen Fehler, Warnung, Hinweis und Info erzeugt.
|
||||||
|
* <p>
|
||||||
|
* Die Komponenten sind infrastrukturneutral: Sie kennen keine JavaFX-Typen, keine
|
||||||
|
* Dateisystempfade, keine Datenbankzugriffe und keine HTTP-Kommunikation. Alle Prüfungen
|
||||||
|
* arbeiten ausschließlich auf den übergebenen String-Werten des aktuellen Editorzustands.
|
||||||
|
* <p>
|
||||||
|
* Die Ergebnistypen ({@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding}
|
||||||
|
* und {@link de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport}) sind immutable
|
||||||
|
* Records und enthalten keine Infrastrukturabhängigkeiten.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eindeutiger Bezeichner für jeden definierten Prüfpunkt des technischen Gesamttests.
|
||||||
|
* <p>
|
||||||
|
* Jeder Wert entspricht genau einem Prüfpunkt, der im Rahmen der Aktion
|
||||||
|
* „Technische Tests ausführen" durchlaufen wird. Die Reihenfolge der Konstanten
|
||||||
|
* ist nicht verbindlich für die Ausführungsreihenfolge; sie dient nur der
|
||||||
|
* Übersichtlichkeit.
|
||||||
|
* <p>
|
||||||
|
* Prüfpunkte sind unabhängig voneinander; ein Fehler in einem Prüfpunkt darf
|
||||||
|
* nicht dazu führen, dass spätere Prüfpunkte übersprungen werden. Wenn ein
|
||||||
|
* Prüfpunkt wegen fehlender Voraussetzungen nicht ausführbar ist (z. B.
|
||||||
|
* API-Key-Test ohne bekannte Base-URL), ist das Ergebnis
|
||||||
|
* {@link CheckpointResult.NotApplicable}, kein Fehler.
|
||||||
|
*/
|
||||||
|
public enum CheckpointId {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grundlegende Konfigurationsvalidierung – entspricht der lokalen Editorvalidierung.
|
||||||
|
* Prüft formale Pflichtfelder und Werteformate ohne Dateisystem- oder Netzwerkkontakt.
|
||||||
|
*/
|
||||||
|
CONFIGURATION_BASIC_VALIDATION,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-Konfiguration prüfen: aktiver Provider bekannt, alle Pflichtfelder
|
||||||
|
* des aktiven Providers formal ausgefüllt.
|
||||||
|
*/
|
||||||
|
PROVIDER_CONFIGURATION,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base-URL bzw. Endpunkt des aktiven Providers technisch erreichbar (Netzwerktest).
|
||||||
|
*/
|
||||||
|
BASE_URL_REACHABLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-Key vorhanden – mindestens eine Quelle (Umgebungsvariable oder Properties-Datei)
|
||||||
|
* liefert einen nicht leeren Wert. Dieser Prüfpunkt trifft keine Aussage über die
|
||||||
|
* Korrektheit des Schlüssels.
|
||||||
|
*/
|
||||||
|
API_KEY_PRESENT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-Key technisch akzeptiert – Authentifizierung am Provider-Endpunkt erfolgreich.
|
||||||
|
* Setzt voraus, dass {@link #API_KEY_PRESENT} bestanden wurde; andernfalls ist dieser
|
||||||
|
* Prüfpunkt {@link CheckpointResult.NotApplicable}.
|
||||||
|
*/
|
||||||
|
API_KEY_ACCEPTED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modellliste abrufbar – der Provider liefert eine nicht leere Liste verfügbarer Modelle.
|
||||||
|
* Nutzt denselben Outbound-Port wie der automatische Modellabruf; keine zweite Implementierung.
|
||||||
|
*/
|
||||||
|
MODEL_LIST_AVAILABLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausgewähltes Modell plausibel – der konfigurierte Modellname ist in der zuletzt
|
||||||
|
* geladenen Modellliste vorhanden oder formal zulässig.
|
||||||
|
* Setzt voraus, dass {@link #MODEL_LIST_AVAILABLE} bestanden wurde.
|
||||||
|
*/
|
||||||
|
SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt-Datei vorhanden und lesbar – die konfigurierte Prompt-Datei existiert im
|
||||||
|
* Dateisystem und kann gelesen werden.
|
||||||
|
*/
|
||||||
|
PROMPT_FILE_PRESENT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quellordner vorhanden und lesbar – der konfigurierte Quellordner existiert und
|
||||||
|
* kann vom Prozess gelesen werden.
|
||||||
|
*/
|
||||||
|
SOURCE_FOLDER_PRESENT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielordner vorhanden oder anlegbar sowie schreibbar – der konfigurierte Zielordner
|
||||||
|
* existiert und ist schreibbar, oder er ist noch nicht vorhanden, aber der Pfad ist
|
||||||
|
* technisch anlegbar.
|
||||||
|
*/
|
||||||
|
TARGET_FOLDER_USABLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-Datei bzw. SQLite-Pfad technisch nutzbar – der konfigurierte SQLite-Pfad
|
||||||
|
* zeigt auf eine vorhandene Datei oder auf einen beschreibbaren Ordner, in dem die
|
||||||
|
* Datei neu angelegt werden kann.
|
||||||
|
*/
|
||||||
|
SQLITE_PATH_USABLE
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis eines einzelnen Prüfpunkts des technischen Gesamttests.
|
||||||
|
* <p>
|
||||||
|
* Jeder Prüfpunkt liefert genau einen der drei möglichen Zustände:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Success} – der Prüfpunkt wurde bestanden.</li>
|
||||||
|
* <li>{@link Failure} – der Prüfpunkt wurde nicht bestanden (Fehler oder Warnung),
|
||||||
|
* optional mit einem Korrekturvorschlag.</li>
|
||||||
|
* <li>{@link NotApplicable} – der Prüfpunkt konnte wegen fehlender Voraussetzungen
|
||||||
|
* nicht ausgeführt werden (z. B. API-Key-Test ohne vorhandenen API-Key).
|
||||||
|
* Dies ist kein Fehler, sondern ein eigenständiger Zustand.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Alle Implementierungen sind immutable und enthalten keine JavaFX-Typen. Sie können
|
||||||
|
* auf beliebigen Threads erzeugt und sicher an den JavaFX Application Thread übergeben werden.
|
||||||
|
*/
|
||||||
|
public sealed interface CheckpointResult
|
||||||
|
permits CheckpointResult.Success,
|
||||||
|
CheckpointResult.Failure,
|
||||||
|
CheckpointResult.NotApplicable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Bezeichner des Prüfpunkts zurück, zu dem dieses Ergebnis gehört.
|
||||||
|
*
|
||||||
|
* @return Prüfpunkt-Bezeichner; nie {@code null}
|
||||||
|
*/
|
||||||
|
CheckpointId checkpointId();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Prüfpunkt wurde bestanden.
|
||||||
|
*
|
||||||
|
* @param checkpointId Bezeichner des bestandenen Prüfpunkts; nie {@code null}
|
||||||
|
* @param message deutsche Bestätigungsmeldung; nie {@code null}
|
||||||
|
*/
|
||||||
|
record Success(
|
||||||
|
CheckpointId checkpointId,
|
||||||
|
String message) implements CheckpointResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Erfolgs-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @param message deutsche Bestätigungsmeldung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code checkpointId} oder {@code message} {@code null} sind
|
||||||
|
*/
|
||||||
|
public Success {
|
||||||
|
Objects.requireNonNull(checkpointId, "checkpointId must not be null");
|
||||||
|
Objects.requireNonNull(message, "message must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Prüfpunkt wurde nicht bestanden.
|
||||||
|
* <p>
|
||||||
|
* Ein gescheiterter Prüfpunkt hat immer einen Schweregrad ({@link CheckpointSeverity})
|
||||||
|
* und eine deutsche Fehlermeldung. Optional ist ein {@link CorrectionSuggestion}
|
||||||
|
* beigefügt, wenn eine sichere technische Korrektur möglich ist.
|
||||||
|
* <p>
|
||||||
|
* Ein Failure mit Schweregrad {@link CheckpointSeverity#WARNING} markiert eine
|
||||||
|
* riskante, aber formal zulässige Einstellung. Ein Failure mit
|
||||||
|
* {@link CheckpointSeverity#ERROR} zeigt an, dass der Gesamtstand nicht lauffähig ist.
|
||||||
|
*
|
||||||
|
* @param checkpointId Bezeichner des nicht bestandenen Prüfpunkts; nie {@code null}
|
||||||
|
* @param severity Schweregrad; nie {@code null}
|
||||||
|
* @param message deutsche Fehlermeldung; nie {@code null}
|
||||||
|
* @param correctionSuggestion optionaler Korrekturvorschlag; leer wenn keine Korrektur angeboten wird
|
||||||
|
*/
|
||||||
|
record Failure(
|
||||||
|
CheckpointId checkpointId,
|
||||||
|
CheckpointSeverity severity,
|
||||||
|
String message,
|
||||||
|
Optional<CorrectionSuggestion> correctionSuggestion) implements CheckpointResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Fehler-Ergebnis mit optionalem Korrekturvorschlag.
|
||||||
|
*
|
||||||
|
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||||
|
* @param message deutsche Fehlermeldung; darf nicht {@code null} sein
|
||||||
|
* @param correctionSuggestion optionaler Vorschlag; {@code null} wird zu leerem Optional
|
||||||
|
* @throws NullPointerException wenn {@code checkpointId}, {@code severity} oder {@code message} {@code null} sind
|
||||||
|
*/
|
||||||
|
public Failure {
|
||||||
|
Objects.requireNonNull(checkpointId, "checkpointId must not be null");
|
||||||
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
|
Objects.requireNonNull(message, "message must not be null");
|
||||||
|
correctionSuggestion = correctionSuggestion == null
|
||||||
|
? Optional.empty()
|
||||||
|
: correctionSuggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Fehler-Ergebnis ohne Korrekturvorschlag.
|
||||||
|
*
|
||||||
|
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||||
|
* @param message deutsche Fehlermeldung; darf nicht {@code null} sein
|
||||||
|
* @return ein neues Failure-Ergebnis ohne Korrekturvorschlag
|
||||||
|
*/
|
||||||
|
public static Failure of(CheckpointId checkpointId, CheckpointSeverity severity, String message) {
|
||||||
|
return new Failure(checkpointId, severity, message, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Fehler-Ergebnis mit einem Korrekturvorschlag.
|
||||||
|
*
|
||||||
|
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||||
|
* @param message deutsche Fehlermeldung; darf nicht {@code null} sein
|
||||||
|
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||||
|
* @return ein neues Failure-Ergebnis mit Korrekturvorschlag
|
||||||
|
*/
|
||||||
|
public static Failure withCorrection(CheckpointId checkpointId, CheckpointSeverity severity,
|
||||||
|
String message, CorrectionSuggestion suggestion) {
|
||||||
|
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||||
|
return new Failure(checkpointId, severity, message, Optional.of(suggestion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob zu diesem Befund ein Korrekturvorschlag vorliegt.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn ein Korrekturvorschlag vorhanden ist
|
||||||
|
*/
|
||||||
|
public boolean hasCorrectionSuggestion() {
|
||||||
|
return correctionSuggestion.isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Prüfpunkt konnte wegen fehlender Voraussetzungen nicht ausgeführt werden.
|
||||||
|
* <p>
|
||||||
|
* Beispiel: Der Prüfpunkt {@link CheckpointId#API_KEY_ACCEPTED} ist nicht ausführbar,
|
||||||
|
* wenn {@link CheckpointId#API_KEY_PRESENT} zuvor als Fehler bewertet wurde.
|
||||||
|
* <p>
|
||||||
|
* {@code NotApplicable} ist kein Fehler; er wird im Meldungsbereich neutral dargestellt
|
||||||
|
* und wird nicht als Korrekturanlass behandelt.
|
||||||
|
*
|
||||||
|
* @param checkpointId Bezeichner des nicht ausgeführten Prüfpunkts; nie {@code null}
|
||||||
|
* @param reason deutsche Begründung, warum der Prüfpunkt übersprungen wurde; nie {@code null}
|
||||||
|
*/
|
||||||
|
record NotApplicable(
|
||||||
|
CheckpointId checkpointId,
|
||||||
|
String reason) implements CheckpointResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Nicht-Anwendbar-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @param reason deutsche Begründung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code checkpointId} oder {@code reason} {@code null} sind
|
||||||
|
*/
|
||||||
|
public NotApplicable {
|
||||||
|
Objects.requireNonNull(checkpointId, "checkpointId must not be null");
|
||||||
|
Objects.requireNonNull(reason, "reason must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schweregrade für gescheiterte Prüfpunkte ({@link CheckpointResult.Failure}) des technischen Gesamttests.
|
||||||
|
* <p>
|
||||||
|
* Die Schweregrade sind analog zu den Stufen der editornahen Validierung
|
||||||
|
* ({@code EditorValidationSeverity}), jedoch auf die Semantik des technischen Gesamttests
|
||||||
|
* zugeschnitten:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #WARNING} – riskante, aber technisch zulässige Einstellung. Das Speichern und
|
||||||
|
* ein späterer headless-Lauf sind möglich, können aber unerwartetes Verhalten zeigen.</li>
|
||||||
|
* <li>{@link #ERROR} – ungültige oder fehlende Konfiguration. Die Einstellung ist im
|
||||||
|
* aktuellen Zustand nicht lauffähig.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Hinweise und neutrale Informationen werden als {@link CheckpointResult.Success} oder
|
||||||
|
* {@link CheckpointResult.NotApplicable} modelliert, nicht als Failure mit diesem Enum.
|
||||||
|
*/
|
||||||
|
public enum CheckpointSeverity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riskante, aber technisch zulässige Einstellung.
|
||||||
|
* Speichern und ein späterer headless-Lauf bleiben möglich.
|
||||||
|
*/
|
||||||
|
WARNING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ungültige oder fehlende Einstellung – Konfiguration ist im aktuellen Zustand nicht lauffähig.
|
||||||
|
*/
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamtergebnis der Ausführung eines bestätigten {@link CorrectionPlan}.
|
||||||
|
* <p>
|
||||||
|
* Enthält für jeden im Plan enthaltenen Korrekturvorschlag ein {@link CorrectionOutcome}.
|
||||||
|
* Die Reihenfolge der Ergebnisse entspricht der Reihenfolge der Vorschläge im Plan.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record ist immutable und enthält keine JavaFX-Typen.
|
||||||
|
*
|
||||||
|
* @param outcomes Ergebnisliste in Ausführungsreihenfolge; nie {@code null}
|
||||||
|
*/
|
||||||
|
public record CorrectionExecutionReport(List<CorrectionOutcome> outcomes) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Ausführungsbericht.
|
||||||
|
*
|
||||||
|
* @param outcomes Liste der Ausführungsergebnisse; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code outcomes} {@code null} ist
|
||||||
|
*/
|
||||||
|
public CorrectionExecutionReport {
|
||||||
|
Objects.requireNonNull(outcomes, "outcomes must not be null");
|
||||||
|
outcomes = List.copyOf(outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob alle Korrekturen erfolgreich angewendet wurden.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code true} zurück, wenn alle Ergebnisse vom Typ {@link CorrectionOutcome.Applied}
|
||||||
|
* sind und der Bericht mindestens einen Eintrag enthält.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein Eintrag vorhanden ist und alle angewendet wurden
|
||||||
|
*/
|
||||||
|
public boolean allApplied() {
|
||||||
|
return !outcomes.isEmpty()
|
||||||
|
&& outcomes.stream().allMatch(o -> o instanceof CorrectionOutcome.Applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob mindestens eine Korrektur gescheitert ist.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein {@link CorrectionOutcome.Failed} vorliegt
|
||||||
|
*/
|
||||||
|
public boolean hasFailures() {
|
||||||
|
return outcomes.stream().anyMatch(o -> o instanceof CorrectionOutcome.Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob mindestens eine Korrektur nicht versucht wurde.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein {@link CorrectionOutcome.NotAttempted} vorliegt
|
||||||
|
*/
|
||||||
|
public boolean hasNotAttempted() {
|
||||||
|
return outcomes.stream().anyMatch(o -> o instanceof CorrectionOutcome.NotAttempted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Gesamtzahl der Ergebnisse zurück.
|
||||||
|
*
|
||||||
|
* @return Anzahl der Ergebnisse; nie negativ
|
||||||
|
*/
|
||||||
|
public int size() {
|
||||||
|
return outcomes.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen bestätigten {@link CorrectionPlan} aus, indem er jeden enthaltenen
|
||||||
|
* {@link CorrectionSuggestion}-Vorschlag über den {@link ResourceCreationPort} ausführt.
|
||||||
|
* <p>
|
||||||
|
* Der Service iteriert alle Vorschläge im Plan und gibt pro Vorschlag ein
|
||||||
|
* {@link CorrectionOutcome} an den {@link ResourceCreationPort} weiter. Das Gesamtergebnis
|
||||||
|
* wird als {@link CorrectionExecutionReport} zurückgegeben.
|
||||||
|
*
|
||||||
|
* <h2>Kein Frühabbruch</h2>
|
||||||
|
* <p>
|
||||||
|
* Wenn eine Korrektur scheitert, laufen alle weiteren Korrekturen trotzdem weiter.
|
||||||
|
* Ein einzelnes {@link CorrectionOutcome.Failed} führt nicht zum Abbruch.
|
||||||
|
*
|
||||||
|
* <h2>Aufrufkonvention</h2>
|
||||||
|
* <p>
|
||||||
|
* Dieser Service darf nur nach ausdrücklicher Benutzerbestätigung des
|
||||||
|
* {@link CorrectionPlan} aufgerufen werden. Es darf keine stille Ausführung im
|
||||||
|
* Hintergrund geben. Da die Ausführung I/O-intensiv sein kann, sollte der Aufruf
|
||||||
|
* auf einem Hintergrund-Worker-Thread erfolgen.
|
||||||
|
*
|
||||||
|
* <h2>Thread-Safety</h2>
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist zustandslos und thread-safe, sofern der injizierte
|
||||||
|
* {@link ResourceCreationPort} ebenfalls thread-safe ist.
|
||||||
|
*/
|
||||||
|
public class CorrectionExecutionService {
|
||||||
|
|
||||||
|
private final ResourceCreationPort port;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Ausführungsservice.
|
||||||
|
*
|
||||||
|
* @param port der Port für schreibende Korrekturmaßnahmen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code port} {@code null} ist
|
||||||
|
*/
|
||||||
|
public CorrectionExecutionService(ResourceCreationPort port) {
|
||||||
|
this.port = Objects.requireNonNull(port, "port must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt alle Korrekturvorschläge im übergebenen Plan aus.
|
||||||
|
* <p>
|
||||||
|
* Iteriert die {@link CorrectionSuggestion}s des Plans und dispatcht jeden Vorschlag
|
||||||
|
* an die passende Methode des {@link ResourceCreationPort}. Alle Ergebnisse werden
|
||||||
|
* gesammelt und als {@link CorrectionExecutionReport} zurückgegeben. Ein Fehler bei
|
||||||
|
* einem Vorschlag führt nicht zum Abbruch der Ausführung der nachfolgenden Vorschläge.
|
||||||
|
* <p>
|
||||||
|
* Wenn der Plan leer ist, wird ein leerer Bericht zurückgegeben.
|
||||||
|
*
|
||||||
|
* @param plan der zu ausführende Korrekturplan; darf nicht {@code null} sein
|
||||||
|
* @return Bericht mit einem {@link CorrectionOutcome} pro Vorschlag; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code plan} {@code null} ist
|
||||||
|
*/
|
||||||
|
public CorrectionExecutionReport execute(CorrectionPlan plan) {
|
||||||
|
Objects.requireNonNull(plan, "plan must not be null");
|
||||||
|
List<CorrectionOutcome> outcomes = new ArrayList<>(plan.size());
|
||||||
|
|
||||||
|
for (CorrectionSuggestion suggestion : plan.suggestions()) {
|
||||||
|
CorrectionOutcome outcome = dispatch(suggestion);
|
||||||
|
outcomes.add(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CorrectionExecutionReport(outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcht einen einzelnen {@link CorrectionSuggestion} an die passende Methode
|
||||||
|
* des {@link ResourceCreationPort}.
|
||||||
|
*
|
||||||
|
* @param suggestion der auszuführende Korrekturvorschlag; nie {@code null}
|
||||||
|
* @return Ausführungsergebnis; nie {@code null}
|
||||||
|
*/
|
||||||
|
private CorrectionOutcome dispatch(CorrectionSuggestion suggestion) {
|
||||||
|
return switch (suggestion) {
|
||||||
|
case CorrectionSuggestion.CreateDirectory cd -> port.createDirectory(cd);
|
||||||
|
case CorrectionSuggestion.CreatePromptFile cp -> port.createPromptFile(cp);
|
||||||
|
case CorrectionSuggestion.PrepareSqlitePath ps -> port.prepareSqlitePath(ps);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis der Ausführung eines einzelnen {@link CorrectionSuggestion}-Vorschlags.
|
||||||
|
* <p>
|
||||||
|
* Nach Benutzerbestätigung eines {@link CorrectionPlan} wird jeder enthaltene Vorschlag
|
||||||
|
* durch den {@link ResourceCreationPort} ausgeführt. Das Ergebnis jeder Ausführung wird
|
||||||
|
* als eines der drei möglichen Zustände modelliert:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Applied} – die Korrektur wurde erfolgreich durchgeführt.</li>
|
||||||
|
* <li>{@link Failed} – die Korrektur wurde versucht, aber ist technisch gescheitert.</li>
|
||||||
|
* <li>{@link NotAttempted} – die Korrektur wurde nicht versucht, obwohl sie im Plan enthalten war.
|
||||||
|
* Typischer Grund: eine Voraussetzung war zur Laufzeit nicht erfüllt.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Alle Implementierungen sind immutable und enthalten keine JavaFX-Typen.
|
||||||
|
*/
|
||||||
|
public sealed interface CorrectionOutcome
|
||||||
|
permits CorrectionOutcome.Applied,
|
||||||
|
CorrectionOutcome.Failed,
|
||||||
|
CorrectionOutcome.NotAttempted {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Korrekturvorschlag zurück, auf den sich dieses Ergebnis bezieht.
|
||||||
|
*
|
||||||
|
* @return Korrekturvorschlag; nie {@code null}
|
||||||
|
*/
|
||||||
|
CorrectionSuggestion suggestion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Korrektur wurde erfolgreich durchgeführt.
|
||||||
|
*
|
||||||
|
* @param suggestion der ausgeführte Korrekturvorschlag; nie {@code null}
|
||||||
|
* @param message deutsche Bestätigungsmeldung; nie {@code null}
|
||||||
|
*/
|
||||||
|
record Applied(
|
||||||
|
CorrectionSuggestion suggestion,
|
||||||
|
String message) implements CorrectionOutcome {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Erfolgs-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||||
|
* @param message Bestätigungsmeldung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public Applied {
|
||||||
|
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||||
|
Objects.requireNonNull(message, "message must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Korrektur wurde versucht, aber ist technisch gescheitert.
|
||||||
|
*
|
||||||
|
* @param suggestion der nicht erfolgreich ausgeführte Korrekturvorschlag; nie {@code null}
|
||||||
|
* @param errorMessage deutsche Fehlerbeschreibung; nie {@code null}
|
||||||
|
*/
|
||||||
|
record Failed(
|
||||||
|
CorrectionSuggestion suggestion,
|
||||||
|
String errorMessage) implements CorrectionOutcome {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Fehler-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||||
|
* @param errorMessage Fehlerbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public Failed {
|
||||||
|
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||||
|
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Korrektur wurde nicht versucht, obwohl sie im Plan enthalten war.
|
||||||
|
* <p>
|
||||||
|
* Typischer Grund: Eine Voraussetzung war zur Ausführungszeit nicht erfüllt
|
||||||
|
* (z. B. übergeordneter Ordner nicht erreichbar). Dies ist kein technischer Fehler
|
||||||
|
* des Korrekturprozesses selbst, sondern ein Hinweis auf eine unerfüllbare Bedingung.
|
||||||
|
*
|
||||||
|
* @param suggestion der nicht versuchte Korrekturvorschlag; nie {@code null}
|
||||||
|
* @param reason deutsche Begründung; nie {@code null}
|
||||||
|
*/
|
||||||
|
record NotAttempted(
|
||||||
|
CorrectionSuggestion suggestion,
|
||||||
|
String reason) implements CorrectionOutcome {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Nicht-Versucht-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||||
|
* @param reason Begründung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public NotAttempted {
|
||||||
|
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||||
|
Objects.requireNonNull(reason, "reason must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesammelter Korrekturplan, der alle schreibenden Korrekturmaßnahmen enthält,
|
||||||
|
* die nach Benutzerbestätigung ausgeführt werden sollen.
|
||||||
|
* <p>
|
||||||
|
* Ein Korrekturplan wird aus den {@link CorrectionSuggestion}-Einträgen der
|
||||||
|
* gescheiterten Prüfpunkte eines {@link TechnicalTestReport} abgeleitet. Er wird dem
|
||||||
|
* Benutzer in einem gesammelten Bestätigungsdialog präsentiert, bevor eine schreibende
|
||||||
|
* Maßnahme ausgeführt wird. Ohne ausdrückliche Bestätigung werden keine Korrekturen
|
||||||
|
* vorgenommen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record ist immutable und enthält keine JavaFX-Typen.
|
||||||
|
*
|
||||||
|
* @param suggestions alle Korrekturvorschläge in Ausführungsreihenfolge; nie {@code null}
|
||||||
|
*/
|
||||||
|
public record CorrectionPlan(List<CorrectionSuggestion> suggestions) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Korrekturplan.
|
||||||
|
*
|
||||||
|
* @param suggestions Liste der Korrekturvorschläge; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code suggestions} {@code null} ist
|
||||||
|
*/
|
||||||
|
public CorrectionPlan {
|
||||||
|
Objects.requireNonNull(suggestions, "suggestions must not be null");
|
||||||
|
suggestions = List.copyOf(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen leeren Korrekturplan ohne Maßnahmen.
|
||||||
|
* <p>
|
||||||
|
* Ein leerer Plan zeigt an, dass nach einem Gesamttest keine sicheren technischen
|
||||||
|
* Korrekturen angeboten werden können.
|
||||||
|
*
|
||||||
|
* @return ein leerer Korrekturplan; nie {@code null}
|
||||||
|
*/
|
||||||
|
public static CorrectionPlan empty() {
|
||||||
|
return new CorrectionPlan(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob dieser Plan mindestens einen Korrekturvorschlag enthält.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein Vorschlag vorhanden ist
|
||||||
|
*/
|
||||||
|
public boolean hasCorrections() {
|
||||||
|
return !suggestions.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Anzahl der enthaltenen Korrekturvorschläge zurück.
|
||||||
|
*
|
||||||
|
* @return Anzahl der Vorschläge; nie negativ
|
||||||
|
*/
|
||||||
|
public int size() {
|
||||||
|
return suggestions.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegelter Korrekturvorschlag für eine schreibende technische Korrekturmaßnahme.
|
||||||
|
* <p>
|
||||||
|
* Korrekturvorschläge beschreiben <em>was</em> korrigiert werden soll, aber noch nicht
|
||||||
|
* <em>wie</em>. Die konkrete Ausführung übernimmt der {@link ResourceCreationPort}. Ein
|
||||||
|
* Vorschlag wird dem Benutzer vor der Ausführung in einem gesammelten Bestätigungsdialog
|
||||||
|
* angezeigt; ohne Bestätigung wird keine schreibende Änderung vorgenommen.
|
||||||
|
* <p>
|
||||||
|
* Nicht automatisch korrigierbare Probleme (falscher API-Key, unerreichbare Base-URL,
|
||||||
|
* nicht verfügbare Modellliste) werden niemals als {@code CorrectionSuggestion} modelliert.
|
||||||
|
* <p>
|
||||||
|
* Alle Pfade werden als {@code String} übergeben, analog zur Konvention der übrigen
|
||||||
|
* Outbound-Ports dieses Projekts. Der Adapter-Out ist für die Konvertierung in
|
||||||
|
* {@code java.nio.file.Path} zuständig.
|
||||||
|
*/
|
||||||
|
public sealed interface CorrectionSuggestion
|
||||||
|
permits CorrectionSuggestion.CreateDirectory,
|
||||||
|
CorrectionSuggestion.CreatePromptFile,
|
||||||
|
CorrectionSuggestion.PrepareSqlitePath {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine kurze deutsche Beschreibung der vorgeschlagenen Korrektur zurück,
|
||||||
|
* die dem Benutzer im Bestätigungsdialog angezeigt wird.
|
||||||
|
*
|
||||||
|
* @return deutsche Beschreibung; nie {@code null}
|
||||||
|
*/
|
||||||
|
String descriptionForUser();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein fehlender Ordner soll angelegt werden.
|
||||||
|
* <p>
|
||||||
|
* Anwendungsfälle: fehlender Zielordner.
|
||||||
|
* Es werden nur Ordner angelegt, die noch nicht existieren und deren Elternpfad
|
||||||
|
* erreichbar ist.
|
||||||
|
*
|
||||||
|
* @param path Pfad des anzulegenden Ordners als String; nie {@code null}
|
||||||
|
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||||
|
*/
|
||||||
|
record CreateDirectory(
|
||||||
|
String path,
|
||||||
|
String descriptionForUser) implements CorrectionSuggestion {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Vorschlag zum Anlegen eines Ordners.
|
||||||
|
*
|
||||||
|
* @param path Pfad des Ordners; darf nicht {@code null} oder leer sein
|
||||||
|
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||||
|
*/
|
||||||
|
public CreateDirectory {
|
||||||
|
Objects.requireNonNull(path, "path must not be null");
|
||||||
|
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
|
||||||
|
if (path.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("path must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine fehlende Prompt-Datei soll mit einem deutschen Standardinhalt erzeugt werden.
|
||||||
|
* <p>
|
||||||
|
* Die Erzeugung erfolgt nur, wenn der Zielpfad beschreibbar ist. Der konkrete
|
||||||
|
* Standardinhalt wird vom {@link ResourceCreationPort} bereitgestellt. Der
|
||||||
|
* Standardpfad liegt im selben Ordner wie die {@code .properties}-Datei.
|
||||||
|
*
|
||||||
|
* @param path Pfad der anzulegenden Prompt-Datei als String; nie {@code null}
|
||||||
|
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||||
|
*/
|
||||||
|
record CreatePromptFile(
|
||||||
|
String path,
|
||||||
|
String descriptionForUser) implements CorrectionSuggestion {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Vorschlag zum Erzeugen einer deutschen Standard-Prompt-Datei.
|
||||||
|
*
|
||||||
|
* @param path Pfad der Prompt-Datei; darf nicht {@code null} oder leer sein
|
||||||
|
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||||
|
*/
|
||||||
|
public CreatePromptFile {
|
||||||
|
Objects.requireNonNull(path, "path must not be null");
|
||||||
|
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
|
||||||
|
if (path.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("path must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein fehlender oder noch nicht vorbereiteter SQLite-Pfad soll nutzbar gemacht werden.
|
||||||
|
* <p>
|
||||||
|
* Konkret bedeutet das: Falls die SQLite-Datei noch nicht existiert, aber ihr
|
||||||
|
* übergeordneter Ordner vorhanden oder anlegbar ist, wird der Ordner sichergestellt.
|
||||||
|
* Eine leere SQLite-Datei wird nicht manuell erzeugt; das übernimmt das JDBC-Layer
|
||||||
|
* beim ersten Datenbankzugriff.
|
||||||
|
*
|
||||||
|
* @param path Pfad der SQLite-Datei als String; nie {@code null}
|
||||||
|
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||||
|
*/
|
||||||
|
record PrepareSqlitePath(
|
||||||
|
String path,
|
||||||
|
String descriptionForUser) implements CorrectionSuggestion {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Vorschlag zur Vorbereitung des SQLite-Pfads.
|
||||||
|
*
|
||||||
|
* @param path Pfad der SQLite-Datei; darf nicht {@code null} oder leer sein
|
||||||
|
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||||
|
*/
|
||||||
|
public PrepareSqlitePath {
|
||||||
|
Objects.requireNonNull(path, "path must not be null");
|
||||||
|
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
|
||||||
|
if (path.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("path must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den deutschen Standardinhalt für neu erzeugte Prompt-Dateien.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse stellt einen brauchbaren Ausgangspunkt für die Prompt-Datei bereit,
|
||||||
|
* der ohne weitere Anpassung funktioniert. Der Inhalt enthält die Anweisung an die KI,
|
||||||
|
* aus einem bereits extrahierten Dokumenttext einen normierten deutschen Dateinamensvorschlag
|
||||||
|
* zu erzeugen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung:</strong> Diese Klasse enthält ausschließlich den Prompt-Text als
|
||||||
|
* reine Zeichenkette. Kein Dateisystem-I/O, kein Template-Engine, keine Platzhalter
|
||||||
|
* für den Dokumentinhalt (der Dokumenttext wird vom Aufrufer separat angefügt).
|
||||||
|
* <p>
|
||||||
|
* Der gelieferte Inhalt ist ein sinnvoller, funktionsfähiger Standard und nicht für
|
||||||
|
* fachliche Weiterentwicklung oder Versionierung vorgesehen.
|
||||||
|
*/
|
||||||
|
public final class DefaultPromptTemplate {
|
||||||
|
|
||||||
|
private DefaultPromptTemplate() {
|
||||||
|
// Utility-Klasse – keine Instanziierung
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den deutschen Standardinhalt für eine neu erzeugte Prompt-Datei zurück.
|
||||||
|
* <p>
|
||||||
|
* Der zurückgegebene Text enthält:
|
||||||
|
* <ul>
|
||||||
|
* <li>Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)</li>
|
||||||
|
* <li>Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}</li>
|
||||||
|
* <li>Benennungsregeln für Titel (maximal 20 Zeichen, deutsch, keine Sonderzeichen)</li>
|
||||||
|
* <li>Hinweis auf das Datumsformat ({@code YYYY-MM-DD})</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Der Text enthält keinen Platzhalter für den Dokumentinhalt. Der Dokumenttext
|
||||||
|
* wird vom {@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer}
|
||||||
|
* separat angehängt.
|
||||||
|
*
|
||||||
|
* @return der deutsche Standard-Prompt-Inhalt; nie {@code null}, nie leer
|
||||||
|
*/
|
||||||
|
public static String defaultContent() {
|
||||||
|
return """
|
||||||
|
Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
|
||||||
|
Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
|
||||||
|
einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
|
||||||
|
|
||||||
|
Antworte ausschließlich mit einem validen JSON-Objekt im folgenden Schema:
|
||||||
|
{
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"title": "Kurztitel auf Deutsch",
|
||||||
|
"reasoning": "Kurze Begründung auf Deutsch"
|
||||||
|
}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Das Feld "title" ist verpflichtend.
|
||||||
|
- Das Feld "reasoning" ist verpflichtend.
|
||||||
|
- Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
|
||||||
|
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
|
||||||
|
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
|
||||||
|
- Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
|
||||||
|
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
|
||||||
|
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||||
|
- Eigennamen bleiben unverändert.
|
||||||
|
- Umlaute und ß sind erlaubt.
|
||||||
|
- Kein Text außerhalb des JSON-Objekts.
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port für technische Pfad- und Dateisystemprüfungen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Port ist <strong>ausschließlich lesend</strong>. Er prüft den Zustand von Pfaden,
|
||||||
|
* ohne Dateien, Ordner oder andere Ressourcen anzulegen, zu verändern oder zu löschen.
|
||||||
|
* Schreibende Korrekturen sind über {@link ResourceCreationPort} zu initiieren.
|
||||||
|
* <p>
|
||||||
|
* <strong>Pfad-Konvention:</strong> Alle Pfade werden als {@code String} übergeben, analog
|
||||||
|
* zur Konvention der übrigen Outbound-Ports dieses Projekts (z. B. {@code TargetFolderPort}).
|
||||||
|
* Der Adapter-Out ist für die Konvertierung in plattformspezifische Pfadobjekte zuständig.
|
||||||
|
* <p>
|
||||||
|
* <strong>Windows- und Netzlaufwerke:</strong> Implementierungen müssen gemappte
|
||||||
|
* Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} im Windows-Kontext ausdrücklich
|
||||||
|
* akzeptieren. Solche Pfade dürfen nicht allein deshalb abgelehnt werden, weil dahinter
|
||||||
|
* technisch ein UNC-Pfad stehen könnte.
|
||||||
|
* <p>
|
||||||
|
* <strong>Fehlerbehandlung:</strong> Implementierungen werfen keine geprüften oder
|
||||||
|
* ungeprüften Ausnahmen für erwartete Fehlerbedingungen (Pfad nicht vorhanden,
|
||||||
|
* keine Leseberechtigung). Alle solchen Zustände werden als {@code boolean}-Ergebnis
|
||||||
|
* oder über separate Methoden kommuniziert.
|
||||||
|
*/
|
||||||
|
public interface PathCheckPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn der Ordner existiert und gelesen werden kann
|
||||||
|
*/
|
||||||
|
boolean isDirectoryReadable(String path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
|
||||||
|
* oder ob dieser Ordner technisch anlegbar wäre.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code true} zurück, wenn:
|
||||||
|
* <ul>
|
||||||
|
* <li>der Ordner existiert und schreibbar ist, oder</li>
|
||||||
|
* <li>der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und
|
||||||
|
* schreibbar ist (anlegbar).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
|
||||||
|
*/
|
||||||
|
boolean isDirectoryWritableOrCreatable(String path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn die Datei existiert und gelesen werden kann
|
||||||
|
*/
|
||||||
|
boolean isFileReadable(String path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
|
||||||
|
* <p>
|
||||||
|
* Gibt {@code true} zurück, wenn:
|
||||||
|
* <ul>
|
||||||
|
* <li>die Datei existiert und les- und schreibbar ist, oder</li>
|
||||||
|
* <li>die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden
|
||||||
|
* und schreibbar ist (Datei wäre anlegbar).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||||
|
* @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
|
||||||
|
*/
|
||||||
|
boolean isSqlitePathUsable(String path);
|
||||||
|
}
|
||||||
+496
@@ -0,0 +1,496 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-Service für die provider-nahen technischen Prüfpunkte des Gesamttests.
|
||||||
|
* <p>
|
||||||
|
* Dieser Service führt genau fünf providerbezogene Prüfpunkte aus:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link CheckpointId#BASE_URL_REACHABLE} – Endpoint technisch erreichbar</li>
|
||||||
|
* <li>{@link CheckpointId#API_KEY_PRESENT} – API-Schlüssel in mindestens einer Quelle vorhanden</li>
|
||||||
|
* <li>{@link CheckpointId#API_KEY_ACCEPTED} – Authentifizierung am Endpoint erfolgreich</li>
|
||||||
|
* <li>{@link CheckpointId#MODEL_LIST_AVAILABLE} – Provider liefert eine Modellliste</li>
|
||||||
|
* <li>{@link CheckpointId#SELECTED_MODEL_PLAUSIBLE} – konfiguriertes Modell in der Liste enthalten</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Port-Wiederverwendung:</strong> Der Service ruft den {@link AiModelCatalogPort}
|
||||||
|
* exakt einmal auf und leitet aus dem Ergebnis alle fünf Prüfpunkte ab. Es findet keine
|
||||||
|
* zweite HTTP-Implementierung statt.
|
||||||
|
* <p>
|
||||||
|
* <strong>API-Key-Vorrangregel:</strong> Der {@link ApiKeyResolutionPort} wird konsultiert,
|
||||||
|
* damit auch reine Umgebungsvariablen-Setups korrekt als „API-Key vorhanden" bewertet werden.
|
||||||
|
* Nur wenn der Deskriptor {@link ApiKeyOrigin#ABSENT} zurückliefert, gilt der Schlüssel als
|
||||||
|
* fehlend. In diesem Fall werden alle Remote-Prüfpunkte als {@link CheckpointResult.NotApplicable}
|
||||||
|
* markiert, ohne einen HTTP-Aufruf durchzuführen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Mapping-Regeln für {@link ModelCatalogResult}-Varianten:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link ModelCatalogResult.Success}: alle fünf Prüfpunkte auswertbar; Modellplausibilität
|
||||||
|
* anhand der zurückgegebenen Liste geprüft.</li>
|
||||||
|
* <li>{@link ModelCatalogResult.EmptyList}: Endpoint und Key akzeptiert, aber keine Modellliste;
|
||||||
|
* Modellplausibilität nicht prüfbar.</li>
|
||||||
|
* <li>{@link ModelCatalogResult.IncompleteConfiguration}: Konfiguration unvollständig; kein
|
||||||
|
* HTTP-Aufruf vom Adapter durchgeführt.</li>
|
||||||
|
* <li>{@link ModelCatalogResult.TechnicalFailure}: abhängig vom Fehlerkategorie-String;
|
||||||
|
* Authentifizierungsfehler, Verbindungsfehler, Serverfehler und ungültige Antworten
|
||||||
|
* werden unterschiedlich auf die Prüfpunkte abgebildet.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #runProviderChecks(EditorValidationInput)}
|
||||||
|
* ist <em>synchron blockierend</em>. Sie darf nicht auf dem JavaFX Application Thread aufgerufen
|
||||||
|
* werden. Der Aufrufer (GUI-Orchestrierung) ist verantwortlich, den Aufruf auf einem
|
||||||
|
* Hintergrund-Worker-Thread auszuführen und die Ergebnisse via {@code Platform.runLater}
|
||||||
|
* in die UI zu überführen. Dieser Service enthält kein {@code Platform.runLater} und
|
||||||
|
* startet keine eigenen Threads.
|
||||||
|
* <p>
|
||||||
|
* <strong>Fehlerklasse-Konstanten (TechnicalFailure.errorCategory):</strong>
|
||||||
|
* Die Adapter-Out-Implementierungen verwenden stabile Kategorie-Strings. Dieser Service
|
||||||
|
* erkennt folgende Präfixe bzw. Werte (Groß-/Kleinschreibung ignoriert):
|
||||||
|
* {@code AUTHENTICATION_FAILED}, {@code CONNECTION_FAILURE}, {@code ENDPOINT_NOT_FOUND},
|
||||||
|
* {@code SERVER_ERROR}, {@code INVALID_RESPONSE}. Unbekannte Kategorien werden als
|
||||||
|
* allgemeiner technischer Fehler behandelt.
|
||||||
|
*/
|
||||||
|
public class ProviderTechnicalTestService {
|
||||||
|
|
||||||
|
/** Fehlerkategorie-Konstante für Authentifizierungsfehler (case-insensitive Präfix-Erkennung). */
|
||||||
|
static final String CATEGORY_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED";
|
||||||
|
/** Fehlerkategorie-Konstante für Verbindungsfehler. */
|
||||||
|
static final String CATEGORY_CONNECTION_FAILURE = "CONNECTION_FAILURE";
|
||||||
|
/** Fehlerkategorie-Konstante für nicht gefundenen Endpoint. */
|
||||||
|
static final String CATEGORY_ENDPOINT_NOT_FOUND = "ENDPOINT_NOT_FOUND";
|
||||||
|
/** Fehlerkategorie-Konstante für Serverfehler (5xx). */
|
||||||
|
static final String CATEGORY_SERVER_ERROR = "SERVER_ERROR";
|
||||||
|
/** Fehlerkategorie-Konstante für nicht parsierbare Antworten. */
|
||||||
|
static final String CATEGORY_INVALID_RESPONSE = "INVALID_RESPONSE";
|
||||||
|
|
||||||
|
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
|
||||||
|
|
||||||
|
private final AiModelCatalogPort modelCatalogPort;
|
||||||
|
private final ApiKeyResolutionPort apiKeyResolutionPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Service mit den erforderlichen Ports.
|
||||||
|
*
|
||||||
|
* @param modelCatalogPort Port für den Modellabruf; darf nicht {@code null} sein
|
||||||
|
* @param apiKeyResolutionPort Port für die API-Key-Herkunftsauflösung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public ProviderTechnicalTestService(AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort) {
|
||||||
|
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort, "modelCatalogPort must not be null");
|
||||||
|
this.apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
|
||||||
|
"apiKeyResolutionPort must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt alle fünf provider-nahen technischen Prüfpunkte für den aktiven Provider aus.
|
||||||
|
* <p>
|
||||||
|
* Der aktive Provider wird aus {@code input.activeProviderIdentifier()} bestimmt.
|
||||||
|
* Wenn der Bezeichner keiner bekannten Provider-Familie entspricht, werden alle fünf
|
||||||
|
* Prüfpunkte als {@link CheckpointResult.Failure} mit Schweregrad ERROR zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Diese Methode blockiert, bis das Ergebnis des Modellabrufs vorliegt oder ein
|
||||||
|
* konfigurierter Timeout abläuft. Sie darf nicht auf dem JavaFX Application Thread
|
||||||
|
* aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste mit genau fünf {@link CheckpointResult}-Einträgen
|
||||||
|
* (in der Reihenfolge: API_KEY_PRESENT, BASE_URL_REACHABLE, API_KEY_ACCEPTED,
|
||||||
|
* MODEL_LIST_AVAILABLE, SELECTED_MODEL_PLAUSIBLE); nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code input} {@code null} ist
|
||||||
|
*/
|
||||||
|
public List<CheckpointResult> runProviderChecks(EditorValidationInput input) {
|
||||||
|
Objects.requireNonNull(input, "input must not be null");
|
||||||
|
|
||||||
|
Optional<AiProviderFamily> familyOpt = AiProviderFamily.fromIdentifier(
|
||||||
|
input.activeProviderIdentifier());
|
||||||
|
|
||||||
|
if (familyOpt.isEmpty()) {
|
||||||
|
String msg = "Aktiver Provider-Bezeichner unbekannt: \""
|
||||||
|
+ input.activeProviderIdentifier() + "\". Provider-Prüfungen können nicht ausgeführt werden.";
|
||||||
|
return List.of(
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.API_KEY_PRESENT, CheckpointSeverity.ERROR, msg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, CheckpointSeverity.ERROR, msg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.API_KEY_ACCEPTED, CheckpointSeverity.ERROR, msg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.MODEL_LIST_AVAILABLE, CheckpointSeverity.ERROR, msg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.ERROR, msg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AiProviderFamily family = familyOpt.get();
|
||||||
|
// Den bereits im EditorValidationInput enthaltenen Descriptor verwenden.
|
||||||
|
// EditorValidationInput enthält keinen rohen API-Key-String, sondern nur den
|
||||||
|
// vom GUI-Adapter bereits aufgelösten Descriptor. Dieser spiegelt die
|
||||||
|
// API-Key-Vorrangregel (ENV → Legacy-ENV → Property) wider.
|
||||||
|
EffectiveApiKeyDescriptor apiKeyDescriptor = resolveApiKeyDescriptor(input, family);
|
||||||
|
|
||||||
|
// Prüfpunkt API_KEY_PRESENT: ohne HTTP-Aufruf
|
||||||
|
CheckpointResult apiKeyPresentResult = checkApiKeyPresent(apiKeyDescriptor);
|
||||||
|
|
||||||
|
if (apiKeyDescriptor.isAbsent()) {
|
||||||
|
// Kein API-Key → alle Remote-Prüfpunkte als NotApplicable markieren
|
||||||
|
String reason = "Kein API-Schlüssel vorhanden. Remote-Prüfungen können nicht ausgeführt werden.";
|
||||||
|
return List.of(
|
||||||
|
apiKeyPresentResult,
|
||||||
|
new CheckpointResult.NotApplicable(CheckpointId.BASE_URL_REACHABLE, reason),
|
||||||
|
new CheckpointResult.NotApplicable(CheckpointId.API_KEY_ACCEPTED, reason),
|
||||||
|
new CheckpointResult.NotApplicable(CheckpointId.MODEL_LIST_AVAILABLE, reason),
|
||||||
|
new CheckpointResult.NotApplicable(CheckpointId.SELECTED_MODEL_PLAUSIBLE, reason)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-Key vorhanden → Modellabruf durchführen
|
||||||
|
String configuredModel = resolveModelValue(input, family);
|
||||||
|
ModelCatalogRequest catalogRequest = buildCatalogRequest(input, family, apiKeyDescriptor);
|
||||||
|
|
||||||
|
ModelCatalogResult catalogResult = modelCatalogPort.fetchAvailableModels(catalogRequest);
|
||||||
|
|
||||||
|
List<CheckpointResult> results = new ArrayList<>();
|
||||||
|
results.add(apiKeyPresentResult);
|
||||||
|
results.addAll(mapCatalogResultToCheckpoints(catalogResult, configuredModel));
|
||||||
|
return List.copyOf(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt das {@link CheckpointResult} für {@link CheckpointId#API_KEY_PRESENT}.
|
||||||
|
*
|
||||||
|
* @param descriptor Herkunftsdeskriptor des API-Schlüssels
|
||||||
|
* @return Success wenn ein Schlüssel vorhanden ist, Failure ERROR sonst
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkApiKeyPresent(EffectiveApiKeyDescriptor descriptor) {
|
||||||
|
if (descriptor.isAbsent()) {
|
||||||
|
return CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.API_KEY_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Kein API-Schlüssel vorhanden. Weder Umgebungsvariable noch Properties-Datei liefert einen Wert.");
|
||||||
|
}
|
||||||
|
String sourceInfo = descriptor.isFromEnvironmentVariable()
|
||||||
|
? "Umgebungsvariable " + descriptor.envVarName().orElse("(unbekannt)")
|
||||||
|
: "Properties-Datei";
|
||||||
|
return new CheckpointResult.Success(
|
||||||
|
CheckpointId.API_KEY_PRESENT,
|
||||||
|
"API-Schlüssel vorhanden (Quelle: " + sourceInfo + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet ein {@link ModelCatalogResult} auf die vier Remote-Prüfpunkte ab.
|
||||||
|
*
|
||||||
|
* @param result Ergebnis des Modellabrufs
|
||||||
|
* @param configuredModel konfigurierter Modellname aus dem Editor
|
||||||
|
* @return Liste mit genau vier Prüfpunkt-Ergebnissen
|
||||||
|
*/
|
||||||
|
private List<CheckpointResult> mapCatalogResultToCheckpoints(ModelCatalogResult result,
|
||||||
|
String configuredModel) {
|
||||||
|
return switch (result) {
|
||||||
|
case ModelCatalogResult.Success success -> mapSuccess(success, configuredModel);
|
||||||
|
case ModelCatalogResult.EmptyList emptyList -> mapEmptyList();
|
||||||
|
case ModelCatalogResult.IncompleteConfiguration incomplete -> mapIncompleteConfiguration(incomplete);
|
||||||
|
case ModelCatalogResult.TechnicalFailure failure -> mapTechnicalFailure(failure);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CheckpointResult> mapSuccess(ModelCatalogResult.Success success, String configuredModel) {
|
||||||
|
CheckpointResult baseUrl = new CheckpointResult.Success(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE, "Endpoint erreichbar.");
|
||||||
|
CheckpointResult apiKeyAccepted = new CheckpointResult.Success(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED, "API-Schlüssel akzeptiert.");
|
||||||
|
CheckpointResult modelList = new CheckpointResult.Success(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
"Modellliste verfügbar (" + success.models().size() + " Modell(e)).");
|
||||||
|
CheckpointResult modelPlausible = checkModelPlausible(success.models(), configuredModel);
|
||||||
|
return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CheckpointResult> mapEmptyList() {
|
||||||
|
CheckpointResult baseUrl = new CheckpointResult.Success(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE, "Endpoint erreichbar.");
|
||||||
|
CheckpointResult apiKeyAccepted = new CheckpointResult.Success(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED, "API-Schlüssel akzeptiert.");
|
||||||
|
CheckpointResult modelList = CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointSeverity.WARNING,
|
||||||
|
"Provider liefert keine Modellliste.");
|
||||||
|
CheckpointResult modelPlausible = new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Keine Modellliste vorhanden, Modellplausibilität nicht prüfbar.");
|
||||||
|
return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CheckpointResult> mapIncompleteConfiguration(ModelCatalogResult.IncompleteConfiguration incomplete) {
|
||||||
|
String reason = incomplete.missingReason();
|
||||||
|
CheckpointResult baseUrl = new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
"Konfiguration unvollständig – kein Verbindungsversuch: " + reason);
|
||||||
|
CheckpointResult apiKeyAccepted = new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
"Konfiguration unvollständig – Authentifizierung nicht prüfbar.");
|
||||||
|
CheckpointResult modelList = CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Provider-Konfiguration unvollständig: " + reason);
|
||||||
|
CheckpointResult modelPlausible = new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Konfiguration unvollständig – Modellplausibilität nicht prüfbar.");
|
||||||
|
return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CheckpointResult> mapTechnicalFailure(ModelCatalogResult.TechnicalFailure failure) {
|
||||||
|
String category = failure.errorCategory().toUpperCase();
|
||||||
|
String detail = failure.errorDetail();
|
||||||
|
|
||||||
|
if (category.contains(CATEGORY_AUTHENTICATION_FAILED)) {
|
||||||
|
return List.of(
|
||||||
|
new CheckpointResult.Success(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
"Endpoint hat geantwortet (Authentifizierungsfehler erhalten)."),
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"API-Schlüssel technisch nicht akzeptiert: " + detail),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
"Authentifizierung fehlgeschlagen – Modellliste nicht abrufbar."),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Authentifizierung fehlgeschlagen – Modellplausibilität nicht prüfbar.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.contains(CATEGORY_CONNECTION_FAILURE) || category.contains(CATEGORY_ENDPOINT_NOT_FOUND)) {
|
||||||
|
String baseUrlMessage = category.contains(CATEGORY_ENDPOINT_NOT_FOUND)
|
||||||
|
? "Endpoint nicht gefunden: " + detail
|
||||||
|
: "Verbindung zum Endpoint fehlgeschlagen: " + detail;
|
||||||
|
return List.of(
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
baseUrlMessage),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
"Endpoint nicht erreichbar – Authentifizierung nicht prüfbar."),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
"Endpoint nicht erreichbar – Modellliste nicht abrufbar."),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Endpoint nicht erreichbar – Modellplausibilität nicht prüfbar.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.contains(CATEGORY_SERVER_ERROR)) {
|
||||||
|
return List.of(
|
||||||
|
new CheckpointResult.Success(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
"Endpoint hat geantwortet (Serverfehler erhalten)."),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
"Serverfehler – Authentifizierung nicht eindeutig prüfbar."),
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointSeverity.WARNING,
|
||||||
|
"Provider antwortet mit Serverfehler: " + detail),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Serverfehler – Modellplausibilität nicht prüfbar.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.contains(CATEGORY_INVALID_RESPONSE)) {
|
||||||
|
return List.of(
|
||||||
|
new CheckpointResult.Success(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
"Endpoint hat geantwortet (Antwort nicht verarbeitbar)."),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
"Antwort nicht parsierbar – Authentifizierung nicht eindeutig prüfbar."),
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Antwort des Providers nicht verarbeitbar: " + detail),
|
||||||
|
new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Antwort nicht parsierbar – Modellplausibilität nicht prüfbar.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekannte Fehlerkategorie
|
||||||
|
String unknownMsg = "Unbekannter technischer Fehler beim Modellabruf: " + detail;
|
||||||
|
return List.of(
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
unknownMsg),
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
unknownMsg),
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
unknownMsg),
|
||||||
|
CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
unknownMsg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob das konfigurierte Modell in der Modellliste enthalten ist.
|
||||||
|
*
|
||||||
|
* @param models verfügbare Modelle vom Provider
|
||||||
|
* @param configuredModel konfigurierter Modellname aus dem Editor
|
||||||
|
* @return Success wenn das Modell enthalten ist, Failure WARNING sonst
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkModelPlausible(List<String> models, String configuredModel) {
|
||||||
|
if (configuredModel.isBlank()) {
|
||||||
|
return CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Kein Modell konfiguriert.");
|
||||||
|
}
|
||||||
|
if (models.contains(configuredModel)) {
|
||||||
|
return new CheckpointResult.Success(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
"Konfiguriertes Modell \"" + configuredModel + "\" in verfügbarer Liste gefunden.");
|
||||||
|
}
|
||||||
|
return CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
CheckpointSeverity.WARNING,
|
||||||
|
"Konfiguriertes Modell \"" + configuredModel
|
||||||
|
+ "\" nicht in verfügbarer Liste gefunden. Bitte Modellname prüfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut den {@link ModelCatalogRequest} aus dem aktuellen Editorzustand auf.
|
||||||
|
* <p>
|
||||||
|
* Da {@link EditorValidationInput} keinen direkten API-Key-String enthält, sondern
|
||||||
|
* nur einen bereits aufgelösten {@link EffectiveApiKeyDescriptor}, wird der Descriptor
|
||||||
|
* aus dem Editorzustand direkt verwendet. Der Adapter-Out-Seitige Dispatcher erwartet
|
||||||
|
* den Key entweder als ENV-Variable (die er selbst liest) oder als optionalen Wert
|
||||||
|
* im Request. Da die Auflösung beim Service bereits über {@link ApiKeyResolutionPort}
|
||||||
|
* erfolgt ist, wird für den Catalog-Request ein leerer Optional-Wert geliefert –
|
||||||
|
* der Adapter verwendet dann intern seine eigene ENV-Variable-Auflösung.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @param family aktive Provider-Familie
|
||||||
|
* @param apiKeyDesc bereits aufgelöster Herkunftsdeskriptor des API-Schlüssels
|
||||||
|
* @return fertiger Request; nie {@code null}
|
||||||
|
*/
|
||||||
|
private ModelCatalogRequest buildCatalogRequest(EditorValidationInput input,
|
||||||
|
AiProviderFamily family,
|
||||||
|
EffectiveApiKeyDescriptor apiKeyDesc) {
|
||||||
|
// EditorValidationInput enthält keinen direkten API-Key-String-Wert, nur den Descriptor.
|
||||||
|
// Für den ModelCatalogRequest übergeben wir einen leeren Optional für den apiKey,
|
||||||
|
// sodass der Adapter seine eigene ENV-Variable-Auflösung durchführt.
|
||||||
|
// Der Adapter liefert dann IncompleteConfiguration, wenn auch er keinen Key findet –
|
||||||
|
// was aber nicht passiert, da wir oben bereits geprüft haben, dass apiKeyDesc nicht ABSENT ist.
|
||||||
|
Optional<String> apiKeyForRequest = Optional.empty();
|
||||||
|
|
||||||
|
String rawBaseUrl = resolveBaseUrlValue(input, family);
|
||||||
|
Optional<String> baseUrl = rawBaseUrl.isBlank() ? Optional.empty() : Optional.of(rawBaseUrl);
|
||||||
|
|
||||||
|
int timeout = parseTimeoutOrDefault(resolveTimeoutValue(input, family));
|
||||||
|
|
||||||
|
return new ModelCatalogRequest(
|
||||||
|
family.getIdentifier(),
|
||||||
|
baseUrl,
|
||||||
|
apiKeyForRequest,
|
||||||
|
timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den bereits aufgelösten {@link EffectiveApiKeyDescriptor} für die aktive Provider-Familie
|
||||||
|
* direkt aus dem {@link EditorValidationInput}.
|
||||||
|
* <p>
|
||||||
|
* {@link EditorValidationInput} enthält keinen rohen API-Key-String, sondern nur den vom
|
||||||
|
* GUI-Adapter bereits aufgelösten Descriptor. Der Descriptor spiegelt die Vorrangregel
|
||||||
|
* (ENV → Legacy-ENV → Property) zum Zeitpunkt des letzten Editor-Refreshs wider.
|
||||||
|
* <p>
|
||||||
|
* Für Tests kann der Descriptor im Eingabeobjekt direkt gesetzt werden.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @param family aktive Provider-Familie
|
||||||
|
* @return der Herkunftsdeskriptor; nie {@code null}
|
||||||
|
*/
|
||||||
|
private EffectiveApiKeyDescriptor resolveApiKeyDescriptor(EditorValidationInput input,
|
||||||
|
AiProviderFamily family) {
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> input.claudeApiKeyDescriptor();
|
||||||
|
case OPENAI_COMPATIBLE -> input.openaiApiKeyDescriptor();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Base-URL-Wert für die angegebene Provider-Familie aus dem Editorzustand.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @param family aktive Provider-Familie
|
||||||
|
* @return Base-URL-String; nie {@code null}, leer wenn nicht gesetzt
|
||||||
|
*/
|
||||||
|
private String resolveBaseUrlValue(EditorValidationInput input, AiProviderFamily family) {
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> input.claudeBaseUrl();
|
||||||
|
case OPENAI_COMPATIBLE -> input.openaiBaseUrl();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den konfigurierten Modellnamen für die angegebene Provider-Familie aus dem Editorzustand.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @param family aktive Provider-Familie
|
||||||
|
* @return Modellname; nie {@code null}, leer wenn nicht gesetzt
|
||||||
|
*/
|
||||||
|
private String resolveModelValue(EditorValidationInput input, AiProviderFamily family) {
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> input.claudeModel();
|
||||||
|
case OPENAI_COMPATIBLE -> input.openaiModel();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Timeout-Wert für die angegebene Provider-Familie aus dem Editorzustand.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @param family aktive Provider-Familie
|
||||||
|
* @return Timeout-String; nie {@code null}, leer wenn nicht gesetzt
|
||||||
|
*/
|
||||||
|
private String resolveTimeoutValue(EditorValidationInput input, AiProviderFamily family) {
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> input.claudeTimeoutSeconds();
|
||||||
|
case OPENAI_COMPATIBLE -> input.openaiTimeoutSeconds();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst einen Timeout-String zu einem Integer. Liefert den Standard-Timeout, wenn der
|
||||||
|
* String leer ist oder nicht als positive Ganzzahl parsierbar ist.
|
||||||
|
*
|
||||||
|
* @param raw roher Timeout-String
|
||||||
|
* @return geparster Timeout in Sekunden (mindestens 1)
|
||||||
|
*/
|
||||||
|
private int parseTimeoutOrDefault(String raw) {
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(raw.trim());
|
||||||
|
return parsed > 0 ? parsed : DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port für schreibende technische Korrekturhilfen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Port ist <strong>schreibend</strong> und darf nur nach ausdrücklicher
|
||||||
|
* Benutzerbestätigung eines {@link CorrectionPlan} aufgerufen werden. Es darf keine
|
||||||
|
* stille Ausführung im Hintergrund geben.
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung zu {@link PathCheckPort}:</strong> {@code PathCheckPort} ist
|
||||||
|
* rein lesend; {@code ResourceCreationPort} ist rein schreibend. Beide Ports werden
|
||||||
|
* niemals für dieselbe Aufgabe verwendet.
|
||||||
|
* <p>
|
||||||
|
* <strong>Pfad-Konvention:</strong> Alle Pfade werden als {@code String} übergeben,
|
||||||
|
* analog zur Konvention der übrigen Outbound-Ports dieses Projekts. Der Adapter-Out
|
||||||
|
* ist für die Konvertierung in plattformspezifische Pfadobjekte zuständig.
|
||||||
|
* <p>
|
||||||
|
* <strong>Fehlerbehandlung:</strong> Implementierungen werfen keine geprüften Ausnahmen.
|
||||||
|
* Jede Methode gibt ein {@link CorrectionOutcome} zurück, das Erfolg, Scheitern oder
|
||||||
|
* Nicht-Durchführbarkeit ausdrückt. Unerwartete technische Fehler werden als
|
||||||
|
* {@link CorrectionOutcome.Failed} zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* <strong>Windows- und Netzlaufwerke:</strong> Implementierungen müssen gemappte
|
||||||
|
* Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} im Windows-Kontext unterstützen.
|
||||||
|
*/
|
||||||
|
public interface ResourceCreationPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
|
||||||
|
* <p>
|
||||||
|
* Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} mit einem
|
||||||
|
* entsprechenden Hinweis zurückgegeben (idempotente Ausführung).
|
||||||
|
*
|
||||||
|
* @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis der Ausführung; nie {@code null}
|
||||||
|
*/
|
||||||
|
CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine neue Prompt-Datei mit einem deutschen Standardinhalt.
|
||||||
|
* <p>
|
||||||
|
* Der Standardinhalt wird von dieser Implementierung bereitgestellt. Die Datei wird
|
||||||
|
* nur erzeugt, wenn sie noch nicht existiert und ihr übergeordneter Ordner beschreibbar ist.
|
||||||
|
* Wenn der Pfad bereits eine Datei enthält, wird {@link CorrectionOutcome.NotAttempted}
|
||||||
|
* zurückgegeben (kein stilless Überschreiben).
|
||||||
|
*
|
||||||
|
* @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis der Ausführung; nie {@code null}
|
||||||
|
*/
|
||||||
|
CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser noch nicht
|
||||||
|
* existiert.
|
||||||
|
* <p>
|
||||||
|
* Eine leere SQLite-Datei wird nicht manuell erzeugt; das übernimmt der JDBC-Layer
|
||||||
|
* beim ersten Datenbankzugriff. Diese Methode stellt lediglich sicher, dass der
|
||||||
|
* übergeordnete Ordner vorhanden und schreibbar ist.
|
||||||
|
*
|
||||||
|
* @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis der Ausführung; nie {@code null}
|
||||||
|
*/
|
||||||
|
CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion);
|
||||||
|
}
|
||||||
+466
@@ -0,0 +1,466 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationSeverity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator für den vollständigen technischen Gesamttest der GUI-Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Führt alle elf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
|
||||||
|
* <ol>
|
||||||
|
* <li><strong>Lokale Validierung:</strong> Prüft den Editorzustand ohne I/O mithilfe des
|
||||||
|
* {@link EditorConfigurationValidator}. Erzeugt Ergebnisse für
|
||||||
|
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
|
||||||
|
* {@link CheckpointId#PROVIDER_CONFIGURATION}.</li>
|
||||||
|
* <li><strong>Pfadprüfungen:</strong> Prüft Quellordner, Zielordner, Prompt-Datei und
|
||||||
|
* SQLite-Pfad über den {@link PathCheckPort}. Erzeugt Ergebnisse für
|
||||||
|
* {@link CheckpointId#PROMPT_FILE_PRESENT}, {@link CheckpointId#SOURCE_FOLDER_PRESENT},
|
||||||
|
* {@link CheckpointId#TARGET_FOLDER_USABLE} und {@link CheckpointId#SQLITE_PATH_USABLE}.</li>
|
||||||
|
* <li><strong>Provider-Prüfungen:</strong> Prüft Endpoint, API-Key, Modellliste und
|
||||||
|
* Modellplausibilität über den {@link ProviderTechnicalTestService}. Erzeugt Ergebnisse für
|
||||||
|
* {@link CheckpointId#BASE_URL_REACHABLE}, {@link CheckpointId#API_KEY_PRESENT},
|
||||||
|
* {@link CheckpointId#API_KEY_ACCEPTED}, {@link CheckpointId#MODEL_LIST_AVAILABLE}
|
||||||
|
* und {@link CheckpointId#SELECTED_MODEL_PLAUSIBLE}.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* <strong>Kein Frühabbruch:</strong> Alle drei Prüfblöcke werden immer vollständig
|
||||||
|
* ausgeführt, auch wenn ein Block eine Exception wirft. In diesem Fall werden die
|
||||||
|
* betroffenen Checkpoints als {@link CheckpointResult.Failure} mit Schweregrad ERROR
|
||||||
|
* und dem Präfix „Interner Fehler:" markiert. Der Gesamtbericht enthält immer genau
|
||||||
|
* elf Einträge.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #run(TechnicalTestRequest)}
|
||||||
|
* ist synchron blockierend (der Provider-Prüfblock führt HTTP-Aufrufe durch). Sie darf
|
||||||
|
* nicht auf dem JavaFX Application Thread aufgerufen werden. Der Aufrufer ist für die
|
||||||
|
* Worker-Thread-Verwaltung und die Rückführung via {@code Platform.runLater} verantwortlich.
|
||||||
|
* <p>
|
||||||
|
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
|
||||||
|
* enthält, leitet der Orchestrator einen Standardpfad aus dem Konfigurationsdateipfad ab
|
||||||
|
* ({@code <config-parent>/prompt.txt}). Ist auch kein Konfigurationsdateipfad gesetzt,
|
||||||
|
* wird {@code config/prompt.txt} relativ zum Arbeitsverzeichnis verwendet.
|
||||||
|
* <p>
|
||||||
|
* Dieser Service enthält keine JavaFX-Typen, keine NIO-Pfadobjekte in Signaturen und
|
||||||
|
* keine Infrastrukturabhängigkeiten jenseits der drei injizierten Abhängigkeiten.
|
||||||
|
*/
|
||||||
|
public class TechnicalTestOrchestrator {
|
||||||
|
|
||||||
|
private final EditorConfigurationValidator editorValidator;
|
||||||
|
private final PathCheckPort pathCheckPort;
|
||||||
|
private final ProviderTechnicalTestService providerTestService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Orchestrator mit den drei erforderlichen Abhängigkeiten.
|
||||||
|
*
|
||||||
|
* @param editorValidator Lokaler Konfigurationsvalidator; darf nicht {@code null} sein
|
||||||
|
* @param pathCheckPort Port für Dateisystem-Pfadprüfungen; darf nicht {@code null} sein
|
||||||
|
* @param providerTestService Service für provider-nahe technische Prüfungen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public TechnicalTestOrchestrator(EditorConfigurationValidator editorValidator,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
ProviderTechnicalTestService providerTestService) {
|
||||||
|
this.editorValidator = Objects.requireNonNull(editorValidator, "editorValidator must not be null");
|
||||||
|
this.pathCheckPort = Objects.requireNonNull(pathCheckPort, "pathCheckPort must not be null");
|
||||||
|
this.providerTestService = Objects.requireNonNull(providerTestService, "providerTestService must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt den vollständigen technischen Gesamttest gegen den angegebenen Editorzustand aus.
|
||||||
|
* <p>
|
||||||
|
* Alle drei Prüfblöcke werden immer vollständig ausgeführt. Ein Fehler in einem Block
|
||||||
|
* führt nicht dazu, dass ein anderer Block übersprungen wird. Der zurückgegebene Bericht
|
||||||
|
* enthält immer genau elf {@link CheckpointResult}-Einträge.
|
||||||
|
* <p>
|
||||||
|
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
|
||||||
|
* enthält, wird als Standardpfad der Elternordner der Konfigurationsdatei gewählt
|
||||||
|
* (aus {@link TechnicalTestRequest#configFilePath()}), konkret
|
||||||
|
* {@code <config-parent>/prompt.txt}. Falls kein Konfigurationsdateipfad gesetzt ist,
|
||||||
|
* lautet der Fallback {@code config/prompt.txt} relativ zum Arbeitsverzeichnis.
|
||||||
|
* <p>
|
||||||
|
* Wenn der Zielpfad der Prompt-Datei nicht beschreibbar ist, wird keine
|
||||||
|
* {@link CorrectionSuggestion} erzeugt, sondern eine Failure-Meldung mit dem Hinweis,
|
||||||
|
* die Datei manuell anzulegen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading-Kontrakt:</strong> Diese Methode blockiert, bis alle Prüfungen
|
||||||
|
* abgeschlossen sind. Sie darf nicht auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param request Eingabedaten für den Gesamttest; darf nicht {@code null} sein
|
||||||
|
* @return vollständiger Gesamttestbericht mit genau elf Einträgen; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code request} {@code null} ist
|
||||||
|
*/
|
||||||
|
public TechnicalTestReport run(TechnicalTestRequest request) {
|
||||||
|
Objects.requireNonNull(request, "request must not be null");
|
||||||
|
Instant startTime = Instant.now();
|
||||||
|
EditorValidationInput input = request.validationInput();
|
||||||
|
|
||||||
|
List<CheckpointResult> results = new ArrayList<>(11);
|
||||||
|
|
||||||
|
// Block 1: Lokale Konfigurationsvalidierung (kein I/O)
|
||||||
|
results.addAll(runLocalValidationBlock(input));
|
||||||
|
|
||||||
|
// Block 2: Pfadprüfungen (Dateisystem-I/O)
|
||||||
|
results.addAll(runPathCheckBlock(input, request.configFilePath()));
|
||||||
|
|
||||||
|
// Block 3: Provider-nahe technische Prüfungen (Netzwerk-I/O)
|
||||||
|
results.addAll(runProviderCheckBlock(input));
|
||||||
|
|
||||||
|
return new TechnicalTestReport(results, startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Block 1: Lokale Konfigurationsvalidierung
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt die lokale Konfigurationsvalidierung durch und bildet das Ergebnis auf
|
||||||
|
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
|
||||||
|
* {@link CheckpointId#PROVIDER_CONFIGURATION} ab.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @return Liste mit genau zwei Einträgen
|
||||||
|
*/
|
||||||
|
private List<CheckpointResult> runLocalValidationBlock(EditorValidationInput input) {
|
||||||
|
try {
|
||||||
|
EditorValidationReport report = editorValidator.validate(input);
|
||||||
|
return mapLocalValidationToCheckpoints(report);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String errorMsg = "Interner Fehler bei der lokalen Konfigurationsvalidierung: " + e.getMessage();
|
||||||
|
return List.of(
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.CONFIGURATION_BASIC_VALIDATION,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.PROVIDER_CONFIGURATION,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet den {@link EditorValidationReport} auf die zwei lokalen Prüfpunkte ab.
|
||||||
|
* <p>
|
||||||
|
* Befunde ohne Feldbezug oder mit allgemeinen Feldbezügen werden
|
||||||
|
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} zugeordnet. Provider-spezifische
|
||||||
|
* Feldbefunde werden {@link CheckpointId#PROVIDER_CONFIGURATION} zugeordnet.
|
||||||
|
*
|
||||||
|
* @param report Validierungsergebnis
|
||||||
|
* @return Liste mit genau zwei Einträgen
|
||||||
|
*/
|
||||||
|
private static List<CheckpointResult> mapLocalValidationToCheckpoints(EditorValidationReport report) {
|
||||||
|
// Trennen: allgemeine Befunde vs. provider-spezifische Befunde
|
||||||
|
List<EditorValidationFinding> generalFindings = report.findings().stream()
|
||||||
|
.filter(f -> !isProviderSpecificField(f.fieldKey().orElse("")))
|
||||||
|
.toList();
|
||||||
|
List<EditorValidationFinding> providerFindings = report.findings().stream()
|
||||||
|
.filter(f -> isProviderSpecificField(f.fieldKey().orElse("")))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
CheckpointResult basicValidation = buildCheckpointFromFindings(
|
||||||
|
CheckpointId.CONFIGURATION_BASIC_VALIDATION,
|
||||||
|
generalFindings,
|
||||||
|
"Konfiguration grundsätzlich gültig.");
|
||||||
|
|
||||||
|
CheckpointResult providerValidation = buildCheckpointFromFindings(
|
||||||
|
CheckpointId.PROVIDER_CONFIGURATION,
|
||||||
|
providerFindings,
|
||||||
|
"Provider-Konfiguration vollständig.");
|
||||||
|
|
||||||
|
return List.of(basicValidation, providerValidation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob ein Feldschlüssel zu einem provider-spezifischen Feld gehört.
|
||||||
|
*
|
||||||
|
* @param fieldKey Property-Schlüssel
|
||||||
|
* @return {@code true} wenn es ein provider-spezifisches Feld ist
|
||||||
|
*/
|
||||||
|
private static boolean isProviderSpecificField(String fieldKey) {
|
||||||
|
return fieldKey.startsWith("ai.provider.claude.")
|
||||||
|
|| fieldKey.startsWith("ai.provider.openai-compatible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein {@link CheckpointResult} aus einer Liste von Befunden.
|
||||||
|
*
|
||||||
|
* @param id Prüfpunkt-ID
|
||||||
|
* @param findings Liste der relevanten Befunde
|
||||||
|
* @param successMessage Meldung bei leerem Befund-Ergebnis
|
||||||
|
* @return Success bei leerer Befund-Liste, Failure andernfalls
|
||||||
|
*/
|
||||||
|
private static CheckpointResult buildCheckpointFromFindings(CheckpointId id,
|
||||||
|
List<EditorValidationFinding> findings,
|
||||||
|
String successMessage) {
|
||||||
|
if (findings.isEmpty()) {
|
||||||
|
return new CheckpointResult.Success(id, successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Höchsten Schweregrad bestimmen
|
||||||
|
boolean hasError = findings.stream()
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
CheckpointSeverity severity = hasError ? CheckpointSeverity.ERROR : CheckpointSeverity.WARNING;
|
||||||
|
|
||||||
|
// Befunde zusammenfassen
|
||||||
|
String summary = findings.size() == 1
|
||||||
|
? findings.get(0).message()
|
||||||
|
: findings.size() + " Befunde: " + findings.get(0).message()
|
||||||
|
+ (findings.size() > 1 ? " (und " + (findings.size() - 1) + " weitere)" : "");
|
||||||
|
|
||||||
|
return CheckpointResult.Failure.of(id, severity, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Block 2: Pfadprüfungen
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner
|
||||||
|
* und SQLite-Pfad durch.
|
||||||
|
* <p>
|
||||||
|
* Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand
|
||||||
|
* einen sinnvollen Standardpfad zu bestimmen ({@code <config-parent>/prompt.txt}).
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
|
||||||
|
* @return Liste mit genau vier Einträgen
|
||||||
|
*/
|
||||||
|
private List<CheckpointResult> runPathCheckBlock(EditorValidationInput input,
|
||||||
|
String configFilePath) {
|
||||||
|
try {
|
||||||
|
List<CheckpointResult> results = new ArrayList<>(4);
|
||||||
|
results.add(checkPromptFile(input.promptTemplateFile(), configFilePath));
|
||||||
|
results.add(checkSourceFolder(input.sourceFolder()));
|
||||||
|
results.add(checkTargetFolder(input.targetFolder()));
|
||||||
|
results.add(checkSqlitePath(input.sqliteFile()));
|
||||||
|
return results;
|
||||||
|
} catch (Exception e) {
|
||||||
|
String errorMsg = "Interner Fehler bei den Pfadprüfungen: " + e.getMessage();
|
||||||
|
return List.of(
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.PROMPT_FILE_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Prompt-Datei auf Vorhandensein und Lesbarkeit.
|
||||||
|
* <p>
|
||||||
|
* <strong>Pfad-Auflösung:</strong> Wenn der konfigurierte Prompt-Pfad leer ist,
|
||||||
|
* wird ein Standardpfad bestimmt:
|
||||||
|
* <ul>
|
||||||
|
* <li>Wenn {@code configFilePath} gesetzt ist: {@code <configFilePath-Elternordner>/prompt.txt}</li>
|
||||||
|
* <li>Sonst: {@code config/prompt.txt} relativ zum Arbeitsverzeichnis</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Schreibbarkeits-Prüfung:</strong> Wenn der Zielpfad fehlt, wird geprüft, ob der
|
||||||
|
* Elternordner beschreibbar wäre. Nur dann wird eine {@link CorrectionSuggestion.CreatePromptFile}
|
||||||
|
* angeboten. Ist der Elternordner nicht beschreibbar, wird eine Failure ohne Korrekturvorschlag
|
||||||
|
* zurückgegeben, aber mit einem Hinweis, die Datei manuell anzulegen.
|
||||||
|
*
|
||||||
|
* @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein
|
||||||
|
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
|
||||||
|
* @return Prüfpunkt-Ergebnis
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkPromptFile(String configuredPath, String configFilePath) {
|
||||||
|
// Effektiven Prompt-Pfad bestimmen
|
||||||
|
String effectivePath = resolvePromptPath(configuredPath, configFilePath);
|
||||||
|
|
||||||
|
if (pathCheckPort.isFileReadable(effectivePath)) {
|
||||||
|
return new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT,
|
||||||
|
"Prompt-Datei vorhanden und lesbar: " + effectivePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei fehlt – Elternordner auf Beschreibbarkeit prüfen
|
||||||
|
String parentPath = extractParentPath(effectivePath);
|
||||||
|
boolean parentWritable = !parentPath.isBlank()
|
||||||
|
&& pathCheckPort.isDirectoryWritableOrCreatable(parentPath);
|
||||||
|
|
||||||
|
if (parentWritable) {
|
||||||
|
// Elternordner beschreibbar → Korrekturvorschlag anbieten
|
||||||
|
CorrectionSuggestion suggestion = new CorrectionSuggestion.CreatePromptFile(
|
||||||
|
effectivePath, "Prompt-Datei anlegen: " + effectivePath);
|
||||||
|
return CheckpointResult.Failure.withCorrection(
|
||||||
|
CheckpointId.PROMPT_FILE_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Prompt-Datei nicht vorhanden oder nicht lesbar: " + effectivePath,
|
||||||
|
suggestion);
|
||||||
|
} else {
|
||||||
|
// Elternordner nicht beschreibbar → kein Korrekturvorschlag, nur Hinweis
|
||||||
|
return CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.PROMPT_FILE_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Prompt-Datei fehlt und kann nicht automatisch erzeugt werden. "
|
||||||
|
+ "Bitte manuell anlegen: " + effectivePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt den effektiven Prompt-Pfad aus dem konfigurierten Pfad und dem Konfigurationsdateipfad.
|
||||||
|
* <p>
|
||||||
|
* Wenn der konfigurierte Pfad nicht leer ist, wird dieser unverändert zurückgegeben.
|
||||||
|
* Andernfalls wird ein Standardpfad aus dem Konfigurationsdateipfad abgeleitet:
|
||||||
|
* {@code <configFilePath-Elternordner>/prompt.txt}. Falls auch der Konfigurationsdateipfad
|
||||||
|
* leer ist, lautet der Fallback {@code config/prompt.txt}.
|
||||||
|
*
|
||||||
|
* @param configuredPath konfigurierter Prompt-Pfad; kann leer sein
|
||||||
|
* @param configFilePath Pfad der geladenen Konfigurationsdatei; kann leer sein
|
||||||
|
* @return effektiver Prompt-Pfad; nie {@code null}, nie leer
|
||||||
|
*/
|
||||||
|
static String resolvePromptPath(String configuredPath, String configFilePath) {
|
||||||
|
if (!configuredPath.isBlank()) {
|
||||||
|
return configuredPath;
|
||||||
|
}
|
||||||
|
// Standardpfad aus dem Konfigurationsdatei-Elternordner ableiten
|
||||||
|
if (!configFilePath.isBlank()) {
|
||||||
|
String parent = extractParentPath(configFilePath);
|
||||||
|
if (!parent.isBlank()) {
|
||||||
|
return parent + File.separator + "prompt.txt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Absoluter Fallback
|
||||||
|
return "config" + File.separator + "prompt.txt";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert den Elternpfad aus einem Dateipfad.
|
||||||
|
* <p>
|
||||||
|
* Gibt eine leere Zeichenkette zurück, wenn kein Elternpfad bestimmbar ist.
|
||||||
|
*
|
||||||
|
* @param filePath Dateipfad als String
|
||||||
|
* @return Elternpfad oder leere Zeichenkette
|
||||||
|
*/
|
||||||
|
private static String extractParentPath(String filePath) {
|
||||||
|
if (filePath == null || filePath.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.nio.file.Path path = Paths.get(filePath);
|
||||||
|
java.nio.file.Path parent = path.getParent();
|
||||||
|
return parent != null ? parent.toString() : "";
|
||||||
|
} catch (InvalidPathException e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Quellordner auf Vorhandensein und Lesbarkeit.
|
||||||
|
*
|
||||||
|
* @param path Pfad des Quellordners
|
||||||
|
* @return Prüfpunkt-Ergebnis
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkSourceFolder(String path) {
|
||||||
|
if (path.isBlank()) {
|
||||||
|
return CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR, "Quellordner: Kein Pfad konfiguriert.");
|
||||||
|
}
|
||||||
|
if (pathCheckPort.isDirectoryReadable(path)) {
|
||||||
|
return new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
|
"Quellordner vorhanden und lesbar: " + path);
|
||||||
|
}
|
||||||
|
return CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Quellordner nicht vorhanden oder nicht lesbar: " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Zielordner auf Vorhandensein oder Anlegbarkeit und Schreibbarkeit.
|
||||||
|
* Bietet eine {@link CorrectionSuggestion.CreateDirectory} an, wenn der Ordner
|
||||||
|
* fehlt, aber anlegbar wäre.
|
||||||
|
*
|
||||||
|
* @param path Pfad des Zielordners
|
||||||
|
* @return Prüfpunkt-Ergebnis
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkTargetFolder(String path) {
|
||||||
|
if (path.isBlank()) {
|
||||||
|
return CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
CheckpointSeverity.ERROR, "Zielordner: Kein Pfad konfiguriert.");
|
||||||
|
}
|
||||||
|
if (pathCheckPort.isDirectoryWritableOrCreatable(path)) {
|
||||||
|
return new CheckpointResult.Success(CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
"Zielordner vorhanden/anlegbar und schreibbar: " + path);
|
||||||
|
}
|
||||||
|
// Ordner ist weder vorhanden/schreibbar noch anlegbar
|
||||||
|
// Wenn der Ordner fehlt, könnte isDirectoryWritableOrCreatable false liefern weil
|
||||||
|
// auch der Elternpfad fehlt. Trotzdem einen Korrekturvorschlag anbieten.
|
||||||
|
CorrectionSuggestion suggestion = new CorrectionSuggestion.CreateDirectory(
|
||||||
|
path, "Zielordner anlegen: " + path);
|
||||||
|
return CheckpointResult.Failure.withCorrection(
|
||||||
|
CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"Zielordner nicht vorhanden oder nicht schreibbar: " + path,
|
||||||
|
suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der SQLite-Pfad technisch nutzbar ist.
|
||||||
|
* Bietet eine {@link CorrectionSuggestion.PrepareSqlitePath} an, wenn der Pfad
|
||||||
|
* noch nicht nutzbar, aber vorbereitbar wäre.
|
||||||
|
*
|
||||||
|
* @param path Pfad der SQLite-Datei
|
||||||
|
* @return Prüfpunkt-Ergebnis
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkSqlitePath(String path) {
|
||||||
|
if (path.isBlank()) {
|
||||||
|
return CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
CheckpointSeverity.ERROR, "SQLite-Pfad: Kein Pfad konfiguriert.");
|
||||||
|
}
|
||||||
|
if (pathCheckPort.isSqlitePathUsable(path)) {
|
||||||
|
return new CheckpointResult.Success(CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
"SQLite-Pfad technisch nutzbar: " + path);
|
||||||
|
}
|
||||||
|
CorrectionSuggestion suggestion = new CorrectionSuggestion.PrepareSqlitePath(
|
||||||
|
path, "SQLite-Pfad vorbereiten: " + path);
|
||||||
|
return CheckpointResult.Failure.withCorrection(
|
||||||
|
CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
CheckpointSeverity.ERROR,
|
||||||
|
"SQLite-Pfad nicht nutzbar: " + path,
|
||||||
|
suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Block 3: Provider-nahe technische Prüfungen
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt die provider-nahen technischen Prüfungen über den {@link ProviderTechnicalTestService} aus.
|
||||||
|
* <p>
|
||||||
|
* Der Service liefert genau fünf Ergebnisse in der Reihenfolge:
|
||||||
|
* API_KEY_PRESENT, BASE_URL_REACHABLE, API_KEY_ACCEPTED, MODEL_LIST_AVAILABLE, SELECTED_MODEL_PLAUSIBLE.
|
||||||
|
*
|
||||||
|
* @param input aktueller Editorzustand
|
||||||
|
* @return Liste mit genau fünf Einträgen
|
||||||
|
*/
|
||||||
|
private List<CheckpointResult> runProviderCheckBlock(EditorValidationInput input) {
|
||||||
|
try {
|
||||||
|
return providerTestService.runProviderChecks(input);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String errorMsg = "Interner Fehler bei den Provider-Prüfungen: " + e.getMessage();
|
||||||
|
return List.of(
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.API_KEY_PRESENT,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis eines vollständigen technischen Gesamttests.
|
||||||
|
* <p>
|
||||||
|
* Enthält die {@link CheckpointResult}-Einträge aller durchlaufenen Prüfpunkte in
|
||||||
|
* Ausführungsreihenfolge. Jeder definierte Prüfpunkt ist vertreten – entweder als
|
||||||
|
* {@link CheckpointResult.Success}, {@link CheckpointResult.Failure} oder
|
||||||
|
* {@link CheckpointResult.NotApplicable}.
|
||||||
|
* <p>
|
||||||
|
* Der Gesamttest bricht bei einem Fehler <em>nicht</em> ab; alle Prüfpunkte werden
|
||||||
|
* vollständig durchlaufen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record ist immutable und enthält keine JavaFX-Typen.
|
||||||
|
*
|
||||||
|
* @param results Prüfpunkt-Ergebnisse in Ausführungsreihenfolge; nie {@code null}
|
||||||
|
* @param evaluatedAt Zeitpunkt, zu dem der Gesamttest gestartet wurde; nie {@code null}
|
||||||
|
*/
|
||||||
|
public record TechnicalTestReport(
|
||||||
|
List<CheckpointResult> results,
|
||||||
|
Instant evaluatedAt) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Gesamttestbericht.
|
||||||
|
*
|
||||||
|
* @param results Ergebnisliste; darf nicht {@code null} sein
|
||||||
|
* @param evaluatedAt Startzeitpunkt des Tests; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public TechnicalTestReport {
|
||||||
|
Objects.requireNonNull(results, "results must not be null");
|
||||||
|
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
|
||||||
|
results = List.copyOf(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob mindestens ein Prüfpunkt mit Schweregrad {@link CheckpointSeverity#ERROR}
|
||||||
|
* gescheitert ist.
|
||||||
|
* <p>
|
||||||
|
* Wenn {@code true}, gilt die Konfiguration im aktuellen Zustand als nicht lauffähig.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein Fehler-Prüfpunkt vorliegt
|
||||||
|
*/
|
||||||
|
public boolean hasErrors() {
|
||||||
|
return results.stream()
|
||||||
|
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||||
|
.map(r -> (CheckpointResult.Failure) r)
|
||||||
|
.anyMatch(f -> f.severity() == CheckpointSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob mindestens ein Prüfpunkt mit Schweregrad {@link CheckpointSeverity#WARNING}
|
||||||
|
* gescheitert ist.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein Warn-Prüfpunkt vorliegt
|
||||||
|
*/
|
||||||
|
public boolean hasWarnings() {
|
||||||
|
return results.stream()
|
||||||
|
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||||
|
.map(r -> (CheckpointResult.Failure) r)
|
||||||
|
.anyMatch(f -> f.severity() == CheckpointSeverity.WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob mindestens ein Prüfpunkt einen {@link CorrectionSuggestion} enthält.
|
||||||
|
* <p>
|
||||||
|
* Wenn {@code true}, kann aus diesem Bericht ein nicht leerer {@link CorrectionPlan}
|
||||||
|
* abgeleitet werden.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens ein korrigierbarer Befund vorliegt
|
||||||
|
*/
|
||||||
|
public boolean hasCorrectableFindings() {
|
||||||
|
return results.stream()
|
||||||
|
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||||
|
.map(r -> (CheckpointResult.Failure) r)
|
||||||
|
.anyMatch(CheckpointResult.Failure::hasCorrectionSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet einen {@link CorrectionPlan} aus den korrigierbaren Prüfpunkt-Fehlern ab.
|
||||||
|
* <p>
|
||||||
|
* Enthält alle {@link CorrectionSuggestion}-Einträge der gescheiterten Prüfpunkte
|
||||||
|
* in Berichtsreihenfolge.
|
||||||
|
*
|
||||||
|
* @return abgeleiteter Korrekturplan; nie {@code null}; leer wenn keine Korrekturen möglich sind
|
||||||
|
*/
|
||||||
|
public CorrectionPlan deriveCorrectionPlan() {
|
||||||
|
List<CorrectionSuggestion> suggestions = results.stream()
|
||||||
|
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||||
|
.map(r -> (CheckpointResult.Failure) r)
|
||||||
|
.filter(CheckpointResult.Failure::hasCorrectionSuggestion)
|
||||||
|
.map(f -> f.correctionSuggestion().orElseThrow())
|
||||||
|
.toList();
|
||||||
|
return new CorrectionPlan(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Gesamtzahl der Prüfpunkt-Ergebnisse zurück.
|
||||||
|
*
|
||||||
|
* @return Anzahl der Ergebnisse; nie negativ
|
||||||
|
*/
|
||||||
|
public int size() {
|
||||||
|
return results.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingabedaten für einen vollständigen technischen Gesamttest der GUI-Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Enthält den aktuellen Editorzustand als {@link EditorValidationInput} (alle String-Werte
|
||||||
|
* so wie sie im Editor vorliegen). Der technische Gesamttest arbeitet ausschließlich auf
|
||||||
|
* diesen Werten; er liest keine Konfigurationsdatei vom Dateisystem und speichert nichts.
|
||||||
|
* <p>
|
||||||
|
* Der optionale Pfad zur Konfigurationsdatei ({@code configFilePath}) ermöglicht es dem
|
||||||
|
* Gesamttest, bei der automatischen Prompt-Erzeugung den Standardpfad relativ zur
|
||||||
|
* Konfigurationsdatei zu bestimmen. Er ist leer, wenn keine Konfigurationsdatei geladen ist.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
||||||
|
*
|
||||||
|
* @param validationInput aktueller Editorzustand; nie {@code null}
|
||||||
|
* @param configFilePath optionaler Pfad der geladenen Konfigurationsdatei als String;
|
||||||
|
* leer wenn keine Datei geladen ist
|
||||||
|
*/
|
||||||
|
public record TechnicalTestRequest(
|
||||||
|
EditorValidationInput validationInput,
|
||||||
|
String configFilePath) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Gesamttest-Anforderung.
|
||||||
|
*
|
||||||
|
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
||||||
|
* @param configFilePath Pfad der Konfigurationsdatei; {@code null} wird zu leerem String
|
||||||
|
* @throws NullPointerException wenn {@code validationInput} {@code null} ist
|
||||||
|
*/
|
||||||
|
public TechnicalTestRequest {
|
||||||
|
Objects.requireNonNull(validationInput, "validationInput must not be null");
|
||||||
|
configFilePath = configFilePath == null ? "" : configFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine Anforderung ohne geladene Konfigurationsdatei.
|
||||||
|
*
|
||||||
|
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
||||||
|
* @return eine neue Anforderung ohne Konfigurationsdateipfad
|
||||||
|
*/
|
||||||
|
public static TechnicalTestRequest of(EditorValidationInput validationInput) {
|
||||||
|
return new TechnicalTestRequest(validationInput, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob ein Konfigurationsdateipfad gesetzt ist.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn ein nicht leerer Pfad vorhanden ist
|
||||||
|
*/
|
||||||
|
public boolean hasConfigFilePath() {
|
||||||
|
return !configFilePath.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Typen und Port-Verträge für den technischen Gesamttest der GUI-Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Dieses Package enthält ausschließlich:
|
||||||
|
* <ul>
|
||||||
|
* <li>Eingabe- und Ergebnismodelle für den vollständigen Gesamttest ({@code TechnicalTestRequest},
|
||||||
|
* {@code TechnicalTestReport}, {@code CheckpointResult}, {@code CheckpointId})</li>
|
||||||
|
* <li>Korrekturmodelle für schreibende Korrekturhilfen ({@code CorrectionSuggestion},
|
||||||
|
* {@code CorrectionPlan}, {@code CorrectionOutcome}, {@code CorrectionExecutionReport})</li>
|
||||||
|
* <li>Outbound-Port-Verträge für Pfadprüfungen ({@code PathCheckPort}) und schreibende
|
||||||
|
* Korrekturen ({@code ResourceCreationPort})</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Abgrenzungen:
|
||||||
|
* <ul>
|
||||||
|
* <li>Dieses Package enthält <strong>keine</strong> konkreten Implementierungen; diese
|
||||||
|
* leben im Adapter-Out-Modul.</li>
|
||||||
|
* <li>Keine JavaFX-, NIO-Framework-, HTTP- oder JDBC-Bibliothekstypen. Standard-JDK-Typen
|
||||||
|
* wie {@code java.nio.file.Path} sind ebenfalls nicht in Port-Signaturen erlaubt;
|
||||||
|
* die Ports verwenden {@code String} als plattformneutralen Pfadtyp.</li>
|
||||||
|
* <li>Die Gesamttest-Orchestrierung und die Bestätigungslogik liegen in späteren
|
||||||
|
* Arbeitspaketen; dieses Package definiert nur die Verträge.</li>
|
||||||
|
* <li>Die automatische Hintergrundvalidierung (Öffnen/Bearbeiten) sowie die explizite
|
||||||
|
* Aktion „Validieren" (nicht schreibend, lokal) sind im Package
|
||||||
|
* {@code validation.editor} definiert und bleiben dort unverändert.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ApiKeyOrigin}.
|
||||||
|
*/
|
||||||
|
class ApiKeyOriginTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allValuesPresent() {
|
||||||
|
assertThat(ApiKeyOrigin.values()).containsExactlyInAnyOrder(
|
||||||
|
ApiKeyOrigin.FROM_PROVIDER_ENV_VAR,
|
||||||
|
ApiKeyOrigin.FROM_LEGACY_ENV_VAR,
|
||||||
|
ApiKeyOrigin.FROM_PROPERTY_FILE,
|
||||||
|
ApiKeyOrigin.ABSENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumLookupByName() {
|
||||||
|
assertThat(ApiKeyOrigin.valueOf("FROM_PROVIDER_ENV_VAR")).isEqualTo(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR);
|
||||||
|
assertThat(ApiKeyOrigin.valueOf("FROM_LEGACY_ENV_VAR")).isEqualTo(ApiKeyOrigin.FROM_LEGACY_ENV_VAR);
|
||||||
|
assertThat(ApiKeyOrigin.valueOf("FROM_PROPERTY_FILE")).isEqualTo(ApiKeyOrigin.FROM_PROPERTY_FILE);
|
||||||
|
assertThat(ApiKeyOrigin.valueOf("ABSENT")).isEqualTo(ApiKeyOrigin.ABSENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link EffectiveApiKeyDescriptor}.
|
||||||
|
*/
|
||||||
|
class EffectiveApiKeyDescriptorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromProviderEnvVar_setsOriginAndVarName() {
|
||||||
|
var descriptor = EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY");
|
||||||
|
|
||||||
|
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR);
|
||||||
|
assertThat(descriptor.envVarName()).contains("ANTHROPIC_API_KEY");
|
||||||
|
assertThat(descriptor.isFromEnvironmentVariable()).isTrue();
|
||||||
|
assertThat(descriptor.isAbsent()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromLegacyEnvVar_setsOriginAndVarName() {
|
||||||
|
var descriptor = EffectiveApiKeyDescriptor.fromLegacyEnvVar("OPENAI_API_KEY");
|
||||||
|
|
||||||
|
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_LEGACY_ENV_VAR);
|
||||||
|
assertThat(descriptor.envVarName()).contains("OPENAI_API_KEY");
|
||||||
|
assertThat(descriptor.isFromEnvironmentVariable()).isTrue();
|
||||||
|
assertThat(descriptor.isAbsent()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromPropertyFile_setsOriginAndEmptyVarName() {
|
||||||
|
var descriptor = EffectiveApiKeyDescriptor.fromPropertyFile();
|
||||||
|
|
||||||
|
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.FROM_PROPERTY_FILE);
|
||||||
|
assertThat(descriptor.envVarName()).isEmpty();
|
||||||
|
assertThat(descriptor.isFromEnvironmentVariable()).isFalse();
|
||||||
|
assertThat(descriptor.isAbsent()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void absent_setsAbsentOriginAndEmptyVarName() {
|
||||||
|
var descriptor = EffectiveApiKeyDescriptor.absent();
|
||||||
|
|
||||||
|
assertThat(descriptor.origin()).isEqualTo(ApiKeyOrigin.ABSENT);
|
||||||
|
assertThat(descriptor.envVarName()).isEmpty();
|
||||||
|
assertThat(descriptor.isFromEnvironmentVariable()).isFalse();
|
||||||
|
assertThat(descriptor.isAbsent()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullEnvVarNameBecomesEmpty() {
|
||||||
|
var descriptor = new EffectiveApiKeyDescriptor(ApiKeyOrigin.FROM_PROPERTY_FILE, null);
|
||||||
|
assertThat(descriptor.envVarName()).isEqualTo(Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullOrigin() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new EffectiveApiKeyDescriptor(null, Optional.empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromProviderEnvVar_rejectsNullName() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> EffectiveApiKeyDescriptor.fromProviderEnvVar(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromLegacyEnvVar_rejectsNullName() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> EffectiveApiKeyDescriptor.fromLegacyEnvVar(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equality_basedOnAllFields() {
|
||||||
|
var a = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_A");
|
||||||
|
var b = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_A");
|
||||||
|
var c = EffectiveApiKeyDescriptor.fromProviderEnvVar("VAR_B");
|
||||||
|
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
assertThat(a).isNotEqualTo(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ModelCatalogRequest}.
|
||||||
|
*/
|
||||||
|
class ModelCatalogRequestTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesAllProvidedValues() {
|
||||||
|
var request = new ModelCatalogRequest(
|
||||||
|
"claude",
|
||||||
|
Optional.of("https://api.anthropic.com"),
|
||||||
|
Optional.of("test-key"),
|
||||||
|
30);
|
||||||
|
|
||||||
|
assertThat(request.providerIdentifier()).isEqualTo("claude");
|
||||||
|
assertThat(request.baseUrl()).contains("https://api.anthropic.com");
|
||||||
|
assertThat(request.apiKey()).contains("test-key");
|
||||||
|
assertThat(request.timeoutSeconds()).isEqualTo(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullBaseUrlBecomesEmpty() {
|
||||||
|
var request = new ModelCatalogRequest("claude", null, Optional.of("key"), 10);
|
||||||
|
assertThat(request.baseUrl()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullApiKeyBecomesEmpty() {
|
||||||
|
var request = new ModelCatalogRequest("claude", Optional.empty(), null, 10);
|
||||||
|
assertThat(request.apiKey()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNullProviderIdentifier() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogRequest(null, Optional.empty(), Optional.empty(), 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsZeroTimeout() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogRequest("claude", Optional.empty(), Optional.empty(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNegativeTimeout() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogRequest("claude", Optional.empty(), Optional.empty(), -5));
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ModelCatalogResult} and its permitted sub-types.
|
||||||
|
*/
|
||||||
|
class ModelCatalogResultTest {
|
||||||
|
|
||||||
|
// ── Success ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_storesAllFields() {
|
||||||
|
var now = Instant.now();
|
||||||
|
var result = new ModelCatalogResult.Success("claude", List.of("claude-3-5-sonnet", "claude-3-opus"), now);
|
||||||
|
|
||||||
|
assertThat(result.providerIdentifier()).isEqualTo("claude");
|
||||||
|
assertThat(result.models()).containsExactly("claude-3-5-sonnet", "claude-3-opus");
|
||||||
|
assertThat(result.loadedAt()).isEqualTo(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_modelListIsDefensiveCopy() {
|
||||||
|
var mutable = new java.util.ArrayList<>(List.of("model-a"));
|
||||||
|
var result = new ModelCatalogResult.Success("claude", mutable, Instant.now());
|
||||||
|
mutable.add("model-b");
|
||||||
|
|
||||||
|
assertThat(result.models()).containsExactly("model-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_rejectsNullProviderIdentifier() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.Success(null, List.of("m"), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_rejectsNullModelList() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.Success("claude", null, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_rejectsEmptyModelList() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.Success("claude", List.of(), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_rejectsNullTimestamp() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.Success("claude", List.of("m"), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EmptyList ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyList_storesAllFields() {
|
||||||
|
var now = Instant.now();
|
||||||
|
var result = new ModelCatalogResult.EmptyList("openai-compatible", now);
|
||||||
|
|
||||||
|
assertThat(result.providerIdentifier()).isEqualTo("openai-compatible");
|
||||||
|
assertThat(result.loadedAt()).isEqualTo(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyList_rejectsNullProviderIdentifier() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.EmptyList(null, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyList_rejectsNullTimestamp() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.EmptyList("claude", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IncompleteConfiguration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteConfiguration_storesAllFields() {
|
||||||
|
var result = new ModelCatalogResult.IncompleteConfiguration("claude", "API-Schluessel fehlt");
|
||||||
|
|
||||||
|
assertThat(result.providerIdentifier()).isEqualTo("claude");
|
||||||
|
assertThat(result.missingReason()).isEqualTo("API-Schluessel fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteConfiguration_rejectsNullProviderIdentifier() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.IncompleteConfiguration(null, "reason"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteConfiguration_rejectsNullMissingReason() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.IncompleteConfiguration("claude", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TechnicalFailure ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void technicalFailure_storesAllFields() {
|
||||||
|
var result = new ModelCatalogResult.TechnicalFailure("openai-compatible", "HTTP_ERROR", "503 Service Unavailable");
|
||||||
|
|
||||||
|
assertThat(result.providerIdentifier()).isEqualTo("openai-compatible");
|
||||||
|
assertThat(result.errorCategory()).isEqualTo("HTTP_ERROR");
|
||||||
|
assertThat(result.errorDetail()).isEqualTo("503 Service Unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void technicalFailure_rejectsNullProviderIdentifier() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure(null, "cat", "detail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void technicalFailure_rejectsNullErrorCategory() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure("claude", null, "detail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void technicalFailure_rejectsNullErrorDetail() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new ModelCatalogResult.TechnicalFailure("claude", "cat", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pattern-matching exhaustiveness ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patternMatchingSwitchCoversAllPermittedSubtypes() {
|
||||||
|
ModelCatalogResult success = new ModelCatalogResult.Success("claude", List.of("m"), Instant.now());
|
||||||
|
ModelCatalogResult empty = new ModelCatalogResult.EmptyList("claude", Instant.now());
|
||||||
|
ModelCatalogResult incomplete = new ModelCatalogResult.IncompleteConfiguration("claude", "reason");
|
||||||
|
ModelCatalogResult failure = new ModelCatalogResult.TechnicalFailure("claude", "TIMEOUT", "detail");
|
||||||
|
|
||||||
|
assertThat(classifyResult(success)).isEqualTo("success");
|
||||||
|
assertThat(classifyResult(empty)).isEqualTo("empty");
|
||||||
|
assertThat(classifyResult(incomplete)).isEqualTo("incomplete");
|
||||||
|
assertThat(classifyResult(failure)).isEqualTo("failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String classifyResult(ModelCatalogResult result) {
|
||||||
|
return switch (result) {
|
||||||
|
case ModelCatalogResult.Success s -> "success";
|
||||||
|
case ModelCatalogResult.EmptyList e -> "empty";
|
||||||
|
case ModelCatalogResult.IncompleteConfiguration i -> "incomplete";
|
||||||
|
case ModelCatalogResult.TechnicalFailure t -> "failure";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+512
@@ -0,0 +1,512 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.editor;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für den {@link EditorConfigurationValidator}.
|
||||||
|
* <p>
|
||||||
|
* Prüft alle Validierungsregeln auf Basis synthetischer Eingaben ohne I/O.
|
||||||
|
*/
|
||||||
|
class EditorConfigurationValidatorTest {
|
||||||
|
|
||||||
|
private EditorConfigurationValidator validator;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
validator = new EditorConfigurationValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hilfsmethode: minimale gültige Eingabe
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static EditorValidationInput minimalValidInput() {
|
||||||
|
return new EditorValidationInput(
|
||||||
|
"claude", // activeProviderIdentifier
|
||||||
|
"C:/source", // sourceFolder
|
||||||
|
"C:/target", // targetFolder
|
||||||
|
"C:/db.sqlite", // sqliteFile
|
||||||
|
"C:/prompt.txt", // promptTemplateFile
|
||||||
|
"3", // maxRetriesTransient
|
||||||
|
"10", // maxPages
|
||||||
|
"500", // maxTextCharacters
|
||||||
|
"https://api.anthropic.com", // claudeBaseUrl
|
||||||
|
"claude-3-5-sonnet", // claudeModel
|
||||||
|
"30", // claudeTimeoutSeconds
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(), // claudeApiKeyDescriptor
|
||||||
|
"https://api.openai.com", // openaiBaseUrl
|
||||||
|
"gpt-4", // openaiModel
|
||||||
|
"30", // openaiTimeoutSeconds
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile() // openaiApiKeyDescriptor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Leere Eingabe / Null-Checks
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_emptyActiveProvider_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.hasErrors()).isTrue();
|
||||||
|
assertThat(report.findings()).anyMatch(f ->
|
||||||
|
f.fieldKey().isPresent()
|
||||||
|
&& f.fieldKey().get().equals(EditorConfigurationValidator.FIELD_ACTIVE_PROVIDER)
|
||||||
|
&& f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_unknownActiveProvider_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"unknown-provider", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.hasErrors()).isTrue();
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_ACTIVE_PROVIDER))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Pflichtpfade
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_emptySourceFolder_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_SOURCE_FOLDER))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_emptyTargetFolder_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_TARGET_FOLDER))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_emptySqliteFile_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_SQLITE_FILE))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_emptyPromptFile_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_PROMPT_FILE))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// max.retries.transient
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxRetriesTransient_zero_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"0", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxRetriesTransient_negative_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"-1", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxRetriesTransient_one_producesNoError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"1", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
List<EditorValidationFinding> retryFindings =
|
||||||
|
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES);
|
||||||
|
assertThat(retryFindings).noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxRetriesTransient_nonNumeric_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"abc", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_RETRIES))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// max.pages
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxPages_zero_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "0", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxPages_over100_producesHint() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "101", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.HINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxPages_exactly100_producesNoHintAndNoError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "100", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
List<EditorValidationFinding> pageFindings =
|
||||||
|
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_PAGES);
|
||||||
|
assertThat(pageFindings).noneMatch(f ->
|
||||||
|
f.severity() == EditorValidationSeverity.ERROR
|
||||||
|
|| f.severity() == EditorValidationSeverity.HINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// max.text.characters – Wirtschaftliche Warnlogik
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxTextCharacters_1000_producesNoFinding() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "1000",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxTextCharacters_1001_producesWarning() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "1001",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxTextCharacters_3000_producesWarning() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "3000",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxTextCharacters_3001_producesStrongWarning() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "3001",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
List<EditorValidationFinding> charFindings =
|
||||||
|
report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS);
|
||||||
|
assertThat(charFindings).anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||||
|
// Starke Warnung enthält "stark" oder "riskant"
|
||||||
|
assertThat(charFindings).anyMatch(f ->
|
||||||
|
f.message().toLowerCase().contains("stark")
|
||||||
|
|| f.message().toLowerCase().contains("riskant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_maxTextCharacters_zero_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "0",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_MAX_CHARS))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider-Felder: Claude
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_emptyModel_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_MODEL))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_emptyBaseUrl_producesWarning() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_BASE_URL))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_negativeTimeout_producesError() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "-5",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_TIMEOUT))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API-Key-Vorrangregel
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_absent_apiKey_producesWarning() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_fromPropertyFile_producesNoFinding() {
|
||||||
|
EditorValidationInput input = minimalValidInput();
|
||||||
|
// minimalValidInput() already uses fromPropertyFile() for Claude
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_fromEnvVar_producesInfoFinding() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY"),
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_CLAUDE_API_KEY))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.INFO
|
||||||
|
&& f.message().contains("ANTHROPIC_API_KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_openai_fromLegacyEnvVar_producesInfoFinding() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"https://api.openai.com", "gpt-4", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromLegacyEnvVar("PDF_UMBENENNER_API_KEY"));
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_API_KEY))
|
||||||
|
.anyMatch(f -> f.severity() == EditorValidationSeverity.INFO
|
||||||
|
&& f.message().contains("PDF_UMBENENNER_API_KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Vollständig gültige Konfiguration
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_fullyValidClaudeConfig_producesNoErrors() {
|
||||||
|
EditorValidationInput input = minimalValidInput();
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.hasErrors()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_fullyValidOpenAiConfig_producesNoErrors() {
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"openai-compatible", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"", "", "30", EffectiveApiKeyDescriptor.absent(),
|
||||||
|
"https://api.openai.com", "gpt-4", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
assertThat(report.hasErrors()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Inaktiver Provider – keine Fehler für inaktiven Block
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_claude_active_openaiBlockEmpty_producesNoOpenaiErrors() {
|
||||||
|
// Claude ist aktiv; OpenAI-Felder sind leer – darf keinen FEHLER für OpenAI-Felder geben
|
||||||
|
EditorValidationInput input = new EditorValidationInput(
|
||||||
|
"claude", "C:/source", "C:/target", "C:/db.sqlite", "C:/prompt.txt",
|
||||||
|
"3", "10", "500",
|
||||||
|
"https://api.anthropic.com", "claude-3-5-sonnet", "30",
|
||||||
|
EffectiveApiKeyDescriptor.fromPropertyFile(),
|
||||||
|
"", "", "", EffectiveApiKeyDescriptor.absent());
|
||||||
|
|
||||||
|
EditorValidationReport report = validator.validate(input);
|
||||||
|
|
||||||
|
// OpenAI-Felder dürfen keinen Fehler produzieren (Provider nicht aktiv)
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_MODEL))
|
||||||
|
.noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
assertThat(report.findingsFor(EditorConfigurationValidator.FIELD_OPENAI_TIMEOUT))
|
||||||
|
.noneMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für den {@link CheckpointId}-Enum.
|
||||||
|
*/
|
||||||
|
class CheckpointIdTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allMandatoryCheckpointIdsArePresent() {
|
||||||
|
var ids = CheckpointId.values();
|
||||||
|
assertThat(ids).contains(
|
||||||
|
CheckpointId.CONFIGURATION_BASIC_VALIDATION,
|
||||||
|
CheckpointId.PROVIDER_CONFIGURATION,
|
||||||
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
|
CheckpointId.API_KEY_PRESENT,
|
||||||
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
|
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||||
|
CheckpointId.PROMPT_FILE_PRESENT,
|
||||||
|
CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
|
CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
|
CheckpointId.SQLITE_PATH_USABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumHasExactlyElevenValues() {
|
||||||
|
assertThat(CheckpointId.values()).hasSize(11);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void valueOfRoundtrip() {
|
||||||
|
for (CheckpointId id : CheckpointId.values()) {
|
||||||
|
assertThat(CheckpointId.valueOf(id.name())).isSameAs(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für das versiegelte Interface {@link CheckpointResult} und seine Untertypen.
|
||||||
|
*/
|
||||||
|
class CheckpointResultTest {
|
||||||
|
|
||||||
|
// --- Success ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_storesCheckpointIdAndMessage() {
|
||||||
|
var result = new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT, "Ordner vorhanden");
|
||||||
|
assertThat(result.checkpointId()).isEqualTo(CheckpointId.SOURCE_FOLDER_PRESENT);
|
||||||
|
assertThat(result.message()).isEqualTo("Ordner vorhanden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_nullCheckpointIdThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CheckpointResult.Success(null, "ok"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_nullMessageThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CheckpointResult.Success(CheckpointId.API_KEY_PRESENT, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_equality() {
|
||||||
|
var a = new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT, "ok");
|
||||||
|
var b = new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT, "ok");
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Failure ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failure_of_noCorrectionSuggestion() {
|
||||||
|
var result = CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt");
|
||||||
|
assertThat(result.checkpointId()).isEqualTo(CheckpointId.TARGET_FOLDER_USABLE);
|
||||||
|
assertThat(result.severity()).isEqualTo(CheckpointSeverity.ERROR);
|
||||||
|
assertThat(result.message()).isEqualTo("Ordner fehlt");
|
||||||
|
assertThat(result.correctionSuggestion()).isEmpty();
|
||||||
|
assertThat(result.hasCorrectionSuggestion()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failure_withCorrection_storesSuggestion() {
|
||||||
|
var suggestion = new CorrectionSuggestion.CreateDirectory("/some/path", "Ordner anlegen");
|
||||||
|
var result = CheckpointResult.Failure.withCorrection(
|
||||||
|
CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt", suggestion);
|
||||||
|
assertThat(result.hasCorrectionSuggestion()).isTrue();
|
||||||
|
assertThat(result.correctionSuggestion()).contains(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failure_nullCorrectionSuggestionInConstructorBecomesEmpty() {
|
||||||
|
var result = new CheckpointResult.Failure(
|
||||||
|
CheckpointId.SQLITE_PATH_USABLE, CheckpointSeverity.WARNING, "Pfad auffällig", null);
|
||||||
|
assertThat(result.correctionSuggestion()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failure_nullCheckpointIdThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> CheckpointResult.Failure.of(null, CheckpointSeverity.ERROR, "msg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failure_warningLevel() {
|
||||||
|
var result = CheckpointResult.Failure.of(
|
||||||
|
CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.WARNING, "Modell unbekannt");
|
||||||
|
assertThat(result.severity()).isEqualTo(CheckpointSeverity.WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NotApplicable ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notApplicable_storesCheckpointIdAndReason() {
|
||||||
|
var result = new CheckpointResult.NotApplicable(
|
||||||
|
CheckpointId.API_KEY_ACCEPTED, "Kein API-Key vorhanden");
|
||||||
|
assertThat(result.checkpointId()).isEqualTo(CheckpointId.API_KEY_ACCEPTED);
|
||||||
|
assertThat(result.reason()).isEqualTo("Kein API-Key vorhanden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notApplicable_nullCheckpointIdThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CheckpointResult.NotApplicable(null, "reason"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notApplicable_nullReasonThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CheckpointResult.NotApplicable(CheckpointId.MODEL_LIST_AVAILABLE, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pattern Matching ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patternMatching_coversAllPermittedTypes() {
|
||||||
|
CheckpointResult success = new CheckpointResult.Success(CheckpointId.CONFIGURATION_BASIC_VALIDATION, "ok");
|
||||||
|
CheckpointResult failure = CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, CheckpointSeverity.ERROR, "nicht erreichbar");
|
||||||
|
CheckpointResult notApplicable = new CheckpointResult.NotApplicable(CheckpointId.API_KEY_ACCEPTED, "kein key");
|
||||||
|
|
||||||
|
assertThat(classify(success)).isEqualTo("success");
|
||||||
|
assertThat(classify(failure)).isEqualTo("failure");
|
||||||
|
assertThat(classify(notApplicable)).isEqualTo("notApplicable");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String classify(CheckpointResult result) {
|
||||||
|
return switch (result) {
|
||||||
|
case CheckpointResult.Success s -> "success";
|
||||||
|
case CheckpointResult.Failure f -> "failure";
|
||||||
|
case CheckpointResult.NotApplicable n -> "notApplicable";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link CorrectionExecutionReport}.
|
||||||
|
*/
|
||||||
|
class CorrectionExecutionReportTest {
|
||||||
|
|
||||||
|
private final CorrectionSuggestion s1 =
|
||||||
|
new CorrectionSuggestion.CreateDirectory("/path/a", "Ordner A");
|
||||||
|
private final CorrectionSuggestion s2 =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt erzeugen");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyReport_allAppliedIsFalse() {
|
||||||
|
var report = new CorrectionExecutionReport(List.of());
|
||||||
|
assertThat(report.allApplied()).isFalse();
|
||||||
|
assertThat(report.hasFailures()).isFalse();
|
||||||
|
assertThat(report.hasNotAttempted()).isFalse();
|
||||||
|
assertThat(report.size()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void report_allApplied() {
|
||||||
|
var report = new CorrectionExecutionReport(List.of(
|
||||||
|
new CorrectionOutcome.Applied(s1, "ok1"),
|
||||||
|
new CorrectionOutcome.Applied(s2, "ok2")));
|
||||||
|
assertThat(report.allApplied()).isTrue();
|
||||||
|
assertThat(report.hasFailures()).isFalse();
|
||||||
|
assertThat(report.hasNotAttempted()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void report_withFailure_hasFailures() {
|
||||||
|
var report = new CorrectionExecutionReport(List.of(
|
||||||
|
new CorrectionOutcome.Applied(s1, "ok"),
|
||||||
|
new CorrectionOutcome.Failed(s2, "Fehler")));
|
||||||
|
assertThat(report.hasFailures()).isTrue();
|
||||||
|
assertThat(report.allApplied()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void report_withNotAttempted_hasNotAttempted() {
|
||||||
|
var report = new CorrectionExecutionReport(List.of(
|
||||||
|
new CorrectionOutcome.NotAttempted(s1, "Grund")));
|
||||||
|
assertThat(report.hasNotAttempted()).isTrue();
|
||||||
|
assertThat(report.allApplied()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outcomesListIsImmutable() {
|
||||||
|
var mutable = new ArrayList<CorrectionOutcome>();
|
||||||
|
mutable.add(new CorrectionOutcome.Applied(s1, "ok"));
|
||||||
|
var report = new CorrectionExecutionReport(mutable);
|
||||||
|
mutable.add(new CorrectionOutcome.Failed(s2, "err"));
|
||||||
|
assertThat(report.outcomes()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullOutcomesThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CorrectionExecutionReport(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link CorrectionExecutionService}.
|
||||||
|
* <p>
|
||||||
|
* Prüft das Dispatch-Verhalten und die Kein-Frühabbruch-Semantik.
|
||||||
|
*/
|
||||||
|
class CorrectionExecutionServiceTest {
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// No-op Port-Implementierungen für Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** Port-Stub, der alle Aufrufe als Applied zurückgibt. */
|
||||||
|
private static ResourceCreationPort allAppliedPort() {
|
||||||
|
return new ResourceCreationPort() {
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
|
||||||
|
return new CorrectionOutcome.Applied(s, "Ordner angelegt");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
|
||||||
|
return new CorrectionOutcome.Applied(s, "Prompt erzeugt");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
|
||||||
|
return new CorrectionOutcome.Applied(s, "SQLite-Pfad vorbereitet");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Port-Stub, der createDirectory als Failed, den Rest als Applied zurückgibt. */
|
||||||
|
private static ResourceCreationPort firstFailsPort() {
|
||||||
|
return new ResourceCreationPort() {
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
|
||||||
|
return new CorrectionOutcome.Failed(s, "Ordner nicht anlegbar");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
|
||||||
|
return new CorrectionOutcome.Applied(s, "Prompt erzeugt");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
|
||||||
|
return new CorrectionOutcome.Applied(s, "SQLite-Pfad vorbereitet");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_emptyPlan_returnsEmptyReport() {
|
||||||
|
CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
|
||||||
|
|
||||||
|
CorrectionExecutionReport report = service.execute(CorrectionPlan.empty());
|
||||||
|
|
||||||
|
assertNotNull(report);
|
||||||
|
assertEquals(0, report.size());
|
||||||
|
assertFalse(report.hasFailures());
|
||||||
|
assertFalse(report.hasNotAttempted());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_planWithThreeSuggestions_allSucceed_reportHasThreeApplied() {
|
||||||
|
CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
|
||||||
|
|
||||||
|
CorrectionSuggestion.CreateDirectory dir =
|
||||||
|
new CorrectionSuggestion.CreateDirectory("C:/foo", "Zielordner anlegen");
|
||||||
|
CorrectionSuggestion.CreatePromptFile prompt =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen");
|
||||||
|
CorrectionSuggestion.PrepareSqlitePath sqlite =
|
||||||
|
new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten");
|
||||||
|
|
||||||
|
CorrectionPlan plan = new CorrectionPlan(List.of(dir, prompt, sqlite));
|
||||||
|
CorrectionExecutionReport report = service.execute(plan);
|
||||||
|
|
||||||
|
assertEquals(3, report.size());
|
||||||
|
assertTrue(report.allApplied(), "Alle drei Korrekturen sollen Applied sein");
|
||||||
|
assertFalse(report.hasFailures());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_planWithOneFailing_othersStillExecuted_noEarlyAbort() {
|
||||||
|
CorrectionExecutionService service = new CorrectionExecutionService(firstFailsPort());
|
||||||
|
|
||||||
|
CorrectionSuggestion.CreateDirectory dir =
|
||||||
|
new CorrectionSuggestion.CreateDirectory("C:/foo", "Ordner anlegen");
|
||||||
|
CorrectionSuggestion.CreatePromptFile prompt =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen");
|
||||||
|
CorrectionSuggestion.PrepareSqlitePath sqlite =
|
||||||
|
new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten");
|
||||||
|
|
||||||
|
CorrectionPlan plan = new CorrectionPlan(List.of(dir, prompt, sqlite));
|
||||||
|
CorrectionExecutionReport report = service.execute(plan);
|
||||||
|
|
||||||
|
assertEquals(3, report.size(), "Alle 3 Korrekturen müssen versucht worden sein (kein Frühabbruch)");
|
||||||
|
assertTrue(report.hasFailures(), "Erste Korrektur ist fehlgeschlagen");
|
||||||
|
assertFalse(report.allApplied());
|
||||||
|
|
||||||
|
// Erster Eintrag: Failed (CreateDirectory)
|
||||||
|
assertInstanceOf(CorrectionOutcome.Failed.class, report.outcomes().get(0));
|
||||||
|
// Zweiter und dritter Eintrag: Applied (trotz Fehler im ersten)
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, report.outcomes().get(1));
|
||||||
|
assertInstanceOf(CorrectionOutcome.Applied.class, report.outcomes().get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNullPointerException() {
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> new CorrectionExecutionService(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void execute_nullPlan_throwsNullPointerException() {
|
||||||
|
CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
|
||||||
|
assertThrows(NullPointerException.class, () -> service.execute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for instanceOf assertion
|
||||||
|
private static <T> void assertInstanceOf(Class<T> expectedType, Object actual) {
|
||||||
|
assertTrue(expectedType.isInstance(actual),
|
||||||
|
"Expected instance of " + expectedType.getSimpleName()
|
||||||
|
+ " but got " + (actual == null ? "null" : actual.getClass().getSimpleName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tests: CreatePromptFile-Dispatch prüft DefaultPromptTemplate-Inhalt
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der {@link CorrectionExecutionService} dispatcht {@link CorrectionSuggestion.CreatePromptFile}
|
||||||
|
* an den Port. Ein Port-Stub, der den Inhalt der Suggestion zurückgibt, muss den
|
||||||
|
* deutschen Standardinhalt aus {@link DefaultPromptTemplate#defaultContent()} enthalten,
|
||||||
|
* wenn der Adapter ihn korrekt befüllt. Hier prüfen wir lediglich, dass
|
||||||
|
* {@link DefaultPromptTemplate#defaultContent()} einen sinnvollen deutschen Text liefert,
|
||||||
|
* der für die Dispatch-Kette geeignet ist.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createPromptFile_dispatch_defaultContentIsGermanAndNonEmpty() {
|
||||||
|
// Der Dispatch selbst ist im Service zustandslos.
|
||||||
|
// Wir prüfen hier, dass DefaultPromptTemplate den benötigten Inhalt liefert,
|
||||||
|
// damit der Adapter ihn verwenden kann.
|
||||||
|
String content = DefaultPromptTemplate.defaultContent();
|
||||||
|
assertNotNull(content);
|
||||||
|
assertFalse(content.isBlank(), "DefaultPromptTemplate.defaultContent() darf nicht leer sein");
|
||||||
|
assertTrue(content.contains("Titel"), "Inhalt muss deutsches Schlüsselwort 'Titel' enthalten");
|
||||||
|
assertTrue(content.contains("date"), "Inhalt muss JSON-Feld 'date' beschreiben");
|
||||||
|
assertTrue(content.contains("reasoning"), "Inhalt muss JSON-Feld 'reasoning' beschreiben");
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für das versiegelte Interface {@link CorrectionOutcome} und seine Untertypen.
|
||||||
|
*/
|
||||||
|
class CorrectionOutcomeTest {
|
||||||
|
|
||||||
|
private final CorrectionSuggestion suggestion =
|
||||||
|
new CorrectionSuggestion.CreateDirectory("/some/dir", "Ordner anlegen");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applied_storesSuggestionAndMessage() {
|
||||||
|
var outcome = new CorrectionOutcome.Applied(suggestion, "Ordner wurde angelegt");
|
||||||
|
assertThat(outcome.suggestion()).isSameAs(suggestion);
|
||||||
|
assertThat(outcome.message()).isEqualTo("Ordner wurde angelegt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applied_nullSuggestionThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CorrectionOutcome.Applied(null, "msg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failed_storesSuggestionAndErrorMessage() {
|
||||||
|
var outcome = new CorrectionOutcome.Failed(suggestion, "Zugriff verweigert");
|
||||||
|
assertThat(outcome.suggestion()).isSameAs(suggestion);
|
||||||
|
assertThat(outcome.errorMessage()).isEqualTo("Zugriff verweigert");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failed_nullErrorMessageThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CorrectionOutcome.Failed(suggestion, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notAttempted_storesSuggestionAndReason() {
|
||||||
|
var outcome = new CorrectionOutcome.NotAttempted(suggestion, "Elternordner nicht erreichbar");
|
||||||
|
assertThat(outcome.suggestion()).isSameAs(suggestion);
|
||||||
|
assertThat(outcome.reason()).isEqualTo("Elternordner nicht erreichbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patternMatching_coversAllPermittedTypes() {
|
||||||
|
CorrectionOutcome applied = new CorrectionOutcome.Applied(suggestion, "ok");
|
||||||
|
CorrectionOutcome failed = new CorrectionOutcome.Failed(suggestion, "error");
|
||||||
|
CorrectionOutcome notAttempted = new CorrectionOutcome.NotAttempted(suggestion, "reason");
|
||||||
|
|
||||||
|
assertThat(classify(applied)).isEqualTo("applied");
|
||||||
|
assertThat(classify(failed)).isEqualTo("failed");
|
||||||
|
assertThat(classify(notAttempted)).isEqualTo("notAttempted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String classify(CorrectionOutcome outcome) {
|
||||||
|
return switch (outcome) {
|
||||||
|
case CorrectionOutcome.Applied a -> "applied";
|
||||||
|
case CorrectionOutcome.Failed f -> "failed";
|
||||||
|
case CorrectionOutcome.NotAttempted n -> "notAttempted";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link CorrectionPlan}.
|
||||||
|
*/
|
||||||
|
class CorrectionPlanTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void empty_hasNoCorrections() {
|
||||||
|
var plan = CorrectionPlan.empty();
|
||||||
|
assertThat(plan.hasCorrections()).isFalse();
|
||||||
|
assertThat(plan.size()).isZero();
|
||||||
|
assertThat(plan.suggestions()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plan_withSuggestions_hasCorrections() {
|
||||||
|
var s = new CorrectionSuggestion.CreateDirectory("/path", "desc");
|
||||||
|
var plan = new CorrectionPlan(List.of(s));
|
||||||
|
assertThat(plan.hasCorrections()).isTrue();
|
||||||
|
assertThat(plan.size()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void suggestionsListIsImmutable() {
|
||||||
|
var mutable = new ArrayList<CorrectionSuggestion>();
|
||||||
|
mutable.add(new CorrectionSuggestion.CreateDirectory("/a", "d1"));
|
||||||
|
var plan = new CorrectionPlan(mutable);
|
||||||
|
mutable.add(new CorrectionSuggestion.CreatePromptFile("/b", "d2"));
|
||||||
|
assertThat(plan.suggestions()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullSuggestionsThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CorrectionPlan(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plan_equality() {
|
||||||
|
var s = new CorrectionSuggestion.CreateDirectory("/path", "desc");
|
||||||
|
var a = new CorrectionPlan(List.of(s));
|
||||||
|
var b = new CorrectionPlan(List.of(s));
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für das versiegelte Interface {@link CorrectionSuggestion} und seine Untertypen.
|
||||||
|
*/
|
||||||
|
class CorrectionSuggestionTest {
|
||||||
|
|
||||||
|
// --- CreateDirectory ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_storesPathAndDescription() {
|
||||||
|
var s = new CorrectionSuggestion.CreateDirectory("/path/to/dir", "Ordner anlegen");
|
||||||
|
assertThat(s.path()).isEqualTo("/path/to/dir");
|
||||||
|
assertThat(s.descriptionForUser()).isEqualTo("Ordner anlegen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_blankPathThrows() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new CorrectionSuggestion.CreateDirectory(" ", "desc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDirectory_nullPathThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CorrectionSuggestion.CreateDirectory(null, "desc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CreatePromptFile ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPromptFile_storesPathAndDescription() {
|
||||||
|
var s = new CorrectionSuggestion.CreatePromptFile("/config/prompt.txt", "Prompt-Datei erzeugen");
|
||||||
|
assertThat(s.path()).isEqualTo("/config/prompt.txt");
|
||||||
|
assertThat(s.descriptionForUser()).isEqualTo("Prompt-Datei erzeugen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPromptFile_blankPathThrows() {
|
||||||
|
assertThatIllegalArgumentException()
|
||||||
|
.isThrownBy(() -> new CorrectionSuggestion.CreatePromptFile("", "desc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PrepareSqlitePath ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareSqlitePath_storesPathAndDescription() {
|
||||||
|
var s = new CorrectionSuggestion.PrepareSqlitePath("/data/store.db", "SQLite-Pfad vorbereiten");
|
||||||
|
assertThat(s.path()).isEqualTo("/data/store.db");
|
||||||
|
assertThat(s.descriptionForUser()).isEqualTo("SQLite-Pfad vorbereiten");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareSqlitePath_nullDescriptionThrows() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new CorrectionSuggestion.PrepareSqlitePath("/path", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pattern Matching ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patternMatching_coversAllPermittedTypes() {
|
||||||
|
CorrectionSuggestion dir = new CorrectionSuggestion.CreateDirectory("/a", "d1");
|
||||||
|
CorrectionSuggestion prompt = new CorrectionSuggestion.CreatePromptFile("/b", "d2");
|
||||||
|
CorrectionSuggestion sqlite = new CorrectionSuggestion.PrepareSqlitePath("/c", "d3");
|
||||||
|
|
||||||
|
assertThat(classify(dir)).isEqualTo("directory");
|
||||||
|
assertThat(classify(prompt)).isEqualTo("promptFile");
|
||||||
|
assertThat(classify(sqlite)).isEqualTo("sqlitePath");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String classify(CorrectionSuggestion s) {
|
||||||
|
return switch (s) {
|
||||||
|
case CorrectionSuggestion.CreateDirectory d -> "directory";
|
||||||
|
case CorrectionSuggestion.CreatePromptFile p -> "promptFile";
|
||||||
|
case CorrectionSuggestion.PrepareSqlitePath sp -> "sqlitePath";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link DefaultPromptTemplate}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass der zurückgegebene Standard-Prompt-Inhalt nicht leer ist,
|
||||||
|
* relevante deutsche Schlüsselwörter enthält und das erwartete JSON-Schema-Format beschreibt.
|
||||||
|
*/
|
||||||
|
class DefaultPromptTemplateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultContent_isNotNullAndNotEmpty() {
|
||||||
|
String content = DefaultPromptTemplate.defaultContent();
|
||||||
|
assertThat(content).isNotNull();
|
||||||
|
assertThat(content).isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultContent_containsGermanKeywords() {
|
||||||
|
String content = DefaultPromptTemplate.defaultContent();
|
||||||
|
assertThat(content).contains("Titel");
|
||||||
|
assertThat(content).contains("Datum");
|
||||||
|
assertThat(content).contains("Deutsch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultContent_containsJsonSchemaHint() {
|
||||||
|
String content = DefaultPromptTemplate.defaultContent();
|
||||||
|
// JSON-Felder müssen im Prompt beschrieben sein
|
||||||
|
assertThat(content).contains("title");
|
||||||
|
assertThat(content).contains("reasoning");
|
||||||
|
assertThat(content).contains("date");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultContent_containsDateFormatHint() {
|
||||||
|
String content = DefaultPromptTemplate.defaultContent();
|
||||||
|
assertThat(content).contains("YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultContent_mentionsTitleMaxLength() {
|
||||||
|
String content = DefaultPromptTemplate.defaultContent();
|
||||||
|
assertThat(content).contains("20");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultContent_isConsistent_calledTwice() {
|
||||||
|
// Idempotenz-Prüfung: zwei Aufrufe liefern denselben Inhalt
|
||||||
|
String first = DefaultPromptTemplate.defaultContent();
|
||||||
|
String second = DefaultPromptTemplate.defaultContent();
|
||||||
|
assertThat(first).isEqualTo(second);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user