Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 406eac80e4 | |||
| 4fba3379b9 | |||
| 9307a18e04 | |||
| 6a5ae4e7b0 | |||
| 479d176536 | |||
| bd2be347f6 | |||
| 18f9c33bbb | |||
| 349ee69a7f | |||
| 3b3e997d13 | |||
| ddfbf9b8cb | |||
| 0b69adf8c9 | |||
| 31c65fb9fd | |||
| 4ee0923721 | |||
| 4b89743404 | |||
| 6e03093ce9 | |||
| 51d6168697 | |||
| 46fc1d4fa4 | |||
| 5d5dee0bbf | |||
| 4f5ce4c750 | |||
| dc17824e84 | |||
| 0fe5359299 | |||
| 563d9f52db | |||
| 732d00c4ad |
@@ -240,6 +240,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
|
|||||||
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
|
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
|
||||||
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
|
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
|
||||||
|
|
||||||
|
## Commit und Push nach jeder Implementierung
|
||||||
|
Nach jeder Implementierung oder Dateiänderung wird ein Commit auf `main` erstellt und gepusht:
|
||||||
|
1. Geänderte Dateien stagen und committen
|
||||||
|
2. `git push origin main` ausführen
|
||||||
|
3. Schlägt der Push mit einem AUTH-Fehler fehl: 1 Sekunde warten, dann genau **einen** weiteren Versuch unternehmen
|
||||||
|
4. Schlägt auch der zweite Versuch fehl: Fehler benennen, keinen weiteren automatischen Retry
|
||||||
|
|
||||||
## Definition of Done pro Arbeitspaket
|
## Definition of Done pro Arbeitspaket
|
||||||
Ein Arbeitspaket ist erst fertig, wenn:
|
Ein Arbeitspaket ist erst fertig, wenn:
|
||||||
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
|
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
|
||||||
|
|||||||
Vendored
+100
-67
@@ -8,76 +8,104 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
}
|
||||||
|
|
||||||
|
tools {
|
||||||
|
maven 'maven-3'
|
||||||
|
}
|
||||||
|
|
||||||
// MAJOR und MINOR werden manuell als Jenkins-Parameter gepflegt.
|
// MAJOR und MINOR werden manuell als Jenkins-Parameter gepflegt.
|
||||||
// BUILD_NUMBER wird automatisch durch Jenkins vergeben.
|
// BUILD_NUMBER wird automatisch durch Jenkins vergeben.
|
||||||
// Die resultierende Versionsnummer lautet: MAJOR.MINOR.BUILD_NUMBER
|
// Die resultierende Versionsnummer lautet: MAJOR.MINOR.BUILD_NUMBER
|
||||||
parameters {
|
parameters {
|
||||||
string(
|
string(name: 'MAJOR', defaultValue: '3', description: 'SemVer MAJOR (manuell)')
|
||||||
name: 'MAJOR',
|
string(name: 'MINOR', defaultValue: '0', description: 'SemVer MINOR (manuell)')
|
||||||
defaultValue: '3',
|
|
||||||
description: 'Hauptversionsnummer (manuell pflegen)'
|
|
||||||
)
|
|
||||||
string(
|
|
||||||
name: 'MINOR',
|
|
||||||
defaultValue: '0',
|
|
||||||
description: 'Nebenversionsnummer (manuell pflegen)'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
// Effektive Versionsteile – übernommen aus Parametern oder State-Datei.
|
|
||||||
// Hinweis: Wenn MAJOR/MINOR aus einer persistierten State-Datei gelesen
|
|
||||||
// werden sollen (z. B. /builds/version.state), muss die Logik unten in
|
|
||||||
// der Stage 'Version bestimmen' entsprechend ergänzt werden.
|
|
||||||
// Im Minimalbetrieb werden die Parameter direkt übernommen.
|
|
||||||
EFFECTIVE_MAJOR = "${params.MAJOR}"
|
|
||||||
EFFECTIVE_MINOR = "${params.MINOR}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
|
|
||||||
stage('Checkout') {
|
|
||||||
steps {
|
|
||||||
checkout scm
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionaler Stub: MAJOR/MINOR aus persistierter State-Datei laden.
|
|
||||||
// Wenn das bestehende Jenkins-Setup die Versionsnummern in einer
|
|
||||||
// State-Datei unter /builds/version.state persistiert, kann diese
|
|
||||||
// Stage die Umgebungsvariablen EFFECTIVE_MAJOR und EFFECTIVE_MINOR
|
|
||||||
// vor dem Build überschreiben. Ansonsten gelten die Parameter-Werte.
|
|
||||||
stage('Version bestimmen') {
|
stage('Version bestimmen') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
// Platzhalter: hier bei Bedarf State-Datei einlesen,
|
def isManual = !currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause').isEmpty()
|
||||||
// z. B.:
|
def jenkinsHome = env.JENKINS_HOME ?: '/var/jenkins_home'
|
||||||
// def state = readFile('/builds/version.state').trim()
|
def safeJobName = env.JOB_NAME.replaceAll(/[^A-Za-z0-9._-]/, '_')
|
||||||
// env.EFFECTIVE_MAJOR = state.split('\\.')[0]
|
def stateDir = "${jenkinsHome}/version-state"
|
||||||
// env.EFFECTIVE_MINOR = state.split('\\.')[1]
|
def stateFile = "${stateDir}/${safeJobName}.properties"
|
||||||
//
|
|
||||||
// Im Minimalbetrieb werden die Parameter-Werte verwendet:
|
if (isManual) {
|
||||||
echo "Buildversion: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
|
env.EFFECTIVE_MAJOR = params.MAJOR
|
||||||
|
env.EFFECTIVE_MINOR = params.MINOR
|
||||||
|
|
||||||
|
sh """
|
||||||
|
mkdir -p '${stateDir}'
|
||||||
|
cat > '${stateFile}' <<'EOF'
|
||||||
|
MAJOR=${params.MAJOR}
|
||||||
|
MINOR=${params.MINOR}
|
||||||
|
EOF
|
||||||
|
"""
|
||||||
|
|
||||||
|
echo "Manueller Build erkannt. Version gespeichert: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
|
} else {
|
||||||
|
def stateExists = (sh(script: "[ -f '${stateFile}' ]", returnStatus: true) == 0)
|
||||||
|
|
||||||
|
if (stateExists) {
|
||||||
|
env.EFFECTIVE_MAJOR = sh(
|
||||||
|
script: "grep '^MAJOR=' '${stateFile}' | cut -d= -f2-",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
env.EFFECTIVE_MINOR = sh(
|
||||||
|
script: "grep '^MINOR=' '${stateFile}' | cut -d= -f2-",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
echo "Automatischer Build erkannt. Gespeicherte Version verwendet: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
|
} else {
|
||||||
|
env.EFFECTIVE_MAJOR = params.MAJOR
|
||||||
|
env.EFFECTIVE_MINOR = params.MINOR
|
||||||
|
|
||||||
|
echo "Automatischer Build ohne gespeicherten Stand. Fallback auf Parameter: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} // stage: Version bestimmen
|
||||||
|
|
||||||
stage('Maven Build') {
|
stage('Maven Build') {
|
||||||
steps {
|
steps {
|
||||||
|
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
|
||||||
// -Drevision übergibt die vollständige Versionsnummer an Maven.
|
// -Drevision übergibt die vollständige Versionsnummer an Maven.
|
||||||
// Das flatten-maven-plugin im Parent-POM löst ${revision} in
|
// Das flatten-maven-plugin im Parent-POM löst ${revision} in
|
||||||
// allen installierten POMs auf, sodass kein unaufgelöstes
|
// allen installierten POMs auf.
|
||||||
// ${revision} in ~/.m2 verbleibt.
|
|
||||||
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
|
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // stage: Maven Build
|
||||||
|
|
||||||
|
stage('Publish PIT Coverage') {
|
||||||
|
steps {
|
||||||
|
recordCoverage(
|
||||||
|
tools: [[
|
||||||
|
parser: 'PIT',
|
||||||
|
pattern: '**/target/pit-reports/mutations.xml'
|
||||||
|
]],
|
||||||
|
id: 'pit',
|
||||||
|
name: 'PIT Mutation Coverage',
|
||||||
|
failOnError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} // stage: Publish PIT Coverage
|
||||||
|
|
||||||
stage('Archive JAR') {
|
stage('Archive JAR') {
|
||||||
steps {
|
steps {
|
||||||
// Bash wird explizit erzwungen (#!/usr/bin/env bash, set -euo pipefail),
|
// Bash wird explizit erzwungen, weil Jenkins-Agenten standardmäßig
|
||||||
// weil Jenkins-Agenten standardmäßig sh (dash) verwenden, das kein
|
// sh (dash) verwenden, das kein mapfile kennt. mapfile zählt exakt
|
||||||
// mapfile kennt. mapfile zählt exakt die gefundenen Shade-JARs und
|
// die gefundenen Shade-JARs und bricht ab, wenn nicht genau eines vorhanden ist.
|
||||||
// bricht den Build ab, wenn nicht genau eines vorhanden ist.
|
|
||||||
sh '''#!/usr/bin/env bash
|
sh '''#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -94,25 +122,10 @@ echo "Shade-JAR archiviert als: $JAR_NAME"
|
|||||||
'''
|
'''
|
||||||
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
|
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
|
||||||
}
|
}
|
||||||
}
|
} // stage: Archive JAR
|
||||||
|
|
||||||
stage('Berichte') {
|
|
||||||
steps {
|
|
||||||
// JUnit-Testergebnisse einlesen
|
|
||||||
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
|
|
||||||
|
|
||||||
// JaCoCo-Coverage (falls im Build erzeugt)
|
|
||||||
// jacoco execPattern: '**/target/jacoco.exec'
|
|
||||||
|
|
||||||
// PIT-Mutationstest-Bericht (falls im Build erzeugt)
|
|
||||||
// publishHTML(target: [reportDir: 'target/pit-reports', ...])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Artefakt ablegen') {
|
stage('Artefakt ablegen') {
|
||||||
steps {
|
steps {
|
||||||
// JAR-Kopie in zentrales Build-Verzeichnis ablegen.
|
|
||||||
// Pfad /builds/ muss auf dem Jenkins-Agent gemountet sein.
|
|
||||||
sh '''#!/usr/bin/env bash
|
sh '''#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -122,19 +135,39 @@ cp pdf-ki-renamer-*.jar "$BUILD_DIR/"
|
|||||||
echo "Artefakt abgelegt unter: $BUILD_DIR"
|
echo "Artefakt abgelegt unter: $BUILD_DIR"
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
} // stage: Artefakt ablegen
|
||||||
|
|
||||||
|
stage('Berichte veröffentlichen') {
|
||||||
|
steps {
|
||||||
|
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
|
||||||
|
|
||||||
|
recordCoverage(
|
||||||
|
tools: [[parser: 'JACOCO', pattern: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate/jacoco.xml']],
|
||||||
|
enabledForFailure: true
|
||||||
|
)
|
||||||
|
|
||||||
|
publishHTML(target: [
|
||||||
|
reportName: 'JaCoCo HTML Report',
|
||||||
|
reportDir: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate',
|
||||||
|
reportFiles: 'index.html',
|
||||||
|
keepAll: true,
|
||||||
|
alwaysLinkToLastBuild: true,
|
||||||
|
allowMissing: true
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
} // stage: Berichte veröffentlichen
|
||||||
|
|
||||||
stage('Aufräumen') {
|
stage('Aufräumen') {
|
||||||
steps {
|
steps {
|
||||||
// Lokale JAR-Kopie im Workspace entfernen (Artefakt ist archiviert)
|
|
||||||
sh '''#!/usr/bin/env bash
|
sh '''#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
rm -f pdf-ki-renamer-*.jar
|
rm -f pdf-ki-renamer-*.jar
|
||||||
echo "Aufräumen abgeschlossen."
|
echo "Aufräumen abgeschlossen."
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
} // stage: Aufräumen
|
||||||
}
|
|
||||||
|
} // stages
|
||||||
|
|
||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
@@ -144,8 +177,8 @@ echo "Aufräumen abgeschlossen."
|
|||||||
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
|
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
|
||||||
}
|
}
|
||||||
always {
|
always {
|
||||||
// Workspace nach Abschluss bereinigen
|
deleteDir()
|
||||||
cleanWs()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // pipeline
|
||||||
@@ -120,12 +120,19 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
|
|||||||
|
|
||||||
#### SQLite
|
#### SQLite
|
||||||
|
|
||||||
- **`...sqlite.SqliteSchemaInitializationAdapter`** – legt Tabellen `document_record` und
|
- **`...sqlite.SqliteSchemaInitializationAdapter`** – Flyway-basierte Schema-Initialisierung
|
||||||
`processing_attempt` an. Schema-Evolution erfolgt per `ALTER TABLE ADD COLUMN`; bestehende
|
mit `V1__initial_schema.sql`. Drei-Fall-Strategie: leere Datenbank (Flyway führt das Skript
|
||||||
Datenbestände bleiben rückwärtskompatibel.
|
vollständig aus), bestehender Datenbestand ohne Flyway-History (Schema-Prüfung, datiertes
|
||||||
|
Backup, dann Baseline-Eintrag ohne Skriptausführung), regulärer Folgestart mit Flyway-History
|
||||||
|
(idempotenter Lauf). Foreign-Key-Durchsetzung via `SQLiteConfig.enforceForeignKeys(true)` auf
|
||||||
|
DataSource-Ebene, sodass jede neue Verbindung automatisch `PRAGMA foreign_keys = ON` erhält.
|
||||||
|
|
||||||
- **`...sqlite.SqliteUnitOfWorkAdapter`** – implementiert `UnitOfWorkPort`. Setzt
|
- **`...sqlite.SqliteUnitOfWorkAdapter`** – implementiert `UnitOfWorkPort`. Setzt
|
||||||
`autoCommit=false`, führt atomare Commits durch, rollt bei Fehlern zurück.
|
`autoCommit=false`, führt atomare Commits durch, rollt bei Fehlern zurück. Die innere
|
||||||
|
`TransactionOperations`-Implementierung wurde um `resetDocumentStatusForRetry(DocumentFingerprint)`
|
||||||
|
erweitert: setzt feldgenau `overall_status = 'READY_FOR_AI'`, `content_error_count = 0`,
|
||||||
|
`transient_error_count = 0`, `last_failure_instant = NULL`; alle anderen Felder und alle
|
||||||
|
`processing_attempt`-Einträge bleiben unangetastet.
|
||||||
|
|
||||||
- **`...sqlite.SqliteDocumentRecordRepositoryAdapter`** – Stammsatz pro SHA-256-Fingerprint
|
- **`...sqlite.SqliteDocumentRecordRepositoryAdapter`** – Stammsatz pro SHA-256-Fingerprint
|
||||||
(Gesamtstatus, Fehlerzähler, Zieldateiname usw.).
|
(Gesamtstatus, Fehlerzähler, Zieldateiname usw.).
|
||||||
@@ -134,6 +141,12 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
|
|||||||
über Fingerprint. Enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator,
|
über Fingerprint. Enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator,
|
||||||
KI-Rohantwort und finalen Zieldateinamen.
|
KI-Rohantwort und finalen Zieldateinamen.
|
||||||
|
|
||||||
|
- **`...sqlite.SqliteHistoryQueryAdapter`** – implementiert `HistoryQueryPort`. Kapselt alle
|
||||||
|
lesenden Datenbankoperationen für den Historien-Tab: Übersicht (`loadOverview` mit
|
||||||
|
Sortierung `updated_at DESC, fingerprint ASC`, LIMIT 501-Strategie, case-insensitive
|
||||||
|
Freitextsuche via `LOWER()` mit Sonderzeichen-Escape für `%` und `_`), Stammsatz-Lookup
|
||||||
|
(`findRecordByFingerprint`) und Versuchshistorie (`findAttemptsByFingerprint`).
|
||||||
|
|
||||||
#### Konfiguration
|
#### Konfiguration
|
||||||
|
|
||||||
- **`...configuration.PropertiesConfigurationPortAdapter`** – implementiert `ConfigurationPort`.
|
- **`...configuration.PropertiesConfigurationPortAdapter`** – implementiert `ConfigurationPort`.
|
||||||
@@ -145,6 +158,15 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
|
|||||||
(Schlüssel wie `api.baseUrl`, `api.model`), legt eine `.bak`-Sicherung an und überführt den
|
(Schlüssel wie `api.baseUrl`, `api.model`), legt eine `.bak`-Sicherung an und überführt den
|
||||||
Inhalt in das aktuelle Multi-Provider-Schema.
|
Inhalt in das aktuelle Multi-Provider-Schema.
|
||||||
|
|
||||||
|
#### Prompt-Adapter
|
||||||
|
|
||||||
|
- **`...prompt.FilesystemPromptPortAdapter`** – implementiert `PromptPort`. Lädt das
|
||||||
|
Prompt-Template aus einer externen Datei und leitet den Identifikator aus dem Dateinamen ab.
|
||||||
|
Die neue Methode `savePrompt(String content)` schreibt den Inhalt atomar: temporäre Datei
|
||||||
|
im selben Verzeichnis anlegen (gleiche Partition), Inhalt in UTF-8 schreiben, dann
|
||||||
|
`ATOMIC_MOVE` zur Zieldatei. Kein stiller Fallback bei `AtomicMoveNotSupportedException`.
|
||||||
|
Der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf.
|
||||||
|
|
||||||
#### Laufzeitinfrastruktur
|
#### Laufzeitinfrastruktur
|
||||||
|
|
||||||
- **`...lock.FilesystemRunLockPortAdapter`** – Lock-Datei mit PID-Inhalt. Wirft
|
- **`...lock.FilesystemRunLockPortAdapter`** – Lock-Datei mit PID-Inhalt. Wirft
|
||||||
@@ -207,6 +229,11 @@ ausschließlich der Bootstrap (→ `AiProviderSelector`).
|
|||||||
Routet `AiModelCatalogPort`-Aufrufe anhand des `providerIdentifier` an den Claude- oder
|
Routet `AiModelCatalogPort`-Aufrufe anhand des `providerIdentifier` an den Claude- oder
|
||||||
OpenAI-kompatiblen Modell-Katalog-Adapter. Thread-safe.
|
OpenAI-kompatiblen Modell-Katalog-Adapter. Thread-safe.
|
||||||
|
|
||||||
|
- **`...bootstrap.ApplicationVersionProvider`** – statische Hilfsklasse ohne Zustand. Liest
|
||||||
|
`Implementation-Version` aus dem Paket-Manifest via `getClass().getPackage().getImplementationVersion()`.
|
||||||
|
Fallback `"dev"` bei IDE-Start und ungepacktem Betrieb (kein Manifest-Eintrag vorhanden).
|
||||||
|
Der aufgelöste Wert wird im GUI-Pfad in `GuiStartupContext.applicationVersion` eingesetzt.
|
||||||
|
|
||||||
- **`...bootstrap.adapter.Log4jProcessingLogger`** – implementiert `ProcessingLogger` auf Basis
|
- **`...bootstrap.adapter.Log4jProcessingLogger`** – implementiert `ProcessingLogger` auf Basis
|
||||||
von Log4j2. Unterdrückt sensitive KI-Inhalte, wenn `AiContentSensitivity.PROTECT_SENSITIVE_CONTENT`
|
von Log4j2. Unterdrückt sensitive KI-Inhalte, wenn `AiContentSensitivity.PROTECT_SENSITIVE_CONTENT`
|
||||||
gesetzt ist.
|
gesetzt ist.
|
||||||
@@ -267,8 +294,18 @@ DI-Framework verwendet.
|
|||||||
- `startGuiMode()` baut via `buildGuiStartupContext()` einen `GuiStartupContext`:
|
- `startGuiMode()` baut via `buildGuiStartupContext()` einen `GuiStartupContext`:
|
||||||
enthält `AiModelCatalogDispatcher`, `EnvironmentApiKeyResolutionAdapter`,
|
enthält `AiModelCatalogDispatcher`, `EnvironmentApiKeyResolutionAdapter`,
|
||||||
`TechnicalTestOrchestrator`, `GuiConfigurationPropertiesWriter`
|
`TechnicalTestOrchestrator`, `GuiConfigurationPropertiesWriter`
|
||||||
|
- Bootstrap verdrahtet zusätzlich vier neue History-Use-Cases (`DefaultHistoryOverviewUseCase`,
|
||||||
|
`DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`,
|
||||||
|
`DefaultDeleteDocumentHistoryUseCase`) und den `DefaultPromptEditorUseCase` als anonyme
|
||||||
|
Bridge-Implementierungen in den `GuiStartupContext`
|
||||||
|
- `ApplicationVersionProvider.resolveVersion()` wird aufgerufen und der Wert in
|
||||||
|
`GuiStartupContext.applicationVersion` gesetzt
|
||||||
|
- Wenn eine Konfigurationsdatei beim Start bekannt ist, erzeugt Bootstrap zusätzlich einen
|
||||||
|
vollständig verdrahteten `GuiPromptEditorPort` (kombiniert `FilesystemPromptPortAdapter` mit
|
||||||
|
`DefaultPromptEditorUseCase`); ohne Konfiguration erhält der Context einen No-Op-Port
|
||||||
- `GuiAdapter.start(context)` übernimmt; ab diesem Punkt liegt die Kontrolle beim GUI-Adapter
|
- `GuiAdapter.start(context)` übernimmt; ab diesem Punkt liegt die Kontrolle beim GUI-Adapter
|
||||||
- Im GUI-Pfad: keine SQLite-Schema-Initialisierung, kein Run-Lock-Erwerb, kein Batch-Use-Case
|
- Im GUI-Pfad: keine SQLite-Schema-Initialisierung beim Start, kein Run-Lock-Erwerb, kein Batch-Use-Case;
|
||||||
|
History-Operationen initialisieren die Schema-Verbindung ad-hoc pro Aufruf
|
||||||
- GUI-interne Ports und deren Verbindung mit Outbound-Adaptern sind in
|
- GUI-interne Ports und deren Verbindung mit Outbound-Adaptern sind in
|
||||||
`docs/architecture/gui-overview.md` beschrieben
|
`docs/architecture/gui-overview.md` beschrieben
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Definiert Use-Case-Orchestrierung sowie alle Inbound- und Outbound-Ports der hex
|
|||||||
| `de.gecheckt.pdf.umbenenner.application.port.in` | Inbound-Ports (Use-Case-Interfaces) – Einstiegspunkte für den Aufrufer |
|
| `de.gecheckt.pdf.umbenenner.application.port.in` | Inbound-Ports (Use-Case-Interfaces) – Einstiegspunkte für den Aufrufer |
|
||||||
| `de.gecheckt.pdf.umbenenner.application.port.out` | Outbound-Ports – Verträge gegenüber Infrastruktur-Adaptern (Persistenz, Dateisystem, KI, Uhr, Logging) |
|
| `de.gecheckt.pdf.umbenenner.application.port.out` | Outbound-Ports – Verträge gegenüber Infrastruktur-Adaptern (Persistenz, Dateisystem, KI, Uhr, Logging) |
|
||||||
| `de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog` | Spezialisierter Outbound-Port für den Abruf verfügbarer KI-Modelle; ausschließlich im GUI-Pfad genutzt (siehe `gui-overview.md`) |
|
| `de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog` | Spezialisierter Outbound-Port für den Abruf verfügbarer KI-Modelle; ausschließlich im GUI-Pfad genutzt (siehe `gui-overview.md`) |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.port.out.history` | Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab; bewusst getrennt von den bestehenden Repositories, um diese nicht mit GUI-spezifischen Methoden aufzublähen |
|
||||||
| `de.gecheckt.pdf.umbenenner.application.service` | Anwendungsnahe, zustandslose Dienste: KI-Antwort-Parsing, Pre-Check-Auswertung, Verarbeitungs-Pipeline, Retry-Entscheidung |
|
| `de.gecheckt.pdf.umbenenner.application.service` | Anwendungsnahe, zustandslose Dienste: KI-Antwort-Parsing, Pre-Check-Auswertung, Verarbeitungs-Pipeline, Retry-Entscheidung |
|
||||||
| `de.gecheckt.pdf.umbenenner.application.config` | Konfigurationsmodelle der Anwendungsschicht (`RuntimeConfiguration`, Provider-Konfiguration) |
|
| `de.gecheckt.pdf.umbenenner.application.config` | Konfigurationsmodelle der Anwendungsschicht (`RuntimeConfiguration`, Provider-Konfiguration) |
|
||||||
| `de.gecheckt.pdf.umbenenner.application.config.startup` | Vollständiges Startup-Konfigurationsmodell (`StartConfiguration`) |
|
| `de.gecheckt.pdf.umbenenner.application.config.startup` | Vollständiges Startup-Konfigurationsmodell (`StartConfiguration`) |
|
||||||
@@ -106,6 +107,25 @@ Record für einen Versuchshistorie-Eintrag; enthält u. a. Provider-Identifikato
|
|||||||
**`de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord`**
|
**`de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord`**
|
||||||
Record für den Dokument-Stammsatz; enthält Gesamtstatus, Fehler- und Transientzähler sowie letzten Zielpfad.
|
Record für den Dokument-Stammsatz; enthält Gesamtstatus, Fehler- und Transientzähler sowie letzten Zielpfad.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery`**
|
||||||
|
Record mit den Abfrageparametern für den Historien-Tab: optionaler Suchbegriff (`searchText`, Teilstring, case-insensitiv), optionaler Status-Filter (`statusFilter` als Enum-Name) und Limit der zurückzugebenden Zeilen (Standard `DEFAULT_LIMIT = 501`). Das Limit 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr als 500 Treffer vorhanden sind.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow`**
|
||||||
|
Einzelzeile der Dokumentenliste im Historien-Tab. Felder: `fingerprint`, `overallStatus`, `sourceFileName`, `targetFileName` (null wenn noch kein Erfolg), `sourcePath`, `updatedAt` und `attemptCount`. Stammt aus `document_record` mit einem `COUNT`-Ausdruck über `processing_attempt`.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult`**
|
||||||
|
Versiegeltes Ergebnis-Interface für `PromptPort.savePrompt(String)`. Zulässige Ausprägungen: `Saved` (Erfolg, enthält absoluten Pfad), `WriteFailed` (I/O-Fehler beim Schreiben der Temp-Datei), `TargetDirectoryMissing` (Zielordner fehlt), `AtomicMoveFailed` (atomares Verschieben nicht möglich; kein stiller Fallback).
|
||||||
|
|
||||||
|
**Neue Use-Case-Implementierungen im Paket `de.gecheckt.pdf.umbenenner.application.usecase`**
|
||||||
|
|
||||||
|
| Klasse | Zweck |
|
||||||
|
|--------|-------|
|
||||||
|
| `DefaultHistoryOverviewUseCase` | Lädt die gefilterte Dokumentenübersicht über `HistoryQueryPort.loadOverview`; gibt `HistoryOverviewResult` mit Liste und `hasMore`-Flag zurück |
|
||||||
|
| `DefaultHistoryDetailsUseCase` | Lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint über `HistoryQueryPort`; gibt `HistoryDetailsResult` zurück |
|
||||||
|
| `DefaultHistoryResetDocumentStatusUseCase` | Feldgenauer Status-Reset via `UnitOfWorkPort.TransactionOperations.resetDocumentStatusForRetry`; setzt `overall_status`, `content_error_count`, `transient_error_count` und `last_failure_instant` zurück; lässt die Versuchshistorie unangetastet |
|
||||||
|
| `DefaultDeleteDocumentHistoryUseCase` | Löscht Stammsatz und alle Verarbeitungsversuche vollständig und transaktional via `UnitOfWorkPort` |
|
||||||
|
| `DefaultPromptEditorUseCase` | Delegiert Laden, Speichern und Standard-Anlegen der Prompt-Datei an `PromptPort` und `ResourceCreationPort`; wird im GUI-Pfad über `GuiPromptEditorPort` angesteuert |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Inbound Ports
|
## 4. Inbound Ports
|
||||||
@@ -142,7 +162,7 @@ Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out`
|
|||||||
| `FingerprintPort` | Berechnet SHA-256-Fingerabdruck eines Kandidaten | `FingerprintResult computeFingerprint(SourceDocumentCandidate)` |
|
| `FingerprintPort` | Berechnet SHA-256-Fingerabdruck eines Kandidaten | `FingerprintResult computeFingerprint(SourceDocumentCandidate)` |
|
||||||
| `PdfTextExtractionPort` | Extrahiert Text und Seitenanzahl aus einer PDF | `PdfExtractionResult extractTextAndPageCount(...)` |
|
| `PdfTextExtractionPort` | Extrahiert Text und Seitenanzahl aus einer PDF | `PdfExtractionResult extractTextAndPageCount(...)` |
|
||||||
| `AiInvocationPort` | Ruft den aktiven KI-Dienst auf; provider-neutral | `AiInvocationResult invoke(AiRequestRepresentation)` |
|
| `AiInvocationPort` | Ruft den aktiven KI-Dienst auf; provider-neutral | `AiInvocationResult invoke(AiRequestRepresentation)` |
|
||||||
| `PromptPort` | Lädt das Prompt-Template aus der konfigurierten Quelle | `PromptLoadingResult loadPrompt()` |
|
| `PromptPort` | Lädt das Prompt-Template aus der konfigurierten Quelle; speichert geänderten Inhalt atomar via `savePrompt(String)` – der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf | `PromptLoadingResult loadPrompt()`, `PromptSaveResult savePrompt(String content)` |
|
||||||
| `TargetFileCopyPort` | Kopiert Quelldokument unter aufgelöstem Namen in den Zielordner (Temp + Rename) | `TargetFileCopyResult copyToTarget(...)` |
|
| `TargetFileCopyPort` | Kopiert Quelldokument unter aufgelöstem Namen in den Zielordner (Temp + Rename) | `TargetFileCopyResult copyToTarget(...)` |
|
||||||
| `TargetFileRenamePort` | Atomare Umbenennung einer bereits kopierten Zieldatei (manuelle Korrektur) | `TargetFileRenameResult rename(...)` |
|
| `TargetFileRenamePort` | Atomare Umbenennung einer bereits kopierten Zieldatei (manuelle Korrektur) | `TargetFileRenameResult rename(...)` |
|
||||||
| `RunLockPort` | Exklusiver Lauf-Lock gegen parallele Instanzen | `acquire()` / `release()` |
|
| `RunLockPort` | Exklusiver Lauf-Lock gegen parallele Instanzen | `acquire()` / `release()` |
|
||||||
@@ -154,8 +174,11 @@ Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out`
|
|||||||
| `PathCheckPort` | Lesende Pfad-Prüfung für den technischen Selbsttest | `isDirectoryReadable`, `isDirectoryWritableOrCreatable`, `isFileReadable`, `isSqlitePathUsable` |
|
| `PathCheckPort` | Lesende Pfad-Prüfung für den technischen Selbsttest | `isDirectoryReadable`, `isDirectoryWritableOrCreatable`, `isFileReadable`, `isSqlitePathUsable` |
|
||||||
| `ResourceCreationPort` | Schreibende Korrektur-Aktionen (Ordner anlegen, Prompt-Datei erzeugen, SQLite-Pfad vorbereiten) | `createDirectory`, `createPromptFile`, `prepareSqlitePath` |
|
| `ResourceCreationPort` | Schreibende Korrektur-Aktionen (Ordner anlegen, Prompt-Datei erzeugen, SQLite-Pfad vorbereiten) | `createDirectory`, `createPromptFile`, `prepareSqlitePath` |
|
||||||
| `ApiKeyResolutionPort` | Ermittelt API-Key-Herkunft pro Provider-Familie für die GUI-Validierung | `EffectiveApiKeyDescriptor resolve(...)` |
|
| `ApiKeyResolutionPort` | Ermittelt API-Key-Herkunft pro Provider-Familie für die GUI-Validierung | `EffectiveApiKeyDescriptor resolve(...)` |
|
||||||
|
| `HistoryQueryPort` | Lesender Zugriff auf die Verarbeitungshistorie für den Historien-Tab; bewusst getrennt von den regulären Repositories | `List<DocumentHistoryRow> loadOverview(HistoryQuery)`, `Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint)`, `List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint)` |
|
||||||
|
|
||||||
> **Hinweis zu GUI-spezifischen Ports:** `AiModelCatalogPort`, `PathCheckPort`, `ResourceCreationPort` und `ApiKeyResolutionPort` werden ausschließlich im GUI-Pfad genutzt. Ihre Implementierungen und der Aufrufkontext sind in `gui-overview.md` beschrieben.
|
> **Hinweis zu GUI-spezifischen Ports:** `AiModelCatalogPort`, `PathCheckPort`, `ResourceCreationPort`, `ApiKeyResolutionPort` und `HistoryQueryPort` werden ausschließlich im GUI-Pfad genutzt. Ihre Implementierungen und der Aufrufkontext sind in `gui-overview.md` bzw. `adapter-overview.md` beschrieben.
|
||||||
|
|
||||||
|
> **Hinweis zu `UnitOfWorkPort.TransactionOperations`:** Die innere Schnittstelle `TransactionOperations` wurde um die Methode `resetDocumentStatusForRetry(DocumentFingerprint)` erweitert. Diese setzt feldgenau `overall_status → READY_FOR_AI`, `content_error_count → 0`, `transient_error_count → 0` und `last_failure_instant → NULL`, ohne die Versuchshistorie zu berühren. Die Implementierung liegt in `SqliteUnitOfWorkAdapter`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,20 +25,33 @@ de.gecheckt.pdf.umbenenner.adapter.in.gui
|
|||||||
├── (root) Einstiegspunkt, Hauptfenster, Orchestrierung, GUI-interne
|
├── (root) Einstiegspunkt, Hauptfenster, Orchestrierung, GUI-interne
|
||||||
│ Ports, Hilfsklassen für Fenstertitel, System-Tray,
|
│ Ports, Hilfsklassen für Fenstertitel, System-Tray,
|
||||||
│ Dateiladen/-schreiben und Startkontext.
|
│ Dateiladen/-schreiben und Startkontext.
|
||||||
|
│ Enthält außerdem: GuiStatusBar, GuiPromptEditorTab und
|
||||||
|
│ GuiPromptEditorPort.
|
||||||
│
|
│
|
||||||
├── batchrun Komponenten für den Tab „Verarbeitungslauf":
|
├── batchrun Komponenten für den Tab „Verarbeitungslauf":
|
||||||
│ Worker-Koordinator, Tab-Ansicht, Ergebniszeilen,
|
│ Worker-Koordinator, Tab-Ansicht, Ergebniszeilen,
|
||||||
│ PDF-Vorschau, Dateiname-Editor sowie GUI-interne
|
│ PDF-Vorschau, Dateiname-Editor sowie GUI-interne
|
||||||
│ Port-Interfaces für Batch-Run, Mini-Run, manuelles
|
│ Port-Interfaces für Batch-Run, Mini-Run, manuelles
|
||||||
│ Umbenennen/Kopieren, Status-Reset und historischen Kontext.
|
│ Umbenennen/Kopieren, Status-Reset und historischen Kontext.
|
||||||
|
│ Enthält außerdem: BatchRunSummaryBanner und
|
||||||
|
│ ProcessingStatusPresentation.
|
||||||
│
|
│
|
||||||
└── editor View-Modell- und Zustandstypen ohne JavaFX-Controls
|
├── editor View-Modell- und Zustandstypen ohne JavaFX-Controls
|
||||||
(Ausnahme: GuiModelFieldContainer). Enthält Snapshot,
|
│ (Ausnahme: GuiModelFieldContainer). Enthält Snapshot,
|
||||||
Baseline/Current-Values, Dirty-State-Berechnung,
|
│ Baseline/Current-Values, Dirty-State-Berechnung,
|
||||||
Provider-Konfigurationszustände, API-Key-Zustände,
|
│ Provider-Konfigurationszustände, API-Key-Zustände,
|
||||||
Validierungsergebnisse, Meldungs- und Feldbefund-Typen.
|
│ Validierungsergebnisse, Meldungs- und Feldbefund-Typen.
|
||||||
|
│
|
||||||
|
└── history Komponenten für den Tab „Verlauf": Tab-Ansicht mit
|
||||||
|
zweigeteiltem Layout (Liste + Detail), Filter, Aktionen
|
||||||
|
(Status-Reset / vollständiges Löschen) sowie die vier
|
||||||
|
Bridge-Interfaces GuiHistoryOverviewPort,
|
||||||
|
GuiHistoryDetailsPort, GuiHistoryResetDocumentStatusPort
|
||||||
|
und GuiDeleteDocumentHistoryPort.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Tab-Reihenfolge:** `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Schlüsselklassen
|
## 3. Schlüsselklassen
|
||||||
@@ -49,8 +62,10 @@ de.gecheckt.pdf.umbenenner.adapter.in.gui
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GuiAdapter` | Einziger öffentlicher Bootstrap-Einstiegspunkt. Speichert `GuiStartupContext` im `GuiStartupContextHolder` und startet JavaFX via `Application.launch`. Genau einmal pro JVM aufrufbar. |
|
| `GuiAdapter` | Einziger öffentlicher Bootstrap-Einstiegspunkt. Speichert `GuiStartupContext` im `GuiStartupContextHolder` und startet JavaFX via `Application.launch`. Genau einmal pro JVM aufrufbar. |
|
||||||
| `PdfUmbenennerGuiApplication` | JavaFX-`Application`-Unterklasse. Baut in `start(Stage)` Hauptfenster, `GuiConfigurationEditorWorkspace`, Titelaktualisierungs-Listener, Close-Handler und System-Tray auf. Triggert nach Anzeige `autoLoadLastConfiguration()`. |
|
| `PdfUmbenennerGuiApplication` | JavaFX-`Application`-Unterklasse. Baut in `start(Stage)` Hauptfenster, `GuiConfigurationEditorWorkspace`, Titelaktualisierungs-Listener, Close-Handler und System-Tray auf. Triggert nach Anzeige `autoLoadLastConfiguration()`. |
|
||||||
| `GuiStartupContext` | Immutable Record mit allen Bootstrap-gelieferten Ports und Services: Dateilader/-schreiber, Modellkatalog-Port, API-Key-Resolution-Port, Technical-Test-Orchestrator, Correction-Execution-Service, Batch-Run-Launcher, Mini-Run-Launcher, Reset-Port, Manual-Rename-Port, Manual-Copy-Port, Historical-Context-Port. Bietet `blank()`-Fabrikmethode für Tests. |
|
| `GuiStartupContext` | Immutable Record mit allen Bootstrap-gelieferten Ports und Services: Dateilader/-schreiber, Modellkatalog-Port, API-Key-Resolution-Port, Technical-Test-Orchestrator, Correction-Execution-Service, Batch-Run-Launcher, Mini-Run-Launcher, Reset-Port, Manual-Rename-Port, Manual-Copy-Port, Historical-Context-Port, `applicationVersion` (Versions-String aus `ApplicationVersionProvider`), `promptEditorPort`, `historyOverviewPort`, `historyDetailsPort`, `historyResetDocumentStatusPort`, `deleteDocumentHistoryPort`. Bietet `blank()`-Fabrikmethode für Tests. |
|
||||||
| `GuiConfigurationEditorWorkspace` | Herzstück der Oberfläche. Baut `TabPane` mit Editor-Tab und Verarbeitungslauf-Tab, verwaltet `GuiConfigurationEditorState`, koordiniert Lade- und Schreibvorgänge auf Worker-Threads, steuert Dirty-State-Anzeige und Fenstertitel. |
|
| `GuiConfigurationEditorWorkspace` | Herzstück der Oberfläche. Baut `TabPane` mit den vier Tabs (Konfiguration, Verarbeitungslauf, Verlauf, Prompt), verwaltet `GuiConfigurationEditorState`, koordiniert Lade- und Schreibvorgänge auf Worker-Threads, steuert Dirty-State-Anzeige und Fenstertitel. |
|
||||||
|
| `GuiStatusBar` | Permanente Statuszeile am unteren Rand des Hauptfensters. Drei Segmente: links Anwendungsversion im Format `V<version>` (z. B. `Vdev`), Mitte aktiver Provider und Modell aus geladener Konfiguration, rechts Pfad der geladenen Konfigurationsdatei. Ohne geladene Konfiguration zeigen Mitte und Rechts den Platzhaltertext. |
|
||||||
|
| `GuiPromptEditorTab` | Tab „Prompt" mit `TextArea` zum Lesen, Bearbeiten und Speichern der Prompt-Datei. Dirty-State markiert den Tab-Titel mit einem Asterisk. Speichert atomar via `GuiPromptEditorPort`. Bietet „Auf Standard zurücksetzen" (füllt `TextArea` ohne zu speichern) und „Standard-Prompt erstellen" bei fehlender Datei. |
|
||||||
| `GuiModelCatalogCoordinator` | Asynchroner Modellabruf über `AiModelCatalogPort` auf Daemon-Thread `gui-model-catalog`. Liefert Ergebnis via `Platform.runLater` und aktualisiert ComboBox oder manuelles TextField. |
|
| `GuiModelCatalogCoordinator` | Asynchroner Modellabruf über `AiModelCatalogPort` auf Daemon-Thread `gui-model-catalog`. Liefert Ergebnis via `Platform.runLater` und aktualisiert ComboBox oder manuelles TextField. |
|
||||||
| `GuiTechnicalTestCoordinator` | Führt `TechnicalTestOrchestrator` asynchron auf Daemon-Thread `gui-technical-test` aus, ohne implizites Speichern. Replace-Semantik in `pendingMessages`. |
|
| `GuiTechnicalTestCoordinator` | Führt `TechnicalTestOrchestrator` asynchron auf Daemon-Thread `gui-technical-test` aus, ohne implizites Speichern. Replace-Semantik in `pendingMessages`. |
|
||||||
| `GuiCorrectionDialogCoordinator` | Empfängt `TechnicalTestReport`, leitet `CorrectionPlan` ab, zeigt gesammelten Bestätigungsdialog. Kein stiller Schreibzugriff. |
|
| `GuiCorrectionDialogCoordinator` | Empfängt `TechnicalTestReport`, leitet `CorrectionPlan` ab, zeigt gesammelten Bestätigungsdialog. Kein stiller Schreibzugriff. |
|
||||||
@@ -70,10 +85,18 @@ de.gecheckt.pdf.umbenenner.adapter.in.gui
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GuiBatchRunCoordinator` | Besitzt den Worker-Thread für Batch- und Mini-Run. Übersetzt `BatchRunProgressObserver`-Callbacks via `Platform.runLater`. Soft-Stop per `AtomicBoolean`. Enthält `toRow()` inkl. historischem Kontext-Nachladen. |
|
| `GuiBatchRunCoordinator` | Besitzt den Worker-Thread für Batch- und Mini-Run. Übersetzt `BatchRunProgressObserver`-Callbacks via `Platform.runLater`. Soft-Stop per `AtomicBoolean`. Enthält `toRow()` inkl. historischem Kontext-Nachladen. |
|
||||||
| `GuiBatchRunTab` | Zweiter Haupt-Tab mit `TableView`, `ProgressBar`, Start-/Stop-Buttons und Detailbereich. Implementiert `GuiBatchRunCoordinator.Listener`. |
|
| `GuiBatchRunTab` | Zweiter Haupt-Tab mit `TableView`, `ProgressBar`, Start-/Stop-Buttons und Detailbereich. Implementiert `GuiBatchRunCoordinator.Listener`. |
|
||||||
|
| `BatchRunSummaryBanner` | Einzeilige Zusammenfassungsleiste nach Laufabschluss, unterhalb des Fortschrittsbalkens. Zeigt nur Kategorien mit Zähler > 0. Farbe ist niemals das einzige Unterscheidungsmerkmal: jedes Segment enthält Icon und Text. |
|
||||||
|
| `ProcessingStatusPresentation` | Zentrale Mapping-Klasse für Status-Icons, CSS-Farben, Tooltip-Texte und Summary-Kategoriebeschriften aller `DocumentCompletionStatus`-Werte. Einzige autoritative Quelle für alle Anzeigeorte (Tabelle, Detail, Banner). Enthält keine JavaFX-Typen; zustandslos und statisch. Farbe ist niemals das einzige Unterscheidungsmerkmal. |
|
||||||
| `PdfPreviewPane` | Asynchrones PDF-Rendering via PDFBox auf Single-Thread-Executor `pdf-preview-worker`. Stale-Request-Schutz via `AtomicLong`-Sequenznummer, In-Memory-Seiten-Cache. |
|
| `PdfPreviewPane` | Asynchrones PDF-Rendering via PDFBox auf Single-Thread-Executor `pdf-preview-worker`. Stale-Request-Schutz via `AtomicLong`-Sequenznummer, In-Memory-Seiten-Cache. |
|
||||||
| `FileNameEditorPane` | Editor für den Zieldateinamen. Drei-Zustands-Modell: KI-Vorschlag / letzter gespeicherter / aktuelle Eingabe. Clientseitige Validierung; Speicher-Aufruf delegiert an Tab. |
|
| `FileNameEditorPane` | Editor für den Zieldateinamen. Drei-Zustands-Modell: KI-Vorschlag / letzter gespeicherter / aktuelle Eingabe. Clientseitige Validierung; Speicher-Aufruf delegiert an Tab. |
|
||||||
| `AiFailureMessageTranslator` | Übersetzt englische technische Fehlermeldungen in deutsche Benutzertexte. Paket-privat, zustandslos. |
|
| `AiFailureMessageTranslator` | Übersetzt englische technische Fehlermeldungen in deutsche Benutzertexte. Paket-privat, zustandslos. |
|
||||||
|
|
||||||
|
### Paket `history`
|
||||||
|
|
||||||
|
| Klasse (Kurzname) | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `GuiHistoryTab` | Tab „Verlauf" mit zweigeteiltem Layout: links Dokumentenliste mit Freitext- und Statusfilter, rechts Detailbereich mit Stammsatz und Versuchshistorie. Aktionen: Status-Reset (feldgenau, Versuchshistorie bleibt) und vollständiges Löschen (mit Bestätigungsdialog). Alle Datenbankoperationen auf Worker-Thread, UI-Updates via `Platform.runLater`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Threading-Modell
|
## 4. Threading-Modell
|
||||||
@@ -92,6 +115,8 @@ Alle blockierenden Operationen laufen auf benannten Daemon-Threads außerhalb de
|
|||||||
| Korrektur-Worker (anonym) | `GuiCorrectionDialogCoordinator` | `correctionExecutionService.execute(...)` |
|
| Korrektur-Worker (anonym) | `GuiCorrectionDialogCoordinator` | `correctionExecutionService.execute(...)` |
|
||||||
| `pdf-preview-worker` | `PdfPreviewPane` | `PDDocument` laden, `PDFRenderer.renderImageWithDPI`, `PDDocument.close` |
|
| `pdf-preview-worker` | `PdfPreviewPane` | `PDDocument` laden, `PDFRenderer.renderImageWithDPI`, `PDDocument.close` |
|
||||||
| Dateisystem-Worker (inline) | `GuiConfigurationEditorWorkspace` | `configurationFileLoader.load(...)`, `configurationFileWriter.write(...)` |
|
| Dateisystem-Worker (inline) | `GuiConfigurationEditorWorkspace` | `configurationFileLoader.load(...)`, `configurationFileWriter.write(...)` |
|
||||||
|
| Inline-Worker (anonym) | `GuiHistoryTab` | `historyOverviewPort.loadOverview(...)`, `historyDetailsPort.loadDetails(...)`, `historyResetDocumentStatusPort.resetStatus(...)`, `deleteDocumentHistoryPort.deleteHistory(...)` |
|
||||||
|
| Inline-Worker (anonym) | `GuiPromptEditorTab` | `promptEditorPort.loadCurrentPrompt()`, `promptEditorPort.save(...)`, `promptEditorPort.createDefaultPromptIfMissing(...)` |
|
||||||
|
|
||||||
### 4.2 JavaFX Application Thread
|
### 4.2 JavaFX Application Thread
|
||||||
|
|
||||||
@@ -137,13 +162,28 @@ Durch diese Injektion sind Unit-Tests vollständig ohne JavaFX-Runtime möglich.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GuiBatchRunLauncher` | Bootstrap-Brücke für den regulären Batch-Run auf dem Worker-Thread. |
|
| `GuiBatchRunLauncher` | Bootstrap-Brücke für den regulären Batch-Run auf dem Worker-Thread. |
|
||||||
| `GuiMiniRunLauncher` | Bootstrap-Brücke für einen auf einen Fingerprint-Filter beschränkten Mini-Run. |
|
| `GuiMiniRunLauncher` | Bootstrap-Brücke für einen auf einen Fingerprint-Filter beschränkten Mini-Run. |
|
||||||
| `GuiResetDocumentStatusPort` | Bootstrap-Brücke für einen Statusreset ohne Folge-Run. |
|
| `GuiResetDocumentStatusPort` | Bootstrap-Brücke für den vollständigen Persistenz-Reset (Stammsatz und Versuchshistorie werden gelöscht) ohne Folge-Run. |
|
||||||
| `GuiManualFileRenamePort` | Bootstrap-Brücke für die manuelle Umbenennung der Zieldatei (Worker-Thread). |
|
| `GuiManualFileRenamePort` | Bootstrap-Brücke für die manuelle Umbenennung der Zieldatei (Worker-Thread). |
|
||||||
| `GuiManualFileCopyPort` | Bootstrap-Brücke für die Kopie mit benutzerdefiniertem Zieldateinamen bei FAILED/SKIPPED-Dokumenten (Worker-Thread). |
|
| `GuiManualFileCopyPort` | Bootstrap-Brücke für die Kopie mit benutzerdefiniertem Zieldateinamen bei FAILED/SKIPPED-Dokumenten (Worker-Thread). |
|
||||||
| `GuiHistoricalDocumentContextPort` | Nachladen des vollständigen historischen Verarbeitungskontexts für übersprungene Dokumente (Worker-Thread). |
|
| `GuiHistoricalDocumentContextPort` | Nachladen des vollständigen historischen Verarbeitungskontexts für übersprungene Dokumente (Worker-Thread). |
|
||||||
| `GuiHistoricalFileNamePort` | Spezialisierter Port für den letzten bekannten KI-Dateinamen. Weitgehend durch `GuiHistoricalDocumentContextPort` abgelöst, aber noch im Einsatz. |
|
| `GuiHistoricalFileNamePort` | Spezialisierter Port für den letzten bekannten KI-Dateinamen. Weitgehend durch `GuiHistoricalDocumentContextPort` abgelöst, aber noch im Einsatz. |
|
||||||
|
|
||||||
Alle Implementierungen dieser Interfaces liegen in `pdf-umbenenner-bootstrap` oder `pdf-umbenenner-adapter-out`. Das GUI-Modul kennt ausschließlich die Interface-Typen.
|
### Root-Paket (GUI-interne Ports)
|
||||||
|
|
||||||
|
| Interface | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GuiPromptEditorPort` | Bootstrap-Brücke zum Prompt-Editor-Use-Case: Laden (`loadCurrentPrompt()`), atomares Speichern (`save(String)`) und Standard-Anlegen (`createDefaultPromptIfMissing(...)`) der Prompt-Datei. |
|
||||||
|
|
||||||
|
### Paket `history`
|
||||||
|
|
||||||
|
| Interface | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GuiHistoryOverviewPort` | Bootstrap-Brücke zur Historien-Übersicht; lädt gefilterte Dokumentenliste via `loadOverview(Path configFilePath, HistoryQuery)`. `configFilePath` ermöglicht der Bootstrap-Implementierung, die SQLite-Datenbank aus der aktuell geladenen Konfiguration abzuleiten. |
|
||||||
|
| `GuiHistoryDetailsPort` | Bootstrap-Brücke zur Detailansicht; lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint via `loadDetails(Path, DocumentFingerprint)`. |
|
||||||
|
| `GuiHistoryResetDocumentStatusPort` | Bootstrap-Brücke für den feldgenauen Status-Reset im Historien-Tab (`overall_status → READY_FOR_AI`, Fehlerzähler → 0, `last_failure_instant → null`). Die Versuchshistorie bleibt vollständig erhalten. **Abgrenzung:** `GuiResetDocumentStatusPort` im `batchrun`-Paket löscht dagegen Stammsatz und Versuchshistorie vollständig. |
|
||||||
|
| `GuiDeleteDocumentHistoryPort` | Bootstrap-Brücke zum vollständigen Löschen von Stammsatz und Versuchshistorie via `deleteHistory(Path, DocumentFingerprint)`; destruktiv und nicht rückgängig zu machen. Die GUI zeigt vor dem Aufruf einen Bestätigungsdialog. |
|
||||||
|
|
||||||
|
Alle Implementierungen dieser Interfaces liegen in `pdf-umbenenner-bootstrap`. Das GUI-Modul kennt ausschließlich die Interface-Typen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+105
-1
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
|||||||
|
|
||||||
### Umfang der GUI
|
### Umfang der GUI
|
||||||
|
|
||||||
Die GUI enthält zwei Tabs:
|
Die GUI enthält drei Tabs:
|
||||||
|
|
||||||
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||||
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
||||||
@@ -75,6 +75,13 @@ Die GUI enthält zwei Tabs:
|
|||||||
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
|
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
|
||||||
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
|
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
|
||||||
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
|
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
|
||||||
|
- **Tab „Prompt"** – Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
|
||||||
|
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
|
||||||
|
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
|
||||||
|
Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage,
|
||||||
|
ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein
|
||||||
|
„Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch
|
||||||
|
geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus.
|
||||||
|
|
||||||
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
|
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
|
||||||
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
|
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
|
||||||
@@ -292,6 +299,35 @@ 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`
|
||||||
|
|
||||||
|
### Prompt-Pfad-Auflösung je Betriebsart
|
||||||
|
|
||||||
|
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
|
||||||
|
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
|
||||||
|
|
||||||
|
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|
||||||
|
|---|---|---|
|
||||||
|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
|
||||||
|
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
|
||||||
|
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
|
||||||
|
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
|
||||||
|
|
||||||
|
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
|
||||||
|
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
|
||||||
|
> immer eindeutig auffindbar – insbesondere beim Start über den Windows Task Scheduler,
|
||||||
|
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
|
||||||
|
|
||||||
|
### Bearbeitung über den GUI-Prompt-Tab
|
||||||
|
|
||||||
|
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
|
||||||
|
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
|
||||||
|
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
|
||||||
|
Fall wird kein stiller Fallback durchgeführt).
|
||||||
|
|
||||||
|
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
|
||||||
|
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
|
||||||
|
aktualisiert sich der Prompt-Tab nicht automatisch – in diesem Fall sollte die GUI neu gestartet
|
||||||
|
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Zielformat
|
## Zielformat
|
||||||
@@ -514,6 +550,74 @@ Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell na
|
|||||||
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||||
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
||||||
|
|
||||||
|
**Empfehlung für Pfade im MSI-Betrieb:**
|
||||||
|
|
||||||
|
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
|
||||||
|
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
|
||||||
|
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
|
||||||
|
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
|
||||||
|
|
||||||
|
> **Warnung – Relative Pfade im MSI-Betrieb nicht verwenden:**
|
||||||
|
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
|
||||||
|
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
|
||||||
|
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
|
||||||
|
> Fehlermeldung zu erzeugen.
|
||||||
|
|
||||||
|
> **Warnung – Backslashes in `.properties`-Dateien:**
|
||||||
|
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
|
||||||
|
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
|
||||||
|
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
|
||||||
|
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
|
||||||
|
|
||||||
|
Betroffene Parameter:
|
||||||
|
|
||||||
|
| Parameter | Empfehlung |
|
||||||
|
|---|---|
|
||||||
|
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
|
||||||
|
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
|
||||||
|
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
|
||||||
|
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
|
||||||
|
|
||||||
|
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
|
||||||
|
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
|
||||||
|
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
|
||||||
|
|
||||||
|
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
|
||||||
|
|
||||||
|
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
|
||||||
|
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
|
||||||
|
|
||||||
|
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
|
||||||
|
- ob das Verzeichnis vorhanden und beschreibbar ist,
|
||||||
|
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
|
||||||
|
|
||||||
|
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
|
||||||
|
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
|
||||||
|
MSI-Betriebsfehler – relatives `log.directory` auf schreibgeschütztem Installationspfad –
|
||||||
|
frühzeitig zu erkennen.
|
||||||
|
|
||||||
|
### MSI-Release-Checkliste
|
||||||
|
|
||||||
|
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
|
||||||
|
|
||||||
|
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
|
||||||
|
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
|
||||||
|
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
|
||||||
|
- [ ] GUI-Start über Startmenü-Eintrag
|
||||||
|
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
|
||||||
|
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
|
||||||
|
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
|
||||||
|
- [ ] Deinstallation sauber – Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
|
||||||
|
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
|
||||||
|
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
|
||||||
|
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
|
||||||
|
|
||||||
|
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
|
||||||
|
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
|
||||||
|
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
|
||||||
|
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
|
||||||
|
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
|
||||||
|
|
||||||
### Build-Kommandos
|
### Build-Kommandos
|
||||||
|
|
||||||
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
|
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Freigabedokument V3.0 – PDF-Umbenenner
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Versionsnummer: `3.0.238`
|
||||||
|
- MSI-Datei: `PDF-KI-Renamer-3.0.238.msi`
|
||||||
|
- Freigabedatum: 2026-05-05
|
||||||
|
- **Status:** freigegeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielsetzung von V3.0
|
||||||
|
|
||||||
|
V3.0 ist kein Wechsel der Kernfunktion, sondern ein gezielter Qualitätssprung in drei
|
||||||
|
Dimensionen: **Infrastruktur** (konsistente Versionierung, Flyway-DB-Migration,
|
||||||
|
Jenkins-Stabilisierung, MSI-Vorbereitung), **Transparenz** (Historien-Tab, differenzierte
|
||||||
|
Fehlerstatus-Darstellung, Lauf-Summary-Banner) und **Bedienkomfort** (Tooltips, Statuszeile,
|
||||||
|
Prompt-Editor). Die fachliche Kernverarbeitung des PDF-Umbenenners – PDF lesen, KI benennen,
|
||||||
|
Zieldatei kopieren – bleibt vollständig unverändert. Es wird kein neues Maven-Modul eingeführt;
|
||||||
|
die hexagonale Architektur bleibt unangetastet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgesetzte Issues
|
||||||
|
|
||||||
|
| # | Commit | Kategorie | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| #67 | `c6379c0` | Infrastruktur | Konsistente Versionierung via Maven CI-friendly `${revision}`, MANIFEST.MF mit `Implementation-Version`, Fallback `dev` |
|
||||||
|
| #68 | `500a8c5` | Infrastruktur | Jenkins-Build mit `-Drevision`-Übergabe, robuste Shade-JAR-Archivierung mit Bash und `mapfile` |
|
||||||
|
| #49 | `732d00c` | Infrastruktur | Flyway-Integration mit V1-Basisskript, 3-Fall-Strategie (leer / Bestand baselined / regulärer Folgestart), `PRAGMA foreign_keys` per `SQLiteConfig`, Lock-Mechanismus, vollständige Schema-Prüfcheckliste, manuelle Schema-Evolution entfernt |
|
||||||
|
| #51 | `563d9f5` | Fachlich/UX | Einheitliche Status-Darstellung mit Icon, Farbe, Tooltip; `FAILED_RETRYABLE` vs. `FAILED_FINAL` eindeutig differenziert |
|
||||||
|
| #66 | `0fe5359` | UX | Tooltips auf Konfigurationstab, Verarbeitungslauf-Tab und Toolbar; zentrale `GuiTooltipTexts`-Konstantenklasse |
|
||||||
|
| #73 | `dc17824` | GUI | Summary-Banner unterhalb Fortschrittsbalken nach Laufabschluss |
|
||||||
|
| #50 | `4f5ce4c` | GUI | Statuszeile mit Version, Provider/Modell und Konfigurationsdateipfad |
|
||||||
|
| #71 | `5d5dee0` | GUI | Prompt-Editor-Tab mit atomarem Speichern (`ATOMIC_MOVE`), Dirty-State, Default-Reset |
|
||||||
|
| #7 | `46fc1d4` | GUI | Historien-Tab mit Liste, Detail, Filter, Status-Reset (feldgenau, Versuche bleiben) und destruktivem Löschen (Attempts vor Record in Transaktion) |
|
||||||
|
| #65 | `51d6168` | Infrastruktur | MSI-Vorbereitung: jdeps-Modulliste, BAT-Dateien, `winUpgradeUuid`, Pfad-Hinweise in `betrieb.md` |
|
||||||
|
|
||||||
|
### Weitere Commits
|
||||||
|
|
||||||
|
| Commit | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| `6e03093` | Architektur-Übersichten ergänzt (`domain-overview.md`, `gui-overview.md`, `adapter-overview.md`) |
|
||||||
|
| `4b89743` | Bedienanleitung auf neuen Stand gebracht |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Bilanz
|
||||||
|
|
||||||
|
| Neu | Anzahl | Bemerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Outbound-Ports | 1 | `HistoryQueryPort` |
|
||||||
|
| Application-Use-Cases | 5 | `DefaultPromptEditorUseCase`, `DefaultHistoryOverviewUseCase`, `DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase` |
|
||||||
|
| Outbound-Adapter | 2 | `SqliteHistoryQueryAdapter`, `FilesystemPromptPortAdapter.savePrompt` |
|
||||||
|
| GUI-Bridge-Interfaces | 5 | `GuiPromptEditorPort`, `GuiHistoryOverviewPort`, `GuiHistoryDetailsPort`, `GuiHistoryResetDocumentStatusPort`, `GuiDeleteDocumentHistoryPort` |
|
||||||
|
| GUI-Tabs | 2 | „Verlauf", „Prompt" |
|
||||||
|
| GUI-Komponenten | 5 | `GuiStatusBar`, `BatchRunSummaryBanner`, `GuiHistoryTab`, `GuiPromptEditorTab`, `ProcessingStatusPresentation` |
|
||||||
|
| Bootstrap | 1 + Erweiterung | `ApplicationVersionProvider` und Erweiterung des `GuiStartupContext` (`applicationVersion`, 5 neue Port-Felder) |
|
||||||
|
| Datenbank-Migration | – | Flyway-V1-Basisskript, 3-Fall-Strategie, FK-Pragma per `SQLiteConfig`, Lock-Mechanismus |
|
||||||
|
|
||||||
|
Nicht geändert: `pdf-umbenenner-domain`, `pdf-umbenenner-adapter-in-cli`, headless-Betrieb.
|
||||||
|
Bootstrap ausschließlich um MANIFEST.MF-Einträge und neue Bridge-Verdrahtung erweitert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindlich verifizierte Spec-Punkte
|
||||||
|
|
||||||
|
- `${revision}` wird durch `flatten-maven-plugin` (`resolveCiFriendliesOnly`) aufgelöst;
|
||||||
|
installierte POMs enthalten kein unaufgelöstes `${revision}`
|
||||||
|
- MANIFEST.MF im Fat-JAR trägt `Implementation-Version`; Laufzeit-Fallback ist `dev`
|
||||||
|
- `evolveTableColumns()` vollständig aus dem Code entfernt; Flyway ist die einzige
|
||||||
|
Schema-Evolutionsquelle
|
||||||
|
- Status-Reset setzt feldgenau `overall_status='READY_FOR_AI'`,
|
||||||
|
`content_error_count=0`, `transient_error_count=0`, `last_failure_instant=NULL`;
|
||||||
|
Versuche (`processing_attempt`) bleiben vollständig unangetastet
|
||||||
|
- Tab-Reihenfolge: `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
|
||||||
|
- `PromptPort.savePrompt` bleibt pfadfrei in der Port-Signatur (Hexagonal-konform;
|
||||||
|
Pfadauflösung liegt im Adapter)
|
||||||
|
- Farbe ist niemals das einzige Unterscheidungsmerkmal; alle Status tragen Icon und Text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Headless-Kompatibilität
|
||||||
|
|
||||||
|
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten. Die
|
||||||
|
`.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code initialisiert
|
||||||
|
den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik, Status-Persistenz
|
||||||
|
oder fachlicher Verarbeitungslogik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Migration
|
||||||
|
|
||||||
|
Bestehende Datenbestände aus dem Vorgängerstand werden beim ersten Start der 3-Fall-Strategie
|
||||||
|
unterworfen:
|
||||||
|
|
||||||
|
- **Neue DB** (keine Tabellen vorhanden): Flyway führt `V1__initial_schema.sql` vollständig aus.
|
||||||
|
- **Bestand ohne Flyway-History** (typische Vorgänger-DB): vollständige Schema-Prüfcheckliste
|
||||||
|
gegen das V1-Zielschema; bei konformem Schema wird eine datierte Backup-Kopie der
|
||||||
|
`.sqlite`-Datei erstellt, danach Baseline auf V1 gesetzt. Bei nicht konformem Schema
|
||||||
|
bricht der Start mit klarer Fehlermeldung ab – kein stilles Weiterlaufen.
|
||||||
|
- **Bestand mit Flyway-History** (regulärer Folgestart): `migrate()` läuft idempotent.
|
||||||
|
|
||||||
|
`baselineOnMigrate=true` wird ausschließlich in Fall 2 gesetzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte (vor finalem Release)
|
||||||
|
|
||||||
|
| Thema | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| MSI-Testmatrix | Manueller MSI-Build und vollständige Abarbeitung der Testmatrix auf Windows-Maschine erforderlich; insbesondere Anwendungsstart **ohne JDK** zur Verifikation der `addModules`-Liste |
|
||||||
|
| `winUpgradeUuid` | Der GUID `EA8D0149-1401-4D3D-A98D-A2B98DAE5495` wurde im Rahmen von #65 neu generiert. Vor dem ersten produktiven MSI-Release ist sicherzustellen, dass kein bisheriges produktives MSI mit einem abweichenden GUID ausgeliefert wurde – andernfalls bricht der MSI-Upgrade-Mechanismus. Nach Bestätigung „nie produktiv ausgeliefert" ist der GUID damit gesetzt und darf nie wieder geändert werden. |
|
||||||
|
| Manueller GUI-Produkttest | Erfolgreicher Build und grüne Tests ersetzen keinen End-to-End-Lauf gegen einen echten KI-Provider mit echten PDFs. |
|
||||||
|
| Finale Freigabe | `freigabe-v3_0.md` wird nach abgeschlossenem manuellen Produkttest und MSI-Verifikation in den Status „freigegeben" überführt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V3.0
|
||||||
|
|
||||||
|
- Automatischer Scheduler / Quellordner-Überwachung
|
||||||
|
- Token- und Kosten-Tracking
|
||||||
|
- Excel-Export
|
||||||
|
- Automatische Update-Prüfung
|
||||||
|
- Dark Mode
|
||||||
|
- Log-Viewer
|
||||||
|
- PDF-Viewer Render-DPI-Konfiguration
|
||||||
|
- Zoom per Mausrad
|
||||||
|
- Hilfe-Datei F1
|
||||||
|
- Änderungen an der fachlichen Kernverarbeitung des PDF-Umbenenners
|
||||||
|
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V3.0 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||||
|
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||||
|
bleibt unverändert gegenüber dem Vorgängerstand. Keine Release-Blocker für die
|
||||||
|
Implementierungs-Freigabe.
|
||||||
|
|
||||||
|
Die finale Release-Freigabe steht aus bis zur vollständigen Abarbeitung der
|
||||||
|
MSI-Testmatrix (insbesondere Verifikation des Anwendungsstarts ohne JDK),
|
||||||
|
Klärung des `winUpgradeUuid`-Erstauslieferungsstatus und abgeschlossenem
|
||||||
|
manuellem GUI-Produkttest gegen einen echten KI-Provider.
|
||||||
+290
-11
@@ -1,4 +1,4 @@
|
|||||||
# GUI-Bedienanleitung – PDF-Umbenenner V2.0
|
# GUI-Bedienanleitung – PDF-Umbenenner
|
||||||
|
|
||||||
Diese Anleitung beschreibt die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie richtet sich an
|
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
|
Endbenutzer und Betreuer, die die Konfiguration der Anwendung über die grafische Oberfläche
|
||||||
@@ -8,15 +8,18 @@ verwalten und technisch prüfen möchten.
|
|||||||
|
|
||||||
## 1. Zweck und Scope der GUI
|
## 1. Zweck und Scope der GUI
|
||||||
|
|
||||||
Die GUI gliedert sich in zwei feste Tabs:
|
Die GUI gliedert sich in vier feste Tabs:
|
||||||
|
|
||||||
- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische
|
- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische
|
||||||
Test-/Diagnoseoberfläche für die `.properties`-Datei.
|
Test-/Diagnoseoberfläche für die `.properties`-Datei.
|
||||||
- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||||
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
|
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
|
||||||
|
- **Tab 3 „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status
|
||||||
|
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 16).
|
||||||
|
- **Tab 4 „Prompt"** – Editor zum Lesen, Bearbeiten und Speichern der
|
||||||
|
konfigurierten KI-Prompt-Datei (siehe Abschnitt 17).
|
||||||
|
|
||||||
Weiterhin **nicht** enthalten sind ein Historien-Tab, eine Datenbankansicht und ein
|
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 18).
|
||||||
Kosten-Tracking — diese Ausbauten sind für spätere Stufen vorbehalten.
|
|
||||||
|
|
||||||
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
|
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
|
||||||
`--headless` der empfohlene Weg.
|
`--headless` der empfohlene Weg.
|
||||||
@@ -479,16 +482,20 @@ in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
|
|||||||
| `✓` | Grün | Erfolgreich |
|
| `✓` | Grün | Erfolgreich |
|
||||||
| `↻` | Orange | Fehlgeschlagen (wiederholbar) |
|
| `↻` | Orange | Fehlgeschlagen (wiederholbar) |
|
||||||
| `×` | Rot | Fehlgeschlagen (permanent) |
|
| `×` | Rot | Fehlgeschlagen (permanent) |
|
||||||
| `≡` | Blau | Übersprungen (bereits erfolgreich verarbeitet) |
|
| `≡` | Grau | Übersprungen (bereits erfolgreich verarbeitet) |
|
||||||
| `⊘` | Grau | Übersprungen (endgültig fehlgeschlagen) |
|
| `⊘` | Dunkelgrau | Übersprungen (endgültig fehlgeschlagen) |
|
||||||
| `⟳` | Grau | Zurückgesetzt – wartet auf nächsten Lauf |
|
| `⟳` | Grau | Zurückgesetzt – wartet auf nächsten Lauf |
|
||||||
|
|
||||||
|
Farbe ist niemals das einzige Unterscheidungsmerkmal – Icon und Tooltip beschreiben
|
||||||
|
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
|
||||||
|
mit Tooltips ist in Abschnitt 19 beschrieben.
|
||||||
|
|
||||||
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
|
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
|
||||||
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
|
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
|
||||||
KI-Reasonings. Liegt weder Reasoning noch Fehlermeldung vor, erscheint der
|
KI-Reasonings. Liegt weder Reasoning noch Fehlermeldung vor, erscheint der
|
||||||
Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning vor.".
|
Hinweistext „Für diesen Eintrag liegt kein KI-Reasoning vor.".
|
||||||
- Nach Laufende erscheint die Zusammenfassung `X erfolgreich, Y fehlgeschlagen,
|
- Nach Laufende erscheint das **Summary-Banner** unterhalb des Fortschrittsbalkens
|
||||||
Z übersprungen` im Meldungs- und Zusammenfassungsbereich.
|
(siehe Abschnitt 13c).
|
||||||
|
|
||||||
### Soft-Stop
|
### Soft-Stop
|
||||||
Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung
|
Der Knopf **Abbrechen** löst einen **Soft-Stop** aus: die aktuell in Bearbeitung
|
||||||
@@ -666,17 +673,54 @@ Das Feld kann direkt bearbeitet werden. Die Eingabe wird **live validiert**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Bekannte Einschränkungen V2.x
|
## 13c. Summary-Banner nach Laufabschluss
|
||||||
|
|
||||||
|
Nach Abschluss eines Verarbeitungslaufs erscheint unterhalb des Fortschrittsbalkens und
|
||||||
|
oberhalb der Ergebnistabelle ein einzeiliges **Summary-Banner** (`BatchRunSummaryBanner`).
|
||||||
|
Es zeigt auf einen Blick, wie viele Dateien in welche Kategorie gefallen sind.
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ 14 erfolgreich · ↻ 1 wird wiederholt · × 2 fehlgeschlagen · ≡ 3 übersprungen · ⊘ 1 endgültig übersprungen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regeln:**
|
||||||
|
|
||||||
|
- Nur Kategorien mit Anzahl größer als 0 werden angezeigt.
|
||||||
|
- Bei einem vollständig sauberen Lauf erscheint nur die Erfolgskategorie,
|
||||||
|
z. B. `✓ 17 erfolgreich`.
|
||||||
|
- Jedes Segment enthält Icon und Text – Farbe ist niemals das einzige Unterscheidungsmerkmal.
|
||||||
|
- Das Banner verschwindet automatisch, wenn der nächste Lauf gestartet wird.
|
||||||
|
- Das Banner erscheint **nicht** beim Anwendungsstart oder bei einem Tab-Wechsel
|
||||||
|
ohne vorherigen Lauf.
|
||||||
|
|
||||||
|
**Kategorien:**
|
||||||
|
|
||||||
|
| Icon | Text | Entsprechender Status |
|
||||||
|
|------|------|-----------------------|
|
||||||
|
| `✓` | erfolgreich | `SUCCESS` |
|
||||||
|
| `↻` | wird wiederholt | `FAILED_RETRYABLE` |
|
||||||
|
| `×` | fehlgeschlagen | `FAILED_FINAL` |
|
||||||
|
| `≡` | übersprungen | `SKIPPED_ALREADY_PROCESSED` |
|
||||||
|
| `⊘` | endgültig übersprungen | `SKIPPED_FINAL_FAILURE` |
|
||||||
|
|
||||||
|
Die Zwischenstatus `READY_FOR_AI`, `PROPOSAL_READY` und `PROCESSING` werden im Banner
|
||||||
|
nicht gezählt – sie treten nach Laufabschluss nicht mehr auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Bekannte Einschränkungen
|
||||||
|
|
||||||
| Einschränkung | Erläuterung |
|
| Einschränkung | Erläuterung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Kein Historien-Tab | Eine Ansicht der SQLite-Datenbank und Verarbeitungshistorie ist für spätere Ausbaustufen vorbehalten |
|
|
||||||
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
|
| Kein Kosten-Tracking | Token-/Preisberechnungen sind für spätere Ausbaustufen vorbehalten |
|
||||||
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
|
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
|
||||||
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
|
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
|
||||||
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
||||||
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer |
|
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 16) einsehbar. |
|
||||||
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
|
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
|
||||||
|
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -705,3 +749,238 @@ Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
|
|||||||
| Ungespeicherte Änderungen | Schutzdialog erscheint zuerst (Speichern / Verwerfen / Abbrechen); erst nach Auflösung wird in den Tray minimiert |
|
| Ungespeicherte Änderungen | Schutzdialog erscheint zuerst (Speichern / Verwerfen / Abbrechen); erst nach Auflösung wird in den Tray minimiert |
|
||||||
| Aktiver Verarbeitungslauf | Hinweisdialog erscheint (Abschnitt 13); nach Soft-Stop oder Abschluss kann in den Tray minimiert werden |
|
| Aktiver Verarbeitungslauf | Hinweisdialog erscheint (Abschnitt 13); nach Soft-Stop oder Abschluss kann in den Tray minimiert werden |
|
||||||
| System-Tray nicht verfügbar | Fenster wird beim Schließen wie ohne Tray-Support behandelt; der Schutzdialog für ungespeicherte Änderungen bleibt aktiv |
|
| System-Tray nicht verfügbar | Fenster wird beim Schließen wie ohne Tray-Support behandelt; der Schutzdialog für ungespeicherte Änderungen bleibt aktiv |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Tab „Verlauf" (Historien-Tab)
|
||||||
|
|
||||||
|
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
|
||||||
|
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
|
||||||
|
die in der geladenen Konfiguration angegeben ist.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Das Tab ist zweigeteilt:
|
||||||
|
|
||||||
|
- **Linke Hälfte (~55%):** Dokumentenliste mit Filter-Bereich oben
|
||||||
|
- **Rechte Hälfte (~45%):** Detailbereich zum ausgewählten Dokument
|
||||||
|
|
||||||
|
### Dokumentenliste
|
||||||
|
|
||||||
|
Die Tabelle zeigt folgende Spalten:
|
||||||
|
|
||||||
|
| Spalte | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 19) |
|
||||||
|
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
|
||||||
|
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
|
||||||
|
| Quellpfad | Letzter bekannter Quellordner |
|
||||||
|
| Letzter Versuch | Zeitpunkt der letzten Statusänderung |
|
||||||
|
| Anzahl Versuche | Gesamtzahl aller Verarbeitungsversuche |
|
||||||
|
|
||||||
|
**Sortierung:** Standardmäßig absteigend nach dem letzten Versuch (neueste zuerst).
|
||||||
|
|
||||||
|
**Hinweise zur Anzeige:**
|
||||||
|
- Lange Dateinamen und Pfade werden in der Tabelle abgekürzt (Ellipsis). Der vollständige
|
||||||
|
Text erscheint im Tooltip beim Hover.
|
||||||
|
- Bei mehr als 500 Treffern erscheint der Hinweis „Weitere Einträge vorhanden – Filter
|
||||||
|
verwenden". Es werden dann nur die 500 neuesten Einträge angezeigt.
|
||||||
|
- Bei leerer Datenbank erscheint der Text „Noch keine Verarbeitungen vorhanden."
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
|
||||||
|
Über dem Tab befinden sich drei Bedienelemente:
|
||||||
|
|
||||||
|
- **Freitextsuche** – filtert über Quelldateiname und Zieldateiname, case-insensitiv
|
||||||
|
- **Status-Filter** – ComboBox zur Auswahl eines bestimmten Status oder „Alle"
|
||||||
|
- **„Aktualisieren"** – lädt die Liste neu aus der Datenbank (kein automatisches Echtzeit-Tailing)
|
||||||
|
|
||||||
|
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
|
||||||
|
|
||||||
|
### Detailbereich
|
||||||
|
|
||||||
|
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
|
||||||
|
|
||||||
|
**Dokument-Info:**
|
||||||
|
- Fingerprint (12 Zeichen des SHA-256-Hash)
|
||||||
|
- Quelldateiname und Quellpfad
|
||||||
|
- Status (Icon + Text)
|
||||||
|
- Erstellt am / Aktualisiert am
|
||||||
|
|
||||||
|
**Versuche-Tabelle:** Alle bisher protokollierten Verarbeitungsversuche:
|
||||||
|
|
||||||
|
| Spalte | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| # | Versuchsnummer |
|
||||||
|
| Datum | Endzeitpunkt des Versuchs |
|
||||||
|
| Status | Ergebnisstatus des Versuchs |
|
||||||
|
| Provider | Verwendeter KI-Provider |
|
||||||
|
| Modell | Verwendetes Sprachmodell |
|
||||||
|
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
|
||||||
|
|
||||||
|
**KI-Begründung:** Das `ai_reasoning` des ausgewählten Versuchs als nicht editierbarer Text.
|
||||||
|
|
||||||
|
### Aktionen
|
||||||
|
|
||||||
|
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung:
|
||||||
|
|
||||||
|
**„Status zurücksetzen"**
|
||||||
|
|
||||||
|
Setzt den Status des ausgewählten Dokuments auf „Wartet auf Verarbeitung" zurück,
|
||||||
|
sodass es beim nächsten Verarbeitungslauf automatisch erneut verarbeitet wird.
|
||||||
|
Die Versuchshistorie bleibt vollständig erhalten – kein Versuch wird gelöscht.
|
||||||
|
Vor der Aktion erscheint ein Bestätigungsdialog.
|
||||||
|
|
||||||
|
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
|
||||||
|
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
|
||||||
|
|
||||||
|
**„Eintrag löschen"**
|
||||||
|
|
||||||
|
Löscht den Stammsatz und alle Verarbeitungsversuche des ausgewählten Dokuments
|
||||||
|
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
|
||||||
|
Vor der Aktion erscheint ein Bestätigungsdialog mit einem ausdrücklichen Hinweis
|
||||||
|
auf die Unwiderruflichkeit.
|
||||||
|
|
||||||
|
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
|
||||||
|
Ein Hinweis „Aktion während Verarbeitungslauf nicht möglich." wird angezeigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Tab „Prompt" (Prompt-Editor)
|
||||||
|
|
||||||
|
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
|
||||||
|
KI-Prompt-Datei direkt in der GUI – ohne externen Editor.
|
||||||
|
|
||||||
|
### Inhalt und Bedienung
|
||||||
|
|
||||||
|
Die TextArea zeigt den aktuellen Inhalt der in der Konfiguration eingetragenen
|
||||||
|
Prompt-Datei. Der Inhalt ist vollständig editierbar.
|
||||||
|
|
||||||
|
**Buttons:**
|
||||||
|
|
||||||
|
- **„Speichern"** – schreibt den aktuellen Inhalt atomar in die Prompt-Datei
|
||||||
|
(Temp-Datei im selben Verzeichnis, dann atomarer Austausch). Encoding: UTF-8;
|
||||||
|
Zeilenenden werden unverändert übernommen. Bei einem Fehler erscheint eine
|
||||||
|
Fehlermeldung im Tab; es gibt keinen stillen Fallback.
|
||||||
|
- **„Auf Standard zurücksetzen"** – füllt die TextArea mit dem eingebauten
|
||||||
|
Standard-Template, ohne die Datei sofort zu speichern. Erst ein anschließendes
|
||||||
|
„Speichern" schreibt die Änderung auf den Datenträger.
|
||||||
|
|
||||||
|
**Dirty State:**
|
||||||
|
|
||||||
|
Sobald der TextArea-Inhalt vom gespeicherten Stand abweicht, erscheint ein
|
||||||
|
Asterisk im Tab-Titel: **„Prompt \*"**. Wird der Tab gewechselt oder die
|
||||||
|
Anwendung geschlossen, während ungespeicherte Änderungen vorliegen, erscheint
|
||||||
|
ein Bestätigungsdialog mit der Frage „Änderungen verwerfen?".
|
||||||
|
|
||||||
|
### Fehlende Prompt-Datei
|
||||||
|
|
||||||
|
Ist keine Prompt-Datei konfiguriert oder existiert die konfigurierte Datei nicht,
|
||||||
|
zeigt der Tab einen Hinweistext und den Button **„Standard-Prompt erstellen"**.
|
||||||
|
Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
|
||||||
|
(standardmäßig im selben Ordner wie die geladene `.properties`-Datei).
|
||||||
|
|
||||||
|
### Hinweise
|
||||||
|
|
||||||
|
- Das Tab lädt den Dateiinhalt automatisch, wenn es geöffnet wird (Hintergrund-Thread).
|
||||||
|
- Wird die Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht.
|
||||||
|
Beim Speichern gilt Last-write-wins.
|
||||||
|
- Für den Betrieb über MSI oder Task Scheduler wird empfohlen, den Prompt-Pfad
|
||||||
|
in der Konfiguration als absoluten Pfad anzugeben, um vom jeweiligen Arbeitsverzeichnis
|
||||||
|
unabhängig zu sein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Statuszeile
|
||||||
|
|
||||||
|
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
|
||||||
|
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
|
||||||
|
|
||||||
|
| Position | Inhalt | Beispiel |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| Links | Anwendungsversion | `V3.0.42` |
|
||||||
|
| Mitte | Aktiver Provider und Modell | `Provider: Claude · claude-opus-4-7` |
|
||||||
|
| Rechts | Pfad der geladenen Konfigurationsdatei | `config/application.properties` |
|
||||||
|
|
||||||
|
**Besonderheiten:**
|
||||||
|
|
||||||
|
- Die Versionsangabe wird aus der JAR-Manifest-Datei gelesen. Beim Start aus einer IDE
|
||||||
|
ohne gepacktes JAR erscheint der Fallback `Vdev`.
|
||||||
|
- Ist keine Konfiguration geladen, zeigen Mitte und Rechts den Text „Kein Profil geladen".
|
||||||
|
- Die Statuszeile aktualisiert sich automatisch beim Laden, Speichern und Schließen
|
||||||
|
einer Konfigurationsdatei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Fehlerstatus – Bedeutung und Unterscheidung
|
||||||
|
|
||||||
|
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
|
||||||
|
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
|
||||||
|
|
||||||
|
### `↻` Wird wiederholt (orange) – `FAILED_RETRYABLE`
|
||||||
|
|
||||||
|
Das Dokument konnte vorübergehend nicht verarbeitet werden. Der Fehler ist
|
||||||
|
wahrscheinlich technischer Natur und kann sich bei einem späteren Versuch
|
||||||
|
von selbst auflösen.
|
||||||
|
|
||||||
|
**Typische Ursachen:** Netzwerkfehler, Timeout beim KI-Dienst, vorübergehende
|
||||||
|
Nicht-Erreichbarkeit.
|
||||||
|
|
||||||
|
**Was passiert:** Das Dokument wird beim nächsten regulären Verarbeitungslauf
|
||||||
|
**automatisch erneut versucht** – kein manuelles Eingreifen notwendig.
|
||||||
|
|
||||||
|
### `×` Fehlgeschlagen (rot) – `FAILED_FINAL`
|
||||||
|
|
||||||
|
Das Dokument ist dauerhaft nicht verarbeitbar. Automatische Wiederholversuche
|
||||||
|
werden nicht mehr unternommen.
|
||||||
|
|
||||||
|
**Typische Ursachen:**
|
||||||
|
- Kein lesbarer Text (z. B. eingescanntes Foto ohne OCR-Verarbeitung)
|
||||||
|
- Passwortgeschützte PDF
|
||||||
|
- Beschädigte oder unlesbare Datei
|
||||||
|
|
||||||
|
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
|
||||||
|
|
||||||
|
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
|
||||||
|
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 16) manuell zurückgesetzt
|
||||||
|
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
|
||||||
|
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Vollständige Status-Mapping-Tabelle
|
||||||
|
|
||||||
|
| Status | Icon | Farbe | Tooltip-Text | Summary-Kategorie |
|
||||||
|
|--------|------|-------|-------------|-------------------|
|
||||||
|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
|
||||||
|
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
|
||||||
|
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
|
||||||
|
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
|
||||||
|
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
|
||||||
|
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | – |
|
||||||
|
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | – |
|
||||||
|
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | – |
|
||||||
|
|
||||||
|
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal. Icon und Tooltip-Text
|
||||||
|
beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Tooltips
|
||||||
|
|
||||||
|
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
|
||||||
|
ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
|
||||||
|
|
||||||
|
Tooltips sind unter anderem vorhanden auf:
|
||||||
|
|
||||||
|
- **Konfigurationsfeldern** – Quellordner, Zielordner, SQLite-Datei, Prompt-Datei,
|
||||||
|
Provider-ComboBox, Modell-Feld, `max.text.characters`, `max.pages`, `max.title.length`
|
||||||
|
- **Toolbar-Buttons** – Neu, Öffnen, Speichern, Speichern unter, Validieren,
|
||||||
|
Technische Tests ausführen
|
||||||
|
- **Status-Icons** im Verarbeitungslauf-Tab – Text gemäß Status-Mapping-Tabelle
|
||||||
|
(Abschnitt 19)
|
||||||
|
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
|
||||||
|
Dateiname-Editor (Abschnitt 13b)
|
||||||
|
|
||||||
|
Der Tooltip erscheint nach einer kurzen Verzögerung beim Verweilen mit der Maus
|
||||||
|
über dem jeweiligen Element.
|
||||||
|
|||||||
+155
-1
@@ -72,6 +72,8 @@ import javafx.scene.control.Tab;
|
|||||||
import javafx.scene.control.TabPane;
|
import javafx.scene.control.TabPane;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
import javafx.scene.control.TitledPane;
|
import javafx.scene.control.TitledPane;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.util.Duration;
|
||||||
import javafx.scene.input.Clipboard;
|
import javafx.scene.input.Clipboard;
|
||||||
import javafx.scene.input.ClipboardContent;
|
import javafx.scene.input.ClipboardContent;
|
||||||
import javafx.scene.input.KeyCode;
|
import javafx.scene.input.KeyCode;
|
||||||
@@ -275,6 +277,15 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
Consumer<String> titleUpdateListener = title -> { };
|
Consumer<String> titleUpdateListener = title -> { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener der bei jedem Zustandswechsel des Editor-Zustands aufgerufen wird und
|
||||||
|
* den neuen Zustand an die Statuszeile weiterleitet.
|
||||||
|
* <p>
|
||||||
|
* Package-private, damit {@link PdfUmbenennerGuiApplication} die Statuszeile verdrahten kann.
|
||||||
|
* Standard ist ein No-Op, damit der Workspace auch ohne Statuszeile funktioniert.
|
||||||
|
*/
|
||||||
|
Consumer<GuiConfigurationEditorState> statusBarStateListener = state -> { };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family.
|
* Per-provider {@link GuiModelFieldContainer} instances, one for each known provider family.
|
||||||
* Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with
|
* Populated in {@link #createProviderBlock(String, AiProviderFamily)} and registered with
|
||||||
@@ -403,6 +414,14 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort}
|
||||||
|
* erzeugt. Wird verwendet, wenn eine neue Konfiguration geladen oder gespeichert wird,
|
||||||
|
* um den {@link GuiPromptEditorTab} mit einem aktualisierten Port zu versorgen.
|
||||||
|
* Supplied by Bootstrap via the startup context.
|
||||||
|
*/
|
||||||
|
private final GuiPromptEditorPortFactory promptEditorPortFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Second main tab of the window that drives the live processing-run view. Created
|
* Second main tab of the window that drives the live processing-run view. Created
|
||||||
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
||||||
@@ -410,6 +429,18 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiBatchRunTab batchRunTab;
|
private final GuiBatchRunTab batchRunTab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
|
||||||
|
* erstellt und in den {@link #tabPane} eingehängt.
|
||||||
|
*/
|
||||||
|
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
|
||||||
|
* und in den {@link #tabPane} eingehängt.
|
||||||
|
*/
|
||||||
|
private final GuiPromptEditorTab promptEditorTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hint banner shown at the top of the configuration tab while a processing run is
|
* Hint banner shown at the top of the configuration tab while a processing run is
|
||||||
* active. Visible + managed state are flipped from the batch run tab's listener when
|
* active. Visible + managed state are flipped from the batch run tab's listener when
|
||||||
@@ -464,6 +495,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
() -> editorState.loadedFileSnapshot()
|
() -> editorState.loadedFileSnapshot()
|
||||||
.map(snapshot -> snapshot.filePath().toString())
|
.map(snapshot -> snapshot.filePath().toString())
|
||||||
.orElse(""),
|
.orElse(""),
|
||||||
|
() -> editorState.values().logDirectory(),
|
||||||
pendingMessages,
|
pendingMessages,
|
||||||
report -> {
|
report -> {
|
||||||
technicalTestsButton.setDisable(false);
|
technicalTestsButton.setDisable(false);
|
||||||
@@ -480,6 +512,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
||||||
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
||||||
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
||||||
|
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
() -> this.miniRunLauncher,
|
() -> this.miniRunLauncher,
|
||||||
@@ -493,6 +526,25 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this::editorSourceFolder,
|
this::editorSourceFolder,
|
||||||
this::editorTargetFolder);
|
this::editorTargetFolder);
|
||||||
|
|
||||||
|
this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
|
||||||
|
effectiveContext.historyOverviewPort(),
|
||||||
|
effectiveContext.historyDetailsPort(),
|
||||||
|
effectiveContext.historyResetDocumentStatusPort(),
|
||||||
|
effectiveContext.deleteDocumentHistoryPort(),
|
||||||
|
this.batchRunTab::isRunning,
|
||||||
|
this::loadedConfigurationPath);
|
||||||
|
|
||||||
|
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
|
||||||
|
int maxTitleLength;
|
||||||
|
try {
|
||||||
|
maxTitleLength = Integer.parseInt(
|
||||||
|
effectiveContext.initialState().values().maxTitleLength().trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
maxTitleLength = 60;
|
||||||
|
}
|
||||||
|
this.promptEditorTab = new GuiPromptEditorTab(
|
||||||
|
effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);
|
||||||
|
|
||||||
configureRoot();
|
configureRoot();
|
||||||
configureHeader(effectiveContext.startupNotice());
|
configureHeader(effectiveContext.startupNotice());
|
||||||
configureTabs();
|
configureTabs();
|
||||||
@@ -1093,6 +1145,10 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
|
|
||||||
this.editorState = completion.newState();
|
this.editorState = completion.newState();
|
||||||
refreshHeader();
|
refreshHeader();
|
||||||
|
// Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
|
||||||
|
statusBarStateListener.accept(this.editorState);
|
||||||
|
// Prompt-Tab über neuen Prompt-Pfad informieren (kann sich durch Speichern geändert haben)
|
||||||
|
notifyPromptTabConfigChanged(this.editorState);
|
||||||
|
|
||||||
if (result.hasApiKeyPreservationNote()) {
|
if (result.hasApiKeyPreservationNote()) {
|
||||||
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
|
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
|
||||||
@@ -1188,6 +1244,63 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
pendingMessages.clear();
|
pendingMessages.clear();
|
||||||
refreshView();
|
refreshView();
|
||||||
runEditorValidation();
|
runEditorValidation();
|
||||||
|
// Statuszeile über den neuen Zustand informieren
|
||||||
|
statusBarStateListener.accept(newState);
|
||||||
|
// Prompt-Tab mit neuem Pfad und Port versorgen
|
||||||
|
notifyPromptTabConfigChanged(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigt den Prompt-Editor-Tab über eine geänderte Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Liest {@code prompt.template.file} und {@code max.title.length} aus dem neuen
|
||||||
|
* Zustand, erzeugt über die Factory einen passenden Port und übergibt beides an den
|
||||||
|
* Tab. Ist der Prompt-Pfad leer, wird ein No-Op-Port verwendet.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param state der neue Editor-Zustand; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void notifyPromptTabConfigChanged(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState state) {
|
||||||
|
String promptPath = state.values().promptTemplateFile();
|
||||||
|
int maxTitle;
|
||||||
|
try {
|
||||||
|
maxTitle = Integer.parseInt(state.values().maxTitleLength().trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
maxTitle = 60;
|
||||||
|
}
|
||||||
|
GuiPromptEditorPort newPort = (promptPath != null && !promptPath.isBlank())
|
||||||
|
? promptEditorPortFactory.create(promptPath)
|
||||||
|
: noOpPromptEditorPort();
|
||||||
|
promptEditorTab.notifyConfigurationChanged(
|
||||||
|
newPort,
|
||||||
|
promptPath != null ? promptPath : "",
|
||||||
|
maxTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||||
|
return new GuiPromptEditorPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||||
|
"NO_PATH", "Kein Prompt-Pfad konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||||
|
"Kein Prompt-Pfad konfiguriert.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDefaultPromptIfMissing(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, "Kein Prompt-Pfad konfiguriert.");
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureRoot() {
|
private void configureRoot() {
|
||||||
@@ -1256,11 +1369,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
scrollPane.setPadding(new Insets(0));
|
scrollPane.setPadding(new Insets(0));
|
||||||
editorTab.setContent(scrollPane);
|
editorTab.setContent(scrollPane);
|
||||||
|
|
||||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
|
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), historyTab.tab(), promptEditorTab.tab());
|
||||||
root.setCenter(tabPane);
|
root.setCenter(tabPane);
|
||||||
|
|
||||||
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
||||||
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
||||||
|
// Gleiches gilt für den Prompt-Tab.
|
||||||
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
||||||
if (oldTab == null || newTab == null) {
|
if (oldTab == null || newTab == null) {
|
||||||
return;
|
return;
|
||||||
@@ -1272,11 +1386,24 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
// Zurück zum Verarbeitungslauf-Tab
|
// Zurück zum Verarbeitungslauf-Tab
|
||||||
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
||||||
}
|
}
|
||||||
|
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
|
||||||
|
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
|
||||||
|
if (!shouldDiscard) {
|
||||||
|
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
||||||
|
} else {
|
||||||
|
promptEditorTab.discardChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureActionBar() {
|
private void configureActionBar() {
|
||||||
|
// Tooltips für Toolbar-Buttons gemäß Spezifikation
|
||||||
|
applyTooltip(newButton, GuiTooltipTexts.TOOLBAR_NEU);
|
||||||
|
applyTooltip(openButton, GuiTooltipTexts.TOOLBAR_OEFFNEN);
|
||||||
|
applyTooltip(saveButton, GuiTooltipTexts.TOOLBAR_SPEICHERN);
|
||||||
|
applyTooltip(saveAsButton, GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);
|
||||||
|
|
||||||
HBox actionBar = new HBox(10, newButton, openButton, saveButton, saveAsButton);
|
HBox actionBar = new HBox(10, newButton, openButton, saveButton, saveAsButton);
|
||||||
actionBar.setAlignment(Pos.CENTER_LEFT);
|
actionBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
actionBar.setPadding(new Insets(16, 0, 0, 0));
|
actionBar.setPadding(new Insets(16, 0, 0, 0));
|
||||||
@@ -1364,12 +1491,14 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField sourceFolderField = boundTextField(
|
TextField sourceFolderField = boundTextField(
|
||||||
editorState.values().sourceFolder(),
|
editorState.values().sourceFolder(),
|
||||||
val -> updateValues(editorState.values().withSourceFolder(val)));
|
val -> updateValues(editorState.values().withSourceFolder(val)));
|
||||||
|
applyTooltip(sourceFolderField, GuiTooltipTexts.PFADE_QUELLORDNER);
|
||||||
Label sourceFolderErrorLabel = createFieldErrorLabel();
|
Label sourceFolderErrorLabel = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put("source.folder", sourceFolderErrorLabel);
|
fieldErrorLabels.put("source.folder", sourceFolderErrorLabel);
|
||||||
|
|
||||||
TextField targetFolderField = boundTextField(
|
TextField targetFolderField = boundTextField(
|
||||||
editorState.values().targetFolder(),
|
editorState.values().targetFolder(),
|
||||||
val -> updateValues(editorState.values().withTargetFolder(val)));
|
val -> updateValues(editorState.values().withTargetFolder(val)));
|
||||||
|
applyTooltip(targetFolderField, GuiTooltipTexts.PFADE_ZIELORDNER);
|
||||||
Label targetFolderErrorLabel = createFieldErrorLabel();
|
Label targetFolderErrorLabel = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put("target.folder", targetFolderErrorLabel);
|
fieldErrorLabels.put("target.folder", targetFolderErrorLabel);
|
||||||
|
|
||||||
@@ -1394,12 +1523,14 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField sqliteField = boundTextField(
|
TextField sqliteField = boundTextField(
|
||||||
editorState.values().sqliteFile(),
|
editorState.values().sqliteFile(),
|
||||||
val -> updateValues(editorState.values().withSqliteFile(val)));
|
val -> updateValues(editorState.values().withSqliteFile(val)));
|
||||||
|
applyTooltip(sqliteField, GuiTooltipTexts.PFADE_SQLITE);
|
||||||
Label sqliteErrorLabel = createFieldErrorLabel();
|
Label sqliteErrorLabel = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put("sqlite.file", sqliteErrorLabel);
|
fieldErrorLabels.put("sqlite.file", sqliteErrorLabel);
|
||||||
|
|
||||||
TextField promptField = boundTextField(
|
TextField promptField = boundTextField(
|
||||||
editorState.values().promptTemplateFile(),
|
editorState.values().promptTemplateFile(),
|
||||||
val -> updateValues(editorState.values().withPromptTemplateFile(val)));
|
val -> updateValues(editorState.values().withPromptTemplateFile(val)));
|
||||||
|
applyTooltip(promptField, GuiTooltipTexts.PFADE_PROMPT);
|
||||||
Label promptErrorLabel = createFieldErrorLabel();
|
Label promptErrorLabel = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put("prompt.template.file", promptErrorLabel);
|
fieldErrorLabels.put("prompt.template.file", promptErrorLabel);
|
||||||
|
|
||||||
@@ -1487,6 +1618,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
providerComboBox.setConverter(new AiProviderFamilyStringConverter());
|
providerComboBox.setConverter(new AiProviderFamilyStringConverter());
|
||||||
providerComboBox.getItems().addAll(AiProviderFamily.CLAUDE, AiProviderFamily.OPENAI_COMPATIBLE);
|
providerComboBox.getItems().addAll(AiProviderFamily.CLAUDE, AiProviderFamily.OPENAI_COMPATIBLE);
|
||||||
providerComboBox.setValue(initialProvider);
|
providerComboBox.setValue(initialProvider);
|
||||||
|
applyTooltip(providerComboBox, GuiTooltipTexts.PROVIDER_COMBOBOX);
|
||||||
|
|
||||||
// --- "Modelle neu laden" button ---
|
// --- "Modelle neu laden" button ---
|
||||||
Button reloadModelsButton = new Button("Modelle neu laden");
|
Button reloadModelsButton = new Button("Modelle neu laden");
|
||||||
@@ -1753,6 +1885,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
pState.model(),
|
pState.model(),
|
||||||
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
|
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
|
||||||
pState2.baseUrl(), val, pState2.timeoutSeconds(), pState2.apiKey())));
|
pState2.baseUrl(), val, pState2.timeoutSeconds(), pState2.apiKey())));
|
||||||
|
modelContainer.applyTooltip(GuiTooltipTexts.PROVIDER_MODELL);
|
||||||
modelFieldContainers.put(family, modelContainer);
|
modelFieldContainers.put(family, modelContainer);
|
||||||
modelCatalogCoordinator.registerFieldContainer(family, modelContainer);
|
modelCatalogCoordinator.registerFieldContainer(family, modelContainer);
|
||||||
Label modelError = createFieldErrorLabel();
|
Label modelError = createFieldErrorLabel();
|
||||||
@@ -1846,12 +1979,14 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField maxPagesField = boundTextField(
|
TextField maxPagesField = boundTextField(
|
||||||
editorState.values().maxPages(),
|
editorState.values().maxPages(),
|
||||||
val -> updateValues(editorState.values().withMaxPages(val)));
|
val -> updateValues(editorState.values().withMaxPages(val)));
|
||||||
|
applyTooltip(maxPagesField, GuiTooltipTexts.LIMITS_MAX_PAGES);
|
||||||
grid.add(new Label("Max. Seiten:"), 0, row);
|
grid.add(new Label("Max. Seiten:"), 0, row);
|
||||||
grid.add(maxPagesField, 1, row);
|
grid.add(maxPagesField, 1, row);
|
||||||
|
|
||||||
TextField maxCharsField = boundTextField(
|
TextField maxCharsField = boundTextField(
|
||||||
editorState.values().maxTextCharacters(),
|
editorState.values().maxTextCharacters(),
|
||||||
val -> updateValues(editorState.values().withMaxTextCharacters(val)));
|
val -> updateValues(editorState.values().withMaxTextCharacters(val)));
|
||||||
|
applyTooltip(maxCharsField, GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
|
||||||
grid.add(new Label("Max. Zeichen:"), 2, row);
|
grid.add(new Label("Max. Zeichen:"), 2, row);
|
||||||
grid.add(maxCharsField, 3, row);
|
grid.add(maxCharsField, 3, row);
|
||||||
row++;
|
row++;
|
||||||
@@ -1860,6 +1995,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField maxTitleLengthField = boundTextField(
|
TextField maxTitleLengthField = boundTextField(
|
||||||
editorState.values().maxTitleLength(),
|
editorState.values().maxTitleLength(),
|
||||||
val -> updateValues(editorState.values().withMaxTitleLength(val)));
|
val -> updateValues(editorState.values().withMaxTitleLength(val)));
|
||||||
|
applyTooltip(maxTitleLengthField, GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
|
||||||
grid.add(new Label("Max. Titellänge:"), 0, row);
|
grid.add(new Label("Max. Titellänge:"), 0, row);
|
||||||
grid.add(maxTitleLengthField, 1, row);
|
grid.add(maxTitleLengthField, 1, row);
|
||||||
|
|
||||||
@@ -1914,9 +2050,11 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
|
|
||||||
validateButton.setId("validate-button");
|
validateButton.setId("validate-button");
|
||||||
validateButton.setOnAction(e -> runValidationAction());
|
validateButton.setOnAction(e -> runValidationAction());
|
||||||
|
applyTooltip(validateButton, GuiTooltipTexts.TOOLBAR_VALIDIEREN);
|
||||||
|
|
||||||
technicalTestsButton.setId("technical-tests-button");
|
technicalTestsButton.setId("technical-tests-button");
|
||||||
technicalTestsButton.setOnAction(e -> runTechnicalTestsAction());
|
technicalTestsButton.setOnAction(e -> runTechnicalTestsAction());
|
||||||
|
applyTooltip(technicalTestsButton, GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);
|
||||||
|
|
||||||
HBox buttonRow = new HBox(8, validateButton, technicalTestsButton);
|
HBox buttonRow = new HBox(8, validateButton, technicalTestsButton);
|
||||||
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
@@ -2838,6 +2976,22 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
: exception.getMessage();
|
: exception.getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control.
|
||||||
|
* <p>
|
||||||
|
* Alle Tooltips in dieser Klasse werden über diese Methode gesetzt, damit ein konsistentes
|
||||||
|
* Erscheinungsbild gewährleistet ist. Darf nur auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param control der Button oder ein anderes {@link javafx.scene.control.Control}, das den
|
||||||
|
* Tooltip erhalten soll; darf nicht {@code null} sein
|
||||||
|
* @param text der Tooltip-Text; darf nicht leer sein
|
||||||
|
*/
|
||||||
|
private static void applyTooltip(javafx.scene.control.Control control, String text) {
|
||||||
|
Tooltip tooltip = new Tooltip(text);
|
||||||
|
tooltip.setShowDelay(Duration.millis(300));
|
||||||
|
control.setTooltip(tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speichert den Pfad einer gerade geladenen Konfigurationsdatei.
|
* Speichert den Pfad einer gerade geladenen Konfigurationsdatei.
|
||||||
* Der Pfad wird in den Java Preferences gespeichert und beim nächsten Start
|
* Der Pfad wird in den Java Preferences gespeichert und beim nächsten Start
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen
|
||||||
|
* Use-Case in der Application-Schicht.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte
|
||||||
|
* Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem
|
||||||
|
* zugreift.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verantwortung:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
|
||||||
|
* <li>Bearbeiteten Inhalt atomar speichern.</li>
|
||||||
|
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}.
|
||||||
|
* Das GUI-Modul kennt ausschließlich den Interface-Typ.
|
||||||
|
*/
|
||||||
|
public interface GuiPromptEditorPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||||
|
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||||
|
*
|
||||||
|
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
|
||||||
|
* oder einem klassifizierten Fehler; nie {@code null}
|
||||||
|
*/
|
||||||
|
PromptLoadingResult loadCurrentPrompt();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||||
|
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||||
|
*
|
||||||
|
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||||
|
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code content} null ist
|
||||||
|
*/
|
||||||
|
PromptSaveResult save(String content);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||||
|
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||||
|
*
|
||||||
|
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
|
||||||
|
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code suggestion} null ist
|
||||||
|
*/
|
||||||
|
CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion);
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort} erzeugt.
|
||||||
|
* <p>
|
||||||
|
* Wird vom {@link GuiConfigurationEditorWorkspace} genutzt, um nach einem Konfigurations-Laden
|
||||||
|
* oder -Speichern einen neuen Port für den {@link GuiPromptEditorTab} zu erstellen, ohne dass
|
||||||
|
* der GUI-Adapter direkt von Bootstrap-internen Klassen abhängen muss.
|
||||||
|
* <p>
|
||||||
|
* Alle Implementierungen liegen in {@code pdf-umbenenner-bootstrap}.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiPromptEditorPortFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen {@link GuiPromptEditorPort} für den angegebenen Prompt-Dateipfad.
|
||||||
|
*
|
||||||
|
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
|
||||||
|
* @return vollständig verdrahteter Port; nie {@code null}
|
||||||
|
*/
|
||||||
|
GuiPromptEditorPort create(String promptFilePath);
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab „Prompt" im Hauptfenster des GUI-Adapters.
|
||||||
|
* <p>
|
||||||
|
* Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei
|
||||||
|
* direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verhalten:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
|
||||||
|
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
|
||||||
|
* <li>„Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
|
||||||
|
* <li>„Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
|
||||||
|
* ohne zu speichern.</li>
|
||||||
|
* <li>Bei fehlendem Prompt wird ein Hinweis und ein „Standard-Prompt erstellen"-Button
|
||||||
|
* angezeigt.</li>
|
||||||
|
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Alle blockierenden Operationen (Laden, Speichern,
|
||||||
|
* Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
|
||||||
|
* ausschließlich via {@code Platform.runLater}.
|
||||||
|
*/
|
||||||
|
public class GuiPromptEditorTab {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Prompt";
|
||||||
|
private static final String TAB_TITLE_DIRTY = "Prompt *";
|
||||||
|
|
||||||
|
private GuiPromptEditorPort promptEditorPort;
|
||||||
|
/** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
|
||||||
|
private String configuredPromptPath;
|
||||||
|
/** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
|
||||||
|
private int maxTitleLength;
|
||||||
|
|
||||||
|
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
|
||||||
|
/** Erzeugt Worker-Threads für blockierende Operationen. */
|
||||||
|
Function<Runnable, Thread> threadFactory;
|
||||||
|
/** Übergibt UI-Updates an den JavaFX Application Thread. */
|
||||||
|
Consumer<Runnable> fxDispatcher;
|
||||||
|
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
private final TextArea textArea = new TextArea();
|
||||||
|
private final Label statusLabel = new Label();
|
||||||
|
private final Button saveButton = new Button("Speichern");
|
||||||
|
private final Button resetButton = new Button("Auf Standard zurücksetzen");
|
||||||
|
private final Button createDefaultButton = new Button("Standard-Prompt erstellen");
|
||||||
|
|
||||||
|
/** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */
|
||||||
|
private boolean dirty = false;
|
||||||
|
/** Zuletzt aus der Datei geladener Inhalt (Baseline). */
|
||||||
|
private String loadedContent = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den Prompt-Editor-Tab.
|
||||||
|
*
|
||||||
|
* @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein
|
||||||
|
* @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile);
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt
|
||||||
|
* @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist
|
||||||
|
*/
|
||||||
|
public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort,
|
||||||
|
String configuredPromptPath,
|
||||||
|
int maxTitleLength) {
|
||||||
|
this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||||
|
this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null");
|
||||||
|
this.maxTitleLength = maxTitleLength;
|
||||||
|
// Standard-Implementierungen für den Produktionsbetrieb
|
||||||
|
this.threadFactory = runnable -> {
|
||||||
|
Thread t = new Thread(runnable, "gui-prompt-editor");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
this.fxDispatcher = Platform::runLater;
|
||||||
|
buildTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann.
|
||||||
|
*
|
||||||
|
* @return das Tab; nie {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn Dirty-State aktiv ist
|
||||||
|
*/
|
||||||
|
public boolean hasDirtyContent() {
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den Tab auf eine neue Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Setzt Port, Prompt-Dateipfad und maximale Titellänge auf die neuen Werte.
|
||||||
|
* Der bisherige Lade-Baseline wird verworfen und der Dirty-State zurückgesetzt.
|
||||||
|
* Ist der Tab zum Zeitpunkt des Aufrufs sichtbar, wird ein erneutes Laden sofort
|
||||||
|
* ausgelöst; andernfalls erfolgt das Laden beim nächsten Öffnen des Tabs.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param newPort neuer Port für Prompt-Operationen; darf nicht {@code null} sein
|
||||||
|
* @param newPromptPath neuer konfigurierter Prompt-Dateipfad; darf nicht {@code null} sein
|
||||||
|
* @param newMaxTitleLength neue konfigurierte maximale Titellänge
|
||||||
|
*/
|
||||||
|
public void notifyConfigurationChanged(GuiPromptEditorPort newPort,
|
||||||
|
String newPromptPath,
|
||||||
|
int newMaxTitleLength) {
|
||||||
|
this.promptEditorPort = Objects.requireNonNull(newPort, "newPort must not be null");
|
||||||
|
this.configuredPromptPath = Objects.requireNonNull(newPromptPath, "newPromptPath must not be null");
|
||||||
|
this.maxTitleLength = newMaxTitleLength;
|
||||||
|
this.loadedContent = null;
|
||||||
|
this.dirty = false;
|
||||||
|
this.tab.setText(TAB_TITLE);
|
||||||
|
this.saveButton.setDisable(true);
|
||||||
|
if (tab.isSelected()) {
|
||||||
|
loadPromptAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft alle ungespeicherten Änderungen und setzt den Tab in den Lade-Bereitschaftszustand.
|
||||||
|
* <p>
|
||||||
|
* Setzt Dirty-State und Tab-Titel zurück. Ist der Tab zum Zeitpunkt des Aufrufs sichtbar,
|
||||||
|
* wird der Prompt-Inhalt sofort neu geladen; andernfalls erfolgt das Laden beim nächsten
|
||||||
|
* Öffnen des Tabs (gesteuert durch den Tab-Selektions-Listener).
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void discardChanges() {
|
||||||
|
this.loadedContent = null;
|
||||||
|
this.dirty = false;
|
||||||
|
this.tab.setText(TAB_TITLE);
|
||||||
|
this.saveButton.setDisable(true);
|
||||||
|
if (tab.isSelected()) {
|
||||||
|
loadPromptAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
|
||||||
|
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
|
||||||
|
*
|
||||||
|
* @return {@code true} zum Verwerfen, {@code false} zum Abbrechen
|
||||||
|
*/
|
||||||
|
public boolean confirmDiscardIfDirty() {
|
||||||
|
if (!dirty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
alert.setTitle("Ungespeicherte Änderungen");
|
||||||
|
alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen.");
|
||||||
|
alert.setContentText("Möchten Sie die Änderungen verwerfen?");
|
||||||
|
alert.getButtonTypes().setAll(
|
||||||
|
new ButtonType("Verwerfen"),
|
||||||
|
ButtonType.CANCEL);
|
||||||
|
Optional<ButtonType> result = alert.showAndWait();
|
||||||
|
return result.isPresent() && result.get().getText().equals("Verwerfen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an.
|
||||||
|
* <p>
|
||||||
|
* Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
|
||||||
|
* läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
|
||||||
|
*/
|
||||||
|
public void loadPromptAsync() {
|
||||||
|
setStatus("Lade Prompt-Datei ...");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
|
||||||
|
Thread worker = threadFactory.apply(() -> {
|
||||||
|
var result = promptEditorPort.loadCurrentPrompt();
|
||||||
|
fxDispatcher.accept(() -> applyLoadResult(result));
|
||||||
|
});
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private Aufbau
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void buildTab() {
|
||||||
|
tab.setClosable(false);
|
||||||
|
|
||||||
|
// TextArea – monospace Font für bessere Lesbarkeit
|
||||||
|
textArea.setWrapText(true);
|
||||||
|
textArea.setFont(Font.font("Monospace", 13));
|
||||||
|
textArea.setPrefRowCount(20);
|
||||||
|
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||||
|
|
||||||
|
// Dirty-State-Tracking
|
||||||
|
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
|
||||||
|
if (loadedContent != null) {
|
||||||
|
boolean nowDirty = !newVal.equals(loadedContent);
|
||||||
|
if (nowDirty != dirty) {
|
||||||
|
dirty = nowDirty;
|
||||||
|
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status-Label
|
||||||
|
statusLabel.setWrapText(true);
|
||||||
|
statusLabel.setStyle("-fx-text-fill: #555555;");
|
||||||
|
|
||||||
|
// Buttons verdrahten
|
||||||
|
saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8)."));
|
||||||
|
saveButton.setOnAction(e -> requestSave());
|
||||||
|
|
||||||
|
resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern."));
|
||||||
|
resetButton.setOnAction(e -> resetToDefault());
|
||||||
|
|
||||||
|
createDefaultButton.setTooltip(new Tooltip(
|
||||||
|
"Standard-Prompt-Datei am konfigurierten Pfad anlegen."));
|
||||||
|
createDefaultButton.setOnAction(e -> requestCreateDefault());
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
|
||||||
|
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
|
||||||
|
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
buttonBar.setPadding(new Insets(6, 0, 0, 0));
|
||||||
|
|
||||||
|
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
|
||||||
|
content.setPadding(new Insets(12));
|
||||||
|
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||||
|
|
||||||
|
BorderPane root = new BorderPane(content);
|
||||||
|
tab.setContent(root);
|
||||||
|
|
||||||
|
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
|
||||||
|
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
|
||||||
|
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
|
||||||
|
loadPromptAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
|
||||||
|
if (result instanceof PromptLoadingSuccess success) {
|
||||||
|
loadedContent = success.promptContent();
|
||||||
|
textArea.setText(loadedContent);
|
||||||
|
textArea.setEditable(true);
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
|
||||||
|
dirty = false;
|
||||||
|
tab.setText(TAB_TITLE);
|
||||||
|
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
|
||||||
|
success.promptIdentifier().identifier());
|
||||||
|
} else if (result instanceof PromptLoadingFailure failure) {
|
||||||
|
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
|
||||||
|
if (fileNotFound) {
|
||||||
|
// Datei fehlt – Hinweis und Anlegen-Button anzeigen
|
||||||
|
loadedContent = null;
|
||||||
|
textArea.setEditable(false);
|
||||||
|
textArea.clear();
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
createDefaultButton.setVisible(true);
|
||||||
|
createDefaultButton.setManaged(true);
|
||||||
|
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
|
||||||
|
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
|
||||||
|
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
|
||||||
|
} else {
|
||||||
|
// Anderer Fehler (I/O, leer usw.)
|
||||||
|
loadedContent = null;
|
||||||
|
textArea.setEditable(false);
|
||||||
|
textArea.clear();
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
|
||||||
|
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
|
||||||
|
failure.failureReason(), failure.failureMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestSave() {
|
||||||
|
String currentText = textArea.getText();
|
||||||
|
|
||||||
|
// Leerer Prompt: Bestätigungsdialog
|
||||||
|
if (currentText.trim().isEmpty()) {
|
||||||
|
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
confirm.setTitle("Leerer Prompt");
|
||||||
|
confirm.setHeaderText("Der Prompt ist leer.");
|
||||||
|
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
|
||||||
|
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Speichere ...");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
|
||||||
|
Thread worker = threadFactory.apply(() -> {
|
||||||
|
PromptSaveResult result = promptEditorPort.save(currentText);
|
||||||
|
fxDispatcher.accept(() -> applySaveResult(result, currentText));
|
||||||
|
});
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySaveResult(PromptSaveResult result, String savedContent) {
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
if (result instanceof PromptSaveResult.Saved saved) {
|
||||||
|
loadedContent = savedContent;
|
||||||
|
dirty = false;
|
||||||
|
tab.setText(TAB_TITLE);
|
||||||
|
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
|
||||||
|
textArea.setEditable(true);
|
||||||
|
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
|
||||||
|
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
|
||||||
|
setStatus("Fehler: " + missing.message());
|
||||||
|
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Ordner fehlt: {}", missing.message());
|
||||||
|
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
|
||||||
|
setStatus("Fehler beim Schreiben: " + failed.message());
|
||||||
|
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Schreibfehler: {}", failed.message());
|
||||||
|
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
|
||||||
|
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
|
||||||
|
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetToDefault() {
|
||||||
|
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
|
||||||
|
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
|
||||||
|
textArea.setText(defaultContent);
|
||||||
|
textArea.setEditable(true);
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
|
||||||
|
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestCreateDefault() {
|
||||||
|
createDefaultButton.setDisable(true);
|
||||||
|
setStatus("Lege Standard-Prompt-Datei an ...");
|
||||||
|
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
|
||||||
|
configuredPromptPath,
|
||||||
|
"Standard-Prompt-Datei anlegen",
|
||||||
|
maxTitleLength);
|
||||||
|
|
||||||
|
Thread worker = threadFactory.apply(() -> {
|
||||||
|
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
|
||||||
|
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
|
||||||
|
});
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
|
||||||
|
createDefaultButton.setDisable(false);
|
||||||
|
if (outcome instanceof CorrectionOutcome.Applied applied) {
|
||||||
|
setStatus(applied.message() + " Lade Inhalt ...");
|
||||||
|
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
|
||||||
|
// Inhalt sofort neu laden
|
||||||
|
loadPromptAsync();
|
||||||
|
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
|
||||||
|
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
|
||||||
|
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
|
||||||
|
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
|
||||||
|
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
|
||||||
|
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setStatus(String message) {
|
||||||
|
statusLabel.setText(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-7
@@ -11,6 +11,10 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
|
|||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
@@ -44,7 +48,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
|||||||
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
||||||
* folder for documents that have not yet been successfully processed, and
|
* folder for documents that have not yet been successfully processed, and
|
||||||
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
||||||
* context for documents that were skipped in the current run.
|
* context for documents that were skipped in the current run, and the resolved application
|
||||||
|
* version string that the status bar displays at the bottom of the main window.
|
||||||
* <p>
|
* <p>
|
||||||
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
||||||
* know about provider-specific HTTP details or adapter wiring.
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
@@ -65,7 +70,14 @@ public record GuiStartupContext(
|
|||||||
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
GuiManualFileRenamePort manualFileRenamePort,
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
GuiManualFileCopyPort manualFileCopyPort,
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
String applicationVersion,
|
||||||
|
GuiPromptEditorPort promptEditorPort,
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort,
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||||
|
GuiPromptEditorPortFactory promptEditorPortFactory) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -92,6 +104,12 @@ public record GuiStartupContext(
|
|||||||
* must not be {@code null}
|
* must not be {@code null}
|
||||||
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
||||||
* for skipped documents; must not be {@code null}
|
* for skipped documents; must not be {@code null}
|
||||||
|
* @param applicationVersion resolved application version string shown in the status
|
||||||
|
* bar; {@code null} defaults to {@code "dev"}
|
||||||
|
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
*/
|
*/
|
||||||
public GuiStartupContext {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
@@ -124,6 +142,19 @@ public record GuiStartupContext(
|
|||||||
"manualFileCopyPort must not be null");
|
"manualFileCopyPort must not be null");
|
||||||
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
||||||
"historicalDocumentContextPort must not be null");
|
"historicalDocumentContextPort must not be null");
|
||||||
|
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||||
|
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||||
|
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||||
|
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
|
||||||
|
"historyOverviewPort must not be null");
|
||||||
|
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
|
||||||
|
"historyDetailsPort must not be null");
|
||||||
|
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
|
||||||
|
"historyResetDocumentStatusPort must not be null");
|
||||||
|
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
||||||
|
"deleteDocumentHistoryPort must not be null");
|
||||||
|
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
|
||||||
|
"promptEditorPortFactory must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +196,9 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +234,9 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,7 +272,9 @@ public record GuiStartupContext(
|
|||||||
technicalTestOrchestrator, correctionExecutionService,
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -307,7 +344,8 @@ public record GuiStartupContext(
|
|||||||
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort,
|
noOpPathCheckPort,
|
||||||
noOpTestService);
|
noOpTestService,
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
||||||
@Override
|
@Override
|
||||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
@@ -351,6 +389,64 @@ public record GuiStartupContext(
|
|||||||
rejectingResetPort(),
|
rejectingResetPort(),
|
||||||
rejectingManualFileRenamePort(),
|
rejectingManualFileRenamePort(),
|
||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort());
|
noOpHistoricalDocumentContextPort(),
|
||||||
|
"dev",
|
||||||
|
noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(),
|
||||||
|
noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(),
|
||||||
|
noOpDeleteHistoryPort(),
|
||||||
|
noOpPromptEditorPortFactory());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
|
||||||
|
return path -> noOpPromptEditorPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||||
|
return new GuiPromptEditorPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||||
|
"NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||||
|
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDefaultPromptIfMissing(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(
|
||||||
|
suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
|
||||||
|
noOpHistoryOverviewPort() {
|
||||||
|
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
|
||||||
|
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
|
||||||
|
noOpHistoryDetailsPort() {
|
||||||
|
return (configFilePath, fingerprint) -> java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
|
||||||
|
noOpHistoryResetPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
|
||||||
|
noOpDeleteHistoryPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Separator;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanente Statuszeile am unteren Rand des Hauptfensters.
|
||||||
|
* <p>
|
||||||
|
* Die Statuszeile zeigt immer drei Segmente:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z. B. {@code Vdev}.</li>
|
||||||
|
* <li><b>Mitte:</b> Aktiver Provider und Modellname aus der geladenen Konfiguration.</li>
|
||||||
|
* <li><b>Rechts:</b> Pfad der geladenen Konfigurationsdatei.</li>
|
||||||
|
* </ul>
|
||||||
|
* Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text
|
||||||
|
* {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar.
|
||||||
|
* <p>
|
||||||
|
* Alle Aktualisierungen dieser Komponente müssen auf dem JavaFX Application Thread erfolgen.
|
||||||
|
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
|
||||||
|
*/
|
||||||
|
public final class GuiStatusBar {
|
||||||
|
|
||||||
|
/** Anzeigetext wenn keine Konfiguration geladen ist. */
|
||||||
|
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
|
||||||
|
|
||||||
|
/** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */
|
||||||
|
private static final String VERSION_PREFIX = "V";
|
||||||
|
|
||||||
|
private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER =
|
||||||
|
new AiProviderFamilyStringConverter();
|
||||||
|
|
||||||
|
private final String applicationVersion;
|
||||||
|
private final BorderPane root;
|
||||||
|
private final Label versionLabel;
|
||||||
|
private final Label providerLabel;
|
||||||
|
private final Label configPathLabel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion.
|
||||||
|
*
|
||||||
|
* @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum
|
||||||
|
* Fallback {@code "dev"}
|
||||||
|
*/
|
||||||
|
public GuiStatusBar(String applicationVersion) {
|
||||||
|
this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank())
|
||||||
|
? "dev"
|
||||||
|
: applicationVersion;
|
||||||
|
|
||||||
|
// Linkes Segment: Versionsanzeige
|
||||||
|
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
|
||||||
|
this.versionLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
||||||
|
|
||||||
|
// Mittleres Segment: Provider und Modell
|
||||||
|
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
|
||||||
|
this.providerLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
||||||
|
this.providerLabel.setAlignment(Pos.CENTER);
|
||||||
|
|
||||||
|
// Rechtes Segment: Konfigurationspfad
|
||||||
|
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
|
||||||
|
this.configPathLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
||||||
|
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
|
||||||
|
|
||||||
|
// Abstandhalter zwischen den Segmenten
|
||||||
|
Region leftSpacer = new Region();
|
||||||
|
Region rightSpacer = new Region();
|
||||||
|
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
|
||||||
|
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox content = new HBox(16,
|
||||||
|
versionLabel, leftSpacer,
|
||||||
|
providerLabel, rightSpacer,
|
||||||
|
configPathLabel);
|
||||||
|
content.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
content.setPadding(new Insets(4, 12, 4, 12));
|
||||||
|
content.setStyle("-fx-background-color: #f5f5f5;");
|
||||||
|
|
||||||
|
Separator topSeparator = new Separator();
|
||||||
|
|
||||||
|
this.root = new BorderPane();
|
||||||
|
this.root.setTop(topSeparator);
|
||||||
|
this.root.setCenter(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird.
|
||||||
|
*
|
||||||
|
* @return der Wurzelknoten; nie {@code null}
|
||||||
|
*/
|
||||||
|
public BorderPane root() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
|
||||||
|
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
|
||||||
|
* und angezeigt.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void applyEditorState(GuiConfigurationEditorState state) {
|
||||||
|
if (state == null || !state.hasLoadedFileSnapshot()) {
|
||||||
|
clearConfiguration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String configPath = state.configurationPathText();
|
||||||
|
String providerText = resolveProviderText(state);
|
||||||
|
providerLabel.setText(providerText);
|
||||||
|
configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück.
|
||||||
|
* <p>
|
||||||
|
* Die Versionsanzeige bleibt unverändert.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void clearConfiguration() {
|
||||||
|
providerLabel.setText(KEIN_PROFIL_TEXT);
|
||||||
|
configPathLabel.setText(KEIN_PROFIL_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}).
|
||||||
|
* <p>
|
||||||
|
* Für Tests zugänglich.
|
||||||
|
*
|
||||||
|
* @return der angezeigte Versionstext; nie {@code null}
|
||||||
|
*/
|
||||||
|
String versionText() {
|
||||||
|
return versionLabel.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell angezeigten Provider-Text zurück.
|
||||||
|
* <p>
|
||||||
|
* Für Tests zugänglich.
|
||||||
|
*
|
||||||
|
* @return der angezeigte Provider-Text; nie {@code null}
|
||||||
|
*/
|
||||||
|
String providerText() {
|
||||||
|
return providerLabel.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell angezeigten Konfigurationspfad-Text zurück.
|
||||||
|
* <p>
|
||||||
|
* Für Tests zugänglich.
|
||||||
|
*
|
||||||
|
* @return der angezeigte Konfigurationspfad-Text; nie {@code null}
|
||||||
|
*/
|
||||||
|
String configPathText() {
|
||||||
|
return configPathLabel.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand.
|
||||||
|
* <p>
|
||||||
|
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, wobei der Modellname
|
||||||
|
* weggelassen wird, wenn er leer ist.
|
||||||
|
*
|
||||||
|
* @param state der Editor-Zustand; darf nicht {@code null} sein
|
||||||
|
* @return der formatierte Provider-Text; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static String resolveProviderText(GuiConfigurationEditorState state) {
|
||||||
|
String activeIdentifier = state.values().activeProviderFamily();
|
||||||
|
if (activeIdentifier == null || activeIdentifier.isBlank()) {
|
||||||
|
return KEIN_PROFIL_TEXT;
|
||||||
|
}
|
||||||
|
AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null);
|
||||||
|
if (family == null) {
|
||||||
|
return KEIN_PROFIL_TEXT;
|
||||||
|
}
|
||||||
|
String displayName = PROVIDER_CONVERTER.toString(family);
|
||||||
|
GuiProviderConfigurationState providerState = state.values().providerConfiguration(family);
|
||||||
|
String model = providerState != null ? providerState.model() : "";
|
||||||
|
if (model == null || model.isBlank()) {
|
||||||
|
return "Provider: " + displayName;
|
||||||
|
}
|
||||||
|
return "Provider: " + displayName + " · " + model;
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -64,6 +64,7 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
private final TechnicalTestOrchestrator orchestrator;
|
private final TechnicalTestOrchestrator orchestrator;
|
||||||
private final Supplier<EditorValidationInput> inputProvider;
|
private final Supplier<EditorValidationInput> inputProvider;
|
||||||
private final Supplier<String> configFilePathProvider;
|
private final Supplier<String> configFilePathProvider;
|
||||||
|
private final Supplier<String> logDirectoryProvider;
|
||||||
private final List<GuiMessageEntry> pendingMessages;
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
private final Consumer<TechnicalTestReport> postResultCallback;
|
private final Consumer<TechnicalTestReport> postResultCallback;
|
||||||
|
|
||||||
@@ -89,6 +90,9 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
||||||
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
||||||
* darf nicht {@code null} sein
|
* darf nicht {@code null} sein
|
||||||
|
* @param logDirectoryProvider Lieferant des konfigurierten {@code log.directory}-Rohwerts;
|
||||||
|
* gibt eine leere Zeichenkette zurück wenn kein Wert konfiguriert ist;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
* @param pendingMessages geteilte veränderliche Nachrichtenliste; 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
|
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
|
||||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
@@ -96,11 +100,13 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
||||||
Supplier<EditorValidationInput> inputProvider,
|
Supplier<EditorValidationInput> inputProvider,
|
||||||
Supplier<String> configFilePathProvider,
|
Supplier<String> configFilePathProvider,
|
||||||
|
Supplier<String> logDirectoryProvider,
|
||||||
List<GuiMessageEntry> pendingMessages,
|
List<GuiMessageEntry> pendingMessages,
|
||||||
Consumer<TechnicalTestReport> postResultCallback) {
|
Consumer<TechnicalTestReport> postResultCallback) {
|
||||||
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
||||||
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider 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.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
|
||||||
|
this.logDirectoryProvider = Objects.requireNonNull(logDirectoryProvider, "logDirectoryProvider must not be null");
|
||||||
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages 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.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
||||||
this.testThreadFactory = task -> {
|
this.testThreadFactory = task -> {
|
||||||
@@ -134,7 +140,8 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
pendingMessages.clear();
|
pendingMessages.clear();
|
||||||
EditorValidationInput input = inputProvider.get();
|
EditorValidationInput input = inputProvider.get();
|
||||||
String configFilePath = configFilePathProvider.get();
|
String configFilePath = configFilePathProvider.get();
|
||||||
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
|
String logDirectory = logDirectoryProvider.get();
|
||||||
|
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath, logDirectory);
|
||||||
|
|
||||||
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
||||||
|
|
||||||
@@ -234,6 +241,7 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
||||||
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
||||||
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
||||||
|
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Konstantenklasse für alle Tooltip-Texte der GUI.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist die einzige autoritative Quelle für Tooltip-Beschriftungen aller
|
||||||
|
* interaktiven Elemente in der Desktop-Oberfläche. Alle Tooltip-Strings werden hier
|
||||||
|
* definiert und von den jeweiligen UI-Klassen referenziert. Streustrings im
|
||||||
|
* UI-Code sind unzulässig.
|
||||||
|
* <p>
|
||||||
|
* Tooltip-Texte für Status-Icons werden <em>nicht</em> hier gepflegt – sie stammen
|
||||||
|
* ausschließlich aus {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation},
|
||||||
|
* die die autoritative Quelle für alle statusbezogenen Darstellungsinformationen ist.
|
||||||
|
* <p>
|
||||||
|
* Alle Texte sind deutschsprachig gemäß Spezifikation.
|
||||||
|
* Diese Klasse enthält keine JavaFX-Typen und ist nicht instanziierbar.
|
||||||
|
*/
|
||||||
|
public final class GuiTooltipTexts {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Toolbar-Buttons
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Neu". */
|
||||||
|
public static final String TOOLBAR_NEU =
|
||||||
|
"Neue Konfiguration erstellen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Öffnen". */
|
||||||
|
public static final String TOOLBAR_OEFFNEN =
|
||||||
|
"Bestehende Konfigurationsdatei (.properties) öffnen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Speichern". */
|
||||||
|
public static final String TOOLBAR_SPEICHERN =
|
||||||
|
"Aktuelle Konfiguration speichern.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Speichern unter". */
|
||||||
|
public static final String TOOLBAR_SPEICHERN_UNTER =
|
||||||
|
"Konfiguration unter neuem Dateipfad speichern.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Validieren". */
|
||||||
|
public static final String TOOLBAR_VALIDIEREN =
|
||||||
|
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Technische Tests ausführen". */
|
||||||
|
public static final String TOOLBAR_TECHNISCHE_TESTS =
|
||||||
|
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurationstab – Pfade
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Quellordner". */
|
||||||
|
public static final String PFADE_QUELLORDNER =
|
||||||
|
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Zielordner". */
|
||||||
|
public static final String PFADE_ZIELORDNER =
|
||||||
|
"Ordner für die umbenannten Kopien.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „SQLite-Datei". */
|
||||||
|
public static final String PFADE_SQLITE =
|
||||||
|
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Prompt-Datei". */
|
||||||
|
public static final String PFADE_PROMPT =
|
||||||
|
"Externe Textdatei mit den KI-Anweisungen.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurationstab – Provider
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für die Provider-ComboBox. */
|
||||||
|
public static final String PROVIDER_COMBOBOX =
|
||||||
|
"Der KI-Dienst, der die Dateinamen generiert.";
|
||||||
|
|
||||||
|
/** Tooltip für das Modell-Eingabefeld (ComboBox oder manuelles TextField). */
|
||||||
|
public static final String PROVIDER_MODELL =
|
||||||
|
"Das konkrete Sprachmodell des gewählten Providers.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurationstab – Verarbeitungslimits
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.text.characters". */
|
||||||
|
public static final String LIMITS_MAX_TEXT_CHARACTERS =
|
||||||
|
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.pages". */
|
||||||
|
public static final String LIMITS_MAX_PAGES =
|
||||||
|
"Maximale Seitenzahl, die aus einem PDF gelesen wird.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.title.length". */
|
||||||
|
public static final String LIMITS_MAX_TITLE_LENGTH =
|
||||||
|
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10–120.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verarbeitungslauf-Tab – Dateiname-Editor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Dateiname übernehmen". */
|
||||||
|
public static final String DATEINAME_UEBERNEHMEN =
|
||||||
|
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Zurücksetzen auf KI-Vorschlag". */
|
||||||
|
public static final String DATEINAME_ZURUECKSETZEN =
|
||||||
|
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
|
||||||
|
|
||||||
|
/** Nicht instanziierbar – reine Konstantenklasse. */
|
||||||
|
private GuiTooltipTexts() {
|
||||||
|
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -8,6 +8,7 @@ import javafx.application.Platform;
|
|||||||
import javafx.event.EventHandler;
|
import javafx.event.EventHandler;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
import javafx.stage.WindowEvent;
|
import javafx.stage.WindowEvent;
|
||||||
|
|
||||||
@@ -69,7 +70,16 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
// Wire the title-update listener so the stage title stays in sync with the dirty state.
|
// Wire the title-update listener so the stage title stays in sync with the dirty state.
|
||||||
workspace.titleUpdateListener = primaryStage::setTitle;
|
workspace.titleUpdateListener = primaryStage::setTitle;
|
||||||
|
|
||||||
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
// Statuszeile anlegen und mit dem Workspace verdrahten
|
||||||
|
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
|
||||||
|
workspace.statusBarStateListener = statusBar::applyEditorState;
|
||||||
|
|
||||||
|
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
||||||
|
BorderPane outerLayout = new BorderPane();
|
||||||
|
outerLayout.setCenter(workspace.root());
|
||||||
|
outerLayout.setBottom(statusBar.root());
|
||||||
|
|
||||||
|
Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||||
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
|
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
|
||||||
primaryStage.setScene(scene);
|
primaryStage.setScene(scene);
|
||||||
|
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
|
||||||
|
* die aggregierten Ergebnisse anzeigt.
|
||||||
|
*
|
||||||
|
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
|
||||||
|
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
|
||||||
|
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
|
||||||
|
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
|
||||||
|
* Enum {@link DocumentCompletionStatus} nicht enthalten – alle enthaltenen Werte
|
||||||
|
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
|
||||||
|
* abgeschlossenen Zustand darstellen.
|
||||||
|
*
|
||||||
|
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
|
||||||
|
* ein Icon und einen Text.
|
||||||
|
*
|
||||||
|
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
|
||||||
|
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
|
||||||
|
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
|
||||||
|
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
|
||||||
|
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public final class BatchRunSummaryBanner {
|
||||||
|
|
||||||
|
/** Trennzeichen zwischen den Kategoriesegmenten. */
|
||||||
|
private static final String SEGMENT_SEPARATOR = " · ";
|
||||||
|
|
||||||
|
/** Abstand zwischen den Label-Segmenten in Pixeln. */
|
||||||
|
private static final int SPACING = 0;
|
||||||
|
|
||||||
|
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
|
||||||
|
private static final double PADDING_V = 4.0;
|
||||||
|
|
||||||
|
/** Standardfarbe für den Summentext. */
|
||||||
|
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
|
||||||
|
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
|
||||||
|
*/
|
||||||
|
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Wurzel-Container des Banners – wird in das Tab-Layout eingebettet. */
|
||||||
|
private final HBox container;
|
||||||
|
|
||||||
|
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
|
||||||
|
private final Label contentLabel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein neues, initial unsichtbares Summary-Banner.
|
||||||
|
*/
|
||||||
|
public BatchRunSummaryBanner() {
|
||||||
|
contentLabel = new Label();
|
||||||
|
contentLabel.setStyle(STYLE_DEFAULT);
|
||||||
|
contentLabel.setWrapText(false);
|
||||||
|
|
||||||
|
container = new HBox(SPACING, contentLabel);
|
||||||
|
container.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
|
||||||
|
|
||||||
|
// Initial unsichtbar, nimmt keinen Platz ein
|
||||||
|
container.setVisible(false);
|
||||||
|
container.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versteckt das Banner und leert seinen Inhalt.
|
||||||
|
*
|
||||||
|
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
contentLabel.setText("");
|
||||||
|
container.setVisible(false);
|
||||||
|
container.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
|
||||||
|
*
|
||||||
|
* <p>Zeigt nur Kategorien mit Anzahl > 0. Wenn alle Zähler null sind (leerer Lauf),
|
||||||
|
* wird das Banner versteckt.
|
||||||
|
*
|
||||||
|
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param counts Zuordnung von Verarbeitungsstatus zu Anzahl;
|
||||||
|
* fehlende Status werden als 0 interpretiert; darf nicht null sein
|
||||||
|
*/
|
||||||
|
public void update(Map<DocumentCompletionStatus, Integer> counts) {
|
||||||
|
Objects.requireNonNull(counts, "counts darf nicht null sein");
|
||||||
|
|
||||||
|
String text = buildBannerText(counts);
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLabel.setText(text);
|
||||||
|
container.setVisible(true);
|
||||||
|
container.setManaged(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
|
||||||
|
*
|
||||||
|
* @return der Container-Knoten; nie null
|
||||||
|
*/
|
||||||
|
public HBox getNode() {
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
|
||||||
|
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
|
||||||
|
* keinen abgeschlossenen Verarbeitungszustand darstellen.
|
||||||
|
*
|
||||||
|
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
|
||||||
|
* Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
|
||||||
|
* null-Elemente werden übersprungen
|
||||||
|
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
|
||||||
|
* Status (fehlende haben Wert 0); nie null
|
||||||
|
*/
|
||||||
|
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
|
||||||
|
Iterable<? extends GuiBatchRunResultRow> rows) {
|
||||||
|
Objects.requireNonNull(rows, "rows darf nicht null sein");
|
||||||
|
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
|
||||||
|
// Alle anzuzeigenden Status mit 0 vorbelegen
|
||||||
|
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
|
||||||
|
counts.put(status, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (GuiBatchRunResultRow row : rows) {
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Reset-Pending-Zeilen zählen nicht – sie haben noch keinen abgeschlossenen Status
|
||||||
|
if (row.resetPending()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DocumentCompletionStatus status = row.status();
|
||||||
|
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
|
||||||
|
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
|
||||||
|
if (counts.containsKey(status)) {
|
||||||
|
counts.merge(status, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Interne Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den angezeigten Bannertext aus den Zählern.
|
||||||
|
* Liefert einen leeren String wenn alle Zähler null sind.
|
||||||
|
*
|
||||||
|
* @param counts die Zähler je Status; darf nicht null sein
|
||||||
|
* @return der fertige Bannertext oder ein leerer String
|
||||||
|
*/
|
||||||
|
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
|
||||||
|
List<String> segments = new ArrayList<>();
|
||||||
|
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
|
||||||
|
int count = counts.getOrDefault(status, 0);
|
||||||
|
if (count > 0) {
|
||||||
|
String icon = ProcessingStatusPresentation.iconFor(status);
|
||||||
|
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
|
||||||
|
segments.add(icon + " " + count + " " + category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String.join(SEGMENT_SEPARATOR, segments);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -6,17 +6,20 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
import javafx.scene.input.KeyCode;
|
import javafx.scene.input.KeyCode;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
|
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
|
||||||
@@ -86,9 +89,15 @@ public final class FileNameEditorPane {
|
|||||||
|
|
||||||
saveButton.setId("filename-editor-save-button");
|
saveButton.setId("filename-editor-save-button");
|
||||||
saveButton.setOnAction(e -> fireSaveRequest());
|
saveButton.setOnAction(e -> fireSaveRequest());
|
||||||
|
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
|
||||||
|
saveTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
saveButton.setTooltip(saveTooltip);
|
||||||
|
|
||||||
resetButton.setId("filename-editor-reset-button");
|
resetButton.setId("filename-editor-reset-button");
|
||||||
resetButton.setOnAction(e -> resetToAiProposal());
|
resetButton.setOnAction(e -> resetToAiProposal());
|
||||||
|
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
|
||||||
|
resetTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
resetButton.setTooltip(resetTooltip);
|
||||||
|
|
||||||
HBox buttonRow = new HBox(8, saveButton, resetButton);
|
HBox buttonRow = new HBox(8, saveButton, resetButton);
|
||||||
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|||||||
+28
-16
@@ -197,6 +197,8 @@ public record GuiBatchRunResultRow(
|
|||||||
* <p>
|
* <p>
|
||||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||||
* eigentlichen Status das Reset-Icon zurückgegeben.
|
* eigentlichen Status das Reset-Icon zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Die Icon-Werte stammen aus {@link ProcessingStatusPresentation}.
|
||||||
*
|
*
|
||||||
* @return das entsprechende Status-Zeichen
|
* @return das entsprechende Status-Zeichen
|
||||||
*/
|
*/
|
||||||
@@ -204,13 +206,7 @@ public record GuiBatchRunResultRow(
|
|||||||
if (resetPending) {
|
if (resetPending) {
|
||||||
return RESET_PENDING_ICON;
|
return RESET_PENDING_ICON;
|
||||||
}
|
}
|
||||||
return switch (status) {
|
return ProcessingStatusPresentation.iconFor(status);
|
||||||
case SUCCESS -> "✓"; // ✓ CHECK MARK
|
|
||||||
case FAILED_RETRYABLE -> "↻"; // ↻ CLOCKWISE OPEN CIRCLE ARROW
|
|
||||||
case FAILED_PERMANENT -> "×"; // × MULTIPLICATION SIGN
|
|
||||||
case SKIPPED_ALREADY_PROCESSED -> "≡"; // ≡ IDENTICAL TO
|
|
||||||
case SKIPPED_FINAL_FAILURE -> "⊘"; // ⊘ CIRCLED DIVISION SLASH
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,20 +214,36 @@ public record GuiBatchRunResultRow(
|
|||||||
* <p>
|
* <p>
|
||||||
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||||
* eigentlichen Status die Reset-Farbe zurückgegeben.
|
* eigentlichen Status die Reset-Farbe zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Farbe ist niemals das einzige Unterscheidungsmerkmal – {@link #statusIcon()} und
|
||||||
|
* {@link #statusTooltip()} beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
||||||
|
* Die Farbwerte stammen aus {@link ProcessingStatusPresentation}.
|
||||||
*
|
*
|
||||||
* @return die entsprechende CSS-Hex-Farbe (z.B. "#2e7d32")
|
* @return die entsprechende CSS-Hex-Farbe (z. B. {@code "#2e7d32"})
|
||||||
*/
|
*/
|
||||||
public String statusColor() {
|
public String statusColor() {
|
||||||
if (resetPending) {
|
if (resetPending) {
|
||||||
return "#757575"; // Grau für Reset-pending
|
return "#757575"; // Grau für Reset-pending
|
||||||
}
|
}
|
||||||
return switch (status) {
|
return ProcessingStatusPresentation.cssColorFor(status);
|
||||||
case SUCCESS -> "#2e7d32"; // Grün
|
}
|
||||||
case FAILED_RETRYABLE -> "#d98200"; // Orange
|
|
||||||
case FAILED_PERMANENT -> "#c62828"; // Rot
|
/**
|
||||||
case SKIPPED_ALREADY_PROCESSED -> "#1565c0"; // Blau-Grau
|
* Gibt den deutschsprachigen Tooltip-Text für den Verarbeitungsstatus dieser Zeile zurück.
|
||||||
case SKIPPED_FINAL_FAILURE -> "#757575"; // Grau
|
* <p>
|
||||||
};
|
* Wenn {@code resetPending} den Wert {@code true} hat, wird ein Tooltip für den
|
||||||
|
* Reset-Zustand zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Der Tooltip-Text beschreibt den Status vollständig ohne Farbe. Die Texte stammen
|
||||||
|
* aus {@link ProcessingStatusPresentation}.
|
||||||
|
*
|
||||||
|
* @return der Tooltip-Text; nie leer
|
||||||
|
*/
|
||||||
|
public String statusTooltip() {
|
||||||
|
if (resetPending) {
|
||||||
|
return RESET_PENDING_LABEL;
|
||||||
|
}
|
||||||
|
return ProcessingStatusPresentation.tooltipFor(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,7 +261,7 @@ public record GuiBatchRunResultRow(
|
|||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> "Erfolgreich";
|
case SUCCESS -> "Erfolgreich";
|
||||||
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
|
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
|
||||||
case FAILED_PERMANENT -> "Fehlgeschlagen (permanent)";
|
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
|
||||||
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
|
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
|
||||||
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
|
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
|
||||||
};
|
};
|
||||||
|
|||||||
+35
-13
@@ -67,6 +67,7 @@ import javafx.scene.control.TableColumn;
|
|||||||
import javafx.scene.control.TableRow;
|
import javafx.scene.control.TableRow;
|
||||||
import javafx.scene.control.TableView;
|
import javafx.scene.control.TableView;
|
||||||
import javafx.scene.control.TextArea;
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
@@ -198,6 +199,9 @@ public final class GuiBatchRunTab {
|
|||||||
/** PDF-Vorschau-Komponente im Detailbereich. */
|
/** PDF-Vorschau-Komponente im Detailbereich. */
|
||||||
private final PdfPreviewPane pdfPreview = new PdfPreviewPane();
|
private final PdfPreviewPane pdfPreview = new PdfPreviewPane();
|
||||||
|
|
||||||
|
/** Summary-Banner unterhalb des Fortschrittsbalkens – sichtbar nach Laufabschluss. */
|
||||||
|
private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner();
|
||||||
|
|
||||||
private final Supplier<Path> configPathSupplier;
|
private final Supplier<Path> configPathSupplier;
|
||||||
private final BooleanSupplier savedConfigurationReadyCheck;
|
private final BooleanSupplier savedConfigurationReadyCheck;
|
||||||
private final Runnable onRunStateChanged;
|
private final Runnable onRunStateChanged;
|
||||||
@@ -500,8 +504,14 @@ public final class GuiBatchRunTab {
|
|||||||
HBox.setHgrow(progressBar, Priority.ALWAYS);
|
HBox.setHgrow(progressBar, Priority.ALWAYS);
|
||||||
|
|
||||||
counterLabel.setId("batch-run-counter");
|
counterLabel.setId("batch-run-counter");
|
||||||
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
|
HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
|
||||||
header.setAlignment(Pos.CENTER_LEFT);
|
progressRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
// Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle
|
||||||
|
HBox bannerNode = summaryBanner.getNode();
|
||||||
|
bannerNode.setId("batch-run-summary-banner");
|
||||||
|
|
||||||
|
VBox header = new VBox(0, progressRow, bannerNode);
|
||||||
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
|
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
@@ -605,6 +615,7 @@ public final class GuiBatchRunTab {
|
|||||||
if (empty || icon == null) {
|
if (empty || icon == null) {
|
||||||
setText(null);
|
setText(null);
|
||||||
setStyle(null);
|
setStyle(null);
|
||||||
|
setTooltip(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setText(icon);
|
setText(icon);
|
||||||
@@ -612,9 +623,15 @@ public final class GuiBatchRunTab {
|
|||||||
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
|
GuiBatchRunResultRow data = tableRow != null ? tableRow.getItem() : null;
|
||||||
if (data != null && data.resetPending()) {
|
if (data != null && data.resetPending()) {
|
||||||
setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
|
setStyle("-fx-text-fill: #1565c0; -fx-alignment: CENTER; -fx-font-size: 14;");
|
||||||
} else {
|
setTooltip(new Tooltip(data.statusTooltip()));
|
||||||
String color = data != null ? statusColor(data.status()) : "#000000";
|
} else if (data != null) {
|
||||||
|
// Farbe aus zentralem Mapping – nie alleiniges Unterscheidungsmerkmal
|
||||||
|
String color = ProcessingStatusPresentation.cssColorFor(data.status());
|
||||||
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
|
setStyle("-fx-text-fill: " + color + "; -fx-alignment: CENTER; -fx-font-size: 14;");
|
||||||
|
setTooltip(new Tooltip(data.statusTooltip()));
|
||||||
|
} else {
|
||||||
|
setStyle("-fx-alignment: CENTER; -fx-font-size: 14;");
|
||||||
|
setTooltip(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1179,6 +1196,7 @@ public final class GuiBatchRunTab {
|
|||||||
messageArea.setVisible(false);
|
messageArea.setVisible(false);
|
||||||
messageArea.setManaged(false);
|
messageArea.setManaged(false);
|
||||||
messageArea.setStyle(null);
|
messageArea.setStyle(null);
|
||||||
|
summaryBanner.clear();
|
||||||
resetMetrics();
|
resetMetrics();
|
||||||
updateCounterLabel();
|
updateCounterLabel();
|
||||||
progressBar.setProgress(0);
|
progressBar.setProgress(0);
|
||||||
@@ -1419,15 +1437,7 @@ public final class GuiBatchRunTab {
|
|||||||
// Statische Helfer
|
// Statische Helfer
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private static String statusColor(DocumentCompletionStatus status) {
|
// statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.
|
||||||
return switch (status) {
|
|
||||||
case SUCCESS -> "#2e7d32";
|
|
||||||
case FAILED_RETRYABLE -> "#e65100";
|
|
||||||
case FAILED_PERMANENT -> "#c62828";
|
|
||||||
case SKIPPED_ALREADY_PROCESSED -> "#1565c0";
|
|
||||||
case SKIPPED_FINAL_FAILURE -> "#757575";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatDuration(Duration duration) {
|
private static String formatDuration(Duration duration) {
|
||||||
double seconds = duration.toMillis() / 1000.0;
|
double seconds = duration.toMillis() / 1000.0;
|
||||||
@@ -1475,6 +1485,14 @@ public final class GuiBatchRunTab {
|
|||||||
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
|
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
|
||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
|
||||||
|
// Erweiterter Erkl\u00e4rungstext gem\u00e4\u00df Spezifikation #51 \u2013 dauerhaft fehlgeschlagen
|
||||||
|
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
|
||||||
|
row.aiFailureMessage().ifPresent(msg ->
|
||||||
|
builder.append("\n\nFehlerdetail: ")
|
||||||
|
.append(AiFailureMessageTranslator.translate(msg)));
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
row.effectiveFileName()
|
row.effectiveFileName()
|
||||||
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
|
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
|
||||||
row.resolvedDate()
|
row.resolvedDate()
|
||||||
@@ -1572,6 +1590,10 @@ public final class GuiBatchRunTab {
|
|||||||
miniRunCompletedFingerprints = new HashSet<>();
|
miniRunCompletedFingerprints = new HashSet<>();
|
||||||
}
|
}
|
||||||
selectedRows.clear();
|
selectedRows.clear();
|
||||||
|
// Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts =
|
||||||
|
BatchRunSummaryBanner.aggregateCounts(resultItems);
|
||||||
|
summaryBanner.update(counts);
|
||||||
appendSummary(outcome);
|
appendSummary(outcome);
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
notifyRunStateChanged();
|
notifyRunStateChanged();
|
||||||
|
|||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist die einzige autoritative Quelle für Status-Icons, CSS-Farben,
|
||||||
|
* Tooltip-Texte und Summary-Kategorielabels aller {@link DocumentCompletionStatus}-Werte.
|
||||||
|
* Alle Anzeigeorte im GUI-Adapter (Ergebnistabelle, Detailbereich, Summary-Banner)
|
||||||
|
* beziehen ihre Darstellungsinformationen ausschließlich über diese Klasse.
|
||||||
|
* <p>
|
||||||
|
* Farbe ist niemals das einzige Unterscheidungsmerkmal: Icon und Tooltip-Text beschreiben
|
||||||
|
* den Status vollständig auch ohne Farbwahrnehmung.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse enthält keine JavaFX-Typen; sie ist rein datenhaltend und zustandslos.
|
||||||
|
* Alle Methoden sind statisch.
|
||||||
|
*/
|
||||||
|
public final class ProcessingStatusPresentation {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String ICON_SUCCESS = "✓"; // CHECK MARK
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String ICON_FAILED_RETRYABLE = "↻"; // CLOCKWISE OPEN CIRCLE ARROW
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String ICON_FAILED_PERMANENT = "×"; // MULTIPLICATION SIGN
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String ICON_SKIPPED_ALREADY_PROCESSED = "≡"; // IDENTICAL TO
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String ICON_SKIPPED_FINAL_FAILURE = "⊘"; // CIRCLED DIVISION SLASH
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CSS-Farben (Hex-Strings für JavaFX setStyle)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String COLOR_SUCCESS = "#2e7d32"; // Grün
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String COLOR_FAILED_RETRYABLE = "#d98200"; // Orange
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String COLOR_FAILED_PERMANENT = "#c62828"; // Rot
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String COLOR_SKIPPED_ALREADY_PROCESSED = "#757575"; // Grau
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String COLOR_SKIPPED_FINAL_FAILURE = "#424242"; // Dunkelgrau
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tooltip-Texte (deutsche Benutzertexte, gemäß Spezifikation)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String TOOLTIP_SUCCESS =
|
||||||
|
"Erfolgreich verarbeitet und umbenannt.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String TOOLTIP_FAILED_RETRYABLE =
|
||||||
|
"Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String TOOLTIP_FAILED_PERMANENT =
|
||||||
|
"Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), Passwortschutz "
|
||||||
|
+ "oder beschädigte Datei. Kein weiterer automatischer Versuch.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String TOOLTIP_SKIPPED_ALREADY_PROCESSED =
|
||||||
|
"Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String TOOLTIP_SKIPPED_FINAL_FAILURE =
|
||||||
|
"Endgültig übersprungen nach wiederholten Fehlern.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Detailtext für FAILED_PERMANENT (Erklärung im Detailbereich)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erweiterter Erklärungstext, der im Detailbereich bei dauerhaft fehlgeschlagenen
|
||||||
|
* Dokumenten angezeigt wird.
|
||||||
|
*/
|
||||||
|
public static final String DETAIL_TEXT_FAILED_PERMANENT =
|
||||||
|
"Diese Datei kann nicht verarbeitet werden. Mögliche Ursachen: "
|
||||||
|
+ "kein lesbarer Text (z. B. gescanntes Foto ohne OCR), Passwortschutz "
|
||||||
|
+ "oder beschädigte Datei. "
|
||||||
|
+ "Sie können den Status manuell zurücksetzen, wenn Sie die Ursache behoben haben.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Summary-Kategorielabels
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_SUCCESS = "erfolgreich";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_FAILED_RETRYABLE = "wird wiederholt";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_FAILED_PERMANENT = "fehlgeschlagen";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED = "übersprungen";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE = "endgültig übersprungen";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Record-Typ für gebündelte Darstellungsinformationen
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gebündelte visuelle Darstellungsinformationen für einen Verarbeitungsstatus.
|
||||||
|
*
|
||||||
|
* @param icon Unicode-Zeichen als Status-Icon; nie leer
|
||||||
|
* @param cssColor CSS-Hex-Farbe für das Icon, z. B. {@code "#2e7d32"}; nie leer
|
||||||
|
* @param tooltipText Deutschsprachiger Tooltip-Text; nie leer
|
||||||
|
* @param summaryCategoryLabel Kategorie-Bezeichnung für das Summary-Banner; nie leer
|
||||||
|
*/
|
||||||
|
public record StatusVisuals(
|
||||||
|
String icon,
|
||||||
|
String cssColor,
|
||||||
|
String tooltipText,
|
||||||
|
String summaryCategoryLabel) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Konstruktor zur Pflichtfeld-Validierung.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException wenn ein Feld {@code null} ist
|
||||||
|
* @throws IllegalArgumentException wenn ein String-Feld leer ist
|
||||||
|
*/
|
||||||
|
public StatusVisuals {
|
||||||
|
Objects.requireNonNull(icon, "icon muss gesetzt sein");
|
||||||
|
Objects.requireNonNull(cssColor, "cssColor muss gesetzt sein");
|
||||||
|
Objects.requireNonNull(tooltipText, "tooltipText muss gesetzt sein");
|
||||||
|
Objects.requireNonNull(summaryCategoryLabel, "summaryCategoryLabel muss gesetzt sein");
|
||||||
|
if (icon.isBlank()) throw new IllegalArgumentException("icon darf nicht leer sein");
|
||||||
|
if (cssColor.isBlank()) throw new IllegalArgumentException("cssColor darf nicht leer sein");
|
||||||
|
if (tooltipText.isBlank()) throw new IllegalArgumentException("tooltipText darf nicht leer sein");
|
||||||
|
if (summaryCategoryLabel.isBlank())
|
||||||
|
throw new IllegalArgumentException("summaryCategoryLabel darf nicht leer sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Zentrale Mapping-Methoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das Status-Icon für den angegebenen Verarbeitungsstatus.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return das zugehörige Unicode-Zeichen; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String iconFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, "status darf nicht null sein");
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> ICON_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> ICON_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> ICON_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> ICON_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die CSS-Hex-Farbe für das Status-Icon des angegebenen Verarbeitungsstatus.
|
||||||
|
* <p>
|
||||||
|
* Die Farbe ist nie das einzige Unterscheidungsmerkmal – Icon und Tooltip-Text
|
||||||
|
* beschreiben den Status unabhängig von der Farbe eindeutig.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return die CSS-Hex-Farbe (z. B. {@code "#2e7d32"}); nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String cssColorFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, "status darf nicht null sein");
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> COLOR_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> COLOR_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> COLOR_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> COLOR_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den deutschsprachigen Tooltip-Text für den angegebenen Verarbeitungsstatus.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return der Tooltip-Text; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String tooltipFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, "status darf nicht null sein");
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> TOOLTIP_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> TOOLTIP_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> TOOLTIP_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> TOOLTIP_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Summary-Kategorie-Bezeichnung für den angegebenen Verarbeitungsstatus.
|
||||||
|
* Diese Kategorie wird im Summary-Banner nach einem Lauf angezeigt.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return die Kategorienbezeichnung; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String summaryCategoryFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, "status darf nicht null sein");
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> SUMMARY_CATEGORY_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle gebündelten visuellen Darstellungsinformationen für den angegebenen
|
||||||
|
* Verarbeitungsstatus in einem einzigen Objekt.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return ein befülltes {@link StatusVisuals}-Record; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, "status darf nicht null sein");
|
||||||
|
return new StatusVisuals(
|
||||||
|
iconFor(status),
|
||||||
|
cssColorFor(status),
|
||||||
|
tooltipFor(status),
|
||||||
|
summaryCategoryFor(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nicht instanziierbar – reine Utility-Klasse. */
|
||||||
|
private ProcessingStatusPresentation() {
|
||||||
|
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -8,7 +8,9 @@ import javafx.geometry.Pos;
|
|||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.ComboBox;
|
import javafx.scene.control.ComboBox;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
import javafx.scene.layout.StackPane;
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
||||||
@@ -169,6 +171,26 @@ public final class GuiModelFieldContainer extends StackPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf beide internen Controls
|
||||||
|
* (ComboBox und TextField). Damit erscheint der Tooltip unabhängig davon, welches der
|
||||||
|
* beiden Controls gerade sichtbar ist.
|
||||||
|
* <p>
|
||||||
|
* Darf nur auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param tooltipText der anzuzeigende Tooltip-Text; darf nicht leer sein
|
||||||
|
*/
|
||||||
|
public void applyTooltip(String tooltipText) {
|
||||||
|
Objects.requireNonNull(tooltipText, "tooltipText darf nicht null sein");
|
||||||
|
Tooltip comboTooltip = new Tooltip(tooltipText);
|
||||||
|
comboTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
comboBox.setTooltip(comboTooltip);
|
||||||
|
|
||||||
|
Tooltip textTooltip = new Tooltip(tooltipText);
|
||||||
|
textTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
textField.setTooltip(textTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JavaFX node that represents this container and can be added to the scene graph.
|
* Returns the JavaFX node that represents this container and can be added to the scene graph.
|
||||||
*
|
*
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
|
||||||
|
* vollständig und transaktional. Die Löschung ist destruktiv und nicht
|
||||||
|
* rückgängig zu machen.
|
||||||
|
* <p>
|
||||||
|
* Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
|
||||||
|
* explizitem Warnhinweis anzeigen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis die Löschung abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiDeleteDocumentHistoryPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
|
||||||
|
* zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
|
||||||
|
* Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
|
||||||
|
* für einen ausgewählten Dokumenteintrag bereitstellt.
|
||||||
|
* <p>
|
||||||
|
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||||
|
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryDetailsPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
Optional<HistoryDetailsResult> loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
|
||||||
|
* für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
|
||||||
|
* Repository-Implementierungen zugreift.
|
||||||
|
* <p>
|
||||||
|
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||||
|
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
|
||||||
|
* ohne den Pfad global zu speichern.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryOverviewPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
|
||||||
|
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
|
||||||
|
* {@code content_error_count}, {@code transient_error_count} und
|
||||||
|
* {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
|
||||||
|
* vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
|
||||||
|
* Verarbeitungslauf als verarbeitbar.
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:</strong>
|
||||||
|
* Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
|
||||||
|
* (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
|
||||||
|
* einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis die Operation abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryResetDocumentStatusPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Status des Dokuments feldgenau zurück.
|
||||||
|
* <p>
|
||||||
|
* Folgende Felder werden aktualisiert:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||||
|
* <li>{@code content_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void resetStatus(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+794
@@ -0,0 +1,794 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.SelectionMode;
|
||||||
|
import javafx.scene.control.SplitPane;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TableCell;
|
||||||
|
import javafx.scene.control.TableColumn;
|
||||||
|
import javafx.scene.control.TableView;
|
||||||
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dritter Haupt-Tab des JavaFX-Editorfensters: der Historien-Tab „Verlauf".
|
||||||
|
* <p>
|
||||||
|
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
||||||
|
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
|
||||||
|
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
|
||||||
|
*
|
||||||
|
* <h2>Layout</h2>
|
||||||
|
* <pre>
|
||||||
|
* ┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
* │ [ Suchfeld ] [ Status ▾ ] [ Aktualisieren ] │
|
||||||
|
* ├────────────────────────┬────────────────────────────────────────┤
|
||||||
|
* │ Dokumentenliste (~55%) │ Detailbereich (~45%) │
|
||||||
|
* │ │ Dokument-Info │
|
||||||
|
* │ │ Versuche-Tabelle │
|
||||||
|
* │ │ KI-Begründung │
|
||||||
|
* ├────────────────────────┴────────────────────────────────────────┤
|
||||||
|
* │ [ Status zurücksetzen ] [ Eintrag löschen ] Statuszeile │
|
||||||
|
* └─────────────────────────────────────────────────────────────────┘
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>Alle DB-Zugriffe laufen auf einem Hintergrund-Worker-Thread.
|
||||||
|
* UI-Updates erfolgen ausschließlich via {@code Platform.runLater()}.
|
||||||
|
* Destruktive Aktionen (Reset, Löschen) sind während eines aktiven
|
||||||
|
* Verarbeitungslaufs deaktiviert.
|
||||||
|
*/
|
||||||
|
public final class GuiHistoryTab {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Verlauf";
|
||||||
|
private static final String EMPTY_DB_TEXT = "Noch keine Verarbeitungen vorhanden.";
|
||||||
|
private static final String TOO_MANY_RESULTS_TEXT =
|
||||||
|
"Weitere Einträge vorhanden – Filter verwenden um die Trefferliste einzuschränken.";
|
||||||
|
private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
|
||||||
|
private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
|
||||||
|
private static final String LOADING_TEXT = "Wird geladen …";
|
||||||
|
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FMT =
|
||||||
|
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
|
// ---- Bridge-Ports ---------------------------------------------------
|
||||||
|
private final GuiHistoryOverviewPort overviewPort;
|
||||||
|
private final GuiHistoryDetailsPort detailsPort;
|
||||||
|
private final GuiHistoryResetDocumentStatusPort resetPort;
|
||||||
|
private final GuiDeleteDocumentHistoryPort deletePort;
|
||||||
|
private final BooleanSupplier runningCheck;
|
||||||
|
/** Liefert den Pfad zur aktuell geladenen Konfigurationsdatei, oder {@code null} wenn keine geladen. */
|
||||||
|
private final Supplier<Path> configPathSupplier;
|
||||||
|
|
||||||
|
// ---- JavaFX-Knoten --------------------------------------------------
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
|
||||||
|
private final TextField searchField = new TextField();
|
||||||
|
private final ComboBox<String> statusFilterBox = new ComboBox<>();
|
||||||
|
private final Button refreshButton = new Button("Aktualisieren");
|
||||||
|
|
||||||
|
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
|
||||||
|
private final ObservableList<DocumentHistoryRow> overviewItems = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
private final Label statusBarLabel = new Label();
|
||||||
|
private final Label moreThanMaxLabel = new Label();
|
||||||
|
|
||||||
|
// Detailbereich
|
||||||
|
private final GridPane detailGrid = new GridPane();
|
||||||
|
private final Label detailFingerprintLabel = new Label();
|
||||||
|
private final Label detailSourceFileLabel = new Label();
|
||||||
|
private final Label detailSourcePathLabel = new Label();
|
||||||
|
private final Label detailStatusLabel = new Label();
|
||||||
|
private final Label detailCreatedLabel = new Label();
|
||||||
|
private final Label detailUpdatedLabel = new Label();
|
||||||
|
|
||||||
|
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
|
||||||
|
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
||||||
|
private final TextArea reasoningArea = new TextArea();
|
||||||
|
|
||||||
|
private final Button resetButton = new Button("Status zurücksetzen");
|
||||||
|
private final Button deleteButton = new Button("Eintrag löschen");
|
||||||
|
|
||||||
|
// ---- Zustand --------------------------------------------------------
|
||||||
|
private final ExecutorService workerPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Historien-Tab.
|
||||||
|
*
|
||||||
|
* @param overviewPort Brücke zur Dokumentenübersicht; darf nicht {@code null} sein
|
||||||
|
* @param detailsPort Brücke zur Detailansicht; darf nicht {@code null} sein
|
||||||
|
* @param resetPort Brücke zum feldgenauen Status-Reset; darf nicht {@code null} sein
|
||||||
|
* @param deletePort Brücke zum vollständigen Löschen; darf nicht {@code null} sein
|
||||||
|
* @param runningCheck Liefert {@code true} wenn gerade ein Verarbeitungslauf aktiv ist;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param configPathSupplier Liefert den Pfad zur aktuell geladenen Konfigurationsdatei,
|
||||||
|
* oder {@code null} wenn keine geladen ist; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public GuiHistoryTab(
|
||||||
|
GuiHistoryOverviewPort overviewPort,
|
||||||
|
GuiHistoryDetailsPort detailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort resetPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deletePort,
|
||||||
|
BooleanSupplier runningCheck,
|
||||||
|
Supplier<Path> configPathSupplier) {
|
||||||
|
this.overviewPort = Objects.requireNonNull(overviewPort, "overviewPort darf nicht null sein");
|
||||||
|
this.detailsPort = Objects.requireNonNull(detailsPort, "detailsPort darf nicht null sein");
|
||||||
|
this.resetPort = Objects.requireNonNull(resetPort, "resetPort darf nicht null sein");
|
||||||
|
this.deletePort = Objects.requireNonNull(deletePort, "deletePort darf nicht null sein");
|
||||||
|
this.runningCheck = Objects.requireNonNull(runningCheck, "runningCheck darf nicht null sein");
|
||||||
|
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier darf nicht null sein");
|
||||||
|
|
||||||
|
this.workerPool = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "HistoryTabWorker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
buildUi();
|
||||||
|
wireEvents();
|
||||||
|
tab.setClosable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den JavaFX-{@link Tab}, der in die TabPane eingefügt werden kann.
|
||||||
|
*
|
||||||
|
* @return der Tab; nie {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Dokumentenübersicht neu – muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
* Wird vom Tab-Wechsel-Listener ausgelöst.
|
||||||
|
*/
|
||||||
|
public void refresh() {
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// UI-Aufbau
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void buildUi() {
|
||||||
|
// --- Toolbar ---
|
||||||
|
searchField.setPromptText("Suche nach Dateiname …");
|
||||||
|
searchField.setPrefWidth(300);
|
||||||
|
Tooltip.install(searchField, new Tooltip(
|
||||||
|
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
|
||||||
|
|
||||||
|
statusFilterBox.getItems().add("Alle Status");
|
||||||
|
for (ProcessingStatus s : ProcessingStatus.values()) {
|
||||||
|
statusFilterBox.getItems().add(s.name());
|
||||||
|
}
|
||||||
|
statusFilterBox.getSelectionModel().selectFirst();
|
||||||
|
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
|
||||||
|
|
||||||
|
refreshButton.setTooltip(new Tooltip("Dokumentenliste neu aus der Datenbank laden."));
|
||||||
|
|
||||||
|
Region spacer = new Region();
|
||||||
|
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox toolbar = new HBox(8, searchField, statusFilterBox, spacer, refreshButton);
|
||||||
|
toolbar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
toolbar.setPadding(new Insets(6, 8, 6, 8));
|
||||||
|
|
||||||
|
// --- Dokumentenliste (links) ---
|
||||||
|
buildOverviewTable();
|
||||||
|
|
||||||
|
moreThanMaxLabel.setStyle("-fx-text-fill: #d98200; -fx-font-style: italic;");
|
||||||
|
moreThanMaxLabel.setVisible(false);
|
||||||
|
moreThanMaxLabel.setManaged(false);
|
||||||
|
|
||||||
|
VBox leftPane = new VBox(4, overviewTable, moreThanMaxLabel);
|
||||||
|
VBox.setVgrow(overviewTable, Priority.ALWAYS);
|
||||||
|
leftPane.setPadding(new Insets(0, 4, 0, 0));
|
||||||
|
|
||||||
|
// --- Detailbereich (rechts) ---
|
||||||
|
VBox rightPane = buildDetailPane();
|
||||||
|
|
||||||
|
// --- SplitPane ---
|
||||||
|
SplitPane splitPane = new SplitPane(leftPane, rightPane);
|
||||||
|
splitPane.setDividerPositions(0.55);
|
||||||
|
|
||||||
|
// --- Aktionsleiste unten ---
|
||||||
|
resetButton.setTooltip(new Tooltip(
|
||||||
|
"Setzt Status, Fehlerzähler und letzten Fehlerzeitpunkt zurück. "
|
||||||
|
+ "Versuche bleiben erhalten. Das Dokument wird beim nächsten Lauf erneut verarbeitet."));
|
||||||
|
deleteButton.setTooltip(new Tooltip(
|
||||||
|
"Löscht den Eintrag und alle Versuche vollständig. "
|
||||||
|
+ "Diese Aktion ist nicht rückgängig zu machen."));
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
|
||||||
|
statusBarLabel.setStyle("-fx-text-fill: #555555; -fx-font-style: italic;");
|
||||||
|
|
||||||
|
HBox actionBar = new HBox(8, resetButton, deleteButton, spacerNew(), statusBarLabel);
|
||||||
|
actionBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
actionBar.setPadding(new Insets(6, 8, 6, 8));
|
||||||
|
|
||||||
|
// --- Gesamtlayout ---
|
||||||
|
BorderPane content = new BorderPane();
|
||||||
|
content.setTop(toolbar);
|
||||||
|
content.setCenter(splitPane);
|
||||||
|
content.setBottom(actionBar);
|
||||||
|
BorderPane.setMargin(toolbar, Insets.EMPTY);
|
||||||
|
|
||||||
|
tab.setContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildOverviewTable() {
|
||||||
|
overviewTable.setItems(overviewItems);
|
||||||
|
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||||
|
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||||
|
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||||
|
|
||||||
|
// Status-Icon-Spalte
|
||||||
|
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status");
|
||||||
|
statusCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
|
||||||
|
statusCol.setCellFactory(col -> new TableCell<>() {
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String icon, boolean empty) {
|
||||||
|
super.updateItem(icon, empty);
|
||||||
|
if (empty || icon == null) {
|
||||||
|
setText(null);
|
||||||
|
setTooltip(null);
|
||||||
|
} else {
|
||||||
|
setText(icon);
|
||||||
|
DocumentHistoryRow row = getTableView().getItems().get(getIndex());
|
||||||
|
setStyle("-fx-text-fill: " + statusColor(row.overallStatus()) + "; -fx-font-weight: bold;");
|
||||||
|
setTooltip(new Tooltip(statusTooltip(row.overallStatus())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
statusCol.setPrefWidth(60);
|
||||||
|
statusCol.setMaxWidth(70);
|
||||||
|
|
||||||
|
// Quelldateiname
|
||||||
|
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei");
|
||||||
|
sourceCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(cell.getValue().sourceFileName()));
|
||||||
|
sourceCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
// Zieldateiname
|
||||||
|
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei");
|
||||||
|
targetCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—"));
|
||||||
|
targetCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
// Letzter Versuch
|
||||||
|
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch");
|
||||||
|
updatedCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
|
||||||
|
updatedCol.setPrefWidth(140);
|
||||||
|
updatedCol.setMaxWidth(160);
|
||||||
|
|
||||||
|
// Anzahl Versuche
|
||||||
|
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche");
|
||||||
|
countCol.setCellValueFactory(cell ->
|
||||||
|
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
|
||||||
|
countCol.setPrefWidth(70);
|
||||||
|
countCol.setMaxWidth(80);
|
||||||
|
|
||||||
|
overviewTable.getColumns().setAll(statusCol, sourceCol, targetCol, updatedCol, countCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VBox buildDetailPane() {
|
||||||
|
// Dokument-Info
|
||||||
|
detailGrid.setHgap(8);
|
||||||
|
detailGrid.setVgap(4);
|
||||||
|
detailGrid.setPadding(new Insets(8));
|
||||||
|
|
||||||
|
addDetailRow(0, "Fingerprint:", detailFingerprintLabel);
|
||||||
|
addDetailRow(1, "Quelldatei:", detailSourceFileLabel);
|
||||||
|
addDetailRow(2, "Quellpfad:", detailSourcePathLabel);
|
||||||
|
addDetailRow(3, "Status:", detailStatusLabel);
|
||||||
|
addDetailRow(4, "Erstellt:", detailCreatedLabel);
|
||||||
|
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
|
||||||
|
|
||||||
|
Label detailTitle = new Label("Dokument-Details");
|
||||||
|
detailTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
// Versuche-Tabelle
|
||||||
|
buildAttemptsTable();
|
||||||
|
Label attemptsTitle = new Label("Verarbeitungsversuche");
|
||||||
|
attemptsTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
// KI-Begründung
|
||||||
|
reasoningArea.setEditable(false);
|
||||||
|
reasoningArea.setWrapText(true);
|
||||||
|
reasoningArea.setPrefRowCount(4);
|
||||||
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
|
||||||
|
reasoningTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
VBox rightPane = new VBox(8,
|
||||||
|
detailTitle, detailGrid,
|
||||||
|
attemptsTitle, attemptsTable,
|
||||||
|
reasoningTitle, reasoningArea);
|
||||||
|
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
||||||
|
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
||||||
|
|
||||||
|
ScrollPane scroll = new ScrollPane(rightPane);
|
||||||
|
scroll.setFitToWidth(true);
|
||||||
|
scroll.setFitToHeight(true);
|
||||||
|
|
||||||
|
VBox wrapper = new VBox(scroll);
|
||||||
|
VBox.setVgrow(scroll, Priority.ALWAYS);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildAttemptsTable() {
|
||||||
|
attemptsTable.setItems(attemptsItems);
|
||||||
|
attemptsTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||||
|
attemptsTable.setPlaceholder(new Label("Keine Versuche vorhanden."));
|
||||||
|
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||||
|
attemptsTable.setPrefHeight(150);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#");
|
||||||
|
numCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
|
||||||
|
numCol.setPrefWidth(40);
|
||||||
|
numCol.setMaxWidth(50);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum");
|
||||||
|
dateCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
|
||||||
|
dateCol.setPrefWidth(130);
|
||||||
|
dateCol.setMaxWidth(150);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status");
|
||||||
|
statusCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
|
||||||
|
statusCol.setPrefWidth(140);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider");
|
||||||
|
providerCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—"));
|
||||||
|
providerCol.setPrefWidth(90);
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell");
|
||||||
|
modelCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
c.getValue().modelName() != null ? c.getValue().modelName() : "—"));
|
||||||
|
modelCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name");
|
||||||
|
fileNameCol.setCellValueFactory(c ->
|
||||||
|
new SimpleStringProperty(
|
||||||
|
c.getValue().finalTargetFileName() != null
|
||||||
|
? c.getValue().finalTargetFileName() : "—"));
|
||||||
|
fileNameCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
|
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Event-Verdrahtung
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void wireEvents() {
|
||||||
|
refreshButton.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
|
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
|
||||||
|
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
|
||||||
|
searchField.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
|
statusFilterBox.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
|
// Detailbereich bei Zeilenselektion
|
||||||
|
overviewTable.getSelectionModel().selectedItemProperty().addListener(
|
||||||
|
(obs, old, selected) -> {
|
||||||
|
if (selected == null) {
|
||||||
|
clearDetailPane();
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
} else {
|
||||||
|
resetButton.setDisable(runningCheck.getAsBoolean());
|
||||||
|
deleteButton.setDisable(runningCheck.getAsBoolean());
|
||||||
|
loadDetails(selected.fingerprint());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resetButton.setOnAction(e -> handleResetAction());
|
||||||
|
deleteButton.setOnAction(e -> handleDeleteAction());
|
||||||
|
|
||||||
|
// Tab soll beim ersten Betreten automatisch laden
|
||||||
|
tab.selectedProperty().addListener((obs, oldVal, selected) -> {
|
||||||
|
if (Boolean.TRUE.equals(selected)) {
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Daten laden (Worker-Thread)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void loadOverview() {
|
||||||
|
statusBarLabel.setText(LOADING_TEXT);
|
||||||
|
overviewItems.clear();
|
||||||
|
moreThanMaxLabel.setVisible(false);
|
||||||
|
moreThanMaxLabel.setManaged(false);
|
||||||
|
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
statusBarLabel.setText("Keine Konfiguration geladen – bitte zuerst eine Konfigurationsdatei öffnen.");
|
||||||
|
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String searchText = searchField.getText();
|
||||||
|
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
|
||||||
|
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
|
||||||
|
? null : selectedStatus;
|
||||||
|
|
||||||
|
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
HistoryOverviewResult result = overviewPort.loadOverview(configPath, query);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
overviewItems.setAll(result.rows());
|
||||||
|
if (result.hasMore()) {
|
||||||
|
moreThanMaxLabel.setText(TOO_MANY_RESULTS_TEXT);
|
||||||
|
moreThanMaxLabel.setVisible(true);
|
||||||
|
moreThanMaxLabel.setManaged(true);
|
||||||
|
} else {
|
||||||
|
moreThanMaxLabel.setVisible(false);
|
||||||
|
moreThanMaxLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
if (result.rows().isEmpty()) {
|
||||||
|
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||||
|
statusBarLabel.setText("Keine Einträge gefunden.");
|
||||||
|
} else {
|
||||||
|
statusBarLabel.setText(result.rows().size() + " Einträge geladen.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Fehler beim Laden der Historienübersicht: {}", ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() ->
|
||||||
|
statusBarLabel.setText("Fehler beim Laden: " + ex.getMessage()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadDetails(DocumentFingerprint fingerprint) {
|
||||||
|
reasoningArea.setText(LOADING_TEXT);
|
||||||
|
attemptsItems.clear();
|
||||||
|
clearDetailFields();
|
||||||
|
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
clearDetailPane();
|
||||||
|
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
|
||||||
|
} else {
|
||||||
|
populateDetailPane(result.get());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
|
||||||
|
fingerprint.sha256Hex(), ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
|
||||||
|
statusBarLabel.setText("Fehler beim Laden der Details.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Aktionen
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void handleResetAction() {
|
||||||
|
if (runningCheck.getAsBoolean()) {
|
||||||
|
showInfo(LAUF_AKTIV_HINWEIS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
||||||
|
if (selected == null) return;
|
||||||
|
|
||||||
|
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
confirm.setTitle("Status zurücksetzen");
|
||||||
|
confirm.setHeaderText("Status zurücksetzen?");
|
||||||
|
confirm.setContentText(
|
||||||
|
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
|
||||||
|
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
|
||||||
|
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
|
||||||
|
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
|
||||||
|
+ "Quelldatei: " + selected.sourceFileName());
|
||||||
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||||
|
|
||||||
|
DocumentFingerprint fp = selected.fingerprint();
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showInfo("Keine Konfiguration geladen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
statusBarLabel.setText("Status wird zurückgesetzt …");
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
resetPort.resetStatus(configPath, fp);
|
||||||
|
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
|
||||||
|
loadOverview();
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDeleteAction() {
|
||||||
|
if (runningCheck.getAsBoolean()) {
|
||||||
|
showInfo(LAUF_AKTIV_HINWEIS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
||||||
|
if (selected == null) return;
|
||||||
|
|
||||||
|
Alert confirm = new Alert(Alert.AlertType.WARNING);
|
||||||
|
confirm.setTitle("Eintrag löschen");
|
||||||
|
confirm.setHeaderText("Eintrag vollständig löschen?");
|
||||||
|
confirm.setContentText(
|
||||||
|
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
|
||||||
|
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
|
||||||
|
+ "Quelldatei: " + selected.sourceFileName());
|
||||||
|
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||||
|
|
||||||
|
DocumentFingerprint fp = selected.fingerprint();
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showInfo("Keine Konfiguration geladen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
statusBarLabel.setText("Eintrag wird gelöscht …");
|
||||||
|
|
||||||
|
workerPool.submit(() -> {
|
||||||
|
try {
|
||||||
|
deletePort.deleteHistory(configPath, fp);
|
||||||
|
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
|
||||||
|
clearDetailPane();
|
||||||
|
loadOverview();
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Detail-Bereich befüllen / leeren
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void populateDetailPane(HistoryDetailsResult result) {
|
||||||
|
DocumentRecord record = result.record();
|
||||||
|
String fpFull = record.fingerprint().sha256Hex();
|
||||||
|
detailFingerprintLabel.setText(fpFull.substring(0, Math.min(12, fpFull.length())) + " …");
|
||||||
|
detailFingerprintLabel.setTooltip(new Tooltip(fpFull));
|
||||||
|
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
|
||||||
|
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
|
||||||
|
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
|
||||||
|
String icon = statusIcon(record.overallStatus());
|
||||||
|
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
|
||||||
|
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
|
||||||
|
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
|
||||||
|
detailCreatedLabel.setText(formatInstant(record.createdAt()));
|
||||||
|
detailUpdatedLabel.setText(formatInstant(record.updatedAt()));
|
||||||
|
|
||||||
|
attemptsItems.setAll(result.attempts());
|
||||||
|
|
||||||
|
// Neuesten Versuch selektieren und Begründung anzeigen
|
||||||
|
if (!result.attempts().isEmpty()) {
|
||||||
|
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
|
||||||
|
attemptsTable.getSelectionModel().select(last);
|
||||||
|
showReasoning(last);
|
||||||
|
} else {
|
||||||
|
reasoningArea.setText(NO_REASONING_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KI-Begründung bei Versuchs-Selektion aktualisieren
|
||||||
|
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
|
||||||
|
(obs, old, attempt) -> {
|
||||||
|
if (attempt != null) {
|
||||||
|
showReasoning(attempt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showReasoning(ProcessingAttempt attempt) {
|
||||||
|
String reasoning = attempt.aiReasoning();
|
||||||
|
reasoningArea.setText(reasoning != null && !reasoning.isBlank()
|
||||||
|
? reasoning : NO_REASONING_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearDetailPane() {
|
||||||
|
clearDetailFields();
|
||||||
|
attemptsItems.clear();
|
||||||
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearDetailFields() {
|
||||||
|
detailFingerprintLabel.setText("");
|
||||||
|
detailFingerprintLabel.setTooltip(null);
|
||||||
|
detailSourceFileLabel.setText("");
|
||||||
|
detailSourcePathLabel.setText("");
|
||||||
|
detailSourcePathLabel.setTooltip(null);
|
||||||
|
detailStatusLabel.setText("");
|
||||||
|
detailStatusLabel.setStyle("");
|
||||||
|
detailStatusLabel.setTooltip(null);
|
||||||
|
detailCreatedLabel.setText("");
|
||||||
|
detailUpdatedLabel.setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hilfsmethoden
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void addDetailRow(int row, String labelText, Label valueLabel) {
|
||||||
|
Label label = new Label(labelText);
|
||||||
|
label.setStyle("-fx-font-weight: bold;");
|
||||||
|
valueLabel.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
|
||||||
|
detailGrid.add(label, 0, row);
|
||||||
|
detailGrid.add(valueLabel, 1, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatInstant(Instant instant) {
|
||||||
|
if (instant == null) return "—";
|
||||||
|
return TIMESTAMP_FMT.format(instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusIcon(ProcessingStatus status) {
|
||||||
|
if (status == null) return "?";
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "✓";
|
||||||
|
case FAILED_RETRYABLE -> "↻";
|
||||||
|
case FAILED_FINAL -> "×";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "≡";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "⊘";
|
||||||
|
case READY_FOR_AI -> "⟳";
|
||||||
|
case PROPOSAL_READY -> "◇";
|
||||||
|
case PROCESSING -> "▶";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusColor(ProcessingStatus status) {
|
||||||
|
if (status == null) return "#000000";
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "#2e7d32";
|
||||||
|
case FAILED_RETRYABLE -> "#d98200";
|
||||||
|
case FAILED_FINAL -> "#c62828";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "#757575";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "#424242";
|
||||||
|
case READY_FOR_AI -> "#1565c0";
|
||||||
|
case PROPOSAL_READY -> "#0288d1";
|
||||||
|
case PROCESSING -> "#9e9e9e";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String statusTooltip(ProcessingStatus status) {
|
||||||
|
if (status == null) return "";
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "Erfolgreich verarbeitet und umbenannt.";
|
||||||
|
case FAILED_RETRYABLE -> "Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
|
||||||
|
case FAILED_FINAL -> "Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), "
|
||||||
|
+ "Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch.";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "Endgültig übersprungen nach wiederholten Fehlern.";
|
||||||
|
case READY_FOR_AI -> "Wartet auf Verarbeitung.";
|
||||||
|
case PROPOSAL_READY -> "KI-Vorschlag liegt vor, wartet auf Bestätigung.";
|
||||||
|
case PROCESSING -> "Wird gerade verarbeitet.";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> TableCell<T, String> ellipsisCell() {
|
||||||
|
return new TableCell<>() {
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty || item == null) {
|
||||||
|
setText(null);
|
||||||
|
setTooltip(null);
|
||||||
|
} else {
|
||||||
|
setText(item);
|
||||||
|
setTooltip(new Tooltip(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Region spacerNew() {
|
||||||
|
Region r = new Region();
|
||||||
|
HBox.setHgrow(r, Priority.ALWAYS);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showInfo(String message) {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||||
|
alert.setTitle("Hinweis");
|
||||||
|
alert.setHeaderText(null);
|
||||||
|
alert.setContentText(message);
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* GUI-Adapter für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
|
||||||
|
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
|
||||||
|
* <p>
|
||||||
|
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
|
||||||
|
* Die GUI-Komponenten kennen ausschließlich diese Interfaces –
|
||||||
|
* niemals direkt Repository- oder Use-Case-Implementierungen.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
+8
-3
@@ -244,12 +244,16 @@ class GuiAdapterSmokeTest {
|
|||||||
"The 'Speichern' button must be visible");
|
"The 'Speichern' button must be visible");
|
||||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||||
"The 'Speichern unter' button must be visible");
|
"The 'Speichern unter' button must be visible");
|
||||||
assertEquals(2, workspace.tabPane().getTabs().size(),
|
assertEquals(4, workspace.tabPane().getTabs().size(),
|
||||||
"Configuration tab and processing-run tab must both be present");
|
"Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
|
||||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||||
"The first tab must use the configuration label");
|
"The first tab must use the configuration label");
|
||||||
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||||
"The second tab must host the processing-run view");
|
"The second tab must host the processing-run view");
|
||||||
|
assertEquals("Verlauf", workspace.tabPane().getTabs().get(2).getText(),
|
||||||
|
"The third tab must host the history view");
|
||||||
|
assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(),
|
||||||
|
"The fourth tab must host the prompt editor");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||||
String.join(",", workspace.sectionTitles()),
|
String.join(",", workspace.sectionTitles()),
|
||||||
@@ -415,7 +419,8 @@ class GuiAdapterSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+2
-1
@@ -345,7 +345,8 @@ class GuiEditorFieldBindingTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+6
-3
@@ -137,7 +137,8 @@ class GuiEditorIntegrationTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -287,7 +288,8 @@ class GuiEditorIntegrationTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -371,7 +373,8 @@ class GuiEditorIntegrationTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+10
-5
@@ -208,7 +208,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -347,7 +348,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -471,7 +473,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -599,7 +602,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -698,7 +702,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+4
-2
@@ -142,7 +142,8 @@ class GuiEditorValidationSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -272,7 +273,8 @@ class GuiEditorValidationSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
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.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
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 org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-basierte Headless-Smoke-Tests für {@link GuiHistoryTab}.
|
||||||
|
* <p>
|
||||||
|
* Geprüfte Szenarien:
|
||||||
|
* <ul>
|
||||||
|
* <li>Tab wird mit Titel „Verlauf" erstellt.</li>
|
||||||
|
* <li>Tab ist nicht schließbar.</li>
|
||||||
|
* <li>Ohne geladene Konfiguration bleibt die Übersicht leer (null-configPath).</li>
|
||||||
|
* <li>Mit leerem Übersichts-Port bleibt die Tabelle leer.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class GuiHistoryTabSmokeTest {
|
||||||
|
|
||||||
|
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 muss innerhalb des Timeouts starten");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Gemeinsame Platform – kein Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Stubs
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static GuiHistoryOverviewPort emptyOverviewPort() {
|
||||||
|
return (configFilePath, query) ->
|
||||||
|
new HistoryOverviewResult(List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoryDetailsPort emptyDetailsPort() {
|
||||||
|
return (configFilePath, fingerprint) -> Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoryResetDocumentStatusPort noOpResetPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* no-op */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiDeleteDocumentHistoryPort noOpDeletePort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* no-op */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoryTab buildTab(Path configPath) {
|
||||||
|
return new GuiHistoryTab(
|
||||||
|
emptyOverviewPort(),
|
||||||
|
emptyDetailsPort(),
|
||||||
|
noOpResetPort(),
|
||||||
|
noOpDeletePort(),
|
||||||
|
() -> false,
|
||||||
|
() -> configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tab_shouldHaveTitleVerlauf() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicReference<Tab> tabRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiHistoryTab historyTab = buildTab(null);
|
||||||
|
tabRef.set(historyTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
|
||||||
|
assertEquals("Verlauf", tabRef.get().getText(), "Tab-Titel muss 'Verlauf' sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tab_shouldNotBeClosable() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean closableRef = new AtomicBoolean(true);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
GuiHistoryTab historyTab = buildTab(null);
|
||||||
|
closableRef.set(historyTab.tab().isClosable());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(closableRef.get(), "Tab darf nicht schließbar sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_withNullConfigPath_doesNotThrow() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
// Konstruktion mit null-configPath-Supplier muss möglich sein
|
||||||
|
GuiHistoryTab historyTab = buildTab(null);
|
||||||
|
assertNotNull(historyTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void construction_withConfigPath_doesNotThrow() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
Path dummyPath = Paths.get("config/application.properties");
|
||||||
|
GuiHistoryTab historyTab = buildTab(dummyPath);
|
||||||
|
assertNotNull(historyTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-4
@@ -336,7 +336,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -478,7 +479,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -565,7 +567,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -888,7 +891,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+2
-1
@@ -529,7 +529,8 @@ class GuiModelCatalogSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+371
@@ -0,0 +1,371 @@
|
|||||||
|
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.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monocle-basierte Headless-Smoke-Tests für {@link GuiPromptEditorTab}.
|
||||||
|
* <p>
|
||||||
|
* Geprüfte Szenarien:
|
||||||
|
* <ul>
|
||||||
|
* <li>Tab wird korrekt mit Titel „Prompt" erstellt.</li>
|
||||||
|
* <li>Dirty-State ist nach Konstruktion {@code false}.</li>
|
||||||
|
* <li>Nach synchronem Laden mit Erfolg: Dirty-State bleibt {@code false},
|
||||||
|
* Tab-Titel enthält keinen Asterisk.</li>
|
||||||
|
* <li>Nach synchronem Laden mit FILE_NOT_FOUND: Dirty-State bleibt {@code false}.</li>
|
||||||
|
* <li>Nach synchronem Speichern mit Erfolg: Dirty-State zurückgesetzt.</li>
|
||||||
|
* <li>Nach {@code resetToDefault}: Textfeld enthält Default-Inhalt (nicht leer),
|
||||||
|
* Dirty-State ist {@code true} (Abweichung von geladener Baseline).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class GuiPromptEditorTabSmokeTest {
|
||||||
|
|
||||||
|
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 muss innerhalb des Timeouts starten");
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
verifyLatch.countDown();
|
||||||
|
});
|
||||||
|
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Vorhandene JavaFX-Platform muss innerhalb des Timeouts erreichbar sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Gemeinsame Platform – kein Platform.exit().
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hilfsklassen
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** Synchroner Stub-Port: gibt vorbereitete Ergebnisse sofort zurück. */
|
||||||
|
private static class SyncPromptEditorPort implements GuiPromptEditorPort {
|
||||||
|
PromptLoadingResult loadResult = new PromptLoadingSuccess(
|
||||||
|
new PromptIdentifier("test-prompt.txt"), "Stub-Prompt-Inhalt");
|
||||||
|
PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/test-prompt.txt");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PromptLoadingResult loadCurrentPrompt() {
|
||||||
|
return loadResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PromptSaveResult save(String content) {
|
||||||
|
return saveResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createDefaultPromptIfMissing(
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new CorrectionOutcome.Applied(suggestion, "Stub-Prompt-Datei angelegt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen {@link GuiPromptEditorTab} mit synchronen Stubs:
|
||||||
|
* threadFactory führt den Runnable inline aus (vor worker.start()),
|
||||||
|
* fxDispatcher gibt den UI-Update-Runnable direkt weiter (kein Platform.runLater).
|
||||||
|
* Damit sind alle Operationen aus Testsicht vollständig synchron.
|
||||||
|
*/
|
||||||
|
private static GuiPromptEditorTab buildSyncTab(SyncPromptEditorPort port) {
|
||||||
|
GuiPromptEditorTab tab = new GuiPromptEditorTab(port, "/stub/test-prompt.txt", 60);
|
||||||
|
// Runnable wird inline ausgeführt; der zurückgegebene Thread startet leer (kein-op).
|
||||||
|
tab.threadFactory = runnable -> {
|
||||||
|
runnable.run(); // Synchron ausführen, inkl. fxDispatcher-Aufruf
|
||||||
|
return new Thread(); // Dummy-Thread; worker.start() beendet sofort
|
||||||
|
};
|
||||||
|
// UI-Updates synchron im selben Thread
|
||||||
|
tab.fxDispatcher = Runnable::run;
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tab_shouldBeCreatedWithTitlePrompt() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicReference<Tab> tabRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
tabRef.set(editorTab.tab());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertNotNull(tabRef.get(), "Tab darf nicht null sein");
|
||||||
|
assertEquals("Prompt", tabRef.get().getText(), "Tab-Titel muss 'Prompt' sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dirtyState_shouldBeFalse_afterConstruction() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(), "Dirty-State muss nach Konstruktion false sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dirtyState_shouldBeFalse_afterSuccessfulLoad() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
// Laden synchron auslösen (fxDispatcher = Runnable::run)
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(), "Dirty-State muss nach erfolgreichem Laden false sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tabTitle_shouldNotContainAsterisk_afterSuccessfulLoad() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
titleRef.set(editorTab.tab().getText());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(titleRef.get().contains("*"),
|
||||||
|
"Tab-Titel darf nach erfolgreichem Laden keinen Asterisk enthalten");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dirtyState_shouldBeFalse_whenLoadReturnsFileNotFound() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
port.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht gefunden");
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyConfigurationChanged_shouldResetDirtyStateAndTitle() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
// Laden und anschliessend Inhalt aendern, um Dirty-State zu erzeugen
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
editorTab.resetToDefault();
|
||||||
|
// Vorbedingung: Dirty-State muss aktiv sein
|
||||||
|
assertTrue(editorTab.hasDirtyContent(),
|
||||||
|
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
|
||||||
|
|
||||||
|
// Konfiguration wechseln – Dirty-State und Titel sollen zurueckgesetzt werden
|
||||||
|
editorTab.notifyConfigurationChanged(new SyncPromptEditorPort(), "/new/prompt.txt", 80);
|
||||||
|
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
titleRef.set(editorTab.tab().getText());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(),
|
||||||
|
"Dirty-State muss nach notifyConfigurationChanged false sein");
|
||||||
|
assertFalse(titleRef.get().contains("*"),
|
||||||
|
"Tab-Titel darf nach notifyConfigurationChanged keinen Asterisk enthalten; Titel war: "
|
||||||
|
+ titleRef.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
// Direkte TextArea-Manipulation simuliert Benutzer-Eingabe
|
||||||
|
// Über Reflection auf das private textArea-Feld zugreifen ist unerwünscht.
|
||||||
|
// Stattdessen: resetToDefault() setzt einen anderen Inhalt als den geladenen,
|
||||||
|
// was den Dirty-State auslöst.
|
||||||
|
editorTab.resetToDefault();
|
||||||
|
titleRef.set(editorTab.tab().getText());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
// Nach resetToDefault() wird der Default-Inhalt gesetzt.
|
||||||
|
// Falls dieser vom geladenen Inhalt abweicht, entsteht ein Dirty-State.
|
||||||
|
// Da Stub-Inhalt != Default-Template, muss Asterisk vorhanden sein.
|
||||||
|
assertTrue(titleRef.get().contains("*"),
|
||||||
|
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
|
||||||
|
+ titleRef.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void discardChanges_shouldResetDirtyStateAndTitle() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
editorTab.resetToDefault();
|
||||||
|
// Vorbedingung: Dirty-State muss aktiv sein
|
||||||
|
assertTrue(editorTab.hasDirtyContent(),
|
||||||
|
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
|
||||||
|
|
||||||
|
// Verwerfen simulieren
|
||||||
|
editorTab.discardChanges();
|
||||||
|
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
titleRef.set(editorTab.tab().getText());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(),
|
||||||
|
"Dirty-State muss nach discardChanges false sein");
|
||||||
|
assertFalse(titleRef.get().contains("*"),
|
||||||
|
"Tab-Titel darf nach discardChanges keinen Asterisk enthalten; Titel war: "
|
||||||
|
+ titleRef.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
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.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
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 org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||||
|
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.application.config.provider.AiProviderFamily;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für die Statuszeilen-Komponente {@link GuiStatusBar}.
|
||||||
|
* <p>
|
||||||
|
* Überprüft die Versionsanzeige, den Provider-Text und den Konfigurationspfad-Text
|
||||||
|
* in den verschiedenen Zuständen (ohne und mit geladener Konfiguration).
|
||||||
|
* <p>
|
||||||
|
* Die Tests laufen unter Monocle (Headless-JavaFX), da {@link GuiStatusBar} JavaFX-Controls erzeugt.
|
||||||
|
*/
|
||||||
|
class GuiStatusBarTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialisiert die JavaFX-Plattform einmalig für alle Tests dieser Klasse.
|
||||||
|
*
|
||||||
|
* @throws InterruptedException falls der Thread beim Warten unterbrochen wird
|
||||||
|
*/
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
CountDownLatch startLatch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(() -> {
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
startLatch.countDown();
|
||||||
|
});
|
||||||
|
} catch (IllegalStateException alreadyInitialized) {
|
||||||
|
// JavaFX wurde bereits durch einen anderen Test gestartet
|
||||||
|
PLATFORM_STARTED.set(true);
|
||||||
|
startLatch.countDown();
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"JavaFX-Plattform muss innerhalb des Timeouts starten");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plattform bleibt für nachfolgende Tests am Leben. */
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownJavaFxPlatform() {
|
||||||
|
// Absichtlich kein Platform.exit() – damit andere Smoke-Tests weiterhin die Plattform nutzen können.
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Versionsanzeige
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass die Versionsanzeige das korrekte Präfix und die übergebene Version enthält.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void versionLabel_zeigtVersionMitPraefix() throws Exception {
|
||||||
|
AtomicReference<String> versionText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("3.0.42");
|
||||||
|
versionText.set(bar.versionText());
|
||||||
|
});
|
||||||
|
assertEquals("V3.0.42", versionText.get(),
|
||||||
|
"Die Versionsanzeige muss das Präfix 'V' gefolgt von der Versionsnummer enthalten");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass ein {@code null}-Wert für die Version als {@code "dev"} angezeigt wird.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void versionLabel_mitNullFaellzurueckAufDev() throws Exception {
|
||||||
|
AtomicReference<String> versionText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar(null);
|
||||||
|
versionText.set(bar.versionText());
|
||||||
|
});
|
||||||
|
assertEquals("Vdev", versionText.get(),
|
||||||
|
"Ein null-Wert muss als Fallback 'dev' angezeigt werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass ein leerer String für die Version als {@code "dev"} angezeigt wird.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void versionLabel_mitLeeremStringFaellzurueckAufDev() throws Exception {
|
||||||
|
AtomicReference<String> versionText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar(" ");
|
||||||
|
versionText.set(bar.versionText());
|
||||||
|
});
|
||||||
|
assertEquals("Vdev", versionText.get(),
|
||||||
|
"Ein leerer String muss als Fallback 'dev' angezeigt werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Standardzustand ohne geladene Konfiguration
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass Mitte und Rechts den Text „Kein Profil geladen" zeigen, wenn keine
|
||||||
|
* Konfiguration geladen ist.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void ohneKonfiguration_zeigtKeinProfilGeladen() throws Exception {
|
||||||
|
AtomicReference<String> providerText = new AtomicReference<>();
|
||||||
|
AtomicReference<String> configPathText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
providerText.set(bar.providerText());
|
||||||
|
configPathText.set(bar.configPathText());
|
||||||
|
});
|
||||||
|
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||||
|
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Provider-Text erscheinen");
|
||||||
|
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
|
||||||
|
"Ohne geladene Konfiguration muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass {@link GuiStatusBar#clearConfiguration()} Mitte und Rechts zurücksetzt.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void clearConfiguration_setztMitteUndRechtsZurueck() throws Exception {
|
||||||
|
AtomicReference<String> providerText = new AtomicReference<>();
|
||||||
|
AtomicReference<String> configPathText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
// Zustand mit Konfiguration setzen, dann löschen
|
||||||
|
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||||
|
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
|
||||||
|
bar.applyEditorState(state);
|
||||||
|
bar.clearConfiguration();
|
||||||
|
providerText.set(bar.providerText());
|
||||||
|
configPathText.set(bar.configPathText());
|
||||||
|
});
|
||||||
|
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||||
|
"Nach clearConfiguration() muss 'Kein Profil geladen' als Provider-Text erscheinen");
|
||||||
|
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, configPathText.get(),
|
||||||
|
"Nach clearConfiguration() muss 'Kein Profil geladen' als Konfigurationspfad erscheinen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Zustand nach Laden einer Konfiguration
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Provider-Text
|
||||||
|
* mit Modell angezeigt wird.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void applyEditorState_mitClaudeUndModell_zeigtKorrektesFormat() throws Exception {
|
||||||
|
AtomicReference<String> providerText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||||
|
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
|
||||||
|
bar.applyEditorState(state);
|
||||||
|
providerText.set(bar.providerText());
|
||||||
|
});
|
||||||
|
assertEquals("Provider: Claude · claude-opus-4-7", providerText.get(),
|
||||||
|
"Der Provider-Text muss das Format 'Provider: <Name> · <Modell>' haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass nach {@link GuiStatusBar#applyEditorState} der korrekte Konfigurationspfad
|
||||||
|
* angezeigt wird.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void applyEditorState_mitKonfigurationspfad_zeigtKonfiguationspfad() throws Exception {
|
||||||
|
AtomicReference<String> configPathText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||||
|
"config/application.properties", AiProviderFamily.CLAUDE, "claude-opus-4-7");
|
||||||
|
bar.applyEditorState(state);
|
||||||
|
configPathText.set(bar.configPathText());
|
||||||
|
});
|
||||||
|
assertTrue(configPathText.get().contains("application.properties"),
|
||||||
|
"Der Konfigurationspfad muss den Dateinamen enthalten");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass ein OpenAI-kompatibler Provider korrekt angezeigt wird.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void applyEditorState_mitOpenAiUndModell_zeigtKorrektesFormat() throws Exception {
|
||||||
|
AtomicReference<String> providerText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
GuiConfigurationEditorState state = buildStateWithConfiguration(
|
||||||
|
"config/application.properties", AiProviderFamily.OPENAI_COMPATIBLE, "gpt-4o");
|
||||||
|
bar.applyEditorState(state);
|
||||||
|
providerText.set(bar.providerText());
|
||||||
|
});
|
||||||
|
assertEquals("Provider: OpenAI-kompatibel · gpt-4o", providerText.get(),
|
||||||
|
"Der Provider-Text muss für OpenAI-kompatibel den deutschen Anzeigenamen verwenden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass beim Übergeben eines {@code null}-Zustands kein Absturz erfolgt und der
|
||||||
|
* Text „Kein Profil geladen" erscheint.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void applyEditorState_mitNull_keinAbsturz() throws Exception {
|
||||||
|
AtomicReference<String> providerText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
bar.applyEditorState(null);
|
||||||
|
providerText.set(bar.providerText());
|
||||||
|
});
|
||||||
|
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||||
|
"Ein null-Zustand darf keinen Absturz verursachen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass ohne geladenen Dateisnapshot „Kein Profil geladen" angezeigt wird,
|
||||||
|
* auch wenn Konfigurationswerte vorhanden sind.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void applyEditorState_ohneSnapshot_zeigtKeinProfilGeladen() throws Exception {
|
||||||
|
AtomicReference<String> providerText = new AtomicReference<>();
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
// Standard-Template hat keinen Snapshot
|
||||||
|
GuiConfigurationEditorState state = GuiConfigurationTemplateFactory.createStandardTemplate();
|
||||||
|
bar.applyEditorState(state);
|
||||||
|
providerText.set(bar.providerText());
|
||||||
|
});
|
||||||
|
assertEquals(GuiStatusBar.KEIN_PROFIL_TEXT, providerText.get(),
|
||||||
|
"Ohne geladenen Dateisnapshot muss 'Kein Profil geladen' erscheinen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass der Wurzelknoten der Statuszeile nicht null ist.
|
||||||
|
*
|
||||||
|
* @throws Exception falls der FX-Thread-Task fehlschlägt oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void root_istNichtNull() throws Exception {
|
||||||
|
AtomicBoolean rootNotNull = new AtomicBoolean(false);
|
||||||
|
runOnFxThread(() -> {
|
||||||
|
GuiStatusBar bar = new GuiStatusBar("1.0.0");
|
||||||
|
rootNotNull.set(bar.root() != null);
|
||||||
|
});
|
||||||
|
assertTrue(rootNotNull.get(), "Der Wurzelknoten der Statuszeile darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hilfsmethoden
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt eine Aktion synchron auf dem JavaFX Application Thread aus und wartet auf Abschluss.
|
||||||
|
*
|
||||||
|
* @param action die auszuführende Aktion
|
||||||
|
* @throws Exception falls die Aktion einen Fehler wirft oder das Timeout überschritten wird
|
||||||
|
*/
|
||||||
|
private static void runOnFxThread(Runnable action) throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"FX-Thread-Task muss innerhalb des Timeouts abgeschlossen werden");
|
||||||
|
if (error.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", error.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Editor-Zustand mit geladenem Dateisnapshot für den angegebenen
|
||||||
|
* Konfigurationspfad, Provider und Modell.
|
||||||
|
*
|
||||||
|
* @param configPath relativer Konfigurationsdateipfad
|
||||||
|
* @param family aktive Provider-Familie
|
||||||
|
* @param model Modellbezeichner
|
||||||
|
* @return ein Editor-Zustand mit Snapshot
|
||||||
|
*/
|
||||||
|
private static GuiConfigurationEditorState buildStateWithConfiguration(
|
||||||
|
String configPath, AiProviderFamily family, String model) {
|
||||||
|
GuiConfigurationEditorState template = GuiConfigurationTemplateFactory.createStandardTemplate();
|
||||||
|
// Provider und Modell setzen
|
||||||
|
GuiProviderConfigurationState providerState = new GuiProviderConfigurationState(
|
||||||
|
"https://api.example.com", model, "30",
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState.unresolved());
|
||||||
|
GuiConfigurationValues values = template.values()
|
||||||
|
.withActiveProviderFamily(family.getIdentifier())
|
||||||
|
.withProviderConfiguration(family, providerState);
|
||||||
|
// Snapshot anlegen
|
||||||
|
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
|
||||||
|
Path.of(configPath), new Properties());
|
||||||
|
return new GuiConfigurationEditorState(
|
||||||
|
Optional.of(snapshot), values, values, Optional.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-8
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
|
|||||||
* {@code technical-tests-button}.</li>
|
* {@code technical-tests-button}.</li>
|
||||||
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
||||||
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
|
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
|
||||||
* <li>A second trigger appends a fresh batch of test entries (accumulation semantics).</li>
|
* <li>A second trigger replaces the previous batch of test entries.</li>
|
||||||
* <li>The post-result callback is invoked after the result is applied.</li>
|
* <li>The post-result callback is invoked after the result is applied.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
@@ -138,12 +138,12 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
|
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
|
||||||
* 11 (one per checkpoint) plus 1 summary entry = 12.
|
* 12 (one per checkpoint) plus 1 summary entry = 13.
|
||||||
*
|
*
|
||||||
* @throws Exception if the FX thread task fails or times out
|
* @throws Exception if the FX thread task fails or times out
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
|
void trigger_producesTwelveCheckpointEntriesPlusSummary() throws Exception {
|
||||||
runOnFx(() -> {
|
runOnFx(() -> {
|
||||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||||
@@ -155,9 +155,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
// 11 checkpoint entries + 1 summary entry = 12
|
// 12 checkpoint entries + 1 summary entry = 13
|
||||||
assertEquals(12, taggedCount,
|
assertEquals(13, taggedCount,
|
||||||
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
|
"Expected 12 checkpoint entries + 1 summary entry = 13 tagged messages");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,12 +256,14 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort(),
|
noOpPathCheckPort(),
|
||||||
noOpProviderService());
|
noOpProviderService(),
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
|
|
||||||
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||||
orchestrator,
|
orchestrator,
|
||||||
currentInput::get, // always reads the current reference
|
currentInput::get, // always reads the current reference
|
||||||
() -> "",
|
() -> "",
|
||||||
|
() -> "",
|
||||||
messages,
|
messages,
|
||||||
report -> { });
|
report -> { });
|
||||||
|
|
||||||
@@ -365,7 +367,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort(),
|
noOpPathCheckPort(),
|
||||||
noOpProviderService());
|
noOpProviderService(),
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
|
|
||||||
EditorValidationInput blankInput = new EditorValidationInput(
|
EditorValidationInput blankInput = new EditorValidationInput(
|
||||||
"claude",
|
"claude",
|
||||||
@@ -380,6 +383,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
orchestrator,
|
orchestrator,
|
||||||
() -> blankInput,
|
() -> blankInput,
|
||||||
() -> "",
|
() -> "",
|
||||||
|
() -> "",
|
||||||
messages,
|
messages,
|
||||||
postResultCallback);
|
postResultCallback);
|
||||||
|
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
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.assertThrows;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link GuiTooltipTexts}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass alle öffentlichen Tooltip-Konstanten vorhanden sind, nicht leer sind
|
||||||
|
* und den exakten Texten gemäß Spezifikation entsprechen.
|
||||||
|
*/
|
||||||
|
class GuiTooltipTextsTest {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Vollständigkeit und Nicht-Leerheit aller Konstanten
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alleKonstantenSindNichtNullUndNichtLeer() {
|
||||||
|
List<String> fehler = new ArrayList<>();
|
||||||
|
for (Field field : GuiTooltipTexts.class.getDeclaredFields()) {
|
||||||
|
if (!Modifier.isPublic(field.getModifiers())
|
||||||
|
|| !Modifier.isStatic(field.getModifiers())
|
||||||
|
|| !Modifier.isFinal(field.getModifiers())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object value = field.get(null);
|
||||||
|
if (value == null) {
|
||||||
|
fehler.add(field.getName() + " ist null");
|
||||||
|
} else if (value instanceof String s && s.isBlank()) {
|
||||||
|
fehler.add(field.getName() + " ist leer");
|
||||||
|
}
|
||||||
|
} catch (IllegalAccessException e) {
|
||||||
|
fehler.add(field.getName() + " nicht zugreifbar: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fehler.isEmpty()) {
|
||||||
|
org.junit.jupiter.api.Assertions.fail(
|
||||||
|
"Fehlerhafte Tooltip-Konstanten: " + String.join(", ", fehler));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Toolbar-Tooltips – exakter Text gemäß Spezifikation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolbar_neu_entsprichtSpezifikation() {
|
||||||
|
assertNotNull(GuiTooltipTexts.TOOLBAR_NEU);
|
||||||
|
assertFalse(GuiTooltipTexts.TOOLBAR_NEU.isBlank());
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Neue Konfiguration erstellen.",
|
||||||
|
GuiTooltipTexts.TOOLBAR_NEU);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolbar_oeffnen_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Bestehende Konfigurationsdatei (.properties) öffnen.",
|
||||||
|
GuiTooltipTexts.TOOLBAR_OEFFNEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolbar_speichern_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Aktuelle Konfiguration speichern.",
|
||||||
|
GuiTooltipTexts.TOOLBAR_SPEICHERN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolbar_speichernUnter_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Konfiguration unter neuem Dateipfad speichern.",
|
||||||
|
GuiTooltipTexts.TOOLBAR_SPEICHERN_UNTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolbar_validieren_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.",
|
||||||
|
GuiTooltipTexts.TOOLBAR_VALIDIEREN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolbar_technischeTests_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.",
|
||||||
|
GuiTooltipTexts.TOOLBAR_TECHNISCHE_TESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Pfade-Tooltips – exakter Text gemäß Spezifikation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pfade_quellordner_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.",
|
||||||
|
GuiTooltipTexts.PFADE_QUELLORDNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pfade_zielordner_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Ordner für die umbenannten Kopien.",
|
||||||
|
GuiTooltipTexts.PFADE_ZIELORDNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pfade_sqlite_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.",
|
||||||
|
GuiTooltipTexts.PFADE_SQLITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pfade_prompt_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Externe Textdatei mit den KI-Anweisungen.",
|
||||||
|
GuiTooltipTexts.PFADE_PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Provider-Tooltips – exakter Text gemäß Spezifikation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void provider_combobox_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Der KI-Dienst, der die Dateinamen generiert.",
|
||||||
|
GuiTooltipTexts.PROVIDER_COMBOBOX);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void provider_modell_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Das konkrete Sprachmodell des gewählten Providers.",
|
||||||
|
GuiTooltipTexts.PROVIDER_MODELL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verarbeitungslimits-Tooltips – exakter Text gemäß Spezifikation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limits_maxTextCharacters_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.",
|
||||||
|
GuiTooltipTexts.LIMITS_MAX_TEXT_CHARACTERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limits_maxPages_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Maximale Seitenzahl, die aus einem PDF gelesen wird.",
|
||||||
|
GuiTooltipTexts.LIMITS_MAX_PAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limits_maxTitleLength_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10–120.",
|
||||||
|
GuiTooltipTexts.LIMITS_MAX_TITLE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verarbeitungslauf-Tab-Tooltips – exakter Text gemäß Spezifikation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dateiname_uebernehmen_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.",
|
||||||
|
GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dateiname_zuruecksetzen_entsprichtSpezifikation() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.",
|
||||||
|
GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Nicht instanziierbar
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void konstruktorWirftException() throws Exception {
|
||||||
|
Constructor<GuiTooltipTexts> ctor = GuiTooltipTexts.class.getDeclaredConstructor();
|
||||||
|
ctor.setAccessible(true);
|
||||||
|
assertThrows(java.lang.reflect.InvocationTargetException.class, ctor::newInstance,
|
||||||
|
"Der private Konstruktor muss UnsupportedOperationException werfen");
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -806,7 +806,8 @@ class GuiUnsavedChangesGuardSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -851,7 +852,8 @@ class GuiUnsavedChangesGuardSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
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())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+4
-2
@@ -323,7 +323,8 @@ class GuiValidateActionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
noOpApiKeyResolutionPort())),
|
noOpApiKeyResolutionPort()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
@@ -390,7 +391,8 @@ class GuiValidateActionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
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"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
noOpApiKeyResolutionPort())),
|
noOpApiKeyResolutionPort()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
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 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"); }
|
||||||
|
|||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link BatchRunSummaryBanner}.
|
||||||
|
* <p>
|
||||||
|
* Geprüft werden die Aggregationslogik und die Textgenerierung unabhängig von JavaFX.
|
||||||
|
* Die GUI-Integrationsmethoden ({@code clear()}, {@code update()}, {@code getNode()})
|
||||||
|
* erfordern eine JavaFX-Runtime und werden durch Smoke-Tests abgedeckt.
|
||||||
|
*/
|
||||||
|
class BatchRunSummaryBannerTest {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden für Testdaten
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
"test.pdf",
|
||||||
|
new DocumentFingerprint("a".repeat(64)),
|
||||||
|
status,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Duration.ZERO,
|
||||||
|
false,
|
||||||
|
Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunResultRow resetPendingRow() {
|
||||||
|
GuiBatchRunResultRow base = row(DocumentCompletionStatus.SUCCESS);
|
||||||
|
return GuiBatchRunResultRow.resetMarker(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// aggregateCounts
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregateCounts_leereListe_alleZaehlerNull() {
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts =
|
||||||
|
BatchRunSummaryBanner.aggregateCounts(Collections.emptyList());
|
||||||
|
|
||||||
|
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SUCCESS, 0));
|
||||||
|
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_RETRYABLE, 0));
|
||||||
|
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.FAILED_PERMANENT, 0));
|
||||||
|
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0));
|
||||||
|
assertEquals(0, counts.getOrDefault(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregateCounts_nurErfolgreiche_zaehltNurSuccess() {
|
||||||
|
List<GuiBatchRunResultRow> rows = List.of(
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
row(DocumentCompletionStatus.SUCCESS));
|
||||||
|
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts =
|
||||||
|
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||||
|
|
||||||
|
assertEquals(3, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||||
|
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||||
|
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
|
||||||
|
assertEquals(0, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregateCounts_gemischterLauf_alleKategorienKorrekt() {
|
||||||
|
List<GuiBatchRunResultRow> rows = List.of(
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
row(DocumentCompletionStatus.FAILED_RETRYABLE),
|
||||||
|
row(DocumentCompletionStatus.FAILED_PERMANENT),
|
||||||
|
row(DocumentCompletionStatus.FAILED_PERMANENT),
|
||||||
|
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
|
||||||
|
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
|
||||||
|
row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED),
|
||||||
|
row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||||
|
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts =
|
||||||
|
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||||
|
|
||||||
|
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||||
|
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||||
|
assertEquals(2, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
assertEquals(3, counts.get(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
|
||||||
|
assertEquals(1, counts.get(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregateCounts_resetPendingZeilenWerdenNichtGezaehlt() {
|
||||||
|
// Reset-Pending-Zeilen haben noch keinen abgeschlossenen Status und
|
||||||
|
// dürfen nicht ins Summary einfließen
|
||||||
|
List<GuiBatchRunResultRow> rows = List.of(
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
resetPendingRow(),
|
||||||
|
resetPendingRow());
|
||||||
|
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts =
|
||||||
|
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||||
|
|
||||||
|
assertEquals(1, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||||
|
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||||
|
assertEquals(0, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// buildBannerText
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildBannerText_alleZaehlerNull_leerString() {
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS, 0,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
|
||||||
|
|
||||||
|
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||||
|
|
||||||
|
assertTrue(text.isEmpty(), "Leere Zähler ergeben leeren Text: '" + text + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildBannerText_nurErfolgreiche_nurSuccessSegment() {
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS, 17,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
|
||||||
|
|
||||||
|
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||||
|
|
||||||
|
assertTrue(text.contains("17"), "Anzahl 17 muss im Text erscheinen: " + text);
|
||||||
|
assertTrue(text.contains("erfolgreich"), "Kategorie 'erfolgreich' muss erscheinen: " + text);
|
||||||
|
assertTrue(text.contains("✓"), "Icon ✓ muss erscheinen: " + text);
|
||||||
|
assertFalse(text.contains("↻"), "Kein ↻ wenn FAILED_RETRYABLE = 0: " + text);
|
||||||
|
assertFalse(text.contains("×"), "Kein × wenn FAILED_PERMANENT = 0: " + text);
|
||||||
|
assertFalse(text.contains("≡"), "Kein ≡ wenn SKIPPED_ALREADY_PROCESSED = 0: " + text);
|
||||||
|
assertFalse(text.contains("⊘"), "Kein ⊘ wenn SKIPPED_FINAL_FAILURE = 0: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildBannerText_vollerLauf_alleSegmenteEnthalten() {
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS, 14,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE, 1,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT, 2,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 3,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 1);
|
||||||
|
|
||||||
|
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||||
|
|
||||||
|
// Jedes Segment enthält Icon + Anzahl + Kategorie
|
||||||
|
assertTrue(text.contains("✓ 14 erfolgreich"), "SUCCESS-Segment: " + text);
|
||||||
|
assertTrue(text.contains("↻ 1 wird wiederholt"), "FAILED_RETRYABLE-Segment: " + text);
|
||||||
|
assertTrue(text.contains("× 2 fehlgeschlagen"), "FAILED_PERMANENT-Segment: " + text);
|
||||||
|
assertTrue(text.contains("≡ 3 übersprungen"), "SKIPPED_ALREADY_PROCESSED-Segment: " + text);
|
||||||
|
assertTrue(text.contains("⊘ 1 endgültig übersprungen"), "SKIPPED_FINAL_FAILURE-Segment: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildBannerText_nurSkippedFinalFailure_erscheintImBanner() {
|
||||||
|
// Sicherstellung: ⊘ erscheint auch wenn > 0, obwohl es die seltenste Kategorie ist
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS, 0,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 2);
|
||||||
|
|
||||||
|
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||||
|
|
||||||
|
assertTrue(text.contains("⊘"), "Icon ⊘ muss erscheinen: " + text);
|
||||||
|
assertTrue(text.contains("2"), "Anzahl 2 muss erscheinen: " + text);
|
||||||
|
assertTrue(text.contains("endgültig übersprungen"), "Kategorie muss erscheinen: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildBannerText_nurKategorienMitAnzahlGroesserNull_erscheinen() {
|
||||||
|
// Nur SUCCESS=5 ist gesetzt; alle anderen 0 → kein anderes Segment
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = Map.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS, 5,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE, 0,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, 0,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE, 0);
|
||||||
|
|
||||||
|
String text = BatchRunSummaryBanner.buildBannerText(counts);
|
||||||
|
|
||||||
|
// Kein Trennzeichen (·) darf erscheinen, wenn nur ein Segment vorhanden ist
|
||||||
|
assertFalse(text.contains("·"), "Kein Trenner bei einzelnem Segment: " + text);
|
||||||
|
assertTrue(text.contains("✓ 5 erfolgreich"), "Nur SUCCESS-Segment: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregateCounts_kombinationMitResetPending_nurEchtAbgeschlosseneGezaehlt() {
|
||||||
|
// 2 SUCCESS + 1 FAILED_PERMANENT + 1 resetPending(SUCCESS) → nur 2+1 gezählt
|
||||||
|
List<GuiBatchRunResultRow> rows = List.of(
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
row(DocumentCompletionStatus.SUCCESS),
|
||||||
|
row(DocumentCompletionStatus.FAILED_PERMANENT),
|
||||||
|
resetPendingRow());
|
||||||
|
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts =
|
||||||
|
BatchRunSummaryBanner.aggregateCounts(rows);
|
||||||
|
|
||||||
|
assertEquals(2, counts.get(DocumentCompletionStatus.SUCCESS));
|
||||||
|
assertEquals(1, counts.get(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
// Summe aller gezählten Einträge = 3, nicht 4
|
||||||
|
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
|
||||||
|
assertEquals(3, total, "Reset-Pending darf nicht mitgezählt werden");
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -188,6 +188,50 @@ class GuiBatchRunResultRowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// statusTooltip
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusTooltip_success_isNonBlank() {
|
||||||
|
assertFalse(row(DocumentCompletionStatus.SUCCESS).statusTooltip().isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusTooltip_failedRetryable_isNonBlank() {
|
||||||
|
assertFalse(row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip().isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusTooltip_failedPermanent_isNonBlank() {
|
||||||
|
assertFalse(row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip().isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusTooltip_failedRetryable_and_failedPermanent_areDifferent() {
|
||||||
|
String retryable = row(DocumentCompletionStatus.FAILED_RETRYABLE).statusTooltip();
|
||||||
|
String permanent = row(DocumentCompletionStatus.FAILED_PERMANENT).statusTooltip();
|
||||||
|
assertFalse(retryable.equals(permanent),
|
||||||
|
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusTooltip_allStatuses_delegatesToProcessingStatusPresentation() {
|
||||||
|
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||||
|
String rowTooltip = row(status).statusTooltip();
|
||||||
|
String expectedTooltip = ProcessingStatusPresentation.tooltipFor(status);
|
||||||
|
assertEquals(expectedTooltip, rowTooltip,
|
||||||
|
"statusTooltip() soll Wert von ProcessingStatusPresentation liefern für " + status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusTooltip_resetPending_returnsResetLabel() {
|
||||||
|
GuiBatchRunResultRow marker = GuiBatchRunResultRow.resetMarker(
|
||||||
|
row(DocumentCompletionStatus.SUCCESS));
|
||||||
|
assertEquals(GuiBatchRunResultRow.RESET_PENDING_LABEL, marker.statusTooltip());
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// aiFailureMessage
|
// aiFailureMessage
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||||
|
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 java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation.StatusVisuals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link ProcessingStatusPresentation}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass alle {@link DocumentCompletionStatus}-Werte korrekte Icons, Farben,
|
||||||
|
* Tooltip-Texte und Summary-Kategorielabels liefern und dass keine zwei Status
|
||||||
|
* dasselbe Icon teilen.
|
||||||
|
*/
|
||||||
|
class ProcessingStatusPresentationTest {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// iconFor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iconFor_success_isCheckMark() {
|
||||||
|
assertEquals(ProcessingStatusPresentation.ICON_SUCCESS,
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iconFor_failedRetryable_isClockwiseArrow() {
|
||||||
|
assertEquals(ProcessingStatusPresentation.ICON_FAILED_RETRYABLE,
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iconFor_failedPermanent_isMultiplicationSign() {
|
||||||
|
assertEquals(ProcessingStatusPresentation.ICON_FAILED_PERMANENT,
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iconFor_skippedAlreadyProcessed_isIdenticalTo() {
|
||||||
|
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_ALREADY_PROCESSED,
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iconFor_skippedFinalFailure_isCircledDivisionSlash() {
|
||||||
|
assertEquals(ProcessingStatusPresentation.ICON_SKIPPED_FINAL_FAILURE,
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iconFor_null_throws() {
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> ProcessingStatusPresentation.iconFor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void icons_areAllDistinct() {
|
||||||
|
Set<String> icons = new HashSet<>();
|
||||||
|
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||||
|
icons.add(ProcessingStatusPresentation.iconFor(status));
|
||||||
|
}
|
||||||
|
assertEquals(DocumentCompletionStatus.values().length, icons.size(),
|
||||||
|
"Jeder Status muss ein eindeutiges Icon haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// cssColorFor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(DocumentCompletionStatus.class)
|
||||||
|
void cssColorFor_allStatuses_returnsNonBlankHexColor(DocumentCompletionStatus status) {
|
||||||
|
String color = ProcessingStatusPresentation.cssColorFor(status);
|
||||||
|
assertAll(
|
||||||
|
() -> assertNotNull(color, "Farbe darf nicht null sein für " + status),
|
||||||
|
() -> assertFalse(color.isBlank(), "Farbe darf nicht leer sein für " + status),
|
||||||
|
() -> assertFalse(color.isEmpty(), "Farbe darf nicht leer sein für " + status)
|
||||||
|
);
|
||||||
|
// Farbe muss im CSS-Hex-Format beginnen (#)
|
||||||
|
assertFalse(color.isBlank());
|
||||||
|
assertEquals('#', color.charAt(0), "CSS-Farbe muss mit # beginnen für " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cssColorFor_null_throws() {
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> ProcessingStatusPresentation.cssColorFor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedRetryable_and_failedPermanent_haveDifferentColors() {
|
||||||
|
String orangeColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||||
|
String redColor = ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||||
|
assertFalse(orangeColor.equals(redColor),
|
||||||
|
"FAILED_RETRYABLE (Orange) und FAILED_PERMANENT (Rot) müssen unterschiedliche Farben haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// tooltipFor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(DocumentCompletionStatus.class)
|
||||||
|
void tooltipFor_allStatuses_returnsNonBlankText(DocumentCompletionStatus status) {
|
||||||
|
String tooltip = ProcessingStatusPresentation.tooltipFor(status);
|
||||||
|
assertNotNull(tooltip, "Tooltip darf nicht null sein für " + status);
|
||||||
|
assertFalse(tooltip.isBlank(), "Tooltip darf nicht leer sein für " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tooltipFor_null_throws() {
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> ProcessingStatusPresentation.tooltipFor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tooltipFor_failedRetryable_containsWiederholung() {
|
||||||
|
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||||
|
assertFalse(tooltip.isBlank());
|
||||||
|
// Tooltip muss die Retry-Semantik kommunizieren
|
||||||
|
assertFalse(tooltip.equals(ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT)),
|
||||||
|
"FAILED_RETRYABLE und FAILED_PERMANENT müssen unterschiedliche Tooltips haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tooltipFor_failedPermanent_containsKeinWeitererVersuch() {
|
||||||
|
String tooltip = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||||
|
// Tooltip für FAILED_PERMANENT muss kommunizieren, dass kein weiterer automatischer Versuch folgt
|
||||||
|
assertFalse(tooltip.isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tooltips_areAllDistinct() {
|
||||||
|
Set<String> tooltips = new HashSet<>();
|
||||||
|
for (DocumentCompletionStatus status : DocumentCompletionStatus.values()) {
|
||||||
|
tooltips.add(ProcessingStatusPresentation.tooltipFor(status));
|
||||||
|
}
|
||||||
|
assertEquals(DocumentCompletionStatus.values().length, tooltips.size(),
|
||||||
|
"Jeder Status muss einen eindeutigen Tooltip haben");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// summaryCategoryFor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(DocumentCompletionStatus.class)
|
||||||
|
void summaryCategoryFor_allStatuses_returnsNonBlankLabel(DocumentCompletionStatus status) {
|
||||||
|
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
|
||||||
|
assertNotNull(category, "Summary-Kategorie darf nicht null sein für " + status);
|
||||||
|
assertFalse(category.isBlank(), "Summary-Kategorie darf nicht leer sein für " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void summaryCategoryFor_null_throws() {
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> ProcessingStatusPresentation.summaryCategoryFor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// visualsFor (gebündelt)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(DocumentCompletionStatus.class)
|
||||||
|
void visualsFor_allStatuses_returnsConsistentRecord(DocumentCompletionStatus status) {
|
||||||
|
StatusVisuals visuals = ProcessingStatusPresentation.visualsFor(status);
|
||||||
|
assertAll(
|
||||||
|
() -> assertNotNull(visuals, "StatusVisuals darf nicht null sein für " + status),
|
||||||
|
() -> assertEquals(ProcessingStatusPresentation.iconFor(status), visuals.icon()),
|
||||||
|
() -> assertEquals(ProcessingStatusPresentation.cssColorFor(status), visuals.cssColor()),
|
||||||
|
() -> assertEquals(ProcessingStatusPresentation.tooltipFor(status), visuals.tooltipText()),
|
||||||
|
() -> assertEquals(ProcessingStatusPresentation.summaryCategoryFor(status),
|
||||||
|
visuals.summaryCategoryLabel())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visualsFor_null_throws() {
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> ProcessingStatusPresentation.visualsFor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Spezifische Status-Mapping-Werte (gemäß Spezifikation)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_mapping_correctValues() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals("✓", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SUCCESS)),
|
||||||
|
() -> assertEquals("#2e7d32", ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.SUCCESS)),
|
||||||
|
() -> assertEquals("erfolgreich",
|
||||||
|
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.SUCCESS))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedRetryable_mapping_correctValues() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals("↻", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
|
||||||
|
() -> assertEquals("#d98200",
|
||||||
|
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_RETRYABLE)),
|
||||||
|
() -> assertEquals("wird wiederholt",
|
||||||
|
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_RETRYABLE))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedPermanent_mapping_correctValues() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals("×", ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT)),
|
||||||
|
() -> assertEquals("#c62828",
|
||||||
|
ProcessingStatusPresentation.cssColorFor(DocumentCompletionStatus.FAILED_PERMANENT)),
|
||||||
|
() -> assertEquals("fehlgeschlagen",
|
||||||
|
ProcessingStatusPresentation.summaryCategoryFor(DocumentCompletionStatus.FAILED_PERMANENT))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skippedAlreadyProcessed_mapping_correctValues() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals("≡",
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED)),
|
||||||
|
() -> assertEquals("übersprungen",
|
||||||
|
ProcessingStatusPresentation.summaryCategoryFor(
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skippedFinalFailure_mapping_correctValues() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals("⊘",
|
||||||
|
ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE)),
|
||||||
|
() -> assertEquals("endgültig übersprungen",
|
||||||
|
ProcessingStatusPresentation.summaryCategoryFor(
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Farbe ist NICHT einziges Unterscheidungsmerkmal
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedRetryable_and_failedPermanent_distinctByIconAndTooltip() {
|
||||||
|
String iconRetryable = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||||
|
String iconPermanent = ProcessingStatusPresentation.iconFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||||
|
String tooltipRetryable = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_RETRYABLE);
|
||||||
|
String tooltipPermanent = ProcessingStatusPresentation.tooltipFor(DocumentCompletionStatus.FAILED_PERMANENT);
|
||||||
|
assertAll(
|
||||||
|
() -> assertFalse(iconRetryable.equals(iconPermanent),
|
||||||
|
"Icons müssen sich unterscheiden"),
|
||||||
|
() -> assertFalse(tooltipRetryable.equals(tooltipPermanent),
|
||||||
|
"Tooltips müssen sich unterscheiden")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@
|
|||||||
<groupId>org.xerial</groupId>
|
<groupId>org.xerial</groupId>
|
||||||
<artifactId>sqlite-jdbc</artifactId>
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.json</groupId>
|
<groupId>org.json</groupId>
|
||||||
<artifactId>json</artifactId>
|
<artifactId>json</artifactId>
|
||||||
|
|||||||
+113
-23
@@ -2,9 +2,12 @@ package de.gecheckt.pdf.umbenenner.adapter.out.prompt;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
@@ -13,28 +16,36 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filesystem-based implementation of {@link PromptPort}.
|
* Dateisystembasierte Implementierung von {@link PromptPort}.
|
||||||
* <p>
|
* <p>
|
||||||
* Loads prompt templates from an external file on disk and derives a stable identifier
|
* Lädt Prompt-Templates aus einer externen Datei auf dem Datenträger und leitet einen
|
||||||
* from the filename. Ensures that empty or technically unusable prompts are rejected.
|
* stabilen Identifikator aus dem Dateinamen ab. Stellt sicher, dass leere oder technisch
|
||||||
|
* unbrauchbare Prompts abgelehnt werden.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Identifier derivation:</strong>
|
* <strong>Identifikatorableitung:</strong>
|
||||||
* The stable prompt identifier is derived from the filename of the prompt file.
|
* Der stabile Identifikator wird aus dem Dateinamen der Prompt-Datei abgeleitet.
|
||||||
* This ensures deterministic, reproducible identification across batch runs.
|
* Eine Prompt-Datei namens {@code "prompt_de_v2.txt"} erhält den Identifikator
|
||||||
* For example, a prompt file named {@code "prompt_de_v2.txt"} receives the identifier
|
|
||||||
* {@code "prompt_de_v2.txt"}.
|
* {@code "prompt_de_v2.txt"}.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Content validation:</strong>
|
* <strong>Inhaltsprüfung:</strong>
|
||||||
* After loading, the prompt content is trimmed and validated to ensure it is not empty.
|
* Nach dem Laden wird der Inhalt getrimmt und auf Leerheit geprüft. Ein leerer Prompt
|
||||||
* An empty prompt (or one containing only whitespace) is considered technically unusable
|
* (oder einer, der nur Leerzeichen enthält) gilt als technisch unbrauchbar und führt zu
|
||||||
* and results in a {@link PromptLoadingFailure}.
|
* {@link PromptLoadingFailure}.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Error handling:</strong>
|
* <strong>Atomares Speichern:</strong>
|
||||||
* All technical failures (file not found, I/O errors, permission issues) are caught
|
* {@link #savePrompt(String)} schreibt zunächst in eine temporäre Datei <em>im selben
|
||||||
* and returned as {@link PromptLoadingFailure} rather than thrown as exceptions.
|
* Verzeichnis</em> wie die Zieldatei und verschiebt diese danach atomar via
|
||||||
|
* {@code ATOMIC_MOVE}. Bei einem Fehler beim atomaren Verschieben wird kein stiller
|
||||||
|
* Fallback auf nicht-atomares Schreiben durchgeführt.
|
||||||
|
* <p>
|
||||||
|
* <strong>Fehlerbehandlung:</strong>
|
||||||
|
* Alle technischen Fehler (Datei nicht gefunden, I/O-Fehler, fehlende Berechtigungen)
|
||||||
|
* werden abgefangen und als strukturierte Ergebnistypen zurückgegeben – keine Exceptions
|
||||||
|
* werden propagiert.
|
||||||
*/
|
*/
|
||||||
public class FilesystemPromptPortAdapter implements PromptPort {
|
public class FilesystemPromptPortAdapter implements PromptPort {
|
||||||
|
|
||||||
@@ -43,15 +54,21 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
private final Path promptFilePath;
|
private final Path promptFilePath;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the adapter with the configured prompt file path.
|
* Erstellt den Adapter mit dem konfigurierten Pfad zur Prompt-Datei.
|
||||||
*
|
*
|
||||||
* @param promptFilePath the path to the prompt template file; must not be null
|
* @param promptFilePath Pfad zur Prompt-Template-Datei; darf nicht {@code null} sein
|
||||||
* @throws NullPointerException if promptFilePath is null
|
* @throws NullPointerException wenn {@code promptFilePath} null ist
|
||||||
*/
|
*/
|
||||||
public FilesystemPromptPortAdapter(Path promptFilePath) {
|
public FilesystemPromptPortAdapter(Path promptFilePath) {
|
||||||
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
|
this.promptFilePath = Objects.requireNonNull(promptFilePath, "promptFilePath must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt das konfigurierte Prompt-Template aus der Datei.
|
||||||
|
*
|
||||||
|
* @return {@link PromptLoadingResult} mit dem geladenen Inhalt oder einem klassifizierten Fehler;
|
||||||
|
* nie {@code null}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public PromptLoadingResult loadPrompt() {
|
public PromptLoadingResult loadPrompt() {
|
||||||
try {
|
try {
|
||||||
@@ -71,11 +88,11 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PromptIdentifier identifier = deriveIdentifier();
|
PromptIdentifier identifier = deriveIdentifier();
|
||||||
LOG.debug("Prompt loaded successfully from {}", promptFilePath);
|
LOG.debug("Prompt erfolgreich geladen von {}", promptFilePath);
|
||||||
return new PromptLoadingSuccess(identifier, trimmedContent);
|
return new PromptLoadingSuccess(identifier, trimmedContent);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOG.error("Failed to load prompt file: {}", promptFilePath, e);
|
LOG.error("Fehler beim Laden der Prompt-Datei: {}", promptFilePath, e);
|
||||||
return new PromptLoadingFailure(
|
return new PromptLoadingFailure(
|
||||||
"IO_ERROR",
|
"IO_ERROR",
|
||||||
"Failed to read prompt file: " + e.getMessage());
|
"Failed to read prompt file: " + e.getMessage());
|
||||||
@@ -83,15 +100,88 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a stable prompt identifier from the filename.
|
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||||
* <p>
|
* <p>
|
||||||
* The identifier is simply the filename (without the directory path).
|
* Der Ablauf:
|
||||||
* This ensures that the same prompt file always receives the same identifier.
|
* <ol>
|
||||||
|
* <li>Prüfen, ob der Zielordner existiert.</li>
|
||||||
|
* <li>Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen.</li>
|
||||||
|
* <li>Inhalt in UTF-8 in die temporäre Datei schreiben.</li>
|
||||||
|
* <li>Temporäre Datei via {@code ATOMIC_MOVE} zur Zieldatei verschieben.</li>
|
||||||
|
* <li>Bei Fehler: temporäre Datei aufräumen, Fehler als Ergebnis zurückgeben.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Zeilenenden werden unverändert übernommen. Es findet keine Normalisierung statt.
|
||||||
*
|
*
|
||||||
* @return a stable PromptIdentifier based on the filename
|
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||||
|
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code content} null ist
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public PromptSaveResult savePrompt(String content) {
|
||||||
|
Objects.requireNonNull(content, "content must not be null");
|
||||||
|
|
||||||
|
Path targetDir = promptFilePath.getParent();
|
||||||
|
if (targetDir == null || !Files.isDirectory(targetDir)) {
|
||||||
|
String message = "Zielordner der Prompt-Datei existiert nicht: "
|
||||||
|
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
|
||||||
|
LOG.warn("Prompt speichern fehlgeschlagen: {}", message);
|
||||||
|
return new PromptSaveResult.TargetDirectoryMissing(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporäre Datei im selben Verzeichnis wie die Zieldatei anlegen
|
||||||
|
// (nicht im System-Temp – ATOMIC_MOVE funktioniert nicht zuverlässig über Dateisystem-Grenzen)
|
||||||
|
Path tempFile = targetDir.resolve(".prompt-tmp-" + UUID.randomUUID() + ".tmp");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.write(tempFile, content.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (IOException e) {
|
||||||
|
beräumeTempDatei(tempFile);
|
||||||
|
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
|
||||||
|
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
|
||||||
|
return new PromptSaveResult.WriteFailed(message, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomares Verschieben – kein stiller Fallback auf nicht-atomares Move
|
||||||
|
try {
|
||||||
|
Files.move(tempFile, promptFilePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
LOG.info("Prompt-Datei erfolgreich gespeichert: {}", promptFilePath.toAbsolutePath());
|
||||||
|
return new PromptSaveResult.Saved(promptFilePath.toAbsolutePath().toString());
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
beräumeTempDatei(tempFile);
|
||||||
|
String message = "Atomares Verschieben der Prompt-Datei wird vom Dateisystem nicht unterstützt: " + e.getMessage();
|
||||||
|
LOG.warn("Prompt speichern fehlgeschlagen (kein Fallback): {}", message, e);
|
||||||
|
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||||
|
} catch (IOException e) {
|
||||||
|
beräumeTempDatei(tempFile);
|
||||||
|
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
|
||||||
|
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
|
||||||
|
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet den stabilen Prompt-Identifikator aus dem Dateinamen ab.
|
||||||
|
* <p>
|
||||||
|
* Der Identifikator entspricht dem Dateinamen ohne Verzeichnispfad.
|
||||||
|
*
|
||||||
|
* @return stabiler {@link PromptIdentifier} basierend auf dem Dateinamen
|
||||||
*/
|
*/
|
||||||
private PromptIdentifier deriveIdentifier() {
|
private PromptIdentifier deriveIdentifier() {
|
||||||
String filename = promptFilePath.getFileName().toString();
|
String filename = promptFilePath.getFileName().toString();
|
||||||
return new PromptIdentifier(filename);
|
return new PromptIdentifier(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht, die temporäre Datei zu löschen. Fehler werden nur geloggt.
|
||||||
|
*
|
||||||
|
* @param tempFile die zu löschende temporäre Datei
|
||||||
|
*/
|
||||||
|
private void beräumeTempDatei(Path tempFile) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
LOG.warn("Temporäre Prompt-Datei konnte nicht gelöscht werden: {}", tempFile, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+409
@@ -0,0 +1,409 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-Implementierung von {@link HistoryQueryPort}.
|
||||||
|
* <p>
|
||||||
|
* Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
|
||||||
|
* Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
|
||||||
|
* keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
|
||||||
|
* Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
|
||||||
|
* SQL-LIKE-Aufruf mit {@code \} escaped.
|
||||||
|
* <p>
|
||||||
|
* <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
|
||||||
|
* Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
|
||||||
|
* <p>
|
||||||
|
* <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
|
||||||
|
* Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
|
||||||
|
* als 500 Treffer vorhanden sind.
|
||||||
|
*/
|
||||||
|
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);
|
||||||
|
|
||||||
|
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
|
||||||
|
|
||||||
|
private final String jdbcUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
|
||||||
|
*
|
||||||
|
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
|
||||||
|
* @throws NullPointerException wenn {@code jdbcUrl} null ist
|
||||||
|
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
|
||||||
|
*/
|
||||||
|
public SqliteHistoryQueryAdapter(String jdbcUrl) {
|
||||||
|
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
|
||||||
|
if (jdbcUrl.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
|
||||||
|
}
|
||||||
|
this.jdbcUrl = jdbcUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
* <p>
|
||||||
|
* Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
|
||||||
|
* Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
|
||||||
|
*
|
||||||
|
* @param query Abfrageparameter; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
Objects.requireNonNull(query, "query darf nicht null sein");
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT
|
||||||
|
dr.fingerprint,
|
||||||
|
dr.overall_status,
|
||||||
|
dr.last_known_source_file_name,
|
||||||
|
dr.last_target_file_name,
|
||||||
|
dr.last_known_source_locator,
|
||||||
|
dr.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
|
||||||
|
FROM document_record dr
|
||||||
|
WHERE 1=1
|
||||||
|
""");
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
|
||||||
|
// Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
|
||||||
|
String searchText = query.searchText();
|
||||||
|
if (searchText != null && !searchText.isBlank()) {
|
||||||
|
String escaped = escapeSqlLike(searchText.strip().toLowerCase());
|
||||||
|
sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
|
||||||
|
+ "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
|
||||||
|
String pattern = "%" + escaped + "%";
|
||||||
|
params.add(pattern);
|
||||||
|
params.add(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Filter
|
||||||
|
String statusFilter = query.statusFilter();
|
||||||
|
if (statusFilter != null && !statusFilter.isBlank()) {
|
||||||
|
sql.append(" AND dr.overall_status = ?");
|
||||||
|
params.add(statusFilter.strip());
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
|
||||||
|
sql.append(" LIMIT ?");
|
||||||
|
params.add(query.limit());
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
Statement pragmaStmt = connection.createStatement();
|
||||||
|
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
|
||||||
|
|
||||||
|
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||||
|
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
stmt.setObject(i + 1, params.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
List<DocumentHistoryRow> rows = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
rows.add(mapToDocumentHistoryRow(rs));
|
||||||
|
}
|
||||||
|
logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
|
||||||
|
return List.copyOf(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
last_known_source_locator,
|
||||||
|
last_known_source_file_name,
|
||||||
|
overall_status,
|
||||||
|
content_error_count,
|
||||||
|
transient_error_count,
|
||||||
|
last_failure_instant,
|
||||||
|
last_success_instant,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
last_target_path,
|
||||||
|
last_target_file_name
|
||||||
|
FROM document_record
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
stmt.setString(1, fingerprint.sha256Hex());
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(mapToDocumentRecord(rs, fingerprint));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
|
||||||
|
* nie {@code null}; kann leer sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
fingerprint, run_id, attempt_number, started_at, ended_at,
|
||||||
|
status, failure_class, failure_message, retryable,
|
||||||
|
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
|
||||||
|
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
|
||||||
|
final_target_file_name
|
||||||
|
FROM processing_attempt
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
ORDER BY attempt_number ASC
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (Connection connection = getConnection();
|
||||||
|
Statement pragmaStmt = connection.createStatement();
|
||||||
|
PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
|
||||||
|
stmt.setString(1, fingerprint.sha256Hex());
|
||||||
|
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
List<ProcessingAttempt> attempts = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
attempts.add(mapToProcessingAttempt(rs));
|
||||||
|
}
|
||||||
|
return List.copyOf(attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mapping-Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
|
||||||
|
*
|
||||||
|
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||||
|
* @return die gemappte Zeile; nie {@code null}
|
||||||
|
* @throws SQLException bei JDBC-Lesefehlern
|
||||||
|
*/
|
||||||
|
private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
|
||||||
|
String fpHex = rs.getString("fingerprint");
|
||||||
|
String statusStr = rs.getString("overall_status");
|
||||||
|
String sourceFileName = rs.getString("last_known_source_file_name");
|
||||||
|
String targetFileName = rs.getString("last_target_file_name"); // nullable
|
||||||
|
String sourcePath = rs.getString("last_known_source_locator");
|
||||||
|
String updatedAtStr = rs.getString("updated_at");
|
||||||
|
long attemptCount = rs.getLong("attempt_count");
|
||||||
|
|
||||||
|
return new DocumentHistoryRow(
|
||||||
|
new DocumentFingerprint(fpHex),
|
||||||
|
ProcessingStatus.valueOf(statusStr),
|
||||||
|
sourceFileName,
|
||||||
|
targetFileName,
|
||||||
|
sourcePath,
|
||||||
|
stringToInstant(updatedAtStr),
|
||||||
|
attemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
|
||||||
|
*
|
||||||
|
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||||
|
* @param fingerprint der Fingerprint, der bereits bekannt ist
|
||||||
|
* @return der gemappte Stammsatz; nie {@code null}
|
||||||
|
* @throws SQLException bei JDBC-Lesefehlern
|
||||||
|
*/
|
||||||
|
private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
|
||||||
|
return new DocumentRecord(
|
||||||
|
fingerprint,
|
||||||
|
new SourceDocumentLocator(rs.getString("last_known_source_locator")),
|
||||||
|
rs.getString("last_known_source_file_name"),
|
||||||
|
ProcessingStatus.valueOf(rs.getString("overall_status")),
|
||||||
|
new FailureCounters(
|
||||||
|
rs.getInt("content_error_count"),
|
||||||
|
rs.getInt("transient_error_count")),
|
||||||
|
stringToInstant(rs.getString("last_failure_instant")),
|
||||||
|
stringToInstant(rs.getString("last_success_instant")),
|
||||||
|
stringToInstant(rs.getString("created_at")),
|
||||||
|
stringToInstant(rs.getString("updated_at")),
|
||||||
|
rs.getString("last_target_path"),
|
||||||
|
rs.getString("last_target_file_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
|
||||||
|
*
|
||||||
|
* @param rs das ResultSet, positioniert auf der aktuellen Zeile
|
||||||
|
* @return der gemappte Versuch; nie {@code null}
|
||||||
|
* @throws SQLException bei JDBC-Lesefehlern
|
||||||
|
*/
|
||||||
|
private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
|
||||||
|
String resolvedDateStr = rs.getString("resolved_date");
|
||||||
|
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
|
||||||
|
|
||||||
|
String dateSourceStr = rs.getString("date_source");
|
||||||
|
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
|
||||||
|
|
||||||
|
int processedPageCountRaw = rs.getInt("processed_page_count");
|
||||||
|
Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;
|
||||||
|
|
||||||
|
int sentCharacterCountRaw = rs.getInt("sent_character_count");
|
||||||
|
Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;
|
||||||
|
|
||||||
|
return new ProcessingAttempt(
|
||||||
|
new DocumentFingerprint(rs.getString("fingerprint")),
|
||||||
|
new RunId(rs.getString("run_id")),
|
||||||
|
rs.getInt("attempt_number"),
|
||||||
|
stringToInstant(rs.getString("started_at")),
|
||||||
|
stringToInstant(rs.getString("ended_at")),
|
||||||
|
ProcessingStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getString("failure_class"),
|
||||||
|
rs.getString("failure_message"),
|
||||||
|
rs.getBoolean("retryable"),
|
||||||
|
rs.getString("ai_provider"),
|
||||||
|
rs.getString("model_name"),
|
||||||
|
rs.getString("prompt_identifier"),
|
||||||
|
processedPageCount,
|
||||||
|
sentCharacterCount,
|
||||||
|
rs.getString("ai_raw_response"),
|
||||||
|
rs.getString("ai_reasoning"),
|
||||||
|
resolvedDate,
|
||||||
|
dateSource,
|
||||||
|
rs.getString("validated_title"),
|
||||||
|
rs.getString("final_target_file_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SQL-LIKE Escaping
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
|
||||||
|
* <p>
|
||||||
|
* Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
|
||||||
|
* {@code ESCAPE '\'} angegeben werden.
|
||||||
|
*
|
||||||
|
* @param input die rohe Benutzereingabe; darf nicht {@code null} sein
|
||||||
|
* @return der escaped String; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static String escapeSqlLike(String input) {
|
||||||
|
return input
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JDBC-Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
|
||||||
|
* <p>
|
||||||
|
* Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
|
||||||
|
* Transaktions-Verbindung bereitzustellen.
|
||||||
|
*
|
||||||
|
* @return eine neue Datenbankverbindung
|
||||||
|
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
|
||||||
|
*/
|
||||||
|
protected Connection getConnection() throws SQLException {
|
||||||
|
return DriverManager.getConnection(jdbcUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst einen Instant aus einer String-Darstellung.
|
||||||
|
* <p>
|
||||||
|
* Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
|
||||||
|
*
|
||||||
|
* @param stringValue die String-Darstellung; kann {@code null} sein
|
||||||
|
* @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
|
||||||
|
*/
|
||||||
|
private Instant stringToInstant(String stringValue) {
|
||||||
|
if (stringValue == null || stringValue.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Instant.parse(stringValue);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
LocalDateTime dateTime = LocalDateTime.parse(stringValue,
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
return dateTime.atZone(ZoneId.of("UTC")).toInstant();
|
||||||
|
} catch (Exception fallback) {
|
||||||
|
logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+517
-277
@@ -1,337 +1,577 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DatabaseMetaData;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Statement;
|
import java.time.Instant;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.sqlite.SQLiteConfig;
|
||||||
|
import org.sqlite.SQLiteDataSource;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQLite implementation of {@link PersistenceSchemaInitializationPort}.
|
* Flyway-basierte Implementierung von {@link PersistenceSchemaInitializationPort}.
|
||||||
* <p>
|
|
||||||
* Creates or verifies the two-level persistence schema in the configured SQLite
|
|
||||||
* database file, and performs a controlled schema evolution from an earlier schema
|
|
||||||
* version to the current one.
|
|
||||||
*
|
*
|
||||||
* <h2>Two-level schema</h2>
|
* <p>Erstellt oder verifiziert das Zwei-Ebenen-Persistenzschema in der konfigurierten
|
||||||
* <p>The schema consists of exactly two tables:
|
* SQLite-Datenbank und führt dabei eine differenzierte Startstrategie durch,
|
||||||
* <ol>
|
* die drei Fälle unterscheidet:
|
||||||
* <li><strong>{@code document_record}</strong> — the document master record
|
|
||||||
* (Dokument-Stammsatz). One row per unique SHA-256 fingerprint.</li>
|
|
||||||
* <li><strong>{@code processing_attempt}</strong> — the processing attempt history
|
|
||||||
* (Versuchshistorie). One row per historised processing attempt, referencing
|
|
||||||
* the master record via fingerprint.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
*
|
||||||
* <h2>Schema evolution</h2>
|
* <h2>Fall 1 – Leere Datenbank</h2>
|
||||||
* <p>
|
* <p>Keine fachlichen Tabellen und keine Flyway-History-Tabelle vorhanden
|
||||||
* When upgrading from an earlier schema, this adapter uses idempotent
|
* (bzw. Datei existiert noch nicht). Flyway führt {@code V1__initial_schema.sql}
|
||||||
* {@code ALTER TABLE ... ADD COLUMN} statements for both tables. Columns that already
|
* vollständig aus und legt das komplette Schema an.
|
||||||
* exist are silently skipped, making the evolution safe to run on both fresh and existing
|
|
||||||
* databases. The current evolution adds:
|
|
||||||
* <ul>
|
|
||||||
* <li>AI-traceability columns to {@code processing_attempt}</li>
|
|
||||||
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
|
|
||||||
* {@code document_record}</li>
|
|
||||||
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
|
|
||||||
* <li>Provider-identifier column ({@code ai_provider}) to {@code processing_attempt};
|
|
||||||
* existing rows receive {@code NULL} as the default, which is the correct value for
|
|
||||||
* attempts recorded before provider tracking was introduced.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
*
|
||||||
* <h2>Legacy-state migration</h2>
|
* <h2>Fall 2 – Bestehende Datenbank ohne Flyway-History</h2>
|
||||||
* <p>
|
* <p>Fachliche Tabellen sind vorhanden, aber die Flyway-History-Tabelle fehlt.
|
||||||
* Documents in an earlier positive intermediate state ({@code SUCCESS} recorded without
|
* Vor der Baseline-Eintralung wird eine vollständige Schema-Prüfung gegen das
|
||||||
* a validated naming proposal) are idempotently migrated to {@code READY_FOR_AI} so that
|
* V1-Zielschema durchgeführt. Bei konformem Schema wird ein datiertes Backup der
|
||||||
* the AI naming pipeline processes them in the next run. Terminal negative states
|
* SQLite-Datei erstellt, und Flyway trägt nur eine Baseline ein (Skript wird
|
||||||
* ({@code FAILED_RETRYABLE}, {@code FAILED_FINAL}, skip states) are left unchanged.
|
* <em>nicht</em> ausgeführt). Bei fehlendem Schema-Element bricht der Start mit
|
||||||
|
* einer klaren Fehlermeldung ab.
|
||||||
*
|
*
|
||||||
* <h2>Initialisation timing</h2>
|
* <h2>Fall 3 – Folgestart mit Flyway-History</h2>
|
||||||
* <p>This adapter must be invoked <em>once</em> at program startup, before the batch
|
* <p>Flyway-History-Tabelle ist vorhanden. Flyway läuft idempotent und
|
||||||
* document processing loop begins.
|
* führt nur noch fehlende Migrationen aus.
|
||||||
*
|
*
|
||||||
* <h2>Architecture boundary</h2>
|
* <h2>Fremdschlüssel</h2>
|
||||||
* <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined
|
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
|
||||||
* to this class. No JDBC or SQLite types appear in the port interface or in any
|
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
|
||||||
* application/domain type.
|
* {@code PRAGMA foreign_keys = ON} erhält.
|
||||||
|
*
|
||||||
|
* <h2>Architekturgrenze</h2>
|
||||||
|
* <p>Alle JDBC-Verbindungen, SQL-DDL und SQLite-spezifisches Verhalten sind
|
||||||
|
* ausschließlich in dieser Klasse gekapselt. Im Port-Interface und in den
|
||||||
|
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
|
||||||
*/
|
*/
|
||||||
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
|
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
|
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// DDL — document_record table
|
// Erwartete Tabellen und Spalten gemäß V1-Zielschema
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */
|
||||||
* DDL for the document master record table.
|
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
|
||||||
* <p>
|
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
|
||||||
* Columns: id (PK), fingerprint (unique), last_known_source_locator,
|
"overall_status", "content_error_count", "transient_error_count",
|
||||||
* last_known_source_file_name, overall_status, content_error_count,
|
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
|
||||||
* transient_error_count, last_failure_instant, last_success_instant,
|
"last_target_path", "last_target_file_name"
|
||||||
* created_at, updated_at.
|
);
|
||||||
*/
|
|
||||||
private static final String DDL_CREATE_DOCUMENT_RECORD = """
|
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
|
||||||
CREATE TABLE IF NOT EXISTS document_record (
|
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
|
||||||
fingerprint TEXT NOT NULL,
|
"status", "failure_class", "failure_message", "retryable",
|
||||||
last_known_source_locator TEXT NOT NULL,
|
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
||||||
last_known_source_file_name TEXT NOT NULL,
|
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
||||||
overall_status TEXT NOT NULL,
|
"validated_title", "final_target_file_name", "ai_provider"
|
||||||
content_error_count INTEGER NOT NULL DEFAULT 0,
|
);
|
||||||
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_failure_instant TEXT,
|
/** Erwartete Indizes. */
|
||||||
last_success_instant TEXT,
|
private static final Set<String> EXPECTED_INDEXES = Set.of(
|
||||||
created_at TEXT NOT NULL,
|
"idx_processing_attempt_fingerprint",
|
||||||
updated_at TEXT NOT NULL,
|
"idx_processing_attempt_run_id",
|
||||||
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
"idx_document_record_overall_status"
|
||||||
)
|
);
|
||||||
""";
|
|
||||||
|
/** Name der Flyway-History-Tabelle. */
|
||||||
|
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// DDL — processing_attempt table (base schema, without AI traceability cols)
|
// Felder
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* DDL for the base processing attempt history table.
|
|
||||||
* <p>
|
|
||||||
* Base columns (present in all schema versions): id, fingerprint, run_id,
|
|
||||||
* attempt_number, started_at, ended_at, status, failure_class, failure_message, retryable.
|
|
||||||
* <p>
|
|
||||||
* AI traceability columns are added separately via {@code ALTER TABLE} to support
|
|
||||||
* idempotent evolution from earlier schemas.
|
|
||||||
*/
|
|
||||||
private static final String DDL_CREATE_PROCESSING_ATTEMPT = """
|
|
||||||
CREATE TABLE IF NOT EXISTS processing_attempt (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
fingerprint TEXT NOT NULL,
|
|
||||||
run_id TEXT NOT NULL,
|
|
||||||
attempt_number INTEGER NOT NULL,
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
ended_at TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
failure_class TEXT,
|
|
||||||
failure_message TEXT,
|
|
||||||
retryable INTEGER NOT NULL DEFAULT 0,
|
|
||||||
CONSTRAINT fk_processing_attempt_fingerprint
|
|
||||||
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
|
|
||||||
CONSTRAINT uq_processing_attempt_fingerprint_number
|
|
||||||
UNIQUE (fingerprint, attempt_number)
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// DDL — indexes
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */
|
|
||||||
private static final String DDL_IDX_ATTEMPT_FINGERPRINT =
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint "
|
|
||||||
+ "ON processing_attempt (fingerprint)";
|
|
||||||
|
|
||||||
/** Index on {@code processing_attempt.run_id} for fast per-run lookups. */
|
|
||||||
private static final String DDL_IDX_ATTEMPT_RUN_ID =
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id "
|
|
||||||
+ "ON processing_attempt (run_id)";
|
|
||||||
|
|
||||||
/** Index on {@code document_record.overall_status} for fast status-based filtering. */
|
|
||||||
private static final String DDL_IDX_RECORD_STATUS =
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_document_record_overall_status "
|
|
||||||
+ "ON document_record (overall_status)";
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// DDL — columns added to processing_attempt via schema evolution
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Columns to add idempotently to {@code processing_attempt}.
|
|
||||||
* Each entry is {@code [column_name, column_type]}.
|
|
||||||
* <p>
|
|
||||||
* {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the
|
|
||||||
* correct sentinel for attempts recorded before provider tracking was introduced.
|
|
||||||
*/
|
|
||||||
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
|
|
||||||
{"model_name", "TEXT"},
|
|
||||||
{"prompt_identifier", "TEXT"},
|
|
||||||
{"processed_page_count", "INTEGER"},
|
|
||||||
{"sent_character_count", "INTEGER"},
|
|
||||||
{"ai_raw_response", "TEXT"},
|
|
||||||
{"ai_reasoning", "TEXT"},
|
|
||||||
{"resolved_date", "TEXT"},
|
|
||||||
{"date_source", "TEXT"},
|
|
||||||
{"validated_title", "TEXT"},
|
|
||||||
{"final_target_file_name", "TEXT"},
|
|
||||||
{"ai_provider", "TEXT"},
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// DDL — columns added to document_record via schema evolution
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Columns to add idempotently to {@code document_record}.
|
|
||||||
* Each entry is {@code [column_name, column_type]}.
|
|
||||||
*/
|
|
||||||
private static final String[][] EVOLUTION_RECORD_COLUMNS = {
|
|
||||||
{"last_target_path", "TEXT"},
|
|
||||||
{"last_target_file_name", "TEXT"},
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Legacy-state status migration
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates earlier positive intermediate states in {@code document_record} that were
|
|
||||||
* recorded as {@code SUCCESS} without a validated naming proposal to {@code READY_FOR_AI},
|
|
||||||
* so the AI naming pipeline processes them in the next run.
|
|
||||||
* <p>
|
|
||||||
* Only rows with {@code overall_status = 'SUCCESS'} that have no corresponding
|
|
||||||
* {@code processing_attempt} with {@code status = 'PROPOSAL_READY'} are updated.
|
|
||||||
* This migration is idempotent.
|
|
||||||
*/
|
|
||||||
private static final String SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI = """
|
|
||||||
UPDATE document_record
|
|
||||||
SET overall_status = 'READY_FOR_AI',
|
|
||||||
updated_at = datetime('now')
|
|
||||||
WHERE overall_status = 'SUCCESS'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM processing_attempt pa
|
|
||||||
WHERE pa.fingerprint = document_record.fingerprint
|
|
||||||
AND pa.status = 'PROPOSAL_READY'
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs the adapter with the JDBC URL of the SQLite database file.
|
* Erstellt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
|
||||||
*
|
*
|
||||||
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
|
* @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
|
||||||
* @throws NullPointerException if {@code jdbcUrl} is null
|
* @throws NullPointerException wenn {@code jdbcUrl} {@code null} ist
|
||||||
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
|
* @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
|
||||||
*/
|
*/
|
||||||
public SqliteSchemaInitializationAdapter(String jdbcUrl) {
|
public SqliteSchemaInitializationAdapter(String jdbcUrl) {
|
||||||
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
|
Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
|
||||||
if (jdbcUrl.isBlank()) {
|
if (jdbcUrl.isBlank()) {
|
||||||
throw new IllegalArgumentException("jdbcUrl must not be blank");
|
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
|
||||||
}
|
}
|
||||||
this.jdbcUrl = jdbcUrl;
|
this.jdbcUrl = jdbcUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates or verifies the persistence schema and performs schema evolution and
|
* Erstellt oder verifiziert das Persistenzschema per Flyway.
|
||||||
* status migration.
|
|
||||||
* <p>
|
|
||||||
* Execution order:
|
|
||||||
* <ol>
|
|
||||||
* <li>Enable foreign key enforcement.</li>
|
|
||||||
* <li>Create {@code document_record} table (if not exists).</li>
|
|
||||||
* <li>Create {@code processing_attempt} table (if not exists).</li>
|
|
||||||
* <li>Create all indexes (if not exist).</li>
|
|
||||||
* <li>Add AI-traceability and provider-identifier columns to {@code processing_attempt}
|
|
||||||
* (idempotent evolution).</li>
|
|
||||||
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
|
|
||||||
* </ol>
|
|
||||||
* <p>
|
|
||||||
* All steps are safe to run on both fresh and existing databases.
|
|
||||||
*
|
*
|
||||||
* @throws DocumentPersistenceException if any DDL or migration step fails
|
* <p>Erkennt anhand des Datenbankzustands automatisch einen der drei Fälle
|
||||||
|
* (leere DB, bestehende DB ohne Flyway-History, Folgestart mit Flyway-History)
|
||||||
|
* und wählt die passende Flyway-Konfiguration.
|
||||||
|
*
|
||||||
|
* @throws DocumentPersistenceException wenn das Schema nicht erstellt oder verifiziert
|
||||||
|
* werden kann, oder wenn die Schema-Prüfung bei
|
||||||
|
* einer bestehenden Datenbank fehlschlägt
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void initializeSchema() {
|
public void initializeSchema() {
|
||||||
logger.info("Initialising SQLite persistence schema at: {}", jdbcUrl);
|
logger.info("Schema-Initialisierung gestartet für: {}", jdbcUrl);
|
||||||
try (Connection connection = DriverManager.getConnection(jdbcUrl);
|
try {
|
||||||
Statement statement = connection.createStatement()) {
|
DataSource dataSource = createDataSource();
|
||||||
|
DbState state = determineDbState(dataSource);
|
||||||
|
logger.info("Erkannter Datenbankzustand: {}", state);
|
||||||
|
|
||||||
// Enable foreign key enforcement (SQLite disables it by default)
|
switch (state) {
|
||||||
statement.execute("PRAGMA foreign_keys = ON");
|
case EMPTY -> runFall1NewDb(dataSource);
|
||||||
|
case EXISTING_WITHOUT_FLYWAY -> runFall2BaselineExistingDb(dataSource);
|
||||||
// Level 1: document master record
|
case FLYWAY_MANAGED -> runFall3FollowUpStart(dataSource);
|
||||||
statement.execute(DDL_CREATE_DOCUMENT_RECORD);
|
|
||||||
logger.debug("Table 'document_record' created or already present.");
|
|
||||||
|
|
||||||
// Level 2: processing attempt history (base columns only)
|
|
||||||
statement.execute(DDL_CREATE_PROCESSING_ATTEMPT);
|
|
||||||
logger.debug("Table 'processing_attempt' created or already present.");
|
|
||||||
|
|
||||||
// Indexes for efficient per-document, per-run, and per-status access
|
|
||||||
statement.execute(DDL_IDX_ATTEMPT_FINGERPRINT);
|
|
||||||
statement.execute(DDL_IDX_ATTEMPT_RUN_ID);
|
|
||||||
statement.execute(DDL_IDX_RECORD_STATUS);
|
|
||||||
logger.debug("Indexes created or already present.");
|
|
||||||
|
|
||||||
// Schema evolution: add AI-traceability + target-copy columns (idempotent)
|
|
||||||
evolveTableColumns(connection, "processing_attempt", EVOLUTION_ATTEMPT_COLUMNS);
|
|
||||||
evolveTableColumns(connection, "document_record", EVOLUTION_RECORD_COLUMNS);
|
|
||||||
|
|
||||||
// Status migration: earlier positive intermediate state → READY_FOR_AI
|
|
||||||
int migrated = statement.executeUpdate(SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI);
|
|
||||||
if (migrated > 0) {
|
|
||||||
logger.info("Status migration: {} document(s) migrated from legacy SUCCESS state to READY_FOR_AI.",
|
|
||||||
migrated);
|
|
||||||
} else {
|
|
||||||
logger.debug("Status migration: no documents required migration.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("SQLite schema initialisation and migration completed successfully.");
|
logger.info("Schema-Initialisierung erfolgreich abgeschlossen.");
|
||||||
|
} catch (DocumentPersistenceException e) {
|
||||||
} catch (SQLException e) {
|
throw e;
|
||||||
String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage();
|
} catch (Exception e) {
|
||||||
logger.error(message, e);
|
String msg = "Schema-Initialisierung fehlgeschlagen für '" + jdbcUrl + "': " + e.getMessage();
|
||||||
throw new DocumentPersistenceException(message, e);
|
logger.error(msg, e);
|
||||||
|
throw new DocumentPersistenceException(msg, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotently adds the given columns to the specified table.
|
* Gibt die JDBC-URL zurück, die dieser Adapter verwendet.
|
||||||
* <p>
|
|
||||||
* For each column that does not yet exist, an {@code ALTER TABLE ... ADD COLUMN}
|
|
||||||
* statement is executed. Columns that already exist are silently skipped.
|
|
||||||
*
|
*
|
||||||
* @param connection an open JDBC connection to the database
|
* @return die JDBC-URL; niemals {@code null} oder leer
|
||||||
* @param tableName the name of the table to evolve
|
|
||||||
* @param columns array of {@code [column_name, column_type]} pairs to add
|
|
||||||
* @throws SQLException if a column addition fails for a reason other than duplicate column
|
|
||||||
*/
|
|
||||||
private void evolveTableColumns(Connection connection, String tableName, String[][] columns)
|
|
||||||
throws SQLException {
|
|
||||||
java.util.Set<String> existingColumns = new java.util.HashSet<>();
|
|
||||||
try (ResultSet rs = connection.getMetaData().getColumns(null, null, tableName, null)) {
|
|
||||||
while (rs.next()) {
|
|
||||||
existingColumns.add(rs.getString("COLUMN_NAME").toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String[] col : columns) {
|
|
||||||
String columnName = col[0];
|
|
||||||
String columnType = col[1];
|
|
||||||
if (!existingColumns.contains(columnName.toLowerCase())) {
|
|
||||||
String alterSql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnType;
|
|
||||||
try (Statement stmt = connection.createStatement()) {
|
|
||||||
stmt.execute(alterSql);
|
|
||||||
}
|
|
||||||
logger.debug("Schema evolution: added column '{}' to '{}'.", columnName, tableName);
|
|
||||||
} else {
|
|
||||||
logger.debug("Schema evolution: column '{}' in '{}' already present, skipped.",
|
|
||||||
columnName, tableName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
|
|
||||||
*
|
|
||||||
* @return the JDBC URL; never null or blank
|
|
||||||
*/
|
*/
|
||||||
public String getJdbcUrl() {
|
public String getJdbcUrl() {
|
||||||
return jdbcUrl;
|
return jdbcUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Fallbehandlung
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fall 1: Leere Datenbank – Flyway führt V1__initial_schema.sql vollständig aus.
|
||||||
|
*
|
||||||
|
* @param dataSource die konfigurierte DataSource
|
||||||
|
*/
|
||||||
|
private void runFall1NewDb(DataSource dataSource) {
|
||||||
|
logger.info("Fall 1: Leere Datenbank – Flyway legt vollständiges Schema an.");
|
||||||
|
Flyway flyway = buildFlyway(dataSource, false);
|
||||||
|
flyway.migrate();
|
||||||
|
logger.info("Fall 1: Schema vollständig erstellt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fall 2: Bestehende Datenbank ohne Flyway-History.
|
||||||
|
*
|
||||||
|
* <p>Führt die vollständige Schema-Prüfcheckliste durch. Bei konformem Schema
|
||||||
|
* wird ein datiertes Backup angelegt und Flyway trägt nur eine Baseline ein.
|
||||||
|
* Bei fehlendem Schema-Element bricht der Start ab.
|
||||||
|
*
|
||||||
|
* @param dataSource die konfigurierte DataSource
|
||||||
|
* @throws DocumentPersistenceException wenn das Schema nicht konform ist oder das Backup schlägt fehl
|
||||||
|
*/
|
||||||
|
private void runFall2BaselineExistingDb(DataSource dataSource) {
|
||||||
|
logger.info("Fall 2: Bestehende Datenbank ohne Flyway-History – Schema-Prüfung läuft.");
|
||||||
|
|
||||||
|
// Vollständige Schema-Prüfung vor Baseline
|
||||||
|
try (Connection conn = dataSource.getConnection()) {
|
||||||
|
verifyExistingSchemaMatches(conn);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String msg = "Datenbankverbindung für Schema-Prüfung fehlgeschlagen: " + e.getMessage();
|
||||||
|
logger.error(msg, e);
|
||||||
|
throw new DocumentPersistenceException(msg, e);
|
||||||
|
}
|
||||||
|
logger.info("Fall 2: Schema-Prüfung bestanden.");
|
||||||
|
|
||||||
|
// Backup der SQLite-Datei anlegen
|
||||||
|
createDatedBackup();
|
||||||
|
|
||||||
|
// Flyway-Baseline eintragen (V1 wird NICHT ausgeführt)
|
||||||
|
Flyway flyway = buildFlyway(dataSource, true);
|
||||||
|
flyway.migrate();
|
||||||
|
logger.info("Fall 2: Flyway-Baseline erfolgreich eingetragen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fall 3: Folgestart – Flyway läuft idempotent und führt nur fehlende Migrationen aus.
|
||||||
|
*
|
||||||
|
* @param dataSource die konfigurierte DataSource
|
||||||
|
*/
|
||||||
|
private void runFall3FollowUpStart(DataSource dataSource) {
|
||||||
|
logger.info("Fall 3: Folgestart mit Flyway-History – idempotente Migration.");
|
||||||
|
Flyway flyway = buildFlyway(dataSource, false);
|
||||||
|
flyway.migrate();
|
||||||
|
logger.info("Fall 3: Migration abgeschlossen (idempotent).");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine standardisiert konfigurierte {@link Flyway}-Instanz.
|
||||||
|
*
|
||||||
|
* <p>Alle drei Fälle nutzen dieselbe Grundkonfiguration:
|
||||||
|
* <ul>
|
||||||
|
* <li>Explizite Migrations-Location {@code classpath:db/migration} – verhindert
|
||||||
|
* unerwünschtes Klasspfad-Scannen des gesamten JARs.</li>
|
||||||
|
* <li>Keine Umgebungsvariablen-Konfiguration – verhindert unbeabsichtigte
|
||||||
|
* Übersteuerung durch Build-System-Variablen.</li>
|
||||||
|
* <li>Kein Verbindungs-Retry ({@code connectRetries=0}) – Fehler schlagen
|
||||||
|
* sofort statt nach mehreren Sekunden Wartezeit fehl.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param dataSource die zu verwendende DataSource
|
||||||
|
* @param baselineOnMigrate ob beim Migrate eine Baseline einzutragen ist (nur Fall 2)
|
||||||
|
* @return eine konfigurierte, betriebsbereite {@link Flyway}-Instanz
|
||||||
|
*/
|
||||||
|
private Flyway buildFlyway(DataSource dataSource, boolean baselineOnMigrate) {
|
||||||
|
var config = Flyway.configure()
|
||||||
|
.dataSource(dataSource)
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.connectRetries(0)
|
||||||
|
.baselineOnMigrate(baselineOnMigrate);
|
||||||
|
if (baselineOnMigrate) {
|
||||||
|
config = config
|
||||||
|
.baselineVersion("1")
|
||||||
|
.baselineDescription("Bestehende Datenbank baselined");
|
||||||
|
}
|
||||||
|
return config.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Datenbankzustand erkennen
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert den erkannten Zustand der SQLite-Datenbank beim Start.
|
||||||
|
*/
|
||||||
|
enum DbState {
|
||||||
|
/** Keine fachlichen Tabellen und keine Flyway-History vorhanden. */
|
||||||
|
EMPTY,
|
||||||
|
/** Fachliche Tabellen vorhanden, aber keine Flyway-History-Tabelle. */
|
||||||
|
EXISTING_WITHOUT_FLYWAY,
|
||||||
|
/** Flyway-History-Tabelle vorhanden – Datenbank wird bereits von Flyway verwaltet. */
|
||||||
|
FLYWAY_MANAGED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt den aktuellen Zustand der Datenbank.
|
||||||
|
*
|
||||||
|
* <p>"Leer" bedeutet: keine Tabellen vorhanden – nicht nur Dateigröße 0 Byte.
|
||||||
|
*
|
||||||
|
* @param dataSource die zu prüfende DataSource
|
||||||
|
* @return der erkannte {@link DbState}
|
||||||
|
* @throws DocumentPersistenceException bei Verbindungsfehlern
|
||||||
|
*/
|
||||||
|
private DbState determineDbState(DataSource dataSource) {
|
||||||
|
try (Connection conn = dataSource.getConnection()) {
|
||||||
|
DatabaseMetaData meta = conn.getMetaData();
|
||||||
|
Set<String> tables = readTableNames(meta);
|
||||||
|
|
||||||
|
if (tables.contains(FLYWAY_HISTORY_TABLE)) {
|
||||||
|
return DbState.FLYWAY_MANAGED;
|
||||||
|
}
|
||||||
|
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
|
||||||
|
boolean hasFachlicheTabellen = tables.contains("document_record")
|
||||||
|
|| tables.contains("processing_attempt");
|
||||||
|
if (hasFachlicheTabellen) {
|
||||||
|
return DbState.EXISTING_WITHOUT_FLYWAY;
|
||||||
|
}
|
||||||
|
return DbState.EMPTY;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String msg = "Datenbankzustand konnte nicht ermittelt werden: " + e.getMessage();
|
||||||
|
logger.error(msg, e);
|
||||||
|
throw new DocumentPersistenceException(msg, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Schema-Prüfcheckliste (Fall 2)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständige Schema-Prüfung gegen das V1-Zielschema.
|
||||||
|
*
|
||||||
|
* <p>Prüft alle erwarteten Tabellen, Spalten, Constraints und Indizes per
|
||||||
|
* {@link DatabaseMetaData}. Bei fehlendem Element wird der Start sofort mit
|
||||||
|
* einer aussagekräftigen Fehlermeldung abgebrochen – kein stilles Heilen.
|
||||||
|
*
|
||||||
|
* @param conn offene JDBC-Verbindung zur Datenbank
|
||||||
|
* @throws DocumentPersistenceException wenn ein Schema-Element fehlt
|
||||||
|
* @throws SQLException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
private void verifyExistingSchemaMatches(Connection conn) throws SQLException {
|
||||||
|
DatabaseMetaData meta = conn.getMetaData();
|
||||||
|
List<String> fehler = new ArrayList<>();
|
||||||
|
|
||||||
|
// Tabellen prüfen
|
||||||
|
Set<String> tabellen = readTableNames(meta);
|
||||||
|
if (!tabellen.contains("document_record")) {
|
||||||
|
fehler.add("Tabelle 'document_record' fehlt");
|
||||||
|
}
|
||||||
|
if (!tabellen.contains("processing_attempt")) {
|
||||||
|
fehler.add("Tabelle 'processing_attempt' fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spalten prüfen – nur wenn Tabellen vorhanden
|
||||||
|
if (tabellen.contains("document_record")) {
|
||||||
|
pruefeSpaltenvollstaendigkeit(meta, "document_record",
|
||||||
|
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
|
||||||
|
}
|
||||||
|
if (tabellen.contains("processing_attempt")) {
|
||||||
|
pruefeSpaltenvollstaendigkeit(meta, "processing_attempt",
|
||||||
|
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indizes prüfen
|
||||||
|
if (tabellen.contains("document_record") && tabellen.contains("processing_attempt")) {
|
||||||
|
Set<String> vorhandeneIndizes = readIndexNames(meta);
|
||||||
|
for (String erwartetIndex : EXPECTED_INDEXES) {
|
||||||
|
if (!vorhandeneIndizes.contains(erwartetIndex)) {
|
||||||
|
fehler.add("Index '" + erwartetIndex + "' fehlt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraints prüfen (soweit per Metadata prüfbar)
|
||||||
|
if (tabellen.contains("document_record")) {
|
||||||
|
pruefeUniqueConstraintAufFingerprint(conn, fehler);
|
||||||
|
}
|
||||||
|
if (tabellen.contains("processing_attempt")) {
|
||||||
|
pruefeForeignKeyAufDocumentRecord(conn, fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fehler.isEmpty()) {
|
||||||
|
String fehlerliste = String.join("; ", fehler);
|
||||||
|
String msg = "Schema-Prüfung fehlgeschlagen – folgende Elemente fehlen oder sind nicht konform: "
|
||||||
|
+ fehlerliste;
|
||||||
|
logger.error(msg);
|
||||||
|
throw new DocumentPersistenceException(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob alle erwarteten Spalten in der angegebenen Tabelle vorhanden sind.
|
||||||
|
*
|
||||||
|
* @param meta Datenbankmetadaten
|
||||||
|
* @param tabellenname Name der zu prüfenden Tabelle
|
||||||
|
* @param erwarteteSpalten Menge der erwarteten Spaltennamen (Kleinschreibung)
|
||||||
|
* @param fehler Liste, in die fehlende Elemente eingetragen werden
|
||||||
|
* @throws SQLException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
private void pruefeSpaltenvollstaendigkeit(DatabaseMetaData meta, String tabellenname,
|
||||||
|
Set<String> erwarteteSpalten, List<String> fehler) throws SQLException {
|
||||||
|
Set<String> vorhandeneSpalten = new HashSet<>();
|
||||||
|
try (ResultSet rs = meta.getColumns(null, null, tabellenname, null)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
vorhandeneSpalten.add(rs.getString("COLUMN_NAME").toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String erwartet : erwarteteSpalten) {
|
||||||
|
if (!vorhandeneSpalten.contains(erwartet)) {
|
||||||
|
fehler.add("Spalte '" + tabellenname + "." + erwartet + "' fehlt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das UNIQUE-Constraint auf {@code document_record.fingerprint} anhand der
|
||||||
|
* Indexmetadaten.
|
||||||
|
*
|
||||||
|
* @param conn offene JDBC-Verbindung
|
||||||
|
* @param fehler Liste, in die fehlende Elemente eingetragen werden
|
||||||
|
* @throws SQLException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
private void pruefeUniqueConstraintAufFingerprint(Connection conn,
|
||||||
|
List<String> fehler) throws SQLException {
|
||||||
|
boolean uniqueGefunden = false;
|
||||||
|
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, "document_record", true, false)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String spalte = rs.getString("COLUMN_NAME");
|
||||||
|
if ("fingerprint".equalsIgnoreCase(spalte)) {
|
||||||
|
uniqueGefunden = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!uniqueGefunden) {
|
||||||
|
fehler.add("UNIQUE-Constraint auf 'document_record.fingerprint' fehlt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Foreign Key von {@code processing_attempt.fingerprint} auf
|
||||||
|
* {@code document_record.fingerprint} anhand der Importschlüssel-Metadaten.
|
||||||
|
*
|
||||||
|
* @param conn offene JDBC-Verbindung
|
||||||
|
* @param fehler Liste, in die fehlende Elemente eingetragen werden
|
||||||
|
* @throws SQLException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
private void pruefeForeignKeyAufDocumentRecord(Connection conn,
|
||||||
|
List<String> fehler) throws SQLException {
|
||||||
|
boolean fkGefunden = false;
|
||||||
|
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, "processing_attempt")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String pkTabelle = rs.getString("PKTABLE_NAME");
|
||||||
|
String fkSpalte = rs.getString("FKCOLUMN_NAME");
|
||||||
|
if ("document_record".equalsIgnoreCase(pkTabelle)
|
||||||
|
&& "fingerprint".equalsIgnoreCase(fkSpalte)) {
|
||||||
|
fkGefunden = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fkGefunden) {
|
||||||
|
fehler.add("Foreign Key von 'processing_attempt.fingerprint' auf 'document_record.fingerprint' fehlt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Backup-Erstellung (Fall 2)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine datierte Kopie der SQLite-Datei als Backup.
|
||||||
|
*
|
||||||
|
* <p>Das Backup-Dateiname-Schema lautet: {@code <original>.<timestamp>.bak},
|
||||||
|
* z. B. {@code data.db.20260430T120000Z.bak}.
|
||||||
|
* Bei einer Kollision wird ein Zähler angehängt.
|
||||||
|
*
|
||||||
|
* @throws DocumentPersistenceException wenn das Backup nicht angelegt werden kann
|
||||||
|
*/
|
||||||
|
private void createDatedBackup() {
|
||||||
|
Path dbPath = extractDbPath();
|
||||||
|
if (dbPath == null) {
|
||||||
|
logger.warn("Kein lokaler Dateipfad aus JDBC-URL ableitbar – Backup übersprungen: {}", jdbcUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Files.exists(dbPath)) {
|
||||||
|
logger.debug("Datenbankdatei existiert noch nicht – kein Backup nötig.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String zeitstempel = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
||||||
|
.format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC));
|
||||||
|
Path backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + ".bak");
|
||||||
|
|
||||||
|
// Kollisionsauflösung
|
||||||
|
int zaehler = 1;
|
||||||
|
while (Files.exists(backup)) {
|
||||||
|
backup = dbPath.resolveSibling(dbPath.getFileName() + "." + zeitstempel + "." + zaehler + ".bak");
|
||||||
|
zaehler++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.copy(dbPath, backup, StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
|
logger.info("Backup der Datenbankdatei erstellt: {}", backup);
|
||||||
|
} catch (IOException e) {
|
||||||
|
String msg = "Backup der Datenbankdatei konnte nicht erstellt werden: " + e.getMessage();
|
||||||
|
logger.error(msg, e);
|
||||||
|
throw new DocumentPersistenceException(msg, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet den Dateisystempfad aus der JDBC-URL ab.
|
||||||
|
*
|
||||||
|
* <p>Erwartet URLs der Form {@code jdbc:sqlite:/pfad/zur/datei.db}.
|
||||||
|
*
|
||||||
|
* @return der abgeleitete {@link Path} oder {@code null}, wenn kein Pfad ableitbar ist
|
||||||
|
*/
|
||||||
|
private Path extractDbPath() {
|
||||||
|
// Erwartet: jdbc:sqlite:/pfad/zur/datei oder jdbc:sqlite:C:/pfad/datei
|
||||||
|
String prefix = "jdbc:sqlite:";
|
||||||
|
if (!jdbcUrl.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String pfad = jdbcUrl.substring(prefix.length());
|
||||||
|
if (pfad.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Paths.get(pfad);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Pfad aus JDBC-URL konnte nicht geparst werden: {}", pfad);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DataSource-Erstellung
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine {@link SQLiteDataSource} mit aktivierten Fremdschlüsseln.
|
||||||
|
*
|
||||||
|
* <p>Die Aktivierung über {@link SQLiteConfig#enforceForeignKeys(boolean)} stellt
|
||||||
|
* sicher, dass jede neue Verbindung automatisch {@code PRAGMA foreign_keys = ON}
|
||||||
|
* erhält – ein einmaliges Statement nach dem Verbindungsaufbau wäre nicht ausreichend.
|
||||||
|
*
|
||||||
|
* @return eine konfigurierte {@link DataSource}; niemals {@code null}
|
||||||
|
*/
|
||||||
|
private DataSource createDataSource() {
|
||||||
|
SQLiteConfig config = new SQLiteConfig();
|
||||||
|
config.enforceForeignKeys(true);
|
||||||
|
SQLiteDataSource ds = new SQLiteDataSource(config);
|
||||||
|
ds.setUrl(jdbcUrl);
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest alle Tabellennamen aus den Datenbankmetadaten (Kleinschreibung).
|
||||||
|
*
|
||||||
|
* @param meta Datenbankmetadaten
|
||||||
|
* @return Menge aller Tabellennamen in Kleinschreibung
|
||||||
|
* @throws SQLException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
private static Set<String> readTableNames(DatabaseMetaData meta) throws SQLException {
|
||||||
|
Set<String> names = new HashSet<>();
|
||||||
|
try (ResultSet rs = meta.getTables(null, null, "%", new String[]{"TABLE"})) {
|
||||||
|
while (rs.next()) {
|
||||||
|
names.add(rs.getString("TABLE_NAME").toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest alle Indexnamen aus den Datenbankmetadaten für beide fachlichen Tabellen.
|
||||||
|
*
|
||||||
|
* @param meta Datenbankmetadaten
|
||||||
|
* @return Menge aller Indexnamen in Kleinschreibung
|
||||||
|
* @throws SQLException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
|
||||||
|
Set<String> names = new HashSet<>();
|
||||||
|
for (String tabelle : new String[]{"document_record", "processing_attempt"}) {
|
||||||
|
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String indexName = rs.getString("INDEX_NAME");
|
||||||
|
if (indexName != null) {
|
||||||
|
names.add(indexName.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-2
@@ -171,7 +171,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// Delete attempts first (FK constraint: processing_attempt → document_record)
|
// Zuerst Versuche löschen (FK-Constraint: processing_attempt → document_record)
|
||||||
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
|
||||||
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl) {
|
||||||
@Override
|
@Override
|
||||||
@@ -181,7 +181,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
};
|
};
|
||||||
attemptRepo.deleteAllByFingerprint(fingerprint);
|
attemptRepo.deleteAllByFingerprint(fingerprint);
|
||||||
|
|
||||||
// Then delete the master record
|
// Dann den Stammsatz löschen
|
||||||
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
SqliteDocumentRecordRepositoryAdapter recordRepo =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl) {
|
||||||
@Override
|
@Override
|
||||||
@@ -191,5 +191,45 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
};
|
};
|
||||||
recordRepo.deleteByFingerprint(fingerprint);
|
recordRepo.deleteByFingerprint(fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||||
|
* ohne die Versuchshistorie zu löschen.
|
||||||
|
* <p>
|
||||||
|
* Die Felder {@code overall_status}, {@code content_error_count},
|
||||||
|
* {@code transient_error_count} und {@code last_failure_instant} werden innerhalb
|
||||||
|
* der laufenden Transaktion per direktem SQL-UPDATE aktualisiert.
|
||||||
|
* Alle anderen Felder sowie alle {@code processing_attempt}-Einträge bleiben unverändert.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Stammsatz für den Fingerprint vorhanden, kehrt die Methode stillschweigend zurück.
|
||||||
|
*
|
||||||
|
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
UPDATE document_record SET
|
||||||
|
overall_status = 'READY_FOR_AI',
|
||||||
|
content_error_count = 0,
|
||||||
|
transient_error_count = 0,
|
||||||
|
last_failure_instant = NULL
|
||||||
|
WHERE fingerprint = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (java.sql.PreparedStatement stmt = connection.prepareStatement(sql)) {
|
||||||
|
stmt.setString(1, fingerprint.sha256Hex());
|
||||||
|
stmt.executeUpdate();
|
||||||
|
logger.debug("Status-Reset (feldgenau) für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (java.sql.SQLException e) {
|
||||||
|
String message = "Status-Reset fehlgeschlagen für Fingerprint '"
|
||||||
|
+ fingerprint.sha256Hex() + "': " + e.getMessage();
|
||||||
|
logger.error(message, e);
|
||||||
|
throw new DocumentPersistenceException(message, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+32
-24
@@ -1,35 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* SQLite persistence adapter for the two-level persistence model.
|
* SQLite-Persistenz-Adapter für das Zwei-Ebenen-Persistenzmodell.
|
||||||
*
|
*
|
||||||
* <h2>Purpose</h2>
|
* <h2>Zweck</h2>
|
||||||
* <p>This package contains the technical SQLite infrastructure for the persistence
|
* <p>Dieses Paket enthält die technische SQLite-Infrastruktur der Persistenzschicht.
|
||||||
* layer. It is the only place in the entire application where JDBC connections, SQL DDL,
|
* Es ist die einzige Stelle in der gesamten Anwendung, an der JDBC-Verbindungen,
|
||||||
* and SQLite-specific types are used. No JDBC or SQLite types leak into the
|
* SQL-DDL und SQLite-spezifische Typen verwendet werden. Keine JDBC- oder
|
||||||
* {@code application} or {@code domain} modules.
|
* SQLite-Typen verlassen dieses Paket in Richtung der {@code application}-
|
||||||
|
* oder {@code domain}-Module.
|
||||||
*
|
*
|
||||||
* <h2>Two-level persistence model</h2>
|
* <h2>Zwei-Ebenen-Persistenzmodell</h2>
|
||||||
* <p>Persistence is structured in exactly two levels:
|
* <p>Die Persistenz ist in genau zwei Ebenen strukturiert:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li><strong>Document master record</strong> ({@code document_record} table) —
|
* <li><strong>Dokument-Stammsatz</strong> ({@code document_record}-Tabelle) –
|
||||||
* one row per unique SHA-256 fingerprint; carries the current overall status,
|
* eine Zeile pro eindeutigem SHA-256-Fingerprint; trägt den aktuellen
|
||||||
* failure counters, and the most recently known source location.</li>
|
* Gesamtstatus, Fehlerzähler und den zuletzt bekannten Quellort.</li>
|
||||||
* <li><strong>Processing attempt history</strong> ({@code processing_attempt} table) —
|
* <li><strong>Versuchshistorie</strong> ({@code processing_attempt}-Tabelle) –
|
||||||
* one row per historised processing attempt; references the master record via
|
* eine Zeile pro historisiertem Verarbeitungsversuch; referenziert den
|
||||||
* fingerprint; attempt numbers are monotonically increasing per fingerprint.</li>
|
* Stammsatz über den Fingerprint; Versuchsnummern sind pro Fingerprint
|
||||||
|
* monoton steigend.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <h2>Schema initialisation timing</h2>
|
* <h2>Schema-Initialisierung mit Flyway</h2>
|
||||||
* <p>The {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
|
* <p>Der {@link de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter}
|
||||||
* implements the
|
* implementiert den
|
||||||
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}
|
||||||
* and must be called <em>once</em> at program startup, before the batch document
|
* und muss <em>einmal</em> beim Programmstart aufgerufen werden, bevor die
|
||||||
* processing loop begins. There is no lazy or hidden initialisation during document
|
* Verarbeitungsschleife beginnt. Die Initialisierung unterscheidet drei Fälle:
|
||||||
* processing.
|
* leere Datenbank, bestehende Datenbank ohne Flyway-History (Baseline-Eintragung
|
||||||
|
* nach vollständiger Schema-Prüfung) und Folgestart mit Flyway-History (idempotent).
|
||||||
*
|
*
|
||||||
* <h2>Architecture boundary</h2>
|
* <h2>Fremdschlüssel</h2>
|
||||||
* <p>All JDBC connections, SQL statements, and SQLite-specific behaviour are strictly
|
* <p>Foreign-Key-Durchsetzung wird über {@code SQLiteConfig.enforceForeignKeys(true)}
|
||||||
* confined to this package. The application layer interacts exclusively through the
|
* auf DataSource-Ebene aktiviert, sodass jede neue Verbindung automatisch
|
||||||
* port interfaces defined in
|
* {@code PRAGMA foreign_keys = ON} erhält.
|
||||||
|
*
|
||||||
|
* <h2>Architekturgrenze</h2>
|
||||||
|
* <p>Alle JDBC-Verbindungen, SQL-Anweisungen und SQLite-spezifisches Verhalten sind
|
||||||
|
* ausschließlich in diesem Paket gekapselt. Die Application-Schicht interagiert
|
||||||
|
* ausschließlich über die Port-Interfaces in
|
||||||
* {@code de.gecheckt.pdf.umbenenner.application.port.out}.
|
* {@code de.gecheckt.pdf.umbenenner.application.port.out}.
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Vollständiges Basisschema: Dokument-Stammsatz und Versuchshistorie.
|
||||||
|
-- Dieses Skript wird für neue Datenbanken ausgeführt (Fall 1).
|
||||||
|
-- Für bestehende Datenbanken mit konformem Schema wird nur eine Flyway-Baseline
|
||||||
|
-- eingetragen; das Skript wird in diesem Fall NICHT ausgeführt (Fall 2).
|
||||||
|
|
||||||
|
CREATE TABLE document_record (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
last_known_source_locator TEXT NOT NULL,
|
||||||
|
last_known_source_file_name TEXT NOT NULL,
|
||||||
|
overall_status TEXT NOT NULL,
|
||||||
|
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_failure_instant TEXT,
|
||||||
|
last_success_instant TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_target_path TEXT,
|
||||||
|
last_target_file_name TEXT,
|
||||||
|
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE processing_attempt (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
run_id TEXT NOT NULL,
|
||||||
|
attempt_number INTEGER NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
failure_class TEXT,
|
||||||
|
failure_message TEXT,
|
||||||
|
retryable INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model_name TEXT,
|
||||||
|
prompt_identifier TEXT,
|
||||||
|
processed_page_count INTEGER,
|
||||||
|
sent_character_count INTEGER,
|
||||||
|
ai_raw_response TEXT,
|
||||||
|
ai_reasoning TEXT,
|
||||||
|
resolved_date TEXT,
|
||||||
|
date_source TEXT,
|
||||||
|
validated_title TEXT,
|
||||||
|
final_target_file_name TEXT,
|
||||||
|
ai_provider TEXT,
|
||||||
|
CONSTRAINT fk_processing_attempt_fingerprint
|
||||||
|
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
|
||||||
|
CONSTRAINT uq_processing_attempt_fingerprint_number
|
||||||
|
UNIQUE (fingerprint, attempt_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_processing_attempt_fingerprint
|
||||||
|
ON processing_attempt (fingerprint);
|
||||||
|
|
||||||
|
CREATE INDEX idx_processing_attempt_run_id
|
||||||
|
ON processing_attempt (run_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_record_overall_status
|
||||||
|
ON document_record (overall_status);
|
||||||
+132
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for {@link FilesystemPromptPortAdapter}.
|
* Unit tests for {@link FilesystemPromptPortAdapter}.
|
||||||
@@ -199,4 +200,135 @@ class FilesystemPromptPortAdapterTest {
|
|||||||
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
|
assertThat(success1.promptContent()).isEqualTo(success2.promptContent());
|
||||||
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
|
assertThat(success1.promptIdentifier()).isEqualTo(success2.promptIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// savePrompt tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldReturnSaved_whenTargetDirExistsAndWriteSucceeds() throws IOException {
|
||||||
|
// Given
|
||||||
|
Path promptFile = tempDir.resolve("prompt_save.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
String content = "Mein Prompt-Inhalt";
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = adapter.savePrompt(content);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||||
|
PromptSaveResult.Saved saved = (PromptSaveResult.Saved) result;
|
||||||
|
assertThat(saved.absolutePath()).contains("prompt_save.txt");
|
||||||
|
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldPreserveUtf8Content_includingUmlauts() throws IOException {
|
||||||
|
// Given
|
||||||
|
Path promptFile = tempDir.resolve("prompt_umlaut.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
String content = "Ärger mit Überschriften und Schluß";
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = adapter.savePrompt(content);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||||
|
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldPreserveLineEndings_withoutNormalization() throws IOException {
|
||||||
|
// Given
|
||||||
|
Path promptFile = tempDir.resolve("prompt_lineendings.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
String content = "Zeile 1\r\nZeile 2\nZeile 3\r\n";
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = adapter.savePrompt(content);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||||
|
byte[] raw = Files.readAllBytes(promptFile);
|
||||||
|
assertThat(new String(raw, StandardCharsets.UTF_8)).isEqualTo(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldOverwriteExistingFile_atomically() throws IOException {
|
||||||
|
// Given
|
||||||
|
Path promptFile = tempDir.resolve("prompt_overwrite.txt");
|
||||||
|
Files.writeString(promptFile, "Alter Inhalt", StandardCharsets.UTF_8);
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
String newContent = "Neuer Inhalt";
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = adapter.savePrompt(newContent);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||||
|
assertThat(Files.readString(promptFile, StandardCharsets.UTF_8)).isEqualTo(newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldReturnTargetDirectoryMissing_whenDirectoryDoesNotExist() {
|
||||||
|
// Given
|
||||||
|
Path nonExistentDir = tempDir.resolve("missing-subdir");
|
||||||
|
Path promptFile = nonExistentDir.resolve("prompt.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = adapter.savePrompt("Inhalt");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
|
||||||
|
PromptSaveResult.TargetDirectoryMissing missing = (PromptSaveResult.TargetDirectoryMissing) result;
|
||||||
|
assertThat(missing.message()).contains("missing-subdir");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() throws IOException {
|
||||||
|
// Given
|
||||||
|
Path promptFile = tempDir.resolve("prompt_null.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThatThrownBy(() -> adapter.savePrompt(null))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessage("content must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldLeaveDirClean_whenTargetDirectoryIsMissing() {
|
||||||
|
// Given – Verzeichnis existiert nicht; keine Temp-Datei soll zurückbleiben
|
||||||
|
Path nonExistentDir = tempDir.resolve("ghost-dir");
|
||||||
|
Path promptFile = nonExistentDir.resolve("prompt.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = adapter.savePrompt("Inhalt");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.TargetDirectoryMissing.class);
|
||||||
|
// Verzeichnis wurde nicht angelegt (da Directory-Check fehlschlug)
|
||||||
|
assertThat(nonExistentDir).doesNotExist();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_roundTrip_loadAfterSaveReturnsSameContent() throws IOException {
|
||||||
|
// Given
|
||||||
|
Path promptFile = tempDir.resolve("prompt_roundtrip.txt");
|
||||||
|
adapter = new FilesystemPromptPortAdapter(promptFile);
|
||||||
|
String content = "Runde-Trip-Inhalt\nMit mehreren Zeilen.";
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult saveResult = adapter.savePrompt(content);
|
||||||
|
PromptLoadingResult loadResult = adapter.loadPrompt();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(saveResult).isInstanceOf(PromptSaveResult.Saved.class);
|
||||||
|
assertThat(loadResult).isInstanceOf(PromptLoadingSuccess.class);
|
||||||
|
PromptLoadingSuccess success = (PromptLoadingSuccess) loadResult;
|
||||||
|
// loadPrompt trims the content; trim the expected too
|
||||||
|
assertThat(success.promptContent()).isEqualTo(content.trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-48
@@ -24,11 +24,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
|||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for the additive {@code ai_provider} column in {@code processing_attempt}.
|
* Tests für {@code ai_provider} in {@code processing_attempt}.
|
||||||
* <p>
|
*
|
||||||
* Covers schema migration (idempotency, nullable default for existing rows),
|
* <p>Prüft Schreib-/Lese-Roundtrips für beide Provider-Identifikatoren,
|
||||||
* write/read round-trips for both supported provider identifiers, and
|
* Idempotenz der Initialisierung sowie das Verhalten bei Schemata,
|
||||||
* backward compatibility with databases created before provider tracking was introduced.
|
* die nicht dem Zielschema entsprechen (harter Abbruch per Fall-2-Strategie).
|
||||||
*/
|
*/
|
||||||
class SqliteAttemptProviderPersistenceTest {
|
class SqliteAttemptProviderPersistenceTest {
|
||||||
|
|
||||||
@@ -64,25 +64,24 @@ class SqliteAttemptProviderPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A database that already has the {@code processing_attempt} table without
|
* Eine bestehende Datenbank ohne {@code ai_provider}-Spalte in {@code processing_attempt}
|
||||||
* {@code ai_provider} (simulating an existing installation before this column was added)
|
* entspricht nicht dem vollständigen Zielschema. Die Initialisierung muss mit einem
|
||||||
* must receive the column via the idempotent schema evolution.
|
* klaren Fehler abbrechen, da kein stilles Heilen stattfindet.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException {
|
void existingDbOhneAiProviderSpalte_brichtAb() throws SQLException {
|
||||||
// Bootstrap schema without the ai_provider column (simulate legacy DB)
|
// Schema ohne ai_provider anlegen
|
||||||
createLegacySchema();
|
createLegacySchema();
|
||||||
|
|
||||||
assertThat(columnExists("processing_attempt", "ai_provider"))
|
assertThat(columnExists("processing_attempt", "ai_provider"))
|
||||||
.as("ai_provider must not be present before evolution")
|
.as("ai_provider darf im Legacy-Schema noch nicht vorhanden sein")
|
||||||
.isFalse();
|
.isFalse();
|
||||||
|
|
||||||
// Running initializeSchema must add the column
|
// Initialisierung muss mit Fehler abbrechen (nicht konformes Schema)
|
||||||
schemaAdapter.initializeSchema();
|
org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
|
||||||
assertThat(columnExists("processing_attempt", "ai_provider"))
|
() -> schemaAdapter.initializeSchema(),
|
||||||
.as("ai_provider column must be added by schema evolution")
|
"Erwarte Fehler bei nicht konformem Schema (fehlende ai_provider-Spalte)");
|
||||||
.isTrue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,25 +100,28 @@ class SqliteAttemptProviderPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rows that existed before the {@code ai_provider} column was added must have
|
* Neue Versuche die ohne Provider-Information gespeichert werden (z. B. über
|
||||||
* {@code NULL} as the column value, not a non-null default.
|
* {@code ProcessingAttempt.withoutAiFields}), müssen {@code null} als
|
||||||
|
* {@code ai_provider} zurückliefern.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void existingRowsKeepNullProvider() throws SQLException {
|
void neuerVersuchOhneProvider_haeltNullProviderNachSchreibenUndLesen() {
|
||||||
// Create legacy schema and insert a row without ai_provider
|
|
||||||
createLegacySchema();
|
|
||||||
DocumentFingerprint fp = fingerprint("aa");
|
|
||||||
insertLegacyDocumentRecord(fp);
|
|
||||||
insertLegacyAttemptRow(fp, "READY_FOR_AI");
|
|
||||||
|
|
||||||
// Now evolve the schema
|
|
||||||
schemaAdapter.initializeSchema();
|
schemaAdapter.initializeSchema();
|
||||||
|
DocumentFingerprint fp = fingerprint("aa");
|
||||||
|
insertDocumentRecord(fp);
|
||||||
|
|
||||||
// Read the existing row — ai_provider must be NULL
|
java.time.Instant now = java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS);
|
||||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
|
ProcessingAttempt attemptOhneProvider = ProcessingAttempt.withoutAiFields(
|
||||||
assertThat(attempts).hasSize(1);
|
fp, new RunId("run-null"), 1,
|
||||||
assertThat(attempts.get(0).aiProvider())
|
now, now.plusSeconds(1),
|
||||||
.as("Existing rows must have NULL ai_provider after schema evolution")
|
ProcessingStatus.FAILED_RETRYABLE,
|
||||||
|
"Err", "msg", true);
|
||||||
|
repository.save(attemptOhneProvider);
|
||||||
|
|
||||||
|
List<ProcessingAttempt> gelesen = repository.findAllByFingerprint(fp);
|
||||||
|
assertThat(gelesen).hasSize(1);
|
||||||
|
assertThat(gelesen.get(0).aiProvider())
|
||||||
|
.as("Versuche ohne Provider müssen null zurückgeben")
|
||||||
.isNull();
|
.isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,29 +215,24 @@ class SqliteAttemptProviderPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reading a database that was created without the {@code ai_provider} column
|
* Eine Datenbank mit nicht konformem Schema (fehlende Spalten, fehlende Indizes)
|
||||||
* (a pre-extension database) must succeed; the new field must be empty/null
|
* wird von der Initialisierung mit einem klaren Fehler abgebrochen.
|
||||||
* for historical attempts.
|
* Es findet kein stilles Heilen statt.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void legacyDataReadingDoesNotFail() throws SQLException {
|
void nichtKonformesSchema_brichtMitAussagekraeftigemFehlerAb() throws SQLException {
|
||||||
// Set up legacy schema with a row that has no ai_provider column
|
// Legacy-Schema anlegen (fehlt: ai_provider, last_target_path, last_target_file_name,
|
||||||
|
// Indizes fehlen ebenfalls)
|
||||||
createLegacySchema();
|
createLegacySchema();
|
||||||
DocumentFingerprint fp = fingerprint("ee");
|
DocumentFingerprint fp = fingerprint("ee");
|
||||||
insertLegacyDocumentRecord(fp);
|
insertLegacyDocumentRecord(fp);
|
||||||
insertLegacyAttemptRow(fp, "FAILED_RETRYABLE");
|
insertLegacyAttemptRow(fp, "FAILED_RETRYABLE");
|
||||||
|
|
||||||
// Evolve schema — now ai_provider column exists but legacy rows have NULL
|
// Initialisierung muss abbrechen
|
||||||
schemaAdapter.initializeSchema();
|
org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
|
||||||
// Reading must not throw and must return null for ai_provider
|
() -> schemaAdapter.initializeSchema(),
|
||||||
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
|
"Erwarte Fehler bei nicht konformem Bestands-Schema");
|
||||||
assertThat(attempts).hasSize(1);
|
|
||||||
assertThat(attempts.get(0).aiProvider())
|
|
||||||
.as("Legacy attempt (from before provider tracking) must have null aiProvider")
|
|
||||||
.isNull();
|
|
||||||
// Other fields must still be readable
|
|
||||||
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+355
-265
@@ -3,6 +3,7 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DatabaseMetaData;
|
import java.sql.DatabaseMetaData;
|
||||||
@@ -14,38 +15,34 @@ import java.util.Set;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.sqlite.SQLiteConfig;
|
||||||
|
import org.sqlite.SQLiteDataSource;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for {@link SqliteSchemaInitializationAdapter}.
|
* Tests für {@link SqliteSchemaInitializationAdapter}.
|
||||||
* <p>
|
*
|
||||||
* Verifies that the two-level schema is created correctly, that schema evolution
|
* <p>Prüft die differenzierte 3-Fall-Strategie (leere DB, bestehende DB ohne
|
||||||
* (idempotent addition of AI traceability columns) works, that the idempotent
|
* Flyway-History, Folgestart), die vollständige Schema-Prüfcheckliste für Fall 2,
|
||||||
* status migration of earlier positive intermediate states to {@code READY_FOR_AI}
|
* die Foreign-Key-Aktivierung via DataSource sowie den Konstruktor.
|
||||||
* is correct, and that invalid configuration is rejected.
|
|
||||||
*/
|
*/
|
||||||
class SqliteSchemaInitializationAdapterTest {
|
class SqliteSchemaInitializationAdapterTest {
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Construction
|
// Konstruktor
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void constructor_rejectsNullJdbcUrl() {
|
void constructor_rejectsNullJdbcUrl() {
|
||||||
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(null))
|
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(null))
|
||||||
.isInstanceOf(NullPointerException.class)
|
.isInstanceOf(NullPointerException.class);
|
||||||
.hasMessageContaining("jdbcUrl");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void constructor_rejectsBlankJdbcUrl() {
|
void constructor_rejectsBlankJdbcUrl() {
|
||||||
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(" "))
|
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(" "))
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
.hasMessageContaining("jdbcUrl");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -56,213 +53,278 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Schema creation – tables present
|
// Fall 1: Leere Datenbank – vollständiges Schema anlegen
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void initializeSchema_createsBothTables(@TempDir Path dir) throws SQLException {
|
void fall1_leereDb_laegtVollstaendigesSchemaAn(@TempDir Path dir) throws SQLException {
|
||||||
String jdbcUrl = jdbcUrl(dir, "schema_test.db");
|
String jdbcUrl = jdbcUrl(dir, "fall1.db");
|
||||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
adapter.initializeSchema();
|
Set<String> tabellen = readTableNames(jdbcUrl);
|
||||||
|
assertThat(tabellen).contains("document_record", "processing_attempt");
|
||||||
Set<String> tables = readTableNames(jdbcUrl);
|
|
||||||
assertThat(tables).contains("document_record", "processing_attempt");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void initializeSchema_documentRecordHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
|
void fall1_leereDb_documentRecordHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
|
||||||
String jdbcUrl = jdbcUrl(dir, "columns_test.db");
|
String jdbcUrl = jdbcUrl(dir, "fall1_columns_dr.db");
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
Set<String> columns = readColumnNames(jdbcUrl, "document_record");
|
Set<String> spalten = readColumnNames(jdbcUrl, "document_record");
|
||||||
assertThat(columns).containsExactlyInAnyOrder(
|
assertThat(spalten).containsExactlyInAnyOrder(
|
||||||
"id",
|
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
|
||||||
"fingerprint",
|
"overall_status", "content_error_count", "transient_error_count",
|
||||||
"last_known_source_locator",
|
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
|
||||||
"last_known_source_file_name",
|
"last_target_path", "last_target_file_name"
|
||||||
"overall_status",
|
|
||||||
"content_error_count",
|
|
||||||
"transient_error_count",
|
|
||||||
"last_failure_instant",
|
|
||||||
"last_success_instant",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"last_target_path",
|
|
||||||
"last_target_file_name"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void initializeSchema_processingAttemptHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
|
void fall1_leereDb_processingAttemptHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
|
||||||
String jdbcUrl = jdbcUrl(dir, "attempt_columns_test.db");
|
String jdbcUrl = jdbcUrl(dir, "fall1_columns_pa.db");
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
|
Set<String> spalten = readColumnNames(jdbcUrl, "processing_attempt");
|
||||||
assertThat(columns).containsExactlyInAnyOrder(
|
assertThat(spalten).containsExactlyInAnyOrder(
|
||||||
"id",
|
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
|
||||||
"fingerprint",
|
"status", "failure_class", "failure_message", "retryable",
|
||||||
"run_id",
|
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
||||||
"attempt_number",
|
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
||||||
"started_at",
|
"validated_title", "final_target_file_name", "ai_provider"
|
||||||
"ended_at",
|
|
||||||
"status",
|
|
||||||
"failure_class",
|
|
||||||
"failure_message",
|
|
||||||
"retryable",
|
|
||||||
"model_name",
|
|
||||||
"prompt_identifier",
|
|
||||||
"processed_page_count",
|
|
||||||
"sent_character_count",
|
|
||||||
"ai_raw_response",
|
|
||||||
"ai_reasoning",
|
|
||||||
"resolved_date",
|
|
||||||
"date_source",
|
|
||||||
"validated_title",
|
|
||||||
"final_target_file_name",
|
|
||||||
"ai_provider"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Idempotency
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void initializeSchema_isIdempotent_calledTwice(@TempDir Path dir) {
|
void fall1_leereDb_indizesVorhanden(@TempDir Path dir) throws SQLException {
|
||||||
String jdbcUrl = jdbcUrl(dir, "idempotent_test.db");
|
String jdbcUrl = jdbcUrl(dir, "fall1_indexes.db");
|
||||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
// Must not throw on second call
|
Set<String> indizes = readIndexNames(jdbcUrl);
|
||||||
adapter.initializeSchema();
|
assertThat(indizes).contains(
|
||||||
adapter.initializeSchema();
|
"idx_processing_attempt_fingerprint",
|
||||||
|
"idx_processing_attempt_run_id",
|
||||||
|
"idx_document_record_overall_status"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Leer" bedeutet: keine Tabellen vorhanden – NICHT nur Dateigröße 0 Byte.
|
||||||
|
* Eine leere SQLite-Datei (0 Byte) muss als leere DB erkannt werden.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void fall1_erkenntLeereDbAuchBeiDateiOhneInhalt(@TempDir Path dir) throws Exception {
|
||||||
|
// Leere Datei anlegen (0 Byte)
|
||||||
|
Path dbPath = dir.resolve("empty.db");
|
||||||
|
Files.createFile(dbPath);
|
||||||
|
assertThat(dbPath).exists();
|
||||||
|
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "empty.db");
|
||||||
|
// Muss als Fall 1 behandelt werden und erfolgreich durchlaufen
|
||||||
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
|
Set<String> tabellen = readTableNames(jdbcUrl);
|
||||||
|
assertThat(tabellen).contains("document_record", "processing_attempt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Unique constraint: fingerprint in document_record
|
// Fall 2: Bestehende DB ohne Flyway-History – Baseline eintragen
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void documentRecord_fingerprintUniqueConstraintIsEnforced(@TempDir Path dir) throws SQLException {
|
void fall2_bestehendeDbOhneHistory_traegtBaseline_einUndLaeuftErfolgreich(@TempDir Path dir)
|
||||||
String jdbcUrl = jdbcUrl(dir, "unique_test.db");
|
throws SQLException {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fall2.db");
|
||||||
|
// Vollständiges konformes Schema anlegen (wie eine bestehende Produktions-DB)
|
||||||
|
erstelleKonformesSchema(jdbcUrl);
|
||||||
|
|
||||||
|
// Adapter muss als Fall 2 erkennen und Baseline eintragen
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
String insertSql = """
|
// Flyway-History-Tabelle muss jetzt vorhanden sein
|
||||||
INSERT INTO document_record
|
Set<String> tabellen = readTableNames(jdbcUrl);
|
||||||
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
assertThat(tabellen).contains("flyway_schema_history");
|
||||||
overall_status, created_at, updated_at)
|
// Fachliche Daten müssen erhalten bleiben
|
||||||
VALUES (?, 'locator', 'file.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
|
assertThat(tabellen).contains("document_record", "processing_attempt");
|
||||||
""";
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fall2_bestehendeDbOhneHistory_erstelltDatiertesBackup(@TempDir Path dir)
|
||||||
|
throws Exception {
|
||||||
|
Path dbPath = dir.resolve("fall2_backup.db");
|
||||||
|
String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath().toString().replace('\\', '/');
|
||||||
|
erstelleKonformesSchema(jdbcUrl);
|
||||||
|
|
||||||
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
|
// Backup-Datei muss vorhanden sein
|
||||||
|
long backupAnzahl = Files.list(dir)
|
||||||
|
.filter(p -> p.getFileName().toString().startsWith("fall2_backup.db.")
|
||||||
|
&& p.getFileName().toString().endsWith(".bak"))
|
||||||
|
.count();
|
||||||
|
assertThat(backupAnzahl).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fall2_bestehendeDbMitFehlendemElement_brichtMitFehlerAb(@TempDir Path dir) {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fall2_broken.db");
|
||||||
|
// Schema ohne Spalte ai_provider anlegen (nicht konform)
|
||||||
|
erstelleSchemaOhneAiProvider(jdbcUrl);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("ai_provider");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fall2_bestehendeDbOhneProcessingAttemptTabelle_brichtAb(@TempDir Path dir) {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fall2_no_attempt.db");
|
||||||
|
// Nur document_record anlegen, processing_attempt fehlt
|
||||||
|
erstelleNurDocumentRecord(jdbcUrl);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("processing_attempt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Fall 3: Folgestart mit Flyway-History – idempotent
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fall3_folgestart_laeuftIdempotentOhneException(@TempDir Path dir) {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fall3.db");
|
||||||
|
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
|
|
||||||
|
// Erster Aufruf (Fall 1)
|
||||||
|
adapter.initializeSchema();
|
||||||
|
// Zweiter Aufruf (Fall 3) – darf nicht werfen
|
||||||
|
adapter.initializeSchema();
|
||||||
|
// Dritter Aufruf (Fall 3) – ebenfalls idempotent
|
||||||
|
adapter.initializeSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fall3_folgestart_fachlicheDatenBleiben(@TempDir Path dir) throws SQLException {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fall3_data.db");
|
||||||
|
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
|
adapter.initializeSchema();
|
||||||
|
|
||||||
|
// Testdatensatz einfügen
|
||||||
String fp = "a".repeat(64);
|
String fp = "a".repeat(64);
|
||||||
|
insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");
|
||||||
|
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
// Folgestart
|
||||||
try (var ps = conn.prepareStatement(insertSql)) {
|
adapter.initializeSchema();
|
||||||
ps.setString(1, fp);
|
|
||||||
|
// Daten müssen erhalten bleiben
|
||||||
|
assertThat(leseStatus(jdbcUrl, fp)).isEqualTo("SUCCESS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PRAGMA foreign_keys – Foreign-Key-Aktivierung via DataSource
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void foreignKeys_sindNachSchemaInitAktiv(@TempDir Path dir) throws Exception {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fk_test.db");
|
||||||
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
|
// Neue Verbindung über SQLiteConfig aufbauen (wie der Adapter es tut)
|
||||||
|
org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
|
||||||
|
config.enforceForeignKeys(true);
|
||||||
|
org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
|
||||||
|
ds.setUrl(jdbcUrl);
|
||||||
|
|
||||||
|
try (Connection conn = ds.getConnection();
|
||||||
|
var stmt = conn.createStatement()) {
|
||||||
|
// PRAGMA foreign_keys muss 1 zurückliefern
|
||||||
|
ResultSet rs = stmt.executeQuery("PRAGMA foreign_keys");
|
||||||
|
assertThat(rs.next()).isTrue();
|
||||||
|
assertThat(rs.getInt(1)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void foreignKeys_verletzungWirdDurchgesetzt(@TempDir Path dir) throws SQLException {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "fk_enforced.db");
|
||||||
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
|
// Versuch, einen processing_attempt ohne passendem document_record einzufügen
|
||||||
|
org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
|
||||||
|
config.enforceForeignKeys(true);
|
||||||
|
org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
|
||||||
|
ds.setUrl(jdbcUrl);
|
||||||
|
|
||||||
|
try (Connection conn = ds.getConnection()) {
|
||||||
|
assertThatThrownBy(() -> {
|
||||||
|
try (var ps = conn.prepareStatement("""
|
||||||
|
INSERT INTO processing_attempt
|
||||||
|
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
||||||
|
VALUES ('nichtvorhanden', 'run-1', 1, '2026-01-01T00:00:00Z',
|
||||||
|
'2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
|
||||||
|
""")) {
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
// Second insert with same fingerprint must fail
|
}).isInstanceOf(SQLException.class);
|
||||||
try (var ps = conn.prepareStatement(insertSql)) {
|
|
||||||
ps.setString(1, fp);
|
|
||||||
org.junit.jupiter.api.Assertions.assertThrows(
|
|
||||||
SQLException.class, ps::executeUpdate,
|
|
||||||
"Expected UNIQUE constraint violation on document_record.fingerprint");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Unique constraint: (fingerprint, attempt_number) in processing_attempt
|
// Eindeutigkeits-Constraints
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void processingAttempt_fingerprintAttemptNumberUniqueConstraintIsEnforced(@TempDir Path dir)
|
void documentRecord_fingerprintUniqueConstraintWirdDurchgesetzt(@TempDir Path dir)
|
||||||
throws SQLException {
|
throws SQLException {
|
||||||
String jdbcUrl = jdbcUrl(dir, "attempt_unique_test.db");
|
String jdbcUrl = jdbcUrl(dir, "unique_dr.db");
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
String fp = "b".repeat(64);
|
String fp = "b".repeat(64);
|
||||||
|
insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");
|
||||||
|
|
||||||
// Insert master record first (FK)
|
// Zweiter Insert mit gleichem Fingerprint muss fehlschlagen
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
assertThatThrownBy(() -> insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS"))
|
||||||
try (var ps = conn.prepareStatement("""
|
.isInstanceOf(SQLException.class);
|
||||||
INSERT INTO document_record
|
|
||||||
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
|
||||||
overall_status, created_at, updated_at)
|
|
||||||
VALUES (?, 'loc', 'f.pdf', 'FAILED_RETRYABLE', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
|
|
||||||
""")) {
|
|
||||||
ps.setString(1, fp);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String attemptSql = """
|
|
||||||
INSERT INTO processing_attempt
|
|
||||||
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
|
||||||
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (var ps = conn.prepareStatement(attemptSql)) {
|
|
||||||
ps.setString(1, fp);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
// Duplicate (fingerprint, attempt_number) must fail
|
|
||||||
try (var ps = conn.prepareStatement(attemptSql)) {
|
|
||||||
ps.setString(1, fp);
|
|
||||||
org.junit.jupiter.api.Assertions.assertThrows(
|
|
||||||
SQLException.class, ps::executeUpdate,
|
|
||||||
"Expected UNIQUE constraint violation on (fingerprint, attempt_number)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Skip attempts are storable
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void processingAttempt_skipStatusIsStorable(@TempDir Path dir) throws SQLException {
|
void processingAttempt_fingerprintUndAttemptNumberUniqueConstraintWirdDurchgesetzt(
|
||||||
String jdbcUrl = jdbcUrl(dir, "skip_test.db");
|
@TempDir Path dir) throws SQLException {
|
||||||
|
String jdbcUrl = jdbcUrl(dir, "unique_pa.db");
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
||||||
|
|
||||||
String fp = "c".repeat(64);
|
String fp = "c".repeat(64);
|
||||||
|
insertiereDocumentRecord(jdbcUrl, fp, "FAILED_RETRYABLE");
|
||||||
|
insertiereProcessingAttempt(jdbcUrl, fp, 1);
|
||||||
|
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
// Zweiter Insert mit gleicher (fingerprint, attempt_number) muss fehlschlagen
|
||||||
// Insert master record
|
assertThatThrownBy(() -> insertiereProcessingAttempt(jdbcUrl, fp, 1))
|
||||||
try (var ps = conn.prepareStatement("""
|
.isInstanceOf(SQLException.class);
|
||||||
INSERT INTO document_record
|
|
||||||
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
|
||||||
overall_status, created_at, updated_at)
|
|
||||||
VALUES (?, 'loc', 'f.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
|
|
||||||
""")) {
|
|
||||||
ps.setString(1, fp);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert a SKIPPED_ALREADY_PROCESSED attempt (null failure fields, retryable=0)
|
|
||||||
try (var ps = conn.prepareStatement("""
|
|
||||||
INSERT INTO processing_attempt
|
|
||||||
(fingerprint, run_id, attempt_number, started_at, ended_at,
|
|
||||||
status, failure_class, failure_message, retryable)
|
|
||||||
VALUES (?, 'run-2', 2, '2026-01-02T00:00:00Z', '2026-01-02T00:00:01Z',
|
|
||||||
'SKIPPED_ALREADY_PROCESSED', NULL, NULL, 0)
|
|
||||||
""")) {
|
|
||||||
ps.setString(1, fp);
|
|
||||||
int rows = ps.executeUpdate();
|
|
||||||
assertThat(rows).isEqualTo(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Schema evolution — AI traceability columns
|
// Fehlerfall: ungültige URL
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void initializeSchema_addsAiTraceabilityColumnsToExistingSchema(@TempDir Path dir)
|
void initializeSchema_wirftDocumentPersistenceException_beiUngueltigerUrl() {
|
||||||
throws SQLException {
|
SqliteSchemaInitializationAdapter adapter =
|
||||||
// Simulate a pre-evolution schema: create the base tables without AI columns
|
new SqliteSchemaInitializationAdapter("keine-jdbc-url");
|
||||||
String jdbcUrl = jdbcUrl(dir, "evolution_test.db");
|
assertThatThrownBy(adapter::initializeSchema)
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden – Schema-Erstellung für Tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein vollständig konformes Schema (entspricht V1-Zielschema) ohne Flyway-History.
|
||||||
|
*/
|
||||||
|
private static void erstelleKonformesSchema(String jdbcUrl) {
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
var stmt = conn.createStatement()) {
|
var stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("PRAGMA foreign_keys = ON");
|
||||||
stmt.execute("""
|
stmt.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS document_record (
|
CREATE TABLE IF NOT EXISTS document_record (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -276,6 +338,8 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
last_success_instant TEXT,
|
last_success_instant TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
last_target_path TEXT,
|
||||||
|
last_target_file_name TEXT,
|
||||||
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
@@ -290,112 +354,118 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
failure_class TEXT,
|
failure_class TEXT,
|
||||||
failure_message TEXT,
|
failure_message TEXT,
|
||||||
retryable INTEGER NOT NULL DEFAULT 0
|
retryable INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model_name TEXT,
|
||||||
|
prompt_identifier TEXT,
|
||||||
|
processed_page_count INTEGER,
|
||||||
|
sent_character_count INTEGER,
|
||||||
|
ai_raw_response TEXT,
|
||||||
|
ai_reasoning TEXT,
|
||||||
|
resolved_date TEXT,
|
||||||
|
date_source TEXT,
|
||||||
|
validated_title TEXT,
|
||||||
|
final_target_file_name TEXT,
|
||||||
|
ai_provider TEXT,
|
||||||
|
CONSTRAINT fk_processing_attempt_fingerprint
|
||||||
|
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
|
||||||
|
CONSTRAINT uq_processing_attempt_fingerprint_number
|
||||||
|
UNIQUE (fingerprint, attempt_number)
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint ON processing_attempt (fingerprint)");
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id ON processing_attempt (run_id)");
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_document_record_overall_status ON document_record (overall_status)");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("Testvorbereitungsfehler: Schema konnte nicht erstellt werden", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Running initializeSchema on the existing base schema must succeed (evolution)
|
/**
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
* Erstellt ein Schema ohne die Spalte {@code ai_provider} in {@code processing_attempt}.
|
||||||
|
*/
|
||||||
|
private static void erstelleSchemaOhneAiProvider(String jdbcUrl) {
|
||||||
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
|
var stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("""
|
||||||
|
CREATE TABLE document_record (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
last_known_source_locator TEXT NOT NULL,
|
||||||
|
last_known_source_file_name TEXT NOT NULL,
|
||||||
|
overall_status TEXT NOT NULL,
|
||||||
|
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_failure_instant TEXT,
|
||||||
|
last_success_instant TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_target_path TEXT,
|
||||||
|
last_target_file_name TEXT,
|
||||||
|
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
// processing_attempt OHNE ai_provider
|
||||||
|
stmt.execute("""
|
||||||
|
CREATE TABLE processing_attempt (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
run_id TEXT NOT NULL,
|
||||||
|
attempt_number INTEGER NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
failure_class TEXT,
|
||||||
|
failure_message TEXT,
|
||||||
|
retryable INTEGER NOT NULL DEFAULT 0,
|
||||||
|
model_name TEXT,
|
||||||
|
prompt_identifier TEXT,
|
||||||
|
processed_page_count INTEGER,
|
||||||
|
sent_character_count INTEGER,
|
||||||
|
ai_raw_response TEXT,
|
||||||
|
ai_reasoning TEXT,
|
||||||
|
resolved_date TEXT,
|
||||||
|
date_source TEXT,
|
||||||
|
validated_title TEXT,
|
||||||
|
final_target_file_name TEXT
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("Testvorbereitungsfehler", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
|
/**
|
||||||
assertThat(columns).contains(
|
* Erstellt nur die Tabelle {@code document_record} (ohne {@code processing_attempt}).
|
||||||
"model_name", "prompt_identifier", "processed_page_count",
|
*/
|
||||||
"sent_character_count", "ai_raw_response", "ai_reasoning",
|
private static void erstelleNurDocumentRecord(String jdbcUrl) {
|
||||||
"resolved_date", "date_source", "validated_title");
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
|
var stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("""
|
||||||
|
CREATE TABLE document_record (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
last_known_source_locator TEXT NOT NULL,
|
||||||
|
last_known_source_file_name TEXT NOT NULL,
|
||||||
|
overall_status TEXT NOT NULL,
|
||||||
|
content_error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transient_error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_failure_instant TEXT,
|
||||||
|
last_success_instant TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("Testvorbereitungsfehler", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Status migration — earlier positive intermediate state → READY_FOR_AI
|
// Hilfsmethoden – JDBC
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initializeSchema_migrates_legacySuccessWithoutProposal_toReadyForAi(@TempDir Path dir)
|
|
||||||
throws SQLException {
|
|
||||||
String jdbcUrl = jdbcUrl(dir, "migration_test.db");
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
// Insert a document with SUCCESS status and no PROPOSAL_READY attempt
|
|
||||||
String fp = "d".repeat(64);
|
|
||||||
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
|
|
||||||
|
|
||||||
// Run schema initialisation again (migration step runs every time)
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
String status = readOverallStatus(jdbcUrl, fp);
|
|
||||||
assertThat(status).isEqualTo("READY_FOR_AI");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initializeSchema_migration_isIdempotent(@TempDir Path dir) throws SQLException {
|
|
||||||
String jdbcUrl = jdbcUrl(dir, "migration_idempotent_test.db");
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
String fp = "e".repeat(64);
|
|
||||||
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
|
|
||||||
|
|
||||||
// Run migration twice — must not corrupt data or throw
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
String status = readOverallStatus(jdbcUrl, fp);
|
|
||||||
assertThat(status).isEqualTo("READY_FOR_AI");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initializeSchema_doesNotMigrate_successWithProposalReadyAttempt(@TempDir Path dir)
|
|
||||||
throws SQLException {
|
|
||||||
String jdbcUrl = jdbcUrl(dir, "migration_proposal_test.db");
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
String fp = "f".repeat(64);
|
|
||||||
// SUCCESS document that already has a PROPOSAL_READY attempt must NOT be migrated
|
|
||||||
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
|
|
||||||
insertAttemptWithStatus(jdbcUrl, fp, "PROPOSAL_READY");
|
|
||||||
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
String status = readOverallStatus(jdbcUrl, fp);
|
|
||||||
assertThat(status).isEqualTo("SUCCESS");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initializeSchema_doesNotMigrate_terminalFailureStates(@TempDir Path dir)
|
|
||||||
throws SQLException {
|
|
||||||
String jdbcUrl = jdbcUrl(dir, "migration_failure_test.db");
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
String fpRetryable = "1".repeat(64);
|
|
||||||
String fpFinal = "2".repeat(64);
|
|
||||||
insertDocumentRecordWithStatus(jdbcUrl, fpRetryable, "FAILED_RETRYABLE");
|
|
||||||
insertDocumentRecordWithStatus(jdbcUrl, fpFinal, "FAILED_FINAL");
|
|
||||||
|
|
||||||
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
|
|
||||||
|
|
||||||
assertThat(readOverallStatus(jdbcUrl, fpRetryable)).isEqualTo("FAILED_RETRYABLE");
|
|
||||||
assertThat(readOverallStatus(jdbcUrl, fpFinal)).isEqualTo("FAILED_FINAL");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Error handling
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initializeSchema_throwsDocumentPersistenceException_onInvalidUrl() {
|
|
||||||
// SQLite is lenient with paths; use a truly invalid JDBC URL format
|
|
||||||
SqliteSchemaInitializationAdapter badAdapter =
|
|
||||||
new SqliteSchemaInitializationAdapter("not-a-jdbc-url-at-all");
|
|
||||||
|
|
||||||
assertThatThrownBy(badAdapter::initializeSchema)
|
|
||||||
.isInstanceOf(DocumentPersistenceException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private static String jdbcUrl(Path dir, String filename) {
|
private static String jdbcUrl(Path dir, String filename) {
|
||||||
return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath();
|
return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath().toString().replace('\\', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Set<String> readTableNames(String jdbcUrl) throws SQLException {
|
private static Set<String> readTableNames(String jdbcUrl) throws SQLException {
|
||||||
@@ -411,7 +481,8 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
return tables;
|
return tables;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Set<String> readColumnNames(String jdbcUrl, String tableName) throws SQLException {
|
private static Set<String> readColumnNames(String jdbcUrl, String tableName)
|
||||||
|
throws SQLException {
|
||||||
Set<String> columns = new HashSet<>();
|
Set<String> columns = new HashSet<>();
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||||
DatabaseMetaData meta = conn.getMetaData();
|
DatabaseMetaData meta = conn.getMetaData();
|
||||||
@@ -424,7 +495,25 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void insertDocumentRecordWithStatus(String jdbcUrl, String fingerprint,
|
private static Set<String> readIndexNames(String jdbcUrl) throws SQLException {
|
||||||
|
Set<String> indexes = new HashSet<>();
|
||||||
|
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
|
||||||
|
DatabaseMetaData meta = conn.getMetaData();
|
||||||
|
for (String table : new String[]{"document_record", "processing_attempt"}) {
|
||||||
|
try (ResultSet rs = meta.getIndexInfo(null, null, table, false, false)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String name = rs.getString("INDEX_NAME");
|
||||||
|
if (name != null) {
|
||||||
|
indexes.add(name.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void insertiereDocumentRecord(String jdbcUrl, String fingerprint,
|
||||||
String status) throws SQLException {
|
String status) throws SQLException {
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
var ps = conn.prepareStatement("""
|
var ps = conn.prepareStatement("""
|
||||||
@@ -439,21 +528,22 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void insertAttemptWithStatus(String jdbcUrl, String fingerprint,
|
private static void insertiereProcessingAttempt(String jdbcUrl, String fingerprint,
|
||||||
String status) throws SQLException {
|
int attemptNumber) throws SQLException {
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
var ps = conn.prepareStatement("""
|
var ps = conn.prepareStatement("""
|
||||||
INSERT INTO processing_attempt
|
INSERT INTO processing_attempt
|
||||||
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
||||||
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', ?, 0)
|
VALUES (?, 'run-1', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z',
|
||||||
|
'FAILED_RETRYABLE', 1)
|
||||||
""")) {
|
""")) {
|
||||||
ps.setString(1, fingerprint);
|
ps.setString(1, fingerprint);
|
||||||
ps.setString(2, status);
|
ps.setInt(2, attemptNumber);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readOverallStatus(String jdbcUrl, String fingerprint) throws SQLException {
|
private static String leseStatus(String jdbcUrl, String fingerprint) throws SQLException {
|
||||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
var ps = conn.prepareStatement(
|
var ps = conn.prepareStatement(
|
||||||
"SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
|
"SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
|
||||||
@@ -462,7 +552,7 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
return rs.getString("overall_status");
|
return rs.getString("overall_status");
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("No document record found for fingerprint: " + fingerprint);
|
throw new IllegalStateException("Kein Eintrag für Fingerprint: " + fingerprint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-31
@@ -1,56 +1,76 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound port for loading external prompt templates.
|
* Outbound-Port zum Laden und Speichern des externen Prompt-Templates.
|
||||||
* <p>
|
* <p>
|
||||||
* This interface abstracts the loading of prompt content from external sources
|
* Dieses Interface abstrahiert den Zugriff auf die Prompt-Datei und erlaubt der
|
||||||
* (files, resources, databases, etc.), allowing the Application layer to remain
|
* Application-Schicht, unabhängig vom konkreten Speichermedium zu bleiben.
|
||||||
* independent of how or where prompts are stored.
|
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Design principles:</strong>
|
* <strong>Designprinzipien:</strong>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Prompt is not embedded in code; it is loaded from an external source</li>
|
* <li>Der Prompt wird nicht im Code fest verdrahtet, sondern aus einer externen Quelle geladen.</li>
|
||||||
* <li>Each prompt receives a stable identifier for traceability across batch runs</li>
|
* <li>Jeder Prompt erhält einen stabilen Identifikator für die lückenlose Nachvollziehbarkeit.</li>
|
||||||
* <li>Results are returned as structured types ({@link PromptLoadingResult}),
|
* <li>Ergebnisse werden als strukturierte Typen zurückgegeben, niemals als Exceptions.</li>
|
||||||
* never as exceptions</li>
|
* <li>Der Pfad zur Prompt-Datei ist Implementierungsdetail des Adapters – er erscheint nicht
|
||||||
|
* in der Port-Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen).</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Adapter responsibilities:</strong>
|
* <strong>Adapter-Verantwortung:</strong>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Locate and read the prompt file/resource from the configured source</li>
|
* <li>Prompt-Datei lokalisieren und lesen.</li>
|
||||||
* <li>Derive a stable prompt identifier (e.g., filename, semantic version, content hash)</li>
|
* <li>Stabilen Identifikator ableiten (z. B. Dateiname).</li>
|
||||||
* <li>Validate that the loaded content is not empty or otherwise invalid</li>
|
* <li>Leere oder technisch unbrauchbare Prompts ablehnen.</li>
|
||||||
* <li>Return either success or a classified failure</li>
|
* <li>Beim Speichern: atomares Schreiben via temporäre Datei und {@code ATOMIC_MOVE}.</li>
|
||||||
* <li>Encapsulate all file I/O, resource loading, and configuration details</li>
|
* <li>Alle Datei-I/O-, Ressourcen- und Konfigurationsdetails kapseln.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Non-goals of this port:</strong>
|
* <strong>Nicht-Ziele dieses Ports:</strong>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Prompt parsing or templating logic</li>
|
* <li>Prompt-Parsing oder Template-Verarbeitung</li>
|
||||||
* <li>Combining prompt with document text (Application layer handles this)</li>
|
* <li>Kombination von Prompt und Dokumenttext (Application-Schicht)</li>
|
||||||
* <li>Template variable substitution</li>
|
* <li>Validierung des Prompt-Inhalts gegen Domänenregeln</li>
|
||||||
* <li>Validation of prompt content against domain rules</li>
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public interface PromptPort {
|
public interface PromptPort {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the configured external prompt template.
|
* Lädt das konfigurierte externe Prompt-Template.
|
||||||
* <p>
|
* <p>
|
||||||
* This method is called once per batch run to obtain the current prompt.
|
* Diese Methode wird einmal pro Verarbeitungslauf aufgerufen, um den aktuellen Prompt zu laden.
|
||||||
* The prompt content and its stable identifier are returned together.
|
* Inhalt und stabiler Identifikator werden gemeinsam zurückgegeben.
|
||||||
* <p>
|
* <p>
|
||||||
* If loading fails for any reason (file not found, I/O error, content validation),
|
* Bei einem technischen Fehler (Datei nicht gefunden, I/O-Fehler, leerer Inhalt) wird
|
||||||
* a {@link PromptLoadingFailure} is returned rather than throwing an exception.
|
* {@link PromptLoadingFailure} zurückgegeben – keine Exception wird geworfen.
|
||||||
*
|
|
||||||
* @return a {@link PromptLoadingResult} encoding either:
|
|
||||||
* <ul>
|
|
||||||
* <li>Success: prompt content and identifier loaded successfully</li>
|
|
||||||
* <li>Failure: prompt could not be loaded or is invalid</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
*
|
||||||
|
* @return {@link PromptLoadingResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
* @see PromptLoadingSuccess
|
* @see PromptLoadingSuccess
|
||||||
* @see PromptLoadingFailure
|
* @see PromptLoadingFailure
|
||||||
*/
|
*/
|
||||||
PromptLoadingResult loadPrompt();
|
PromptLoadingResult loadPrompt();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||||
|
* <p>
|
||||||
|
* Der Zielpfad wird intern aus der Konfiguration des Adapters ermittelt und ist
|
||||||
|
* <em>nicht</em> Teil dieser Signatur (hexagonale Regel: keine {@code Path}/{@code File}-Typen
|
||||||
|
* im Port-Vertrag).
|
||||||
|
* <p>
|
||||||
|
* Die Implementierung schreibt zunächst in eine temporäre Datei <em>im selben Verzeichnis</em>
|
||||||
|
* wie die Zieldatei und verschiebt diese danach atomar via {@code ATOMIC_MOVE}.
|
||||||
|
* Bei einem Fehler beim atomaren Verschieben wird <strong>kein stiller Fallback</strong>
|
||||||
|
* auf ein nicht-atomares Schreiben durchgeführt; stattdessen wird
|
||||||
|
* {@link PromptSaveResult.AtomicMoveFailed} zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Zeichenkodierung: UTF-8. Zeilenenden werden unverändert übernommen.
|
||||||
|
*
|
||||||
|
* @param content der zu speichernde Prompt-Inhalt; darf leer sein (Entscheidung liegt
|
||||||
|
* beim Aufrufer, ob ein leerer Prompt erwünscht ist)
|
||||||
|
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code content} null ist
|
||||||
|
* @see PromptSaveResult.Saved
|
||||||
|
* @see PromptSaveResult.WriteFailed
|
||||||
|
* @see PromptSaveResult.TargetDirectoryMissing
|
||||||
|
* @see PromptSaveResult.AtomicMoveFailed
|
||||||
|
*/
|
||||||
|
PromptSaveResult savePrompt(String content);
|
||||||
}
|
}
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis-Interface für das Speichern einer Prompt-Datei via
|
||||||
|
* {@link PromptPort#savePrompt(String)}.
|
||||||
|
* <p>
|
||||||
|
* Mögliche Ergebnisse:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Saved} – das Speichern war erfolgreich.</li>
|
||||||
|
* <li>{@link WriteFailed} – ein technischer Fehler beim Schreiben ist aufgetreten.</li>
|
||||||
|
* <li>{@link TargetDirectoryMissing} – der konfigurierte Zielordner existiert nicht.</li>
|
||||||
|
* <li>{@link AtomicMoveFailed} – das atomare Verschieben der temporären Datei ist
|
||||||
|
* fehlgeschlagen; kein stiller Fallback.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public sealed interface PromptSaveResult
|
||||||
|
permits PromptSaveResult.Saved,
|
||||||
|
PromptSaveResult.WriteFailed,
|
||||||
|
PromptSaveResult.TargetDirectoryMissing,
|
||||||
|
PromptSaveResult.AtomicMoveFailed {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Prompt-Datei wurde erfolgreich gespeichert.
|
||||||
|
*
|
||||||
|
* @param absolutePath absoluter Pfad der gespeicherten Datei; nie {@code null}
|
||||||
|
*/
|
||||||
|
record Saved(String absolutePath) implements PromptSaveResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Saved-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param absolutePath absoluter Pfad der gespeicherten Datei; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code absolutePath} null ist
|
||||||
|
*/
|
||||||
|
public Saved {
|
||||||
|
java.util.Objects.requireNonNull(absolutePath, "absolutePath must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Schreiben der temporären Datei ist fehlgeschlagen.
|
||||||
|
*
|
||||||
|
* @param message Fehlerbeschreibung; nie {@code null}
|
||||||
|
* @param cause auslösende Ausnahme; kann {@code null} sein
|
||||||
|
*/
|
||||||
|
record WriteFailed(String message, Throwable cause) implements PromptSaveResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein WriteFailed-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param message Fehlerbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @param cause auslösende Ausnahme; kann {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code message} null ist
|
||||||
|
*/
|
||||||
|
public WriteFailed {
|
||||||
|
java.util.Objects.requireNonNull(message, "message must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der konfigurierte Zielordner existiert nicht.
|
||||||
|
*
|
||||||
|
* @param message Beschreibung des fehlenden Ordners; nie {@code null}
|
||||||
|
*/
|
||||||
|
record TargetDirectoryMissing(String message) implements PromptSaveResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein TargetDirectoryMissing-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param message Beschreibung des fehlenden Ordners; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code message} null ist
|
||||||
|
*/
|
||||||
|
public TargetDirectoryMissing {
|
||||||
|
java.util.Objects.requireNonNull(message, "message must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das atomare Verschieben der temporären Datei zur Zieldatei ist fehlgeschlagen.
|
||||||
|
* Es wird kein stiller Fallback auf nicht-atomares Schreiben durchgeführt.
|
||||||
|
*
|
||||||
|
* @param message Fehlerbeschreibung; nie {@code null}
|
||||||
|
*/
|
||||||
|
record AtomicMoveFailed(String message) implements PromptSaveResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein AtomicMoveFailed-Ergebnis.
|
||||||
|
*
|
||||||
|
* @param message Fehlerbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code message} null ist
|
||||||
|
*/
|
||||||
|
public AtomicMoveFailed {
|
||||||
|
java.util.Objects.requireNonNull(message, "message must not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -60,5 +60,28 @@ public interface UnitOfWorkPort {
|
|||||||
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
* @throws DocumentPersistenceException if the delete fails due to a technical error
|
||||||
*/
|
*/
|
||||||
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
|
void resetDocumentByFingerprint(DocumentFingerprint fingerprint);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||||
|
* ohne die Versuchshistorie zu löschen.
|
||||||
|
* <p>
|
||||||
|
* Folgende Felder werden aktualisiert:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||||
|
* <li>{@code content_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||||
|
* </ul>
|
||||||
|
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
|
||||||
|
* {@code last_target_path}, {@code last_target_file_name} sowie alle
|
||||||
|
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
|
||||||
|
* <p>
|
||||||
|
* Nach diesem Aufruf gilt das Dokument beim nächsten Lauf als verarbeitbar.
|
||||||
|
*
|
||||||
|
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void resetDocumentStatusForRetry(DocumentFingerprint fingerprint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelzeile der Dokumentenliste im Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Enthält alle Felder, die für die linke Tabelle des Historien-Tabs benötigt werden.
|
||||||
|
* Die Felder stammen aus {@code document_record} und einem {@code COUNT}-Ausdruck über
|
||||||
|
* {@code processing_attempt}.
|
||||||
|
*
|
||||||
|
* @param fingerprint Inhalts-basierter Dokumentbezeichner; nie {@code null}
|
||||||
|
* @param overallStatus aktueller Gesamtstatus des Dokuments; nie {@code null}
|
||||||
|
* @param sourceFileName zuletzt bekannter Quelldateiname; nie {@code null}
|
||||||
|
* @param targetFileName zuletzt bekannter Zieldateiname; {@code null} falls noch kein
|
||||||
|
* erfolgreicher Lauf stattgefunden hat
|
||||||
|
* @param sourcePath zuletzt bekannter Quellpfad (opaker Locator-Wert); nie {@code null}
|
||||||
|
* @param updatedAt Zeitpunkt der letzten Aktualisierung des Stammsatzes; nie {@code null}
|
||||||
|
* @param attemptCount Anzahl historisierter Verarbeitungsversuche; immer >= 0
|
||||||
|
*/
|
||||||
|
public record DocumentHistoryRow(
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
ProcessingStatus overallStatus,
|
||||||
|
String sourceFileName,
|
||||||
|
String targetFileName,
|
||||||
|
String sourcePath,
|
||||||
|
Instant updatedAt,
|
||||||
|
long attemptCount) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException wenn ein Pflichtfeld {@code null} ist
|
||||||
|
* @throws IllegalArgumentException wenn {@code attemptCount} negativ ist
|
||||||
|
*/
|
||||||
|
public DocumentHistoryRow {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
Objects.requireNonNull(overallStatus, "overallStatus darf nicht null sein");
|
||||||
|
Objects.requireNonNull(sourceFileName, "sourceFileName darf nicht null sein");
|
||||||
|
Objects.requireNonNull(sourcePath, "sourcePath darf nicht null sein");
|
||||||
|
Objects.requireNonNull(updatedAt, "updatedAt darf nicht null sein");
|
||||||
|
if (attemptCount < 0) {
|
||||||
|
throw new IllegalArgumentException("attemptCount darf nicht negativ sein, war: " + attemptCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abfrageparameter für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Kapselt Freitextsuche, optionalen Status-Filter und das Limit der zurückzugebenden
|
||||||
|
* Zeilen. Das Limit ist bewusst auf 501 gesetzt, damit die aufrufende Schicht erkennen
|
||||||
|
* kann, ob mehr als 500 Treffer vorhanden sind.
|
||||||
|
*
|
||||||
|
* @param searchText optionaler Suchbegriff (Teilstring, case-insensitiv); {@code null}
|
||||||
|
* oder leer bedeutet keine Texteinschränkung
|
||||||
|
* @param statusFilter optionaler Status-Filter als Enum-Name; {@code null} bedeutet alle
|
||||||
|
* Status werden angezeigt
|
||||||
|
* @param limit maximale Anzahl zurückzugebender Zeilen; muss >= 1 sein
|
||||||
|
*/
|
||||||
|
public record HistoryQuery(
|
||||||
|
String searchText,
|
||||||
|
String statusFilter,
|
||||||
|
int limit) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard-Limit: 501 Zeilen abfragen, um bei Bedarf „mehr vorhanden" erkennen zu können.
|
||||||
|
*/
|
||||||
|
public static final int DEFAULT_LIMIT = 501;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException wenn {@code limit} kleiner als 1 ist
|
||||||
|
*/
|
||||||
|
public HistoryQuery {
|
||||||
|
if (limit < 1) {
|
||||||
|
throw new IllegalArgumentException("limit muss mindestens 1 sein, war: " + limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine Abfrage ohne Filter mit Standard-Limit.
|
||||||
|
*
|
||||||
|
* @return neue Abfrage ohne Einschränkungen
|
||||||
|
*/
|
||||||
|
public static HistoryQuery unfiltered() {
|
||||||
|
return new HistoryQuery(null, null, DEFAULT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine Abfrage mit Freitextsuche und Standard-Limit.
|
||||||
|
*
|
||||||
|
* @param searchText Suchbegriff; {@code null} oder leer bedeutet kein Filter
|
||||||
|
* @return neue Abfrage mit Textfilter
|
||||||
|
*/
|
||||||
|
public static HistoryQuery withSearchText(String searchText) {
|
||||||
|
return new HistoryQuery(searchText, null, DEFAULT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine Abfrage mit Status-Filter und Standard-Limit.
|
||||||
|
*
|
||||||
|
* @param statusFilter Enum-Name des gewünschten Status; {@code null} bedeutet kein Filter
|
||||||
|
* @return neue Abfrage mit Status-Filter
|
||||||
|
*/
|
||||||
|
public static HistoryQuery withStatus(String statusFilter) {
|
||||||
|
return new HistoryQuery(null, statusFilter, DEFAULT_LIMIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Kapselt alle Datenbanklese-Operationen, die der Historien-Tab benötigt.
|
||||||
|
* Die Implementierung liegt ausschließlich in {@code pdf-umbenenner-adapter-out}.
|
||||||
|
* Die Application-Schicht kennt nur diesen Port-Vertrag – keine JDBC-Typen.
|
||||||
|
*
|
||||||
|
* <h2>Architektur</h2>
|
||||||
|
* <p>
|
||||||
|
* Dieser Port ist bewusst von {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository}
|
||||||
|
* und {@link de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository}
|
||||||
|
* getrennt, damit die bestehenden Repositories nicht mit GUI-spezifischen Methoden
|
||||||
|
* aufgebläht werden.
|
||||||
|
*/
|
||||||
|
public interface HistoryQueryPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt eine gefilterte und sortierte Übersicht aller Dokumenteneinträge.
|
||||||
|
* <p>
|
||||||
|
* Sortierung: {@code updated_at DESC, fingerprint ASC} (stabiler Tie-Breaker).
|
||||||
|
* Das in {@link HistoryQuery#limit()} angegebene Limit wird direkt als SQL-{@code LIMIT}
|
||||||
|
* angewendet. Wenn das Limit 501 beträgt und 501 Zeilen zurückgegeben werden, gibt es
|
||||||
|
* mehr als 500 Treffer.
|
||||||
|
*
|
||||||
|
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
|
||||||
|
* technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
List<DocumentHistoryRow> loadOverview(HistoryQuery query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den vollständigen Dokumenten-Stammsatz für den angegebenen Fingerprint.
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
|
||||||
|
* technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt alle historisierten Verarbeitungsversuche für den angegebenen Fingerprint,
|
||||||
|
* aufsteigend sortiert nach {@code attempt_number}.
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return unveränderliche Liste der Versuche; nie {@code null}; kann leer sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException bei
|
||||||
|
* technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Outbound-Ports und DTOs für lesende Historien-Abfragen des Historien-Tabs.
|
||||||
|
* <p>
|
||||||
|
* Enthält den {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort}
|
||||||
|
* sowie die zugehörigen Datentypen
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery} und
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow}.
|
||||||
|
* Diese Typen sind bewusst vom bestehenden {@code port.out}-Paket getrennt,
|
||||||
|
* damit die allgemeinen Repository-Schnittstellen nicht mit GUI-spezifischen Methoden
|
||||||
|
* belastet werden.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out.history;
|
||||||
+15
-5
@@ -6,6 +6,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
|
|||||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
|
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
|
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
||||||
|
|
||||||
@@ -26,10 +27,14 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
|||||||
* <li><strong>Naming proposal ready:</strong> Status becomes
|
* <li><strong>Naming proposal ready:</strong> Status becomes
|
||||||
* {@link ProcessingStatus#PROPOSAL_READY}, counters unchanged,
|
* {@link ProcessingStatus#PROPOSAL_READY}, counters unchanged,
|
||||||
* {@code retryable=false}.</li>
|
* {@code retryable=false}.</li>
|
||||||
* <li><strong>Pre-check content error (first occurrence):</strong>
|
* <li><strong>Pre-check content error {@link PreCheckFailureReason#NO_USABLE_TEXT}:</strong>
|
||||||
|
* Status becomes {@link ProcessingStatus#FAILED_FINAL} immediately,
|
||||||
|
* content error counter incremented by 1, {@code retryable=false}.
|
||||||
|
* Image-only PDFs without OCR text will not yield usable text on retry.</li>
|
||||||
|
* <li><strong>Pre-check content error (other reason, first occurrence):</strong>
|
||||||
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
|
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
|
||||||
* content error counter incremented by 1, {@code retryable=true}.</li>
|
* content error counter incremented by 1, {@code retryable=true}.</li>
|
||||||
* <li><strong>Pre-check content error (second or later occurrence):</strong>
|
* <li><strong>Pre-check content error (other reason, second or later occurrence):</strong>
|
||||||
* Status becomes {@link ProcessingStatus#FAILED_FINAL},
|
* Status becomes {@link ProcessingStatus#FAILED_FINAL},
|
||||||
* content error counter incremented by 1, {@code retryable=false}.</li>
|
* content error counter incremented by 1, {@code retryable=false}.</li>
|
||||||
* <li><strong>AI functional failure (first occurrence):</strong>
|
* <li><strong>AI functional failure (first occurrence):</strong>
|
||||||
@@ -112,11 +117,16 @@ final class ProcessingOutcomeTransition {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case PreCheckFailed ignored2 -> {
|
case PreCheckFailed preCheckFailed -> {
|
||||||
// Deterministic content error from pre-check: apply the 1-retry rule
|
|
||||||
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
|
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
|
||||||
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
|
|
||||||
|
|
||||||
|
if (preCheckFailed.failureReason() == PreCheckFailureReason.NO_USABLE_TEXT) {
|
||||||
|
// Image-only PDFs without OCR text will not change on retry.
|
||||||
|
yield new ProcessingOutcome(ProcessingStatus.FAILED_FINAL, updatedCounters, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other deterministic content errors: apply the 1-retry rule
|
||||||
|
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
|
||||||
if (isFirstOccurrence) {
|
if (isFirstOccurrence) {
|
||||||
yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
|
yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case-Implementierung für das vollständige Löschen eines Dokumenteintrags
|
||||||
|
* aus dem Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Löscht innerhalb einer Transaktion in der korrekten Reihenfolge, um den
|
||||||
|
* Foreign-Key-Constraint zwischen {@code processing_attempt.fingerprint} und
|
||||||
|
* {@code document_record.fingerprint} zu erfüllen (kein {@code ON DELETE CASCADE}):
|
||||||
|
* <ol>
|
||||||
|
* <li>Alle {@code processing_attempt}-Einträge zum Fingerprint</li>
|
||||||
|
* <li>Den {@code document_record}-Stammsatz zum Fingerprint</li>
|
||||||
|
* </ol>
|
||||||
|
* Die Operation ist idempotent: wenn kein Datensatz für den Fingerprint existiert,
|
||||||
|
* kehrt die Methode stillschweigend zurück.
|
||||||
|
* <p>
|
||||||
|
* <strong>Hinweis:</strong> Diese Aktion ist destruktiv und nicht rückgängig zu machen.
|
||||||
|
* Die GUI muss vor dem Aufruf einen Bestätigungsdialog anzeigen.
|
||||||
|
*/
|
||||||
|
public class DefaultDeleteDocumentHistoryUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(DefaultDeleteDocumentHistoryUseCase.class);
|
||||||
|
|
||||||
|
private final UnitOfWorkPort unitOfWorkPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
|
||||||
|
*
|
||||||
|
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
|
||||||
|
*/
|
||||||
|
public DefaultDeleteDocumentHistoryUseCase(UnitOfWorkPort unitOfWorkPort) {
|
||||||
|
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Die Löschung erfolgt in einer einzigen Transaktion. Versuche werden vor dem
|
||||||
|
* Stammsatz gelöscht, damit der Foreign-Key-Constraint eingehalten wird.
|
||||||
|
*
|
||||||
|
* @param fingerprint der Dokumentbezeichner, dessen Daten vollständig gelöscht werden sollen;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
* @throws NullPointerException wenn {@code fingerprint} null ist
|
||||||
|
*/
|
||||||
|
public void deleteHistory(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
// Nutzung der bestehenden Transaktion mit korrekter Löschreihenfolge:
|
||||||
|
// zuerst Versuche, dann Stammsatz (FK-Constraint)
|
||||||
|
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentByFingerprint(fingerprint));
|
||||||
|
|
||||||
|
logger.info("Dokumenteintrag vollständig gelöscht für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case-Implementierung für das Laden der Detailansicht eines Dokuments im Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Kombiniert den Dokument-Stammsatz und alle historisierten Verarbeitungsversuche
|
||||||
|
* für einen bestimmten Fingerprint in einem einzigen Ergebnisobjekt.
|
||||||
|
* <p>
|
||||||
|
* Wird kein Stammsatz gefunden (z. B. weil das Dokument zwischenzeitlich gelöscht wurde),
|
||||||
|
* liefert {@link #loadDetails(DocumentFingerprint)} ein leeres {@link Optional}.
|
||||||
|
*/
|
||||||
|
public class DefaultHistoryDetailsUseCase {
|
||||||
|
|
||||||
|
private final HistoryQueryPort historyQueryPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
|
||||||
|
*
|
||||||
|
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code historyQueryPort} null ist
|
||||||
|
*/
|
||||||
|
public DefaultHistoryDetailsUseCase(HistoryQueryPort historyQueryPort) {
|
||||||
|
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
*
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit den Detaildaten, oder leer wenn kein Stammsatz gefunden wurde
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
public Optional<HistoryDetailsResult> loadDetails(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
Optional<DocumentRecord> record = historyQueryPort.findRecordByFingerprint(fingerprint);
|
||||||
|
if (record.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ProcessingAttempt> attempts = historyQueryPort.findAttemptsByFingerprint(fingerprint);
|
||||||
|
return Optional.of(new HistoryDetailsResult(record.get(), attempts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer Historien-Detailabfrage.
|
||||||
|
*
|
||||||
|
* @param record Dokument-Stammsatz; nie {@code null}
|
||||||
|
* @param attempts alle historisierten Verarbeitungsversuche aufsteigend nach Versuchsnummer;
|
||||||
|
* nie {@code null}; kann leer sein
|
||||||
|
*/
|
||||||
|
public record HistoryDetailsResult(DocumentRecord record, List<ProcessingAttempt> attempts) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException wenn {@code record} oder {@code attempts} null ist
|
||||||
|
*/
|
||||||
|
public HistoryDetailsResult {
|
||||||
|
Objects.requireNonNull(record, "record darf nicht null sein");
|
||||||
|
Objects.requireNonNull(attempts, "attempts darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case-Implementierung für das Laden der Dokumentenliste im Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Delegiert die Datenbankabfrage vollständig an {@link HistoryQueryPort} und
|
||||||
|
* wertet das LIMIT-501-Ergebnis aus, um der GUI signalisieren zu können, ob
|
||||||
|
* weitere Einträge vorhanden sind, die durch einen engeren Filter erreichbar wären.
|
||||||
|
* <p>
|
||||||
|
* <strong>LIMIT-501-Technik:</strong> Die Query wird mit {@code limit + 1 = 501}
|
||||||
|
* ausgeführt (sofern das übergebene Limit 500 beträgt). Wenn die Datenbank 501
|
||||||
|
* Zeilen zurückgibt, existieren mehr als 500 Treffer. Die zurückgegebene Liste
|
||||||
|
* enthält dann exakt 500 Zeilen (das letzte Element wird verworfen) und
|
||||||
|
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
|
||||||
|
*/
|
||||||
|
public class DefaultHistoryOverviewUseCase {
|
||||||
|
|
||||||
|
private static final int MAX_DISPLAY_COUNT = 500;
|
||||||
|
|
||||||
|
private final HistoryQueryPort historyQueryPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Use-Case mit dem erforderlichen Abfrage-Port.
|
||||||
|
*
|
||||||
|
* @param historyQueryPort Port für lesende Historienabfragen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code historyQueryPort} null ist
|
||||||
|
*/
|
||||||
|
public DefaultHistoryOverviewUseCase(HistoryQueryPort historyQueryPort) {
|
||||||
|
this.historyQueryPort = Objects.requireNonNull(historyQueryPort, "historyQueryPort darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Dokumentenliste auf Basis der übergebenen Abfrageparameter.
|
||||||
|
* <p>
|
||||||
|
* Intern wird ein Limit von 501 verwendet, um erkennen zu können, ob mehr
|
||||||
|
* als 500 Treffer vorhanden sind.
|
||||||
|
*
|
||||||
|
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnisobjekt mit Trefferlist und {@code hasMore}-Flag; nie {@code null}
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
public HistoryOverviewResult loadOverview(HistoryQuery query) {
|
||||||
|
Objects.requireNonNull(query, "query darf nicht null sein");
|
||||||
|
|
||||||
|
List<DocumentHistoryRow> rows = historyQueryPort.loadOverview(query);
|
||||||
|
|
||||||
|
if (rows.size() > MAX_DISPLAY_COUNT) {
|
||||||
|
// 501 Zeilen zurückgegeben: mehr als 500 Treffer vorhanden
|
||||||
|
List<DocumentHistoryRow> truncated = List.copyOf(rows.subList(0, MAX_DISPLAY_COUNT));
|
||||||
|
return new HistoryOverviewResult(truncated, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HistoryOverviewResult(List.copyOf(rows), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer Historien-Übersichtsabfrage.
|
||||||
|
*
|
||||||
|
* @param rows Liste der Trefferzeilen; nie {@code null}; enthält maximal 500 Einträge
|
||||||
|
* @param hasMore {@code true}, wenn mehr als 500 Treffer vorhanden sind und durch
|
||||||
|
* einen engeren Filter eingegrenzt werden könnten
|
||||||
|
*/
|
||||||
|
public record HistoryOverviewResult(List<DocumentHistoryRow> rows, boolean hasMore) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Konstruktor mit Pflichtfeldprüfung.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException wenn {@code rows} null ist
|
||||||
|
*/
|
||||||
|
public HistoryOverviewResult {
|
||||||
|
Objects.requireNonNull(rows, "rows darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case-Implementierung für den feldgenauen Status-Reset aus dem Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Setzt ausschließlich die vier fachlich relevanten Status-Felder zurück,
|
||||||
|
* ohne die Versuchshistorie zu löschen:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||||
|
* <li>{@code content_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||||
|
* </ul>
|
||||||
|
* Nicht geändert werden: {@code created_at}, {@code last_success_instant},
|
||||||
|
* {@code last_target_path}, {@code last_target_file_name} sowie alle
|
||||||
|
* {@code processing_attempt}-Einträge, die vollständig erhalten bleiben.
|
||||||
|
* <p>
|
||||||
|
* Nach dem Reset gilt das Dokument beim nächsten Verarbeitungslauf als verarbeitbar,
|
||||||
|
* da {@code READY_FOR_AI} der einzige Trigger für die Verarbeitungslogik ist.
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung:</strong> Dieser Use-Case unterscheidet sich von
|
||||||
|
* {@link DefaultResetDocumentStatusUseCase}, der alle Persistenzdaten (Stammsatz und
|
||||||
|
* Versuchshistorie) vollständig löscht und das Dokument so behandelt, als wäre es
|
||||||
|
* noch nie verarbeitet worden.
|
||||||
|
*/
|
||||||
|
public class DefaultHistoryResetDocumentStatusUseCase {
|
||||||
|
|
||||||
|
private static final Logger logger = LogManager.getLogger(DefaultHistoryResetDocumentStatusUseCase.class);
|
||||||
|
|
||||||
|
private final UnitOfWorkPort unitOfWorkPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Use-Case mit dem erforderlichen Persistenz-Port.
|
||||||
|
*
|
||||||
|
* @param unitOfWorkPort Port für transaktionale Persistenzoperationen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code unitOfWorkPort} null ist
|
||||||
|
*/
|
||||||
|
public DefaultHistoryResetDocumentStatusUseCase(UnitOfWorkPort unitOfWorkPort) {
|
||||||
|
this.unitOfWorkPort = Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt den feldgenauen Status-Reset für den angegebenen Fingerprint durch.
|
||||||
|
* <p>
|
||||||
|
* Die Operation ist atomar: entweder werden alle vier Felder aktualisiert,
|
||||||
|
* oder keine Änderung findet statt (Rollback).
|
||||||
|
*
|
||||||
|
* @param fingerprint der Dokumentbezeichner, dessen Status zurückgesetzt werden soll;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws DocumentPersistenceException bei technischen Datenbankfehlern
|
||||||
|
* @throws NullPointerException wenn {@code fingerprint} null ist
|
||||||
|
*/
|
||||||
|
public void resetStatus(DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");
|
||||||
|
|
||||||
|
unitOfWorkPort.executeInTransaction(tx -> tx.resetDocumentStatusForRetry(fingerprint));
|
||||||
|
|
||||||
|
logger.info("Feldgenauer Status-Reset durchgeführt für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
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.ResourceCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-Case zur Anzeige und Bearbeitung des KI-Prompt-Templates über die GUI.
|
||||||
|
* <p>
|
||||||
|
* Dieser Use-Case vermittelt zwischen dem GUI-Adapter und dem {@link PromptPort} sowie dem
|
||||||
|
* {@link ResourceCreationPort}. Er kennt keine JavaFX-Typen, kein Dateisystem und keine
|
||||||
|
* HTTP-Kommunikation; alle technischen Details bleiben in den jeweiligen Adaptern.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verantwortung:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Aktuellen Prompt-Inhalt laden und als strukturiertes Ergebnis zurückgeben.</li>
|
||||||
|
* <li>Bearbeiteten Inhalt atomar in die konfigurierte Prompt-Datei speichern.</li>
|
||||||
|
* <li>Anlegen einer Standard-Prompt-Datei delegieren, wenn keine Datei vorhanden ist.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung:</strong> Dieser Use-Case trifft keine Entscheidungen über
|
||||||
|
* Benutzeroberfläche, Threading oder Dirty-State-Verwaltung. Diese Verantwortung
|
||||||
|
* liegt im GUI-Adapter.
|
||||||
|
*/
|
||||||
|
public class DefaultPromptEditorUseCase {
|
||||||
|
|
||||||
|
private final PromptPort promptPort;
|
||||||
|
private final ResourceCreationPort resourceCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den Use-Case mit den erforderlichen Ports.
|
||||||
|
*
|
||||||
|
* @param promptPort Port zum Laden und Speichern des Prompt-Templates;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param resourceCreationPort Port zum Anlegen der Standard-Prompt-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public DefaultPromptEditorUseCase(PromptPort promptPort, ResourceCreationPort resourceCreationPort) {
|
||||||
|
this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null");
|
||||||
|
this.resourceCreationPort = Objects.requireNonNull(resourceCreationPort,
|
||||||
|
"resourceCreationPort must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Prompt-Datei.
|
||||||
|
* <p>
|
||||||
|
* Delegiert direkt an {@link PromptPort#loadPrompt()} und gibt das Ergebnis
|
||||||
|
* unverändert zurück.
|
||||||
|
*
|
||||||
|
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
|
||||||
|
* oder einem klassifizierten Fehler; nie {@code null}
|
||||||
|
* @see PromptLoadingSuccess
|
||||||
|
* @see PromptLoadingFailure
|
||||||
|
*/
|
||||||
|
public PromptLoadingResult loadPrompt() {
|
||||||
|
return promptPort.loadPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||||
|
* <p>
|
||||||
|
* Delegiert direkt an {@link PromptPort#savePrompt(String)}. Der Zielpfad ist
|
||||||
|
* Implementierungsdetail des Adapters.
|
||||||
|
*
|
||||||
|
* @param content der zu speichernde Prompt-Inhalt; darf nicht {@code null} sein
|
||||||
|
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code content} null ist
|
||||||
|
* @see PromptSaveResult.Saved
|
||||||
|
* @see PromptSaveResult.WriteFailed
|
||||||
|
* @see PromptSaveResult.TargetDirectoryMissing
|
||||||
|
* @see PromptSaveResult.AtomicMoveFailed
|
||||||
|
*/
|
||||||
|
public PromptSaveResult savePrompt(String content) {
|
||||||
|
Objects.requireNonNull(content, "content must not be null");
|
||||||
|
return promptPort.savePrompt(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Standard-Prompt-Datei an, wenn noch keine vorhanden ist.
|
||||||
|
* <p>
|
||||||
|
* Delegiert an {@link ResourceCreationPort#createPromptFile(CorrectionSuggestion.CreatePromptFile)}.
|
||||||
|
* Das Ergebnis beschreibt, ob die Datei angelegt wurde, ob sie bereits existierte
|
||||||
|
* oder ob ein Fehler aufgetreten ist.
|
||||||
|
*
|
||||||
|
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
|
||||||
|
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code suggestion} null ist
|
||||||
|
*/
|
||||||
|
public CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||||
|
return resourceCreationPort.createPromptFile(suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -84,5 +84,13 @@ public enum CheckpointId {
|
|||||||
* zeigt auf eine vorhandene Datei oder auf einen beschreibbaren Ordner, in dem die
|
* zeigt auf eine vorhandene Datei oder auf einen beschreibbaren Ordner, in dem die
|
||||||
* Datei neu angelegt werden kann.
|
* Datei neu angelegt werden kann.
|
||||||
*/
|
*/
|
||||||
SQLITE_PATH_USABLE
|
SQLITE_PATH_USABLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log-Verzeichnis beschreibbar – das konfigurierte (oder Standard-)Log-Verzeichnis
|
||||||
|
* ist vorhanden und schreibbar. Zeigt zusätzlich den tatsächlichen Log-Dateipfad
|
||||||
|
* aus der aktiven Log4j2-Konfiguration an. Ein nicht beschreibbares Log-Verzeichnis
|
||||||
|
* ist eine Warnung, kein harter Fehler.
|
||||||
|
*/
|
||||||
|
LOG_DIRECTORY_USABLE
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausgehender Port zur Diagnose des aktiven Log-Ausgabepfads.
|
||||||
|
* <p>
|
||||||
|
* Implementierungen lesen den tatsächlich von der Logging-Infrastruktur verwendeten
|
||||||
|
* Dateipfad aus der laufenden Konfiguration des Logging-Frameworks aus. Der Port ist
|
||||||
|
* provider-neutral; er kennt weder Log4j2 noch andere Framework-spezifische Typen.
|
||||||
|
* <p>
|
||||||
|
* Diese Information ergänzt den konfigurierten {@code log.directory}-Wert aus der
|
||||||
|
* Properties-Datei und zeigt, wo Logeinträge tatsächlich landen – unabhängig davon,
|
||||||
|
* ob das Log-Verzeichnis zum Zeitpunkt des Tests beschreibbar ist.
|
||||||
|
*/
|
||||||
|
public interface LogDiagnosticsPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt den absoluten Dateipfad der aktiven Log-Ausgabedatei.
|
||||||
|
* <p>
|
||||||
|
* Gibt einen leeren {@link Optional} zurück, wenn der Pfad nicht bestimmbar ist –
|
||||||
|
* beispielsweise weil kein dateibasierter Appender aktiv ist oder die
|
||||||
|
* Logging-Konfiguration nicht ausgelesen werden kann.
|
||||||
|
*
|
||||||
|
* @return absoluter Pfad der aktiven Log-Datei; leer wenn nicht bestimmbar
|
||||||
|
*/
|
||||||
|
Optional<String> resolveActiveLogFilePath();
|
||||||
|
}
|
||||||
+88
-17
@@ -17,16 +17,18 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
|
|||||||
/**
|
/**
|
||||||
* Orchestrator für den vollständigen technischen Gesamttest der GUI-Konfiguration.
|
* Orchestrator für den vollständigen technischen Gesamttest der GUI-Konfiguration.
|
||||||
* <p>
|
* <p>
|
||||||
* Führt alle elf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
|
* Führt alle zwölf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li><strong>Lokale Validierung:</strong> Prüft den Editorzustand ohne I/O mithilfe des
|
* <li><strong>Lokale Validierung:</strong> Prüft den Editorzustand ohne I/O mithilfe des
|
||||||
* {@link EditorConfigurationValidator}. Erzeugt Ergebnisse für
|
* {@link EditorConfigurationValidator}. Erzeugt Ergebnisse für
|
||||||
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
|
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
|
||||||
* {@link CheckpointId#PROVIDER_CONFIGURATION}.</li>
|
* {@link CheckpointId#PROVIDER_CONFIGURATION}.</li>
|
||||||
* <li><strong>Pfadprüfungen:</strong> Prüft Quellordner, Zielordner, Prompt-Datei und
|
* <li><strong>Pfadprüfungen:</strong> Prüft Quellordner, Zielordner, Prompt-Datei,
|
||||||
* SQLite-Pfad über den {@link PathCheckPort}. Erzeugt Ergebnisse für
|
* SQLite-Pfad und Log-Verzeichnis über den {@link PathCheckPort} sowie den
|
||||||
|
* {@link LogDiagnosticsPort}. Erzeugt Ergebnisse für
|
||||||
* {@link CheckpointId#PROMPT_FILE_PRESENT}, {@link CheckpointId#SOURCE_FOLDER_PRESENT},
|
* {@link CheckpointId#PROMPT_FILE_PRESENT}, {@link CheckpointId#SOURCE_FOLDER_PRESENT},
|
||||||
* {@link CheckpointId#TARGET_FOLDER_USABLE} und {@link CheckpointId#SQLITE_PATH_USABLE}.</li>
|
* {@link CheckpointId#TARGET_FOLDER_USABLE}, {@link CheckpointId#SQLITE_PATH_USABLE}
|
||||||
|
* und {@link CheckpointId#LOG_DIRECTORY_USABLE}.</li>
|
||||||
* <li><strong>Provider-Prüfungen:</strong> Prüft Endpoint, API-Key, Modellliste und
|
* <li><strong>Provider-Prüfungen:</strong> Prüft Endpoint, API-Key, Modellliste und
|
||||||
* Modellplausibilität über den {@link ProviderTechnicalTestService}. Erzeugt Ergebnisse für
|
* Modellplausibilität über den {@link ProviderTechnicalTestService}. Erzeugt Ergebnisse für
|
||||||
* {@link CheckpointId#BASE_URL_REACHABLE}, {@link CheckpointId#API_KEY_PRESENT},
|
* {@link CheckpointId#BASE_URL_REACHABLE}, {@link CheckpointId#API_KEY_PRESENT},
|
||||||
@@ -38,7 +40,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
|
|||||||
* ausgeführt, auch wenn ein Block eine Exception wirft. In diesem Fall werden die
|
* ausgeführt, auch wenn ein Block eine Exception wirft. In diesem Fall werden die
|
||||||
* betroffenen Checkpoints als {@link CheckpointResult.Failure} mit Schweregrad ERROR
|
* betroffenen Checkpoints als {@link CheckpointResult.Failure} mit Schweregrad ERROR
|
||||||
* und dem Präfix „Interner Fehler:" markiert. Der Gesamtbericht enthält immer genau
|
* und dem Präfix „Interner Fehler:" markiert. Der Gesamtbericht enthält immer genau
|
||||||
* elf Einträge.
|
* zwölf Einträge.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #run(TechnicalTestRequest)}
|
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #run(TechnicalTestRequest)}
|
||||||
* ist synchron blockierend (der Provider-Prüfblock führt HTTP-Aufrufe durch). Sie darf
|
* ist synchron blockierend (der Provider-Prüfblock führt HTTP-Aufrufe durch). Sie darf
|
||||||
@@ -51,28 +53,32 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
|
|||||||
* wird {@code config/prompt.txt} relativ zum Arbeitsverzeichnis verwendet.
|
* wird {@code config/prompt.txt} relativ zum Arbeitsverzeichnis verwendet.
|
||||||
* <p>
|
* <p>
|
||||||
* Dieser Service enthält keine JavaFX-Typen, keine NIO-Pfadobjekte in Signaturen und
|
* Dieser Service enthält keine JavaFX-Typen, keine NIO-Pfadobjekte in Signaturen und
|
||||||
* keine Infrastrukturabhängigkeiten jenseits der drei injizierten Abhängigkeiten.
|
* keine Infrastrukturabhängigkeiten jenseits der vier injizierten Abhängigkeiten.
|
||||||
*/
|
*/
|
||||||
public class TechnicalTestOrchestrator {
|
public class TechnicalTestOrchestrator {
|
||||||
|
|
||||||
private final EditorConfigurationValidator editorValidator;
|
private final EditorConfigurationValidator editorValidator;
|
||||||
private final PathCheckPort pathCheckPort;
|
private final PathCheckPort pathCheckPort;
|
||||||
private final ProviderTechnicalTestService providerTestService;
|
private final ProviderTechnicalTestService providerTestService;
|
||||||
|
private final LogDiagnosticsPort logDiagnosticsPort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt einen neuen Orchestrator mit den drei erforderlichen Abhängigkeiten.
|
* Erstellt einen neuen Orchestrator mit den vier erforderlichen Abhängigkeiten.
|
||||||
*
|
*
|
||||||
* @param editorValidator Lokaler Konfigurationsvalidator; darf nicht {@code null} sein
|
* @param editorValidator Lokaler Konfigurationsvalidator; darf nicht {@code null} sein
|
||||||
* @param pathCheckPort Port für Dateisystem-Pfadprüfungen; 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
|
* @param providerTestService Service für provider-nahe technische Prüfungen; darf nicht {@code null} sein
|
||||||
|
* @param logDiagnosticsPort Port zur Auflösung des aktiven Log-Dateipfads; darf nicht {@code null} sein
|
||||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
*/
|
*/
|
||||||
public TechnicalTestOrchestrator(EditorConfigurationValidator editorValidator,
|
public TechnicalTestOrchestrator(EditorConfigurationValidator editorValidator,
|
||||||
PathCheckPort pathCheckPort,
|
PathCheckPort pathCheckPort,
|
||||||
ProviderTechnicalTestService providerTestService) {
|
ProviderTechnicalTestService providerTestService,
|
||||||
|
LogDiagnosticsPort logDiagnosticsPort) {
|
||||||
this.editorValidator = Objects.requireNonNull(editorValidator, "editorValidator must not be null");
|
this.editorValidator = Objects.requireNonNull(editorValidator, "editorValidator must not be null");
|
||||||
this.pathCheckPort = Objects.requireNonNull(pathCheckPort, "pathCheckPort must not be null");
|
this.pathCheckPort = Objects.requireNonNull(pathCheckPort, "pathCheckPort must not be null");
|
||||||
this.providerTestService = Objects.requireNonNull(providerTestService, "providerTestService must not be null");
|
this.providerTestService = Objects.requireNonNull(providerTestService, "providerTestService must not be null");
|
||||||
|
this.logDiagnosticsPort = Objects.requireNonNull(logDiagnosticsPort, "logDiagnosticsPort must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,7 +86,7 @@ public class TechnicalTestOrchestrator {
|
|||||||
* <p>
|
* <p>
|
||||||
* Alle drei Prüfblöcke werden immer vollständig ausgeführt. Ein Fehler in einem Block
|
* 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
|
* führt nicht dazu, dass ein anderer Block übersprungen wird. Der zurückgegebene Bericht
|
||||||
* enthält immer genau elf {@link CheckpointResult}-Einträge.
|
* enthält immer genau zwölf {@link CheckpointResult}-Einträge.
|
||||||
* <p>
|
* <p>
|
||||||
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
|
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
|
||||||
* enthält, wird als Standardpfad der Elternordner der Konfigurationsdatei gewählt
|
* enthält, wird als Standardpfad der Elternordner der Konfigurationsdatei gewählt
|
||||||
@@ -96,7 +102,7 @@ public class TechnicalTestOrchestrator {
|
|||||||
* abgeschlossen sind. Sie darf nicht auf dem JavaFX Application Thread aufgerufen werden.
|
* abgeschlossen sind. Sie darf nicht auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
*
|
*
|
||||||
* @param request Eingabedaten für den Gesamttest; darf nicht {@code null} sein
|
* @param request Eingabedaten für den Gesamttest; darf nicht {@code null} sein
|
||||||
* @return vollständiger Gesamttestbericht mit genau elf Einträgen; nie {@code null}
|
* @return vollständiger Gesamttestbericht mit genau zwölf Einträgen; nie {@code null}
|
||||||
* @throws NullPointerException wenn {@code request} {@code null} ist
|
* @throws NullPointerException wenn {@code request} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public TechnicalTestReport run(TechnicalTestRequest request) {
|
public TechnicalTestReport run(TechnicalTestRequest request) {
|
||||||
@@ -104,13 +110,13 @@ public class TechnicalTestOrchestrator {
|
|||||||
Instant startTime = Instant.now();
|
Instant startTime = Instant.now();
|
||||||
EditorValidationInput input = request.validationInput();
|
EditorValidationInput input = request.validationInput();
|
||||||
|
|
||||||
List<CheckpointResult> results = new ArrayList<>(11);
|
List<CheckpointResult> results = new ArrayList<>(12);
|
||||||
|
|
||||||
// Block 1: Lokale Konfigurationsvalidierung (kein I/O)
|
// Block 1: Lokale Konfigurationsvalidierung (kein I/O)
|
||||||
results.addAll(runLocalValidationBlock(input));
|
results.addAll(runLocalValidationBlock(input));
|
||||||
|
|
||||||
// Block 2: Pfadprüfungen (Dateisystem-I/O)
|
// Block 2: Pfadprüfungen (Dateisystem-I/O)
|
||||||
results.addAll(runPathCheckBlock(input, request.configFilePath()));
|
results.addAll(runPathCheckBlock(input, request.configFilePath(), request.logDirectory()));
|
||||||
|
|
||||||
// Block 3: Provider-nahe technische Prüfungen (Netzwerk-I/O)
|
// Block 3: Provider-nahe technische Prüfungen (Netzwerk-I/O)
|
||||||
results.addAll(runProviderCheckBlock(input));
|
results.addAll(runProviderCheckBlock(input));
|
||||||
@@ -222,25 +228,30 @@ public class TechnicalTestOrchestrator {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner
|
* Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner,
|
||||||
* und SQLite-Pfad durch.
|
* SQLite-Pfad und Log-Verzeichnis durch.
|
||||||
* <p>
|
* <p>
|
||||||
* Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand
|
* Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand
|
||||||
* einen sinnvollen Standardpfad zu bestimmen ({@code <config-parent>/prompt.txt}).
|
* einen sinnvollen Standardpfad zu bestimmen ({@code <config-parent>/prompt.txt}).
|
||||||
|
* Der {@code logDirectory} ist der konfigurierte Rohwert von {@code log.directory};
|
||||||
|
* leer bedeutet Standardwert {@code ./logs/}.
|
||||||
*
|
*
|
||||||
* @param input aktueller Editorzustand
|
* @param input aktueller Editorzustand
|
||||||
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
|
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
|
||||||
* @return Liste mit genau vier Einträgen
|
* @param logDirectory konfigurierter Rohwert von {@code log.directory}; leer = Standard
|
||||||
|
* @return Liste mit genau fünf Einträgen
|
||||||
*/
|
*/
|
||||||
private List<CheckpointResult> runPathCheckBlock(EditorValidationInput input,
|
private List<CheckpointResult> runPathCheckBlock(EditorValidationInput input,
|
||||||
String configFilePath) {
|
String configFilePath,
|
||||||
|
String logDirectory) {
|
||||||
try {
|
try {
|
||||||
List<CheckpointResult> results = new ArrayList<>(4);
|
List<CheckpointResult> results = new ArrayList<>(5);
|
||||||
results.add(checkPromptFile(input.promptTemplateFile(), configFilePath,
|
results.add(checkPromptFile(input.promptTemplateFile(), configFilePath,
|
||||||
resolveMaxTitleLengthForPromptCreation(input.maxTitleLength())));
|
resolveMaxTitleLengthForPromptCreation(input.maxTitleLength())));
|
||||||
results.add(checkSourceFolder(input.sourceFolder()));
|
results.add(checkSourceFolder(input.sourceFolder()));
|
||||||
results.add(checkTargetFolder(input.targetFolder()));
|
results.add(checkTargetFolder(input.targetFolder()));
|
||||||
results.add(checkSqlitePath(input.sqliteFile()));
|
results.add(checkSqlitePath(input.sqliteFile()));
|
||||||
|
results.add(checkLogDirectory(logDirectory));
|
||||||
return results;
|
return results;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String errorMsg = "Interner Fehler bei den Pfadprüfungen: " + e.getMessage();
|
String errorMsg = "Interner Fehler bei den Pfadprüfungen: " + e.getMessage();
|
||||||
@@ -252,6 +263,8 @@ public class TechnicalTestOrchestrator {
|
|||||||
CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
|
CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
CheckpointSeverity.ERROR, errorMsg),
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
|
CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
CheckpointSeverity.ERROR, errorMsg),
|
||||||
|
CheckpointResult.Failure.of(CheckpointId.LOG_DIRECTORY_USABLE,
|
||||||
CheckpointSeverity.ERROR, errorMsg)
|
CheckpointSeverity.ERROR, errorMsg)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -471,6 +484,64 @@ public class TechnicalTestOrchestrator {
|
|||||||
suggestion);
|
suggestion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Log-Verzeichnis auf Schreibbarkeit und zeigt den tatsächlichen
|
||||||
|
* Log-Dateipfad aus der aktiven Log4j2-Konfiguration an.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verzeichnis-Ermittlung:</strong> Wenn der konfigurierte {@code log.directory}-Wert
|
||||||
|
* leer ist, wird der Standard {@code ./logs} relativ zum Arbeitsverzeichnis angenommen.
|
||||||
|
* Der Wert wird zu einem absoluten Pfad aufgelöst.
|
||||||
|
* <p>
|
||||||
|
* <strong>Ergebnis:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link CheckpointResult.Success}: Verzeichnis ist vorhanden und schreibbar.
|
||||||
|
* Die Meldung enthält den aufgelösten absoluten Pfad sowie – sofern ermittelbar –
|
||||||
|
* den tatsächlichen Log-Dateipfad aus Log4j2.</li>
|
||||||
|
* <li>{@link CheckpointResult.Failure} mit Schweregrad {@link CheckpointSeverity#WARNING}:
|
||||||
|
* Verzeichnis ist nicht vorhanden oder nicht schreibbar. Ein nicht beschreibbares
|
||||||
|
* Log-Verzeichnis ist eine Warnung, kein harter Fehler, da die Anwendung auch ohne
|
||||||
|
* Datei-Logging lauffähig ist. Die Meldung enthält Konfiguration und aufgelösten
|
||||||
|
* absoluten Pfad als Diagnoseinformation.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param configuredLogDir konfigurierter Rohwert von {@code log.directory}; leer = Standard
|
||||||
|
* @return Prüfpunkt-Ergebnis
|
||||||
|
*/
|
||||||
|
private CheckpointResult checkLogDirectory(String configuredLogDir) {
|
||||||
|
String effectiveDir = (configuredLogDir == null || configuredLogDir.isBlank())
|
||||||
|
? "./logs" : configuredLogDir;
|
||||||
|
|
||||||
|
String absolutePath;
|
||||||
|
try {
|
||||||
|
absolutePath = Paths.get(effectiveDir).toAbsolutePath().toString();
|
||||||
|
} catch (java.nio.file.InvalidPathException e) {
|
||||||
|
return CheckpointResult.Failure.of(CheckpointId.LOG_DIRECTORY_USABLE,
|
||||||
|
CheckpointSeverity.WARNING,
|
||||||
|
"Log-Verzeichnis: ungültiger Pfad: " + effectiveDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean configuredExplicitly = configuredLogDir != null && !configuredLogDir.isBlank();
|
||||||
|
String configLabel = configuredExplicitly
|
||||||
|
? "konfiguriert: " + configuredLogDir + " → "
|
||||||
|
: "Standard → ";
|
||||||
|
|
||||||
|
java.util.Optional<String> activeLogFile = logDiagnosticsPort.resolveActiveLogFilePath();
|
||||||
|
String logFileInfo = activeLogFile
|
||||||
|
.map(p -> " | Aktive Log-Datei: " + p)
|
||||||
|
.orElse("");
|
||||||
|
|
||||||
|
if (pathCheckPort.isDirectoryWritableOrCreatable(absolutePath)) {
|
||||||
|
return new CheckpointResult.Success(CheckpointId.LOG_DIRECTORY_USABLE,
|
||||||
|
"Log-Verzeichnis beschreibbar (" + configLabel + absolutePath + ")" + logFileInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CheckpointResult.Failure.of(CheckpointId.LOG_DIRECTORY_USABLE,
|
||||||
|
CheckpointSeverity.WARNING,
|
||||||
|
"Log-Verzeichnis nicht beschreibbar (" + configLabel + absolutePath + ")"
|
||||||
|
+ logFileInfo
|
||||||
|
+ ". Tipp: Absoluten Pfad mit Forward-Slashes verwenden, z. B. C:/Benutzer/Logs");
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Block 3: Provider-nahe technische Prüfungen
|
// Block 3: Provider-nahe technische Prüfungen
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+11
-3
@@ -15,36 +15,44 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
|
|||||||
* Gesamttest, bei der automatischen Prompt-Erzeugung den Standardpfad relativ zur
|
* Gesamttest, bei der automatischen Prompt-Erzeugung den Standardpfad relativ zur
|
||||||
* Konfigurationsdatei zu bestimmen. Er ist leer, wenn keine Konfigurationsdatei geladen ist.
|
* Konfigurationsdatei zu bestimmen. Er ist leer, wenn keine Konfigurationsdatei geladen ist.
|
||||||
* <p>
|
* <p>
|
||||||
|
* Das {@code logDirectory}-Feld trägt den konfigurierten Rohwert von {@code log.directory}
|
||||||
|
* aus dem Editor; leer bedeutet Standardwert {@code ./logs/}.
|
||||||
|
* <p>
|
||||||
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
||||||
*
|
*
|
||||||
* @param validationInput aktueller Editorzustand; nie {@code null}
|
* @param validationInput aktueller Editorzustand; nie {@code null}
|
||||||
* @param configFilePath optionaler Pfad der geladenen Konfigurationsdatei als String;
|
* @param configFilePath optionaler Pfad der geladenen Konfigurationsdatei als String;
|
||||||
* leer wenn keine Datei geladen ist
|
* leer wenn keine Datei geladen ist
|
||||||
|
* @param logDirectory konfigurierter Rohwert von {@code log.directory};
|
||||||
|
* leer wenn kein Wert konfiguriert ist (Standard {@code ./logs/})
|
||||||
*/
|
*/
|
||||||
public record TechnicalTestRequest(
|
public record TechnicalTestRequest(
|
||||||
EditorValidationInput validationInput,
|
EditorValidationInput validationInput,
|
||||||
String configFilePath) {
|
String configFilePath,
|
||||||
|
String logDirectory) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt eine neue Gesamttest-Anforderung.
|
* Erstellt eine neue Gesamttest-Anforderung.
|
||||||
*
|
*
|
||||||
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
||||||
* @param configFilePath Pfad der Konfigurationsdatei; {@code null} wird zu leerem String
|
* @param configFilePath Pfad der Konfigurationsdatei; {@code null} wird zu leerem String
|
||||||
|
* @param logDirectory Rohwert von {@code log.directory}; {@code null} wird zu leerem String
|
||||||
* @throws NullPointerException wenn {@code validationInput} {@code null} ist
|
* @throws NullPointerException wenn {@code validationInput} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public TechnicalTestRequest {
|
public TechnicalTestRequest {
|
||||||
Objects.requireNonNull(validationInput, "validationInput must not be null");
|
Objects.requireNonNull(validationInput, "validationInput must not be null");
|
||||||
configFilePath = configFilePath == null ? "" : configFilePath;
|
configFilePath = configFilePath == null ? "" : configFilePath;
|
||||||
|
logDirectory = logDirectory == null ? "" : logDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt eine Anforderung ohne geladene Konfigurationsdatei.
|
* Erstellt eine Anforderung ohne geladene Konfigurationsdatei und ohne Log-Verzeichnis-Angabe.
|
||||||
*
|
*
|
||||||
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
||||||
* @return eine neue Anforderung ohne Konfigurationsdateipfad
|
* @return eine neue Anforderung ohne Konfigurationsdateipfad
|
||||||
*/
|
*/
|
||||||
public static TechnicalTestRequest of(EditorValidationInput validationInput) {
|
public static TechnicalTestRequest of(EditorValidationInput validationInput) {
|
||||||
return new TechnicalTestRequest(validationInput, "");
|
return new TechnicalTestRequest(validationInput, "", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+67
-7
@@ -154,13 +154,36 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void process_newDocument_firstContentError_persistsFailedRetryable_contentCounterOne() {
|
void process_newDocument_noUsableText_persistsFailedFinal_contentCounterOne() {
|
||||||
|
// NO_USABLE_TEXT (image-only PDF) finalises immediately — no retry.
|
||||||
recordRepo.setLookupResult(new DocumentUnknown());
|
recordRepo.setLookupResult(new DocumentUnknown());
|
||||||
DocumentProcessingOutcome outcome = new PreCheckFailed(
|
DocumentProcessingOutcome outcome = new PreCheckFailed(
|
||||||
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
|
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
|
||||||
|
|
||||||
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
||||||
|
|
||||||
|
assertEquals(1, attemptRepo.savedAttempts.size());
|
||||||
|
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
|
||||||
|
assertEquals(ProcessingStatus.FAILED_FINAL, attempt.status());
|
||||||
|
assertFalse(attempt.retryable());
|
||||||
|
|
||||||
|
assertEquals(1, recordRepo.createdRecords.size());
|
||||||
|
DocumentRecord record = recordRepo.createdRecords.get(0);
|
||||||
|
assertEquals(ProcessingStatus.FAILED_FINAL, record.overallStatus());
|
||||||
|
assertEquals(1, record.failureCounters().contentErrorCount());
|
||||||
|
assertEquals(0, record.failureCounters().transientErrorCount());
|
||||||
|
assertNotNull(record.lastFailureInstant());
|
||||||
|
assertNull(record.lastSuccessInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void process_newDocument_firstPageLimitExceeded_persistsFailedRetryable_contentCounterOne() {
|
||||||
|
recordRepo.setLookupResult(new DocumentUnknown());
|
||||||
|
DocumentProcessingOutcome outcome = new PreCheckFailed(
|
||||||
|
candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
|
||||||
|
|
||||||
|
processor.process(candidate, fingerprint, outcome, context, attemptStart);
|
||||||
|
|
||||||
assertEquals(1, attemptRepo.savedAttempts.size());
|
assertEquals(1, attemptRepo.savedAttempts.size());
|
||||||
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
|
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
|
||||||
assertEquals(ProcessingStatus.FAILED_RETRYABLE, attempt.status());
|
assertEquals(ProcessingStatus.FAILED_RETRYABLE, attempt.status());
|
||||||
@@ -1191,17 +1214,18 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void process_contentErrorLifecycle_firstRunRetryable_secondRunFinal_thirdRunSkipped() {
|
void process_contentErrorLifecycle_pageLimitExceeded_firstRunRetryable_secondRunFinal_thirdRunSkipped() {
|
||||||
// Run 1: new document, first deterministic content error → FAILED_RETRYABLE
|
// PAGE_LIMIT_EXCEEDED follows the 1-retry rule: first run → FAILED_RETRYABLE, second → FAILED_FINAL.
|
||||||
recordRepo.setLookupResult(new DocumentUnknown());
|
recordRepo.setLookupResult(new DocumentUnknown());
|
||||||
DocumentProcessingOutcome contentError = new PreCheckFailed(
|
DocumentProcessingOutcome contentError = new PreCheckFailed(
|
||||||
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
|
candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
|
||||||
|
|
||||||
|
// Run 1: new document, first content error → FAILED_RETRYABLE
|
||||||
processor.process(candidate, fingerprint, contentError, context, attemptStart);
|
processor.process(candidate, fingerprint, contentError, context, attemptStart);
|
||||||
|
|
||||||
DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
|
DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
|
||||||
assertEquals(ProcessingStatus.FAILED_RETRYABLE, afterRun1.overallStatus(),
|
assertEquals(ProcessingStatus.FAILED_RETRYABLE, afterRun1.overallStatus(),
|
||||||
"First content error must yield FAILED_RETRYABLE");
|
"First PAGE_LIMIT_EXCEEDED must yield FAILED_RETRYABLE");
|
||||||
assertEquals(1, afterRun1.failureCounters().contentErrorCount());
|
assertEquals(1, afterRun1.failureCounters().contentErrorCount());
|
||||||
assertTrue(attemptRepo.savedAttempts.get(0).retryable(),
|
assertTrue(attemptRepo.savedAttempts.get(0).retryable(),
|
||||||
"First content error attempt must be retryable");
|
"First content error attempt must be retryable");
|
||||||
@@ -1236,6 +1260,36 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
"Transient error counter must remain 0 after a SKIPPED_FINAL_FAILURE event");
|
"Transient error counter must remain 0 after a SKIPPED_FINAL_FAILURE event");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void process_contentErrorLifecycle_noUsableText_immediatelyFinal_secondRunSkipped() {
|
||||||
|
// NO_USABLE_TEXT (image-only PDF): first run is immediately FAILED_FINAL, second is skipped.
|
||||||
|
recordRepo.setLookupResult(new DocumentUnknown());
|
||||||
|
DocumentProcessingOutcome noTextError = new PreCheckFailed(
|
||||||
|
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
|
||||||
|
|
||||||
|
// Run 1: new document → FAILED_FINAL immediately
|
||||||
|
processor.process(candidate, fingerprint, noTextError, context, attemptStart);
|
||||||
|
|
||||||
|
DocumentRecord afterRun1 = recordRepo.createdRecords.get(0);
|
||||||
|
assertEquals(ProcessingStatus.FAILED_FINAL, afterRun1.overallStatus(),
|
||||||
|
"NO_USABLE_TEXT must yield FAILED_FINAL immediately");
|
||||||
|
assertEquals(1, afterRun1.failureCounters().contentErrorCount());
|
||||||
|
assertFalse(attemptRepo.savedAttempts.get(0).retryable());
|
||||||
|
|
||||||
|
// Run 2: terminal FAILED_FINAL → SKIPPED_FINAL_FAILURE; counters must not change
|
||||||
|
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(afterRun1));
|
||||||
|
|
||||||
|
processor.process(candidate, fingerprint, noTextError, context, attemptStart);
|
||||||
|
|
||||||
|
assertEquals(2, attemptRepo.savedAttempts.size());
|
||||||
|
ProcessingAttempt skipAttempt = attemptRepo.savedAttempts.get(1);
|
||||||
|
assertEquals(ProcessingStatus.SKIPPED_FINAL_FAILURE, skipAttempt.status());
|
||||||
|
|
||||||
|
DocumentRecord afterRun2 = recordRepo.updatedRecords.get(0);
|
||||||
|
assertEquals(1, afterRun2.failureCounters().contentErrorCount(),
|
||||||
|
"Content error counter must remain 1 after SKIPPED_FINAL_FAILURE");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void process_transientErrorLifecycle_maxRetriesTransient2_firstRetryable_secondFinal() {
|
void process_transientErrorLifecycle_maxRetriesTransient2_firstRetryable_secondFinal() {
|
||||||
// maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL
|
// maxRetriesTransient=2: first transient error → FAILED_RETRYABLE, second → FAILED_FINAL
|
||||||
@@ -1416,6 +1470,11 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// No-op in tests
|
// No-op in tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
operations.accept(mockOps);
|
operations.accept(mockOps);
|
||||||
@@ -1589,8 +1648,9 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void process_firstContentError_retryDecisionLog_containsFingerprintAndFailedRetryable() {
|
void process_firstContentError_retryDecisionLog_containsFingerprintAndFailedRetryable() {
|
||||||
// Proves that the retry decision log for a first deterministic content error contains
|
// Proves that the retry decision log for a first retryable content error contains
|
||||||
// both the document fingerprint and the FAILED_RETRYABLE classification.
|
// both the document fingerprint and the FAILED_RETRYABLE classification.
|
||||||
|
// Uses PAGE_LIMIT_EXCEEDED which follows the 1-retry rule.
|
||||||
MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
|
MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
|
||||||
DocumentProcessingCoordinator coordinatorWithCapturing =
|
DocumentProcessingCoordinator coordinatorWithCapturing =
|
||||||
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
|
||||||
@@ -1599,7 +1659,7 @@ class DocumentProcessingCoordinatorTest {
|
|||||||
recordRepo.setLookupResult(new DocumentUnknown());
|
recordRepo.setLookupResult(new DocumentUnknown());
|
||||||
|
|
||||||
coordinatorWithCapturing.process(candidate, fingerprint,
|
coordinatorWithCapturing.process(candidate, fingerprint,
|
||||||
new PreCheckFailed(candidate, PreCheckFailureReason.NO_USABLE_TEXT),
|
new PreCheckFailed(candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED),
|
||||||
context, attemptStart);
|
context, attemptStart);
|
||||||
|
|
||||||
assertTrue(capturingLogger.anyWarnContains(FINGERPRINT_HEX),
|
assertTrue(capturingLogger.anyWarnContains(FINGERPRINT_HEX),
|
||||||
|
|||||||
+20
-4
@@ -103,13 +103,28 @@ class ProcessingOutcomeTransitionTest {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void forNewDocument_firstPreCheckFailed_returnsFailedRetryable_contentCounterOne() {
|
void forNewDocument_noUsableText_immediatelyFailedFinal_noRetry() {
|
||||||
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
|
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
|
||||||
|
|
||||||
ProcessingOutcomeTransition.ProcessingOutcome result =
|
ProcessingOutcomeTransition.ProcessingOutcome result =
|
||||||
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
|
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
|
||||||
|
|
||||||
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus());
|
assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(),
|
||||||
|
"NO_USABLE_TEXT must finalise immediately without retry");
|
||||||
|
assertFalse(result.retryable());
|
||||||
|
assertEquals(1, result.counters().contentErrorCount());
|
||||||
|
assertEquals(0, result.counters().transientErrorCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forNewDocument_firstPageLimitExceeded_returnsFailedRetryable_contentCounterOne() {
|
||||||
|
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
|
||||||
|
|
||||||
|
ProcessingOutcomeTransition.ProcessingOutcome result =
|
||||||
|
ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1);
|
||||||
|
|
||||||
|
assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(),
|
||||||
|
"PAGE_LIMIT_EXCEEDED first occurrence must be retryable");
|
||||||
assertTrue(result.retryable());
|
assertTrue(result.retryable());
|
||||||
assertEquals(1, result.counters().contentErrorCount());
|
assertEquals(1, result.counters().contentErrorCount());
|
||||||
assertEquals(0, result.counters().transientErrorCount());
|
assertEquals(0, result.counters().transientErrorCount());
|
||||||
@@ -149,9 +164,10 @@ class ProcessingOutcomeTransitionTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void forNewDocument_contentError_transientCounterIsIrrelevant() {
|
void forNewDocument_contentError_transientCounterIsIrrelevant() {
|
||||||
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT);
|
// PAGE_LIMIT_EXCEEDED is used here: it follows the 1-retry rule, and a non-zero
|
||||||
|
// transient counter must not influence the content-error decision.
|
||||||
|
PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED);
|
||||||
|
|
||||||
// Counter before: 0 content errors (first occurrence), transient ignored
|
|
||||||
ProcessingOutcomeTransition.ProcessingOutcome result =
|
ProcessingOutcomeTransition.ProcessingOutcome result =
|
||||||
ProcessingOutcomeTransition.forKnownDocument(
|
ProcessingOutcomeTransition.forKnownDocument(
|
||||||
outcome, new FailureCounters(0, 5), LIMIT_1);
|
outcome, new FailureCounters(0, 5), LIMIT_1);
|
||||||
|
|||||||
+20
-2
@@ -1062,8 +1062,16 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
private static AiNamingService buildStubAiNamingService() {
|
private static AiNamingService buildStubAiNamingService() {
|
||||||
AiInvocationPort stubAiPort = request ->
|
AiInvocationPort stubAiPort = request ->
|
||||||
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
|
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
|
||||||
PromptPort stubPromptPort = () ->
|
PromptPort stubPromptPort = new PromptPort() {
|
||||||
new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
|
||||||
|
return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
|
||||||
|
}
|
||||||
|
};
|
||||||
ClockPort stubClock = () -> java.time.Instant.EPOCH;
|
ClockPort stubClock = () -> java.time.Instant.EPOCH;
|
||||||
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
|
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE_LENGTH);
|
||||||
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
|
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000,
|
||||||
@@ -1388,6 +1396,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// No-op
|
// No-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1596,6 +1609,11 @@ class BatchRunProcessingUseCaseTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
// No-op in tests
|
// No-op in tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -279,8 +279,16 @@ class BatchRunProgressObservationTest {
|
|||||||
AiInvocationPort stubAi = req -> {
|
AiInvocationPort stubAi = req -> {
|
||||||
throw new IllegalStateException("AI must not be invoked in these tests");
|
throw new IllegalStateException("AI must not be invoked in these tests");
|
||||||
};
|
};
|
||||||
PromptPort stubPrompt = () -> new PromptLoadingSuccess(
|
PromptPort stubPrompt = new PromptPort() {
|
||||||
new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadPrompt() {
|
||||||
|
return new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "Prompt: {{text}}");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult savePrompt(String content) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.Saved("stub-path");
|
||||||
|
}
|
||||||
|
};
|
||||||
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
|
ClockPort stubClock = () -> Instant.parse("2026-04-22T00:00:00Z");
|
||||||
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
|
AiResponseValidator validator = new AiResponseValidator(stubClock, TEST_MAX_TITLE);
|
||||||
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
|
return new AiNamingService(stubAi, stubPrompt, validator, "stub-model", 1000, TEST_MAX_TITLE);
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultDeleteDocumentHistoryUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass ausschließlich {@code resetDocumentByFingerprint} aufgerufen wird
|
||||||
|
* (vollständige Löschung inklusive Versuchen, FK-sicher), Null-Guards greifen
|
||||||
|
* und Port-Fehler propagiert werden.
|
||||||
|
*/
|
||||||
|
class DefaultDeleteDocumentHistoryUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("b".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultDeleteDocumentHistoryUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_nullFingerprint_throwsNPE() {
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(noOpPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.deleteHistory(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: vollständige Löschung
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_callsResetDocumentByFingerprint() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(port);
|
||||||
|
useCase.deleteHistory(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetByFingerprintFingerprints)
|
||||||
|
.containsExactly(FP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_doesNotCallResetDocumentStatusForRetry() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(port);
|
||||||
|
useCase.deleteHistory(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetStatusForRetryFingerprints).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteHistory_portThrows_exceptionPropagated() {
|
||||||
|
UnitOfWorkPort failingPort = operations ->
|
||||||
|
operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override
|
||||||
|
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
|
||||||
|
@Override
|
||||||
|
public void createDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
|
});
|
||||||
|
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.deleteHistory(FP))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static UnitOfWorkPort noOpPort() {
|
||||||
|
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet {@code resetDocumentByFingerprint}- und {@code resetDocumentStatusForRetry}-Aufrufe auf.
|
||||||
|
*/
|
||||||
|
private static class RecordingTransactionOperations
|
||||||
|
implements UnitOfWorkPort.TransactionOperations {
|
||||||
|
|
||||||
|
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
|
||||||
|
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
resetByFingerprintFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
resetStatusForRetryFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultHistoryDetailsUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft den Happy-Path (Stammsatz vorhanden), das leere-Optional-Verhalten
|
||||||
|
* (kein Stammsatz), Null-Guards und Port-Fehler-Propagation.
|
||||||
|
*/
|
||||||
|
class DefaultHistoryDetailsUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultHistoryDetailsUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_nullFingerprint_throwsNPE() {
|
||||||
|
DefaultHistoryDetailsUseCase useCase =
|
||||||
|
new DefaultHistoryDetailsUseCase(emptyPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.loadDetails(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Kein Stammsatz vorhanden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_noRecord_returnsEmpty() {
|
||||||
|
DefaultHistoryDetailsUseCase useCase =
|
||||||
|
new DefaultHistoryDetailsUseCase(emptyPort());
|
||||||
|
|
||||||
|
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: Stammsatz vorhanden, Versuche vorhanden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_recordExists_returnsResultWithRecordAndAttempts() {
|
||||||
|
DocumentRecord record = buildRecord(FP);
|
||||||
|
ProcessingAttempt attempt = buildAttempt(FP);
|
||||||
|
|
||||||
|
HistoryQueryPort port = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.of(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return List.of(attempt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
|
||||||
|
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().record()).isSameAs(record);
|
||||||
|
assertThat(result.get().attempts()).containsExactly(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Stammsatz vorhanden, keine Versuche
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_recordExistsNoAttempts_returnsResultWithEmptyAttempts() {
|
||||||
|
DocumentRecord record = buildRecord(FP);
|
||||||
|
|
||||||
|
HistoryQueryPort port = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.of(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(port);
|
||||||
|
Optional<HistoryDetailsResult> result = useCase.loadDetails(FP);
|
||||||
|
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().attempts()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadDetails_portThrowsOnRecord_exceptionPropagated() {
|
||||||
|
HistoryQueryPort failingPort = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.loadDetails(FP))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static HistoryQueryPort emptyPort() {
|
||||||
|
return new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentRecord buildRecord(DocumentFingerprint fp) {
|
||||||
|
return new DocumentRecord(
|
||||||
|
fp,
|
||||||
|
new SourceDocumentLocator("/source"),
|
||||||
|
"source.pdf",
|
||||||
|
ProcessingStatus.SUCCESS,
|
||||||
|
new FailureCounters(0, 0),
|
||||||
|
null,
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now(),
|
||||||
|
"/target",
|
||||||
|
"2024-01-01 - Dokument.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessingAttempt buildAttempt(DocumentFingerprint fp) {
|
||||||
|
return ProcessingAttempt.withoutAiFields(
|
||||||
|
fp,
|
||||||
|
new de.gecheckt.pdf.umbenenner.domain.model.RunId(
|
||||||
|
java.util.UUID.randomUUID().toString()),
|
||||||
|
1,
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now(),
|
||||||
|
ProcessingStatus.SUCCESS,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultHistoryOverviewUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft den Happy-Path, das LIMIT-501-Verhalten und Null-Guards.
|
||||||
|
*/
|
||||||
|
class DefaultHistoryOverviewUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultHistoryOverviewUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_nullQuery_throwsNPE() {
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(emptyPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.loadOverview(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: leer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_emptyDatabase_returnsEmptyResultWithoutMore() {
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(emptyPort());
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).isEmpty();
|
||||||
|
assertThat(result.hasMore()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: weniger als 500 Treffer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_fewerThan500Results_returnsAllRowsWithoutMore() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(10);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).hasSize(10);
|
||||||
|
assertThat(result.hasMore()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// LIMIT-501-Technik
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_exactly500Results_returnsAllWithoutMore() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(500);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).hasSize(500);
|
||||||
|
assertThat(result.hasMore()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_moreThan500Results_returns500RowsWithHasMore() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(501);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThat(result.rows()).hasSize(500);
|
||||||
|
assertThat(result.hasMore()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_resultListIsImmutable() {
|
||||||
|
List<DocumentHistoryRow> rows = buildRows(3);
|
||||||
|
DefaultHistoryOverviewUseCase useCase =
|
||||||
|
new DefaultHistoryOverviewUseCase(fixedPort(rows));
|
||||||
|
|
||||||
|
HistoryOverviewResult result = useCase.loadOverview(HistoryQuery.unfiltered());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> result.rows().add(buildRow("0".repeat(64))))
|
||||||
|
.isInstanceOf(UnsupportedOperationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadOverview_portThrows_exceptionPropagated() {
|
||||||
|
HistoryQueryPort failingPort = new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.loadOverview(HistoryQuery.unfiltered()))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static HistoryQueryPort emptyPort() {
|
||||||
|
return fixedPort(Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HistoryQueryPort fixedPort(List<DocumentHistoryRow> rows) {
|
||||||
|
return new HistoryQueryPort() {
|
||||||
|
@Override
|
||||||
|
public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
|
||||||
|
return new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fp) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DocumentHistoryRow> buildRows(int count) {
|
||||||
|
List<DocumentHistoryRow> result = new ArrayList<>();
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
String hex = String.format("%064x", i);
|
||||||
|
result.add(buildRow(hex));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentHistoryRow buildRow(String fpHex) {
|
||||||
|
return new DocumentHistoryRow(
|
||||||
|
new DocumentFingerprint(fpHex),
|
||||||
|
ProcessingStatus.SUCCESS,
|
||||||
|
"source.pdf",
|
||||||
|
"2024-01-01 - Dokument.pdf",
|
||||||
|
"/source",
|
||||||
|
Instant.now(),
|
||||||
|
1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link DefaultHistoryResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass ausschließlich {@code resetDocumentStatusForRetry} aufgerufen wird
|
||||||
|
* (nicht {@code resetDocumentByFingerprint}), Null-Guards greifen und
|
||||||
|
* Port-Fehler propagiert werden.
|
||||||
|
*/
|
||||||
|
class DefaultHistoryResetDocumentStatusUseCaseTest {
|
||||||
|
|
||||||
|
private static final DocumentFingerprint FP =
|
||||||
|
new DocumentFingerprint("a".repeat(64));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-Guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_nullPort_throwsNPE() {
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> new DefaultHistoryResetDocumentStatusUseCase(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_nullFingerprint_throwsNPE() {
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(noOpPort());
|
||||||
|
assertThatNullPointerException()
|
||||||
|
.isThrownBy(() -> useCase.resetStatus(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Happy path: feldgenauer Reset
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_callsResetDocumentStatusForRetry() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(port);
|
||||||
|
useCase.resetStatus(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetStatusForRetryFingerprints)
|
||||||
|
.containsExactly(FP);
|
||||||
|
assertThat(ops.resetByFingerprintFingerprints)
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_doesNotCallResetDocumentByFingerprint() {
|
||||||
|
RecordingTransactionOperations ops = new RecordingTransactionOperations();
|
||||||
|
UnitOfWorkPort port = operations -> operations.accept(ops);
|
||||||
|
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(port);
|
||||||
|
useCase.resetStatus(FP);
|
||||||
|
|
||||||
|
assertThat(ops.resetByFingerprintFingerprints).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Port-Fehler wird propagiert
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetStatus_portThrows_exceptionPropagated() {
|
||||||
|
UnitOfWorkPort failingPort = operations ->
|
||||||
|
operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override
|
||||||
|
public void saveProcessingAttempt(ProcessingAttempt attempt) { }
|
||||||
|
@Override
|
||||||
|
public void createDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
throw new DocumentPersistenceException("Simulated DB error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(failingPort);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.resetStatus(FP))
|
||||||
|
.isInstanceOf(DocumentPersistenceException.class)
|
||||||
|
.hasMessageContaining("Simulated DB error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static UnitOfWorkPort noOpPort() {
|
||||||
|
return operations -> operations.accept(new UnitOfWorkPort.TransactionOperations() {
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fp) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fp) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet {@code resetDocumentStatusForRetry}- und {@code resetDocumentByFingerprint}-Aufrufe auf.
|
||||||
|
*/
|
||||||
|
private static class RecordingTransactionOperations
|
||||||
|
implements UnitOfWorkPort.TransactionOperations {
|
||||||
|
|
||||||
|
final List<DocumentFingerprint> resetStatusForRetryFingerprints = new ArrayList<>();
|
||||||
|
final List<DocumentFingerprint> resetByFingerprintFingerprints = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void saveProcessingAttempt(ProcessingAttempt a) { }
|
||||||
|
@Override public void createDocumentRecord(DocumentRecord r) { }
|
||||||
|
@Override public void updateDocumentRecord(DocumentRecord r) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
|
resetByFingerprintFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
resetStatusForRetryFingerprints.add(fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -549,6 +549,7 @@ class DefaultManualFileCopyUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
private static class RecordCapturingTransactionOperations implements UnitOfWorkPort.TransactionOperations {
|
||||||
@@ -562,5 +563,6 @@ class DefaultManualFileCopyUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -620,6 +620,7 @@ class DefaultManualFileRenameUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
@Override public void updateDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeichnet updateDocumentRecord-Aufrufe auf. */
|
/** Zeichnet updateDocumentRecord-Aufrufe auf. */
|
||||||
@@ -634,5 +635,6 @@ class DefaultManualFileRenameUseCaseTest {
|
|||||||
@Override public void createDocumentRecord(DocumentRecord record) { }
|
@Override public void createDocumentRecord(DocumentRecord record) { }
|
||||||
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
@Override public void updateDocumentRecord(DocumentRecord record) { captured.add(record); }
|
||||||
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
@Override public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) { }
|
||||||
|
@Override public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
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.ResourceCreationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link DefaultPromptEditorUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft die Delegation an {@link PromptPort} und {@link ResourceCreationPort}
|
||||||
|
* sowie die Null-Prüfungen am Konstruktor und an den Methoden.
|
||||||
|
*/
|
||||||
|
class DefaultPromptEditorUseCaseTest {
|
||||||
|
|
||||||
|
private static final String STUB_CONTENT = "Mein Test-Prompt";
|
||||||
|
private static final PromptIdentifier STUB_IDENTIFIER = new PromptIdentifier("test-prompt.txt");
|
||||||
|
|
||||||
|
private StubPromptPort stubPromptPort;
|
||||||
|
private StubResourceCreationPort stubResourceCreationPort;
|
||||||
|
private DefaultPromptEditorUseCase useCase;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
stubPromptPort = new StubPromptPort();
|
||||||
|
stubResourceCreationPort = new StubResourceCreationPort();
|
||||||
|
useCase = new DefaultPromptEditorUseCase(stubPromptPort, stubResourceCreationPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konstruktor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_shouldThrowNullPointerException_whenPromptPortIsNull() {
|
||||||
|
assertThatThrownBy(() -> new DefaultPromptEditorUseCase(null, stubResourceCreationPort))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("promptPort");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_shouldThrowNullPointerException_whenResourceCreationPortIsNull() {
|
||||||
|
assertThatThrownBy(() -> new DefaultPromptEditorUseCase(stubPromptPort, null))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("resourceCreationPort");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// loadPrompt
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadPrompt_shouldDelegateToPromptPort_andReturnSuccess() {
|
||||||
|
// Given
|
||||||
|
stubPromptPort.loadResult = new PromptLoadingSuccess(STUB_IDENTIFIER, STUB_CONTENT);
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptLoadingResult result = useCase.loadPrompt();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptLoadingSuccess.class);
|
||||||
|
PromptLoadingSuccess success = (PromptLoadingSuccess) result;
|
||||||
|
assertThat(success.promptContent()).isEqualTo(STUB_CONTENT);
|
||||||
|
assertThat(success.promptIdentifier()).isEqualTo(STUB_IDENTIFIER);
|
||||||
|
assertThat(stubPromptPort.loadCallCount).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadPrompt_shouldDelegateToPromptPort_andReturnFailure() {
|
||||||
|
// Given
|
||||||
|
stubPromptPort.loadResult = new PromptLoadingFailure("FILE_NOT_FOUND", "Datei nicht vorhanden");
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptLoadingResult result = useCase.loadPrompt();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptLoadingFailure.class);
|
||||||
|
PromptLoadingFailure failure = (PromptLoadingFailure) result;
|
||||||
|
assertThat(failure.failureReason()).isEqualTo("FILE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// savePrompt
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldDelegateToPromptPort_andReturnSaved() {
|
||||||
|
// Given
|
||||||
|
stubPromptPort.saveResult = new PromptSaveResult.Saved("/some/path/prompt.txt");
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = useCase.savePrompt(STUB_CONTENT);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.Saved.class);
|
||||||
|
assertThat(stubPromptPort.lastSavedContent).isEqualTo(STUB_CONTENT);
|
||||||
|
assertThat(stubPromptPort.saveCallCount).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldDelegateToPromptPort_andReturnWriteFailed() {
|
||||||
|
// Given
|
||||||
|
stubPromptPort.saveResult = new PromptSaveResult.WriteFailed("Schreibfehler", null);
|
||||||
|
|
||||||
|
// When
|
||||||
|
PromptSaveResult result = useCase.savePrompt(STUB_CONTENT);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(PromptSaveResult.WriteFailed.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savePrompt_shouldThrowNullPointerException_whenContentIsNull() {
|
||||||
|
assertThatThrownBy(() -> useCase.savePrompt(null))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("content");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// createDefaultPromptIfMissing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDefaultPromptIfMissing_shouldDelegateToResourceCreationPort_andReturnApplied() {
|
||||||
|
// Given
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||||
|
new CorrectionSuggestion.CreatePromptFile(
|
||||||
|
"/some/prompt.txt", "Standard anlegen", 60);
|
||||||
|
CorrectionOutcome.Applied applied = new CorrectionOutcome.Applied(
|
||||||
|
suggestion, "Standard-Prompt-Datei wurde angelegt.");
|
||||||
|
stubResourceCreationPort.createPromptFileResult = applied;
|
||||||
|
|
||||||
|
// When
|
||||||
|
CorrectionOutcome result = useCase.createDefaultPromptIfMissing(suggestion);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isInstanceOf(CorrectionOutcome.Applied.class);
|
||||||
|
assertThat(stubResourceCreationPort.createPromptFileCallCount).isEqualTo(1);
|
||||||
|
assertThat(stubResourceCreationPort.lastSuggestion).isSameAs(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDefaultPromptIfMissing_shouldThrowNullPointerException_whenSuggestionIsNull() {
|
||||||
|
assertThatThrownBy(() -> useCase.createDefaultPromptIfMissing(null))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("suggestion");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Test-Stubs
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static class StubPromptPort implements PromptPort {
|
||||||
|
PromptLoadingResult loadResult = new PromptLoadingSuccess(
|
||||||
|
new PromptIdentifier("stub.txt"), "Stub-Inhalt");
|
||||||
|
PromptSaveResult saveResult = new PromptSaveResult.Saved("/stub/path.txt");
|
||||||
|
int loadCallCount = 0;
|
||||||
|
int saveCallCount = 0;
|
||||||
|
String lastSavedContent = null;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PromptLoadingResult loadPrompt() {
|
||||||
|
loadCallCount++;
|
||||||
|
return loadResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PromptSaveResult savePrompt(String content) {
|
||||||
|
saveCallCount++;
|
||||||
|
lastSavedContent = content;
|
||||||
|
return saveResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class StubResourceCreationPort implements ResourceCreationPort {
|
||||||
|
CorrectionOutcome createPromptFileResult = new CorrectionOutcome.Applied(
|
||||||
|
new CorrectionSuggestion.CreatePromptFile("/stub.txt", "Stub", 60),
|
||||||
|
"Angelegt.");
|
||||||
|
int createPromptFileCallCount = 0;
|
||||||
|
CorrectionSuggestion.CreatePromptFile lastSuggestion = null;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
|
||||||
|
return new CorrectionOutcome.NotAttempted(suggestion, "Nicht implementiert im Stub.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
createPromptFileCallCount++;
|
||||||
|
lastSuggestion = suggestion;
|
||||||
|
return createPromptFileResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||||
|
return new CorrectionOutcome.NotAttempted(suggestion, "Nicht implementiert im Stub.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -216,5 +216,10 @@ class DefaultResetDocumentStatusUseCaseTest {
|
|||||||
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
public void resetDocumentByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
recorded.add(fingerprint);
|
recorded.add(fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetDocumentStatusForRetry(DocumentFingerprint fingerprint) {
|
||||||
|
// No-op in tests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -23,13 +23,14 @@ class CheckpointIdTest {
|
|||||||
CheckpointId.PROMPT_FILE_PRESENT,
|
CheckpointId.PROMPT_FILE_PRESENT,
|
||||||
CheckpointId.SOURCE_FOLDER_PRESENT,
|
CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
CheckpointId.TARGET_FOLDER_USABLE,
|
CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
CheckpointId.SQLITE_PATH_USABLE
|
CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
CheckpointId.LOG_DIRECTORY_USABLE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void enumHasExactlyElevenValues() {
|
void enumHasTwelveValues() {
|
||||||
assertThat(CheckpointId.values()).hasSize(11);
|
assertThat(CheckpointId.values()).hasSize(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+39
-22
@@ -15,7 +15,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidation
|
|||||||
* <p>
|
* <p>
|
||||||
* Prüft insbesondere, dass alle drei Blöcke immer vollständig durchlaufen werden
|
* Prüft insbesondere, dass alle drei Blöcke immer vollständig durchlaufen werden
|
||||||
* (kein Frühabbruch), auch wenn ein Block eine Exception wirft, und dass der
|
* (kein Frühabbruch), auch wenn ein Block eine Exception wirft, und dass der
|
||||||
* zurückgegebene Bericht immer genau elf Einträge enthält.
|
* zurückgegebene Bericht immer genau zwölf Einträge enthält.
|
||||||
*/
|
*/
|
||||||
class TechnicalTestOrchestratorTest {
|
class TechnicalTestOrchestratorTest {
|
||||||
|
|
||||||
@@ -88,6 +88,11 @@ class TechnicalTestOrchestratorTest {
|
|||||||
(family, propertyValue) -> EffectiveApiKeyDescriptor.fromPropertyFile());
|
(family, propertyValue) -> EffectiveApiKeyDescriptor.fromPropertyFile());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No-op {@link LogDiagnosticsPort}: gibt immer einen leeren Optional zurück. */
|
||||||
|
private static LogDiagnosticsPort noOpLogDiagnosticsPort() {
|
||||||
|
return () -> java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
private static CheckpointResult findById(List<CheckpointResult> results, CheckpointId id) {
|
private static CheckpointResult findById(List<CheckpointResult> results, CheckpointId id) {
|
||||||
return results.stream()
|
return results.stream()
|
||||||
.filter(r -> r.checkpointId() == id)
|
.filter(r -> r.checkpointId() == id)
|
||||||
@@ -98,19 +103,20 @@ class TechnicalTestOrchestratorTest {
|
|||||||
// ------------------------------------------------------------------ Tests: Vollständig grüner Pfad
|
// ------------------------------------------------------------------ Tests: Vollständig grüner Pfad
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle drei Blöcke liefern Erfolg: der Bericht enthält genau 11 Success-Einträge.
|
* Alle drei Blöcke liefern Erfolg: der Bericht enthält genau 12 Success-Einträge.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void allBlocksSucceed_reportContainsElevenSuccessEntries() {
|
void allBlocksSucceed_reportContainsTwelveSuccessEntries() {
|
||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
allOkPathCheckPort(),
|
allOkPathCheckPort(),
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
|
|
||||||
assertThat(report.size()).isEqualTo(11);
|
assertThat(report.size()).isEqualTo(12);
|
||||||
assertThat(report.results())
|
assertThat(report.results())
|
||||||
.allSatisfy(r -> assertThat(r).isInstanceOf(CheckpointResult.Success.class));
|
.allSatisfy(r -> assertThat(r).isInstanceOf(CheckpointResult.Success.class));
|
||||||
assertThat(report.hasErrors()).isFalse();
|
assertThat(report.hasErrors()).isFalse();
|
||||||
@@ -120,19 +126,20 @@ class TechnicalTestOrchestratorTest {
|
|||||||
// ------------------------------------------------------------------ Tests: Kein Frühabbruch
|
// ------------------------------------------------------------------ Tests: Kein Frühabbruch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bericht enthält immer genau 11 Einträge, auch wenn Block 2 und Block 3 alle Fehler liefern.
|
* Bericht enthält immer genau 12 Einträge, auch wenn Block 2 und Block 3 alle Fehler liefern.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void alwaysElevenCheckpointsInReport_evenWithFailures() {
|
void alwaysTwelveCheckpointsInReport_evenWithFailures() {
|
||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort(), // Block 2: alle Pfade nicht vorhanden
|
noOpPathCheckPort(), // Block 2: alle Pfade nicht vorhanden
|
||||||
throwingProviderService()); // Block 3: Exception
|
throwingProviderService(), // Block 3: Exception
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(emptyProviderInput()));
|
TechnicalTestRequest.of(emptyProviderInput()));
|
||||||
|
|
||||||
assertThat(report.size()).isEqualTo(11);
|
assertThat(report.size()).isEqualTo(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Tests: Block 1 - Exception führt zu Failure
|
// ------------------------------------------------------------------ Tests: Block 1 - Exception führt zu Failure
|
||||||
@@ -155,12 +162,13 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
throwingValidator,
|
throwingValidator,
|
||||||
allOkPathCheckPort(),
|
allOkPathCheckPort(),
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
|
|
||||||
assertThat(report.size()).isEqualTo(11);
|
assertThat(report.size()).isEqualTo(12);
|
||||||
|
|
||||||
// Block-1-Checkpoints müssen Failure mit "Interner Fehler" sein
|
// Block-1-Checkpoints müssen Failure mit "Interner Fehler" sein
|
||||||
CheckpointResult basicValidation = findById(report.results(),
|
CheckpointResult basicValidation = findById(report.results(),
|
||||||
@@ -197,12 +205,13 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
allOkPathCheckPort(),
|
allOkPathCheckPort(),
|
||||||
throwingProviderService());
|
throwingProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
|
|
||||||
assertThat(report.size()).isEqualTo(11);
|
assertThat(report.size()).isEqualTo(12);
|
||||||
|
|
||||||
// Block-3-Checkpoints müssen Failure mit "Interner Fehler" sein
|
// Block-3-Checkpoints müssen Failure mit "Interner Fehler" sein
|
||||||
List<CheckpointId> block3Ids = List.of(
|
List<CheckpointId> block3Ids = List.of(
|
||||||
@@ -250,7 +259,8 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathPort,
|
pathPort,
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
@@ -280,7 +290,8 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathPort,
|
pathPort,
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
@@ -310,7 +321,8 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathPort,
|
pathPort,
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
@@ -327,14 +339,15 @@ class TechnicalTestOrchestratorTest {
|
|||||||
// ------------------------------------------------------------------ Tests: Korrekte Checkpoint-Reihenfolge
|
// ------------------------------------------------------------------ Tests: Korrekte Checkpoint-Reihenfolge
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Bericht enthält genau die erwarteten 11 Checkpoint-IDs.
|
* Der Bericht enthält genau die erwarteten 12 Checkpoint-IDs.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void report_containsAllExpectedCheckpointIds() {
|
void report_containsAllExpectedCheckpointIds() {
|
||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort(),
|
noOpPathCheckPort(),
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
@@ -350,6 +363,7 @@ class TechnicalTestOrchestratorTest {
|
|||||||
CheckpointId.SOURCE_FOLDER_PRESENT,
|
CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||||
CheckpointId.TARGET_FOLDER_USABLE,
|
CheckpointId.TARGET_FOLDER_USABLE,
|
||||||
CheckpointId.SQLITE_PATH_USABLE,
|
CheckpointId.SQLITE_PATH_USABLE,
|
||||||
|
CheckpointId.LOG_DIRECTORY_USABLE,
|
||||||
CheckpointId.API_KEY_PRESENT,
|
CheckpointId.API_KEY_PRESENT,
|
||||||
CheckpointId.BASE_URL_REACHABLE,
|
CheckpointId.BASE_URL_REACHABLE,
|
||||||
CheckpointId.API_KEY_ACCEPTED,
|
CheckpointId.API_KEY_ACCEPTED,
|
||||||
@@ -387,11 +401,12 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathPort,
|
pathPort,
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
// configFilePath = C:/config/application.properties → Elternordner = C:/config
|
// configFilePath = C:/config/application.properties → Elternordner = C:/config
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
new TechnicalTestRequest(inputWithoutPrompt, "C:/config/application.properties"));
|
new TechnicalTestRequest(inputWithoutPrompt, "C:/config/application.properties", ""));
|
||||||
|
|
||||||
CheckpointResult promptResult = findById(report.results(), CheckpointId.PROMPT_FILE_PRESENT);
|
CheckpointResult promptResult = findById(report.results(), CheckpointId.PROMPT_FILE_PRESENT);
|
||||||
assertThat(promptResult).isInstanceOf(CheckpointResult.Failure.class);
|
assertThat(promptResult).isInstanceOf(CheckpointResult.Failure.class);
|
||||||
@@ -431,7 +446,8 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathPort,
|
pathPort,
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
// Kein configFilePath → Fallback auf config/prompt.txt
|
// Kein configFilePath → Fallback auf config/prompt.txt
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
@@ -462,7 +478,8 @@ class TechnicalTestOrchestratorTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathPort,
|
pathPort,
|
||||||
allSuccessProviderService());
|
allSuccessProviderService(),
|
||||||
|
noOpLogDiagnosticsPort());
|
||||||
|
|
||||||
TechnicalTestReport report = orchestrator.run(
|
TechnicalTestReport report = orchestrator.run(
|
||||||
TechnicalTestRequest.of(validClaudeInput()));
|
TechnicalTestRequest.of(validClaudeInput()));
|
||||||
|
|||||||
+14
-2
@@ -29,18 +29,30 @@ class TechnicalTestRequestTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void withConfigFilePath_detectedAsPresent() {
|
void withConfigFilePath_detectedAsPresent() {
|
||||||
var request = new TechnicalTestRequest(minimalInput(), "/config/app.properties");
|
var request = new TechnicalTestRequest(minimalInput(), "/config/app.properties", "");
|
||||||
assertThat(request.hasConfigFilePath()).isTrue();
|
assertThat(request.hasConfigFilePath()).isTrue();
|
||||||
assertThat(request.configFilePath()).isEqualTo("/config/app.properties");
|
assertThat(request.configFilePath()).isEqualTo("/config/app.properties");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nullConfigFilePath_normalisedToEmpty() {
|
void nullConfigFilePath_normalisedToEmpty() {
|
||||||
var request = new TechnicalTestRequest(minimalInput(), null);
|
var request = new TechnicalTestRequest(minimalInput(), null, null);
|
||||||
assertThat(request.configFilePath()).isEmpty();
|
assertThat(request.configFilePath()).isEmpty();
|
||||||
assertThat(request.hasConfigFilePath()).isFalse();
|
assertThat(request.hasConfigFilePath()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withLogDirectory_preservedAsIs() {
|
||||||
|
var request = new TechnicalTestRequest(minimalInput(), "", "C:/logs");
|
||||||
|
assertThat(request.logDirectory()).isEqualTo("C:/logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullLogDirectory_normalisedToEmpty() {
|
||||||
|
var request = new TechnicalTestRequest(minimalInput(), "", null);
|
||||||
|
assertThat(request.logDirectory()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nullValidationInputThrows() {
|
void nullValidationInputThrows() {
|
||||||
assertThatNullPointerException()
|
assertThatNullPointerException()
|
||||||
|
|||||||
+254
-5
@@ -21,6 +21,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
|
|||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiPromptEditorPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
||||||
@@ -86,9 +87,21 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatal
|
|||||||
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
|
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
|
||||||
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
|
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
|
||||||
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteHistoryQueryAdapter;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileCopyUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultManualFileRenameUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultPromptEditorUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResetDocumentStatusUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultResolveHistoricalDocumentContextUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
@@ -96,6 +109,7 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderT
|
|||||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher;
|
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
|
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
|
||||||
|
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jLogDiagnosticsAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
|
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.AnotherInstanceRunningException;
|
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.AnotherInstanceRunningException;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard;
|
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard;
|
||||||
@@ -794,7 +808,8 @@ public class BootstrapRunner {
|
|||||||
TechnicalTestOrchestrator technicalTestOrchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator technicalTestOrchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
pathCheckPort,
|
pathCheckPort,
|
||||||
providerTechnicalTestService);
|
providerTechnicalTestService,
|
||||||
|
new Log4jLogDiagnosticsAdapter());
|
||||||
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService =
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
|
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter());
|
||||||
@@ -806,6 +821,12 @@ public class BootstrapRunner {
|
|||||||
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
GuiManualFileRenamePort manualRenamePort = this::performGuiManualFileRename;
|
||||||
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
|
||||||
|
GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
|
||||||
|
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
|
||||||
|
String applicationVersion = ApplicationVersionProvider.resolveVersion();
|
||||||
|
|
||||||
if (configPathOverride.isEmpty()) {
|
if (configPathOverride.isEmpty()) {
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
@@ -824,7 +845,14 @@ public class BootstrapRunner {
|
|||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort,
|
||||||
|
applicationVersion,
|
||||||
|
noOpGuiPromptEditorPort(),
|
||||||
|
historyOverviewPort,
|
||||||
|
historyDetailsPort,
|
||||||
|
historyResetPort,
|
||||||
|
deleteHistoryPort,
|
||||||
|
this::buildGuiPromptEditorPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -848,17 +876,28 @@ public class BootstrapRunner {
|
|||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort,
|
||||||
|
applicationVersion,
|
||||||
|
noOpGuiPromptEditorPort(),
|
||||||
|
historyOverviewPort,
|
||||||
|
historyDetailsPort,
|
||||||
|
historyResetPort,
|
||||||
|
deleteHistoryPort,
|
||||||
|
this::buildGuiPromptEditorPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
try {
|
try {
|
||||||
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
||||||
|
GuiPromptEditorPort promptEditorPort = buildGuiPromptEditorPort(
|
||||||
|
loadedState.values().promptTemplateFile());
|
||||||
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer,
|
||||||
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
||||||
|
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
|
||||||
|
this::buildGuiPromptEditorPort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -878,10 +917,93 @@ public class BootstrapRunner {
|
|||||||
resetPort,
|
resetPort,
|
||||||
manualRenamePort,
|
manualRenamePort,
|
||||||
manualCopyPort,
|
manualCopyPort,
|
||||||
historicalDocumentContextPort);
|
historicalDocumentContextPort,
|
||||||
|
applicationVersion,
|
||||||
|
noOpGuiPromptEditorPort(),
|
||||||
|
historyOverviewPort,
|
||||||
|
historyDetailsPort,
|
||||||
|
historyResetPort,
|
||||||
|
deleteHistoryPort,
|
||||||
|
this::buildGuiPromptEditorPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen vollständig verdrahteten {@link GuiPromptEditorPort} für den angegebenen
|
||||||
|
* Prompt-Dateipfad.
|
||||||
|
* <p>
|
||||||
|
* Kombiniert {@link FilesystemPromptPortAdapter} und
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter}
|
||||||
|
* in einem {@link DefaultPromptEditorUseCase}. Der zurückgegebene Port delegiert alle
|
||||||
|
* drei Operationen (Laden, Speichern, Standard-Anlegen) an den Use-Case.
|
||||||
|
*
|
||||||
|
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
|
||||||
|
* @return vollständig verdrahteter Port; nie {@code null}
|
||||||
|
*/
|
||||||
|
private GuiPromptEditorPort buildGuiPromptEditorPort(String promptFilePath) {
|
||||||
|
FilesystemPromptPortAdapter promptPortAdapter =
|
||||||
|
new FilesystemPromptPortAdapter(Paths.get(promptFilePath));
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort
|
||||||
|
resourceCreationPort =
|
||||||
|
new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter();
|
||||||
|
DefaultPromptEditorUseCase useCase = new DefaultPromptEditorUseCase(
|
||||||
|
promptPortAdapter, resourceCreationPort);
|
||||||
|
return new GuiPromptEditorPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||||
|
return useCase.loadPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||||
|
return useCase.savePrompt(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDefaultPromptIfMissing(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return useCase.createDefaultPromptIfMissing(suggestion);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt einen No-Op-{@link GuiPromptEditorPort} zurück, der alle Operationen als
|
||||||
|
* nicht verfügbar meldet.
|
||||||
|
* <p>
|
||||||
|
* Wird eingesetzt, wenn beim GUI-Start noch keine Konfiguration geladen ist und daher
|
||||||
|
* kein Prompt-Dateipfad bekannt ist.
|
||||||
|
*
|
||||||
|
* @return no-op Port; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static GuiPromptEditorPort noOpGuiPromptEditorPort() {
|
||||||
|
return new GuiPromptEditorPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||||
|
"NO_OP", "Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||||
|
"Kein Prompt-Pfad konfiguriert. Bitte zuerst eine Konfiguration öffnen.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDefaultPromptIfMissing(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(
|
||||||
|
suggestion, "Kein Prompt-Pfad konfiguriert.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes exactly one batch run triggered by the GUI's processing-run tab.
|
* Executes exactly one batch run triggered by the GUI's processing-run tab.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -1333,6 +1455,133 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryOverviewUseCase}
|
||||||
|
* frisch pro Aufruf, da keine persistente Verbindung über GUI-Interaktionen hinweg
|
||||||
|
* gehalten wird.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param query Abfrageparameter; darf nicht {@code null} sein
|
||||||
|
* @return Ergebnis mit gefundenen Zeilen und hasMore-Flag; nie {@code null}
|
||||||
|
*/
|
||||||
|
DefaultHistoryOverviewUseCase.HistoryOverviewResult loadHistoryOverviewForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery query) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(query, "query must not be null");
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
|
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
|
||||||
|
return useCase.loadOverview(query);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historienübersicht konnte nicht geladen werden: {}", e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Historienübersicht konnte nicht geladen werden: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Verdrahtet {@link SqliteHistoryQueryAdapter} und {@link DefaultHistoryDetailsUseCase}
|
||||||
|
* frisch pro Aufruf.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
|
||||||
|
*/
|
||||||
|
Optional<DefaultHistoryDetailsUseCase.HistoryDetailsResult> loadHistoryDetailsForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
|
||||||
|
return useCase.loadDetails(fingerprint);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historiendetails für {} konnten nicht geladen werden: {}",
|
||||||
|
fingerprint.sha256Hex(), e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Historiendetails konnten nicht geladen werden: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt den feldgenauen Status-Reset für das angegebene Dokument durch.
|
||||||
|
* <p>
|
||||||
|
* Setzt {@code overall_status}, {@code content_error_count}, {@code transient_error_count}
|
||||||
|
* und {@code last_failure_instant} zurück. Die Versuchshistorie bleibt erhalten.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
void resetHistoryDocumentStatusForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
LOG.info("Historien-Status-Reset für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
|
new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
|
||||||
|
useCase.resetStatus(fingerprint);
|
||||||
|
LOG.info("Historien-Status-Reset abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historien-Status-Reset fehlgeschlagen für {}: {}",
|
||||||
|
fingerprint.sha256Hex(), e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Status-Reset fehlgeschlagen: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den Stammsatz und alle Verarbeitungsversuche für das angegebene Dokument.
|
||||||
|
* <p>
|
||||||
|
* Die Löschung ist destruktiv und nicht rückgängig zu machen.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur geladenen Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
void deleteDocumentHistoryForGui(
|
||||||
|
Path configFilePath,
|
||||||
|
DocumentFingerprint fingerprint) {
|
||||||
|
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
LOG.info("Historien-Löschen für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
initializeSchema(config);
|
||||||
|
String jdbcUrl = buildJdbcUrl(config);
|
||||||
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
|
new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
|
||||||
|
useCase.deleteHistory(fingerprint);
|
||||||
|
LOG.info("Historien-Löschen abgeschlossen für Fingerprint: {}", fingerprint.sha256Hex());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Historien-Löschen fehlgeschlagen für {}: {}",
|
||||||
|
fingerprint.sha256Hex(), e.getMessage(), e);
|
||||||
|
throw new DocumentPersistenceException(
|
||||||
|
"Löschen fehlgeschlagen: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
||||||
* recorded as a failure with the given error message.
|
* recorded as a failure with the given error message.
|
||||||
|
|||||||
+7
-2
@@ -4,6 +4,7 @@ import org.apache.logging.log4j.LogManager;
|
|||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.CliArgumentParser;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.CliArgumentParser;
|
||||||
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.EarlyLogDirectoryInitializer;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult;
|
||||||
|
|
||||||
@@ -28,18 +29,22 @@ import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult;
|
|||||||
*/
|
*/
|
||||||
public class PdfUmbenennerApplication {
|
public class PdfUmbenennerApplication {
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point.
|
* Application entry point.
|
||||||
* <p>
|
* <p>
|
||||||
* Parses the command-line arguments and delegates to {@link BootstrapRunner}.
|
* Parses the command-line arguments and delegates to {@link BootstrapRunner}.
|
||||||
* If the arguments cannot be parsed, an error is logged and the process exits
|
* If the arguments cannot be parsed, an error is logged and the process exits
|
||||||
* with code 1 before any further initialisation takes place.
|
* with code 1 before any further initialisation takes place.
|
||||||
|
* <p>
|
||||||
|
* Vor jeder Logger-Nutzung wird {@link EarlyLogDirectoryInitializer} aufgerufen,
|
||||||
|
* damit die Property {@code log.directory} aus der aktiven Konfigurationsdatei
|
||||||
|
* bereits vor der einmaligen Log4j2-Initialisierung gesetzt ist.
|
||||||
*
|
*
|
||||||
* @param args command-line arguments; see class JavaDoc for supported options
|
* @param args command-line arguments; see class JavaDoc for supported options
|
||||||
*/
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
EarlyLogDirectoryInitializer.applyFromArgs(args);
|
||||||
|
Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class);
|
||||||
LOG.info("Starting PDF Umbenenner application...");
|
LOG.info("Starting PDF Umbenenner application...");
|
||||||
try {
|
try {
|
||||||
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
|
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
|
||||||
|
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.core.LoggerContext;
|
||||||
|
import org.apache.logging.log4j.core.appender.RollingFileAppender;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.LogDiagnosticsPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log4j2-basierte Implementierung von {@link LogDiagnosticsPort}.
|
||||||
|
* <p>
|
||||||
|
* Liest den aktiven {@link RollingFileAppender} aus dem laufenden {@link LoggerContext}
|
||||||
|
* und gibt dessen absoluten Dateipfad zurück. Diese Information zeigt, wo Log-Einträge
|
||||||
|
* tatsächlich landen – unabhängig vom konfigurierten {@code log.directory}-Wert in der
|
||||||
|
* Properties-Datei.
|
||||||
|
* <p>
|
||||||
|
* Gibt einen leeren {@link Optional} zurück, wenn kein dateibasierter Appender aktiv ist,
|
||||||
|
* der Kontext nicht auslesbar ist oder der Pfad nicht zu einem absoluten Pfad aufgelöst
|
||||||
|
* werden kann.
|
||||||
|
*/
|
||||||
|
public class Log4jLogDiagnosticsAdapter implements LogDiagnosticsPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen {@code Log4jLogDiagnosticsAdapter}.
|
||||||
|
*/
|
||||||
|
public Log4jLogDiagnosticsAdapter() {
|
||||||
|
// zustandslos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt den absoluten Pfad der aktiven Log-Datei aus dem laufenden Log4j2-Kontext.
|
||||||
|
* <p>
|
||||||
|
* Durchsucht die Appender-Liste des aktiven {@link LoggerContext} nach dem ersten
|
||||||
|
* {@link RollingFileAppender} und gibt dessen {@code fileName} als absoluten Pfad zurück.
|
||||||
|
* Alle Ausnahmen werden abgefangen und als leeres Ergebnis zurückgegeben.
|
||||||
|
*
|
||||||
|
* @return absoluter Pfad der aktiven Log-Datei; leer wenn nicht bestimmbar
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<String> resolveActiveLogFilePath() {
|
||||||
|
try {
|
||||||
|
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
|
||||||
|
return ctx.getConfiguration().getAppenders().values().stream()
|
||||||
|
.filter(a -> a instanceof RollingFileAppender)
|
||||||
|
.map(a -> (RollingFileAppender) a)
|
||||||
|
.map(RollingFileAppender::getFileName)
|
||||||
|
.filter(name -> name != null && !name.isBlank())
|
||||||
|
.map(this::toAbsolutePath)
|
||||||
|
.filter(Optional::isPresent)
|
||||||
|
.map(Optional::get)
|
||||||
|
.findFirst();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> toAbsolutePath(String path) {
|
||||||
|
try {
|
||||||
|
return Optional.of(Paths.get(path).toAbsolutePath().toString());
|
||||||
|
} catch (InvalidPathException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Log-Verzeichnis-Angabe so früh wie möglich aus der aktiven Konfigurationsdatei
|
||||||
|
* und setzt sie als System-Property {@code log.directory}, bevor Log4j2 zum ersten Mal
|
||||||
|
* initialisiert wird.
|
||||||
|
* <p>
|
||||||
|
* Hintergrund: {@code log4j2.xml} referenziert {@code ${sys:log.directory}} für den
|
||||||
|
* Pfad der Rolling-File-Datei. Da Log4j2 sich beim ersten {@code LogManager}-Aufruf
|
||||||
|
* einmalig konfiguriert, muss die System-Property bereits vorher gesetzt sein. Greift
|
||||||
|
* die Property nicht, fällt {@code log4j2.xml} auf ein nutzerschreibbares
|
||||||
|
* Default-Verzeichnis zurück, damit auch im MSI-Betrieb (Arbeitsverzeichnis unter
|
||||||
|
* {@code Program Files}, typischerweise nicht beschreibbar) eine Log-Datei entsteht.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse vermeidet bewusst jede Logger-Nutzung und schluckt sämtliche Fehler:
|
||||||
|
* Sie soll niemals den Programmstart verhindern, sondern lediglich einen frühen
|
||||||
|
* Best-Effort-Hinweis an Log4j2 liefern.
|
||||||
|
*/
|
||||||
|
public final class EarlyLogDirectoryInitializer {
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROPERTY_KEY = "log.directory";
|
||||||
|
private static final String CONFIG_OPTION = "--config";
|
||||||
|
private static final Path DEFAULT_CONFIG_PATH = Paths.get("config/application.properties");
|
||||||
|
private static final String CONFIG_PROPERTY_KEY = "log.directory";
|
||||||
|
|
||||||
|
private EarlyLogDirectoryInitializer() {
|
||||||
|
// utility
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht, aus der aktiven Konfigurationsdatei den Wert von {@code log.directory}
|
||||||
|
* zu lesen, und setzt ihn als System-Property, sofern er ein nicht-leerer String ist.
|
||||||
|
* <p>
|
||||||
|
* Greift {@code --config <pfad>} auf, ansonsten {@code config/application.properties}
|
||||||
|
* relativ zum Arbeitsverzeichnis. Ist kein Wert ableitbar, bleibt die System-Property
|
||||||
|
* unverändert; in diesem Fall greift der in {@code log4j2.xml} hinterlegte Fallback.
|
||||||
|
*
|
||||||
|
* @param args Kommandozeilenargumente, dürfen {@code null} sein
|
||||||
|
*/
|
||||||
|
public static void applyFromArgs(String[] args) {
|
||||||
|
try {
|
||||||
|
if (System.getProperty(SYSTEM_PROPERTY_KEY) != null
|
||||||
|
&& !System.getProperty(SYSTEM_PROPERTY_KEY).isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path configPath = resolveConfigPath(args);
|
||||||
|
if (configPath == null || !Files.isRegularFile(configPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Properties properties = new Properties();
|
||||||
|
try (InputStream in = Files.newInputStream(configPath)) {
|
||||||
|
properties.load(in);
|
||||||
|
}
|
||||||
|
String value = properties.getProperty(CONFIG_PROPERTY_KEY);
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
System.setProperty(SYSTEM_PROPERTY_KEY, value.trim());
|
||||||
|
}
|
||||||
|
} catch (IOException | RuntimeException ignored) {
|
||||||
|
// bewusst still: Log4j2-Fallback aus log4j2.xml übernimmt ansonsten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path resolveConfigPath(String[] args) {
|
||||||
|
if (args != null) {
|
||||||
|
for (int i = 0; i < args.length - 1; i++) {
|
||||||
|
if (CONFIG_OPTION.equals(args[i])) {
|
||||||
|
String raw = args[i + 1];
|
||||||
|
if (raw != null && !raw.isBlank()) {
|
||||||
|
try {
|
||||||
|
return Paths.get(raw);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_CONFIG_PATH;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
|
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
|
||||||
</Console>
|
</Console>
|
||||||
|
|
||||||
<!-- Rolling file appender for logs in ./logs/ directory -->
|
<!-- Rolling file appender. Honours the system property log.directory which
|
||||||
<RollingFile name="File" fileName="logs/pdf-umbenenner.log"
|
is set very early in the bootstrap from the active configuration file.
|
||||||
filePattern="logs/pdf-umbenenner-%d{yyyy-MM-dd}-%i.log.gz">
|
The fallback intentionally points to a guaranteed-writable user-scoped
|
||||||
|
directory so MSI installs (where the working directory typically lives
|
||||||
|
under Program Files and is not user-writable) still produce log files. -->
|
||||||
|
<RollingFile name="File" fileName="${sys:log.directory:-${sys:user.home}/pdf-umbenenner/logs}/pdf-umbenenner.log"
|
||||||
|
filePattern="${sys:log.directory:-${sys:user.home}/pdf-umbenenner/logs}/pdf-umbenenner-%d{yyyy-MM-dd}-%i.log.gz">
|
||||||
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
|
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" charset="UTF-8"/>
|
||||||
<Policies>
|
<Policies>
|
||||||
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
|
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
|
||||||
|
|||||||
+106
-77
@@ -5,12 +5,16 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
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.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -135,30 +139,21 @@ class ExecutableJarSmokeTestIT {
|
|||||||
System.out.println("[SMOKE-TEST] Working directory: " + workDir.toAbsolutePath());
|
System.out.println("[SMOKE-TEST] Working directory: " + workDir.toAbsolutePath());
|
||||||
System.out.println("[SMOKE-TEST] Command: " + String.join(" ", command));
|
System.out.println("[SMOKE-TEST] Command: " + String.join(" ", command));
|
||||||
|
|
||||||
Process process = pb.start();
|
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
|
||||||
|
|
||||||
// Wait for process completion with timeout
|
System.out.println("[SMOKE-TEST] Exit code: " + result.exitCode());
|
||||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
System.out.println("[SMOKE-TEST] Subprocess stdout/stderr:\n" + result.output());
|
||||||
assertTrue(completed, "Process should complete within " + PROCESS_TIMEOUT_MS + "ms timeout");
|
|
||||||
|
|
||||||
int exitCode = process.exitValue();
|
assertTrue(result.completed(), "Process should complete within " + PROCESS_TIMEOUT_MS + "ms timeout");
|
||||||
|
assertEquals(0, result.exitCode(), "Successful startup should return exit code 0. Output was: " + result.output());
|
||||||
// Capture all output for diagnostic purposes
|
|
||||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
|
||||||
String outputText = new String(outputBytes);
|
|
||||||
|
|
||||||
System.out.println("[SMOKE-TEST] Exit code: " + exitCode);
|
|
||||||
System.out.println("[SMOKE-TEST] Subprocess stdout/stderr:\n" + outputText);
|
|
||||||
|
|
||||||
assertEquals(0, exitCode, "Successful startup should return exit code 0. Output was: " + outputText);
|
|
||||||
|
|
||||||
// Verify logging output was produced (check console output)
|
// Verify logging output was produced (check console output)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
outputText.contains("Starting") ||
|
result.output().contains("Starting") ||
|
||||||
outputText.contains("Bootstrap") ||
|
result.output().contains("Bootstrap") ||
|
||||||
outputText.contains("completed") ||
|
result.output().contains("completed") ||
|
||||||
outputText.contains("successfully"),
|
result.output().contains("successfully"),
|
||||||
"Output should contain startup/shutdown indicators. Got: " + outputText
|
"Output should contain startup/shutdown indicators. Got: " + result.output()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify no unexpected artifacts were created beyond our fixtures
|
// Verify no unexpected artifacts were created beyond our fixtures
|
||||||
@@ -259,31 +254,22 @@ class ExecutableJarSmokeTestIT {
|
|||||||
System.out.println("[SMOKE-TEST-INVALID] Working directory: " + workDir.toAbsolutePath());
|
System.out.println("[SMOKE-TEST-INVALID] Working directory: " + workDir.toAbsolutePath());
|
||||||
System.out.println("[SMOKE-TEST-INVALID] Command: " + String.join(" ", command));
|
System.out.println("[SMOKE-TEST-INVALID] Command: " + String.join(" ", command));
|
||||||
|
|
||||||
Process process = pb.start();
|
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
|
||||||
|
|
||||||
// Wait for process completion with timeout
|
System.out.println("[SMOKE-TEST-INVALID] Exit code: " + result.exitCode());
|
||||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
System.out.println("[SMOKE-TEST-INVALID] Subprocess stdout/stderr:\n" + result.output());
|
||||||
assertTrue(completed, "Process should complete within timeout even on failure");
|
|
||||||
|
|
||||||
int exitCode = process.exitValue();
|
assertTrue(result.completed(), "Process should complete within timeout even on failure");
|
||||||
|
assertEquals(1, result.exitCode(), "Invalid configuration should return exit code 1. Output was: " + result.output());
|
||||||
// Capture all output for diagnostic purposes
|
|
||||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
|
||||||
String outputText = new String(outputBytes);
|
|
||||||
|
|
||||||
System.out.println("[SMOKE-TEST-INVALID] Exit code: " + exitCode);
|
|
||||||
System.out.println("[SMOKE-TEST-INVALID] Subprocess stdout/stderr:\n" + outputText);
|
|
||||||
|
|
||||||
assertEquals(1, exitCode, "Invalid configuration should return exit code 1. Output was: " + outputText);
|
|
||||||
|
|
||||||
// Verify error output indicates configuration failure
|
// Verify error output indicates configuration failure
|
||||||
assertTrue(
|
assertTrue(
|
||||||
outputText.toLowerCase().contains("config") ||
|
result.output().toLowerCase().contains("config") ||
|
||||||
outputText.toLowerCase().contains("validation") ||
|
result.output().toLowerCase().contains("validation") ||
|
||||||
outputText.toLowerCase().contains("invalid") ||
|
result.output().toLowerCase().contains("invalid") ||
|
||||||
outputText.toLowerCase().contains("error") ||
|
result.output().toLowerCase().contains("error") ||
|
||||||
outputText.toLowerCase().contains("failed"),
|
result.output().toLowerCase().contains("failed"),
|
||||||
"Output should indicate configuration/validation error. Got: " + outputText
|
"Output should indicate configuration/validation error. Got: " + result.output()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,17 +344,14 @@ class ExecutableJarSmokeTestIT {
|
|||||||
|
|
||||||
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Command: " + String.join(" ", command));
|
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Command: " + String.join(" ", command));
|
||||||
|
|
||||||
Process process = pb.start();
|
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
|
||||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
|
||||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
|
||||||
String outputText = new String(outputBytes);
|
|
||||||
|
|
||||||
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Exit code: " + process.exitValue());
|
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Exit code: " + result.exitCode());
|
||||||
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Output:\n" + outputText);
|
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Output:\n" + result.output());
|
||||||
|
|
||||||
assertTrue(completed, "Process should complete within timeout");
|
assertTrue(result.completed(), "Process should complete within timeout");
|
||||||
assertEquals(0, process.exitValue(),
|
assertEquals(0, result.exitCode(),
|
||||||
"Headless start with explicit valid --config path must exit 0. Output: " + outputText);
|
"Headless start with explicit valid --config path must exit 0. Output: " + result.output());
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -403,27 +386,24 @@ class ExecutableJarSmokeTestIT {
|
|||||||
|
|
||||||
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Command: " + String.join(" ", command));
|
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Command: " + String.join(" ", command));
|
||||||
|
|
||||||
Process process = pb.start();
|
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
|
||||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
|
||||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
|
||||||
String outputText = new String(outputBytes);
|
|
||||||
|
|
||||||
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Exit code: " + process.exitValue());
|
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Exit code: " + result.exitCode());
|
||||||
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Output:\n" + outputText);
|
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Output:\n" + result.output());
|
||||||
|
|
||||||
assertTrue(completed, "Process should complete within timeout");
|
assertTrue(result.completed(), "Process should complete within timeout");
|
||||||
assertEquals(1, process.exitValue(),
|
assertEquals(1, result.exitCode(),
|
||||||
"Headless start with non-existent --config path must exit 1. Output: " + outputText);
|
"Headless start with non-existent --config path must exit 1. Output: " + result.output());
|
||||||
|
|
||||||
// Verify that the output contains a diagnostic keyword so operators can trace the cause.
|
// Verify that the output contains a diagnostic keyword so operators can trace the cause.
|
||||||
// Only stable keywords are checked; exact message text may evolve.
|
// Only stable keywords are checked; exact message text may evolve.
|
||||||
assertTrue(
|
assertTrue(
|
||||||
outputText.toLowerCase().contains("not found")
|
result.output().toLowerCase().contains("not found")
|
||||||
|| outputText.toLowerCase().contains("does not exist")
|
|| result.output().toLowerCase().contains("does not exist")
|
||||||
|| outputText.toLowerCase().contains("missing")
|
|| result.output().toLowerCase().contains("missing")
|
||||||
|| outputText.toLowerCase().contains("error")
|
|| result.output().toLowerCase().contains("error")
|
||||||
|| outputText.toLowerCase().contains("config"),
|
|| result.output().toLowerCase().contains("config"),
|
||||||
"Output must contain a diagnostic keyword for the missing config file. Got: " + outputText
|
"Output must contain a diagnostic keyword for the missing config file. Got: " + result.output()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,30 +477,79 @@ class ExecutableJarSmokeTestIT {
|
|||||||
|
|
||||||
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Command: " + String.join(" ", command));
|
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Command: " + String.join(" ", command));
|
||||||
|
|
||||||
Process process = pb.start();
|
ProcessResult result = runProcess(pb, PROCESS_TIMEOUT_MS);
|
||||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
|
||||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
|
||||||
String outputText = new String(outputBytes);
|
|
||||||
|
|
||||||
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Exit code: " + process.exitValue());
|
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Exit code: " + result.exitCode());
|
||||||
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Output:\n" + outputText);
|
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Output:\n" + result.output());
|
||||||
|
|
||||||
assertTrue(completed, "Process should complete within timeout");
|
assertTrue(result.completed(), "Process should complete within timeout");
|
||||||
assertEquals(0, process.exitValue(),
|
assertEquals(0, result.exitCode(),
|
||||||
"Headless start must exit 0 for the JavaFX-freedom check to be meaningful. "
|
"Headless start must exit 0 for the JavaFX-freedom check to be meaningful. "
|
||||||
+ "Output: " + outputText);
|
+ "Output: " + result.output());
|
||||||
|
|
||||||
// JavaFX initialisation would produce one of these markers in stdout/stderr.
|
// JavaFX initialisation would produce one of these markers in stdout/stderr.
|
||||||
// Their absence is the evidence that the headless path is JavaFX-free at runtime.
|
// Their absence is the evidence that the headless path is JavaFX-free at runtime.
|
||||||
assertFalse(
|
assertFalse(
|
||||||
outputText.contains("Platform.startup")
|
result.output().contains("Platform.startup")
|
||||||
|| outputText.contains("Monocle")
|
|| result.output().contains("Monocle")
|
||||||
|| outputText.contains("com.sun.javafx")
|
|| result.output().contains("com.sun.javafx")
|
||||||
|| outputText.contains("javafx.application"),
|
|| result.output().contains("javafx.application"),
|
||||||
"Headless output must not contain JavaFX initialisation markers. Got:\n" + outputText
|
"Headless output must not contain JavaFX initialisation markers. Got:\n" + result.output()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Shared helper: run a process and capture output concurrently
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the result of a subprocess execution.
|
||||||
|
*
|
||||||
|
* @param completed {@code true} if the process exited within the timeout
|
||||||
|
* @param exitCode the process exit code (meaningful only when {@code completed} is {@code true})
|
||||||
|
* @param output all bytes written to stdout/stderr by the subprocess
|
||||||
|
*/
|
||||||
|
private record ProcessResult(boolean completed, int exitCode, String output) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the given {@link ProcessBuilder} and waits for the subprocess to finish,
|
||||||
|
* draining its combined stdout/stderr concurrently to avoid pipe-buffer deadlocks.
|
||||||
|
*
|
||||||
|
* <p>On Windows, the default OS pipe buffer is only 4 KB. If the subprocess writes
|
||||||
|
* more than that without the parent reading, the subprocess blocks on its next write
|
||||||
|
* while the parent blocks in {@code waitFor} — a classic deadlock. This helper prevents
|
||||||
|
* that by reading the subprocess output in a background thread so the pipe never fills up.
|
||||||
|
*
|
||||||
|
* @param pb configured and ready-to-start {@link ProcessBuilder}; must have
|
||||||
|
* {@code redirectErrorStream(true)} set so that stderr is merged into stdout
|
||||||
|
* @param timeoutMs maximum milliseconds to wait for the subprocess to finish
|
||||||
|
* @return a {@link ProcessResult} containing completion status, exit code, and captured output
|
||||||
|
* @throws Exception if the process cannot be started or the drain thread is interrupted
|
||||||
|
*/
|
||||||
|
private ProcessResult runProcess(ProcessBuilder pb, long timeoutMs) throws Exception {
|
||||||
|
Process process = pb.start();
|
||||||
|
|
||||||
|
// Drain stdout/stderr in a background thread to prevent Windows pipe-buffer deadlocks.
|
||||||
|
// The OS pipe buffer is only 4 KB on Windows; if the subprocess writes more than that
|
||||||
|
// while the parent is blocked in waitFor(), neither side can proceed.
|
||||||
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
Thread drainThread = new Thread(() -> {
|
||||||
|
try (InputStream in = process.getInputStream()) {
|
||||||
|
in.transferTo(buffer);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// Stream closed by process exit — normal termination path
|
||||||
|
}
|
||||||
|
}, "subprocess-output-drain");
|
||||||
|
drainThread.setDaemon(true);
|
||||||
|
drainThread.start();
|
||||||
|
|
||||||
|
boolean completed = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
|
||||||
|
drainThread.join(5_000); // Allow drain to finish (process has already exited or timed out)
|
||||||
|
|
||||||
|
int exitCode = completed ? process.exitValue() : -1;
|
||||||
|
return new ProcessResult(completed, exitCode, buffer.toString());
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Shared helper: locate the shaded JAR
|
// Shared helper: locate the shaded JAR
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+45
-36
@@ -33,8 +33,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
|||||||
* the document's master record reaches {@code SUCCESS} and the target file is on disk
|
* the document's master record reaches {@code SUCCESS} and the target file is on disk
|
||||||
* after the first run.</li>
|
* after the first run.</li>
|
||||||
* <li><strong>Deterministic content error</strong>: blank PDFs (no extractable text) reach
|
* <li><strong>Deterministic content error</strong>: blank PDFs (no extractable text) reach
|
||||||
* {@code FAILED_RETRYABLE} after the first run and {@code FAILED_FINAL} after the
|
* {@code FAILED_FINAL} immediately after the first run, because {@code NO_USABLE_TEXT}
|
||||||
* second run, exercising the one-retry rule for deterministic content errors.</li>
|
* is not retryable — an image-only scan without OCR text will not change on retry.</li>
|
||||||
* <li><strong>Transient technical error</strong>: AI stub failures produce
|
* <li><strong>Transient technical error</strong>: AI stub failures produce
|
||||||
* {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.</li>
|
* {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.</li>
|
||||||
* <li><strong>Transient error exhaustion</strong>: repeated AI stub failures across
|
* <li><strong>Transient error exhaustion</strong>: repeated AI stub failures across
|
||||||
@@ -124,27 +124,29 @@ class BatchRunEndToEndTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Scenario 2: Deterministic content error → FAILED_RETRYABLE → FAILED_FINAL
|
// Scenario 2: Deterministic content error (NO_USABLE_TEXT) → immediate FAILED_FINAL
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the one-retry rule for deterministic content errors:
|
* Verifies that a blank PDF (no extractable text) reaches {@code FAILED_FINAL} immediately
|
||||||
|
* in a single run without any retry:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Run 1: blank PDF → pre-check fails (no extractable text) →
|
* <li>Run 1: blank PDF → pre-check fails ({@code NO_USABLE_TEXT}) →
|
||||||
* {@code FAILED_RETRYABLE}, content error counter = 1.</li>
|
* {@code FAILED_FINAL} immediately, content error counter = 1.</li>
|
||||||
* <li>Run 2: same outcome again → {@code FAILED_FINAL}, content error counter = 2.</li>
|
* <li>Run 2: document is already terminal → {@code SKIPPED_FINAL_FAILURE}.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* No AI call is made in either run because the content pre-check prevents it.
|
* No AI call is made because the content pre-check prevents it.
|
||||||
|
* An image-only scan without OCR text will not change between runs, so no retry is useful.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void deterministicContentError_twoRuns_reachesFailedFinal(@TempDir Path tempDir)
|
void deterministicContentError_noUsableText_immediatelyFailedFinal(@TempDir Path tempDir)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
||||||
ctx.createBlankPdf("blank.pdf");
|
ctx.createBlankPdf("blank.pdf");
|
||||||
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
||||||
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
||||||
|
|
||||||
// --- Run 1 ---
|
// --- Run 1: NO_USABLE_TEXT → FAILED_FINAL immediately ---
|
||||||
ctx.runBatch();
|
ctx.runBatch();
|
||||||
|
|
||||||
assertThat(ctx.aiStub.invocationCount())
|
assertThat(ctx.aiStub.invocationCount())
|
||||||
@@ -152,26 +154,29 @@ class BatchRunEndToEndTest {
|
|||||||
.isEqualTo(0);
|
.isEqualTo(0);
|
||||||
|
|
||||||
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
assertThat(record1.overallStatus())
|
||||||
|
.as("Blank PDF must finalise immediately without retry")
|
||||||
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(record1.failureCounters().contentErrorCount()).isEqualTo(1);
|
assertThat(record1.failureCounters().contentErrorCount()).isEqualTo(1);
|
||||||
assertThat(record1.failureCounters().transientErrorCount()).isEqualTo(0);
|
assertThat(record1.failureCounters().transientErrorCount()).isEqualTo(0);
|
||||||
|
|
||||||
List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
|
List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
|
||||||
assertThat(attempts1).hasSize(1);
|
assertThat(attempts1).hasSize(1);
|
||||||
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(attempts1.get(0).retryable()).isTrue();
|
assertThat(attempts1.get(0).retryable()).isFalse();
|
||||||
|
|
||||||
// --- Run 2 ---
|
// --- Run 2: terminal FAILED_FINAL → skip ---
|
||||||
ctx.runBatch();
|
ctx.runBatch();
|
||||||
|
|
||||||
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(record2.failureCounters().contentErrorCount()).isEqualTo(2);
|
assertThat(record2.failureCounters().contentErrorCount())
|
||||||
|
.as("Content error counter must not change after a skip")
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
|
List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
|
||||||
assertThat(attempts2).hasSize(2);
|
assertThat(attempts2).hasSize(2);
|
||||||
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
||||||
assertThat(attempts2.get(1).retryable()).isFalse();
|
|
||||||
|
|
||||||
// No target file should exist
|
// No target file should exist
|
||||||
assertThat(ctx.listTargetFiles()).isEmpty();
|
assertThat(ctx.listTargetFiles()).isEmpty();
|
||||||
@@ -277,40 +282,39 @@ class BatchRunEndToEndTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the skip-after-final-failure invariant:
|
* Verifies the skip-after-final-failure invariant:
|
||||||
* after a document reaches {@code FAILED_FINAL} (via two blank-PDF runs), a third run
|
* after a document reaches {@code FAILED_FINAL} (in a single blank-PDF run), a second run
|
||||||
* records a {@code SKIPPED_FINAL_FAILURE} attempt without changing the overall status
|
* records a {@code SKIPPED_FINAL_FAILURE} attempt without changing the overall status
|
||||||
* or failure counters.
|
* or failure counters.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void skipAfterFailedFinal_thirdRun_recordsSkip(@TempDir Path tempDir) throws Exception {
|
void skipAfterFailedFinal_secondRun_recordsSkip(@TempDir Path tempDir) throws Exception {
|
||||||
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
||||||
ctx.createBlankPdf("blank.pdf");
|
ctx.createBlankPdf("blank.pdf");
|
||||||
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
||||||
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
||||||
|
|
||||||
// Reach FAILED_FINAL via two blank-PDF runs
|
// Reach FAILED_FINAL in a single blank-PDF run (NO_USABLE_TEXT finalises immediately)
|
||||||
ctx.runBatch(); // → FAILED_RETRYABLE
|
|
||||||
ctx.runBatch(); // → FAILED_FINAL
|
ctx.runBatch(); // → FAILED_FINAL
|
||||||
|
|
||||||
DocumentRecord finalRecord = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord finalRecord = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(finalRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(finalRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
int contentErrorsBefore = finalRecord.failureCounters().contentErrorCount();
|
int contentErrorsBefore = finalRecord.failureCounters().contentErrorCount();
|
||||||
|
|
||||||
// --- Run 3: should produce skip ---
|
// --- Run 2: should produce skip ---
|
||||||
ctx.runBatch();
|
ctx.runBatch();
|
||||||
|
|
||||||
DocumentRecord record3 = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(record3.overallStatus())
|
assertThat(record2.overallStatus())
|
||||||
.as("Overall status must remain FAILED_FINAL after a skip")
|
.as("Overall status must remain FAILED_FINAL after a skip")
|
||||||
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(record3.failureCounters().contentErrorCount())
|
assertThat(record2.failureCounters().contentErrorCount())
|
||||||
.as("Failure counters must not change after a skip")
|
.as("Failure counters must not change after a skip")
|
||||||
.isEqualTo(contentErrorsBefore);
|
.isEqualTo(contentErrorsBefore);
|
||||||
|
|
||||||
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
|
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
|
||||||
assertThat(attempts).hasSize(3);
|
assertThat(attempts).hasSize(2);
|
||||||
assertThat(attempts.get(2).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
||||||
assertThat(attempts.get(2).retryable()).isFalse();
|
assertThat(attempts.get(1).retryable()).isFalse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,10 +583,11 @@ class BatchRunEndToEndTest {
|
|||||||
* <ol>
|
* <ol>
|
||||||
* <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
|
* <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
|
||||||
* (PROPOSAL_READY → SUCCESS); a blank PDF (no extractable text) reaches
|
* (PROPOSAL_READY → SUCCESS); a blank PDF (no extractable text) reaches
|
||||||
* {@code FAILED_RETRYABLE}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
* {@code FAILED_FINAL} immediately (no retry for {@code NO_USABLE_TEXT}).
|
||||||
|
* {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
||||||
* <li>Run 2: the searchable PDF is skipped as {@code SKIPPED_ALREADY_PROCESSED};
|
* <li>Run 2: the searchable PDF is skipped as {@code SKIPPED_ALREADY_PROCESSED};
|
||||||
* the blank PDF reaches its second content error and is finalized to
|
* the blank PDF is skipped as {@code SKIPPED_FINAL_FAILURE} (already terminal).
|
||||||
* {@code FAILED_FINAL}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
* {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* This confirms the exit-code contract: only hard bootstrap or infrastructure
|
* This confirms the exit-code contract: only hard bootstrap or infrastructure
|
||||||
* failures produce a non-zero exit code; document-level errors do not.
|
* failures produce a non-zero exit code; document-level errors do not.
|
||||||
@@ -609,7 +614,8 @@ class BatchRunEndToEndTest {
|
|||||||
.as("Searchable PDF must reach SUCCESS within the same single run")
|
.as("Searchable PDF must reach SUCCESS within the same single run")
|
||||||
.isEqualTo(ProcessingStatus.SUCCESS);
|
.isEqualTo(ProcessingStatus.SUCCESS);
|
||||||
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
|
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
|
||||||
.isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
.as("Blank PDF (NO_USABLE_TEXT) must finalise immediately to FAILED_FINAL")
|
||||||
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
|
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
|
||||||
.failureCounters().contentErrorCount()).isEqualTo(1);
|
.failureCounters().contentErrorCount()).isEqualTo(1);
|
||||||
|
|
||||||
@@ -622,8 +628,7 @@ class BatchRunEndToEndTest {
|
|||||||
BatchRunOutcome run2 = ctx.runBatch();
|
BatchRunOutcome run2 = ctx.runBatch();
|
||||||
|
|
||||||
assertThat(run2)
|
assertThat(run2)
|
||||||
.as("Batch must complete with SUCCESS even when a document is finalised "
|
.as("Batch must complete with SUCCESS when all documents are skipped")
|
||||||
+ "to FAILED_FINAL")
|
|
||||||
.isEqualTo(BatchRunOutcome.SUCCESS);
|
.isEqualTo(BatchRunOutcome.SUCCESS);
|
||||||
|
|
||||||
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
|
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
|
||||||
@@ -632,8 +637,12 @@ class BatchRunEndToEndTest {
|
|||||||
.isEqualTo(ProcessingStatus.SUCCESS);
|
.isEqualTo(ProcessingStatus.SUCCESS);
|
||||||
|
|
||||||
DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
|
DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
|
||||||
assertThat(blankRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(blankRecord.overallStatus())
|
||||||
assertThat(blankRecord.failureCounters().contentErrorCount()).isEqualTo(2);
|
.as("Terminal FAILED_FINAL must remain unchanged after skip")
|
||||||
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
|
assertThat(blankRecord.failureCounters().contentErrorCount())
|
||||||
|
.as("Content error counter must not change after SKIPPED_FINAL_FAILURE")
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
// Exactly one target file from the successfully processed document
|
// Exactly one target file from the successfully processed document
|
||||||
List<String> targetFiles = ctx.listTargetFiles();
|
List<String> targetFiles = ctx.listTargetFiles();
|
||||||
|
|||||||
+16
-43
@@ -252,64 +252,37 @@ class ProviderIdentifierE2ETest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Pflicht-Testfall: legacyDataFromBeforeV11RemainsReadable
|
// Nicht-konformes Bestands-Schema – Schema-Prüfung schlägt ab
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proves backward compatibility with databases created before the {@code ai_provider}
|
* Eine Datenbank, die fachliche Tabellen enthält, aber nicht dem vollständigen
|
||||||
* column was introduced.
|
* Zielschema entspricht (fehlende Spalten, fehlende Indizes), darf nicht stillschweigend
|
||||||
|
* heilen. Die Initialisierung muss mit einem klaren Fehler abbrechen.
|
||||||
*
|
*
|
||||||
* <h2>What is verified</h2>
|
* <p>Geprüft wird, dass die Schema-Prüfcheckliste greift: fehlen Spalten wie
|
||||||
* <ol>
|
* {@code ai_provider}, {@code last_target_path} oder fehlende Indizes, dann bricht
|
||||||
* <li>A database without the {@code ai_provider} column can be opened and its existing
|
* der Start mit {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException} ab.
|
||||||
* rows read without throwing any exception.</li>
|
|
||||||
* <li>The {@code aiProvider} field for pre-extension rows is {@code null} (no synthesised
|
|
||||||
* default, no error).</li>
|
|
||||||
* <li>Other fields on the pre-extension attempt (status, retryable flag) remain
|
|
||||||
* correctly readable after schema evolution.</li>
|
|
||||||
* <li>A new batch run on the same database succeeds, proving that the evolved schema
|
|
||||||
* is fully write-compatible with the legacy data.</li>
|
|
||||||
* </ol>
|
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void legacyDataFromBeforeV11RemainsReadable(@TempDir Path tempDir) throws Exception {
|
void nichtKonformesBestandsSchema_fuehrtZuFehlerBeimStart(@TempDir Path tempDir) throws Exception {
|
||||||
// Build a database without the ai_provider column (simulates pre-extension installation)
|
// Datenbank mit unvollständigem Schema anlegen (fehlt: ai_provider, last_target_path,
|
||||||
|
// last_target_file_name sowie alle drei Indizes)
|
||||||
String jdbcUrl = "jdbc:sqlite:"
|
String jdbcUrl = "jdbc:sqlite:"
|
||||||
+ tempDir.resolve("legacy.db").toAbsolutePath().toString().replace('\\', '/');
|
+ tempDir.resolve("legacy.db").toAbsolutePath().toString().replace('\\', '/');
|
||||||
createPreExtensionSchema(jdbcUrl);
|
createPreExtensionSchema(jdbcUrl);
|
||||||
|
|
||||||
// Insert a legacy attempt row (no ai_provider column present in schema at this point)
|
// Datensatz einfügen (Schema ist noch partiell vorhanden)
|
||||||
DocumentFingerprint legacyFp = fingerprint("aabbcc");
|
DocumentFingerprint legacyFp = fingerprint("aabbcc");
|
||||||
insertLegacyData(jdbcUrl, legacyFp);
|
insertLegacyData(jdbcUrl, legacyFp);
|
||||||
|
|
||||||
// Initialize the full schema — this must add ai_provider idempotently
|
// Initialisierung muss mit klarem Fehler abbrechen – kein stilles Heilen
|
||||||
de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter schema =
|
de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter schema =
|
||||||
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter(jdbcUrl);
|
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
schema.initializeSchema();
|
org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
|
||||||
// Read back the legacy attempt — must not throw, aiProvider must be null
|
schema::initializeSchema,
|
||||||
de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter repo =
|
"Erwarte Fehler bei nicht konformem Bestands-Schema (fehlende Spalten/Indizes)");
|
||||||
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
|
||||||
List<ProcessingAttempt> attempts = repo.findAllByFingerprint(legacyFp);
|
|
||||||
|
|
||||||
assertThat(attempts).hasSize(1);
|
|
||||||
assertThat(attempts.get(0).aiProvider())
|
|
||||||
.as("Pre-extension attempt must have null aiProvider after schema evolution")
|
|
||||||
.isNull();
|
|
||||||
assertThat(attempts.get(0).status())
|
|
||||||
.as("Other fields of the pre-extension row must still be readable")
|
|
||||||
.isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
|
||||||
assertThat(attempts.get(0).retryable()).isTrue();
|
|
||||||
|
|
||||||
// A new batch run on the same database must succeed (write-compatible evolved schema)
|
|
||||||
try (E2ETestContext ctx = E2ETestContext.initializeWithProvider(
|
|
||||||
tempDir.resolve("newrun"), "openai-compatible")) {
|
|
||||||
ctx.createSearchablePdf("newdoc.pdf", SAMPLE_PDF_TEXT);
|
|
||||||
BatchRunOutcome outcome = ctx.runBatch();
|
|
||||||
assertThat(outcome)
|
|
||||||
.as("Batch run on evolved database must succeed")
|
|
||||||
.isEqualTo(BatchRunOutcome.SUCCESS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+3
-2
@@ -21,9 +21,10 @@ public enum PreCheckFailureReason {
|
|||||||
* The extracted PDF text, after normalization, contains no letters or digits.
|
* The extracted PDF text, after normalization, contains no letters or digits.
|
||||||
* <p>
|
* <p>
|
||||||
* This is a deterministic content error: reprocessing the same file in a later run
|
* This is a deterministic content error: reprocessing the same file in a later run
|
||||||
* will have the same outcome unless the source file is changed.
|
* will have the same outcome unless the source file is changed (e.g. by adding OCR).
|
||||||
* <p>
|
* <p>
|
||||||
* Retry logic: exactly 1 retry in a later batch run.
|
* Retry logic: no retry — the document is immediately finalised to
|
||||||
|
* {@link ProcessingStatus#FAILED_FINAL}.
|
||||||
*/
|
*/
|
||||||
NO_USABLE_TEXT("No usable text in extracted PDF content"),
|
NO_USABLE_TEXT("No usable text in extracted PDF content"),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# jdeps-Ausgabe fuer das Shade-JAR
|
||||||
|
# Erstellt: 2026-04-30
|
||||||
|
# Befehl:
|
||||||
|
# jdeps --print-module-deps --ignore-missing-deps \
|
||||||
|
# pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
|
#
|
||||||
|
# Ausgabe (einzeilig, Trennzeichen Komma):
|
||||||
|
java.base,java.compiler,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.sql,jdk.jfr,jdk.unsupported,jdk.unsupported.desktop
|
||||||
|
#
|
||||||
|
# Neu gegenueber vorheriger addModules-Liste:
|
||||||
|
# java.prefs – hinzugefuegt (von jdeps erkannt)
|
||||||
|
# jdk.unsupported.desktop – hinzugefuegt (von jdeps erkannt)
|
||||||
|
#
|
||||||
|
# Manuell beibehalten (nicht von jdeps --ignore-missing-deps gemeldet,
|
||||||
|
# aber zur Laufzeit reflektiv erforderlich):
|
||||||
|
# java.desktop – JavaFX-Grafiksubsystem (ohne explizite statische Abhaengigkeit)
|
||||||
|
# java.logging – Log4j2-JUL-Bridge (reflektiver Zugriff zur Laufzeit)
|
||||||
|
# java.xml – FXML/XML-Parsing (reflektiver Zugriff zur Laufzeit)
|
||||||
|
#
|
||||||
|
# HINWEIS: Die Laufzeit-Verifikation ohne Entwicklungs-JDK ist zwingend erforderlich
|
||||||
|
# (GUI-Start, PDF laden und rendern, Verarbeitungslauf, Verlaufs-Tab oeffnen).
|
||||||
|
# Nur ein erfolgreicher Test auf einem System ohne JDK bestaetigt die Vollstaendigkeit
|
||||||
|
# der Modulliste. Anleitung: betrieb.md, Abschnitt "MSI-Release-Checkliste".
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
@@ -67,10 +67,13 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
Erzeugt den nativen Windows-Installer (MSI) ueber jpackage.
|
Erzeugt den nativen Windows-Installer (MSI) ueber jpackage.
|
||||||
Die Modulliste wurde auf Basis von `jdeps` (print-module-deps) auf dem
|
Die Modulliste wurde per jdeps (print-module-deps, ignore-missing-deps)
|
||||||
Shade-JAR ermittelt und um die zur Laufzeit reflektiv genutzten Module
|
auf dem Shade-JAR ermittelt (Stand 2026-04-30, vollstaendige Ausgabe
|
||||||
java.logging (Log4j2-Bridge) und java.xml (FXML/XML-Parsing) erweitert.
|
in pdf-umbenenner-packaging/jdeps-output.txt) und um die zur Laufzeit
|
||||||
|
reflektiv genutzten Module java.desktop, java.logging und java.xml erweitert.
|
||||||
WiX Toolset 3.x muss im PATH verfuegbar sein (nur auf der Entwicklungsmaschine).
|
WiX Toolset 3.x muss im PATH verfuegbar sein (nur auf der Entwicklungsmaschine).
|
||||||
|
Nach dem MSI-Build muss die installierte Anwendung ohne Entwicklungs-JDK
|
||||||
|
gestartet und verifiziert werden (GUI-Start, PDF rendern, Verlaufs-Tab).
|
||||||
-->
|
-->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.panteleyev</groupId>
|
<groupId>org.panteleyev</groupId>
|
||||||
@@ -93,14 +96,36 @@
|
|||||||
<destination>${project.build.directory}/dist</destination>
|
<destination>${project.build.directory}/dist</destination>
|
||||||
<icon>${project.basedir}/src/main/packaging/icon.ico</icon>
|
<icon>${project.basedir}/src/main/packaging/icon.ico</icon>
|
||||||
<!--
|
<!--
|
||||||
Bindet die Beispiel-Konfiguration als zusaetzliche Datei in das
|
Bindet zusaetzliche Dateien in das Anwendungs-Image ein.
|
||||||
Anwendungs-Image ein. Sie landet neben den ausgelieferten JAR-Artefakten
|
Sie landen im Installationsverzeichnis neben der PDF-KI-Renamer.exe.
|
||||||
im Installationsverzeichnis und muss vom Betreiber nach
|
Die Batch-Dateien referenzieren die EXE relativ zu ihrer eigenen
|
||||||
|
Position (%~dp0PDF-KI-Renamer.exe, kein Unterverzeichnis).
|
||||||
|
Die Beispiel-Konfiguration muss vom Betreiber nach
|
||||||
C:\ProgramData\PDF KI Renamer\config\ kopiert und angepasst werden.
|
C:\ProgramData\PDF KI Renamer\config\ kopiert und angepasst werden.
|
||||||
-->
|
-->
|
||||||
<appContent>
|
<appContent>
|
||||||
<appContent>src/main/packaging/application.example.properties</appContent>
|
<appContent>src/main/packaging/application.example.properties</appContent>
|
||||||
|
<appContent>src/main/packaging/PDF-KI-Renamer.bat</appContent>
|
||||||
|
<appContent>src/main/packaging/PDF-KI-Renamer-GUI.bat</appContent>
|
||||||
</appContent>
|
</appContent>
|
||||||
|
<!--
|
||||||
|
Modulliste ermittelt per jdeps (Stand 2026-04-30):
|
||||||
|
java.base, java.compiler, java.management, java.naming,
|
||||||
|
java.net.http, java.prefs, java.rmi, java.scripting, java.sql,
|
||||||
|
jdk.jfr, jdk.unsupported, jdk.unsupported.desktop
|
||||||
|
Vollstaendige Ausgabe: pdf-umbenenner-packaging/jdeps-output.txt
|
||||||
|
Manuell ergaenzt (reflektiv genutzt, von jdeps nicht erkannt):
|
||||||
|
java.desktop - JavaFX-Grafiksubsystem
|
||||||
|
java.logging - Log4j2-JUL-Bridge
|
||||||
|
java.xml - FXML/XML-Parsing
|
||||||
|
jdk.crypto.ec - EC-Kryptographie (ECDH/ECDSA) fuer TLS 1.2/1.3;
|
||||||
|
ohne dieses Modul schlaegt der TLS-Handshake
|
||||||
|
mit modernen HTTPS-Endpunkten fehl (#92)
|
||||||
|
jdk.crypto.cryptoki - PKCS#11-Provider; vervollstaendigt den
|
||||||
|
JRE-Krypto-Stack analog zu einem Voll-JDK (#92)
|
||||||
|
Laufzeit-Verifikation ohne Entwicklungs-JDK erforderlich
|
||||||
|
(Anleitung in betrieb.md, Abschnitt MSI-Release-Checkliste).
|
||||||
|
-->
|
||||||
<addModules>
|
<addModules>
|
||||||
<module>java.base</module>
|
<module>java.base</module>
|
||||||
<module>java.compiler</module>
|
<module>java.compiler</module>
|
||||||
@@ -109,12 +134,16 @@
|
|||||||
<module>java.management</module>
|
<module>java.management</module>
|
||||||
<module>java.naming</module>
|
<module>java.naming</module>
|
||||||
<module>java.net.http</module>
|
<module>java.net.http</module>
|
||||||
|
<module>java.prefs</module>
|
||||||
<module>java.rmi</module>
|
<module>java.rmi</module>
|
||||||
<module>java.scripting</module>
|
<module>java.scripting</module>
|
||||||
<module>java.sql</module>
|
<module>java.sql</module>
|
||||||
<module>java.xml</module>
|
<module>java.xml</module>
|
||||||
|
<module>jdk.crypto.ec</module>
|
||||||
|
<module>jdk.crypto.cryptoki</module>
|
||||||
<module>jdk.jfr</module>
|
<module>jdk.jfr</module>
|
||||||
<module>jdk.unsupported</module>
|
<module>jdk.unsupported</module>
|
||||||
|
<module>jdk.unsupported.desktop</module>
|
||||||
</addModules>
|
</addModules>
|
||||||
<javaOptions>
|
<javaOptions>
|
||||||
<javaOption>-Xms64m</javaOption>
|
<javaOption>-Xms64m</javaOption>
|
||||||
@@ -126,6 +155,13 @@
|
|||||||
<winMenuGroup>PDF KI Renamer</winMenuGroup>
|
<winMenuGroup>PDF KI Renamer</winMenuGroup>
|
||||||
<winDirChooser>true</winDirChooser>
|
<winDirChooser>true</winDirChooser>
|
||||||
<winShortcutPrompt>false</winShortcutPrompt>
|
<winShortcutPrompt>false</winShortcutPrompt>
|
||||||
|
<!--
|
||||||
|
winUpgradeUuid: Stabiler GUID fuer MSI-Upgrade-Erkennung.
|
||||||
|
Einmalig gesetzt - darf NIEMALS geaendert werden.
|
||||||
|
Eine Aenderung wuerde verhindern, dass ein bestehendes MSI
|
||||||
|
durch das neue MSI automatisch ersetzt wird (Upgrade-Pfad kaputt).
|
||||||
|
-->
|
||||||
|
<winUpgradeUuid>EA8D0149-1401-4D3D-A98D-A2B98DAE5495</winUpgradeUuid>
|
||||||
<installDir>PDF KI Renamer</installDir>
|
<installDir>PDF KI Renamer</installDir>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
@@ -133,8 +169,11 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Kopiert die beiden Start-Batch-Dateien direkt in das EXE-Ausgabeverzeichnis,
|
Kopiert die beiden Start-Batch-Dateien direkt in das MSI-Ausgabeverzeichnis,
|
||||||
damit sie gleichrangig neben dem erzeugten Anwendungsverzeichnis liegen.
|
damit sie auch ausserhalb des MSI-Installers (z. B. fuer manuelle Tests oder
|
||||||
|
portable Nutzung) gleichrangig neben dem erzeugten Installer liegen.
|
||||||
|
Hinweis: Im installierten MSI landen die Batch-Dateien ueber appContent
|
||||||
|
direkt im Installationsverzeichnis neben der EXE.
|
||||||
-->
|
-->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
"%~dp0PDF-KI-Renamer\PDF-KI-Renamer.exe" %*
|
start "" "%~dp0PDF-KI-Renamer.exe" %*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user