From 1bb7a427357c73039c09a8e1bfe351dee54df765 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 20 Apr 2026 21:57:06 +0200 Subject: [PATCH] =?UTF-8?q?M12=20vollst=C3=A4ndig=20abgeschlossen=20(AP-00?= =?UTF-8?q?1=20bis=20AP-008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AP-001: Prüf- und Korrektur-Kernobjekte (CheckpointId, CheckpointResult sealed interface, TechnicalTestReport mit Correction-Plan-Ableitung, CorrectionSuggestion sealed interface, PathCheckPort, ResourceCreationPort) - AP-002: Aktion "Validieren" als explizite, nicht schreibende Gesamtprüfung des aktuellen Editorzustands - AP-003: Provider-nahe technische Prüflogik für Endpoint, API-Key, Modellliste und Modellplausibilität — wiederverwendet den bestehenden Modellabruf-Port, kein zweiter HTTP-Pfad - AP-004: Windows-Pfadprüfung mit ausdrücklicher Unterstützung gemappter Laufwerksbuchstaben (FilesystemPathCheckAdapter) - AP-005: Aktion "Technische Tests ausführen" als vollständiger Gesamttest ohne Frühabbruch, Orchestrator sammelt Befunde aller Prüfblöcke - AP-006: Schreibende Korrekturhilfen mit gesammeltem Bestätigungsdialog, CorrectionExecutionService, FilesystemResourceCreationAdapter - AP-007: Automatische deutsche Standard-Prompt-Datei-Erzeugung, Default-Pfad neben der .properties-Datei, klare Fehlermeldung bei nicht beschreibbarem Zielpfad - AP-008: Regressionstests für Gesamttest ohne Frühabbruch, ungespeicherte Editorzustände, Korrekturdialog, Prompt-Erzeugung, Windows-Pfade Hexagonale Architektur durchgehend eingehalten, Domain und Application bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt. Naming-Regel und JavaDoc-Standard eingehalten. Co-Authored-By: Claude Haiku 4.5 --- .../gui/GuiConfigurationEditorWorkspace.java | 176 +++++- .../gui/GuiCorrectionDialogCoordinator.java | 287 ++++++++++ .../adapter/in/gui/GuiStartupContext.java | 116 +++- .../in/gui/GuiTechnicalTestCoordinator.java | 268 +++++++++ .../gui/editor/ConfirmationDialogContent.java | 80 +++ .../adapter/in/gui/editor/package-info.java | 2 + .../adapter/in/gui/GuiAdapterSmokeTest.java | 28 +- ...iCorrectionDialogCoordinatorSmokeTest.java | 318 +++++++++++ .../in/gui/GuiEditorFieldBindingTest.java | 28 +- .../in/gui/GuiEditorIntegrationTest.java | 56 +- .../in/gui/GuiEditorRegressionSmokeTest.java | 140 ++++- .../in/gui/GuiEditorValidationSmokeTest.java | 56 +- .../in/gui/GuiMessageAreaSmokeTest.java | 112 +++- .../in/gui/GuiModelCatalogSmokeTest.java | 28 +- .../GuiTechnicalTestCoordinatorSmokeTest.java | 432 +++++++++++++++ .../gui/GuiUnsavedChangesGuardSmokeTest.java | 56 +- .../in/gui/GuiValidateActionSmokeTest.java | 422 ++++++++++++++ .../editor/ConfirmationDialogContentTest.java | 77 +++ .../pathcheck/FilesystemPathCheckAdapter.java | 222 ++++++++ .../adapter/out/pathcheck/package-info.java | 11 + .../FilesystemResourceCreationAdapter.java | 213 ++++++++ .../out/resourcecreation/package-info.java | 9 + .../FilesystemPathCheckAdapterTest.java | 237 ++++++++ ...FilesystemResourceCreationAdapterTest.java | 190 +++++++ .../technicaltest/CheckpointId.java | 88 +++ .../technicaltest/CheckpointResult.java | 162 ++++++ .../technicaltest/CheckpointSeverity.java | 31 ++ .../CorrectionExecutionReport.java | 68 +++ .../CorrectionExecutionService.java | 86 +++ .../technicaltest/CorrectionOutcome.java | 104 ++++ .../technicaltest/CorrectionPlan.java | 62 +++ .../technicaltest/CorrectionSuggestion.java | 126 +++++ .../technicaltest/DefaultPromptTemplate.java | 68 +++ .../technicaltest/PathCheckPort.java | 72 +++ .../ProviderTechnicalTestService.java | 496 +++++++++++++++++ .../technicaltest/ResourceCreationPort.java | 64 +++ .../TechnicalTestOrchestrator.java | 466 ++++++++++++++++ .../technicaltest/TechnicalTestReport.java | 109 ++++ .../technicaltest/TechnicalTestRequest.java | 58 ++ .../technicaltest/package-info.java | 28 + .../technicaltest/CheckpointIdTest.java | 41 ++ .../technicaltest/CheckpointResultTest.java | 127 +++++ .../CorrectionExecutionReportTest.java | 71 +++ .../CorrectionExecutionServiceTest.java | 163 ++++++ .../technicaltest/CorrectionOutcomeTest.java | 67 +++ .../technicaltest/CorrectionPlanTest.java | 54 ++ .../CorrectionSuggestionTest.java | 85 +++ .../DefaultPromptTemplateTest.java | 58 ++ .../ProviderTechnicalTestServiceTest.java | 431 +++++++++++++++ .../TechnicalTestOrchestratorTest.java | 516 ++++++++++++++++++ .../TechnicalTestReportTest.java | 108 ++++ .../TechnicalTestRequestTest.java | 48 ++ .../umbenenner/bootstrap/BootstrapRunner.java | 36 +- 53 files changed, 7410 insertions(+), 47 deletions(-) create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinator.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContent.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCorrectionDialogCoordinatorSmokeTest.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java create mode 100644 pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapter.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/package-info.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapter.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/package-info.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapterTest.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointId.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResult.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointSeverity.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReport.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionService.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcome.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlan.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestion.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/PathCheckPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestService.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ResourceCreationPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestrator.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReport.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequest.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/package-info.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointIdTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CheckpointResultTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionReportTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcomeTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/ProviderTechnicalTestServiceTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java 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. + * + *

Ablauf

+ *
    + *
  1. Bericht erhält: prüfen ob {@code hasCorrectableFindings()}.
  2. + *
  3. Wenn keine korrigierbaren Befunde: kein Dialog, keine Aktion.
  4. + *
  5. Wenn korrigierbare Befunde: {@link CorrectionPlan} ableiten.
  6. + *
  7. Dialog auf FX-Thread anzeigen.
  8. + *
  9. Bei Bestätigung: Korrekturen auf Worker-Thread ausführen.
  10. + *
  11. Ergebnisse via {@code Platform.runLater} auf FX-Thread zurückführen.
  12. + *
  13. Meldungen in {@code pendingMessages} einhängen (Replace-Semantik).
  14. + *
+ * + *

Threading-Kontrakt

+ *

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

Keine stillen Korrekturen

+ *

+ * Ohne ausdrückliche Benutzerbestätigung werden keine schreibenden Änderungen ausgeführt. + * Bei Dialog-Abbruch bleibt der Zustand unverändert. + * + *

Anti-Scope

+ *

+ * 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 pendingMessages; + private final Consumer refreshCallback; + + /** + * Funktion, die dem Benutzer den Bestätigungsdialog zeigt. + *

+ * 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 dialogSupplier; + + /** + * Factory für den Hintergrund-Worker-Thread. + * Standard: Daemon-Thread namens {@code gui-correction-worker}. + * Paket-privat für Test-Substitution. + */ + Function correctionThreadFactory; + + /** + * Verbraucher zur Rückführung des Ergebnisses auf den FX-Thread. + * Standard: {@code Platform.runLater}. Paket-privat für Test-Substitution. + */ + java.util.function.Consumer resultDelivery; + + /** + * Erstellt einen neuen Koordinator. + * + * @param correctionExecutionService Service für die Ausführung von Korrekturen; darf nicht {@code null} sein + * @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein + * @param refreshCallback Callback nach Anwendung der Ergebnisse (z. B. View-Aktualisierung); + * darf nicht {@code null} sein + * @throws NullPointerException wenn einer der Parameter {@code null} ist + */ + public GuiCorrectionDialogCoordinator(CorrectionExecutionService correctionExecutionService, + List pendingMessages, + Consumer refreshCallback) { + this.correctionExecutionService = Objects.requireNonNull(correctionExecutionService, + "correctionExecutionService must not be null"); + this.pendingMessages = Objects.requireNonNull(pendingMessages, + "pendingMessages must not be null"); + this.refreshCallback = Objects.requireNonNull(refreshCallback, + "refreshCallback must not be null"); + + this.dialogSupplier = this::showConfirmationDialog; + this.correctionThreadFactory = task -> { + Thread t = new Thread(task, "gui-correction-worker"); + t.setDaemon(true); + return t; + }; + this.resultDelivery = Platform::runLater; + } + + /** + * Prüft den Bericht auf korrigierbare Befunde, zeigt bei Bedarf den Bestätigungsdialog + * und führt die Korrekturen nach Bestätigung asynchron aus. + *

+ * 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 result = alert.showAndWait(); + return result.isPresent() && result.get() == proceedButton; + } + + /** + * 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 pendingMessagesSnapshot() { + return List.copyOf(pendingMessages); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 572280c..76f72ae 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -9,18 +9,29 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort; import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator; /** * Immutable startup data for the GUI adapter. *

* 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 startupNotice) { + de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort noOpCatalogPort = + request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog + .ModelCatalogResult.IncompleteConfiguration( + request.providerIdentifier(), + "Kein Modellkatalog in diesem Startkontext verfügbar."); + ApiKeyResolutionPort noOpApiKeyPort = (family, propertyValue) -> EffectiveApiKeyDescriptor.absent(); + ProviderTechnicalTestService noOpTestService = + new ProviderTechnicalTestService(noOpCatalogPort, noOpApiKeyPort); + PathCheckPort noOpPathCheckPort = new PathCheckPort() { + @Override + public boolean isDirectoryReadable(String path) { return false; } + @Override + public boolean isDirectoryWritableOrCreatable(String path) { return false; } + @Override + public boolean isFileReadable(String path) { return false; } + @Override + public boolean isSqlitePathUsable(String path) { return false; } + }; + TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + noOpPathCheckPort, + noOpTestService); + ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() { + @Override + public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome + createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionSuggestion.CreateDirectory suggestion) { + return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext."); + } + @Override + public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome + createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionSuggestion.CreatePromptFile suggestion) { + return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext."); + } + @Override + public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome + prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionSuggestion.PrepareSqlitePath suggestion) { + return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest + .CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext."); + } + }; + CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort); return new GuiStartupContext( GuiConfigurationEditorStateFactory.createBlankStartState(), startupNotice, configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(), (values, path) -> GuiConfigurationSaveResult.saved(path), - request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog - .ModelCatalogResult.IncompleteConfiguration( - request.providerIdentifier(), - "Kein Modellkatalog in diesem Startkontext verfügbar."), - (family, propertyValue) -> EffectiveApiKeyDescriptor.absent()); + noOpCatalogPort, + noOpApiKeyPort, + noOpTestService, + noOpPathCheckPort, + noOpOrchestrator, + noOpCorrectionService); } } diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java new file mode 100644 index 0000000..47ff2e8 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinator.java @@ -0,0 +1,268 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestRequest; +import javafx.application.Platform; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Koordiniert die asynchrone Ausführung der Aktion „Technische Tests ausführen" + * für den GUI-Konfigurationseditor. + *

+ * 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 inputProvider; + private final Supplier configFilePathProvider; + private final List pendingMessages; + private final Consumer postResultCallback; + + /** + * Factory für den Hintergrund-Worker-Thread. Paket-privat für Test-Substitution. + * Standard: Daemon-Thread namens {@code gui-technical-test}. + */ + Function testThreadFactory; + + /** + * Verbraucher zur Rückführung des Ergebnisses. In der Produktion kapselt er {@code Platform.runLater}. + * In Tests kann er durch einen direkten Aufruf ersetzt werden, damit das Ergebnis sofort + * auf dem Worker-Thread angewendet wird, ohne die FX-Warteschlange zu entwässern. + * Paket-privat für Test-Substitution. + */ + java.util.function.Consumer resultDelivery = Platform::runLater; + + /** + * Erstellt einen neuen Koordinator. + * + * @param orchestrator Orchestrator für den vollständigen Gesamttest; darf nicht {@code null} sein + * @param inputProvider Lieferant des aktuellen {@link EditorValidationInput}; darf nicht {@code null} sein + * @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String; + * gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist; + * darf nicht {@code null} sein + * @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein + * @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein + * @throws NullPointerException wenn einer der Parameter {@code null} ist + */ + public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator, + Supplier inputProvider, + Supplier configFilePathProvider, + List pendingMessages, + Consumer postResultCallback) { + this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null"); + this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null"); + this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null"); + this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null"); + this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null"); + this.testThreadFactory = task -> { + Thread t = new Thread(task, "gui-technical-test"); + t.setDaemon(true); + return t; + }; + } + + /** + * Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus. + *

+ * 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 /prompt.txt}) zu bestimmen. + *

