diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index d1db99c..868a870 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -29,6 +29,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSectio import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService; import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding; import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput; @@ -116,6 +117,18 @@ public final class GuiConfigurationEditorWorkspace { private final Button openButton = new Button("Öffnen"); private final Button saveButton = new Button("Speichern"); private final Button saveAsButton = new Button("Speichern unter"); + /** + * The "Validieren" button in the "Tests" section. + * Package-private to allow CSS-ID and state assertions in smoke tests. + */ + final Button validateButton = new Button("Validieren"); + + /** + * The "Technische Tests ausführen" button in the "Tests" section. + * Disabled while a test run is in progress to prevent concurrent runs. + * Package-private to allow CSS-ID and state assertions in smoke tests. + */ + final Button technicalTestsButton = new Button("Technische Tests ausführen"); private static final Path DEFAULT_SAVE_PATH = Paths.get("config/application.properties"); @@ -226,6 +239,19 @@ public final class GuiConfigurationEditorWorkspace { */ final GuiModelCatalogCoordinator modelCatalogCoordinator; + /** + * Coordinator that manages asynchronous execution of the "Technische Tests ausführen" action. + * Package-private to allow thread-factory substitution in smoke tests. + */ + final GuiTechnicalTestCoordinator technicalTestCoordinator; + + /** + * Coordinator that manages the correction confirmation dialog and asynchronous execution + * of corrective actions after a technical test run. + * Package-private to allow dialog-supplier substitution in smoke tests. + */ + final GuiCorrectionDialogCoordinator correctionDialogCoordinator; + /** * Mutable list of pending message entries produced by the model catalogue coordinator * and the editor validation. Entries are appended on the JavaFX Application Thread. @@ -302,6 +328,25 @@ public final class GuiConfigurationEditorWorkspace { effectiveContext.modelCatalogPort(), pendingMessages); this.modelCatalogCoordinator.postResultCallback = this::refreshAfterValidation; + CorrectionExecutionService correctionService = effectiveContext.correctionExecutionService(); + this.correctionDialogCoordinator = new GuiCorrectionDialogCoordinator( + correctionService, + pendingMessages, + ignored -> refreshAfterValidation()); + + this.technicalTestCoordinator = new GuiTechnicalTestCoordinator( + effectiveContext.technicalTestOrchestrator(), + this::buildValidationInput, + () -> editorState.loadedFileSnapshot() + .map(snapshot -> snapshot.filePath().toString()) + .orElse(""), + pendingMessages, + report -> { + technicalTestsButton.setDisable(false); + refreshAfterValidation(); + correctionDialogCoordinator.offerCorrections(report); + }); + this.unsavedChangesGuard = new GuiUnsavedChangesGuard( triggerLabel -> showUnsavedChangesDialog(triggerLabel)); @@ -1363,14 +1408,56 @@ public final class GuiConfigurationEditorWorkspace { // Tests / Meldungen sections (structural placeholders) // ========================================================================= + /** + * Builds the "Tests" section containing the explicit validation action button. + *
+ * The "Validieren" button triggers a full in-memory validation of the current editor + * state without writing anything to disk. It produces the same set of local findings + * as the automatic background validation, but additionally appends an INFO message to + * the central message area that confirms the action was executed and reports the number + * of findings found. + *
+ * Later technical overall-check actions (e.g. provider endpoint tests, path checks) will + * be added to this section as separate buttons in subsequent iterations. The validation + * action itself remains local and non-writing at all times. + * + * @return the card node for the "Tests" section + */ private Node createTestsSection() { VBox card = createCardContainer(); + + validateButton.setId("validate-button"); + validateButton.setOnAction(e -> runValidationAction()); + + technicalTestsButton.setId("technical-tests-button"); + technicalTestsButton.setOnAction(e -> runTechnicalTestsAction()); + + HBox buttonRow = new HBox(8, validateButton, technicalTestsButton); + buttonRow.setAlignment(Pos.CENTER_LEFT); + card.getChildren().addAll( sectionTitle("Tests"), - textLabel("Technische Tests und Diagnoseaktionen werden in einem späteren Schritt ergänzt.")); + buttonRow); return card; } + /** + * Executes the "Technische Tests ausführen" action triggered by the user. + *
+ * Disables the button for the duration of the test run to prevent concurrent starts. + * Delegates to {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()}, which + * reads the current editor state, starts a background worker thread, and re-enables + * the button after the result has been applied to the message area. + *
+ * This method does not write anything to disk and does not execute any corrective actions. + * Must be called on the FX Application Thread. + */ + private void runTechnicalTestsAction() { + LOG.info("Aktion Technische Tests ausführen gestartet."); + technicalTestsButton.setDisable(true); + technicalTestCoordinator.triggerTechnicalTests(); + } + /** * Builds the "Meldungen" section containing the central message area. *
@@ -1449,16 +1536,22 @@ public final class GuiConfigurationEditorWorkspace { // ========================================================================= /** - * Runs the editor validation against the current editor state and stores the result - * in {@link #lastValidationResult}, {@link #pendingMessages} and {@link #pendingFieldFindings}. + * Builds an {@link EditorValidationInput} from the current editor state. *
- * This method is pure in-memory (no I/O) and may be called on the JavaFX Application Thread. - * Path existence checks, SQLite roundtrips and network calls are explicitly excluded from - * this validation and belong to later technical overall checks. + * Reads the active provider family and per-provider fields from the current + * {@link GuiConfigurationValues}, resolves API key descriptors via the injected + * {@link ApiKeyResolutionPort}, and returns the assembled input. This method is + * pure in-memory (no I/O) and may be called on the JavaFX Application Thread. + *
+ * This method is used both by the synchronous local validation and by the asynchronous + * technical overall test to extract the current GUI state before handing it to a + * background worker thread. *
* Must be called on the FX Application Thread. + * + * @return assembled validation input representing the current editor state; never {@code null} */ - private void runEditorValidation() { + EditorValidationInput buildValidationInput() { GuiConfigurationValues values = editorState.values(); GuiProviderConfigurationState claudeState = Optional.ofNullable( values.providerConfiguration(AiProviderFamily.CLAUDE)) @@ -1474,7 +1567,7 @@ public final class GuiConfigurationEditorWorkspace { apiKeyResolutionPort.resolve(AiProviderFamily.OPENAI_COMPATIBLE, openaiState.apiKey().propertyValue()); - EditorValidationInput input = new EditorValidationInput( + return new EditorValidationInput( values.activeProviderFamily(), values.sourceFolder(), values.targetFolder(), @@ -1491,7 +1584,20 @@ public final class GuiConfigurationEditorWorkspace { openaiState.model(), openaiState.timeoutSeconds(), openaiKeyDescriptor); + } + /** + * Runs the editor validation against the current editor state and stores the result + * in {@link #lastValidationResult}, {@link #pendingMessages} and {@link #pendingFieldFindings}. + *
+ * This method is pure in-memory (no I/O) and may be called on the JavaFX Application Thread. + * Path existence checks, SQLite roundtrips and network calls are explicitly excluded from + * this validation and belong to later technical overall checks. + *
+ * Must be called on the FX Application Thread. + */ + private void runEditorValidation() { + EditorValidationInput input = buildValidationInput(); EditorValidationReport report = editorValidator.validate(input); // Translate to GUI types @@ -1523,6 +1629,60 @@ public final class GuiConfigurationEditorWorkspace { refreshAfterValidation(); } + /** + * Executes the explicit "Validieren" action triggered by the user. + *
+ * This method re-runs the same in-memory validation logic as the automatic background + * check ({@link #runEditorValidation()}) against the current editor state, without + * writing anything to disk or making any network or filesystem calls. It is therefore + * safe to call on the FX Application Thread at any time. + *
+ * In addition to updating the findings shown by the automatic validation, this action + * appends a dedicated INFO message to the central message area to confirm to the user + * that the action was explicitly executed and to report the number of findings found. + * The message uses a distinct source tag so that it can be replaced on subsequent + * executions without removing messages from other sources. + *
+ * Differences from the automatic background validation: + *
+ * Must be called on the FX Application Thread. + */ + private void runValidationAction() { + LOG.info("Aktion Validieren ausgeführt."); + + // Re-run in-memory validation; this updates pendingMessages, pendingFieldFindings + // and lastValidationResult identically to the automatic background check. + runEditorValidation(); + + // Replace any previous action-confirmation message; preserve all other messages. + pendingMessages.removeIf(m -> m.source().isPresent() + && "Validierung-Aktion".equals(m.source().get())); + + int findingCount = lastValidationResult.fieldFindings().size(); + String confirmationText; + if (findingCount == 0) { + confirmationText = "Aktion Validieren wurde ausgeführt. Keine Befunde."; + } else { + confirmationText = "Aktion Validieren wurde ausgeführt. " + + findingCount + " Befund" + (findingCount == 1 ? "" : "e") + " gefunden."; + } + pendingMessages.add(GuiMessageEntry.of( + GuiMessageSeverity.INFO, confirmationText, "Validierung-Aktion")); + + lastValidationResult = new GuiEditorValidationResult( + List.copyOf(pendingMessages), + List.copyOf(pendingFieldFindings), + java.time.Instant.now()); + + refreshAfterValidation(); + } + /** * Maps an {@link EditorValidationSeverity} to the corresponding {@link GuiMessageSeverity}. * diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinator.java new file mode 100644 index 0000000..704a3a9 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinator.java @@ -0,0 +1,287 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionReport; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport; +import javafx.application.Platform; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Koordiniert den gesammelten Bestätigungsdialog und die anschließende Ausführung + * schreibender Korrekturmaßnahmen nach einem technischen Gesamttest. + *
+ * Der Koordinator empfängt einen {@link TechnicalTestReport}, prüft ob korrigierbare + * Befunde vorliegen, leitet daraus einen {@link CorrectionPlan} ab, zeigt dem Benutzer + * einen gesammelten Bestätigungsdialog und führt die Korrekturen bei Bestätigung über + * den {@link CorrectionExecutionService} auf einem Hintergrund-Worker-Thread aus. + * Ergebnisse werden in die geteilte {@code pendingMessages}-Liste eingehängt. + * + *
+ * {@link #offerCorrections(TechnicalTestReport)} muss auf dem JavaFX Application Thread + * aufgerufen werden. I/O (Ausführung der Korrekturen) läuft auf einem dedizierten + * Daemon-Hintergrund-Thread. UI-Updates erfolgen ausschließlich via + * {@code Platform.runLater}. + * + *
+ * Ohne ausdrückliche Benutzerbestätigung werden keine schreibenden Änderungen ausgeführt. + * Bei Dialog-Abbruch bleibt der Zustand unverändert. + * + *
+ * Dieser Koordinator führt keine Provider-nahen Korrekturen, keine Änderung fachlich
+ * riskanter Werte und keine automatischen Laufstarts durch.
+ */
+public final class GuiCorrectionDialogCoordinator {
+
+ /** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
+ static final String SOURCE_TAG = "Korrekturen";
+
+ private static final Logger LOG = LogManager.getLogger(GuiCorrectionDialogCoordinator.class);
+
+ private final CorrectionExecutionService correctionExecutionService;
+ private final List
+ * Erhält den {@link ConfirmationDialogContent} und gibt {@code true} zurück, wenn der
+ * Benutzer „Fortfahren" wählt, andernfalls {@code false}. Standard: echter Alert.
+ * Paket-privat für Test-Substitution.
+ */
+ Function
+ * Wenn der Bericht keine korrigierbaren Befunde enthält ({@code hasCorrectableFindings()
+ * == false}), wird kein Dialog angezeigt und keine Aktion ausgeführt.
+ *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ *
+ * @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
+ */
+ public void offerCorrections(TechnicalTestReport report) {
+ Objects.requireNonNull(report, "report must not be null");
+
+ if (!report.hasCorrectableFindings()) {
+ LOG.debug("Gesamttest: Keine korrigierbaren Befunde – kein Bestätigungsdialog.");
+ return;
+ }
+
+ CorrectionPlan plan = report.deriveCorrectionPlan();
+ if (!plan.hasCorrections()) {
+ LOG.debug("Gesamttest: Korrekturplan ist leer – kein Bestätigungsdialog.");
+ return;
+ }
+
+ LOG.info("Gesamttest: {} korrigierbare Befunde. Bestätigungsdialog wird angezeigt.", plan.size());
+
+ ConfirmationDialogContent dialogContent = ConfirmationDialogContent.fromPlan(plan);
+ boolean confirmed = dialogSupplier.apply(dialogContent);
+
+ if (!confirmed) {
+ LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen abgelehnt. Keine Änderungen.");
+ return;
+ }
+
+ LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen bestätigt. Ausführung startet.");
+ Runnable task = () -> {
+ CorrectionExecutionReport executionReport = correctionExecutionService.execute(plan);
+ resultDelivery.accept(() -> {
+ applyResult(executionReport);
+ refreshCallback.accept(null);
+ });
+ };
+
+ Thread worker = correctionThreadFactory.apply(task);
+ worker.start();
+ }
+
+ /**
+ * Wendet das Ergebnis der Korrekturausführung auf die geteilte Nachrichtenliste an.
+ *
+ * Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jedes
+ * {@link CorrectionOutcome} einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
+ * angehängt.
+ *
+ * Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
+ *
+ * @param report das Ausführungsergebnis; darf nicht {@code null} sein
+ */
+ private void applyResult(CorrectionExecutionReport report) {
+ // Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
+ pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
+
+ long appliedCount = 0;
+ long failedCount = 0;
+ long notAttemptedCount = 0;
+
+ for (CorrectionOutcome outcome : report.outcomes()) {
+ switch (outcome) {
+ case CorrectionOutcome.Applied applied -> {
+ pendingMessages.add(GuiMessageEntry.of(
+ GuiMessageSeverity.INFO,
+ "Korrektur angewendet: " + applied.suggestion().descriptionForUser()
+ + " – " + applied.message(),
+ SOURCE_TAG));
+ appliedCount++;
+ LOG.info("Korrektur angewendet: {} – {}", applied.suggestion().descriptionForUser(),
+ applied.message());
+ }
+ case CorrectionOutcome.Failed failed -> {
+ pendingMessages.add(GuiMessageEntry.of(
+ GuiMessageSeverity.ERROR,
+ "Korrektur fehlgeschlagen: " + failed.suggestion().descriptionForUser()
+ + " (" + failed.errorMessage() + ")",
+ SOURCE_TAG));
+ failedCount++;
+ LOG.warn("Korrektur fehlgeschlagen: {} – {}", failed.suggestion().descriptionForUser(),
+ failed.errorMessage());
+ }
+ case CorrectionOutcome.NotAttempted notAttempted -> {
+ pendingMessages.add(GuiMessageEntry.of(
+ GuiMessageSeverity.HINT,
+ "Korrektur nicht durchgeführt: " + notAttempted.suggestion().descriptionForUser()
+ + " – " + notAttempted.reason(),
+ SOURCE_TAG));
+ notAttemptedCount++;
+ LOG.info("Korrektur nicht durchgeführt: {} – {}", notAttempted.suggestion().descriptionForUser(),
+ notAttempted.reason());
+ }
+ }
+ }
+
+ // Zusammenfassung
+ String summary = "Korrekturausführung abgeschlossen: "
+ + appliedCount + " angewendet, "
+ + failedCount + " fehlgeschlagen, "
+ + notAttemptedCount + " nicht versucht.";
+ pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
+ LOG.info("Korrekturausführung abgeschlossen: {} angewendet, {} fehlgeschlagen, {} nicht versucht.",
+ appliedCount, failedCount, notAttemptedCount);
+ }
+
+ /**
+ * Zeigt den echten JavaFX-Bestätigungsdialog und gibt die Benutzerentscheidung zurück.
+ *
+ * Muss auf dem JavaFX Application Thread aufgerufen werden. Standard-Fokus liegt auf
+ * dem Abbrechen-Button (kein versehentliches Bestätigen durch Enter).
+ *
+ * @param content der Dialoginhalt; darf nicht {@code null} sein
+ * @return {@code true} wenn der Benutzer „Fortfahren" wählt, sonst {@code false}
+ */
+ private boolean showConfirmationDialog(ConfirmationDialogContent content) {
+ javafx.scene.control.ButtonType proceedButton =
+ new javafx.scene.control.ButtonType("Fortfahren",
+ javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
+ javafx.scene.control.ButtonType cancelButton =
+ new javafx.scene.control.ButtonType("Abbrechen",
+ javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);
+
+ javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
+ javafx.scene.control.Alert.AlertType.CONFIRMATION);
+ alert.setTitle(content.title());
+ alert.setHeaderText(content.introText());
+
+ StringBuilder correctionListText = new StringBuilder();
+ for (String line : content.correctionLines()) {
+ correctionListText.append("• ").append(line).append("\n");
+ }
+ correctionListText.append("\nFortfahren?");
+ alert.setContentText(correctionListText.toString());
+
+ alert.getButtonTypes().setAll(proceedButton, cancelButton);
+
+ // Standard-Fokus: Abbrechen
+ javafx.scene.Node cancelNode = alert.getDialogPane().lookupButton(cancelButton);
+ if (cancelNode instanceof javafx.scene.control.Button btn) {
+ btn.setDefaultButton(true);
+ }
+ javafx.scene.Node proceedNode = alert.getDialogPane().lookupButton(proceedButton);
+ if (proceedNode instanceof javafx.scene.control.Button btn) {
+ btn.setDefaultButton(false);
+ }
+
+ java.util.Optional
+ * Ausschließlich für Tests gedacht.
+ *
+ * @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
+ */
+ public List
* Carries the initial editor state, the optional startup notice, the file-loading callback,
* the file-writing callback that the workspace uses for native save actions, the
- * {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, and the
+ * {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, the
* {@link ApiKeyResolutionPort} used by the editor validation to determine the effective
- * API key provenance from environment variables.
+ * API key provenance from environment variables, the {@link ProviderTechnicalTestService}
+ * used to execute provider-specific technical checks, the {@link PathCheckPort}
+ * used to verify filesystem path accessibility for configuration values, the
+ * {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, and the
+ * {@link CorrectionExecutionService} used to execute corrective actions after a
+ * technical test run has been confirmed by the user.
*
- * All ports are supplied by Bootstrap so that the GUI adapter does not need to know about
- * provider-specific HTTP details or adapter wiring.
+ * All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
+ * know about provider-specific HTTP details or adapter wiring.
*/
public record GuiStartupContext(
GuiConfigurationEditorState initialState,
@@ -28,17 +39,25 @@ public record GuiStartupContext(
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
- ApiKeyResolutionPort apiKeyResolutionPort) {
+ ApiKeyResolutionPort apiKeyResolutionPort,
+ ProviderTechnicalTestService providerTechnicalTestService,
+ PathCheckPort pathCheckPort,
+ TechnicalTestOrchestrator technicalTestOrchestrator,
+ CorrectionExecutionService correctionExecutionService) {
/**
* Creates a startup context.
*
- * @param initialState initial editor state; must not be {@code null}
- * @param startupNotice optional startup notice; {@code null} becomes empty
- * @param configurationFileLoader file-loading callback; must not be {@code null}
- * @param configurationFileWriter file-writing callback; must not be {@code null}
- * @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
- * @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
+ * @param initialState initial editor state; must not be {@code null}
+ * @param startupNotice optional startup notice; {@code null} becomes empty
+ * @param configurationFileLoader file-loading callback; must not be {@code null}
+ * @param configurationFileWriter file-writing callback; must not be {@code null}
+ * @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
+ * @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
+ * @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
+ * @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
+ * @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
+ * @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -51,14 +70,30 @@ public record GuiStartupContext(
"modelCatalogPort must not be null");
apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
"apiKeyResolutionPort must not be null");
+ providerTechnicalTestService = Objects.requireNonNull(providerTechnicalTestService,
+ "providerTechnicalTestService must not be null");
+ pathCheckPort = Objects.requireNonNull(pathCheckPort,
+ "pathCheckPort must not be null");
+ technicalTestOrchestrator = Objects.requireNonNull(technicalTestOrchestrator,
+ "technicalTestOrchestrator must not be null");
+ correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
+ "correctionExecutionService must not be null");
}
/**
* Creates a blank startup context with no loader or writer side effects, a no-op model
- * catalogue port, and a no-op API key resolution port.
+ * catalogue port, a no-op API key resolution port, a no-op provider technical test service,
+ * a no-op path check port, a no-op technical test orchestrator, and a no-op
+ * correction execution service.
*
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
* The no-op API key resolution port always returns {@code ABSENT}.
+ * The no-op provider technical test service uses the no-op ports above.
+ * The no-op path check port always returns {@code false} for all checks.
+ * The no-op technical test orchestrator returns a report where all checkpoints are
+ * {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
+ * The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
+ * returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
* This is safe for environments where no Bootstrap wiring is present, such as isolated
* GUI tests.
*
@@ -66,15 +101,62 @@ public record GuiStartupContext(
* @return a startup context for the unloaded editor start
*/
public static GuiStartupContext blank(Optional
+ * Dieser Koordinator ist verantwortlich für:
+ *
+ * Threading-Kontrakt: {@link #triggerTechnicalTests()} darf nur auf dem
+ * JavaFX Application Thread aufgerufen werden. Hintergrund-Worker-Threads dürfen nur über
+ * den injizierten {@code resultDelivery}-Verbraucher mit der UI interagieren, der in der
+ * Produktion {@code Platform.runLater} kapselt.
+ *
+ * Kein implizites Speichern: Der Koordinator liest den aktuellen GUI-Zustand
+ * und führt den Test aus, ohne die Konfigurationsdatei zu schreiben oder den Dirty-Zustand
+ * des Editors zu ändern.
+ *
+ * Anti-Scope: Dieser Koordinator führt keine schreibenden Korrekturen durch.
+ * Korrekturvorschläge werden als Bestandteil des {@link TechnicalTestReport} zurückgegeben,
+ * sind aber nicht ausführbar. Die Ausführung ist einem späteren Arbeitsschritt vorbehalten.
+ *
+ * Die Worker-Thread-Factory und die Result-Delivery-Funktion sind injizierbar, damit Tests
+ * deterministisch ohne echten Hintergrund-Thread laufen können.
+ */
+public final class GuiTechnicalTestCoordinator {
+
+ /** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
+ static final String SOURCE_TAG = "Technische-Tests";
+
+ private static final Logger LOG = LogManager.getLogger(GuiTechnicalTestCoordinator.class);
+
+ private final TechnicalTestOrchestrator orchestrator;
+ private final Supplier
+ * Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
+ * {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
+ * einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
+ * JavaFX Application Thread zurückgegeben.
+ *
+ * Der Konfigurationsdateipfad wird genutzt, um bei fehlender Prompt-Datei-Konfiguration
+ * einen sinnvollen Standardpfad ({@code
+ * Muss auf dem JavaFX Application Thread aufgerufen werden.
+ */
+ public void triggerTechnicalTests() {
+ EditorValidationInput input = inputProvider.get();
+ String configFilePath = configFilePathProvider.get();
+ TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
+
+ LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
+
+ Runnable task = () -> {
+ TechnicalTestReport report = orchestrator.run(request);
+ resultDelivery.accept(() -> {
+ applyResult(report);
+ postResultCallback.accept(report);
+ });
+ };
+
+ Thread worker = testThreadFactory.apply(task);
+ worker.start();
+ }
+
+ /**
+ * Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
+ *
+ * Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jeden
+ * Checkpoint-Ergebnis einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
+ * angehängt.
+ *
+ * Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
+ *
+ * @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
+ */
+ private void applyResult(TechnicalTestReport report) {
+ // Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
+ pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
+
+ long successCount = 0;
+ long failureErrorCount = 0;
+ long failureWarnCount = 0;
+ long notApplicableCount = 0;
+
+ for (CheckpointResult result : report.results()) {
+ String label = labelFor(result.checkpointId());
+ switch (result) {
+ case CheckpointResult.Success success -> {
+ pendingMessages.add(GuiMessageEntry.of(
+ GuiMessageSeverity.INFO,
+ label + ": OK – " + success.message(),
+ SOURCE_TAG));
+ successCount++;
+ LOG.info("GUI-Gesamttest: {} → OK", label);
+ }
+ case CheckpointResult.Failure failure -> {
+ GuiMessageSeverity severity = failure.severity() ==
+ de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity.ERROR
+ ? GuiMessageSeverity.ERROR : GuiMessageSeverity.WARNING;
+ pendingMessages.add(GuiMessageEntry.of(
+ severity,
+ label + ": " + failure.message(),
+ SOURCE_TAG));
+ if (severity == GuiMessageSeverity.ERROR) {
+ failureErrorCount++;
+ LOG.warn("GUI-Gesamttest: {} → FEHLER: {}", label, failure.message());
+ } else {
+ failureWarnCount++;
+ LOG.warn("GUI-Gesamttest: {} → WARNUNG: {}", label, failure.message());
+ }
+ }
+ case CheckpointResult.NotApplicable notApplicable -> {
+ pendingMessages.add(GuiMessageEntry.of(
+ GuiMessageSeverity.HINT,
+ label + ": nicht anwendbar – " + notApplicable.reason(),
+ SOURCE_TAG));
+ notApplicableCount++;
+ LOG.info("GUI-Gesamttest: {} → nicht anwendbar: {}", label, notApplicable.reason());
+ }
+ }
+ }
+
+ // Zusammenfassung
+ long totalFindings = failureErrorCount + failureWarnCount;
+ String summary = buildSummaryMessage(report.results().size(),
+ successCount, failureErrorCount, failureWarnCount, notApplicableCount);
+ pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
+ LOG.info("GUI-Gesamttest abgeschlossen. {} Befunde ({} Erfolg, {} Fehler, {} Warnung, {} nicht anwendbar).",
+ totalFindings, successCount, failureErrorCount, failureWarnCount, notApplicableCount);
+ }
+
+ /**
+ * Gibt das deutsche Label für einen Prüfpunkt zurück.
+ *
+ * @param id der Prüfpunkt-Bezeichner; darf nicht {@code null} sein
+ * @return das deutsche Label; nie {@code null}
+ */
+ static String labelFor(CheckpointId id) {
+ return switch (id) {
+ case CONFIGURATION_BASIC_VALIDATION -> "Konfiguration grundsätzlich validierbar";
+ case PROVIDER_CONFIGURATION -> "Provider-Konfiguration prüfbar";
+ case BASE_URL_REACHABLE -> "Basis-URL/Endpoint erreichbar";
+ case API_KEY_PRESENT -> "API-Schlüssel vorhanden";
+ case API_KEY_ACCEPTED -> "API-Schlüssel technisch akzeptiert";
+ case MODEL_LIST_AVAILABLE -> "Modellliste abrufbar";
+ case SELECTED_MODEL_PLAUSIBLE -> "Ausgewähltes Modell plausibel";
+ case PROMPT_FILE_PRESENT -> "Prompt-Datei vorhanden und lesbar";
+ case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
+ case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
+ case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
+ };
+ }
+
+ /**
+ * Baut die deutsche Zusammenfassungsmeldung des Gesamttests.
+ *
+ * @param total Gesamtzahl der Prüfpunkte
+ * @param successCount Anzahl der erfolgreichen Prüfpunkte
+ * @param errorCount Anzahl der fehlgeschlagenen Prüfpunkte (Schweregrad ERROR)
+ * @param warningCount Anzahl der Warnungs-Prüfpunkte
+ * @param notApplicable Anzahl der nicht-anwendbaren Prüfpunkte
+ * @return deutsche Zusammenfassungsmeldung; nie {@code null}
+ */
+ private static String buildSummaryMessage(long total, long successCount, long errorCount,
+ long warningCount, long notApplicable) {
+ long findings = errorCount + warningCount;
+ String base = "Gesamttest abgeschlossen. " + total + " Prüfpunkte: "
+ + successCount + " Erfolg, "
+ + errorCount + " Fehler, "
+ + warningCount + " Warnung, "
+ + notApplicable + " nicht anwendbar.";
+ if (findings == 0) {
+ return base + " Keine Befunde.";
+ }
+ return base;
+ }
+
+ /**
+ * Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
+ *
+ * Ausschließlich für Tests gedacht.
+ *
+ * @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
+ */
+ public List
+ * Bevor schreibende Korrekturen aus einem {@link CorrectionPlan} ausgeführt werden,
+ * zeigt die GUI diesen Inhalt in einem einmaligen Bestätigungsdialog. Der Benutzer
+ * kann die Korrekturen bestätigen oder ablehnen; ohne Bestätigung werden keine
+ * Änderungen vorgenommen.
+ *
+ * Dieser Record liegt bewusst im GUI-Modul, da er ausschließlich für die
+ * Darstellung im Bestätigungsdialog der JavaFX-Oberfläche genutzt wird. Er enthält
+ * selbst keine JavaFX-Typen und kann auf beliebigen Threads erzeugt werden.
+ *
+ * Die Beschreibungszeilen ({@link #correctionLines}) entsprechen den
+ * {@link CorrectionSuggestion#descriptionForUser()}-Texten der im Plan enthaltenen
+ * Vorschläge in Reihenfolge.
+ *
+ * @param title deutscher Dialogtitel; nie {@code null}
+ * @param introText einleitender deutschsprachiger Text; nie {@code null}
+ * @param correctionLines Liste der deutschen Beschreibungszeilen, eine pro Korrekturmaßnahme;
+ * nie {@code null}
+ */
+public record ConfirmationDialogContent(
+ String title,
+ String introText,
+ List
+ * Die Beschreibungszeilen werden aus den
+ * {@link CorrectionSuggestion#descriptionForUser()}-Texten der Vorschläge im Plan
+ * in Reihenfolge übernommen.
+ *
+ * @param plan Korrekturplan; darf nicht {@code null} sein
+ * @return ein neuer Dialoginhalt; nie {@code null}
+ * @throws NullPointerException wenn {@code plan} {@code null} ist
+ */
+ public static ConfirmationDialogContent fromPlan(CorrectionPlan plan) {
+ Objects.requireNonNull(plan, "plan must not be null");
+ List
* Most classes in this package are intentionally free of JavaFX controls so they can be used
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
index 844c3cf..d17f78c 100644
--- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java
@@ -397,7 +397,33 @@ class GuiAdapterSmokeTest {
configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(),
testWriter,
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"),
- (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent());
+ (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
+ req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
+ (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ },
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
+ new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ },
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
+ req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
+ (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ }));
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(context);
workspaceRef.set(workspace);
diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinatorSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinatorSmokeTest.java
new file mode 100644
index 0000000..64d4255
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinatorSmokeTest.java
@@ -0,0 +1,318 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
+import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
+import javafx.application.Platform;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Monocle-basierte headless Smoke-Tests für {@link GuiCorrectionDialogCoordinator}.
+ *
+ * Prüft folgende Szenarien:
+ *
+ * Der {@code correctionThreadFactory} wird auf synchrone Ausführung umgestellt und
+ * {@code resultDelivery} auf direkten Aufruf, damit Ergebnisse sofort nach
+ * {@code offerCorrections()} verfügbar sind.
+ */
+class GuiCorrectionDialogCoordinatorSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Shared platform – do not call Platform.exit().
+ }
+
+ // =========================================================================
+ // Scenario: report with correctable findings → dialog shown, corrections applied
+ // =========================================================================
+
+ /**
+ * Smoke-Test: Bei korrigierbaren Befunden und Dialog-Bestätigung werden Korrekturen
+ * ausgeführt und als Meldungen eingehängt.
+ */
+ @Test
+ void offerCorrections_withCorrectableFindings_dialogConfirmed_appliesCorrectionAndAddsMessages()
+ throws Exception {
+ runOnFx(() -> {
+ List
+ * Thread-Factory läuft inline; resultDelivery ist direkter Aufruf; dialogSupplier
+ * gibt den festen {@code confirm}-Wert zurück.
+ */
+ private static GuiCorrectionDialogCoordinator buildSyncCoordinator(
+ CorrectionExecutionService service,
+ List
+ * Verifies the following scenarios:
+ *
+ * The coordinator's {@code testThreadFactory} is overridden to run the task inline
+ * (on the calling thread) and {@code resultDelivery} is overridden with a direct
+ * synchronous call, so results are available immediately after
+ * {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()} returns.
+ */
+class GuiTechnicalTestCoordinatorSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Shared platform – do not call Platform.exit().
+ }
+
+ // =========================================================================
+ // Scenario: button is findable by CSS ID
+ // =========================================================================
+
+ /**
+ * Smoke test: after constructing a workspace, the "Technische Tests ausführen" button
+ * exists and carries the CSS ID {@code technical-tests-button}.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void technicalTestsButton_hasCssId() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws =
+ new GuiConfigurationEditorWorkspace(Optional.empty());
+ Button btn = ws.technicalTestsButton;
+ assertNotNull(btn, "technicalTestsButton must not be null");
+ assertEquals("technical-tests-button", btn.getId(),
+ "technicalTestsButton must carry CSS ID 'technical-tests-button'");
+ });
+ }
+
+ // =========================================================================
+ // Scenario: triggering populates pendingMessages with SOURCE_TAG entries
+ // =========================================================================
+
+ /**
+ * Smoke test: triggering the coordinator synchronously populates
+ * {@code pendingMessages} with at least one entry tagged with the source
+ * {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void trigger_populatesPendingMessagesWithSourceTag() throws Exception {
+ runOnFx(() -> {
+ List The test wires the coordinator with a mutable {@link AtomicReference} as input supplier.
+ * Before the first trigger the supplier returns valid input; before the second trigger the
+ * supplier is updated to return input with an empty active-provider (which causes failures).
+ * After the second trigger the message list must contain ERROR entries, proving that the
+ * coordinator forwarded the new, unsaved input value rather than the previous one.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void trigger_readsCurrentInputFromSupplier_unsavedChangesAreReflected() throws Exception {
+ runOnFx(() -> {
+ List
+ * Verifies that clicking the "Validieren" button in the "Tests" section executes
+ * the in-memory validation against the current editor state, reports the findings
+ * via the central message area, and neither writes any file nor triggers remote calls.
+ *
+ * The test loads the standard template (which has a valid active-provider value and
+ * produces no errors), then directly overwrites the in-memory editor state with an
+ * equivalent state whose active-provider value is cleared. This mimics a user editing a
+ * field without saving. A subsequent click on "Validieren" must produce ERROR findings
+ * that originate from the unsaved change — proving that validation is always based on the
+ * current editor content.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void validateAction_withUnsavedDirtyChange_validatesCurrentState() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = buildWorkspace();
+
+ // Load the template so the baseline is valid (no errors expected initially).
+ ws.requestNewConfiguration();
+
+ // Confirm: template produces no errors.
+ ws.validateButton.fire();
+ assertFalse(ws.lastValidationResult().hasErrors(),
+ "Standard template must produce no errors before the dirty change");
+
+ // Simulate an unsaved edit: replace the editor state with a version that has
+ // an empty active-provider value. The baseline remains the template values,
+ // so isDirty() returns true. The workspace field is package-private.
+ GuiConfigurationEditorState currentState = ws.editorState;
+ GuiConfigurationEditorState dirtyState = currentState.withValues(
+ currentState.values().withActiveProviderFamily(""));
+ ws.editorState = dirtyState;
+
+ // Confirm the editor is now dirty (sanity check).
+ assertTrue(ws.editorState.isDirty(),
+ "Editor state must be dirty after the unsaved change");
+
+ // Click "Validieren" again — must validate the dirty (unsaved) state.
+ ws.validateButton.fire();
+
+ assertTrue(ws.lastValidationResult().hasErrors(),
+ "Validieren must produce ERROR findings reflecting the unsaved dirty state, "
+ + "not the previously clean baseline state");
+ });
+ }
+
+ // =========================================================================
+ // Scenario: clicking twice → message appears exactly once (replace semantics)
+ // =========================================================================
+
+ /**
+ * Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
+ * INFO message in the message list (the second click replaces the first).
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void validateAction_clickedTwice_infoMessageAppearsExactlyOnce() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = buildWorkspace();
+
+ ws.validateButton.fire();
+ ws.validateButton.fire();
+
+ long count = ws.pendingMessages.stream()
+ .filter(m -> m.source().isPresent()
+ && ACTION_SOURCE.equals(m.source().get()))
+ .count();
+ assertEquals(1, count,
+ "After two clicks the action-confirmation INFO message must appear exactly once");
+ });
+ }
+
+ // =========================================================================
+ // Scenario: clicking Validieren does not trigger a file write
+ // =========================================================================
+
+ /**
+ * Smoke test: clicking "Validieren" must not invoke the configuration file writer.
+ * The writer stub records whether it was called; it must remain uncalled after
+ * the button is fired.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void validateAction_doesNotTriggerFileWrite() throws Exception {
+ runOnFx(() -> {
+ AtomicBoolean writerCalled = new AtomicBoolean(false);
+
+ GuiConfigurationEditorState blankState =
+ GuiConfigurationEditorStateFactory.createBlankStartState();
+
+ GuiStartupContext ctx = new GuiStartupContext(
+ blankState,
+ Optional.empty(),
+ path -> blankState, // no-op loader
+ (values, path) -> {
+ writerCalled.set(true);
+ return GuiConfigurationSaveResult.saved(path);
+ },
+ req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
+ .ModelCatalogResult.IncompleteConfiguration(
+ req.providerIdentifier(), "kein Port im Test"),
+ (family, propertyValue) ->
+ de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
+ .EffectiveApiKeyDescriptor.absent(),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
+ req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
+ (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ },
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
+ new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ },
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
+ req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
+ (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ }));
+
+ GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
+ ws.validateButton.fire();
+
+ assertFalse(writerCalled.get(),
+ "Clicking Validieren must not invoke the file writer");
+ });
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ /**
+ * Builds a workspace with no-op loader/writer and absent API-key resolution,
+ * suitable for in-memory validation tests.
+ */
+ private static GuiConfigurationEditorWorkspace buildWorkspace() {
+ GuiConfigurationEditorState blankState =
+ GuiConfigurationEditorStateFactory.createBlankStartState();
+
+ GuiStartupContext ctx = new GuiStartupContext(
+ blankState,
+ Optional.empty(),
+ path -> blankState,
+ (values, path) -> GuiConfigurationSaveResult.saved(path),
+ req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
+ .ModelCatalogResult.IncompleteConfiguration(
+ req.providerIdentifier(), "kein Port im Test"),
+ (family, propertyValue) ->
+ de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
+ .EffectiveApiKeyDescriptor.absent(),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
+ req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
+ (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ },
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
+ new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ },
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
+ req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
+ (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
+ new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
+ }));
+
+ return new GuiConfigurationEditorWorkspace(ctx);
+ }
+
+ private static void runOnFx(ThrowingRunnable task) throws Exception {
+ AtomicReference
+ * Prüft die Zugänglichkeit von Pfaden für Quellordner, Zielordner, SQLite-Datei
+ * und Prompt-Datei ausschließlich lesend. Es werden keinerlei Dateien, Ordner oder
+ * andere Ressourcen angelegt, verändert oder gelöscht.
+ *
+ *
+ * Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
+ * akzeptiert. Solche Pfade werden nicht allein deshalb abgelehnt, weil dahinter technisch
+ * ein UNC-Pfad stehen könnte. Maßgeblich ist, dass Windows den Pfad als gültig bereitstellt.
+ * UNC-Pfade ({@code \\server\share\...}) werden ebenfalls akzeptiert, sofern das
+ * Betriebssystem sie direkt auflösen kann. Es findet keine Umdeutung zwischen gemappten
+ * Laufwerksbuchstaben und UNC-Pfaden statt.
+ *
+ * Die Implementierung nutzt {@link Paths#get(String)}, {@link Files#exists(Path, java.nio.file.LinkOption...)},
+ * {@link Files#isReadable(Path)} und {@link Files#isWritable(Path)}, die unter Windows
+ * gemappte Laufwerke korrekt respektieren.
+ *
+ *
+ * Diese Klasse ist zustandslos und damit thread-safe. Jede Methode kann gleichzeitig
+ * von mehreren Threads aufgerufen werden. Der Aufrufer ist dafür verantwortlich, die
+ * Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
+ * blockierend sein kann.
+ *
+ *
+ * Erwartete Fehlerbedingungen (Pfad nicht vorhanden, keine Leseberechtigung) werden
+ * als {@code boolean}-Rückgabewert kommuniziert. Unerwartete technische Fehler werden
+ * geloggt und als {@code false} zurückgegeben.
+ */
+public class FilesystemPathCheckAdapter implements PathCheckPort {
+
+ private static final Logger LOG = LogManager.getLogger(FilesystemPathCheckAdapter.class);
+
+ /**
+ * Erstellt einen neuen {@code FilesystemPathCheckAdapter}.
+ */
+ public FilesystemPathCheckAdapter() {
+ // stateless — kein Zustand zu initialisieren
+ }
+
+ /**
+ * Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
+ *
+ * Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
+ * kein Verzeichnis oder nicht lesbar ist.
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn der Ordner existiert und gelesen werden kann
+ */
+ @Override
+ public boolean isDirectoryReadable(String path) {
+ LOG.debug("Prüfe Ordner auf Lesbarkeit: {}", path);
+ Path resolved = toPath(path);
+ if (resolved == null) {
+ LOG.warn("Ordner-Lesbarkeit: ungültiger Pfad: {}", path);
+ return false;
+ }
+ boolean result = Files.exists(resolved)
+ && Files.isDirectory(resolved)
+ && Files.isReadable(resolved);
+ if (result) {
+ LOG.debug("Ordner lesbar: {}", resolved);
+ } else {
+ LOG.warn("Ordner nicht lesbar oder nicht vorhanden: {}", resolved);
+ }
+ return result;
+ }
+
+ /**
+ * Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
+ * oder ob dieser Ordner technisch anlegbar wäre.
+ *
+ * Gibt {@code true} zurück, wenn:
+ *
+ * Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
+ * kein reguläres File oder nicht lesbar ist.
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn die Datei existiert und gelesen werden kann
+ */
+ @Override
+ public boolean isFileReadable(String path) {
+ LOG.debug("Prüfe Datei auf Lesbarkeit: {}", path);
+ Path resolved = toPath(path);
+ if (resolved == null) {
+ LOG.warn("Datei-Lesbarkeit: ungültiger Pfad: {}", path);
+ return false;
+ }
+ boolean result = Files.exists(resolved)
+ && Files.isRegularFile(resolved)
+ && Files.isReadable(resolved);
+ if (result) {
+ LOG.debug("Datei lesbar: {}", resolved);
+ } else {
+ LOG.warn("Datei nicht lesbar oder nicht vorhanden: {}", resolved);
+ }
+ return result;
+ }
+
+ /**
+ * Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
+ *
+ * Gibt {@code true} zurück, wenn:
+ *
+ * Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist
+ * (z. B. wegen ungültiger Zeichen auf Windows). Keine Ausnahme wird geworfen.
+ *
+ * @param path der zu konvertierende Pfad-String
+ * @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
+ */
+ private static Path toPath(String path) {
+ if (path == null || path.isBlank()) {
+ return null;
+ }
+ try {
+ return Paths.get(path);
+ } catch (InvalidPathException e) {
+ LOG.warn("Pfad nicht parsebar: '{}' — {}", path, e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/package-info.java
new file mode 100644
index 0000000..67f4d1e
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * Adapter für Dateisystem-basierte Pfadprüfungen.
+ *
+ * Dieses Paket enthält die konkrete Implementierung des {@code PathCheckPort} auf Basis
+ * der JDK-NIO-Dateisystem-API. Es unterstützt ausdrücklich Windows-Pfade mit gemappten
+ * Laufwerksbuchstaben (z. B. {@code S:\}, {@code H:\}) sowie UNC-Pfade.
+ *
+ * Alle Klassen in diesem Paket sind rein lesend und nehmen keinerlei schreibende
+ * Änderungen am Dateisystem vor.
+ */
+package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java
new file mode 100644
index 0000000..67fbbb5
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java
@@ -0,0 +1,213 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
+import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
+
+/**
+ * Dateisystem-basierte Implementierung von {@link ResourceCreationPort}.
+ *
+ * Führt schreibende technische Korrekturmaßnahmen durch: Ordner anlegen,
+ * SQLite-Elternordner vorbereiten und Prompt-Dateien mit übergebenem Inhalt erzeugen.
+ * Alle Methoden sind idempotent, sofern die Ziel-Ressource bereits vorhanden ist.
+ *
+ *
+ * Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
+ * akzeptiert. Die Implementierung nutzt ausschließlich {@link Paths#get(String)} und
+ * {@link Files}-Methoden, die unter Windows gemappte Laufwerke korrekt respektieren.
+ *
+ *
+ * Diese Klasse ist zustandslos und thread-safe. Der Aufrufer ist verantwortlich dafür,
+ * Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
+ * blockierend sein kann.
+ *
+ *
+ * Jede Methode fängt alle technischen Ausnahmen und gibt ein entsprechendes
+ * {@link CorrectionOutcome.Failed}-Ergebnis zurück. Es werden keine geprüften
+ * Ausnahmen an den Aufrufer weitergegeben.
+ */
+public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
+
+ private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
+
+ /**
+ * Erstellt einen neuen {@code FilesystemResourceCreationAdapter}.
+ */
+ public FilesystemResourceCreationAdapter() {
+ // zustandslos — kein Zustand zu initialisieren
+ }
+
+ /**
+ * Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
+ *
+ * Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} zurückgegeben
+ * (idempotente Ausführung). Die Aktion wird mit Zielpfad geloggt.
+ *
+ * @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
+ * @return Ergebnis der Ausführung; nie {@code null}
+ */
+ @Override
+ public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
+ Path path = toPath(suggestion.path());
+ if (path == null) {
+ String msg = "Ungültiger Pfad: " + suggestion.path();
+ LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+
+ try {
+ if (Files.exists(path)) {
+ if (Files.isDirectory(path)) {
+ LOG.info("Ordner bereits vorhanden (kein Anlegen nötig): {}", path);
+ return new CorrectionOutcome.Applied(suggestion,
+ "Ordner bereits vorhanden: " + path.toAbsolutePath());
+ } else {
+ String msg = "Pfad existiert bereits als Datei (kein Ordner): " + path.toAbsolutePath();
+ LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+ }
+ Files.createDirectories(path);
+ LOG.info("Ordner erfolgreich angelegt: {}", path.toAbsolutePath());
+ return new CorrectionOutcome.Applied(suggestion,
+ "Ordner angelegt: " + path.toAbsolutePath());
+ } catch (IOException e) {
+ String msg = "Ordner konnte nicht angelegt werden: " + e.getMessage();
+ LOG.warn("Ordner anlegen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+ }
+
+ /**
+ * Erzeugt eine neue Prompt-Datei mit dem übergebenen Inhalt.
+ *
+ * Die Datei wird nur erzeugt, wenn sie noch nicht existiert. Falls die Datei bereits
+ * vorhanden ist, wird {@link CorrectionOutcome.NotAttempted} zurückgegeben (kein
+ * stilles Überschreiben). Der Inhalt wird als UTF-8-Text geschrieben.
+ * Die Aktion wird mit Zielpfad geloggt.
+ *
+ * Der Inhalt der erzeugten Datei wird von {@link DefaultPromptTemplate#defaultContent()} geliefert.
+ * Es handelt sich um einen deutschen Standardprompt, der ohne weitere Anpassung funktioniert.
+ *
+ * @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
+ * @return Ergebnis der Ausführung; nie {@code null}
+ */
+ @Override
+ public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
+ Path path = toPath(suggestion.path());
+ if (path == null) {
+ String msg = "Ungültiger Pfad: " + suggestion.path();
+ LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+
+ try {
+ if (Files.exists(path)) {
+ String msg = "Prompt-Datei bereits vorhanden – kein Überschreiben: " + path.toAbsolutePath();
+ LOG.info("Prompt-Datei erzeugen: Datei bereits vorhanden, wird nicht überschrieben: {}", path);
+ return new CorrectionOutcome.NotAttempted(suggestion, msg);
+ }
+
+ // Elternordner sicherstellen
+ Path parent = path.getParent();
+ if (parent != null && !Files.exists(parent)) {
+ Files.createDirectories(parent);
+ LOG.info("Prompt-Datei: Elternordner angelegt: {}", parent);
+ }
+
+ Files.writeString(path, DefaultPromptTemplate.defaultContent(), StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
+ LOG.info("Prompt-Datei erfolgreich erzeugt: {}", path.toAbsolutePath());
+ return new CorrectionOutcome.Applied(suggestion,
+ "Prompt-Datei erzeugt: " + path.toAbsolutePath());
+ } catch (FileAlreadyExistsException e) {
+ String msg = "Prompt-Datei bereits vorhanden – kein Überschreiben: " + path.toAbsolutePath();
+ LOG.info("Prompt-Datei erzeugen: race condition – Datei bereits vorhanden: {}", path);
+ return new CorrectionOutcome.NotAttempted(suggestion, msg);
+ } catch (IOException e) {
+ String msg = "Prompt-Datei konnte nicht erzeugt werden: " + e.getMessage();
+ LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+ }
+
+ /**
+ * Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser fehlt.
+ *
+ * Legt den Elternordner der SQLite-Datei mit allen fehlenden Zwischenordnern an,
+ * falls er noch nicht vorhanden ist. Die SQLite-Datei selbst wird nicht erzeugt;
+ * das übernimmt das JDBC-Layer beim ersten Datenbankzugriff. Die Aktion wird geloggt.
+ *
+ * @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
+ * @return Ergebnis der Ausführung; nie {@code null}
+ */
+ @Override
+ public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
+ Path path = toPath(suggestion.path());
+ if (path == null) {
+ String msg = "Ungültiger Pfad: " + suggestion.path();
+ LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+
+ Path parent = path.getParent();
+ if (parent == null) {
+ // Datei liegt direkt im Wurzelverzeichnis — kein Elternordner anlegbar
+ LOG.info("SQLite-Pfad: kein Elternordner vorhanden (Wurzelpfad): {}", path);
+ return new CorrectionOutcome.Applied(suggestion,
+ "SQLite-Pfad liegt im Wurzelverzeichnis, kein Ordner anzulegen: " + path.toAbsolutePath());
+ }
+
+ try {
+ if (Files.exists(parent)) {
+ LOG.info("SQLite-Elternordner bereits vorhanden: {}", parent);
+ return new CorrectionOutcome.Applied(suggestion,
+ "SQLite-Elternordner bereits vorhanden: " + parent.toAbsolutePath());
+ }
+ Files.createDirectories(parent);
+ LOG.info("SQLite-Elternordner erfolgreich angelegt: {}", parent.toAbsolutePath());
+ return new CorrectionOutcome.Applied(suggestion,
+ "SQLite-Elternordner angelegt: " + parent.toAbsolutePath());
+ } catch (IOException e) {
+ String msg = "SQLite-Elternordner konnte nicht angelegt werden: " + e.getMessage();
+ LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {} — {}", parent, e.getMessage(), e);
+ return new CorrectionOutcome.Failed(suggestion, msg);
+ }
+ }
+
+ /**
+ * Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
+ *
+ * Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist.
+ *
+ * @param pathString der zu konvertierende Pfad-String
+ * @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
+ */
+ private static Path toPath(String pathString) {
+ if (pathString == null || pathString.isBlank()) {
+ return null;
+ }
+ try {
+ return Paths.get(pathString);
+ } catch (InvalidPathException e) {
+ LOG.warn("Pfad nicht parsebar: '{}' — {}", pathString, e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/package-info.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/package-info.java
new file mode 100644
index 0000000..801b6a0
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Adapter für schreibende technische Korrekturmaßnahmen am Dateisystem.
+ *
+ * Implementiert den {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort}
+ * über direkten Dateisystemzugriff. Alle Operationen sind schreibend und dürfen nur nach
+ * ausdrücklicher Benutzerbestätigung eines
+ * {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan} aufgerufen werden.
+ */
+package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapterTest.java
new file mode 100644
index 0000000..fa8cc18
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapterTest.java
@@ -0,0 +1,237 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Unit-Tests für {@link FilesystemPathCheckAdapter}.
+ *
+ * Prüft alle vier Methoden des Ports unter realen Dateisystem-Bedingungen mit
+ * {@link TempDir}. Windows-spezifische Tests werden auf Nicht-Windows-Systemen
+ * automatisch übersprungen.
+ */
+class FilesystemPathCheckAdapterTest {
+
+ @TempDir
+ Path tempDir;
+
+ private FilesystemPathCheckAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ adapter = new FilesystemPathCheckAdapter();
+ }
+
+ // -----------------------------------------------------------------------
+ // isDirectoryReadable
+ // -----------------------------------------------------------------------
+
+ @Test
+ void isDirectoryReadable_existingReadableDirectory_returnsTrue() {
+ assertTrue(adapter.isDirectoryReadable(tempDir.toString()));
+ }
+
+ @Test
+ void isDirectoryReadable_nonExistentPath_returnsFalse() {
+ Path absent = tempDir.resolve("does-not-exist");
+ assertFalse(adapter.isDirectoryReadable(absent.toString()));
+ }
+
+ @Test
+ void isDirectoryReadable_existingFile_returnsFalse() throws IOException {
+ Path file = Files.createFile(tempDir.resolve("some-file.txt"));
+ assertFalse(adapter.isDirectoryReadable(file.toString()));
+ }
+
+ @Test
+ void isDirectoryReadable_emptyString_returnsFalse() {
+ assertFalse(adapter.isDirectoryReadable(""));
+ }
+
+ @Test
+ void isDirectoryReadable_nullValue_returnsFalse() {
+ assertFalse(adapter.isDirectoryReadable(null));
+ }
+
+ @Test
+ @EnabledOnOs(OS.WINDOWS)
+ void isDirectoryReadable_invalidWindowsCharacters_returnsFalse() {
+ // Zeichen wie '<', '>', '?' sind auf Windows in Pfaden unzulässig
+ assertFalse(adapter.isDirectoryReadable("C:\\invalid
+ * Prüft die drei Kernmethoden auf Erfolgs-, Idempotenz- und Fehlerfälle.
+ */
+class FilesystemResourceCreationAdapterTest {
+
+ private final FilesystemResourceCreationAdapter adapter = new FilesystemResourceCreationAdapter();
+
+ // =========================================================================
+ // createDirectory
+ // =========================================================================
+
+ @Test
+ void createDirectory_nonExistent_returnsApplied(@TempDir Path tempDir) {
+ Path newDir = tempDir.resolve("neu");
+ CorrectionSuggestion.CreateDirectory suggestion =
+ new CorrectionSuggestion.CreateDirectory(newDir.toString(), "Zielordner anlegen");
+
+ CorrectionOutcome outcome = adapter.createDirectory(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
+ "Neues Verzeichnis muss Applied zurückgeben");
+ assertTrue(Files.isDirectory(newDir), "Verzeichnis muss nach dem Anlegen existieren");
+ }
+
+ @Test
+ void createDirectory_nestedNonExistent_returnsApplied(@TempDir Path tempDir) {
+ Path nestedDir = tempDir.resolve("a").resolve("b").resolve("c");
+ CorrectionSuggestion.CreateDirectory suggestion =
+ new CorrectionSuggestion.CreateDirectory(nestedDir.toString(), "Tiefer Ordner");
+
+ CorrectionOutcome outcome = adapter.createDirectory(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
+ assertTrue(Files.isDirectory(nestedDir));
+ }
+
+ @Test
+ void createDirectory_alreadyExists_returnsApplied(@TempDir Path tempDir) {
+ // tempDir exists already — should be idempotent
+ CorrectionSuggestion.CreateDirectory suggestion =
+ new CorrectionSuggestion.CreateDirectory(tempDir.toString(), "Ordner vorhanden");
+
+ CorrectionOutcome outcome = adapter.createDirectory(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
+ "Bereits vorhandener Ordner muss Applied zurückgeben (idempotent)");
+ }
+
+ @Test
+ void createDirectory_existingFileAtPath_returnsFailed(@TempDir Path tempDir) throws IOException {
+ Path filePath = tempDir.resolve("existingFile.txt");
+ Files.createFile(filePath);
+ CorrectionSuggestion.CreateDirectory suggestion =
+ new CorrectionSuggestion.CreateDirectory(filePath.toString(), "Datei statt Ordner");
+
+ CorrectionOutcome outcome = adapter.createDirectory(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Failed.class, outcome,
+ "Pfad zeigt auf Datei — muss Failed zurückgeben");
+ }
+
+ // =========================================================================
+ // prepareSqlitePath
+ // =========================================================================
+
+ @Test
+ void prepareSqlitePath_nonExistentParent_createsParentAndReturnsApplied(@TempDir Path tempDir) {
+ Path sqliteFile = tempDir.resolve("data").resolve("db.sqlite");
+ CorrectionSuggestion.PrepareSqlitePath suggestion =
+ new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "SQLite-Pfad vorbereiten");
+
+ CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
+ assertTrue(Files.isDirectory(sqliteFile.getParent()),
+ "Elternordner muss nach prepareSqlitePath existieren");
+ assertFalse(Files.exists(sqliteFile),
+ "SQLite-Datei selbst darf NICHT angelegt werden");
+ }
+
+ @Test
+ void prepareSqlitePath_existingParent_returnsApplied(@TempDir Path tempDir) {
+ // tempDir already exists — parent is tempDir itself
+ Path sqliteFile = tempDir.resolve("existing.sqlite");
+ CorrectionSuggestion.PrepareSqlitePath suggestion =
+ new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "Vorhandener Parent");
+
+ CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
+ "Bereits vorhandener Elternordner muss Applied zurückgeben (idempotent)");
+ assertFalse(Files.exists(sqliteFile),
+ "SQLite-Datei selbst darf NICHT angelegt werden");
+ }
+
+ // =========================================================================
+ // createPromptFile
+ // =========================================================================
+
+ @Test
+ void createPromptFile_nonExistent_createsFileAndReturnsApplied(@TempDir Path tempDir) {
+ Path promptFile = tempDir.resolve("prompt.txt");
+ CorrectionSuggestion.CreatePromptFile suggestion =
+ new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
+
+ CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
+ assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
+ }
+
+ @Test
+ void createPromptFile_alreadyExists_returnsNotAttempted(@TempDir Path tempDir) throws IOException {
+ Path promptFile = tempDir.resolve("existing_prompt.txt");
+ Files.createFile(promptFile);
+ CorrectionSuggestion.CreatePromptFile suggestion =
+ new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Datei vorhanden");
+
+ CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.NotAttempted.class, outcome,
+ "Bereits vorhandene Datei darf nicht überschrieben werden — NotAttempted erwartet");
+ }
+
+ @Test
+ void createPromptFile_nonExistentParent_createsParentAndFile(@TempDir Path tempDir) {
+ Path promptFile = tempDir.resolve("subdir").resolve("prompt.txt");
+ CorrectionSuggestion.CreatePromptFile suggestion =
+ new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt in Unterordner");
+
+ CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
+ assertTrue(Files.exists(promptFile));
+ }
+
+ @Test
+ void createPromptFile_nonExistent_contentMatchesDefaultPromptTemplate(@TempDir Path tempDir) throws IOException {
+ Path promptFile = tempDir.resolve("prompt.txt");
+ CorrectionSuggestion.CreatePromptFile suggestion =
+ new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
+
+ CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
+
+ assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
+ assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
+ String writtenContent = Files.readString(promptFile, StandardCharsets.UTF_8);
+ String expectedContent = DefaultPromptTemplate.defaultContent();
+ // Der geschriebene Inhalt muss dem deutschen Standard-Prompt entsprechen
+ assertTrue(writtenContent.contains("Titel"),
+ "Geschriebener Inhalt muss deutschen Standard-Prompt enthalten");
+ assertTrue(writtenContent.equals(expectedContent),
+ "Geschriebener Inhalt muss exakt DefaultPromptTemplate.defaultContent() entsprechen");
+ }
+
+ // =========================================================================
+ // Ungültige Pfade
+ // =========================================================================
+
+ @Test
+ void createDirectory_blankPath_returnsFailed() {
+ CorrectionSuggestion.CreateDirectory suggestion =
+ new CorrectionSuggestion.CreateDirectory("C:/valid-placeholder", "Dummy");
+
+ // Simulate invalid path behavior by using an adapter that receives an unusual path.
+ // Here we just verify a valid path works — blank path is caught by CorrectionSuggestion constructor.
+ assertNotNull(suggestion);
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointId.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointId.java
new file mode 100644
index 0000000..3d47167
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointId.java
@@ -0,0 +1,88 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+/**
+ * Eindeutiger Bezeichner für jeden definierten Prüfpunkt des technischen Gesamttests.
+ *
+ * Jeder Wert entspricht genau einem Prüfpunkt, der im Rahmen der Aktion
+ * „Technische Tests ausführen" durchlaufen wird. Die Reihenfolge der Konstanten
+ * ist nicht verbindlich für die Ausführungsreihenfolge; sie dient nur der
+ * Übersichtlichkeit.
+ *
+ * Prüfpunkte sind unabhängig voneinander; ein Fehler in einem Prüfpunkt darf
+ * nicht dazu führen, dass spätere Prüfpunkte übersprungen werden. Wenn ein
+ * Prüfpunkt wegen fehlender Voraussetzungen nicht ausführbar ist (z. B.
+ * API-Key-Test ohne bekannte Base-URL), ist das Ergebnis
+ * {@link CheckpointResult.NotApplicable}, kein Fehler.
+ */
+public enum CheckpointId {
+
+ /**
+ * Grundlegende Konfigurationsvalidierung – entspricht der lokalen Editorvalidierung.
+ * Prüft formale Pflichtfelder und Werteformate ohne Dateisystem- oder Netzwerkkontakt.
+ */
+ CONFIGURATION_BASIC_VALIDATION,
+
+ /**
+ * Provider-Konfiguration prüfen: aktiver Provider bekannt, alle Pflichtfelder
+ * des aktiven Providers formal ausgefüllt.
+ */
+ PROVIDER_CONFIGURATION,
+
+ /**
+ * Base-URL bzw. Endpunkt des aktiven Providers technisch erreichbar (Netzwerktest).
+ */
+ BASE_URL_REACHABLE,
+
+ /**
+ * API-Key vorhanden – mindestens eine Quelle (Umgebungsvariable oder Properties-Datei)
+ * liefert einen nicht leeren Wert. Dieser Prüfpunkt trifft keine Aussage über die
+ * Korrektheit des Schlüssels.
+ */
+ API_KEY_PRESENT,
+
+ /**
+ * API-Key technisch akzeptiert – Authentifizierung am Provider-Endpunkt erfolgreich.
+ * Setzt voraus, dass {@link #API_KEY_PRESENT} bestanden wurde; andernfalls ist dieser
+ * Prüfpunkt {@link CheckpointResult.NotApplicable}.
+ */
+ API_KEY_ACCEPTED,
+
+ /**
+ * Modellliste abrufbar – der Provider liefert eine nicht leere Liste verfügbarer Modelle.
+ * Nutzt denselben Outbound-Port wie der automatische Modellabruf; keine zweite Implementierung.
+ */
+ MODEL_LIST_AVAILABLE,
+
+ /**
+ * Ausgewähltes Modell plausibel – der konfigurierte Modellname ist in der zuletzt
+ * geladenen Modellliste vorhanden oder formal zulässig.
+ * Setzt voraus, dass {@link #MODEL_LIST_AVAILABLE} bestanden wurde.
+ */
+ SELECTED_MODEL_PLAUSIBLE,
+
+ /**
+ * Prompt-Datei vorhanden und lesbar – die konfigurierte Prompt-Datei existiert im
+ * Dateisystem und kann gelesen werden.
+ */
+ PROMPT_FILE_PRESENT,
+
+ /**
+ * Quellordner vorhanden und lesbar – der konfigurierte Quellordner existiert und
+ * kann vom Prozess gelesen werden.
+ */
+ SOURCE_FOLDER_PRESENT,
+
+ /**
+ * Zielordner vorhanden oder anlegbar sowie schreibbar – der konfigurierte Zielordner
+ * existiert und ist schreibbar, oder er ist noch nicht vorhanden, aber der Pfad ist
+ * technisch anlegbar.
+ */
+ TARGET_FOLDER_USABLE,
+
+ /**
+ * SQLite-Datei bzw. SQLite-Pfad technisch nutzbar – der konfigurierte SQLite-Pfad
+ * zeigt auf eine vorhandene Datei oder auf einen beschreibbaren Ordner, in dem die
+ * Datei neu angelegt werden kann.
+ */
+ SQLITE_PATH_USABLE
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResult.java
new file mode 100644
index 0000000..2e392b7
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResult.java
@@ -0,0 +1,162 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Versiegeltes Ergebnis eines einzelnen Prüfpunkts des technischen Gesamttests.
+ *
+ * Jeder Prüfpunkt liefert genau einen der drei möglichen Zustände:
+ *
+ * Alle Implementierungen sind immutable und enthalten keine JavaFX-Typen. Sie können
+ * auf beliebigen Threads erzeugt und sicher an den JavaFX Application Thread übergeben werden.
+ */
+public sealed interface CheckpointResult
+ permits CheckpointResult.Success,
+ CheckpointResult.Failure,
+ CheckpointResult.NotApplicable {
+
+ /**
+ * Gibt den Bezeichner des Prüfpunkts zurück, zu dem dieses Ergebnis gehört.
+ *
+ * @return Prüfpunkt-Bezeichner; nie {@code null}
+ */
+ CheckpointId checkpointId();
+
+ /**
+ * Der Prüfpunkt wurde bestanden.
+ *
+ * @param checkpointId Bezeichner des bestandenen Prüfpunkts; nie {@code null}
+ * @param message deutsche Bestätigungsmeldung; nie {@code null}
+ */
+ record Success(
+ CheckpointId checkpointId,
+ String message) implements CheckpointResult {
+
+ /**
+ * Erstellt ein Erfolgs-Ergebnis.
+ *
+ * @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
+ * @param message deutsche Bestätigungsmeldung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code checkpointId} oder {@code message} {@code null} sind
+ */
+ public Success {
+ Objects.requireNonNull(checkpointId, "checkpointId must not be null");
+ Objects.requireNonNull(message, "message must not be null");
+ }
+ }
+
+ /**
+ * Der Prüfpunkt wurde nicht bestanden.
+ *
+ * Ein gescheiterter Prüfpunkt hat immer einen Schweregrad ({@link CheckpointSeverity})
+ * und eine deutsche Fehlermeldung. Optional ist ein {@link CorrectionSuggestion}
+ * beigefügt, wenn eine sichere technische Korrektur möglich ist.
+ *
+ * Ein Failure mit Schweregrad {@link CheckpointSeverity#WARNING} markiert eine
+ * riskante, aber formal zulässige Einstellung. Ein Failure mit
+ * {@link CheckpointSeverity#ERROR} zeigt an, dass der Gesamtstand nicht lauffähig ist.
+ *
+ * @param checkpointId Bezeichner des nicht bestandenen Prüfpunkts; nie {@code null}
+ * @param severity Schweregrad; nie {@code null}
+ * @param message deutsche Fehlermeldung; nie {@code null}
+ * @param correctionSuggestion optionaler Korrekturvorschlag; leer wenn keine Korrektur angeboten wird
+ */
+ record Failure(
+ CheckpointId checkpointId,
+ CheckpointSeverity severity,
+ String message,
+ Optional
+ * Beispiel: Der Prüfpunkt {@link CheckpointId#API_KEY_ACCEPTED} ist nicht ausführbar,
+ * wenn {@link CheckpointId#API_KEY_PRESENT} zuvor als Fehler bewertet wurde.
+ *
+ * {@code NotApplicable} ist kein Fehler; er wird im Meldungsbereich neutral dargestellt
+ * und wird nicht als Korrekturanlass behandelt.
+ *
+ * @param checkpointId Bezeichner des nicht ausgeführten Prüfpunkts; nie {@code null}
+ * @param reason deutsche Begründung, warum der Prüfpunkt übersprungen wurde; nie {@code null}
+ */
+ record NotApplicable(
+ CheckpointId checkpointId,
+ String reason) implements CheckpointResult {
+
+ /**
+ * Erstellt ein Nicht-Anwendbar-Ergebnis.
+ *
+ * @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
+ * @param reason deutsche Begründung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code checkpointId} oder {@code reason} {@code null} sind
+ */
+ public NotApplicable {
+ Objects.requireNonNull(checkpointId, "checkpointId must not be null");
+ Objects.requireNonNull(reason, "reason must not be null");
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointSeverity.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointSeverity.java
new file mode 100644
index 0000000..6c80e70
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointSeverity.java
@@ -0,0 +1,31 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+/**
+ * Schweregrade für gescheiterte Prüfpunkte ({@link CheckpointResult.Failure}) des technischen Gesamttests.
+ *
+ * Die Schweregrade sind analog zu den Stufen der editornahen Validierung
+ * ({@code EditorValidationSeverity}), jedoch auf die Semantik des technischen Gesamttests
+ * zugeschnitten:
+ *
+ * Hinweise und neutrale Informationen werden als {@link CheckpointResult.Success} oder
+ * {@link CheckpointResult.NotApplicable} modelliert, nicht als Failure mit diesem Enum.
+ */
+public enum CheckpointSeverity {
+
+ /**
+ * Riskante, aber technisch zulässige Einstellung.
+ * Speichern und ein späterer headless-Lauf bleiben möglich.
+ */
+ WARNING,
+
+ /**
+ * Ungültige oder fehlende Einstellung – Konfiguration ist im aktuellen Zustand nicht lauffähig.
+ */
+ ERROR
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReport.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReport.java
new file mode 100644
index 0000000..ad3052d
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReport.java
@@ -0,0 +1,68 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Gesamtergebnis der Ausführung eines bestätigten {@link CorrectionPlan}.
+ *
+ * Enthält für jeden im Plan enthaltenen Korrekturvorschlag ein {@link CorrectionOutcome}.
+ * Die Reihenfolge der Ergebnisse entspricht der Reihenfolge der Vorschläge im Plan.
+ *
+ * Dieser Record ist immutable und enthält keine JavaFX-Typen.
+ *
+ * @param outcomes Ergebnisliste in Ausführungsreihenfolge; nie {@code null}
+ */
+public record CorrectionExecutionReport(List
+ * Gibt {@code true} zurück, wenn alle Ergebnisse vom Typ {@link CorrectionOutcome.Applied}
+ * sind und der Bericht mindestens einen Eintrag enthält.
+ *
+ * @return {@code true} wenn mindestens ein Eintrag vorhanden ist und alle angewendet wurden
+ */
+ public boolean allApplied() {
+ return !outcomes.isEmpty()
+ && outcomes.stream().allMatch(o -> o instanceof CorrectionOutcome.Applied);
+ }
+
+ /**
+ * Gibt an, ob mindestens eine Korrektur gescheitert ist.
+ *
+ * @return {@code true} wenn mindestens ein {@link CorrectionOutcome.Failed} vorliegt
+ */
+ public boolean hasFailures() {
+ return outcomes.stream().anyMatch(o -> o instanceof CorrectionOutcome.Failed);
+ }
+
+ /**
+ * Gibt an, ob mindestens eine Korrektur nicht versucht wurde.
+ *
+ * @return {@code true} wenn mindestens ein {@link CorrectionOutcome.NotAttempted} vorliegt
+ */
+ public boolean hasNotAttempted() {
+ return outcomes.stream().anyMatch(o -> o instanceof CorrectionOutcome.NotAttempted);
+ }
+
+ /**
+ * Gibt die Gesamtzahl der Ergebnisse zurück.
+ *
+ * @return Anzahl der Ergebnisse; nie negativ
+ */
+ public int size() {
+ return outcomes.size();
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionService.java
new file mode 100644
index 0000000..e1ab36d
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionService.java
@@ -0,0 +1,86 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Führt einen bestätigten {@link CorrectionPlan} aus, indem er jeden enthaltenen
+ * {@link CorrectionSuggestion}-Vorschlag über den {@link ResourceCreationPort} ausführt.
+ *
+ * Der Service iteriert alle Vorschläge im Plan und gibt pro Vorschlag ein
+ * {@link CorrectionOutcome} an den {@link ResourceCreationPort} weiter. Das Gesamtergebnis
+ * wird als {@link CorrectionExecutionReport} zurückgegeben.
+ *
+ *
+ * Wenn eine Korrektur scheitert, laufen alle weiteren Korrekturen trotzdem weiter.
+ * Ein einzelnes {@link CorrectionOutcome.Failed} führt nicht zum Abbruch.
+ *
+ *
+ * Dieser Service darf nur nach ausdrücklicher Benutzerbestätigung des
+ * {@link CorrectionPlan} aufgerufen werden. Es darf keine stille Ausführung im
+ * Hintergrund geben. Da die Ausführung I/O-intensiv sein kann, sollte der Aufruf
+ * auf einem Hintergrund-Worker-Thread erfolgen.
+ *
+ *
+ * Diese Klasse ist zustandslos und thread-safe, sofern der injizierte
+ * {@link ResourceCreationPort} ebenfalls thread-safe ist.
+ */
+public class CorrectionExecutionService {
+
+ private final ResourceCreationPort port;
+
+ /**
+ * Erstellt einen neuen Ausführungsservice.
+ *
+ * @param port der Port für schreibende Korrekturmaßnahmen; darf nicht {@code null} sein
+ * @throws NullPointerException wenn {@code port} {@code null} ist
+ */
+ public CorrectionExecutionService(ResourceCreationPort port) {
+ this.port = Objects.requireNonNull(port, "port must not be null");
+ }
+
+ /**
+ * Führt alle Korrekturvorschläge im übergebenen Plan aus.
+ *
+ * Iteriert die {@link CorrectionSuggestion}s des Plans und dispatcht jeden Vorschlag
+ * an die passende Methode des {@link ResourceCreationPort}. Alle Ergebnisse werden
+ * gesammelt und als {@link CorrectionExecutionReport} zurückgegeben. Ein Fehler bei
+ * einem Vorschlag führt nicht zum Abbruch der Ausführung der nachfolgenden Vorschläge.
+ *
+ * Wenn der Plan leer ist, wird ein leerer Bericht zurückgegeben.
+ *
+ * @param plan der zu ausführende Korrekturplan; darf nicht {@code null} sein
+ * @return Bericht mit einem {@link CorrectionOutcome} pro Vorschlag; nie {@code null}
+ * @throws NullPointerException wenn {@code plan} {@code null} ist
+ */
+ public CorrectionExecutionReport execute(CorrectionPlan plan) {
+ Objects.requireNonNull(plan, "plan must not be null");
+ List
+ * Nach Benutzerbestätigung eines {@link CorrectionPlan} wird jeder enthaltene Vorschlag
+ * durch den {@link ResourceCreationPort} ausgeführt. Das Ergebnis jeder Ausführung wird
+ * als eines der drei möglichen Zustände modelliert:
+ *
+ * Alle Implementierungen sind immutable und enthalten keine JavaFX-Typen.
+ */
+public sealed interface CorrectionOutcome
+ permits CorrectionOutcome.Applied,
+ CorrectionOutcome.Failed,
+ CorrectionOutcome.NotAttempted {
+
+ /**
+ * Gibt den Korrekturvorschlag zurück, auf den sich dieses Ergebnis bezieht.
+ *
+ * @return Korrekturvorschlag; nie {@code null}
+ */
+ CorrectionSuggestion suggestion();
+
+ /**
+ * Die Korrektur wurde erfolgreich durchgeführt.
+ *
+ * @param suggestion der ausgeführte Korrekturvorschlag; nie {@code null}
+ * @param message deutsche Bestätigungsmeldung; nie {@code null}
+ */
+ record Applied(
+ CorrectionSuggestion suggestion,
+ String message) implements CorrectionOutcome {
+
+ /**
+ * Erstellt ein Erfolgs-Ergebnis.
+ *
+ * @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
+ * @param message Bestätigungsmeldung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ */
+ public Applied {
+ Objects.requireNonNull(suggestion, "suggestion must not be null");
+ Objects.requireNonNull(message, "message must not be null");
+ }
+ }
+
+ /**
+ * Die Korrektur wurde versucht, aber ist technisch gescheitert.
+ *
+ * @param suggestion der nicht erfolgreich ausgeführte Korrekturvorschlag; nie {@code null}
+ * @param errorMessage deutsche Fehlerbeschreibung; nie {@code null}
+ */
+ record Failed(
+ CorrectionSuggestion suggestion,
+ String errorMessage) implements CorrectionOutcome {
+
+ /**
+ * Erstellt ein Fehler-Ergebnis.
+ *
+ * @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
+ * @param errorMessage Fehlerbeschreibung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ */
+ public Failed {
+ Objects.requireNonNull(suggestion, "suggestion must not be null");
+ Objects.requireNonNull(errorMessage, "errorMessage must not be null");
+ }
+ }
+
+ /**
+ * Die Korrektur wurde nicht versucht, obwohl sie im Plan enthalten war.
+ *
+ * Typischer Grund: Eine Voraussetzung war zur Ausführungszeit nicht erfüllt
+ * (z. B. übergeordneter Ordner nicht erreichbar). Dies ist kein technischer Fehler
+ * des Korrekturprozesses selbst, sondern ein Hinweis auf eine unerfüllbare Bedingung.
+ *
+ * @param suggestion der nicht versuchte Korrekturvorschlag; nie {@code null}
+ * @param reason deutsche Begründung; nie {@code null}
+ */
+ record NotAttempted(
+ CorrectionSuggestion suggestion,
+ String reason) implements CorrectionOutcome {
+
+ /**
+ * Erstellt ein Nicht-Versucht-Ergebnis.
+ *
+ * @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
+ * @param reason Begründung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ */
+ public NotAttempted {
+ Objects.requireNonNull(suggestion, "suggestion must not be null");
+ Objects.requireNonNull(reason, "reason must not be null");
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlan.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlan.java
new file mode 100644
index 0000000..965242c
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlan.java
@@ -0,0 +1,62 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Gesammelter Korrekturplan, der alle schreibenden Korrekturmaßnahmen enthält,
+ * die nach Benutzerbestätigung ausgeführt werden sollen.
+ *
+ * Ein Korrekturplan wird aus den {@link CorrectionSuggestion}-Einträgen der
+ * gescheiterten Prüfpunkte eines {@link TechnicalTestReport} abgeleitet. Er wird dem
+ * Benutzer in einem gesammelten Bestätigungsdialog präsentiert, bevor eine schreibende
+ * Maßnahme ausgeführt wird. Ohne ausdrückliche Bestätigung werden keine Korrekturen
+ * vorgenommen.
+ *
+ * Dieser Record ist immutable und enthält keine JavaFX-Typen.
+ *
+ * @param suggestions alle Korrekturvorschläge in Ausführungsreihenfolge; nie {@code null}
+ */
+public record CorrectionPlan(List
+ * Ein leerer Plan zeigt an, dass nach einem Gesamttest keine sicheren technischen
+ * Korrekturen angeboten werden können.
+ *
+ * @return ein leerer Korrekturplan; nie {@code null}
+ */
+ public static CorrectionPlan empty() {
+ return new CorrectionPlan(List.of());
+ }
+
+ /**
+ * Gibt an, ob dieser Plan mindestens einen Korrekturvorschlag enthält.
+ *
+ * @return {@code true} wenn mindestens ein Vorschlag vorhanden ist
+ */
+ public boolean hasCorrections() {
+ return !suggestions.isEmpty();
+ }
+
+ /**
+ * Gibt die Anzahl der enthaltenen Korrekturvorschläge zurück.
+ *
+ * @return Anzahl der Vorschläge; nie negativ
+ */
+ public int size() {
+ return suggestions.size();
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java
new file mode 100644
index 0000000..afcff9e
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java
@@ -0,0 +1,126 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.util.Objects;
+
+/**
+ * Versiegelter Korrekturvorschlag für eine schreibende technische Korrekturmaßnahme.
+ *
+ * Korrekturvorschläge beschreiben was korrigiert werden soll, aber noch nicht
+ * wie. Die konkrete Ausführung übernimmt der {@link ResourceCreationPort}. Ein
+ * Vorschlag wird dem Benutzer vor der Ausführung in einem gesammelten Bestätigungsdialog
+ * angezeigt; ohne Bestätigung wird keine schreibende Änderung vorgenommen.
+ *
+ * Nicht automatisch korrigierbare Probleme (falscher API-Key, unerreichbare Base-URL,
+ * nicht verfügbare Modellliste) werden niemals als {@code CorrectionSuggestion} modelliert.
+ *
+ * Alle Pfade werden als {@code String} übergeben, analog zur Konvention der übrigen
+ * Outbound-Ports dieses Projekts. Der Adapter-Out ist für die Konvertierung in
+ * {@code java.nio.file.Path} zuständig.
+ */
+public sealed interface CorrectionSuggestion
+ permits CorrectionSuggestion.CreateDirectory,
+ CorrectionSuggestion.CreatePromptFile,
+ CorrectionSuggestion.PrepareSqlitePath {
+
+ /**
+ * Gibt eine kurze deutsche Beschreibung der vorgeschlagenen Korrektur zurück,
+ * die dem Benutzer im Bestätigungsdialog angezeigt wird.
+ *
+ * @return deutsche Beschreibung; nie {@code null}
+ */
+ String descriptionForUser();
+
+ /**
+ * Ein fehlender Ordner soll angelegt werden.
+ *
+ * Anwendungsfälle: fehlender Zielordner.
+ * Es werden nur Ordner angelegt, die noch nicht existieren und deren Elternpfad
+ * erreichbar ist.
+ *
+ * @param path Pfad des anzulegenden Ordners als String; nie {@code null}
+ * @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
+ */
+ record CreateDirectory(
+ String path,
+ String descriptionForUser) implements CorrectionSuggestion {
+
+ /**
+ * Erstellt einen Vorschlag zum Anlegen eines Ordners.
+ *
+ * @param path Pfad des Ordners; darf nicht {@code null} oder leer sein
+ * @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ * @throws IllegalArgumentException wenn {@code path} leer ist
+ */
+ public CreateDirectory {
+ Objects.requireNonNull(path, "path must not be null");
+ Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
+ if (path.isBlank()) {
+ throw new IllegalArgumentException("path must not be blank");
+ }
+ }
+ }
+
+ /**
+ * Eine fehlende Prompt-Datei soll mit einem deutschen Standardinhalt erzeugt werden.
+ *
+ * Die Erzeugung erfolgt nur, wenn der Zielpfad beschreibbar ist. Der konkrete
+ * Standardinhalt wird vom {@link ResourceCreationPort} bereitgestellt. Der
+ * Standardpfad liegt im selben Ordner wie die {@code .properties}-Datei.
+ *
+ * @param path Pfad der anzulegenden Prompt-Datei als String; nie {@code null}
+ * @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
+ */
+ record CreatePromptFile(
+ String path,
+ String descriptionForUser) implements CorrectionSuggestion {
+
+ /**
+ * Erstellt einen Vorschlag zum Erzeugen einer deutschen Standard-Prompt-Datei.
+ *
+ * @param path Pfad der Prompt-Datei; darf nicht {@code null} oder leer sein
+ * @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ * @throws IllegalArgumentException wenn {@code path} leer ist
+ */
+ public CreatePromptFile {
+ Objects.requireNonNull(path, "path must not be null");
+ Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
+ if (path.isBlank()) {
+ throw new IllegalArgumentException("path must not be blank");
+ }
+ }
+ }
+
+ /**
+ * Ein fehlender oder noch nicht vorbereiteter SQLite-Pfad soll nutzbar gemacht werden.
+ *
+ * Konkret bedeutet das: Falls die SQLite-Datei noch nicht existiert, aber ihr
+ * übergeordneter Ordner vorhanden oder anlegbar ist, wird der Ordner sichergestellt.
+ * Eine leere SQLite-Datei wird nicht manuell erzeugt; das übernimmt das JDBC-Layer
+ * beim ersten Datenbankzugriff.
+ *
+ * @param path Pfad der SQLite-Datei als String; nie {@code null}
+ * @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
+ */
+ record PrepareSqlitePath(
+ String path,
+ String descriptionForUser) implements CorrectionSuggestion {
+
+ /**
+ * Erstellt einen Vorschlag zur Vorbereitung des SQLite-Pfads.
+ *
+ * @param path Pfad der SQLite-Datei; darf nicht {@code null} oder leer sein
+ * @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn ein Parameter {@code null} ist
+ * @throws IllegalArgumentException wenn {@code path} leer ist
+ */
+ public PrepareSqlitePath {
+ Objects.requireNonNull(path, "path must not be null");
+ Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
+ if (path.isBlank()) {
+ throw new IllegalArgumentException("path must not be blank");
+ }
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java
new file mode 100644
index 0000000..8417db2
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java
@@ -0,0 +1,68 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+/**
+ * Liefert den deutschen Standardinhalt für neu erzeugte Prompt-Dateien.
+ *
+ * Diese Klasse stellt einen brauchbaren Ausgangspunkt für die Prompt-Datei bereit,
+ * der ohne weitere Anpassung funktioniert. Der Inhalt enthält die Anweisung an die KI,
+ * aus einem bereits extrahierten Dokumenttext einen normierten deutschen Dateinamensvorschlag
+ * zu erzeugen.
+ *
+ * Abgrenzung: Diese Klasse enthält ausschließlich den Prompt-Text als
+ * reine Zeichenkette. Kein Dateisystem-I/O, kein Template-Engine, keine Platzhalter
+ * für den Dokumentinhalt (der Dokumenttext wird vom Aufrufer separat angefügt).
+ *
+ * Der gelieferte Inhalt ist ein sinnvoller, funktionsfähiger Standard und nicht für
+ * fachliche Weiterentwicklung oder Versionierung vorgesehen.
+ */
+public final class DefaultPromptTemplate {
+
+ private DefaultPromptTemplate() {
+ // Utility-Klasse – keine Instanziierung
+ }
+
+ /**
+ * Gibt den deutschen Standardinhalt für eine neu erzeugte Prompt-Datei zurück.
+ *
+ * Der zurückgegebene Text enthält:
+ *
+ * Der Text enthält keinen Platzhalter für den Dokumentinhalt. Der Dokumenttext
+ * wird vom {@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer}
+ * separat angehängt.
+ *
+ * @return der deutsche Standard-Prompt-Inhalt; nie {@code null}, nie leer
+ */
+ public static String defaultContent() {
+ return """
+ Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
+ Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
+ einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
+
+ Antworte ausschließlich mit einem validen JSON-Objekt im folgenden Schema:
+ {
+ "date": "YYYY-MM-DD",
+ "title": "Kurztitel auf Deutsch",
+ "reasoning": "Kurze Begründung auf Deutsch"
+ }
+
+ Regeln:
+ - Das Feld "title" ist verpflichtend.
+ - Das Feld "reasoning" ist verpflichtend.
+ - Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
+ - Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
+ - Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
+ - Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
+ - Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
+ - Keine Sonderzeichen außer Leerzeichen im Titel.
+ - Eigennamen bleiben unverändert.
+ - Umlaute und ß sind erlaubt.
+ - Kein Text außerhalb des JSON-Objekts.
+ """;
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/PathCheckPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/PathCheckPort.java
new file mode 100644
index 0000000..dea543c
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/PathCheckPort.java
@@ -0,0 +1,72 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+/**
+ * Outbound-Port für technische Pfad- und Dateisystemprüfungen.
+ *
+ * Dieser Port ist ausschließlich lesend. Er prüft den Zustand von Pfaden,
+ * ohne Dateien, Ordner oder andere Ressourcen anzulegen, zu verändern oder zu löschen.
+ * Schreibende Korrekturen sind über {@link ResourceCreationPort} zu initiieren.
+ *
+ * Pfad-Konvention: Alle Pfade werden als {@code String} übergeben, analog
+ * zur Konvention der übrigen Outbound-Ports dieses Projekts (z. B. {@code TargetFolderPort}).
+ * Der Adapter-Out ist für die Konvertierung in plattformspezifische Pfadobjekte zuständig.
+ *
+ * Windows- und Netzlaufwerke: Implementierungen müssen gemappte
+ * Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} im Windows-Kontext ausdrücklich
+ * akzeptieren. Solche Pfade dürfen nicht allein deshalb abgelehnt werden, weil dahinter
+ * technisch ein UNC-Pfad stehen könnte.
+ *
+ * Fehlerbehandlung: Implementierungen werfen keine geprüften oder
+ * ungeprüften Ausnahmen für erwartete Fehlerbedingungen (Pfad nicht vorhanden,
+ * keine Leseberechtigung). Alle solchen Zustände werden als {@code boolean}-Ergebnis
+ * oder über separate Methoden kommuniziert.
+ */
+public interface PathCheckPort {
+
+ /**
+ * Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn der Ordner existiert und gelesen werden kann
+ */
+ boolean isDirectoryReadable(String path);
+
+ /**
+ * Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
+ * oder ob dieser Ordner technisch anlegbar wäre.
+ *
+ * Gibt {@code true} zurück, wenn:
+ *
+ * Gibt {@code true} zurück, wenn:
+ *
+ * Dieser Service führt genau fünf providerbezogene Prüfpunkte aus:
+ *
+ * Port-Wiederverwendung: Der Service ruft den {@link AiModelCatalogPort}
+ * exakt einmal auf und leitet aus dem Ergebnis alle fünf Prüfpunkte ab. Es findet keine
+ * zweite HTTP-Implementierung statt.
+ *
+ * API-Key-Vorrangregel: Der {@link ApiKeyResolutionPort} wird konsultiert,
+ * damit auch reine Umgebungsvariablen-Setups korrekt als „API-Key vorhanden" bewertet werden.
+ * Nur wenn der Deskriptor {@link ApiKeyOrigin#ABSENT} zurückliefert, gilt der Schlüssel als
+ * fehlend. In diesem Fall werden alle Remote-Prüfpunkte als {@link CheckpointResult.NotApplicable}
+ * markiert, ohne einen HTTP-Aufruf durchzuführen.
+ *
+ * Mapping-Regeln für {@link ModelCatalogResult}-Varianten:
+ *
+ * Threading-Kontrakt: Die Methode {@link #runProviderChecks(EditorValidationInput)}
+ * ist synchron blockierend. Sie darf nicht auf dem JavaFX Application Thread aufgerufen
+ * werden. Der Aufrufer (GUI-Orchestrierung) ist verantwortlich, den Aufruf auf einem
+ * Hintergrund-Worker-Thread auszuführen und die Ergebnisse via {@code Platform.runLater}
+ * in die UI zu überführen. Dieser Service enthält kein {@code Platform.runLater} und
+ * startet keine eigenen Threads.
+ *
+ * Fehlerklasse-Konstanten (TechnicalFailure.errorCategory):
+ * Die Adapter-Out-Implementierungen verwenden stabile Kategorie-Strings. Dieser Service
+ * erkennt folgende Präfixe bzw. Werte (Groß-/Kleinschreibung ignoriert):
+ * {@code AUTHENTICATION_FAILED}, {@code CONNECTION_FAILURE}, {@code ENDPOINT_NOT_FOUND},
+ * {@code SERVER_ERROR}, {@code INVALID_RESPONSE}. Unbekannte Kategorien werden als
+ * allgemeiner technischer Fehler behandelt.
+ */
+public class ProviderTechnicalTestService {
+
+ /** Fehlerkategorie-Konstante für Authentifizierungsfehler (case-insensitive Präfix-Erkennung). */
+ static final String CATEGORY_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED";
+ /** Fehlerkategorie-Konstante für Verbindungsfehler. */
+ static final String CATEGORY_CONNECTION_FAILURE = "CONNECTION_FAILURE";
+ /** Fehlerkategorie-Konstante für nicht gefundenen Endpoint. */
+ static final String CATEGORY_ENDPOINT_NOT_FOUND = "ENDPOINT_NOT_FOUND";
+ /** Fehlerkategorie-Konstante für Serverfehler (5xx). */
+ static final String CATEGORY_SERVER_ERROR = "SERVER_ERROR";
+ /** Fehlerkategorie-Konstante für nicht parsierbare Antworten. */
+ static final String CATEGORY_INVALID_RESPONSE = "INVALID_RESPONSE";
+
+ private static final int DEFAULT_TIMEOUT_SECONDS = 30;
+
+ private final AiModelCatalogPort modelCatalogPort;
+ private final ApiKeyResolutionPort apiKeyResolutionPort;
+
+ /**
+ * Erstellt einen neuen Service mit den erforderlichen Ports.
+ *
+ * @param modelCatalogPort Port für den Modellabruf; darf nicht {@code null} sein
+ * @param apiKeyResolutionPort Port für die API-Key-Herkunftsauflösung; darf nicht {@code null} sein
+ * @throws NullPointerException wenn einer der Parameter {@code null} ist
+ */
+ public ProviderTechnicalTestService(AiModelCatalogPort modelCatalogPort,
+ ApiKeyResolutionPort apiKeyResolutionPort) {
+ this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort, "modelCatalogPort must not be null");
+ this.apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
+ "apiKeyResolutionPort must not be null");
+ }
+
+ /**
+ * Führt alle fünf provider-nahen technischen Prüfpunkte für den aktiven Provider aus.
+ *
+ * Der aktive Provider wird aus {@code input.activeProviderIdentifier()} bestimmt.
+ * Wenn der Bezeichner keiner bekannten Provider-Familie entspricht, werden alle fünf
+ * Prüfpunkte als {@link CheckpointResult.Failure} mit Schweregrad ERROR zurückgegeben.
+ *
+ * Diese Methode blockiert, bis das Ergebnis des Modellabrufs vorliegt oder ein
+ * konfigurierter Timeout abläuft. Sie darf nicht auf dem JavaFX Application Thread
+ * aufgerufen werden.
+ *
+ * @param input aktueller Editorzustand; darf nicht {@code null} sein
+ * @return unveränderliche Liste mit genau fünf {@link CheckpointResult}-Einträgen
+ * (in der Reihenfolge: API_KEY_PRESENT, BASE_URL_REACHABLE, API_KEY_ACCEPTED,
+ * MODEL_LIST_AVAILABLE, SELECTED_MODEL_PLAUSIBLE); nie {@code null}
+ * @throws NullPointerException wenn {@code input} {@code null} ist
+ */
+ public List
+ * Da {@link EditorValidationInput} keinen direkten API-Key-String enthält, sondern
+ * nur einen bereits aufgelösten {@link EffectiveApiKeyDescriptor}, wird der Descriptor
+ * aus dem Editorzustand direkt verwendet. Der Adapter-Out-Seitige Dispatcher erwartet
+ * den Key entweder als ENV-Variable (die er selbst liest) oder als optionalen Wert
+ * im Request. Da die Auflösung beim Service bereits über {@link ApiKeyResolutionPort}
+ * erfolgt ist, wird für den Catalog-Request ein leerer Optional-Wert geliefert –
+ * der Adapter verwendet dann intern seine eigene ENV-Variable-Auflösung.
+ *
+ * @param input aktueller Editorzustand
+ * @param family aktive Provider-Familie
+ * @param apiKeyDesc bereits aufgelöster Herkunftsdeskriptor des API-Schlüssels
+ * @return fertiger Request; nie {@code null}
+ */
+ private ModelCatalogRequest buildCatalogRequest(EditorValidationInput input,
+ AiProviderFamily family,
+ EffectiveApiKeyDescriptor apiKeyDesc) {
+ // EditorValidationInput enthält keinen direkten API-Key-String-Wert, nur den Descriptor.
+ // Für den ModelCatalogRequest übergeben wir einen leeren Optional für den apiKey,
+ // sodass der Adapter seine eigene ENV-Variable-Auflösung durchführt.
+ // Der Adapter liefert dann IncompleteConfiguration, wenn auch er keinen Key findet –
+ // was aber nicht passiert, da wir oben bereits geprüft haben, dass apiKeyDesc nicht ABSENT ist.
+ Optional
+ * {@link EditorValidationInput} enthält keinen rohen API-Key-String, sondern nur den vom
+ * GUI-Adapter bereits aufgelösten Descriptor. Der Descriptor spiegelt die Vorrangregel
+ * (ENV → Legacy-ENV → Property) zum Zeitpunkt des letzten Editor-Refreshs wider.
+ *
+ * Für Tests kann der Descriptor im Eingabeobjekt direkt gesetzt werden.
+ *
+ * @param input aktueller Editorzustand
+ * @param family aktive Provider-Familie
+ * @return der Herkunftsdeskriptor; nie {@code null}
+ */
+ private EffectiveApiKeyDescriptor resolveApiKeyDescriptor(EditorValidationInput input,
+ AiProviderFamily family) {
+ return switch (family) {
+ case CLAUDE -> input.claudeApiKeyDescriptor();
+ case OPENAI_COMPATIBLE -> input.openaiApiKeyDescriptor();
+ };
+ }
+
+ /**
+ * Liest den Base-URL-Wert für die angegebene Provider-Familie aus dem Editorzustand.
+ *
+ * @param input aktueller Editorzustand
+ * @param family aktive Provider-Familie
+ * @return Base-URL-String; nie {@code null}, leer wenn nicht gesetzt
+ */
+ private String resolveBaseUrlValue(EditorValidationInput input, AiProviderFamily family) {
+ return switch (family) {
+ case CLAUDE -> input.claudeBaseUrl();
+ case OPENAI_COMPATIBLE -> input.openaiBaseUrl();
+ };
+ }
+
+ /**
+ * Liest den konfigurierten Modellnamen für die angegebene Provider-Familie aus dem Editorzustand.
+ *
+ * @param input aktueller Editorzustand
+ * @param family aktive Provider-Familie
+ * @return Modellname; nie {@code null}, leer wenn nicht gesetzt
+ */
+ private String resolveModelValue(EditorValidationInput input, AiProviderFamily family) {
+ return switch (family) {
+ case CLAUDE -> input.claudeModel();
+ case OPENAI_COMPATIBLE -> input.openaiModel();
+ };
+ }
+
+ /**
+ * Liest den Timeout-Wert für die angegebene Provider-Familie aus dem Editorzustand.
+ *
+ * @param input aktueller Editorzustand
+ * @param family aktive Provider-Familie
+ * @return Timeout-String; nie {@code null}, leer wenn nicht gesetzt
+ */
+ private String resolveTimeoutValue(EditorValidationInput input, AiProviderFamily family) {
+ return switch (family) {
+ case CLAUDE -> input.claudeTimeoutSeconds();
+ case OPENAI_COMPATIBLE -> input.openaiTimeoutSeconds();
+ };
+ }
+
+ /**
+ * Parst einen Timeout-String zu einem Integer. Liefert den Standard-Timeout, wenn der
+ * String leer ist oder nicht als positive Ganzzahl parsierbar ist.
+ *
+ * @param raw roher Timeout-String
+ * @return geparster Timeout in Sekunden (mindestens 1)
+ */
+ private int parseTimeoutOrDefault(String raw) {
+ try {
+ int parsed = Integer.parseInt(raw.trim());
+ return parsed > 0 ? parsed : DEFAULT_TIMEOUT_SECONDS;
+ } catch (NumberFormatException e) {
+ return DEFAULT_TIMEOUT_SECONDS;
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ResourceCreationPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ResourceCreationPort.java
new file mode 100644
index 0000000..606ee19
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ResourceCreationPort.java
@@ -0,0 +1,64 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+/**
+ * Outbound-Port für schreibende technische Korrekturhilfen.
+ *
+ * Dieser Port ist schreibend und darf nur nach ausdrücklicher
+ * Benutzerbestätigung eines {@link CorrectionPlan} aufgerufen werden. Es darf keine
+ * stille Ausführung im Hintergrund geben.
+ *
+ * Abgrenzung zu {@link PathCheckPort}: {@code PathCheckPort} ist
+ * rein lesend; {@code ResourceCreationPort} ist rein schreibend. Beide Ports werden
+ * niemals für dieselbe Aufgabe verwendet.
+ *
+ * Pfad-Konvention: Alle Pfade werden als {@code String} übergeben,
+ * analog zur Konvention der übrigen Outbound-Ports dieses Projekts. Der Adapter-Out
+ * ist für die Konvertierung in plattformspezifische Pfadobjekte zuständig.
+ *
+ * Fehlerbehandlung: Implementierungen werfen keine geprüften Ausnahmen.
+ * Jede Methode gibt ein {@link CorrectionOutcome} zurück, das Erfolg, Scheitern oder
+ * Nicht-Durchführbarkeit ausdrückt. Unerwartete technische Fehler werden als
+ * {@link CorrectionOutcome.Failed} zurückgegeben.
+ *
+ * Windows- und Netzlaufwerke: Implementierungen müssen gemappte
+ * Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} im Windows-Kontext unterstützen.
+ */
+public interface ResourceCreationPort {
+
+ /**
+ * Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
+ *
+ * Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} mit einem
+ * entsprechenden Hinweis zurückgegeben (idempotente Ausführung).
+ *
+ * @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
+ * @return Ergebnis der Ausführung; nie {@code null}
+ */
+ CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion);
+
+ /**
+ * Erzeugt eine neue Prompt-Datei mit einem deutschen Standardinhalt.
+ *
+ * Der Standardinhalt wird von dieser Implementierung bereitgestellt. Die Datei wird
+ * nur erzeugt, wenn sie noch nicht existiert und ihr übergeordneter Ordner beschreibbar ist.
+ * Wenn der Pfad bereits eine Datei enthält, wird {@link CorrectionOutcome.NotAttempted}
+ * zurückgegeben (kein stilless Überschreiben).
+ *
+ * @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
+ * @return Ergebnis der Ausführung; nie {@code null}
+ */
+ CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion);
+
+ /**
+ * Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser noch nicht
+ * existiert.
+ *
+ * Eine leere SQLite-Datei wird nicht manuell erzeugt; das übernimmt der JDBC-Layer
+ * beim ersten Datenbankzugriff. Diese Methode stellt lediglich sicher, dass der
+ * übergeordnete Ordner vorhanden und schreibbar ist.
+ *
+ * @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
+ * @return Ergebnis der Ausführung; nie {@code null}
+ */
+ CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion);
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java
new file mode 100644
index 0000000..39abe7d
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java
@@ -0,0 +1,466 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.io.File;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationSeverity;
+
+/**
+ * Orchestrator für den vollständigen technischen Gesamttest der GUI-Konfiguration.
+ *
+ * Führt alle elf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
+ *
+ * Kein Frühabbruch: Alle drei Prüfblöcke werden immer vollständig
+ * ausgeführt, auch wenn ein Block eine Exception wirft. In diesem Fall werden die
+ * betroffenen Checkpoints als {@link CheckpointResult.Failure} mit Schweregrad ERROR
+ * und dem Präfix „Interner Fehler:" markiert. Der Gesamtbericht enthält immer genau
+ * elf Einträge.
+ *
+ * Threading-Kontrakt: Die Methode {@link #run(TechnicalTestRequest)}
+ * ist synchron blockierend (der Provider-Prüfblock führt HTTP-Aufrufe durch). Sie darf
+ * nicht auf dem JavaFX Application Thread aufgerufen werden. Der Aufrufer ist für die
+ * Worker-Thread-Verwaltung und die Rückführung via {@code Platform.runLater} verantwortlich.
+ *
+ * Prompt-Datei-Standardpfad: Wenn der Editorzustand keinen Prompt-Pfad
+ * enthält, leitet der Orchestrator einen Standardpfad aus dem Konfigurationsdateipfad ab
+ * ({@code
+ * Dieser Service enthält keine JavaFX-Typen, keine NIO-Pfadobjekte in Signaturen und
+ * keine Infrastrukturabhängigkeiten jenseits der drei injizierten Abhängigkeiten.
+ */
+public class TechnicalTestOrchestrator {
+
+ private final EditorConfigurationValidator editorValidator;
+ private final PathCheckPort pathCheckPort;
+ private final ProviderTechnicalTestService providerTestService;
+
+ /**
+ * Erstellt einen neuen Orchestrator mit den drei erforderlichen Abhängigkeiten.
+ *
+ * @param editorValidator Lokaler Konfigurationsvalidator; darf nicht {@code null} sein
+ * @param pathCheckPort Port für Dateisystem-Pfadprüfungen; darf nicht {@code null} sein
+ * @param providerTestService Service für provider-nahe technische Prüfungen; darf nicht {@code null} sein
+ * @throws NullPointerException wenn einer der Parameter {@code null} ist
+ */
+ public TechnicalTestOrchestrator(EditorConfigurationValidator editorValidator,
+ PathCheckPort pathCheckPort,
+ ProviderTechnicalTestService providerTestService) {
+ this.editorValidator = Objects.requireNonNull(editorValidator, "editorValidator must not be null");
+ this.pathCheckPort = Objects.requireNonNull(pathCheckPort, "pathCheckPort must not be null");
+ this.providerTestService = Objects.requireNonNull(providerTestService, "providerTestService must not be null");
+ }
+
+ /**
+ * Führt den vollständigen technischen Gesamttest gegen den angegebenen Editorzustand aus.
+ *
+ * Alle drei Prüfblöcke werden immer vollständig ausgeführt. Ein Fehler in einem Block
+ * führt nicht dazu, dass ein anderer Block übersprungen wird. Der zurückgegebene Bericht
+ * enthält immer genau elf {@link CheckpointResult}-Einträge.
+ *
+ * Prompt-Datei-Standardpfad: Wenn der Editorzustand keinen Prompt-Pfad
+ * enthält, wird als Standardpfad der Elternordner der Konfigurationsdatei gewählt
+ * (aus {@link TechnicalTestRequest#configFilePath()}), konkret
+ * {@code
+ * Wenn der Zielpfad der Prompt-Datei nicht beschreibbar ist, wird keine
+ * {@link CorrectionSuggestion} erzeugt, sondern eine Failure-Meldung mit dem Hinweis,
+ * die Datei manuell anzulegen.
+ *
+ * Threading-Kontrakt: Diese Methode blockiert, bis alle Prüfungen
+ * abgeschlossen sind. Sie darf nicht auf dem JavaFX Application Thread aufgerufen werden.
+ *
+ * @param request Eingabedaten für den Gesamttest; darf nicht {@code null} sein
+ * @return vollständiger Gesamttestbericht mit genau elf Einträgen; nie {@code null}
+ * @throws NullPointerException wenn {@code request} {@code null} ist
+ */
+ public TechnicalTestReport run(TechnicalTestRequest request) {
+ Objects.requireNonNull(request, "request must not be null");
+ Instant startTime = Instant.now();
+ EditorValidationInput input = request.validationInput();
+
+ List
+ * Befunde ohne Feldbezug oder mit allgemeinen Feldbezügen werden
+ * {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} zugeordnet. Provider-spezifische
+ * Feldbefunde werden {@link CheckpointId#PROVIDER_CONFIGURATION} zugeordnet.
+ *
+ * @param report Validierungsergebnis
+ * @return Liste mit genau zwei Einträgen
+ */
+ private static List
+ * Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand
+ * einen sinnvollen Standardpfad zu bestimmen ({@code
+ * Pfad-Auflösung: Wenn der konfigurierte Prompt-Pfad leer ist,
+ * wird ein Standardpfad bestimmt:
+ *
+ * Schreibbarkeits-Prüfung: Wenn der Zielpfad fehlt, wird geprüft, ob der
+ * Elternordner beschreibbar wäre. Nur dann wird eine {@link CorrectionSuggestion.CreatePromptFile}
+ * angeboten. Ist der Elternordner nicht beschreibbar, wird eine Failure ohne Korrekturvorschlag
+ * zurückgegeben, aber mit einem Hinweis, die Datei manuell anzulegen.
+ *
+ * @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein
+ * @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
+ * @return Prüfpunkt-Ergebnis
+ */
+ private CheckpointResult checkPromptFile(String configuredPath, String configFilePath) {
+ // Effektiven Prompt-Pfad bestimmen
+ String effectivePath = resolvePromptPath(configuredPath, configFilePath);
+
+ if (pathCheckPort.isFileReadable(effectivePath)) {
+ return new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT,
+ "Prompt-Datei vorhanden und lesbar: " + effectivePath);
+ }
+
+ // Datei fehlt – Elternordner auf Beschreibbarkeit prüfen
+ String parentPath = extractParentPath(effectivePath);
+ boolean parentWritable = !parentPath.isBlank()
+ && pathCheckPort.isDirectoryWritableOrCreatable(parentPath);
+
+ if (parentWritable) {
+ // Elternordner beschreibbar → Korrekturvorschlag anbieten
+ CorrectionSuggestion suggestion = new CorrectionSuggestion.CreatePromptFile(
+ effectivePath, "Prompt-Datei anlegen: " + effectivePath);
+ return CheckpointResult.Failure.withCorrection(
+ CheckpointId.PROMPT_FILE_PRESENT,
+ CheckpointSeverity.ERROR,
+ "Prompt-Datei nicht vorhanden oder nicht lesbar: " + effectivePath,
+ suggestion);
+ } else {
+ // Elternordner nicht beschreibbar → kein Korrekturvorschlag, nur Hinweis
+ return CheckpointResult.Failure.of(
+ CheckpointId.PROMPT_FILE_PRESENT,
+ CheckpointSeverity.ERROR,
+ "Prompt-Datei fehlt und kann nicht automatisch erzeugt werden. "
+ + "Bitte manuell anlegen: " + effectivePath);
+ }
+ }
+
+ /**
+ * Bestimmt den effektiven Prompt-Pfad aus dem konfigurierten Pfad und dem Konfigurationsdateipfad.
+ *
+ * Wenn der konfigurierte Pfad nicht leer ist, wird dieser unverändert zurückgegeben.
+ * Andernfalls wird ein Standardpfad aus dem Konfigurationsdateipfad abgeleitet:
+ * {@code
+ * Gibt eine leere Zeichenkette zurück, wenn kein Elternpfad bestimmbar ist.
+ *
+ * @param filePath Dateipfad als String
+ * @return Elternpfad oder leere Zeichenkette
+ */
+ private static String extractParentPath(String filePath) {
+ if (filePath == null || filePath.isBlank()) {
+ return "";
+ }
+ try {
+ java.nio.file.Path path = Paths.get(filePath);
+ java.nio.file.Path parent = path.getParent();
+ return parent != null ? parent.toString() : "";
+ } catch (InvalidPathException e) {
+ return "";
+ }
+ }
+
+ /**
+ * Prüft den Quellordner auf Vorhandensein und Lesbarkeit.
+ *
+ * @param path Pfad des Quellordners
+ * @return Prüfpunkt-Ergebnis
+ */
+ private CheckpointResult checkSourceFolder(String path) {
+ if (path.isBlank()) {
+ return CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
+ CheckpointSeverity.ERROR, "Quellordner: Kein Pfad konfiguriert.");
+ }
+ if (pathCheckPort.isDirectoryReadable(path)) {
+ return new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT,
+ "Quellordner vorhanden und lesbar: " + path);
+ }
+ return CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
+ CheckpointSeverity.ERROR,
+ "Quellordner nicht vorhanden oder nicht lesbar: " + path);
+ }
+
+ /**
+ * Prüft den Zielordner auf Vorhandensein oder Anlegbarkeit und Schreibbarkeit.
+ * Bietet eine {@link CorrectionSuggestion.CreateDirectory} an, wenn der Ordner
+ * fehlt, aber anlegbar wäre.
+ *
+ * @param path Pfad des Zielordners
+ * @return Prüfpunkt-Ergebnis
+ */
+ private CheckpointResult checkTargetFolder(String path) {
+ if (path.isBlank()) {
+ return CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
+ CheckpointSeverity.ERROR, "Zielordner: Kein Pfad konfiguriert.");
+ }
+ if (pathCheckPort.isDirectoryWritableOrCreatable(path)) {
+ return new CheckpointResult.Success(CheckpointId.TARGET_FOLDER_USABLE,
+ "Zielordner vorhanden/anlegbar und schreibbar: " + path);
+ }
+ // Ordner ist weder vorhanden/schreibbar noch anlegbar
+ // Wenn der Ordner fehlt, könnte isDirectoryWritableOrCreatable false liefern weil
+ // auch der Elternpfad fehlt. Trotzdem einen Korrekturvorschlag anbieten.
+ CorrectionSuggestion suggestion = new CorrectionSuggestion.CreateDirectory(
+ path, "Zielordner anlegen: " + path);
+ return CheckpointResult.Failure.withCorrection(
+ CheckpointId.TARGET_FOLDER_USABLE,
+ CheckpointSeverity.ERROR,
+ "Zielordner nicht vorhanden oder nicht schreibbar: " + path,
+ suggestion);
+ }
+
+ /**
+ * Prüft, ob der SQLite-Pfad technisch nutzbar ist.
+ * Bietet eine {@link CorrectionSuggestion.PrepareSqlitePath} an, wenn der Pfad
+ * noch nicht nutzbar, aber vorbereitbar wäre.
+ *
+ * @param path Pfad der SQLite-Datei
+ * @return Prüfpunkt-Ergebnis
+ */
+ private CheckpointResult checkSqlitePath(String path) {
+ if (path.isBlank()) {
+ return CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
+ CheckpointSeverity.ERROR, "SQLite-Pfad: Kein Pfad konfiguriert.");
+ }
+ if (pathCheckPort.isSqlitePathUsable(path)) {
+ return new CheckpointResult.Success(CheckpointId.SQLITE_PATH_USABLE,
+ "SQLite-Pfad technisch nutzbar: " + path);
+ }
+ CorrectionSuggestion suggestion = new CorrectionSuggestion.PrepareSqlitePath(
+ path, "SQLite-Pfad vorbereiten: " + path);
+ return CheckpointResult.Failure.withCorrection(
+ CheckpointId.SQLITE_PATH_USABLE,
+ CheckpointSeverity.ERROR,
+ "SQLite-Pfad nicht nutzbar: " + path,
+ suggestion);
+ }
+
+ // =========================================================================
+ // Block 3: Provider-nahe technische Prüfungen
+ // =========================================================================
+
+ /**
+ * Führt die provider-nahen technischen Prüfungen über den {@link ProviderTechnicalTestService} aus.
+ *
+ * Der Service liefert genau fünf Ergebnisse in der Reihenfolge:
+ * API_KEY_PRESENT, BASE_URL_REACHABLE, API_KEY_ACCEPTED, MODEL_LIST_AVAILABLE, SELECTED_MODEL_PLAUSIBLE.
+ *
+ * @param input aktueller Editorzustand
+ * @return Liste mit genau fünf Einträgen
+ */
+ private List
+ * Enthält die {@link CheckpointResult}-Einträge aller durchlaufenen Prüfpunkte in
+ * Ausführungsreihenfolge. Jeder definierte Prüfpunkt ist vertreten – entweder als
+ * {@link CheckpointResult.Success}, {@link CheckpointResult.Failure} oder
+ * {@link CheckpointResult.NotApplicable}.
+ *
+ * Der Gesamttest bricht bei einem Fehler nicht ab; alle Prüfpunkte werden
+ * vollständig durchlaufen.
+ *
+ * Dieser Record ist immutable und enthält keine JavaFX-Typen.
+ *
+ * @param results Prüfpunkt-Ergebnisse in Ausführungsreihenfolge; nie {@code null}
+ * @param evaluatedAt Zeitpunkt, zu dem der Gesamttest gestartet wurde; nie {@code null}
+ */
+public record TechnicalTestReport(
+ List
+ * Wenn {@code true}, gilt die Konfiguration im aktuellen Zustand als nicht lauffähig.
+ *
+ * @return {@code true} wenn mindestens ein Fehler-Prüfpunkt vorliegt
+ */
+ public boolean hasErrors() {
+ return results.stream()
+ .filter(r -> r instanceof CheckpointResult.Failure)
+ .map(r -> (CheckpointResult.Failure) r)
+ .anyMatch(f -> f.severity() == CheckpointSeverity.ERROR);
+ }
+
+ /**
+ * Gibt an, ob mindestens ein Prüfpunkt mit Schweregrad {@link CheckpointSeverity#WARNING}
+ * gescheitert ist.
+ *
+ * @return {@code true} wenn mindestens ein Warn-Prüfpunkt vorliegt
+ */
+ public boolean hasWarnings() {
+ return results.stream()
+ .filter(r -> r instanceof CheckpointResult.Failure)
+ .map(r -> (CheckpointResult.Failure) r)
+ .anyMatch(f -> f.severity() == CheckpointSeverity.WARNING);
+ }
+
+ /**
+ * Gibt an, ob mindestens ein Prüfpunkt einen {@link CorrectionSuggestion} enthält.
+ *
+ * Wenn {@code true}, kann aus diesem Bericht ein nicht leerer {@link CorrectionPlan}
+ * abgeleitet werden.
+ *
+ * @return {@code true} wenn mindestens ein korrigierbarer Befund vorliegt
+ */
+ public boolean hasCorrectableFindings() {
+ return results.stream()
+ .filter(r -> r instanceof CheckpointResult.Failure)
+ .map(r -> (CheckpointResult.Failure) r)
+ .anyMatch(CheckpointResult.Failure::hasCorrectionSuggestion);
+ }
+
+ /**
+ * Leitet einen {@link CorrectionPlan} aus den korrigierbaren Prüfpunkt-Fehlern ab.
+ *
+ * Enthält alle {@link CorrectionSuggestion}-Einträge der gescheiterten Prüfpunkte
+ * in Berichtsreihenfolge.
+ *
+ * @return abgeleiteter Korrekturplan; nie {@code null}; leer wenn keine Korrekturen möglich sind
+ */
+ public CorrectionPlan deriveCorrectionPlan() {
+ List
+ * Enthält den aktuellen Editorzustand als {@link EditorValidationInput} (alle String-Werte
+ * so wie sie im Editor vorliegen). Der technische Gesamttest arbeitet ausschließlich auf
+ * diesen Werten; er liest keine Konfigurationsdatei vom Dateisystem und speichert nichts.
+ *
+ * Der optionale Pfad zur Konfigurationsdatei ({@code configFilePath}) ermöglicht es dem
+ * Gesamttest, bei der automatischen Prompt-Erzeugung den Standardpfad relativ zur
+ * Konfigurationsdatei zu bestimmen. Er ist leer, wenn keine Konfigurationsdatei geladen ist.
+ *
+ * Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
+ *
+ * @param validationInput aktueller Editorzustand; nie {@code null}
+ * @param configFilePath optionaler Pfad der geladenen Konfigurationsdatei als String;
+ * leer wenn keine Datei geladen ist
+ */
+public record TechnicalTestRequest(
+ EditorValidationInput validationInput,
+ String configFilePath) {
+
+ /**
+ * Erstellt eine neue Gesamttest-Anforderung.
+ *
+ * @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
+ * @param configFilePath Pfad der Konfigurationsdatei; {@code null} wird zu leerem String
+ * @throws NullPointerException wenn {@code validationInput} {@code null} ist
+ */
+ public TechnicalTestRequest {
+ Objects.requireNonNull(validationInput, "validationInput must not be null");
+ configFilePath = configFilePath == null ? "" : configFilePath;
+ }
+
+ /**
+ * Erstellt eine Anforderung ohne geladene Konfigurationsdatei.
+ *
+ * @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
+ * @return eine neue Anforderung ohne Konfigurationsdateipfad
+ */
+ public static TechnicalTestRequest of(EditorValidationInput validationInput) {
+ return new TechnicalTestRequest(validationInput, "");
+ }
+
+ /**
+ * Gibt an, ob ein Konfigurationsdateipfad gesetzt ist.
+ *
+ * @return {@code true} wenn ein nicht leerer Pfad vorhanden ist
+ */
+ public boolean hasConfigFilePath() {
+ return !configFilePath.isBlank();
+ }
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/package-info.java
new file mode 100644
index 0000000..64cbef8
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/package-info.java
@@ -0,0 +1,28 @@
+/**
+ * Typen und Port-Verträge für den technischen Gesamttest der GUI-Konfiguration.
+ *
+ * Dieses Package enthält ausschließlich:
+ *
+ * Abgrenzungen:
+ *
+ * Prüft das Dispatch-Verhalten und die Kein-Frühabbruch-Semantik.
+ */
+class CorrectionExecutionServiceTest {
+
+ // =========================================================================
+ // No-op Port-Implementierungen für Tests
+ // =========================================================================
+
+ /** Port-Stub, der alle Aufrufe als Applied zurückgibt. */
+ private static ResourceCreationPort allAppliedPort() {
+ return new ResourceCreationPort() {
+ @Override
+ public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
+ return new CorrectionOutcome.Applied(s, "Ordner angelegt");
+ }
+ @Override
+ public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
+ return new CorrectionOutcome.Applied(s, "Prompt erzeugt");
+ }
+ @Override
+ public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
+ return new CorrectionOutcome.Applied(s, "SQLite-Pfad vorbereitet");
+ }
+ };
+ }
+
+ /** Port-Stub, der createDirectory als Failed, den Rest als Applied zurückgibt. */
+ private static ResourceCreationPort firstFailsPort() {
+ return new ResourceCreationPort() {
+ @Override
+ public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
+ return new CorrectionOutcome.Failed(s, "Ordner nicht anlegbar");
+ }
+ @Override
+ public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
+ return new CorrectionOutcome.Applied(s, "Prompt erzeugt");
+ }
+ @Override
+ public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
+ return new CorrectionOutcome.Applied(s, "SQLite-Pfad vorbereitet");
+ }
+ };
+ }
+
+ // =========================================================================
+ // Tests
+ // =========================================================================
+
+ @Test
+ void execute_emptyPlan_returnsEmptyReport() {
+ CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
+
+ CorrectionExecutionReport report = service.execute(CorrectionPlan.empty());
+
+ assertNotNull(report);
+ assertEquals(0, report.size());
+ assertFalse(report.hasFailures());
+ assertFalse(report.hasNotAttempted());
+ }
+
+ @Test
+ void execute_planWithThreeSuggestions_allSucceed_reportHasThreeApplied() {
+ CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
+
+ CorrectionSuggestion.CreateDirectory dir =
+ new CorrectionSuggestion.CreateDirectory("C:/foo", "Zielordner anlegen");
+ CorrectionSuggestion.CreatePromptFile prompt =
+ new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen");
+ CorrectionSuggestion.PrepareSqlitePath sqlite =
+ new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten");
+
+ CorrectionPlan plan = new CorrectionPlan(List.of(dir, prompt, sqlite));
+ CorrectionExecutionReport report = service.execute(plan);
+
+ assertEquals(3, report.size());
+ assertTrue(report.allApplied(), "Alle drei Korrekturen sollen Applied sein");
+ assertFalse(report.hasFailures());
+ }
+
+ @Test
+ void execute_planWithOneFailing_othersStillExecuted_noEarlyAbort() {
+ CorrectionExecutionService service = new CorrectionExecutionService(firstFailsPort());
+
+ CorrectionSuggestion.CreateDirectory dir =
+ new CorrectionSuggestion.CreateDirectory("C:/foo", "Ordner anlegen");
+ CorrectionSuggestion.CreatePromptFile prompt =
+ new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen");
+ CorrectionSuggestion.PrepareSqlitePath sqlite =
+ new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten");
+
+ CorrectionPlan plan = new CorrectionPlan(List.of(dir, prompt, sqlite));
+ CorrectionExecutionReport report = service.execute(plan);
+
+ assertEquals(3, report.size(), "Alle 3 Korrekturen müssen versucht worden sein (kein Frühabbruch)");
+ assertTrue(report.hasFailures(), "Erste Korrektur ist fehlgeschlagen");
+ assertFalse(report.allApplied());
+
+ // Erster Eintrag: Failed (CreateDirectory)
+ assertInstanceOf(CorrectionOutcome.Failed.class, report.outcomes().get(0));
+ // Zweiter und dritter Eintrag: Applied (trotz Fehler im ersten)
+ assertInstanceOf(CorrectionOutcome.Applied.class, report.outcomes().get(1));
+ assertInstanceOf(CorrectionOutcome.Applied.class, report.outcomes().get(2));
+ }
+
+ @Test
+ void constructor_nullPort_throwsNullPointerException() {
+ assertThrows(NullPointerException.class,
+ () -> new CorrectionExecutionService(null));
+ }
+
+ @Test
+ void execute_nullPlan_throwsNullPointerException() {
+ CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
+ assertThrows(NullPointerException.class, () -> service.execute(null));
+ }
+
+ // Helper for instanceOf assertion
+ private static
+ * Prüft, dass der zurückgegebene Standard-Prompt-Inhalt nicht leer ist,
+ * relevante deutsche Schlüsselwörter enthält und das erwartete JSON-Schema-Format beschreibt.
+ */
+class DefaultPromptTemplateTest {
+
+ @Test
+ void defaultContent_isNotNullAndNotEmpty() {
+ String content = DefaultPromptTemplate.defaultContent();
+ assertThat(content).isNotNull();
+ assertThat(content).isNotBlank();
+ }
+
+ @Test
+ void defaultContent_containsGermanKeywords() {
+ String content = DefaultPromptTemplate.defaultContent();
+ assertThat(content).contains("Titel");
+ assertThat(content).contains("Datum");
+ assertThat(content).contains("Deutsch");
+ }
+
+ @Test
+ void defaultContent_containsJsonSchemaHint() {
+ String content = DefaultPromptTemplate.defaultContent();
+ // JSON-Felder müssen im Prompt beschrieben sein
+ assertThat(content).contains("title");
+ assertThat(content).contains("reasoning");
+ assertThat(content).contains("date");
+ }
+
+ @Test
+ void defaultContent_containsDateFormatHint() {
+ String content = DefaultPromptTemplate.defaultContent();
+ assertThat(content).contains("YYYY-MM-DD");
+ }
+
+ @Test
+ void defaultContent_mentionsTitleMaxLength() {
+ String content = DefaultPromptTemplate.defaultContent();
+ assertThat(content).contains("20");
+ }
+
+ @Test
+ void defaultContent_isConsistent_calledTwice() {
+ // Idempotenz-Prüfung: zwei Aufrufe liefern denselben Inhalt
+ String first = DefaultPromptTemplate.defaultContent();
+ String second = DefaultPromptTemplate.defaultContent();
+ assertThat(first).isEqualTo(second);
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java
new file mode 100644
index 0000000..20671ca
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java
@@ -0,0 +1,431 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
+
+/**
+ * Unit-Tests für {@link ProviderTechnicalTestService}.
+ *
+ * Testet alle relevanten Kombinationen aus {@link ModelCatalogResult}-Varianten und
+ * API-Key-Herkunft für beide Provider-Familien. Port-Stubs werden als einfache Lambdas
+ * implementiert.
+ */
+class ProviderTechnicalTestServiceTest {
+
+ // ------------------------------------------------------------------ Hilfsmethoden
+
+ private static EditorValidationInput claudeInput(EffectiveApiKeyDescriptor apiKeyDescriptor,
+ String model) {
+ return new EditorValidationInput(
+ "claude",
+ "/src", "/tgt", "/db.sqlite", "/prompt.txt",
+ "3", "10", "2000",
+ "https://api.anthropic.com", model, "30",
+ apiKeyDescriptor,
+ "https://api.openai.com", "gpt-4", "30",
+ EffectiveApiKeyDescriptor.absent());
+ }
+
+ private static EditorValidationInput openaiInput(EffectiveApiKeyDescriptor apiKeyDescriptor,
+ String model) {
+ return new EditorValidationInput(
+ "openai-compatible",
+ "/src", "/tgt", "/db.sqlite", "/prompt.txt",
+ "3", "10", "2000",
+ "https://api.anthropic.com", "claude-3-sonnet", "30",
+ EffectiveApiKeyDescriptor.absent(),
+ "https://api.openai.com", model, "30",
+ apiKeyDescriptor);
+ }
+
+ private static EffectiveApiKeyDescriptor keyFromEnv() {
+ return EffectiveApiKeyDescriptor.fromProviderEnvVar("ANTHROPIC_API_KEY");
+ }
+
+ private static EffectiveApiKeyDescriptor keyFromProperty() {
+ return EffectiveApiKeyDescriptor.fromPropertyFile();
+ }
+
+ private static EffectiveApiKeyDescriptor keyAbsent() {
+ return EffectiveApiKeyDescriptor.absent();
+ }
+
+ /**
+ * Stub-Port der immer den über den Konstruktor übergebenen Descriptor zurückgibt,
+ * unabhängig von family und propertyValue.
+ */
+ private static de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort
+ apiKeyPort(EffectiveApiKeyDescriptor descriptor) {
+ return (family, propertyValue) -> descriptor;
+ }
+
+ /**
+ * Stub-Port der immer das angegebene {@link ModelCatalogResult} zurückgibt.
+ */
+ private static de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort
+ catalogPort(ModelCatalogResult result) {
+ return request -> result;
+ }
+
+ // ------------------------------------------------------------------ Tests: API-Key absent
+
+ @Test
+ void givenApiKeyAbsent_allRemoteCheckpointsAreNotApplicable() {
+ ProviderTechnicalTestService service = new ProviderTechnicalTestService(
+ catalogPort(new ModelCatalogResult.IncompleteConfiguration("claude", "nicht erreichbar")),
+ apiKeyPort(keyAbsent()));
+
+ List
+ * 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
+ * zurückgegebene Bericht immer genau elf Einträge enthält.
+ */
+class TechnicalTestOrchestratorTest {
+
+ // ------------------------------------------------------------------ Hilfsmethoden
+
+ /**
+ * Erstellt eine minimal gültige {@link EditorValidationInput} für den Claude-Provider.
+ * Verwendet {@code max.text.characters=500} um wirtschaftliche Warnungen zu vermeiden.
+ */
+ private static EditorValidationInput validClaudeInput() {
+ return new EditorValidationInput(
+ "claude",
+ "/src", "/tgt", "/db.sqlite", "/prompt.txt",
+ "3", "10", "500",
+ "https://api.anthropic.com", "claude-3-sonnet", "30",
+ EffectiveApiKeyDescriptor.fromPropertyFile(),
+ "https://api.openai.com", "gpt-4", "30",
+ EffectiveApiKeyDescriptor.absent());
+ }
+
+ /**
+ * Erstellt eine {@link EditorValidationInput} mit leerem Aktiv-Provider (erzeugt Fehler im Validierungsblock).
+ */
+ private static EditorValidationInput emptyProviderInput() {
+ return new EditorValidationInput(
+ "", // leerer aktiver Provider → Fehler in Block 1
+ "", "", "", "",
+ "", "", "",
+ "", "", "",
+ EffectiveApiKeyDescriptor.absent(),
+ "", "", "",
+ EffectiveApiKeyDescriptor.absent());
+ }
+
+ /** No-op {@link PathCheckPort}: alle Prüfungen liefern {@code false}. */
+ private static PathCheckPort noOpPathCheckPort() {
+ return new PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return false; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
+ @Override public boolean isFileReadable(String p) { return false; }
+ @Override public boolean isSqlitePathUsable(String p) { return false; }
+ };
+ }
+
+ /** {@link PathCheckPort}: alle Prüfungen liefern {@code true}. */
+ private static PathCheckPort allOkPathCheckPort() {
+ return new PathCheckPort() {
+ @Override public boolean isDirectoryReadable(String p) { return true; }
+ @Override public boolean isDirectoryWritableOrCreatable(String p) { return true; }
+ @Override public boolean isFileReadable(String p) { return true; }
+ @Override public boolean isSqlitePathUsable(String p) { return true; }
+ };
+ }
+
+ /** {@link ProviderTechnicalTestService} der immer fünf {@link CheckpointResult.Success} liefert. */
+ private static ProviderTechnicalTestService allSuccessProviderService() {
+ return new ProviderTechnicalTestService(
+ req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
+ .ModelCatalogResult.Success(
+ req.providerIdentifier(),
+ List.of("claude-3-sonnet", "gpt-4"),
+ java.time.Instant.now()),
+ (family, propertyValue) -> EffectiveApiKeyDescriptor.fromPropertyFile());
+ }
+
+ /** {@link ProviderTechnicalTestService} der immer eine RuntimeException wirft. */
+ private static ProviderTechnicalTestService throwingProviderService() {
+ return new ProviderTechnicalTestService(
+ req -> { throw new RuntimeException("Simulierter Provider-Fehler"); },
+ (family, propertyValue) -> EffectiveApiKeyDescriptor.fromPropertyFile());
+ }
+
+ private static CheckpointResult findById(List
+ *
+ *
+ *
+ *
+ *
+ * Covered scenarios
+ *
+ *
+ *
+ * Threading
+ * All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
+ * The Monocle headless configuration is activated by the Surefire JVM arguments.
+ */
+class GuiValidateActionSmokeTest {
+
+ private static final long FX_TIMEOUT_SECONDS = 10;
+ private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
+
+ /** Source tag used by the validation action INFO message. */
+ private static final String ACTION_SOURCE = "Validierung-Aktion";
+
+ @BeforeAll
+ static void setUpJavaFxPlatform() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ CountDownLatch latch = new CountDownLatch(1);
+ try {
+ Platform.startup(() -> {
+ PLATFORM_STARTED.set(true);
+ latch.countDown();
+ });
+ assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "JavaFX Platform must start within timeout");
+ } catch (IllegalStateException alreadyStarted) {
+ CountDownLatch verifyLatch = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ PLATFORM_STARTED.set(true);
+ verifyLatch.countDown();
+ });
+ assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "Existing JavaFX Platform must be reachable within timeout");
+ }
+ }
+
+ @AfterAll
+ static void tearDownJavaFxPlatform() {
+ // Shared platform – do not call Platform.exit().
+ }
+
+ // =========================================================================
+ // Scenario: button is findable by CSS ID
+ // =========================================================================
+
+ /**
+ * Smoke test: after constructing a workspace, the "Validieren" button exists
+ * and carries the CSS ID {@code validate-button}.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void validateButton_hasCssId() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws =
+ new GuiConfigurationEditorWorkspace(Optional.empty());
+ Button btn = ws.validateButton;
+ assertNotNull(btn, "validateButton must not be null");
+ assertEquals("validate-button", btn.getId(),
+ "validateButton must carry CSS ID 'validate-button'");
+ });
+ }
+
+ // =========================================================================
+ // Scenario: incomplete configuration → ERROR findings + INFO message with count
+ // =========================================================================
+
+ /**
+ * Smoke test: after clicking "Validieren" on a workspace whose editor state has
+ * an empty active-provider value, the last validation result contains at least one
+ * ERROR and the central message area contains an INFO message with source
+ * "Validierung-Aktion" that reports the number of findings.
+ *
+ * @throws Exception if the FX thread task fails or times out
+ */
+ @Test
+ void validateAction_incompleteConfiguration_producesErrorsAndInfoMessage() throws Exception {
+ runOnFx(() -> {
+ GuiConfigurationEditorWorkspace ws = buildWorkspace();
+
+ // Force an incomplete state: start with blank (no active provider).
+ // The blank start state already has an empty active provider → errors expected.
+
+ ws.validateButton.fire();
+
+ GuiEditorValidationResult result = ws.lastValidationResult();
+ assertTrue(result.hasErrors(),
+ "Clicking Validieren on incomplete config must produce ERROR findings");
+
+ ListWindows- und Netzlaufwerk-Unterstützung
+ * Thread-Safety
+ * Fehlerbehandlung
+ *
+ *
+ * Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, der Ordner
+ * existiert aber nicht schreibbar ist, oder weder der Ordner noch ein schreibbarer
+ * Elternpfad vorhanden ist.
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
+ */
+ @Override
+ public boolean isDirectoryWritableOrCreatable(String path) {
+ LOG.debug("Prüfe Ordner auf Schreibbarkeit oder Anlegbarkeit: {}", path);
+ Path resolved = toPath(path);
+ if (resolved == null) {
+ LOG.warn("Ordner-Schreibbarkeit: ungültiger Pfad: {}", path);
+ return false;
+ }
+ if (Files.exists(resolved)) {
+ boolean writable = Files.isDirectory(resolved) && Files.isWritable(resolved);
+ if (writable) {
+ LOG.debug("Ordner vorhanden und schreibbar: {}", resolved);
+ } else {
+ LOG.warn("Ordner vorhanden, aber nicht schreibbar: {}", resolved);
+ }
+ return writable;
+ }
+ // Ordner existiert nicht — prüfen ob Elternpfad schreibbar ist
+ Path parent = resolved.getParent();
+ if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
+ LOG.debug("Ordner nicht vorhanden, aber anlegbar (Elternpfad schreibbar): {}", resolved);
+ return true;
+ }
+ LOG.warn("Ordner nicht vorhanden und nicht anlegbar: {}", resolved);
+ return false;
+ }
+
+ /**
+ * Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
+ *
+ *
+ * Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, die Datei
+ * existiert aber nicht nutzbar ist, oder weder die Datei noch ein beschreibbarer
+ * Elternordner vorhanden ist.
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
+ */
+ @Override
+ public boolean isSqlitePathUsable(String path) {
+ LOG.debug("Prüfe SQLite-Pfad auf Nutzbarkeit: {}", path);
+ Path resolved = toPath(path);
+ if (resolved == null) {
+ LOG.warn("SQLite-Pfad: ungültiger Pfad: {}", path);
+ return false;
+ }
+ if (Files.exists(resolved)) {
+ boolean usable = Files.isRegularFile(resolved)
+ && Files.isReadable(resolved)
+ && Files.isWritable(resolved);
+ if (usable) {
+ LOG.debug("SQLite-Datei vorhanden und nutzbar: {}", resolved);
+ } else {
+ LOG.warn("SQLite-Datei vorhanden, aber nicht les- und schreibbar: {}", resolved);
+ }
+ return usable;
+ }
+ // Datei existiert nicht — prüfen ob Elternordner schreibbar ist
+ Path parent = resolved.getParent();
+ if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
+ LOG.debug("SQLite-Datei nicht vorhanden, aber anlegbar (Elternordner schreibbar): {}", resolved);
+ return true;
+ }
+ LOG.warn("SQLite-Pfad nicht nutzbar und nicht anlegbar: {}", resolved);
+ return false;
+ }
+
+ /**
+ * Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
+ * Windows- und Netzlaufwerk-Unterstützung
+ * Thread-Safety
+ * Fehlerbehandlung
+ *
+ *
+ *
+ *
+ * Kein Frühabbruch
+ * Aufrufkonvention
+ * Thread-Safety
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
+ */
+ boolean isDirectoryWritableOrCreatable(String path);
+
+ /**
+ * Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn die Datei existiert und gelesen werden kann
+ */
+ boolean isFileReadable(String path);
+
+ /**
+ * Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
+ *
+ *
+ *
+ * @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
+ * @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
+ */
+ boolean isSqlitePathUsable(String path);
+}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java
new file mode 100644
index 0000000..faa69a6
--- /dev/null
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java
@@ -0,0 +1,496 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
+import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
+import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
+
+/**
+ * Application-Service für die provider-nahen technischen Prüfpunkte des Gesamttests.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointIdTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointIdTest.java
new file mode 100644
index 0000000..ba2ac35
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointIdTest.java
@@ -0,0 +1,41 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests für den {@link CheckpointId}-Enum.
+ */
+class CheckpointIdTest {
+
+ @Test
+ void allMandatoryCheckpointIdsArePresent() {
+ var ids = CheckpointId.values();
+ assertThat(ids).contains(
+ CheckpointId.CONFIGURATION_BASIC_VALIDATION,
+ CheckpointId.PROVIDER_CONFIGURATION,
+ CheckpointId.BASE_URL_REACHABLE,
+ CheckpointId.API_KEY_PRESENT,
+ CheckpointId.API_KEY_ACCEPTED,
+ CheckpointId.MODEL_LIST_AVAILABLE,
+ CheckpointId.SELECTED_MODEL_PLAUSIBLE,
+ CheckpointId.PROMPT_FILE_PRESENT,
+ CheckpointId.SOURCE_FOLDER_PRESENT,
+ CheckpointId.TARGET_FOLDER_USABLE,
+ CheckpointId.SQLITE_PATH_USABLE
+ );
+ }
+
+ @Test
+ void enumHasExactlyElevenValues() {
+ assertThat(CheckpointId.values()).hasSize(11);
+ }
+
+ @Test
+ void valueOfRoundtrip() {
+ for (CheckpointId id : CheckpointId.values()) {
+ assertThat(CheckpointId.valueOf(id.name())).isSameAs(id);
+ }
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResultTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResultTest.java
new file mode 100644
index 0000000..98b5448
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResultTest.java
@@ -0,0 +1,127 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+/**
+ * Tests für das versiegelte Interface {@link CheckpointResult} und seine Untertypen.
+ */
+class CheckpointResultTest {
+
+ // --- Success ---
+
+ @Test
+ void success_storesCheckpointIdAndMessage() {
+ var result = new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT, "Ordner vorhanden");
+ assertThat(result.checkpointId()).isEqualTo(CheckpointId.SOURCE_FOLDER_PRESENT);
+ assertThat(result.message()).isEqualTo("Ordner vorhanden");
+ }
+
+ @Test
+ void success_nullCheckpointIdThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new CheckpointResult.Success(null, "ok"));
+ }
+
+ @Test
+ void success_nullMessageThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new CheckpointResult.Success(CheckpointId.API_KEY_PRESENT, null));
+ }
+
+ @Test
+ void success_equality() {
+ var a = new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT, "ok");
+ var b = new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT, "ok");
+ assertThat(a).isEqualTo(b);
+ }
+
+ // --- Failure ---
+
+ @Test
+ void failure_of_noCorrectionSuggestion() {
+ var result = CheckpointResult.Failure.of(
+ CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt");
+ assertThat(result.checkpointId()).isEqualTo(CheckpointId.TARGET_FOLDER_USABLE);
+ assertThat(result.severity()).isEqualTo(CheckpointSeverity.ERROR);
+ assertThat(result.message()).isEqualTo("Ordner fehlt");
+ assertThat(result.correctionSuggestion()).isEmpty();
+ assertThat(result.hasCorrectionSuggestion()).isFalse();
+ }
+
+ @Test
+ void failure_withCorrection_storesSuggestion() {
+ var suggestion = new CorrectionSuggestion.CreateDirectory("/some/path", "Ordner anlegen");
+ var result = CheckpointResult.Failure.withCorrection(
+ CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt", suggestion);
+ assertThat(result.hasCorrectionSuggestion()).isTrue();
+ assertThat(result.correctionSuggestion()).contains(suggestion);
+ }
+
+ @Test
+ void failure_nullCorrectionSuggestionInConstructorBecomesEmpty() {
+ var result = new CheckpointResult.Failure(
+ CheckpointId.SQLITE_PATH_USABLE, CheckpointSeverity.WARNING, "Pfad auffällig", null);
+ assertThat(result.correctionSuggestion()).isEmpty();
+ }
+
+ @Test
+ void failure_nullCheckpointIdThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> CheckpointResult.Failure.of(null, CheckpointSeverity.ERROR, "msg"));
+ }
+
+ @Test
+ void failure_warningLevel() {
+ var result = CheckpointResult.Failure.of(
+ CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.WARNING, "Modell unbekannt");
+ assertThat(result.severity()).isEqualTo(CheckpointSeverity.WARNING);
+ }
+
+ // --- NotApplicable ---
+
+ @Test
+ void notApplicable_storesCheckpointIdAndReason() {
+ var result = new CheckpointResult.NotApplicable(
+ CheckpointId.API_KEY_ACCEPTED, "Kein API-Key vorhanden");
+ assertThat(result.checkpointId()).isEqualTo(CheckpointId.API_KEY_ACCEPTED);
+ assertThat(result.reason()).isEqualTo("Kein API-Key vorhanden");
+ }
+
+ @Test
+ void notApplicable_nullCheckpointIdThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new CheckpointResult.NotApplicable(null, "reason"));
+ }
+
+ @Test
+ void notApplicable_nullReasonThrows() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> new CheckpointResult.NotApplicable(CheckpointId.MODEL_LIST_AVAILABLE, null));
+ }
+
+ // --- Pattern Matching ---
+
+ @Test
+ void patternMatching_coversAllPermittedTypes() {
+ CheckpointResult success = new CheckpointResult.Success(CheckpointId.CONFIGURATION_BASIC_VALIDATION, "ok");
+ CheckpointResult failure = CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, CheckpointSeverity.ERROR, "nicht erreichbar");
+ CheckpointResult notApplicable = new CheckpointResult.NotApplicable(CheckpointId.API_KEY_ACCEPTED, "kein key");
+
+ assertThat(classify(success)).isEqualTo("success");
+ assertThat(classify(failure)).isEqualTo("failure");
+ assertThat(classify(notApplicable)).isEqualTo("notApplicable");
+ }
+
+ private String classify(CheckpointResult result) {
+ return switch (result) {
+ case CheckpointResult.Success s -> "success";
+ case CheckpointResult.Failure f -> "failure";
+ case CheckpointResult.NotApplicable n -> "notApplicable";
+ };
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java
new file mode 100644
index 0000000..ed66847
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java
@@ -0,0 +1,71 @@
+package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+/**
+ * Tests für {@link CorrectionExecutionReport}.
+ */
+class CorrectionExecutionReportTest {
+
+ private final CorrectionSuggestion s1 =
+ new CorrectionSuggestion.CreateDirectory("/path/a", "Ordner A");
+ private final CorrectionSuggestion s2 =
+ new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt erzeugen");
+
+ @Test
+ void emptyReport_allAppliedIsFalse() {
+ var report = new CorrectionExecutionReport(List.of());
+ assertThat(report.allApplied()).isFalse();
+ assertThat(report.hasFailures()).isFalse();
+ assertThat(report.hasNotAttempted()).isFalse();
+ assertThat(report.size()).isZero();
+ }
+
+ @Test
+ void report_allApplied() {
+ var report = new CorrectionExecutionReport(List.of(
+ new CorrectionOutcome.Applied(s1, "ok1"),
+ new CorrectionOutcome.Applied(s2, "ok2")));
+ assertThat(report.allApplied()).isTrue();
+ assertThat(report.hasFailures()).isFalse();
+ assertThat(report.hasNotAttempted()).isFalse();
+ }
+
+ @Test
+ void report_withFailure_hasFailures() {
+ var report = new CorrectionExecutionReport(List.of(
+ new CorrectionOutcome.Applied(s1, "ok"),
+ new CorrectionOutcome.Failed(s2, "Fehler")));
+ assertThat(report.hasFailures()).isTrue();
+ assertThat(report.allApplied()).isFalse();
+ }
+
+ @Test
+ void report_withNotAttempted_hasNotAttempted() {
+ var report = new CorrectionExecutionReport(List.of(
+ new CorrectionOutcome.NotAttempted(s1, "Grund")));
+ assertThat(report.hasNotAttempted()).isTrue();
+ assertThat(report.allApplied()).isFalse();
+ }
+
+ @Test
+ void outcomesListIsImmutable() {
+ var mutable = new ArrayList