+ * 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 pendingMessagesSnapshot() { + return List.copyOf(pendingMessages); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContent.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContent.java new file mode 100644 index 0000000..4becf60 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContent.java @@ -0,0 +1,80 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import java.util.List; +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; + +/** + * Inhalt des gesammelten Bestätigungsdialogs für schreibende Korrekturmaßnahmen. + *

+ * 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 correctionLines) { + + /** + * Erstellt einen neuen Bestätigungsdialog-Inhalt. + * + * @param title Dialogtitel; darf nicht {@code null} sein + * @param introText einleitender Text; darf nicht {@code null} sein + * @param correctionLines Beschreibungszeilen; darf nicht {@code null} sein + * @throws NullPointerException wenn ein Parameter {@code null} ist + */ + public ConfirmationDialogContent { + Objects.requireNonNull(title, "title must not be null"); + Objects.requireNonNull(introText, "introText must not be null"); + Objects.requireNonNull(correctionLines, "correctionLines must not be null"); + correctionLines = List.copyOf(correctionLines); + } + + /** + * Erstellt den Bestätigungsdialog-Inhalt aus einem {@link CorrectionPlan}. + *

+ * 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 lines = plan.suggestions().stream() + .map(CorrectionSuggestion::descriptionForUser) + .toList(); + return new ConfirmationDialogContent( + "Korrekturen bestätigen", + "Folgende technische Korrekturen werden durchgeführt:", + lines); + } + + /** + * Gibt an, ob der Dialoginhalt mindestens eine Beschreibungszeile enthält. + * + * @return {@code true} wenn mindestens eine Korrekturmaßnahme beschrieben ist + */ + public boolean hasCorrections() { + return !correctionLines.isEmpty(); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java index c32bc13..8de622a 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/package-info.java @@ -25,6 +25,8 @@ *

  • The consolidated validation result that feeds both the central message area and * field-near error display * ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).
  • + *
  • The confirmation dialog content for collected write-corrective actions + * ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent}).
  • * *

    * 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 messages = new ArrayList<>(); + AtomicBoolean correctionExecuted = new AtomicBoolean(false); + + CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted); + GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator( + service, messages, true /* confirm */); + + TechnicalTestReport report = buildReportWithCorrectableFinding(); + coordinator.offerCorrections(report); + + assertTrue(correctionExecuted.get(), + "Korrektur muss nach Bestätigung ausgeführt worden sein"); + + long correctionEntries = messages.stream() + .filter(m -> m.source().isPresent() + && GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + assertTrue(correctionEntries > 0, + "Nach Ausführung müssen Meldungen mit Source '" + + GuiCorrectionDialogCoordinator.SOURCE_TAG + "' vorhanden sein"); + }); + } + + // ========================================================================= + // Scenario: report without correctable findings → no dialog, no corrections + // ========================================================================= + + /** + * Smoke-Test: Bericht ohne korrigierbare Befunde → kein Dialog, keine Korrekturen. + */ + @Test + void offerCorrections_withoutCorrectableFindings_noDialogNoCorrections() throws Exception { + runOnFx(() -> { + List messages = new ArrayList<>(); + AtomicBoolean dialogShown = new AtomicBoolean(false); + AtomicBoolean correctionExecuted = new AtomicBoolean(false); + + CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted); + GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator( + service, messages, true /* confirm */); + coordinator.dialogSupplier = content -> { + dialogShown.set(true); + return true; + }; + + // Report with NO correctable findings (all succeed) + TechnicalTestReport report = buildReportWithNoCorrectableFindings(); + coordinator.offerCorrections(report); + + assertFalse(dialogShown.get(), "Kein Dialog darf angezeigt werden wenn keine Korrekturen möglich"); + assertFalse(correctionExecuted.get(), "Keine Korrektur darf ausgeführt werden"); + assertTrue(messages.isEmpty(), "Keine Meldungen dürfen hinzugefügt werden"); + }); + } + + // ========================================================================= + // Scenario: dialog cancelled → no corrections, no messages with SOURCE_TAG + // ========================================================================= + + /** + * Smoke-Test: Dialog-Abbruch → keine Korrekturen, keine Meldungen mit Source-Tag. + */ + @Test + void offerCorrections_dialogCancelled_noCorrectionsNoMessages() throws Exception { + runOnFx(() -> { + List messages = new ArrayList<>(); + AtomicBoolean correctionExecuted = new AtomicBoolean(false); + + CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted); + GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator( + service, messages, false /* cancel */); + + TechnicalTestReport report = buildReportWithCorrectableFinding(); + coordinator.offerCorrections(report); + + assertFalse(correctionExecuted.get(), + "Bei Dialog-Abbruch dürfen keine Korrekturen ausgeführt werden"); + + long correctionEntries = messages.stream() + .filter(m -> m.source().isPresent() + && GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + assertEquals(0, correctionEntries, + "Bei Dialog-Abbruch dürfen keine Meldungen mit Source '" + + GuiCorrectionDialogCoordinator.SOURCE_TAG + "' hinzugefügt werden"); + }); + } + + // ========================================================================= + // Scenario: replace semantics – second run replaces previous SOURCE_TAG entries + // ========================================================================= + + /** + * Smoke-Test: Beim zweiten Aufruf werden vorherige SOURCE_TAG-Einträge ersetzt. + */ + @Test + void offerCorrections_calledTwice_replacesPreviousMessages() throws Exception { + runOnFx(() -> { + List messages = new ArrayList<>(); + AtomicBoolean ignored = new AtomicBoolean(false); + + CorrectionExecutionService service = buildServiceThatTracksExecution(ignored); + GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator( + service, messages, true); + + TechnicalTestReport report = buildReportWithCorrectableFinding(); + coordinator.offerCorrections(report); + long countAfterFirst = messages.stream() + .filter(m -> m.source().isPresent() + && GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + + coordinator.offerCorrections(report); + long countAfterSecond = messages.stream() + .filter(m -> m.source().isPresent() + && GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + + assertEquals(countAfterFirst, countAfterSecond, + "Zweiter Aufruf muss vorherige Einträge ersetzen (Replace-Semantik)"); + }); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Baut einen synchron laufenden {@link GuiCorrectionDialogCoordinator}. + *

    + * 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 messages, + boolean confirm) { + + GuiCorrectionDialogCoordinator coordinator = new GuiCorrectionDialogCoordinator( + service, + messages, + ignored -> { /* no-op refresh */ }); + + coordinator.dialogSupplier = content -> confirm; + coordinator.correctionThreadFactory = task -> new Thread(task, "sync-correction-thread") { + @Override + public void start() { + run(); // inline, synchronous + } + }; + coordinator.resultDelivery = Runnable::run; // direct call, no FX queue + + return coordinator; + } + + /** + * Baut einen {@link CorrectionExecutionService}, der {@code correctionExecuted} auf {@code true} + * setzt, wenn eine Korrektur aufgerufen wird. + */ + private static CorrectionExecutionService buildServiceThatTracksExecution( + AtomicBoolean correctionExecuted) { + ResourceCreationPort trackingPort = new ResourceCreationPort() { + @Override + public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) { + correctionExecuted.set(true); + return new CorrectionOutcome.Applied(s, "Angelegt"); + } + @Override + public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) { + correctionExecuted.set(true); + return new CorrectionOutcome.Applied(s, "Erzeugt"); + } + @Override + public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) { + correctionExecuted.set(true); + return new CorrectionOutcome.Applied(s, "Vorbereitet"); + } + }; + return new CorrectionExecutionService(trackingPort); + } + + /** Baut einen Bericht mit einem korrigierbaren Fehler-Befund. */ + private static TechnicalTestReport buildReportWithCorrectableFinding() { + CorrectionSuggestion.CreateDirectory suggestion = + new CorrectionSuggestion.CreateDirectory("C:/test/target", "Zielordner anlegen: C:/test/target"); + CheckpointResult.Failure failure = new CheckpointResult.Failure( + CheckpointId.TARGET_FOLDER_USABLE, + CheckpointSeverity.ERROR, + "Zielordner nicht vorhanden", + Optional.of(suggestion)); + return new TechnicalTestReport(List.of(failure), Instant.now()); + } + + /** Baut einen Bericht ohne korrigierbare Befunde (alles erfolgreich). */ + private static TechnicalTestReport buildReportWithNoCorrectableFindings() { + CheckpointResult.Success success = new CheckpointResult.Success( + CheckpointId.TARGET_FOLDER_USABLE, + "Zielordner vorhanden"); + return new TechnicalTestReport(List.of(success), Instant.now()); + } + + private static void runOnFx(ThrowingRunnable task) throws Exception { + java.util.concurrent.atomic.AtomicReference error = + new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + try { + task.run(); + } catch (Throwable t) { + error.set(t); + } finally { + latch.countDown(); + } + }); + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "FX task must complete within timeout"); + Throwable t = error.get(); + if (t != null) { + if (t instanceof Exception ex) throw ex; + throw new AssertionError("Unexpected error", t); + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java index 0201997..3261d66 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorFieldBindingTest.java @@ -324,7 +324,33 @@ class GuiEditorFieldBindingTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), capturingWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context); ws.requestNewConfiguration(); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java index 247ba55..b4dba68 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorIntegrationTest.java @@ -117,7 +117,33 @@ class GuiEditorIntegrationTest { GuiConfigurationFileWriter noOpWriter = (values, path) -> GuiConfigurationSaveResult.saved(path); GuiStartupContext context = new GuiStartupContext(loadedState, Optional.empty(), fileLoader, noOpWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); AtomicReference error = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); @@ -241,7 +267,33 @@ class GuiEditorIntegrationTest { configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(), noOpWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); AtomicReference error = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java index e2b03dc..144a041 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorRegressionSmokeTest.java @@ -188,7 +188,33 @@ class GuiEditorRegressionSmokeTest { GuiConfigurationEditorState initialState = GuiConfigurationEditorStateFactory.createBlankStartState(); GuiStartupContext context = new GuiStartupContext(initialState, Optional.empty(), loader, noOpWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); AtomicReference wsRef = new AtomicReference<>(); AtomicReference error = new AtomicReference<>(); @@ -301,7 +327,33 @@ class GuiEditorRegressionSmokeTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), capturingWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); CountDownLatch setupLatch = new CountDownLatch(1); Platform.runLater(() -> { @@ -399,7 +451,33 @@ class GuiEditorRegressionSmokeTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), capturingWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); CountDownLatch setupLatch = new CountDownLatch(1); Platform.runLater(() -> { @@ -501,7 +579,33 @@ class GuiEditorRegressionSmokeTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), capturingWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); CountDownLatch setupLatch = new CountDownLatch(1); Platform.runLater(() -> { @@ -574,7 +678,33 @@ class GuiEditorRegressionSmokeTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), trackingWriter, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); CountDownLatch setupLatch = new CountDownLatch(1); Platform.runLater(() -> { diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java index 7aa0123..3d987e4 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiEditorValidationSmokeTest.java @@ -123,7 +123,33 @@ class GuiEditorValidationSmokeTest { req.providerIdentifier(), "kein Port im Test"), (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog - .EffectiveApiKeyDescriptor.absent()); + .EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); AtomicReference wsRef = new AtomicReference<>(); AtomicReference error = new AtomicReference<>(); @@ -227,7 +253,33 @@ class GuiEditorValidationSmokeTest { req.providerIdentifier(), "kein Port im Test"), (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog - .EffectiveApiKeyDescriptor.absent()); + .EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); AtomicReference wsRef = new AtomicReference<>(); AtomicReference error = new AtomicReference<>(); diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiMessageAreaSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiMessageAreaSmokeTest.java index 2d6e022..479e180 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiMessageAreaSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiMessageAreaSmokeTest.java @@ -315,7 +315,33 @@ class GuiMessageAreaSmokeTest { return EffectiveApiKeyDescriptor.fromProviderEnvVar("CLAUDE_API_KEY"); } return EffectiveApiKeyDescriptor.absent(); - }); + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); runOnFx(() -> { GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx); @@ -358,7 +384,33 @@ class GuiMessageAreaSmokeTest { req.providerIdentifier(), java.util.List.of("claude-3-5-sonnet"), java.time.Instant.now()), - (family, propertyValue) -> EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx); // Make retrieval synchronous. @@ -419,7 +471,33 @@ class GuiMessageAreaSmokeTest { req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog .ModelCatalogResult.EmptyList( req.providerIdentifier(), java.time.Instant.now()), - (family, propertyValue) -> EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx); ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(() -> task.run()) { @@ -577,7 +655,33 @@ class GuiMessageAreaSmokeTest { (values, path) -> GuiConfigurationSaveResult.saved(path), req -> new ModelCatalogResult.IncompleteConfiguration( req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); CountDownLatch setupLatch = new CountDownLatch(1); Platform.runLater(() -> { diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiModelCatalogSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiModelCatalogSmokeTest.java index 3796068..2ed136d 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiModelCatalogSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiModelCatalogSmokeTest.java @@ -509,7 +509,33 @@ class GuiModelCatalogSmokeTest { .createBlankStartState(), (values, path) -> GuiConfigurationSaveResult.saved(path), stub, - (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 ws = new GuiConfigurationEditorWorkspace(ctx); // Synchronous thread factory: run the task directly instead of starting an OS thread. ws.modelCatalogCoordinator.modelCatalogThreadFactory = task -> new Thread(task, "gui-model-catalog-test") { diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java new file mode 100644 index 0000000..bc9d074 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiTechnicalTestCoordinatorSmokeTest.java @@ -0,0 +1,432 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity; +import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; +import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport; +import javafx.application.Platform; +import javafx.scene.control.Button; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Monocle-based headless smoke tests for the "Technische Tests ausführen" button + * and {@link GuiTechnicalTestCoordinator}. + *

    + * 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 messages = new ArrayList<>(); + GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { }); + + coordinator.triggerTechnicalTests(); + + long taggedCount = messages.stream() + .filter(m -> m.source().isPresent() + && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + + assertTrue(taggedCount > 0, + "Triggering 'Technische Tests ausführen' must add entries tagged '" + + GuiTechnicalTestCoordinator.SOURCE_TAG + "'"); + }); + } + + // ========================================================================= + // Scenario: 11 checkpoint entries + 1 summary = 12 entries total + // ========================================================================= + + /** + * Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals + * 11 (one per checkpoint) plus 1 summary entry = 12. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception { + runOnFx(() -> { + List messages = new ArrayList<>(); + GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { }); + + coordinator.triggerTechnicalTests(); + + long taggedCount = messages.stream() + .filter(m -> m.source().isPresent() + && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + + // 11 checkpoint entries + 1 summary entry = 12 + assertEquals(12, taggedCount, + "Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages"); + }); + } + + // ========================================================================= + // Scenario: replace semantics – second trigger replaces previous entries + // ========================================================================= + + /** + * Smoke test: triggering the coordinator twice replaces the previous SOURCE_TAG + * entries; the count remains the same as after a single trigger. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void trigger_twice_replacesPreviousTestEntries() throws Exception { + runOnFx(() -> { + List messages = new ArrayList<>(); + GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { }); + + coordinator.triggerTechnicalTests(); + long countAfterFirst = messages.stream() + .filter(m -> m.source().isPresent() + && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + + coordinator.triggerTechnicalTests(); + long countAfterSecond = messages.stream() + .filter(m -> m.source().isPresent() + && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) + .count(); + + assertEquals(countAfterFirst, countAfterSecond, + "Second trigger must replace (not append) the previous test entries"); + }); + } + + // ========================================================================= + // Scenario: post-result callback is invoked + // ========================================================================= + + /** + * Smoke test: the post-result callback is invoked after the result is applied. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void trigger_postResultCallbackIsInvoked() throws Exception { + runOnFx(() -> { + AtomicBoolean callbackInvoked = new AtomicBoolean(false); + List messages = new ArrayList<>(); + GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator( + messages, report -> callbackInvoked.set(true)); + + coordinator.triggerTechnicalTests(); + + assertTrue(callbackInvoked.get(), + "The post-result callback must be invoked after the technical tests finish"); + }); + } + + // ========================================================================= + // Scenario: input supplier is consulted at trigger time — reflects current (unsaved) state + // ========================================================================= + + /** + * Smoke test: the coordinator must read the current editor state at trigger time via the + * injected {@link java.util.function.Supplier}, not from a cached snapshot. This verifies + * that unsaved changes are always reflected in the technical test results. + * + *

    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 messages = new ArrayList<>(); + + // Mutable supplier: starts with valid input, will be swapped before second trigger. + AtomicReference currentInput = new AtomicReference<>( + new EditorValidationInput( + "claude", + "/src", "/tgt", "/db.sqlite", "/prompt.txt", + "3", "10", "500", + "https://api.anthropic.com", "claude-3-sonnet", "30", + EffectiveApiKeyDescriptor.absent(), + "https://api.openai.com", "gpt-4", "30", + EffectiveApiKeyDescriptor.absent())); + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + noOpPathCheckPort(), + noOpProviderService()); + + GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator( + orchestrator, + currentInput::get, // always reads the current reference + () -> "", + messages, + report -> { }); + + coordinator.testThreadFactory = task -> new Thread(task, "sync-test-thread") { + @Override public void start() { run(); } + }; + coordinator.resultDelivery = Runnable::run; + + // First trigger with valid input — all failures are from path checks (no-op port), + // but no CONFIGURATION_BASIC_VALIDATION error because activeProvider is valid. + coordinator.triggerTechnicalTests(); + long errorCountFirst = messages.stream() + .filter(m -> m.source().isPresent() + && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) + .filter(m -> m.severity() == GuiMessageSeverity.ERROR) + .count(); + + // Simulate unsaved edit: replace input with one having an empty active-provider. + currentInput.set(new EditorValidationInput( + "", // empty active provider → validation error in block 1 + "/src", "/tgt", "/db.sqlite", "/prompt.txt", + "3", "10", "500", + "https://api.anthropic.com", "claude-3-sonnet", "30", + EffectiveApiKeyDescriptor.absent(), + "https://api.openai.com", "gpt-4", "30", + EffectiveApiKeyDescriptor.absent())); + + // Second trigger with the updated (unsaved) input. + coordinator.triggerTechnicalTests(); + long errorCountSecond = messages.stream() + .filter(m -> m.source().isPresent() + && GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get())) + .filter(m -> m.severity() == GuiMessageSeverity.ERROR) + .count(); + + // After the change, there must be at least as many errors as before, + // because the empty active-provider introduces additional validation errors. + assertTrue(errorCountSecond >= errorCountFirst, + "Second trigger with empty active-provider must produce at least as many " + + "errors as the first trigger, proving the supplier is read at trigger time"); + }); + } + + // ========================================================================= + // Scenario: workspace button is wired to coordinator + // ========================================================================= + + /** + * Smoke test: after constructing a workspace, the button is not null and + * the coordinator is not null. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void workspace_buttonAndCoordinatorAreWired() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = + new GuiConfigurationEditorWorkspace(Optional.empty()); + assertNotNull(ws.technicalTestsButton, + "technicalTestsButton must not be null"); + assertNotNull(ws.technicalTestCoordinator, + "technicalTestCoordinator must not be null"); + }); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** No-op {@link PathCheckPort}: all checks return {@code false}. */ + private static PathCheckPort noOpPathCheckPort() { + return new PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }; + } + + /** No-op {@link ProviderTechnicalTestService}. */ + private static ProviderTechnicalTestService noOpProviderService() { + return new ProviderTechnicalTestService( + req -> new ModelCatalogResult.IncompleteConfiguration( + req.providerIdentifier(), "kein Port im Test"), + (fam, pv) -> EffectiveApiKeyDescriptor.absent()); + } + + /** + * Builds a {@link GuiTechnicalTestCoordinator} that runs synchronously (inline thread + * factory, direct result delivery) so results are available immediately after + * {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()} returns. + * + * @param messages mutable list to collect message entries + * @param postResultCallback callback to invoke after result is applied + * @return synchronously-wired coordinator + */ + private static GuiTechnicalTestCoordinator buildSyncCoordinator( + List messages, + java.util.function.Consumer postResultCallback) { + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + noOpPathCheckPort(), + noOpProviderService()); + + EditorValidationInput blankInput = new EditorValidationInput( + "claude", + "/src", "/tgt", "/db.sqlite", "/prompt.txt", + "3", "10", "2000", + "https://api.anthropic.com", "claude-3-sonnet", "30", + EffectiveApiKeyDescriptor.absent(), + "https://api.openai.com", "gpt-4", "30", + EffectiveApiKeyDescriptor.absent()); + + GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator( + orchestrator, + () -> blankInput, + () -> "", + messages, + postResultCallback); + + // Override thread factory to run task inline (synchronous, no real thread spawn) + coordinator.testThreadFactory = task -> new Thread(task, "sync-test-thread") { + @Override + public void start() { + // Run the task inline instead of starting a new thread + run(); + } + }; + // Override result delivery to be synchronous (direct call) + coordinator.resultDelivery = Runnable::run; + + return coordinator; + } + + private static void runOnFx(ThrowingRunnable task) throws Exception { + AtomicReference error = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + try { + task.run(); + } catch (Throwable t) { + error.set(t); + } finally { + latch.countDown(); + } + }); + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "FX task must complete within timeout"); + rethrow(error); + } + + private static void rethrow(AtomicReference error) throws Exception { + Throwable t = error.get(); + if (t == null) { + return; + } + if (t instanceof Exception ex) { + throw ex; + } + throw new AssertionError("Unexpected error", t); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java index fc7eaf8..a10dcac 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiUnsavedChangesGuardSmokeTest.java @@ -791,7 +791,33 @@ class GuiUnsavedChangesGuardSmokeTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), writer, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(context); ws.requestNewConfiguration(); return ws; @@ -810,7 +836,33 @@ class GuiUnsavedChangesGuardSmokeTest { configFilePath -> GuiConfigurationTemplateFactory.createStandardTemplate(), writer, req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req.providerIdentifier(), "kein Port im Test"), - (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()); + (family, propertyValue) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"), + (fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator( + new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return false; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } + @Override public boolean isFileReadable(String p) { return false; } + @Override public boolean isSqlitePathUsable(String p) { return false; } + }, + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService( + req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"), + (fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())), + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() { + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + @Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); } + })); return new GuiConfigurationEditorWorkspace(context); } diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java new file mode 100644 index 0000000..12e0133 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiValidateActionSmokeTest.java @@ -0,0 +1,422 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity; +import javafx.application.Platform; +import javafx.scene.control.Button; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Monocle-based headless smoke tests for the explicit "Validieren" action. + *

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

    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"); + + List actionMessages = ws.pendingMessages.stream() + .filter(m -> m.source().isPresent() + && ACTION_SOURCE.equals(m.source().get())) + .toList(); + + assertEquals(1, actionMessages.size(), + "Exactly one action-confirmation INFO message must be present"); + GuiMessageEntry msg = actionMessages.get(0); + assertEquals(GuiMessageSeverity.INFO, msg.severity(), + "Action-confirmation message must have INFO severity"); + assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."), + "Action-confirmation message text must start with expected prefix"); + assertFalse(msg.text().contains("Keine Befunde"), + "With errors present the message must NOT say 'Keine Befunde'"); + }); + } + + // ========================================================================= + // Scenario: valid configuration → no ERRORs + INFO message "Keine Befunde." + // ========================================================================= + + /** + * Smoke test: after clicking "Validieren" on a workspace with the standard template + * values loaded, the last validation result contains no ERRORs and the action-confirmation + * message either contains "Keine Befunde." or a zero finding count. + * + * @throws Exception if the FX thread task fails or times out + */ + @Test + void validateAction_validTemplate_noErrorsAndNoBefundeMessage() throws Exception { + runOnFx(() -> { + GuiConfigurationEditorWorkspace ws = buildWorkspace(); + ws.requestNewConfiguration(); + + ws.validateButton.fire(); + + GuiEditorValidationResult result = ws.lastValidationResult(); + assertFalse(result.hasErrors(), + "Clicking Validieren on standard template must produce no ERROR findings"); + + List actionMessages = ws.pendingMessages.stream() + .filter(m -> m.source().isPresent() + && ACTION_SOURCE.equals(m.source().get())) + .toList(); + + assertEquals(1, actionMessages.size(), + "Exactly one action-confirmation INFO message must be present"); + GuiMessageEntry msg = actionMessages.get(0); + assertEquals(GuiMessageSeverity.INFO, msg.severity(), + "Action-confirmation message must have INFO severity"); + // Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0. + assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."), + "Action-confirmation message text must start with expected prefix"); + }); + } + + // ========================================================================= + // Scenario: "Validieren" with unsaved (dirty) editor state — validates current content + // ========================================================================= + + /** + * Smoke test: when the editor holds unsaved changes that introduce a validation error, + * clicking "Validieren" must reflect the current, modified (dirty) editor state, + * not the previously saved or baseline state. + * + *

    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 error = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + try { + task.run(); + } catch (Throwable t) { + error.set(t); + } finally { + latch.countDown(); + } + }); + assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), + "FX task must complete within timeout"); + rethrow(error); + } + + private static void rethrow(AtomicReference error) throws Exception { + Throwable t = error.get(); + if (t == null) { + return; + } + if (t instanceof Exception ex) { + throw ex; + } + throw new AssertionError("Unexpected error", t); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java new file mode 100644 index 0000000..51838c9 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/editor/ConfirmationDialogContentTest.java @@ -0,0 +1,77 @@ +package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor; + +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests für {@link ConfirmationDialogContent}. + */ +class ConfirmationDialogContentTest { + + @Test + void fromPlan_extractsDescriptionsInOrder() { + var s1 = new CorrectionSuggestion.CreateDirectory("/path/a", "Zielordner anlegen"); + var s2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen"); + var plan = new CorrectionPlan(List.of(s1, s2)); + + var content = ConfirmationDialogContent.fromPlan(plan); + + assertThat(content.correctionLines()).containsExactly( + "Zielordner anlegen", + "Prompt-Datei erzeugen"); + assertThat(content.title()).isNotBlank(); + assertThat(content.introText()).isNotBlank(); + assertThat(content.hasCorrections()).isTrue(); + } + + @Test + void fromPlan_emptyPlan_hasNoCorrections() { + var content = ConfirmationDialogContent.fromPlan(CorrectionPlan.empty()); + assertThat(content.hasCorrections()).isFalse(); + assertThat(content.correctionLines()).isEmpty(); + } + + @Test + void fromPlan_nullPlanThrows() { + assertThatNullPointerException() + .isThrownBy(() -> ConfirmationDialogContent.fromPlan(null)); + } + + @Test + void correctionLinesAreImmutable() { + var content = new ConfirmationDialogContent("Titel", "Intro", new ArrayList<>(List.of("Zeile 1"))); + assertThat(content.correctionLines()).hasSize(1); + } + + @Test + void nullTitleThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new ConfirmationDialogContent(null, "intro", List.of())); + } + + @Test + void nullIntroTextThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new ConfirmationDialogContent("title", null, List.of())); + } + + @Test + void nullCorrectionLinesThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new ConfirmationDialogContent("title", "intro", null)); + } + + @Test + void equality() { + var a = new ConfirmationDialogContent("T", "I", List.of("Z1")); + var b = new ConfirmationDialogContent("T", "I", List.of("Z1")); + assertThat(a).isEqualTo(b); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapter.java new file mode 100644 index 0000000..6a8b5c2 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/pathcheck/FilesystemPathCheckAdapter.java @@ -0,0 +1,222 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck; + +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort; + +/** + * Dateisystem-basierte Implementierung von {@link PathCheckPort}. + *

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

    Windows- und Netzlaufwerk-Unterstützung

    + *

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

    Thread-Safety

    + *

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

    Fehlerbehandlung

    + *

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

      + *
    • der Ordner existiert und schreibbar ist, oder
    • + *
    • der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und + * schreibbar ist (anlegbar).
    • + *
    + * 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, 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: + *

      + *
    • die Datei existiert, les- und schreibbar ist, oder
    • + *
    • die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden + * und schreibbar ist (Datei wäre anlegbar).
    • + *
    + * 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. + *

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

    Windows- und Netzlaufwerk-Unterstützung

    + *

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

    Thread-Safety

    + *

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

    Fehlerbehandlung

    + *

    + * 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?")); + } + + // ----------------------------------------------------------------------- + // isDirectoryWritableOrCreatable + // ----------------------------------------------------------------------- + + @Test + void isDirectoryWritableOrCreatable_existingWritableDirectory_returnsTrue() { + assertTrue(adapter.isDirectoryWritableOrCreatable(tempDir.toString())); + } + + @Test + void isDirectoryWritableOrCreatable_nonExistentDirectoryWithWritableParent_returnsTrue() { + Path newDir = tempDir.resolve("new-sub-dir"); + assertTrue(adapter.isDirectoryWritableOrCreatable(newDir.toString())); + } + + @Test + void isDirectoryWritableOrCreatable_nonExistentDirectoryAndNonExistentParent_returnsFalse() { + Path deepAbsent = tempDir.resolve("ghost").resolve("deeply").resolve("nested"); + assertFalse(adapter.isDirectoryWritableOrCreatable(deepAbsent.toString())); + } + + @Test + void isDirectoryWritableOrCreatable_emptyString_returnsFalse() { + assertFalse(adapter.isDirectoryWritableOrCreatable("")); + } + + @Test + void isDirectoryWritableOrCreatable_nullValue_returnsFalse() { + assertFalse(adapter.isDirectoryWritableOrCreatable(null)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void isDirectoryWritableOrCreatable_invalidWindowsCharacters_returnsFalse() { + assertFalse(adapter.isDirectoryWritableOrCreatable("C:\\invalid?")); + } + + // ----------------------------------------------------------------------- + // isFileReadable + // ----------------------------------------------------------------------- + + @Test + void isFileReadable_existingReadableFile_returnsTrue() throws IOException { + Path file = Files.createFile(tempDir.resolve("readable.txt")); + assertTrue(adapter.isFileReadable(file.toString())); + } + + @Test + void isFileReadable_nonExistentFile_returnsFalse() { + Path absent = tempDir.resolve("missing.txt"); + assertFalse(adapter.isFileReadable(absent.toString())); + } + + @Test + void isFileReadable_existingDirectory_returnsFalse() { + assertFalse(adapter.isFileReadable(tempDir.toString())); + } + + @Test + void isFileReadable_emptyString_returnsFalse() { + assertFalse(adapter.isFileReadable("")); + } + + @Test + void isFileReadable_nullValue_returnsFalse() { + assertFalse(adapter.isFileReadable(null)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void isFileReadable_invalidWindowsCharacters_returnsFalse() { + assertFalse(adapter.isFileReadable("C:\\invalid?.txt")); + } + + // ----------------------------------------------------------------------- + // isSqlitePathUsable + // ----------------------------------------------------------------------- + + @Test + void isSqlitePathUsable_existingWritableFile_returnsTrue() throws IOException { + Path db = Files.createFile(tempDir.resolve("test.db")); + assertTrue(adapter.isSqlitePathUsable(db.toString())); + } + + @Test + void isSqlitePathUsable_nonExistentFileWithWritableParentDir_returnsTrue() { + Path newDb = tempDir.resolve("new.db"); + assertTrue(adapter.isSqlitePathUsable(newDb.toString())); + } + + @Test + void isSqlitePathUsable_nonExistentFileAndNonExistentParentDir_returnsFalse() { + Path deepAbsent = tempDir.resolve("ghost").resolve("sub.db"); + assertFalse(adapter.isSqlitePathUsable(deepAbsent.toString())); + } + + @Test + void isSqlitePathUsable_existingDirectory_returnsFalse() { + // Ein Verzeichnis ist kein gültiger SQLite-Dateipfad + assertFalse(adapter.isSqlitePathUsable(tempDir.toString())); + } + + @Test + void isSqlitePathUsable_emptyString_returnsFalse() { + assertFalse(adapter.isSqlitePathUsable("")); + } + + @Test + void isSqlitePathUsable_nullValue_returnsFalse() { + assertFalse(adapter.isSqlitePathUsable(null)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void isSqlitePathUsable_invalidWindowsCharacters_returnsFalse() { + assertFalse(adapter.isSqlitePathUsable("C:\\invalid?.db")); + } + + // ----------------------------------------------------------------------- + // Windows-Pfad-Semantik (Syntaxprüfung, kein echtes Laufwerk erforderlich) + // ----------------------------------------------------------------------- + + /** + * Stellt sicher, dass Pfade mit gemapptem Laufwerksbuchstaben syntaktisch akzeptiert + * werden (kein sofortiger Syntaxfehler). Das Ergebnis ist {@code false}, weil das + * Laufwerk in dieser Testumgebung nicht existiert — aber es darf nicht wegen des + * Laufwerksbuchstabens allein abgelehnt werden. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void windowsMappedDriveSyntax_isAcceptedByAdapter() { + // Ein Pfad mit gemapptem Laufwerksbuchstaben darf nicht wegen der Syntax abgelehnt + // werden. Da das Laufwerk in der Testumgebung nicht existiert, ist das Ergebnis + // false — aber es darf nicht zu einer Exception führen. + assertFalse(adapter.isDirectoryReadable("S:\\nonexistent-in-test")); + assertFalse(adapter.isDirectoryWritableOrCreatable("H:\\nonexistent-in-test")); + assertFalse(adapter.isFileReadable("X:\\nonexistent-in-test\\file.txt")); + assertFalse(adapter.isSqlitePathUsable("Z:\\nonexistent-in-test\\db.db")); + } + + /** + * Stellt sicher, dass UNC-Pfade syntaktisch akzeptiert werden. + * Das Ergebnis ist {@code false}, weil der Server nicht existiert. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void windowsUncPathSyntax_isAcceptedByAdapter() { + assertFalse(adapter.isDirectoryReadable("\\\\nonexistent-server\\share\\folder")); + } + + /** + * Stellt sicher, dass der Adapter auf dem lokalen temporären Verzeichnis korrekt + * arbeitet — dieses ist plattformübergreifend immer vorhanden. + */ + @Test + void tmpDirIsReadableAndWritableOrCreatable() { + String tmpDir = System.getProperty("java.io.tmpdir"); + assumeTrue(tmpDir != null && !tmpDir.isBlank(), "java.io.tmpdir must be set"); + assertTrue(adapter.isDirectoryReadable(tmpDir), + "java.io.tmpdir must be readable: " + tmpDir); + assertTrue(adapter.isDirectoryWritableOrCreatable(tmpDir), + "java.io.tmpdir must be writable: " + tmpDir); + } +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java new file mode 100644 index 0000000..4cb1459 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/resourcecreation/FilesystemResourceCreationAdapterTest.java @@ -0,0 +1,190 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Unit-Tests für {@link FilesystemResourceCreationAdapter}. + *

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

      + *
    • {@link Success} – der Prüfpunkt wurde bestanden.
    • + *
    • {@link Failure} – der Prüfpunkt wurde nicht bestanden (Fehler oder Warnung), + * optional mit einem Korrekturvorschlag.
    • + *
    • {@link NotApplicable} – der Prüfpunkt konnte wegen fehlender Voraussetzungen + * nicht ausgeführt werden (z. B. API-Key-Test ohne vorhandenen API-Key). + * Dies ist kein Fehler, sondern ein eigenständiger Zustand.
    • + *
    + *

    + * 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 correctionSuggestion) implements CheckpointResult { + + /** + * Erstellt ein Fehler-Ergebnis mit optionalem Korrekturvorschlag. + * + * @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein + * @param severity Schweregrad; darf nicht {@code null} sein + * @param message deutsche Fehlermeldung; darf nicht {@code null} sein + * @param correctionSuggestion optionaler Vorschlag; {@code null} wird zu leerem Optional + * @throws NullPointerException wenn {@code checkpointId}, {@code severity} oder {@code message} {@code null} sind + */ + public Failure { + Objects.requireNonNull(checkpointId, "checkpointId must not be null"); + Objects.requireNonNull(severity, "severity must not be null"); + Objects.requireNonNull(message, "message must not be null"); + correctionSuggestion = correctionSuggestion == null + ? Optional.empty() + : correctionSuggestion; + } + + /** + * Erstellt ein Fehler-Ergebnis ohne Korrekturvorschlag. + * + * @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein + * @param severity Schweregrad; darf nicht {@code null} sein + * @param message deutsche Fehlermeldung; darf nicht {@code null} sein + * @return ein neues Failure-Ergebnis ohne Korrekturvorschlag + */ + public static Failure of(CheckpointId checkpointId, CheckpointSeverity severity, String message) { + return new Failure(checkpointId, severity, message, Optional.empty()); + } + + /** + * Erstellt ein Fehler-Ergebnis mit einem Korrekturvorschlag. + * + * @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein + * @param severity Schweregrad; darf nicht {@code null} sein + * @param message deutsche Fehlermeldung; darf nicht {@code null} sein + * @param suggestion Korrekturvorschlag; darf nicht {@code null} sein + * @return ein neues Failure-Ergebnis mit Korrekturvorschlag + */ + public static Failure withCorrection(CheckpointId checkpointId, CheckpointSeverity severity, + String message, CorrectionSuggestion suggestion) { + Objects.requireNonNull(suggestion, "suggestion must not be null"); + return new Failure(checkpointId, severity, message, Optional.of(suggestion)); + } + + /** + * Gibt an, ob zu diesem Befund ein Korrekturvorschlag vorliegt. + * + * @return {@code true} wenn ein Korrekturvorschlag vorhanden ist + */ + public boolean hasCorrectionSuggestion() { + return correctionSuggestion.isPresent(); + } + } + + /** + * Der Prüfpunkt konnte wegen fehlender Voraussetzungen nicht ausgeführt werden. + *

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

      + *
    • {@link #WARNING} – riskante, aber technisch zulässige Einstellung. Das Speichern und + * ein späterer headless-Lauf sind möglich, können aber unerwartetes Verhalten zeigen.
    • + *
    • {@link #ERROR} – ungültige oder fehlende Konfiguration. Die Einstellung ist im + * aktuellen Zustand nicht lauffähig.
    • + *
    + *

    + * 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 outcomes) { + + /** + * Erstellt einen neuen Ausführungsbericht. + * + * @param outcomes Liste der Ausführungsergebnisse; darf nicht {@code null} sein + * @throws NullPointerException wenn {@code outcomes} {@code null} ist + */ + public CorrectionExecutionReport { + Objects.requireNonNull(outcomes, "outcomes must not be null"); + outcomes = List.copyOf(outcomes); + } + + /** + * Gibt an, ob alle Korrekturen erfolgreich angewendet wurden. + *

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

    Kein Frühabbruch

    + *

    + * Wenn eine Korrektur scheitert, laufen alle weiteren Korrekturen trotzdem weiter. + * Ein einzelnes {@link CorrectionOutcome.Failed} führt nicht zum Abbruch. + * + *

    Aufrufkonvention

    + *

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

    Thread-Safety

    + *

    + * 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 outcomes = new ArrayList<>(plan.size()); + + for (CorrectionSuggestion suggestion : plan.suggestions()) { + CorrectionOutcome outcome = dispatch(suggestion); + outcomes.add(outcome); + } + + return new CorrectionExecutionReport(outcomes); + } + + /** + * Dispatcht einen einzelnen {@link CorrectionSuggestion} an die passende Methode + * des {@link ResourceCreationPort}. + * + * @param suggestion der auszuführende Korrekturvorschlag; nie {@code null} + * @return Ausführungsergebnis; nie {@code null} + */ + private CorrectionOutcome dispatch(CorrectionSuggestion suggestion) { + return switch (suggestion) { + case CorrectionSuggestion.CreateDirectory cd -> port.createDirectory(cd); + case CorrectionSuggestion.CreatePromptFile cp -> port.createPromptFile(cp); + case CorrectionSuggestion.PrepareSqlitePath ps -> port.prepareSqlitePath(ps); + }; + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcome.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcome.java new file mode 100644 index 0000000..9bdfd89 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcome.java @@ -0,0 +1,104 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import java.util.Objects; + +/** + * Versiegeltes Ergebnis der Ausführung eines einzelnen {@link CorrectionSuggestion}-Vorschlags. + *

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

      + *
    • {@link Applied} – die Korrektur wurde erfolgreich durchgeführt.
    • + *
    • {@link Failed} – die Korrektur wurde versucht, aber ist technisch gescheitert.
    • + *
    • {@link NotAttempted} – die Korrektur wurde nicht versucht, obwohl sie im Plan enthalten war. + * Typischer Grund: eine Voraussetzung war zur Laufzeit nicht erfüllt.
    • + *
    + *

    + * 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 suggestions) { + + /** + * Erstellt einen neuen Korrekturplan. + * + * @param suggestions Liste der Korrekturvorschläge; darf nicht {@code null} sein + * @throws NullPointerException wenn {@code suggestions} {@code null} ist + */ + public CorrectionPlan { + Objects.requireNonNull(suggestions, "suggestions must not be null"); + suggestions = List.copyOf(suggestions); + } + + /** + * Erstellt einen leeren Korrekturplan ohne Maßnahmen. + *

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

      + *
    • Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)
    • + *
    • Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}
    • + *
    • Benennungsregeln für Titel (maximal 20 Zeichen, deutsch, keine Sonderzeichen)
    • + *
    • Hinweis auf das Datumsformat ({@code YYYY-MM-DD})
    • + *
    + *

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

      + *
    • der Ordner existiert und schreibbar ist, oder
    • + *
    • der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und + * schreibbar ist (anlegbar).
    • + *
    + * + * @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. + *

    + * Gibt {@code true} zurück, wenn: + *

      + *
    • die Datei existiert und les- und schreibbar ist, oder
    • + *
    • die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden + * und schreibbar ist (Datei wäre anlegbar).
    • + *
    + * + * @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. + *

    + * Dieser Service führt genau fünf providerbezogene Prüfpunkte aus: + *

      + *
    • {@link CheckpointId#BASE_URL_REACHABLE} – Endpoint technisch erreichbar
    • + *
    • {@link CheckpointId#API_KEY_PRESENT} – API-Schlüssel in mindestens einer Quelle vorhanden
    • + *
    • {@link CheckpointId#API_KEY_ACCEPTED} – Authentifizierung am Endpoint erfolgreich
    • + *
    • {@link CheckpointId#MODEL_LIST_AVAILABLE} – Provider liefert eine Modellliste
    • + *
    • {@link CheckpointId#SELECTED_MODEL_PLAUSIBLE} – konfiguriertes Modell in der Liste enthalten
    • + *
    + *

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

      + *
    • {@link ModelCatalogResult.Success}: alle fünf Prüfpunkte auswertbar; Modellplausibilität + * anhand der zurückgegebenen Liste geprüft.
    • + *
    • {@link ModelCatalogResult.EmptyList}: Endpoint und Key akzeptiert, aber keine Modellliste; + * Modellplausibilität nicht prüfbar.
    • + *
    • {@link ModelCatalogResult.IncompleteConfiguration}: Konfiguration unvollständig; kein + * HTTP-Aufruf vom Adapter durchgeführt.
    • + *
    • {@link ModelCatalogResult.TechnicalFailure}: abhängig vom Fehlerkategorie-String; + * Authentifizierungsfehler, Verbindungsfehler, Serverfehler und ungültige Antworten + * werden unterschiedlich auf die Prüfpunkte abgebildet.
    • + *
    + *

    + * 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 runProviderChecks(EditorValidationInput input) { + Objects.requireNonNull(input, "input must not be null"); + + Optional familyOpt = AiProviderFamily.fromIdentifier( + input.activeProviderIdentifier()); + + if (familyOpt.isEmpty()) { + String msg = "Aktiver Provider-Bezeichner unbekannt: \"" + + input.activeProviderIdentifier() + "\". Provider-Prüfungen können nicht ausgeführt werden."; + return List.of( + CheckpointResult.Failure.of(CheckpointId.API_KEY_PRESENT, CheckpointSeverity.ERROR, msg), + CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, CheckpointSeverity.ERROR, msg), + CheckpointResult.Failure.of(CheckpointId.API_KEY_ACCEPTED, CheckpointSeverity.ERROR, msg), + CheckpointResult.Failure.of(CheckpointId.MODEL_LIST_AVAILABLE, CheckpointSeverity.ERROR, msg), + CheckpointResult.Failure.of(CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.ERROR, msg) + ); + } + + AiProviderFamily family = familyOpt.get(); + // Den bereits im EditorValidationInput enthaltenen Descriptor verwenden. + // EditorValidationInput enthält keinen rohen API-Key-String, sondern nur den + // vom GUI-Adapter bereits aufgelösten Descriptor. Dieser spiegelt die + // API-Key-Vorrangregel (ENV → Legacy-ENV → Property) wider. + EffectiveApiKeyDescriptor apiKeyDescriptor = resolveApiKeyDescriptor(input, family); + + // Prüfpunkt API_KEY_PRESENT: ohne HTTP-Aufruf + CheckpointResult apiKeyPresentResult = checkApiKeyPresent(apiKeyDescriptor); + + if (apiKeyDescriptor.isAbsent()) { + // Kein API-Key → alle Remote-Prüfpunkte als NotApplicable markieren + String reason = "Kein API-Schlüssel vorhanden. Remote-Prüfungen können nicht ausgeführt werden."; + return List.of( + apiKeyPresentResult, + new CheckpointResult.NotApplicable(CheckpointId.BASE_URL_REACHABLE, reason), + new CheckpointResult.NotApplicable(CheckpointId.API_KEY_ACCEPTED, reason), + new CheckpointResult.NotApplicable(CheckpointId.MODEL_LIST_AVAILABLE, reason), + new CheckpointResult.NotApplicable(CheckpointId.SELECTED_MODEL_PLAUSIBLE, reason) + ); + } + + // API-Key vorhanden → Modellabruf durchführen + String configuredModel = resolveModelValue(input, family); + ModelCatalogRequest catalogRequest = buildCatalogRequest(input, family, apiKeyDescriptor); + + ModelCatalogResult catalogResult = modelCatalogPort.fetchAvailableModels(catalogRequest); + + List results = new ArrayList<>(); + results.add(apiKeyPresentResult); + results.addAll(mapCatalogResultToCheckpoints(catalogResult, configuredModel)); + return List.copyOf(results); + } + + // ------------------------------------------------------------------ helpers + + /** + * Erzeugt das {@link CheckpointResult} für {@link CheckpointId#API_KEY_PRESENT}. + * + * @param descriptor Herkunftsdeskriptor des API-Schlüssels + * @return Success wenn ein Schlüssel vorhanden ist, Failure ERROR sonst + */ + private CheckpointResult checkApiKeyPresent(EffectiveApiKeyDescriptor descriptor) { + if (descriptor.isAbsent()) { + return CheckpointResult.Failure.of( + CheckpointId.API_KEY_PRESENT, + CheckpointSeverity.ERROR, + "Kein API-Schlüssel vorhanden. Weder Umgebungsvariable noch Properties-Datei liefert einen Wert."); + } + String sourceInfo = descriptor.isFromEnvironmentVariable() + ? "Umgebungsvariable " + descriptor.envVarName().orElse("(unbekannt)") + : "Properties-Datei"; + return new CheckpointResult.Success( + CheckpointId.API_KEY_PRESENT, + "API-Schlüssel vorhanden (Quelle: " + sourceInfo + ")."); + } + + /** + * Bildet ein {@link ModelCatalogResult} auf die vier Remote-Prüfpunkte ab. + * + * @param result Ergebnis des Modellabrufs + * @param configuredModel konfigurierter Modellname aus dem Editor + * @return Liste mit genau vier Prüfpunkt-Ergebnissen + */ + private List mapCatalogResultToCheckpoints(ModelCatalogResult result, + String configuredModel) { + return switch (result) { + case ModelCatalogResult.Success success -> mapSuccess(success, configuredModel); + case ModelCatalogResult.EmptyList emptyList -> mapEmptyList(); + case ModelCatalogResult.IncompleteConfiguration incomplete -> mapIncompleteConfiguration(incomplete); + case ModelCatalogResult.TechnicalFailure failure -> mapTechnicalFailure(failure); + }; + } + + private List mapSuccess(ModelCatalogResult.Success success, String configuredModel) { + CheckpointResult baseUrl = new CheckpointResult.Success( + CheckpointId.BASE_URL_REACHABLE, "Endpoint erreichbar."); + CheckpointResult apiKeyAccepted = new CheckpointResult.Success( + CheckpointId.API_KEY_ACCEPTED, "API-Schlüssel akzeptiert."); + CheckpointResult modelList = new CheckpointResult.Success( + CheckpointId.MODEL_LIST_AVAILABLE, + "Modellliste verfügbar (" + success.models().size() + " Modell(e))."); + CheckpointResult modelPlausible = checkModelPlausible(success.models(), configuredModel); + return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible); + } + + private List mapEmptyList() { + CheckpointResult baseUrl = new CheckpointResult.Success( + CheckpointId.BASE_URL_REACHABLE, "Endpoint erreichbar."); + CheckpointResult apiKeyAccepted = new CheckpointResult.Success( + CheckpointId.API_KEY_ACCEPTED, "API-Schlüssel akzeptiert."); + CheckpointResult modelList = CheckpointResult.Failure.of( + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointSeverity.WARNING, + "Provider liefert keine Modellliste."); + CheckpointResult modelPlausible = new CheckpointResult.NotApplicable( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Keine Modellliste vorhanden, Modellplausibilität nicht prüfbar."); + return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible); + } + + private List mapIncompleteConfiguration(ModelCatalogResult.IncompleteConfiguration incomplete) { + String reason = incomplete.missingReason(); + CheckpointResult baseUrl = new CheckpointResult.NotApplicable( + CheckpointId.BASE_URL_REACHABLE, + "Konfiguration unvollständig – kein Verbindungsversuch: " + reason); + CheckpointResult apiKeyAccepted = new CheckpointResult.NotApplicable( + CheckpointId.API_KEY_ACCEPTED, + "Konfiguration unvollständig – Authentifizierung nicht prüfbar."); + CheckpointResult modelList = CheckpointResult.Failure.of( + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointSeverity.ERROR, + "Provider-Konfiguration unvollständig: " + reason); + CheckpointResult modelPlausible = new CheckpointResult.NotApplicable( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Konfiguration unvollständig – Modellplausibilität nicht prüfbar."); + return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible); + } + + private List mapTechnicalFailure(ModelCatalogResult.TechnicalFailure failure) { + String category = failure.errorCategory().toUpperCase(); + String detail = failure.errorDetail(); + + if (category.contains(CATEGORY_AUTHENTICATION_FAILED)) { + return List.of( + new CheckpointResult.Success( + CheckpointId.BASE_URL_REACHABLE, + "Endpoint hat geantwortet (Authentifizierungsfehler erhalten)."), + CheckpointResult.Failure.of( + CheckpointId.API_KEY_ACCEPTED, + CheckpointSeverity.ERROR, + "API-Schlüssel technisch nicht akzeptiert: " + detail), + new CheckpointResult.NotApplicable( + CheckpointId.MODEL_LIST_AVAILABLE, + "Authentifizierung fehlgeschlagen – Modellliste nicht abrufbar."), + new CheckpointResult.NotApplicable( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Authentifizierung fehlgeschlagen – Modellplausibilität nicht prüfbar.") + ); + } + + if (category.contains(CATEGORY_CONNECTION_FAILURE) || category.contains(CATEGORY_ENDPOINT_NOT_FOUND)) { + String baseUrlMessage = category.contains(CATEGORY_ENDPOINT_NOT_FOUND) + ? "Endpoint nicht gefunden: " + detail + : "Verbindung zum Endpoint fehlgeschlagen: " + detail; + return List.of( + CheckpointResult.Failure.of( + CheckpointId.BASE_URL_REACHABLE, + CheckpointSeverity.ERROR, + baseUrlMessage), + new CheckpointResult.NotApplicable( + CheckpointId.API_KEY_ACCEPTED, + "Endpoint nicht erreichbar – Authentifizierung nicht prüfbar."), + new CheckpointResult.NotApplicable( + CheckpointId.MODEL_LIST_AVAILABLE, + "Endpoint nicht erreichbar – Modellliste nicht abrufbar."), + new CheckpointResult.NotApplicable( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Endpoint nicht erreichbar – Modellplausibilität nicht prüfbar.") + ); + } + + if (category.contains(CATEGORY_SERVER_ERROR)) { + return List.of( + new CheckpointResult.Success( + CheckpointId.BASE_URL_REACHABLE, + "Endpoint hat geantwortet (Serverfehler erhalten)."), + new CheckpointResult.NotApplicable( + CheckpointId.API_KEY_ACCEPTED, + "Serverfehler – Authentifizierung nicht eindeutig prüfbar."), + CheckpointResult.Failure.of( + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointSeverity.WARNING, + "Provider antwortet mit Serverfehler: " + detail), + new CheckpointResult.NotApplicable( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Serverfehler – Modellplausibilität nicht prüfbar.") + ); + } + + if (category.contains(CATEGORY_INVALID_RESPONSE)) { + return List.of( + new CheckpointResult.Success( + CheckpointId.BASE_URL_REACHABLE, + "Endpoint hat geantwortet (Antwort nicht verarbeitbar)."), + new CheckpointResult.NotApplicable( + CheckpointId.API_KEY_ACCEPTED, + "Antwort nicht parsierbar – Authentifizierung nicht eindeutig prüfbar."), + CheckpointResult.Failure.of( + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointSeverity.ERROR, + "Antwort des Providers nicht verarbeitbar: " + detail), + new CheckpointResult.NotApplicable( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Antwort nicht parsierbar – Modellplausibilität nicht prüfbar.") + ); + } + + // Unbekannte Fehlerkategorie + String unknownMsg = "Unbekannter technischer Fehler beim Modellabruf: " + detail; + return List.of( + CheckpointResult.Failure.of( + CheckpointId.BASE_URL_REACHABLE, + CheckpointSeverity.ERROR, + unknownMsg), + CheckpointResult.Failure.of( + CheckpointId.API_KEY_ACCEPTED, + CheckpointSeverity.ERROR, + unknownMsg), + CheckpointResult.Failure.of( + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointSeverity.ERROR, + unknownMsg), + CheckpointResult.Failure.of( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + CheckpointSeverity.ERROR, + unknownMsg) + ); + } + + /** + * Prüft, ob das konfigurierte Modell in der Modellliste enthalten ist. + * + * @param models verfügbare Modelle vom Provider + * @param configuredModel konfigurierter Modellname aus dem Editor + * @return Success wenn das Modell enthalten ist, Failure WARNING sonst + */ + private CheckpointResult checkModelPlausible(List models, String configuredModel) { + if (configuredModel.isBlank()) { + return CheckpointResult.Failure.of( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + CheckpointSeverity.ERROR, + "Kein Modell konfiguriert."); + } + if (models.contains(configuredModel)) { + return new CheckpointResult.Success( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + "Konfiguriertes Modell \"" + configuredModel + "\" in verfügbarer Liste gefunden."); + } + return CheckpointResult.Failure.of( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, + CheckpointSeverity.WARNING, + "Konfiguriertes Modell \"" + configuredModel + + "\" nicht in verfügbarer Liste gefunden. Bitte Modellname prüfen."); + } + + /** + * Baut den {@link ModelCatalogRequest} aus dem aktuellen Editorzustand auf. + *

    + * 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 apiKeyForRequest = Optional.empty(); + + String rawBaseUrl = resolveBaseUrlValue(input, family); + Optional baseUrl = rawBaseUrl.isBlank() ? Optional.empty() : Optional.of(rawBaseUrl); + + int timeout = parseTimeoutOrDefault(resolveTimeoutValue(input, family)); + + return new ModelCatalogRequest( + family.getIdentifier(), + baseUrl, + apiKeyForRequest, + timeout); + } + + /** + * Liest den bereits aufgelösten {@link EffectiveApiKeyDescriptor} für die aktive Provider-Familie + * direkt aus dem {@link EditorValidationInput}. + *

    + * {@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: + *

      + *
    1. Lokale Validierung: Prüft den Editorzustand ohne I/O mithilfe des + * {@link EditorConfigurationValidator}. Erzeugt Ergebnisse für + * {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und + * {@link CheckpointId#PROVIDER_CONFIGURATION}.
    2. + *
    3. Pfadprüfungen: Prüft Quellordner, Zielordner, Prompt-Datei und + * SQLite-Pfad über den {@link PathCheckPort}. Erzeugt Ergebnisse für + * {@link CheckpointId#PROMPT_FILE_PRESENT}, {@link CheckpointId#SOURCE_FOLDER_PRESENT}, + * {@link CheckpointId#TARGET_FOLDER_USABLE} und {@link CheckpointId#SQLITE_PATH_USABLE}.
    4. + *
    5. Provider-Prüfungen: Prüft Endpoint, API-Key, Modellliste und + * Modellplausibilität über den {@link ProviderTechnicalTestService}. Erzeugt Ergebnisse für + * {@link CheckpointId#BASE_URL_REACHABLE}, {@link CheckpointId#API_KEY_PRESENT}, + * {@link CheckpointId#API_KEY_ACCEPTED}, {@link CheckpointId#MODEL_LIST_AVAILABLE} + * und {@link CheckpointId#SELECTED_MODEL_PLAUSIBLE}.
    6. + *
    + *

    + * 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 /prompt.txt}). Ist auch kein Konfigurationsdateipfad gesetzt, + * wird {@code config/prompt.txt} relativ zum Arbeitsverzeichnis verwendet. + *

    + * 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 /prompt.txt}. Falls kein Konfigurationsdateipfad gesetzt ist, + * lautet der Fallback {@code config/prompt.txt} relativ zum Arbeitsverzeichnis. + *

    + * 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 results = new ArrayList<>(11); + + // Block 1: Lokale Konfigurationsvalidierung (kein I/O) + results.addAll(runLocalValidationBlock(input)); + + // Block 2: Pfadprüfungen (Dateisystem-I/O) + results.addAll(runPathCheckBlock(input, request.configFilePath())); + + // Block 3: Provider-nahe technische Prüfungen (Netzwerk-I/O) + results.addAll(runProviderCheckBlock(input)); + + return new TechnicalTestReport(results, startTime); + } + + // ========================================================================= + // Block 1: Lokale Konfigurationsvalidierung + // ========================================================================= + + /** + * Führt die lokale Konfigurationsvalidierung durch und bildet das Ergebnis auf + * {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und + * {@link CheckpointId#PROVIDER_CONFIGURATION} ab. + * + * @param input aktueller Editorzustand + * @return Liste mit genau zwei Einträgen + */ + private List runLocalValidationBlock(EditorValidationInput input) { + try { + EditorValidationReport report = editorValidator.validate(input); + return mapLocalValidationToCheckpoints(report); + } catch (Exception e) { + String errorMsg = "Interner Fehler bei der lokalen Konfigurationsvalidierung: " + e.getMessage(); + return List.of( + CheckpointResult.Failure.of(CheckpointId.CONFIGURATION_BASIC_VALIDATION, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.PROVIDER_CONFIGURATION, + CheckpointSeverity.ERROR, errorMsg) + ); + } + } + + /** + * Bildet den {@link EditorValidationReport} auf die zwei lokalen Prüfpunkte ab. + *

    + * 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 mapLocalValidationToCheckpoints(EditorValidationReport report) { + // Trennen: allgemeine Befunde vs. provider-spezifische Befunde + List generalFindings = report.findings().stream() + .filter(f -> !isProviderSpecificField(f.fieldKey().orElse(""))) + .toList(); + List providerFindings = report.findings().stream() + .filter(f -> isProviderSpecificField(f.fieldKey().orElse(""))) + .toList(); + + CheckpointResult basicValidation = buildCheckpointFromFindings( + CheckpointId.CONFIGURATION_BASIC_VALIDATION, + generalFindings, + "Konfiguration grundsätzlich gültig."); + + CheckpointResult providerValidation = buildCheckpointFromFindings( + CheckpointId.PROVIDER_CONFIGURATION, + providerFindings, + "Provider-Konfiguration vollständig."); + + return List.of(basicValidation, providerValidation); + } + + /** + * Prüft, ob ein Feldschlüssel zu einem provider-spezifischen Feld gehört. + * + * @param fieldKey Property-Schlüssel + * @return {@code true} wenn es ein provider-spezifisches Feld ist + */ + private static boolean isProviderSpecificField(String fieldKey) { + return fieldKey.startsWith("ai.provider.claude.") + || fieldKey.startsWith("ai.provider.openai-compatible."); + } + + /** + * Erzeugt ein {@link CheckpointResult} aus einer Liste von Befunden. + * + * @param id Prüfpunkt-ID + * @param findings Liste der relevanten Befunde + * @param successMessage Meldung bei leerem Befund-Ergebnis + * @return Success bei leerer Befund-Liste, Failure andernfalls + */ + private static CheckpointResult buildCheckpointFromFindings(CheckpointId id, + List findings, + String successMessage) { + if (findings.isEmpty()) { + return new CheckpointResult.Success(id, successMessage); + } + + // Höchsten Schweregrad bestimmen + boolean hasError = findings.stream() + .anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR); + CheckpointSeverity severity = hasError ? CheckpointSeverity.ERROR : CheckpointSeverity.WARNING; + + // Befunde zusammenfassen + String summary = findings.size() == 1 + ? findings.get(0).message() + : findings.size() + " Befunde: " + findings.get(0).message() + + (findings.size() > 1 ? " (und " + (findings.size() - 1) + " weitere)" : ""); + + return CheckpointResult.Failure.of(id, severity, summary); + } + + // ========================================================================= + // Block 2: Pfadprüfungen + // ========================================================================= + + /** + * Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner + * und SQLite-Pfad durch. + *

    + * Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand + * einen sinnvollen Standardpfad zu bestimmen ({@code /prompt.txt}). + * + * @param input aktueller Editorzustand + * @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen + * @return Liste mit genau vier Einträgen + */ + private List runPathCheckBlock(EditorValidationInput input, + String configFilePath) { + try { + List results = new ArrayList<>(4); + results.add(checkPromptFile(input.promptTemplateFile(), configFilePath)); + results.add(checkSourceFolder(input.sourceFolder())); + results.add(checkTargetFolder(input.targetFolder())); + results.add(checkSqlitePath(input.sqliteFile())); + return results; + } catch (Exception e) { + String errorMsg = "Interner Fehler bei den Pfadprüfungen: " + e.getMessage(); + return List.of( + CheckpointResult.Failure.of(CheckpointId.PROMPT_FILE_PRESENT, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE, + CheckpointSeverity.ERROR, errorMsg) + ); + } + } + + /** + * Prüft die Prompt-Datei auf Vorhandensein und Lesbarkeit. + *

    + * Pfad-Auflösung: Wenn der konfigurierte Prompt-Pfad leer ist, + * wird ein Standardpfad bestimmt: + *

      + *
    • Wenn {@code configFilePath} gesetzt ist: {@code /prompt.txt}
    • + *
    • Sonst: {@code config/prompt.txt} relativ zum Arbeitsverzeichnis
    • + *
    + *

    + * 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 /prompt.txt}. Falls auch der Konfigurationsdateipfad + * leer ist, lautet der Fallback {@code config/prompt.txt}. + * + * @param configuredPath konfigurierter Prompt-Pfad; kann leer sein + * @param configFilePath Pfad der geladenen Konfigurationsdatei; kann leer sein + * @return effektiver Prompt-Pfad; nie {@code null}, nie leer + */ + static String resolvePromptPath(String configuredPath, String configFilePath) { + if (!configuredPath.isBlank()) { + return configuredPath; + } + // Standardpfad aus dem Konfigurationsdatei-Elternordner ableiten + if (!configFilePath.isBlank()) { + String parent = extractParentPath(configFilePath); + if (!parent.isBlank()) { + return parent + File.separator + "prompt.txt"; + } + } + // Absoluter Fallback + return "config" + File.separator + "prompt.txt"; + } + + /** + * Extrahiert den Elternpfad aus einem Dateipfad. + *

    + * 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 runProviderCheckBlock(EditorValidationInput input) { + try { + return providerTestService.runProviderChecks(input); + } catch (Exception e) { + String errorMsg = "Interner Fehler bei den Provider-Prüfungen: " + e.getMessage(); + return List.of( + CheckpointResult.Failure.of(CheckpointId.API_KEY_PRESENT, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.API_KEY_ACCEPTED, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointSeverity.ERROR, errorMsg), + CheckpointResult.Failure.of(CheckpointId.SELECTED_MODEL_PLAUSIBLE, + CheckpointSeverity.ERROR, errorMsg) + ); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReport.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReport.java new file mode 100644 index 0000000..9866df3 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReport.java @@ -0,0 +1,109 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Ergebnis eines vollständigen technischen Gesamttests. + *

    + * 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 results, + Instant evaluatedAt) { + + /** + * Erstellt einen neuen Gesamttestbericht. + * + * @param results Ergebnisliste; darf nicht {@code null} sein + * @param evaluatedAt Startzeitpunkt des Tests; darf nicht {@code null} sein + * @throws NullPointerException wenn ein Parameter {@code null} ist + */ + public TechnicalTestReport { + Objects.requireNonNull(results, "results must not be null"); + Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null"); + results = List.copyOf(results); + } + + /** + * Gibt an, ob mindestens ein Prüfpunkt mit Schweregrad {@link CheckpointSeverity#ERROR} + * gescheitert ist. + *

    + * 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 suggestions = results.stream() + .filter(r -> r instanceof CheckpointResult.Failure) + .map(r -> (CheckpointResult.Failure) r) + .filter(CheckpointResult.Failure::hasCorrectionSuggestion) + .map(f -> f.correctionSuggestion().orElseThrow()) + .toList(); + return new CorrectionPlan(suggestions); + } + + /** + * Gibt die Gesamtzahl der Prüfpunkt-Ergebnisse zurück. + * + * @return Anzahl der Ergebnisse; nie negativ + */ + public int size() { + return results.size(); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequest.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequest.java new file mode 100644 index 0000000..ffbb8c2 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequest.java @@ -0,0 +1,58 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import java.util.Objects; + +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput; + +/** + * Eingabedaten für einen vollständigen technischen Gesamttest der GUI-Konfiguration. + *

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

      + *
    • Eingabe- und Ergebnismodelle für den vollständigen Gesamttest ({@code TechnicalTestRequest}, + * {@code TechnicalTestReport}, {@code CheckpointResult}, {@code CheckpointId})
    • + *
    • Korrekturmodelle für schreibende Korrekturhilfen ({@code CorrectionSuggestion}, + * {@code CorrectionPlan}, {@code CorrectionOutcome}, {@code CorrectionExecutionReport})
    • + *
    • Outbound-Port-Verträge für Pfadprüfungen ({@code PathCheckPort}) und schreibende + * Korrekturen ({@code ResourceCreationPort})
    • + *
    + *

    + * Abgrenzungen: + *

      + *
    • Dieses Package enthält keine konkreten Implementierungen; diese + * leben im Adapter-Out-Modul.
    • + *
    • Keine JavaFX-, NIO-Framework-, HTTP- oder JDBC-Bibliothekstypen. Standard-JDK-Typen + * wie {@code java.nio.file.Path} sind ebenfalls nicht in Port-Signaturen erlaubt; + * die Ports verwenden {@code String} als plattformneutralen Pfadtyp.
    • + *
    • Die Gesamttest-Orchestrierung und die Bestätigungslogik liegen in späteren + * Arbeitspaketen; dieses Package definiert nur die Verträge.
    • + *
    • Die automatische Hintergrundvalidierung (Öffnen/Bearbeiten) sowie die explizite + * Aktion „Validieren" (nicht schreibend, lokal) sind im Package + * {@code validation.editor} definiert und bleiben dort unverändert.
    • + *
    + */ +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(); + mutable.add(new CorrectionOutcome.Applied(s1, "ok")); + var report = new CorrectionExecutionReport(mutable); + mutable.add(new CorrectionOutcome.Failed(s2, "err")); + assertThat(report.outcomes()).hasSize(1); + } + + @Test + void nullOutcomesThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new CorrectionExecutionReport(null)); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java new file mode 100644 index 0000000..5516c83 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionExecutionServiceTest.java @@ -0,0 +1,163 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit-Tests für {@link CorrectionExecutionService}. + *

    + * 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 void assertInstanceOf(Class expectedType, Object actual) { + assertTrue(expectedType.isInstance(actual), + "Expected instance of " + expectedType.getSimpleName() + + " but got " + (actual == null ? "null" : actual.getClass().getSimpleName())); + } + + // ========================================================================= + // Tests: CreatePromptFile-Dispatch prüft DefaultPromptTemplate-Inhalt + // ========================================================================= + + /** + * Der {@link CorrectionExecutionService} dispatcht {@link CorrectionSuggestion.CreatePromptFile} + * an den Port. Ein Port-Stub, der den Inhalt der Suggestion zurückgibt, muss den + * deutschen Standardinhalt aus {@link DefaultPromptTemplate#defaultContent()} enthalten, + * wenn der Adapter ihn korrekt befüllt. Hier prüfen wir lediglich, dass + * {@link DefaultPromptTemplate#defaultContent()} einen sinnvollen deutschen Text liefert, + * der für die Dispatch-Kette geeignet ist. + */ + @Test + void createPromptFile_dispatch_defaultContentIsGermanAndNonEmpty() { + // Der Dispatch selbst ist im Service zustandslos. + // Wir prüfen hier, dass DefaultPromptTemplate den benötigten Inhalt liefert, + // damit der Adapter ihn verwenden kann. + String content = DefaultPromptTemplate.defaultContent(); + assertNotNull(content); + assertFalse(content.isBlank(), "DefaultPromptTemplate.defaultContent() darf nicht leer sein"); + assertTrue(content.contains("Titel"), "Inhalt muss deutsches Schlüsselwort 'Titel' enthalten"); + assertTrue(content.contains("date"), "Inhalt muss JSON-Feld 'date' beschreiben"); + assertTrue(content.contains("reasoning"), "Inhalt muss JSON-Feld 'reasoning' beschreiben"); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcomeTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcomeTest.java new file mode 100644 index 0000000..c8b28f0 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionOutcomeTest.java @@ -0,0 +1,67 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests für das versiegelte Interface {@link CorrectionOutcome} und seine Untertypen. + */ +class CorrectionOutcomeTest { + + private final CorrectionSuggestion suggestion = + new CorrectionSuggestion.CreateDirectory("/some/dir", "Ordner anlegen"); + + @Test + void applied_storesSuggestionAndMessage() { + var outcome = new CorrectionOutcome.Applied(suggestion, "Ordner wurde angelegt"); + assertThat(outcome.suggestion()).isSameAs(suggestion); + assertThat(outcome.message()).isEqualTo("Ordner wurde angelegt"); + } + + @Test + void applied_nullSuggestionThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new CorrectionOutcome.Applied(null, "msg")); + } + + @Test + void failed_storesSuggestionAndErrorMessage() { + var outcome = new CorrectionOutcome.Failed(suggestion, "Zugriff verweigert"); + assertThat(outcome.suggestion()).isSameAs(suggestion); + assertThat(outcome.errorMessage()).isEqualTo("Zugriff verweigert"); + } + + @Test + void failed_nullErrorMessageThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new CorrectionOutcome.Failed(suggestion, null)); + } + + @Test + void notAttempted_storesSuggestionAndReason() { + var outcome = new CorrectionOutcome.NotAttempted(suggestion, "Elternordner nicht erreichbar"); + assertThat(outcome.suggestion()).isSameAs(suggestion); + assertThat(outcome.reason()).isEqualTo("Elternordner nicht erreichbar"); + } + + @Test + void patternMatching_coversAllPermittedTypes() { + CorrectionOutcome applied = new CorrectionOutcome.Applied(suggestion, "ok"); + CorrectionOutcome failed = new CorrectionOutcome.Failed(suggestion, "error"); + CorrectionOutcome notAttempted = new CorrectionOutcome.NotAttempted(suggestion, "reason"); + + assertThat(classify(applied)).isEqualTo("applied"); + assertThat(classify(failed)).isEqualTo("failed"); + assertThat(classify(notAttempted)).isEqualTo("notAttempted"); + } + + private String classify(CorrectionOutcome outcome) { + return switch (outcome) { + case CorrectionOutcome.Applied a -> "applied"; + case CorrectionOutcome.Failed f -> "failed"; + case CorrectionOutcome.NotAttempted n -> "notAttempted"; + }; + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java new file mode 100644 index 0000000..a9da296 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionPlanTest.java @@ -0,0 +1,54 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests für {@link CorrectionPlan}. + */ +class CorrectionPlanTest { + + @Test + void empty_hasNoCorrections() { + var plan = CorrectionPlan.empty(); + assertThat(plan.hasCorrections()).isFalse(); + assertThat(plan.size()).isZero(); + assertThat(plan.suggestions()).isEmpty(); + } + + @Test + void plan_withSuggestions_hasCorrections() { + var s = new CorrectionSuggestion.CreateDirectory("/path", "desc"); + var plan = new CorrectionPlan(List.of(s)); + assertThat(plan.hasCorrections()).isTrue(); + assertThat(plan.size()).isEqualTo(1); + } + + @Test + void suggestionsListIsImmutable() { + var mutable = new ArrayList(); + mutable.add(new CorrectionSuggestion.CreateDirectory("/a", "d1")); + var plan = new CorrectionPlan(mutable); + mutable.add(new CorrectionSuggestion.CreatePromptFile("/b", "d2")); + assertThat(plan.suggestions()).hasSize(1); + } + + @Test + void nullSuggestionsThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new CorrectionPlan(null)); + } + + @Test + void plan_equality() { + var s = new CorrectionSuggestion.CreateDirectory("/path", "desc"); + var a = new CorrectionPlan(List.of(s)); + var b = new CorrectionPlan(List.of(s)); + assertThat(a).isEqualTo(b); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java new file mode 100644 index 0000000..5348eda --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/CorrectionSuggestionTest.java @@ -0,0 +1,85 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests für das versiegelte Interface {@link CorrectionSuggestion} und seine Untertypen. + */ +class CorrectionSuggestionTest { + + // --- CreateDirectory --- + + @Test + void createDirectory_storesPathAndDescription() { + var s = new CorrectionSuggestion.CreateDirectory("/path/to/dir", "Ordner anlegen"); + assertThat(s.path()).isEqualTo("/path/to/dir"); + assertThat(s.descriptionForUser()).isEqualTo("Ordner anlegen"); + } + + @Test + void createDirectory_blankPathThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CorrectionSuggestion.CreateDirectory(" ", "desc")); + } + + @Test + void createDirectory_nullPathThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new CorrectionSuggestion.CreateDirectory(null, "desc")); + } + + // --- CreatePromptFile --- + + @Test + void createPromptFile_storesPathAndDescription() { + var s = new CorrectionSuggestion.CreatePromptFile("/config/prompt.txt", "Prompt-Datei erzeugen"); + assertThat(s.path()).isEqualTo("/config/prompt.txt"); + assertThat(s.descriptionForUser()).isEqualTo("Prompt-Datei erzeugen"); + } + + @Test + void createPromptFile_blankPathThrows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CorrectionSuggestion.CreatePromptFile("", "desc")); + } + + // --- PrepareSqlitePath --- + + @Test + void prepareSqlitePath_storesPathAndDescription() { + var s = new CorrectionSuggestion.PrepareSqlitePath("/data/store.db", "SQLite-Pfad vorbereiten"); + assertThat(s.path()).isEqualTo("/data/store.db"); + assertThat(s.descriptionForUser()).isEqualTo("SQLite-Pfad vorbereiten"); + } + + @Test + void prepareSqlitePath_nullDescriptionThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new CorrectionSuggestion.PrepareSqlitePath("/path", null)); + } + + // --- Pattern Matching --- + + @Test + void patternMatching_coversAllPermittedTypes() { + CorrectionSuggestion dir = new CorrectionSuggestion.CreateDirectory("/a", "d1"); + CorrectionSuggestion prompt = new CorrectionSuggestion.CreatePromptFile("/b", "d2"); + CorrectionSuggestion sqlite = new CorrectionSuggestion.PrepareSqlitePath("/c", "d3"); + + assertThat(classify(dir)).isEqualTo("directory"); + assertThat(classify(prompt)).isEqualTo("promptFile"); + assertThat(classify(sqlite)).isEqualTo("sqlitePath"); + } + + private String classify(CorrectionSuggestion s) { + return switch (s) { + case CorrectionSuggestion.CreateDirectory d -> "directory"; + case CorrectionSuggestion.CreatePromptFile p -> "promptFile"; + case CorrectionSuggestion.PrepareSqlitePath sp -> "sqlitePath"; + }; + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java new file mode 100644 index 0000000..50661f3 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplateTest.java @@ -0,0 +1,58 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Unit-Tests für {@link DefaultPromptTemplate}. + *

    + * 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 results = service.runProviderChecks( + claudeInput(keyAbsent(), "claude-3-sonnet")); + + assertThat(results).hasSize(5); + assertThat(findById(results, CheckpointId.API_KEY_PRESENT)) + .isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) findById(results, CheckpointId.API_KEY_PRESENT)).severity()) + .isEqualTo(CheckpointSeverity.ERROR); + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.MODEL_LIST_AVAILABLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + @Test + void givenApiKeyAbsent_noModelCatalogCallIsMade() { + boolean[] called = {false}; + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + request -> { called[0] = true; return new ModelCatalogResult.EmptyList("claude", Instant.now()); }, + apiKeyPort(keyAbsent())); + + service.runProviderChecks(claudeInput(keyAbsent(), "claude-3-sonnet")); + + assertThat(called[0]).isFalse(); + } + + // ------------------------------------------------------------------ Tests: Success mit passendem Modell + + @Test + void givenSuccessWithMatchingModel_allFiveCheckpointsSucceed() { + List models = List.of("claude-3-sonnet", "claude-3-opus"); + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + assertThat(results).hasSize(5); + assertThat(findById(results, CheckpointId.API_KEY_PRESENT)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.MODEL_LIST_AVAILABLE)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)).isInstanceOf(CheckpointResult.Success.class); + } + + @Test + void givenSuccessWithMatchingModel_apiKeyPresentMentionsEnvSource() { + List models = List.of("gpt-4"); + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.Success("openai-compatible", models, Instant.now())), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + openaiInput(keyFromEnv(), "gpt-4")); + + CheckpointResult apiKeyResult = findById(results, CheckpointId.API_KEY_PRESENT); + assertThat(apiKeyResult).isInstanceOf(CheckpointResult.Success.class); + assertThat(((CheckpointResult.Success) apiKeyResult).message()).contains("Umgebungsvariable"); + } + + @Test + void givenSuccessWithMatchingModel_apiKeyPresentMentionsPropertySource() { + List models = List.of("claude-3-opus"); + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())), + apiKeyPort(keyFromProperty())); + + List results = service.runProviderChecks( + claudeInput(keyFromProperty(), "claude-3-opus")); + + CheckpointResult apiKeyResult = findById(results, CheckpointId.API_KEY_PRESENT); + assertThat(apiKeyResult).isInstanceOf(CheckpointResult.Success.class); + assertThat(((CheckpointResult.Success) apiKeyResult).message()).contains("Properties-Datei"); + } + + // ------------------------------------------------------------------ Tests: Success ohne passendes Modell + + @Test + void givenSuccessWithoutMatchingModel_selectedModelPlausibleIsFailureWarning() { + List models = List.of("claude-3-opus", "claude-3-haiku"); + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "nonexistent-model")); + + CheckpointResult plausibleResult = findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE); + assertThat(plausibleResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) plausibleResult).severity()).isEqualTo(CheckpointSeverity.WARNING); + assertThat(((CheckpointResult.Failure) plausibleResult).message()).contains("nonexistent-model"); + } + + @Test + void givenSuccessWithBlankModel_selectedModelPlausibleIsFailureError() { + List models = List.of("claude-3-sonnet"); + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "")); + + CheckpointResult plausibleResult = findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE); + assertThat(plausibleResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) plausibleResult).severity()).isEqualTo(CheckpointSeverity.ERROR); + } + + // ------------------------------------------------------------------ Tests: EmptyList + + @Test + void givenEmptyList_modelListIsFailureWarning_andModelPlausibleIsNotApplicable() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.EmptyList("claude", Instant.now())), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)).isInstanceOf(CheckpointResult.Success.class); + CheckpointResult modelListResult = findById(results, CheckpointId.MODEL_LIST_AVAILABLE); + assertThat(modelListResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) modelListResult).severity()).isEqualTo(CheckpointSeverity.WARNING); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + // ------------------------------------------------------------------ Tests: IncompleteConfiguration + + @Test + void givenIncompleteConfiguration_modelListIsFailureError_othersNotApplicable() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.IncompleteConfiguration("claude", "Base-URL fehlt")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + CheckpointResult modelListResult = findById(results, CheckpointId.MODEL_LIST_AVAILABLE); + assertThat(modelListResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) modelListResult).severity()).isEqualTo(CheckpointSeverity.ERROR); + assertThat(((CheckpointResult.Failure) modelListResult).message()).contains("Base-URL fehlt"); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + // ------------------------------------------------------------------ Tests: TechnicalFailure – AUTHENTICATION_FAILED + + @Test + void givenTechnicalFailureAuthenticationFailed_apiKeyAcceptedIsFailureError_baseUrlIsSuccess() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.TechnicalFailure("claude", + "AUTHENTICATION_FAILED", "401 Unauthorized")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)).isInstanceOf(CheckpointResult.Success.class); + CheckpointResult apiKeyAcceptedResult = findById(results, CheckpointId.API_KEY_ACCEPTED); + assertThat(apiKeyAcceptedResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) apiKeyAcceptedResult).severity()).isEqualTo(CheckpointSeverity.ERROR); + assertThat(((CheckpointResult.Failure) apiKeyAcceptedResult).message()).contains("401 Unauthorized"); + assertThat(findById(results, CheckpointId.MODEL_LIST_AVAILABLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + // ------------------------------------------------------------------ Tests: TechnicalFailure – CONNECTION_FAILURE + + @Test + void givenTechnicalFailureConnectionFailure_baseUrlIsFailureError_othersNotApplicable() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.TechnicalFailure("openai-compatible", + "CONNECTION_FAILURE", "Verbindung abgelehnt")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + openaiInput(keyFromEnv(), "gpt-4")); + + CheckpointResult baseUrlResult = findById(results, CheckpointId.BASE_URL_REACHABLE); + assertThat(baseUrlResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) baseUrlResult).severity()).isEqualTo(CheckpointSeverity.ERROR); + assertThat(((CheckpointResult.Failure) baseUrlResult).message()).contains("Verbindung abgelehnt"); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.MODEL_LIST_AVAILABLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + @Test + void givenTechnicalFailureEndpointNotFound_baseUrlIsFailureError() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.TechnicalFailure("claude", + "ENDPOINT_NOT_FOUND", "404 Not Found")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + CheckpointResult baseUrlResult = findById(results, CheckpointId.BASE_URL_REACHABLE); + assertThat(baseUrlResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) baseUrlResult).severity()).isEqualTo(CheckpointSeverity.ERROR); + assertThat(((CheckpointResult.Failure) baseUrlResult).message()).contains("404 Not Found"); + } + + // ------------------------------------------------------------------ Tests: TechnicalFailure – SERVER_ERROR + + @Test + void givenTechnicalFailureServerError_baseUrlIsSuccess_modelListIsFailureWarning() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.TechnicalFailure("claude", + "SERVER_ERROR", "500 Internal Server Error")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + CheckpointResult modelListResult = findById(results, CheckpointId.MODEL_LIST_AVAILABLE); + assertThat(modelListResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) modelListResult).severity()).isEqualTo(CheckpointSeverity.WARNING); + assertThat(((CheckpointResult.Failure) modelListResult).message()).contains("500 Internal Server Error"); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + // ------------------------------------------------------------------ Tests: TechnicalFailure – INVALID_RESPONSE + + @Test + void givenTechnicalFailureInvalidResponse_baseUrlIsSuccess_modelListIsFailureError() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.TechnicalFailure("openai-compatible", + "INVALID_RESPONSE", "Unerwartetes JSON-Format")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + openaiInput(keyFromEnv(), "gpt-4o")); + + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)).isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + CheckpointResult modelListResult = findById(results, CheckpointId.MODEL_LIST_AVAILABLE); + assertThat(modelListResult).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) modelListResult).severity()).isEqualTo(CheckpointSeverity.ERROR); + assertThat(((CheckpointResult.Failure) modelListResult).message()).contains("Unerwartetes JSON-Format"); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.NotApplicable.class); + } + + // ------------------------------------------------------------------ Tests: TechnicalFailure – UNKNOWN + + @Test + void givenTechnicalFailureUnknownCategory_allCheckpointsAreFailureError() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.TechnicalFailure("claude", + "UNKNOWN_MYSTERY_ERROR", "Seltsamer Fehler")), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + claudeInput(keyFromEnv(), "claude-3-sonnet")); + + // API_KEY_PRESENT bleibt Success (kein HTTP-Aufruf für diesen Check) + assertThat(findById(results, CheckpointId.API_KEY_PRESENT)).isInstanceOf(CheckpointResult.Success.class); + // Remote-Checks auf Failure ERROR + assertThat(findById(results, CheckpointId.BASE_URL_REACHABLE)) + .isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) findById(results, CheckpointId.BASE_URL_REACHABLE)).severity()) + .isEqualTo(CheckpointSeverity.ERROR); + assertThat(findById(results, CheckpointId.API_KEY_ACCEPTED)) + .isInstanceOf(CheckpointResult.Failure.class); + assertThat(findById(results, CheckpointId.MODEL_LIST_AVAILABLE)) + .isInstanceOf(CheckpointResult.Failure.class); + assertThat(findById(results, CheckpointId.SELECTED_MODEL_PLAUSIBLE)) + .isInstanceOf(CheckpointResult.Failure.class); + } + + // ------------------------------------------------------------------ Tests: Unbekannter Provider + + @Test + void givenUnknownProviderIdentifier_allFiveCheckpointsAreFailureError() { + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.EmptyList("unknown", Instant.now())), + apiKeyPort(keyFromEnv())); + + EditorValidationInput input = new EditorValidationInput( + "unknown-provider", + "/src", "/tgt", "/db.sqlite", "/prompt.txt", + "3", "10", "2000", + "", "model", "30", + EffectiveApiKeyDescriptor.absent(), + "", "model", "30", + EffectiveApiKeyDescriptor.absent()); + + List results = service.runProviderChecks(input); + + assertThat(results).hasSize(5); + results.forEach(r -> assertThat(r).isInstanceOf(CheckpointResult.Failure.class)); + results.forEach(r -> + assertThat(((CheckpointResult.Failure) r).severity()).isEqualTo(CheckpointSeverity.ERROR)); + } + + // ------------------------------------------------------------------ Tests: OpenAI Provider + + @Test + void givenOpenAiProvider_successWithMatchingModel_allFiveCheckpointsSucceed() { + List models = List.of("gpt-4", "gpt-3.5-turbo"); + ProviderTechnicalTestService service = new ProviderTechnicalTestService( + catalogPort(new ModelCatalogResult.Success("openai-compatible", models, Instant.now())), + apiKeyPort(keyFromEnv())); + + List results = service.runProviderChecks( + openaiInput(keyFromEnv(), "gpt-4")); + + assertThat(results).hasSize(5); + results.forEach(r -> assertThat(r).isInstanceOf(CheckpointResult.Success.class)); + } + + // ------------------------------------------------------------------ Hilfsmethode + + private static CheckpointResult findById(List results, CheckpointId id) { + return results.stream() + .filter(r -> r.checkpointId() == id) + .findFirst() + .orElseThrow(() -> new AssertionError("No result found for CheckpointId: " + id)); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java new file mode 100644 index 0000000..6a6cd76 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestOrchestratorTest.java @@ -0,0 +1,516 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput; + +/** + * Unit-Tests für {@link TechnicalTestOrchestrator}. + *

    + * 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 results, CheckpointId id) { + return results.stream() + .filter(r -> r.checkpointId() == id) + .findFirst() + .orElseThrow(() -> new AssertionError("No result for CheckpointId: " + id)); + } + + // ------------------------------------------------------------------ Tests: Vollständig grüner Pfad + + /** + * Alle drei Blöcke liefern Erfolg: der Bericht enthält genau 11 Success-Einträge. + */ + @Test + void allBlocksSucceed_reportContainsElevenSuccessEntries() { + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + allOkPathCheckPort(), + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + assertThat(report.size()).isEqualTo(11); + assertThat(report.results()) + .allSatisfy(r -> assertThat(r).isInstanceOf(CheckpointResult.Success.class)); + assertThat(report.hasErrors()).isFalse(); + assertThat(report.hasWarnings()).isFalse(); + } + + // ------------------------------------------------------------------ Tests: Kein Frühabbruch + + /** + * Bericht enthält immer genau 11 Einträge, auch wenn Block 2 und Block 3 alle Fehler liefern. + */ + @Test + void alwaysElevenCheckpointsInReport_evenWithFailures() { + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + noOpPathCheckPort(), // Block 2: alle Pfade nicht vorhanden + throwingProviderService()); // Block 3: Exception + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(emptyProviderInput())); + + assertThat(report.size()).isEqualTo(11); + } + + // ------------------------------------------------------------------ Tests: Block 1 - Exception führt zu Failure + + /** + * Wenn der lokale Validator eine Exception wirft, werden die beiden Block-1-Checkpoints + * als Failure mit "Interner Fehler" markiert. Blöcke 2 und 3 laufen trotzdem durch. + */ + @Test + void localValidatorThrowsException_block1CheckpointsAreFailure_otherBlocksRunThrough() { + // EditorConfigurationValidator der eine Exception wirft + EditorConfigurationValidator throwingValidator = new EditorConfigurationValidator() { + @Override + public de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport + validate(EditorValidationInput input) { + throw new RuntimeException("Simulierter Validierungsfehler"); + } + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + throwingValidator, + allOkPathCheckPort(), + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + assertThat(report.size()).isEqualTo(11); + + // Block-1-Checkpoints müssen Failure mit "Interner Fehler" sein + CheckpointResult basicValidation = findById(report.results(), + CheckpointId.CONFIGURATION_BASIC_VALIDATION); + assertThat(basicValidation).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) basicValidation).message()) + .contains("Interner Fehler"); + + CheckpointResult providerConfig = findById(report.results(), + CheckpointId.PROVIDER_CONFIGURATION); + assertThat(providerConfig).isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) providerConfig).message()) + .contains("Interner Fehler"); + + // Block-2-Checkpoints laufen durch (Pfade sind "ok" durch allOkPathCheckPort) + assertThat(findById(report.results(), CheckpointId.SOURCE_FOLDER_PRESENT)) + .isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(report.results(), CheckpointId.TARGET_FOLDER_USABLE)) + .isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(report.results(), CheckpointId.PROMPT_FILE_PRESENT)) + .isInstanceOf(CheckpointResult.Success.class); + assertThat(findById(report.results(), CheckpointId.SQLITE_PATH_USABLE)) + .isInstanceOf(CheckpointResult.Success.class); + } + + // ------------------------------------------------------------------ Tests: Block 3 - Exception führt zu Failure + + /** + * Wenn der ProviderTechnicalTestService eine Exception wirft, werden die fünf Block-3-Checkpoints + * als Failure mit "Interner Fehler" markiert. Blöcke 1 und 2 laufen trotzdem durch. + */ + @Test + void providerServiceThrowsException_block3CheckpointsAreFailure_otherBlocksRunThrough() { + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + allOkPathCheckPort(), + throwingProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + assertThat(report.size()).isEqualTo(11); + + // Block-3-Checkpoints müssen Failure mit "Interner Fehler" sein + List block3Ids = List.of( + CheckpointId.API_KEY_PRESENT, + CheckpointId.BASE_URL_REACHABLE, + CheckpointId.API_KEY_ACCEPTED, + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointId.SELECTED_MODEL_PLAUSIBLE); + + for (CheckpointId id : block3Ids) { + CheckpointResult result = findById(report.results(), id); + assertThat(result).as("Block-3-Checkpoint %s muss Failure sein", id) + .isInstanceOf(CheckpointResult.Failure.class); + assertThat(((CheckpointResult.Failure) result).message()) + .as("Fehlermeldung für %s muss 'Interner Fehler' enthalten", id) + .contains("Interner Fehler"); + } + + // Block-1-Checkpoints laufen durch + assertThat(findById(report.results(), CheckpointId.CONFIGURATION_BASIC_VALIDATION)) + .isInstanceOf(CheckpointResult.Success.class); + + // Block-2-Checkpoints laufen durch + assertThat(findById(report.results(), CheckpointId.SOURCE_FOLDER_PRESENT)) + .isInstanceOf(CheckpointResult.Success.class); + } + + // ------------------------------------------------------------------ Tests: CorrectionSuggestion + + /** + * Wenn der Zielordner nicht vorhanden aber anlegbar ist, enthält der Checkpoint + * {@link CheckpointId#TARGET_FOLDER_USABLE} eine {@link CorrectionSuggestion.CreateDirectory}. + */ + @Test + void targetFolderNotPresent_correctionSuggestionIsCreateDirectory() { + // PathCheckPort: Zielordner nicht schreibbar (returned false), aber wir testen + // dass eine CreateDirectory-Suggestion angehängt wird + PathCheckPort pathPort = new PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return true; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } // Zielordner + @Override public boolean isFileReadable(String p) { return true; } + @Override public boolean isSqlitePathUsable(String p) { return true; } + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathPort, + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + CheckpointResult targetFolderResult = findById(report.results(), + CheckpointId.TARGET_FOLDER_USABLE); + assertThat(targetFolderResult).isInstanceOf(CheckpointResult.Failure.class); + CheckpointResult.Failure failure = (CheckpointResult.Failure) targetFolderResult; + assertThat(failure.hasCorrectionSuggestion()).isTrue(); + assertThat(failure.correctionSuggestion().orElseThrow()) + .isInstanceOf(CorrectionSuggestion.CreateDirectory.class); + } + + /** + * Wenn die Prompt-Datei fehlt, enthält der Checkpoint {@link CheckpointId#PROMPT_FILE_PRESENT} + * eine {@link CorrectionSuggestion.CreatePromptFile}. + */ + @Test + void promptFileMissing_correctionSuggestionIsCreatePromptFile() { + PathCheckPort pathPort = 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 false; } // Prompt-Datei fehlt + @Override public boolean isSqlitePathUsable(String p) { return true; } + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathPort, + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + CheckpointResult promptResult = findById(report.results(), + CheckpointId.PROMPT_FILE_PRESENT); + assertThat(promptResult).isInstanceOf(CheckpointResult.Failure.class); + CheckpointResult.Failure failure = (CheckpointResult.Failure) promptResult; + assertThat(failure.hasCorrectionSuggestion()).isTrue(); + assertThat(failure.correctionSuggestion().orElseThrow()) + .isInstanceOf(CorrectionSuggestion.CreatePromptFile.class); + } + + /** + * Wenn der SQLite-Pfad nicht nutzbar ist, enthält der Checkpoint + * {@link CheckpointId#SQLITE_PATH_USABLE} eine {@link CorrectionSuggestion.PrepareSqlitePath}. + */ + @Test + void sqlitePathNotUsable_correctionSuggestionIsPrepareSqlitePath() { + PathCheckPort pathPort = 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 false; } // SQLite-Pfad nicht nutzbar + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathPort, + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + CheckpointResult sqliteResult = findById(report.results(), + CheckpointId.SQLITE_PATH_USABLE); + assertThat(sqliteResult).isInstanceOf(CheckpointResult.Failure.class); + CheckpointResult.Failure failure = (CheckpointResult.Failure) sqliteResult; + assertThat(failure.hasCorrectionSuggestion()).isTrue(); + assertThat(failure.correctionSuggestion().orElseThrow()) + .isInstanceOf(CorrectionSuggestion.PrepareSqlitePath.class); + } + + // ------------------------------------------------------------------ Tests: Korrekte Checkpoint-Reihenfolge + + /** + * Der Bericht enthält genau die erwarteten 11 Checkpoint-IDs. + */ + @Test + void report_containsAllExpectedCheckpointIds() { + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + noOpPathCheckPort(), + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + List actualIds = report.results().stream() + .map(CheckpointResult::checkpointId) + .toList(); + + assertThat(actualIds).containsExactlyInAnyOrder( + CheckpointId.CONFIGURATION_BASIC_VALIDATION, + CheckpointId.PROVIDER_CONFIGURATION, + CheckpointId.PROMPT_FILE_PRESENT, + CheckpointId.SOURCE_FOLDER_PRESENT, + CheckpointId.TARGET_FOLDER_USABLE, + CheckpointId.SQLITE_PATH_USABLE, + CheckpointId.API_KEY_PRESENT, + CheckpointId.BASE_URL_REACHABLE, + CheckpointId.API_KEY_ACCEPTED, + CheckpointId.MODEL_LIST_AVAILABLE, + CheckpointId.SELECTED_MODEL_PLAUSIBLE); + } + + // ------------------------------------------------------------------ Tests: Prompt-Pfad-Fallback (AP-007) + + /** + * Kein Prompt-Pfad im Editorzustand, aber configFilePath gesetzt → + * Suggestion nutzt den Standardpfad {@code /prompt.txt}. + */ + @Test + void promptFileMissing_noConfiguredPath_withConfigFilePath_suggestionUsesDefaultPath() { + // Editorzustand ohne Prompt-Pfad, aber mit configFilePath + EditorValidationInput inputWithoutPrompt = new EditorValidationInput( + "claude", + "/src", "/tgt", "/db.sqlite", + "", // kein Prompt-Pfad + "3", "10", "500", + "https://api.anthropic.com", "claude-3-sonnet", "30", + EffectiveApiKeyDescriptor.fromPropertyFile(), + "https://api.openai.com", "gpt-4", "30", + EffectiveApiKeyDescriptor.absent()); + + // PathCheckPort: Dateien fehlen, aber Elternordner sind schreibbar + PathCheckPort pathPort = 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 false; } // Prompt fehlt + @Override public boolean isSqlitePathUsable(String p) { return true; } + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathPort, + allSuccessProviderService()); + + // configFilePath = C:/config/application.properties → Elternordner = C:/config + TechnicalTestReport report = orchestrator.run( + new TechnicalTestRequest(inputWithoutPrompt, "C:/config/application.properties")); + + CheckpointResult promptResult = findById(report.results(), CheckpointId.PROMPT_FILE_PRESENT); + assertThat(promptResult).isInstanceOf(CheckpointResult.Failure.class); + CheckpointResult.Failure failure = (CheckpointResult.Failure) promptResult; + assertThat(failure.hasCorrectionSuggestion()).isTrue(); + CorrectionSuggestion suggestion = failure.correctionSuggestion().orElseThrow(); + assertThat(suggestion).isInstanceOf(CorrectionSuggestion.CreatePromptFile.class); + // Pfad muss im Elternordner der Konfigurationsdatei liegen + String suggestionPath = ((CorrectionSuggestion.CreatePromptFile) suggestion).path(); + assertThat(suggestionPath).contains("config"); + assertThat(suggestionPath).contains("prompt.txt"); + } + + /** + * Kein Prompt-Pfad im Editorzustand, kein configFilePath → + * Suggestion nutzt Fallback-Pfad {@code config/prompt.txt}. + */ + @Test + void promptFileMissing_noConfiguredPath_noConfigFilePath_suggestionUsesFallbackPath() { + EditorValidationInput inputWithoutPrompt = new EditorValidationInput( + "claude", + "/src", "/tgt", "/db.sqlite", + "", // kein Prompt-Pfad + "3", "10", "500", + "https://api.anthropic.com", "claude-3-sonnet", "30", + EffectiveApiKeyDescriptor.fromPropertyFile(), + "https://api.openai.com", "gpt-4", "30", + EffectiveApiKeyDescriptor.absent()); + + PathCheckPort pathPort = 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 false; } // Prompt fehlt + @Override public boolean isSqlitePathUsable(String p) { return true; } + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathPort, + allSuccessProviderService()); + + // Kein configFilePath → Fallback auf config/prompt.txt + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(inputWithoutPrompt)); + + CheckpointResult promptResult = findById(report.results(), CheckpointId.PROMPT_FILE_PRESENT); + assertThat(promptResult).isInstanceOf(CheckpointResult.Failure.class); + CheckpointResult.Failure failure = (CheckpointResult.Failure) promptResult; + assertThat(failure.hasCorrectionSuggestion()).isTrue(); + String suggestionPath = ((CorrectionSuggestion.CreatePromptFile) failure.correctionSuggestion().orElseThrow()).path(); + assertThat(suggestionPath).contains("config"); + assertThat(suggestionPath).contains("prompt.txt"); + } + + /** + * Prompt-Datei fehlt, aber Elternordner ist nicht beschreibbar → + * keine Suggestion, stattdessen Failure mit Meldung „bitte manuell anlegen". + */ + @Test + void promptFileMissing_parentNotWritable_noSuggestion_failureWithManualHint() { + PathCheckPort pathPort = new PathCheckPort() { + @Override public boolean isDirectoryReadable(String p) { return true; } + @Override public boolean isDirectoryWritableOrCreatable(String p) { return false; } // Elternordner nicht schreibbar + @Override public boolean isFileReadable(String p) { return false; } // Prompt fehlt + @Override public boolean isSqlitePathUsable(String p) { return true; } + }; + + TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathPort, + allSuccessProviderService()); + + TechnicalTestReport report = orchestrator.run( + TechnicalTestRequest.of(validClaudeInput())); + + CheckpointResult promptResult = findById(report.results(), CheckpointId.PROMPT_FILE_PRESENT); + assertThat(promptResult).isInstanceOf(CheckpointResult.Failure.class); + CheckpointResult.Failure failure = (CheckpointResult.Failure) promptResult; + // Keine Suggestion, da Elternordner nicht schreibbar + assertThat(failure.hasCorrectionSuggestion()).isFalse(); + // Meldung muss Hinweis auf manuelles Anlegen enthalten + assertThat(failure.message()).contains("manuell anlegen"); + } + + // ------------------------------------------------------------------ Tests: resolvePromptPath (statische Hilfsmethode) + + /** + * Wenn ein konfigurierter Prompt-Pfad vorhanden ist, wird dieser zurückgegeben. + */ + @Test + void resolvePromptPath_configuredPathPresent_returnsConfiguredPath() { + String result = TechnicalTestOrchestrator.resolvePromptPath( + "/data/prompt.txt", + "/config/application.properties"); + assertThat(result).isEqualTo("/data/prompt.txt"); + } + + /** + * Wenn kein konfigurierter Pfad vorhanden ist, aber ein configFilePath gesetzt ist, + * wird der Standardpfad im Elternordner der Konfigurationsdatei zurückgegeben. + */ + @Test + void resolvePromptPath_noConfiguredPath_withConfigFilePath_returnsParentBasedDefault() { + // Plattformunabhängig: separator kann / oder \ sein + String result = TechnicalTestOrchestrator.resolvePromptPath( + "", + "C:/myconfig/application.properties"); + assertThat(result).startsWith("C:" + java.io.File.separator + "myconfig"); + assertThat(result).endsWith("prompt.txt"); + } + + /** + * Wenn weder konfigurierter Pfad noch configFilePath vorhanden sind, + * wird {@code config/prompt.txt} als Fallback zurückgegeben. + */ + @Test + void resolvePromptPath_noConfiguredPath_noConfigFilePath_returnsFallback() { + String result = TechnicalTestOrchestrator.resolvePromptPath("", ""); + assertThat(result).contains("config"); + assertThat(result).endsWith("prompt.txt"); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java new file mode 100644 index 0000000..c75110b --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestReportTest.java @@ -0,0 +1,108 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests für {@link TechnicalTestReport} und seine Helper-Methoden. + */ +class TechnicalTestReportTest { + + @Test + void emptyReport_noErrors_noWarnings_noCorrectableFindings() { + var report = new TechnicalTestReport(List.of(), Instant.now()); + assertThat(report.hasErrors()).isFalse(); + assertThat(report.hasWarnings()).isFalse(); + assertThat(report.hasCorrectableFindings()).isFalse(); + assertThat(report.size()).isZero(); + } + + @Test + void report_withErrorFailure_hasErrors() { + var failure = CheckpointResult.Failure.of( + CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt"); + var report = new TechnicalTestReport(List.of(failure), Instant.now()); + assertThat(report.hasErrors()).isTrue(); + assertThat(report.hasWarnings()).isFalse(); + } + + @Test + void report_withWarningFailure_hasWarnings() { + var failure = CheckpointResult.Failure.of( + CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.WARNING, "Modell unbekannt"); + var report = new TechnicalTestReport(List.of(failure), Instant.now()); + assertThat(report.hasErrors()).isFalse(); + assertThat(report.hasWarnings()).isTrue(); + } + + @Test + void report_withSuccessOnly_noErrorsNoWarnings() { + var success = new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT, "ok"); + var report = new TechnicalTestReport(List.of(success), Instant.now()); + assertThat(report.hasErrors()).isFalse(); + assertThat(report.hasWarnings()).isFalse(); + assertThat(report.hasCorrectableFindings()).isFalse(); + } + + @Test + void report_withCorrectableFailure_hasCorrectableFindings() { + var suggestion = new CorrectionSuggestion.CreateDirectory("/path/target", "Zielordner anlegen"); + var failure = CheckpointResult.Failure.withCorrection( + CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt", suggestion); + var report = new TechnicalTestReport(List.of(failure), Instant.now()); + assertThat(report.hasCorrectableFindings()).isTrue(); + } + + @Test + void deriveCorrectionPlan_extractsSuggestionsFromFailures() { + var suggestion1 = new CorrectionSuggestion.CreateDirectory("/path/target", "Zielordner anlegen"); + var suggestion2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen"); + var failure1 = CheckpointResult.Failure.withCorrection( + CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "fehlt", suggestion1); + var failure2 = CheckpointResult.Failure.withCorrection( + CheckpointId.PROMPT_FILE_PRESENT, CheckpointSeverity.ERROR, "fehlt", suggestion2); + var success = new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT, "ok"); + + var report = new TechnicalTestReport(List.of(failure1, success, failure2), Instant.now()); + var plan = report.deriveCorrectionPlan(); + + assertThat(plan.hasCorrections()).isTrue(); + assertThat(plan.size()).isEqualTo(2); + assertThat(plan.suggestions()).containsExactly(suggestion1, suggestion2); + } + + @Test + void deriveCorrectionPlan_emptyWhenNoCorrectableFailures() { + var failure = CheckpointResult.Failure.of( + CheckpointId.API_KEY_ACCEPTED, CheckpointSeverity.ERROR, "Schlüssel falsch"); + var report = new TechnicalTestReport(List.of(failure), Instant.now()); + var plan = report.deriveCorrectionPlan(); + assertThat(plan.hasCorrections()).isFalse(); + } + + @Test + void resultsListIsImmutable() { + var mutable = new java.util.ArrayList(); + mutable.add(new CheckpointResult.Success(CheckpointId.CONFIGURATION_BASIC_VALIDATION, "ok")); + var report = new TechnicalTestReport(mutable, Instant.now()); + mutable.add(new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT, "ok2")); + assertThat(report.results()).hasSize(1); + } + + @Test + void nullResultsThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new TechnicalTestReport(null, Instant.now())); + } + + @Test + void nullEvaluatedAtThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new TechnicalTestReport(List.of(), null)); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java new file mode 100644 index 0000000..679eb96 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/TechnicalTestRequestTest.java @@ -0,0 +1,48 @@ +package de.gecheckt.pdf.umbenenner.application.validation.technicaltest; + +import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests für {@link TechnicalTestRequest}. + */ +class TechnicalTestRequestTest { + + private static EditorValidationInput minimalInput() { + return new EditorValidationInput( + "claude", "", "", "", "", "3", "10", "2000", + "", "model-x", "60", EffectiveApiKeyDescriptor.absent(), + "", "", "60", EffectiveApiKeyDescriptor.absent()); + } + + @Test + void of_setsEmptyConfigFilePath() { + var request = TechnicalTestRequest.of(minimalInput()); + assertThat(request.configFilePath()).isEmpty(); + assertThat(request.hasConfigFilePath()).isFalse(); + } + + @Test + void withConfigFilePath_detectedAsPresent() { + var request = new TechnicalTestRequest(minimalInput(), "/config/app.properties"); + assertThat(request.hasConfigFilePath()).isTrue(); + assertThat(request.configFilePath()).isEqualTo("/config/app.properties"); + } + + @Test + void nullConfigFilePath_normalisedToEmpty() { + var request = new TechnicalTestRequest(minimalInput(), null); + assertThat(request.configFilePath()).isEmpty(); + assertThat(request.hasConfigFilePath()).isFalse(); + } + + @Test + void nullValidationInputThrows() { + assertThatNullPointerException() + .isThrownBy(() -> TechnicalTestRequest.of(null)); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index d47bde8..da23f46 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -21,6 +21,9 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException; import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext; import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher; +import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService; +import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator; import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter; import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.ClaudeModelCatalogAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog.OpenAiCompatibleModelCatalogAdapter; @@ -47,6 +50,7 @@ import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordReposit import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter; +import de.gecheckt.pdf.umbenenner.adapter.out.pathcheck.FilesystemPathCheckAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter; import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration; @@ -623,6 +627,17 @@ public class BootstrapRunner { AiModelCatalogPort modelCatalogPort = buildModelCatalogDispatcher(); de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort apiKeyResolutionPort = new de.gecheckt.pdf.umbenenner.adapter.out.validation.EnvironmentApiKeyResolutionAdapter(); + ProviderTechnicalTestService providerTechnicalTestService = + new ProviderTechnicalTestService(modelCatalogPort, apiKeyResolutionPort); + de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort pathCheckPort = + new FilesystemPathCheckAdapter(); + TechnicalTestOrchestrator technicalTestOrchestrator = new TechnicalTestOrchestrator( + new EditorConfigurationValidator(), + pathCheckPort, + providerTechnicalTestService); + de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService correctionExecutionService = + new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService( + new de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation.FilesystemResourceCreationAdapter()); if (configPathOverride.isEmpty()) { return new GuiStartupContext( @@ -631,7 +646,11 @@ public class BootstrapRunner { loader, writer, modelCatalogPort, - apiKeyResolutionPort); + apiKeyResolutionPort, + providerTechnicalTestService, + pathCheckPort, + technicalTestOrchestrator, + correctionExecutionService); } Path configPath = Paths.get(configPathOverride.get()); @@ -645,14 +664,19 @@ public class BootstrapRunner { loader, writer, modelCatalogPort, - apiKeyResolutionPort); + apiKeyResolutionPort, + providerTechnicalTestService, + pathCheckPort, + technicalTestOrchestrator, + correctionExecutionService); } LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath()); try { GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath); return new GuiStartupContext(loadedState, Optional.empty(), loader, writer, - modelCatalogPort, apiKeyResolutionPort); + modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort, + technicalTestOrchestrator, correctionExecutionService); } catch (GuiConfigurationLoadException e) { LOG.error("GUI startup: configuration could not be loaded, starting without it: {}", e.getMessage(), e); @@ -662,7 +686,11 @@ public class BootstrapRunner { loader, writer, modelCatalogPort, - apiKeyResolutionPort); + apiKeyResolutionPort, + providerTechnicalTestService, + pathCheckPort, + technicalTestOrchestrator, + correctionExecutionService); } }