M12 vollständig abgeschlossen (AP-001 bis AP-008)
- 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 <noreply@anthropic.com>
This commit is contained in:
+168
-8
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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}.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* Differences from the automatic background validation:
|
||||
* <ul>
|
||||
* <li>This method is user-initiated and always produces the confirmation INFO message.</li>
|
||||
* <li>The automatic validation runs silently on field changes and file loads.</li>
|
||||
* <li>Remote checks (provider endpoint, API key acceptance, model list) are not part of
|
||||
* this action; they belong to the technical overall-check action.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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}.
|
||||
*
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionReport;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Koordiniert den gesammelten Bestätigungsdialog und die anschließende Ausführung
|
||||
* schreibender Korrekturmaßnahmen nach einem technischen Gesamttest.
|
||||
* <p>
|
||||
* Der Koordinator empfängt einen {@link TechnicalTestReport}, prüft ob korrigierbare
|
||||
* Befunde vorliegen, leitet daraus einen {@link CorrectionPlan} ab, zeigt dem Benutzer
|
||||
* einen gesammelten Bestätigungsdialog und führt die Korrekturen bei Bestätigung über
|
||||
* den {@link CorrectionExecutionService} auf einem Hintergrund-Worker-Thread aus.
|
||||
* Ergebnisse werden in die geteilte {@code pendingMessages}-Liste eingehängt.
|
||||
*
|
||||
* <h2>Ablauf</h2>
|
||||
* <ol>
|
||||
* <li>Bericht erhält: prüfen ob {@code hasCorrectableFindings()}.</li>
|
||||
* <li>Wenn keine korrigierbaren Befunde: kein Dialog, keine Aktion.</li>
|
||||
* <li>Wenn korrigierbare Befunde: {@link CorrectionPlan} ableiten.</li>
|
||||
* <li>Dialog auf FX-Thread anzeigen.</li>
|
||||
* <li>Bei Bestätigung: Korrekturen auf Worker-Thread ausführen.</li>
|
||||
* <li>Ergebnisse via {@code Platform.runLater} auf FX-Thread zurückführen.</li>
|
||||
* <li>Meldungen in {@code pendingMessages} einhängen (Replace-Semantik).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h2>Threading-Kontrakt</h2>
|
||||
* <p>
|
||||
* {@link #offerCorrections(TechnicalTestReport)} muss auf dem JavaFX Application Thread
|
||||
* aufgerufen werden. I/O (Ausführung der Korrekturen) läuft auf einem dedizierten
|
||||
* Daemon-Hintergrund-Thread. UI-Updates erfolgen ausschließlich via
|
||||
* {@code Platform.runLater}.
|
||||
*
|
||||
* <h2>Keine stillen Korrekturen</h2>
|
||||
* <p>
|
||||
* Ohne ausdrückliche Benutzerbestätigung werden keine schreibenden Änderungen ausgeführt.
|
||||
* Bei Dialog-Abbruch bleibt der Zustand unverändert.
|
||||
*
|
||||
* <h2>Anti-Scope</h2>
|
||||
* <p>
|
||||
* Dieser Koordinator führt keine Provider-nahen Korrekturen, keine Änderung fachlich
|
||||
* riskanter Werte und keine automatischen Laufstarts durch.
|
||||
*/
|
||||
public final class GuiCorrectionDialogCoordinator {
|
||||
|
||||
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
|
||||
static final String SOURCE_TAG = "Korrekturen";
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiCorrectionDialogCoordinator.class);
|
||||
|
||||
private final CorrectionExecutionService correctionExecutionService;
|
||||
private final List<GuiMessageEntry> pendingMessages;
|
||||
private final Consumer<Void> refreshCallback;
|
||||
|
||||
/**
|
||||
* Funktion, die dem Benutzer den Bestätigungsdialog zeigt.
|
||||
* <p>
|
||||
* Erhält den {@link ConfirmationDialogContent} und gibt {@code true} zurück, wenn der
|
||||
* Benutzer „Fortfahren" wählt, andernfalls {@code false}. Standard: echter Alert.
|
||||
* Paket-privat für Test-Substitution.
|
||||
*/
|
||||
Function<ConfirmationDialogContent, Boolean> dialogSupplier;
|
||||
|
||||
/**
|
||||
* Factory für den Hintergrund-Worker-Thread.
|
||||
* Standard: Daemon-Thread namens {@code gui-correction-worker}.
|
||||
* Paket-privat für Test-Substitution.
|
||||
*/
|
||||
Function<Runnable, Thread> correctionThreadFactory;
|
||||
|
||||
/**
|
||||
* Verbraucher zur Rückführung des Ergebnisses auf den FX-Thread.
|
||||
* Standard: {@code Platform.runLater}. Paket-privat für Test-Substitution.
|
||||
*/
|
||||
java.util.function.Consumer<Runnable> resultDelivery;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Koordinator.
|
||||
*
|
||||
* @param correctionExecutionService Service für die Ausführung von Korrekturen; darf nicht {@code null} sein
|
||||
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||
* @param refreshCallback Callback nach Anwendung der Ergebnisse (z. B. View-Aktualisierung);
|
||||
* darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||
*/
|
||||
public GuiCorrectionDialogCoordinator(CorrectionExecutionService correctionExecutionService,
|
||||
List<GuiMessageEntry> pendingMessages,
|
||||
Consumer<Void> refreshCallback) {
|
||||
this.correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||
"correctionExecutionService must not be null");
|
||||
this.pendingMessages = Objects.requireNonNull(pendingMessages,
|
||||
"pendingMessages must not be null");
|
||||
this.refreshCallback = Objects.requireNonNull(refreshCallback,
|
||||
"refreshCallback must not be null");
|
||||
|
||||
this.dialogSupplier = this::showConfirmationDialog;
|
||||
this.correctionThreadFactory = task -> {
|
||||
Thread t = new Thread(task, "gui-correction-worker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
};
|
||||
this.resultDelivery = Platform::runLater;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Bericht auf korrigierbare Befunde, zeigt bei Bedarf den Bestätigungsdialog
|
||||
* und führt die Korrekturen nach Bestätigung asynchron aus.
|
||||
* <p>
|
||||
* Wenn der Bericht keine korrigierbaren Befunde enthält ({@code hasCorrectableFindings()
|
||||
* == false}), wird kein Dialog angezeigt und keine Aktion ausgeführt.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||
*/
|
||||
public void offerCorrections(TechnicalTestReport report) {
|
||||
Objects.requireNonNull(report, "report must not be null");
|
||||
|
||||
if (!report.hasCorrectableFindings()) {
|
||||
LOG.debug("Gesamttest: Keine korrigierbaren Befunde – kein Bestätigungsdialog.");
|
||||
return;
|
||||
}
|
||||
|
||||
CorrectionPlan plan = report.deriveCorrectionPlan();
|
||||
if (!plan.hasCorrections()) {
|
||||
LOG.debug("Gesamttest: Korrekturplan ist leer – kein Bestätigungsdialog.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("Gesamttest: {} korrigierbare Befunde. Bestätigungsdialog wird angezeigt.", plan.size());
|
||||
|
||||
ConfirmationDialogContent dialogContent = ConfirmationDialogContent.fromPlan(plan);
|
||||
boolean confirmed = dialogSupplier.apply(dialogContent);
|
||||
|
||||
if (!confirmed) {
|
||||
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen abgelehnt. Keine Änderungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen bestätigt. Ausführung startet.");
|
||||
Runnable task = () -> {
|
||||
CorrectionExecutionReport executionReport = correctionExecutionService.execute(plan);
|
||||
resultDelivery.accept(() -> {
|
||||
applyResult(executionReport);
|
||||
refreshCallback.accept(null);
|
||||
});
|
||||
};
|
||||
|
||||
Thread worker = correctionThreadFactory.apply(task);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet das Ergebnis der Korrekturausführung auf die geteilte Nachrichtenliste an.
|
||||
* <p>
|
||||
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jedes
|
||||
* {@link CorrectionOutcome} einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
|
||||
* angehängt.
|
||||
* <p>
|
||||
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||
*
|
||||
* @param report das Ausführungsergebnis; darf nicht {@code null} sein
|
||||
*/
|
||||
private void applyResult(CorrectionExecutionReport report) {
|
||||
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
|
||||
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
|
||||
|
||||
long appliedCount = 0;
|
||||
long failedCount = 0;
|
||||
long notAttemptedCount = 0;
|
||||
|
||||
for (CorrectionOutcome outcome : report.outcomes()) {
|
||||
switch (outcome) {
|
||||
case CorrectionOutcome.Applied applied -> {
|
||||
pendingMessages.add(GuiMessageEntry.of(
|
||||
GuiMessageSeverity.INFO,
|
||||
"Korrektur angewendet: " + applied.suggestion().descriptionForUser()
|
||||
+ " – " + applied.message(),
|
||||
SOURCE_TAG));
|
||||
appliedCount++;
|
||||
LOG.info("Korrektur angewendet: {} – {}", applied.suggestion().descriptionForUser(),
|
||||
applied.message());
|
||||
}
|
||||
case CorrectionOutcome.Failed failed -> {
|
||||
pendingMessages.add(GuiMessageEntry.of(
|
||||
GuiMessageSeverity.ERROR,
|
||||
"Korrektur fehlgeschlagen: " + failed.suggestion().descriptionForUser()
|
||||
+ " (" + failed.errorMessage() + ")",
|
||||
SOURCE_TAG));
|
||||
failedCount++;
|
||||
LOG.warn("Korrektur fehlgeschlagen: {} – {}", failed.suggestion().descriptionForUser(),
|
||||
failed.errorMessage());
|
||||
}
|
||||
case CorrectionOutcome.NotAttempted notAttempted -> {
|
||||
pendingMessages.add(GuiMessageEntry.of(
|
||||
GuiMessageSeverity.HINT,
|
||||
"Korrektur nicht durchgeführt: " + notAttempted.suggestion().descriptionForUser()
|
||||
+ " – " + notAttempted.reason(),
|
||||
SOURCE_TAG));
|
||||
notAttemptedCount++;
|
||||
LOG.info("Korrektur nicht durchgeführt: {} – {}", notAttempted.suggestion().descriptionForUser(),
|
||||
notAttempted.reason());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zusammenfassung
|
||||
String summary = "Korrekturausführung abgeschlossen: "
|
||||
+ appliedCount + " angewendet, "
|
||||
+ failedCount + " fehlgeschlagen, "
|
||||
+ notAttemptedCount + " nicht versucht.";
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
|
||||
LOG.info("Korrekturausführung abgeschlossen: {} angewendet, {} fehlgeschlagen, {} nicht versucht.",
|
||||
appliedCount, failedCount, notAttemptedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt den echten JavaFX-Bestätigungsdialog und gibt die Benutzerentscheidung zurück.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden. Standard-Fokus liegt auf
|
||||
* dem Abbrechen-Button (kein versehentliches Bestätigen durch Enter).
|
||||
*
|
||||
* @param content der Dialoginhalt; darf nicht {@code null} sein
|
||||
* @return {@code true} wenn der Benutzer „Fortfahren" wählt, sonst {@code false}
|
||||
*/
|
||||
private boolean showConfirmationDialog(ConfirmationDialogContent content) {
|
||||
javafx.scene.control.ButtonType proceedButton =
|
||||
new javafx.scene.control.ButtonType("Fortfahren",
|
||||
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
|
||||
javafx.scene.control.ButtonType cancelButton =
|
||||
new javafx.scene.control.ButtonType("Abbrechen",
|
||||
javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
|
||||
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
|
||||
javafx.scene.control.Alert.AlertType.CONFIRMATION);
|
||||
alert.setTitle(content.title());
|
||||
alert.setHeaderText(content.introText());
|
||||
|
||||
StringBuilder correctionListText = new StringBuilder();
|
||||
for (String line : content.correctionLines()) {
|
||||
correctionListText.append("• ").append(line).append("\n");
|
||||
}
|
||||
correctionListText.append("\nFortfahren?");
|
||||
alert.setContentText(correctionListText.toString());
|
||||
|
||||
alert.getButtonTypes().setAll(proceedButton, cancelButton);
|
||||
|
||||
// Standard-Fokus: Abbrechen
|
||||
javafx.scene.Node cancelNode = alert.getDialogPane().lookupButton(cancelButton);
|
||||
if (cancelNode instanceof javafx.scene.control.Button btn) {
|
||||
btn.setDefaultButton(true);
|
||||
}
|
||||
javafx.scene.Node proceedNode = alert.getDialogPane().lookupButton(proceedButton);
|
||||
if (proceedNode instanceof javafx.scene.control.Button btn) {
|
||||
btn.setDefaultButton(false);
|
||||
}
|
||||
|
||||
java.util.Optional<javafx.scene.control.ButtonType> result = alert.showAndWait();
|
||||
return result.isPresent() && result.get() == proceedButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
|
||||
* <p>
|
||||
* Ausschließlich für Tests gedacht.
|
||||
*
|
||||
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
|
||||
*/
|
||||
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||
return List.copyOf(pendingMessages);
|
||||
}
|
||||
}
|
||||
+93
-11
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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,7 +39,11 @@ 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.
|
||||
@@ -39,6 +54,10 @@ public record GuiStartupContext(
|
||||
* @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.
|
||||
* <p>
|
||||
* The no-op model catalogue port always returns {@code IncompleteConfiguration}.
|
||||
* The no-op API key resolution port always returns {@code ABSENT}.
|
||||
* The no-op provider technical test service uses the no-op ports above.
|
||||
* The no-op path check port always returns {@code false} for all checks.
|
||||
* The no-op technical test orchestrator returns a report where all checkpoints are
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult.NotApplicable}.
|
||||
* The no-op correction execution service uses a no-op {@link ResourceCreationPort} that always
|
||||
* returns {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted}.
|
||||
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
||||
* GUI tests.
|
||||
*
|
||||
@@ -66,15 +101,62 @@ public record GuiStartupContext(
|
||||
* @return a startup context for the unloaded editor start
|
||||
*/
|
||||
public static GuiStartupContext blank(Optional<String> startupNotice) {
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort noOpCatalogPort =
|
||||
request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.ModelCatalogResult.IncompleteConfiguration(
|
||||
request.providerIdentifier(),
|
||||
"Kein Modellkatalog in diesem Startkontext verfügbar.");
|
||||
ApiKeyResolutionPort noOpApiKeyPort = (family, propertyValue) -> EffectiveApiKeyDescriptor.absent();
|
||||
ProviderTechnicalTestService noOpTestService =
|
||||
new ProviderTechnicalTestService(noOpCatalogPort, noOpApiKeyPort);
|
||||
PathCheckPort noOpPathCheckPort = new PathCheckPort() {
|
||||
@Override
|
||||
public boolean isDirectoryReadable(String path) { return false; }
|
||||
@Override
|
||||
public boolean isDirectoryWritableOrCreatable(String path) { return false; }
|
||||
@Override
|
||||
public boolean isFileReadable(String path) { return false; }
|
||||
@Override
|
||||
public boolean isSqlitePathUsable(String path) { return false; }
|
||||
};
|
||||
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||
noOpPathCheckPort,
|
||||
noOpTestService);
|
||||
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.CreateDirectory suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||
}
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||
}
|
||||
@Override
|
||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
||||
}
|
||||
};
|
||||
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||
return new GuiStartupContext(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestRequest;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Koordiniert die asynchrone Ausführung der Aktion „Technische Tests ausführen"
|
||||
* für den GUI-Konfigurationseditor.
|
||||
* <p>
|
||||
* Dieser Koordinator ist verantwortlich für:
|
||||
* <ul>
|
||||
* <li>Lesen des aktuellen GUI-Editorzustands (via {@link Supplier}).</li>
|
||||
* <li>Aufbau eines {@link TechnicalTestRequest} aus dem aktuellen Zustand.</li>
|
||||
* <li>Ausführung des {@link TechnicalTestOrchestrator} auf einem dedizierten
|
||||
* Daemon-Hinterground-Thread namens {@code gui-technical-test}.</li>
|
||||
* <li>Rückführung des {@link TechnicalTestReport} auf den JavaFX Application Thread
|
||||
* via {@code Platform.runLater}.</li>
|
||||
* <li>Einhängen der Ergebnisse als {@link GuiMessageEntry}-Einträge in die geteilte
|
||||
* {@code pendingMessages}-Liste (Quelle: „Technische-Tests").</li>
|
||||
* <li>Ersetzen vorheriger Test-Einträge (Replace-Semantik) bei jedem neuen Aufruf.</li>
|
||||
* <li>Weitergabe des vollständigen Berichts an den {@code postResultCallback}, damit
|
||||
* spätere Arbeitsschritte (z. B. Korrekturhilfen) auf das Ergebnis zugreifen können.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Threading-Kontrakt:</strong> {@link #triggerTechnicalTests()} darf nur auf dem
|
||||
* JavaFX Application Thread aufgerufen werden. Hintergrund-Worker-Threads dürfen nur über
|
||||
* den injizierten {@code resultDelivery}-Verbraucher mit der UI interagieren, der in der
|
||||
* Produktion {@code Platform.runLater} kapselt.
|
||||
* <p>
|
||||
* <strong>Kein implizites Speichern:</strong> Der Koordinator liest den aktuellen GUI-Zustand
|
||||
* und führt den Test aus, ohne die Konfigurationsdatei zu schreiben oder den Dirty-Zustand
|
||||
* des Editors zu ändern.
|
||||
* <p>
|
||||
* <strong>Anti-Scope:</strong> Dieser Koordinator führt keine schreibenden Korrekturen durch.
|
||||
* Korrekturvorschläge werden als Bestandteil des {@link TechnicalTestReport} zurückgegeben,
|
||||
* sind aber nicht ausführbar. Die Ausführung ist einem späteren Arbeitsschritt vorbehalten.
|
||||
* <p>
|
||||
* Die Worker-Thread-Factory und die Result-Delivery-Funktion sind injizierbar, damit Tests
|
||||
* deterministisch ohne echten Hintergrund-Thread laufen können.
|
||||
*/
|
||||
public final class GuiTechnicalTestCoordinator {
|
||||
|
||||
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
|
||||
static final String SOURCE_TAG = "Technische-Tests";
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiTechnicalTestCoordinator.class);
|
||||
|
||||
private final TechnicalTestOrchestrator orchestrator;
|
||||
private final Supplier<EditorValidationInput> inputProvider;
|
||||
private final Supplier<String> configFilePathProvider;
|
||||
private final List<GuiMessageEntry> pendingMessages;
|
||||
private final Consumer<TechnicalTestReport> postResultCallback;
|
||||
|
||||
/**
|
||||
* Factory für den Hintergrund-Worker-Thread. Paket-privat für Test-Substitution.
|
||||
* Standard: Daemon-Thread namens {@code gui-technical-test}.
|
||||
*/
|
||||
Function<Runnable, Thread> testThreadFactory;
|
||||
|
||||
/**
|
||||
* Verbraucher zur Rückführung des Ergebnisses. In der Produktion kapselt er {@code Platform.runLater}.
|
||||
* In Tests kann er durch einen direkten Aufruf ersetzt werden, damit das Ergebnis sofort
|
||||
* auf dem Worker-Thread angewendet wird, ohne die FX-Warteschlange zu entwässern.
|
||||
* Paket-privat für Test-Substitution.
|
||||
*/
|
||||
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Koordinator.
|
||||
*
|
||||
* @param orchestrator Orchestrator für den vollständigen Gesamttest; darf nicht {@code null} sein
|
||||
* @param inputProvider Lieferant des aktuellen {@link EditorValidationInput}; darf nicht {@code null} sein
|
||||
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
||||
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
||||
* darf nicht {@code null} sein
|
||||
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||
*/
|
||||
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
||||
Supplier<EditorValidationInput> inputProvider,
|
||||
Supplier<String> configFilePathProvider,
|
||||
List<GuiMessageEntry> pendingMessages,
|
||||
Consumer<TechnicalTestReport> postResultCallback) {
|
||||
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
||||
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
|
||||
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
|
||||
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
|
||||
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
||||
this.testThreadFactory = task -> {
|
||||
Thread t = new Thread(task, "gui-technical-test");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus.
|
||||
* <p>
|
||||
* Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
|
||||
* {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
|
||||
* einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
|
||||
* JavaFX Application Thread zurückgegeben.
|
||||
* <p>
|
||||
* Der Konfigurationsdateipfad wird genutzt, um bei fehlender Prompt-Datei-Konfiguration
|
||||
* einen sinnvollen Standardpfad ({@code <config-parent>/prompt.txt}) zu bestimmen.
|
||||
* <p>
|
||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*/
|
||||
public void triggerTechnicalTests() {
|
||||
EditorValidationInput input = inputProvider.get();
|
||||
String configFilePath = configFilePathProvider.get();
|
||||
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
|
||||
|
||||
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
||||
|
||||
Runnable task = () -> {
|
||||
TechnicalTestReport report = orchestrator.run(request);
|
||||
resultDelivery.accept(() -> {
|
||||
applyResult(report);
|
||||
postResultCallback.accept(report);
|
||||
});
|
||||
};
|
||||
|
||||
Thread worker = testThreadFactory.apply(task);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
|
||||
* <p>
|
||||
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jeden
|
||||
* Checkpoint-Ergebnis einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
|
||||
* angehängt.
|
||||
* <p>
|
||||
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||
*
|
||||
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||
*/
|
||||
private void applyResult(TechnicalTestReport report) {
|
||||
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
|
||||
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
|
||||
|
||||
long successCount = 0;
|
||||
long failureErrorCount = 0;
|
||||
long failureWarnCount = 0;
|
||||
long notApplicableCount = 0;
|
||||
|
||||
for (CheckpointResult result : report.results()) {
|
||||
String label = labelFor(result.checkpointId());
|
||||
switch (result) {
|
||||
case CheckpointResult.Success success -> {
|
||||
pendingMessages.add(GuiMessageEntry.of(
|
||||
GuiMessageSeverity.INFO,
|
||||
label + ": OK – " + success.message(),
|
||||
SOURCE_TAG));
|
||||
successCount++;
|
||||
LOG.info("GUI-Gesamttest: {} → OK", label);
|
||||
}
|
||||
case CheckpointResult.Failure failure -> {
|
||||
GuiMessageSeverity severity = failure.severity() ==
|
||||
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity.ERROR
|
||||
? GuiMessageSeverity.ERROR : GuiMessageSeverity.WARNING;
|
||||
pendingMessages.add(GuiMessageEntry.of(
|
||||
severity,
|
||||
label + ": " + failure.message(),
|
||||
SOURCE_TAG));
|
||||
if (severity == GuiMessageSeverity.ERROR) {
|
||||
failureErrorCount++;
|
||||
LOG.warn("GUI-Gesamttest: {} → FEHLER: {}", label, failure.message());
|
||||
} else {
|
||||
failureWarnCount++;
|
||||
LOG.warn("GUI-Gesamttest: {} → WARNUNG: {}", label, failure.message());
|
||||
}
|
||||
}
|
||||
case CheckpointResult.NotApplicable notApplicable -> {
|
||||
pendingMessages.add(GuiMessageEntry.of(
|
||||
GuiMessageSeverity.HINT,
|
||||
label + ": nicht anwendbar – " + notApplicable.reason(),
|
||||
SOURCE_TAG));
|
||||
notApplicableCount++;
|
||||
LOG.info("GUI-Gesamttest: {} → nicht anwendbar: {}", label, notApplicable.reason());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zusammenfassung
|
||||
long totalFindings = failureErrorCount + failureWarnCount;
|
||||
String summary = buildSummaryMessage(report.results().size(),
|
||||
successCount, failureErrorCount, failureWarnCount, notApplicableCount);
|
||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
|
||||
LOG.info("GUI-Gesamttest abgeschlossen. {} Befunde ({} Erfolg, {} Fehler, {} Warnung, {} nicht anwendbar).",
|
||||
totalFindings, successCount, failureErrorCount, failureWarnCount, notApplicableCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das deutsche Label für einen Prüfpunkt zurück.
|
||||
*
|
||||
* @param id der Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||
* @return das deutsche Label; nie {@code null}
|
||||
*/
|
||||
static String labelFor(CheckpointId id) {
|
||||
return switch (id) {
|
||||
case CONFIGURATION_BASIC_VALIDATION -> "Konfiguration grundsätzlich validierbar";
|
||||
case PROVIDER_CONFIGURATION -> "Provider-Konfiguration prüfbar";
|
||||
case BASE_URL_REACHABLE -> "Basis-URL/Endpoint erreichbar";
|
||||
case API_KEY_PRESENT -> "API-Schlüssel vorhanden";
|
||||
case API_KEY_ACCEPTED -> "API-Schlüssel technisch akzeptiert";
|
||||
case MODEL_LIST_AVAILABLE -> "Modellliste abrufbar";
|
||||
case SELECTED_MODEL_PLAUSIBLE -> "Ausgewähltes Modell plausibel";
|
||||
case PROMPT_FILE_PRESENT -> "Prompt-Datei vorhanden und lesbar";
|
||||
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
||||
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
||||
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die deutsche Zusammenfassungsmeldung des Gesamttests.
|
||||
*
|
||||
* @param total Gesamtzahl der Prüfpunkte
|
||||
* @param successCount Anzahl der erfolgreichen Prüfpunkte
|
||||
* @param errorCount Anzahl der fehlgeschlagenen Prüfpunkte (Schweregrad ERROR)
|
||||
* @param warningCount Anzahl der Warnungs-Prüfpunkte
|
||||
* @param notApplicable Anzahl der nicht-anwendbaren Prüfpunkte
|
||||
* @return deutsche Zusammenfassungsmeldung; nie {@code null}
|
||||
*/
|
||||
private static String buildSummaryMessage(long total, long successCount, long errorCount,
|
||||
long warningCount, long notApplicable) {
|
||||
long findings = errorCount + warningCount;
|
||||
String base = "Gesamttest abgeschlossen. " + total + " Prüfpunkte: "
|
||||
+ successCount + " Erfolg, "
|
||||
+ errorCount + " Fehler, "
|
||||
+ warningCount + " Warnung, "
|
||||
+ notApplicable + " nicht anwendbar.";
|
||||
if (findings == 0) {
|
||||
return base + " Keine Befunde.";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
|
||||
* <p>
|
||||
* Ausschließlich für Tests gedacht.
|
||||
*
|
||||
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
|
||||
*/
|
||||
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||
return List.copyOf(pendingMessages);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
|
||||
/**
|
||||
* Inhalt des gesammelten Bestätigungsdialogs für schreibende Korrekturmaßnahmen.
|
||||
* <p>
|
||||
* Bevor schreibende Korrekturen aus einem {@link CorrectionPlan} ausgeführt werden,
|
||||
* zeigt die GUI diesen Inhalt in einem einmaligen Bestätigungsdialog. Der Benutzer
|
||||
* kann die Korrekturen bestätigen oder ablehnen; ohne Bestätigung werden keine
|
||||
* Änderungen vorgenommen.
|
||||
* <p>
|
||||
* Dieser Record liegt bewusst im GUI-Modul, da er ausschließlich für die
|
||||
* Darstellung im Bestätigungsdialog der JavaFX-Oberfläche genutzt wird. Er enthält
|
||||
* selbst keine JavaFX-Typen und kann auf beliebigen Threads erzeugt werden.
|
||||
* <p>
|
||||
* Die Beschreibungszeilen ({@link #correctionLines}) entsprechen den
|
||||
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der im Plan enthaltenen
|
||||
* Vorschläge in Reihenfolge.
|
||||
*
|
||||
* @param title deutscher Dialogtitel; nie {@code null}
|
||||
* @param introText einleitender deutschsprachiger Text; nie {@code null}
|
||||
* @param correctionLines Liste der deutschen Beschreibungszeilen, eine pro Korrekturmaßnahme;
|
||||
* nie {@code null}
|
||||
*/
|
||||
public record ConfirmationDialogContent(
|
||||
String title,
|
||||
String introText,
|
||||
List<String> correctionLines) {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Bestätigungsdialog-Inhalt.
|
||||
*
|
||||
* @param title Dialogtitel; darf nicht {@code null} sein
|
||||
* @param introText einleitender Text; darf nicht {@code null} sein
|
||||
* @param correctionLines Beschreibungszeilen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
*/
|
||||
public ConfirmationDialogContent {
|
||||
Objects.requireNonNull(title, "title must not be null");
|
||||
Objects.requireNonNull(introText, "introText must not be null");
|
||||
Objects.requireNonNull(correctionLines, "correctionLines must not be null");
|
||||
correctionLines = List.copyOf(correctionLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt den Bestätigungsdialog-Inhalt aus einem {@link CorrectionPlan}.
|
||||
* <p>
|
||||
* Die Beschreibungszeilen werden aus den
|
||||
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der Vorschläge im Plan
|
||||
* in Reihenfolge übernommen.
|
||||
*
|
||||
* @param plan Korrekturplan; darf nicht {@code null} sein
|
||||
* @return ein neuer Dialoginhalt; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code plan} {@code null} ist
|
||||
*/
|
||||
public static ConfirmationDialogContent fromPlan(CorrectionPlan plan) {
|
||||
Objects.requireNonNull(plan, "plan must not be null");
|
||||
List<String> lines = plan.suggestions().stream()
|
||||
.map(CorrectionSuggestion::descriptionForUser)
|
||||
.toList();
|
||||
return new ConfirmationDialogContent(
|
||||
"Korrekturen bestätigen",
|
||||
"Folgende technische Korrekturen werden durchgeführt:",
|
||||
lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob der Dialoginhalt mindestens eine Beschreibungszeile enthält.
|
||||
*
|
||||
* @return {@code true} wenn mindestens eine Korrekturmaßnahme beschrieben ist
|
||||
*/
|
||||
public boolean hasCorrections() {
|
||||
return !correctionLines.isEmpty();
|
||||
}
|
||||
}
|
||||
+2
@@ -25,6 +25,8 @@
|
||||
* <li>The consolidated validation result that feeds both the central message area and
|
||||
* field-near error display
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).</li>
|
||||
* <li>The confirmation dialog content for collected write-corrective actions
|
||||
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent}).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Most classes in this package are intentionally free of JavaFX controls so they can be used
|
||||
|
||||
+27
-1
@@ -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);
|
||||
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Monocle-basierte headless Smoke-Tests für {@link GuiCorrectionDialogCoordinator}.
|
||||
* <p>
|
||||
* Prüft folgende Szenarien:
|
||||
* <ul>
|
||||
* <li>Bericht mit korrigierbaren Befunden → Dialog wird angefragt, bei Bestätigung
|
||||
* werden Korrekturen ausgeführt, Meldungen erscheinen.</li>
|
||||
* <li>Bericht ohne korrigierbare Befunde → kein Dialog, keine Korrekturen.</li>
|
||||
* <li>Bei Dialog-Abbruch → keine Korrekturen, keine Meldungen mit Source „Korrekturen".</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Der {@code correctionThreadFactory} wird auf synchrone Ausführung umgestellt und
|
||||
* {@code resultDelivery} auf direkten Aufruf, damit Ergebnisse sofort nach
|
||||
* {@code offerCorrections()} verfügbar sind.
|
||||
*/
|
||||
class GuiCorrectionDialogCoordinatorSmokeTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
latch.countDown();
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform must start within timeout");
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
verifyLatch.countDown();
|
||||
});
|
||||
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"Existing JavaFX Platform must be reachable within timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Shared platform – do not call Platform.exit().
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: report with correctable findings → dialog shown, corrections applied
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke-Test: Bei korrigierbaren Befunden und Dialog-Bestätigung werden Korrekturen
|
||||
* ausgeführt und als Meldungen eingehängt.
|
||||
*/
|
||||
@Test
|
||||
void offerCorrections_withCorrectableFindings_dialogConfirmed_appliesCorrectionAndAddsMessages()
|
||||
throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
|
||||
|
||||
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
|
||||
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||
service, messages, true /* confirm */);
|
||||
|
||||
TechnicalTestReport report = buildReportWithCorrectableFinding();
|
||||
coordinator.offerCorrections(report);
|
||||
|
||||
assertTrue(correctionExecuted.get(),
|
||||
"Korrektur muss nach Bestätigung ausgeführt worden sein");
|
||||
|
||||
long correctionEntries = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
assertTrue(correctionEntries > 0,
|
||||
"Nach Ausführung müssen Meldungen mit Source '"
|
||||
+ GuiCorrectionDialogCoordinator.SOURCE_TAG + "' vorhanden sein");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: report without correctable findings → no dialog, no corrections
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke-Test: Bericht ohne korrigierbare Befunde → kein Dialog, keine Korrekturen.
|
||||
*/
|
||||
@Test
|
||||
void offerCorrections_withoutCorrectableFindings_noDialogNoCorrections() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
AtomicBoolean dialogShown = new AtomicBoolean(false);
|
||||
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
|
||||
|
||||
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
|
||||
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||
service, messages, true /* confirm */);
|
||||
coordinator.dialogSupplier = content -> {
|
||||
dialogShown.set(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Report with NO correctable findings (all succeed)
|
||||
TechnicalTestReport report = buildReportWithNoCorrectableFindings();
|
||||
coordinator.offerCorrections(report);
|
||||
|
||||
assertFalse(dialogShown.get(), "Kein Dialog darf angezeigt werden wenn keine Korrekturen möglich");
|
||||
assertFalse(correctionExecuted.get(), "Keine Korrektur darf ausgeführt werden");
|
||||
assertTrue(messages.isEmpty(), "Keine Meldungen dürfen hinzugefügt werden");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: dialog cancelled → no corrections, no messages with SOURCE_TAG
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke-Test: Dialog-Abbruch → keine Korrekturen, keine Meldungen mit Source-Tag.
|
||||
*/
|
||||
@Test
|
||||
void offerCorrections_dialogCancelled_noCorrectionsNoMessages() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
AtomicBoolean correctionExecuted = new AtomicBoolean(false);
|
||||
|
||||
CorrectionExecutionService service = buildServiceThatTracksExecution(correctionExecuted);
|
||||
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||
service, messages, false /* cancel */);
|
||||
|
||||
TechnicalTestReport report = buildReportWithCorrectableFinding();
|
||||
coordinator.offerCorrections(report);
|
||||
|
||||
assertFalse(correctionExecuted.get(),
|
||||
"Bei Dialog-Abbruch dürfen keine Korrekturen ausgeführt werden");
|
||||
|
||||
long correctionEntries = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
assertEquals(0, correctionEntries,
|
||||
"Bei Dialog-Abbruch dürfen keine Meldungen mit Source '"
|
||||
+ GuiCorrectionDialogCoordinator.SOURCE_TAG + "' hinzugefügt werden");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: replace semantics – second run replaces previous SOURCE_TAG entries
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke-Test: Beim zweiten Aufruf werden vorherige SOURCE_TAG-Einträge ersetzt.
|
||||
*/
|
||||
@Test
|
||||
void offerCorrections_calledTwice_replacesPreviousMessages() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
AtomicBoolean ignored = new AtomicBoolean(false);
|
||||
|
||||
CorrectionExecutionService service = buildServiceThatTracksExecution(ignored);
|
||||
GuiCorrectionDialogCoordinator coordinator = buildSyncCoordinator(
|
||||
service, messages, true);
|
||||
|
||||
TechnicalTestReport report = buildReportWithCorrectableFinding();
|
||||
coordinator.offerCorrections(report);
|
||||
long countAfterFirst = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
coordinator.offerCorrections(report);
|
||||
long countAfterSecond = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiCorrectionDialogCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
assertEquals(countAfterFirst, countAfterSecond,
|
||||
"Zweiter Aufruf muss vorherige Einträge ersetzen (Replace-Semantik)");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Baut einen synchron laufenden {@link GuiCorrectionDialogCoordinator}.
|
||||
* <p>
|
||||
* Thread-Factory läuft inline; resultDelivery ist direkter Aufruf; dialogSupplier
|
||||
* gibt den festen {@code confirm}-Wert zurück.
|
||||
*/
|
||||
private static GuiCorrectionDialogCoordinator buildSyncCoordinator(
|
||||
CorrectionExecutionService service,
|
||||
List<GuiMessageEntry> messages,
|
||||
boolean confirm) {
|
||||
|
||||
GuiCorrectionDialogCoordinator coordinator = new GuiCorrectionDialogCoordinator(
|
||||
service,
|
||||
messages,
|
||||
ignored -> { /* no-op refresh */ });
|
||||
|
||||
coordinator.dialogSupplier = content -> confirm;
|
||||
coordinator.correctionThreadFactory = task -> new Thread(task, "sync-correction-thread") {
|
||||
@Override
|
||||
public void start() {
|
||||
run(); // inline, synchronous
|
||||
}
|
||||
};
|
||||
coordinator.resultDelivery = Runnable::run; // direct call, no FX queue
|
||||
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut einen {@link CorrectionExecutionService}, der {@code correctionExecuted} auf {@code true}
|
||||
* setzt, wenn eine Korrektur aufgerufen wird.
|
||||
*/
|
||||
private static CorrectionExecutionService buildServiceThatTracksExecution(
|
||||
AtomicBoolean correctionExecuted) {
|
||||
ResourceCreationPort trackingPort = new ResourceCreationPort() {
|
||||
@Override
|
||||
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
|
||||
correctionExecuted.set(true);
|
||||
return new CorrectionOutcome.Applied(s, "Angelegt");
|
||||
}
|
||||
@Override
|
||||
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
|
||||
correctionExecuted.set(true);
|
||||
return new CorrectionOutcome.Applied(s, "Erzeugt");
|
||||
}
|
||||
@Override
|
||||
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
|
||||
correctionExecuted.set(true);
|
||||
return new CorrectionOutcome.Applied(s, "Vorbereitet");
|
||||
}
|
||||
};
|
||||
return new CorrectionExecutionService(trackingPort);
|
||||
}
|
||||
|
||||
/** Baut einen Bericht mit einem korrigierbaren Fehler-Befund. */
|
||||
private static TechnicalTestReport buildReportWithCorrectableFinding() {
|
||||
CorrectionSuggestion.CreateDirectory suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory("C:/test/target", "Zielordner anlegen: C:/test/target");
|
||||
CheckpointResult.Failure failure = new CheckpointResult.Failure(
|
||||
CheckpointId.TARGET_FOLDER_USABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Zielordner nicht vorhanden",
|
||||
Optional.of(suggestion));
|
||||
return new TechnicalTestReport(List.of(failure), Instant.now());
|
||||
}
|
||||
|
||||
/** Baut einen Bericht ohne korrigierbare Befunde (alles erfolgreich). */
|
||||
private static TechnicalTestReport buildReportWithNoCorrectableFindings() {
|
||||
CheckpointResult.Success success = new CheckpointResult.Success(
|
||||
CheckpointId.TARGET_FOLDER_USABLE,
|
||||
"Zielordner vorhanden");
|
||||
return new TechnicalTestReport(List.of(success), Instant.now());
|
||||
}
|
||||
|
||||
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||
java.util.concurrent.atomic.AtomicReference<Throwable> error =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Throwable t) {
|
||||
error.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"FX task must complete within timeout");
|
||||
Throwable t = error.get();
|
||||
if (t != null) {
|
||||
if (t instanceof Exception ex) throw ex;
|
||||
throw new AssertionError("Unexpected error", t);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingRunnable {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -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();
|
||||
|
||||
|
||||
+54
-2
@@ -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<Throwable> 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<Throwable> error = new AtomicReference<>();
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
+135
-5
@@ -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<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||
AtomicReference<Throwable> 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(() -> {
|
||||
|
||||
+54
-2
@@ -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<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||
AtomicReference<Throwable> 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<GuiConfigurationEditorWorkspace> wsRef = new AtomicReference<>();
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
|
||||
+108
-4
@@ -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(() -> {
|
||||
|
||||
+27
-1
@@ -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") {
|
||||
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Button;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the "Technische Tests ausführen" button
|
||||
* and {@link GuiTechnicalTestCoordinator}.
|
||||
* <p>
|
||||
* Verifies the following scenarios:
|
||||
* <ul>
|
||||
* <li>The "Technische Tests ausführen" button is findable by CSS ID
|
||||
* {@code technical-tests-button}.</li>
|
||||
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
||||
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
|
||||
* <li>A second trigger replaces the previous test entries (replace semantics).</li>
|
||||
* <li>The post-result callback is invoked after the result is applied.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The coordinator's {@code testThreadFactory} is overridden to run the task inline
|
||||
* (on the calling thread) and {@code resultDelivery} is overridden with a direct
|
||||
* synchronous call, so results are available immediately after
|
||||
* {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()} returns.
|
||||
*/
|
||||
class GuiTechnicalTestCoordinatorSmokeTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
latch.countDown();
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform must start within timeout");
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
verifyLatch.countDown();
|
||||
});
|
||||
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"Existing JavaFX Platform must be reachable within timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Shared platform – do not call Platform.exit().
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: button is findable by CSS ID
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after constructing a workspace, the "Technische Tests ausführen" button
|
||||
* exists and carries the CSS ID {@code technical-tests-button}.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void technicalTestsButton_hasCssId() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
Button btn = ws.technicalTestsButton;
|
||||
assertNotNull(btn, "technicalTestsButton must not be null");
|
||||
assertEquals("technical-tests-button", btn.getId(),
|
||||
"technicalTestsButton must carry CSS ID 'technical-tests-button'");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: triggering populates pendingMessages with SOURCE_TAG entries
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: triggering the coordinator synchronously populates
|
||||
* {@code pendingMessages} with at least one entry tagged with the source
|
||||
* {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void trigger_populatesPendingMessagesWithSourceTag() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||
|
||||
coordinator.triggerTechnicalTests();
|
||||
|
||||
long taggedCount = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
assertTrue(taggedCount > 0,
|
||||
"Triggering 'Technische Tests ausführen' must add entries tagged '"
|
||||
+ GuiTechnicalTestCoordinator.SOURCE_TAG + "'");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: 11 checkpoint entries + 1 summary = 12 entries total
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
|
||||
* 11 (one per checkpoint) plus 1 summary entry = 12.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||
|
||||
coordinator.triggerTechnicalTests();
|
||||
|
||||
long taggedCount = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
// 11 checkpoint entries + 1 summary entry = 12
|
||||
assertEquals(12, taggedCount,
|
||||
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: replace semantics – second trigger replaces previous entries
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: triggering the coordinator twice replaces the previous SOURCE_TAG
|
||||
* entries; the count remains the same as after a single trigger.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void trigger_twice_replacesPreviousTestEntries() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||
|
||||
coordinator.triggerTechnicalTests();
|
||||
long countAfterFirst = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
coordinator.triggerTechnicalTests();
|
||||
long countAfterSecond = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.count();
|
||||
|
||||
assertEquals(countAfterFirst, countAfterSecond,
|
||||
"Second trigger must replace (not append) the previous test entries");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: post-result callback is invoked
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: the post-result callback is invoked after the result is applied.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void trigger_postResultCallbackIsInvoked() throws Exception {
|
||||
runOnFx(() -> {
|
||||
AtomicBoolean callbackInvoked = new AtomicBoolean(false);
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(
|
||||
messages, report -> callbackInvoked.set(true));
|
||||
|
||||
coordinator.triggerTechnicalTests();
|
||||
|
||||
assertTrue(callbackInvoked.get(),
|
||||
"The post-result callback must be invoked after the technical tests finish");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: input supplier is consulted at trigger time — reflects current (unsaved) state
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: the coordinator must read the current editor state at trigger time via the
|
||||
* injected {@link java.util.function.Supplier}, not from a cached snapshot. This verifies
|
||||
* that unsaved changes are always reflected in the technical test results.
|
||||
*
|
||||
* <p>The test wires the coordinator with a mutable {@link AtomicReference} as input supplier.
|
||||
* Before the first trigger the supplier returns valid input; before the second trigger the
|
||||
* supplier is updated to return input with an empty active-provider (which causes failures).
|
||||
* After the second trigger the message list must contain ERROR entries, proving that the
|
||||
* coordinator forwarded the new, unsaved input value rather than the previous one.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void trigger_readsCurrentInputFromSupplier_unsavedChangesAreReflected() throws Exception {
|
||||
runOnFx(() -> {
|
||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||
|
||||
// Mutable supplier: starts with valid input, will be swapped before second trigger.
|
||||
AtomicReference<EditorValidationInput> currentInput = new AtomicReference<>(
|
||||
new EditorValidationInput(
|
||||
"claude",
|
||||
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.absent()));
|
||||
|
||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||
new EditorConfigurationValidator(),
|
||||
noOpPathCheckPort(),
|
||||
noOpProviderService());
|
||||
|
||||
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||
orchestrator,
|
||||
currentInput::get, // always reads the current reference
|
||||
() -> "",
|
||||
messages,
|
||||
report -> { });
|
||||
|
||||
coordinator.testThreadFactory = task -> new Thread(task, "sync-test-thread") {
|
||||
@Override public void start() { run(); }
|
||||
};
|
||||
coordinator.resultDelivery = Runnable::run;
|
||||
|
||||
// First trigger with valid input — all failures are from path checks (no-op port),
|
||||
// but no CONFIGURATION_BASIC_VALIDATION error because activeProvider is valid.
|
||||
coordinator.triggerTechnicalTests();
|
||||
long errorCountFirst = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.filter(m -> m.severity() == GuiMessageSeverity.ERROR)
|
||||
.count();
|
||||
|
||||
// Simulate unsaved edit: replace input with one having an empty active-provider.
|
||||
currentInput.set(new EditorValidationInput(
|
||||
"", // empty active provider → validation error in block 1
|
||||
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||
"3", "10", "500",
|
||||
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.absent()));
|
||||
|
||||
// Second trigger with the updated (unsaved) input.
|
||||
coordinator.triggerTechnicalTests();
|
||||
long errorCountSecond = messages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||
.filter(m -> m.severity() == GuiMessageSeverity.ERROR)
|
||||
.count();
|
||||
|
||||
// After the change, there must be at least as many errors as before,
|
||||
// because the empty active-provider introduces additional validation errors.
|
||||
assertTrue(errorCountSecond >= errorCountFirst,
|
||||
"Second trigger with empty active-provider must produce at least as many "
|
||||
+ "errors as the first trigger, proving the supplier is read at trigger time");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: workspace button is wired to coordinator
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after constructing a workspace, the button is not null and
|
||||
* the coordinator is not null.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void workspace_buttonAndCoordinatorAreWired() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
assertNotNull(ws.technicalTestsButton,
|
||||
"technicalTestsButton must not be null");
|
||||
assertNotNull(ws.technicalTestCoordinator,
|
||||
"technicalTestCoordinator must not be null");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/** No-op {@link PathCheckPort}: all checks return {@code false}. */
|
||||
private static PathCheckPort noOpPathCheckPort() {
|
||||
return new PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@Override public boolean isFileReadable(String p) { return false; }
|
||||
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||
};
|
||||
}
|
||||
|
||||
/** No-op {@link ProviderTechnicalTestService}. */
|
||||
private static ProviderTechnicalTestService noOpProviderService() {
|
||||
return new ProviderTechnicalTestService(
|
||||
req -> new ModelCatalogResult.IncompleteConfiguration(
|
||||
req.providerIdentifier(), "kein Port im Test"),
|
||||
(fam, pv) -> EffectiveApiKeyDescriptor.absent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link GuiTechnicalTestCoordinator} that runs synchronously (inline thread
|
||||
* factory, direct result delivery) so results are available immediately after
|
||||
* {@link GuiTechnicalTestCoordinator#triggerTechnicalTests()} returns.
|
||||
*
|
||||
* @param messages mutable list to collect message entries
|
||||
* @param postResultCallback callback to invoke after result is applied
|
||||
* @return synchronously-wired coordinator
|
||||
*/
|
||||
private static GuiTechnicalTestCoordinator buildSyncCoordinator(
|
||||
List<GuiMessageEntry> messages,
|
||||
java.util.function.Consumer<TechnicalTestReport> postResultCallback) {
|
||||
|
||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||
new EditorConfigurationValidator(),
|
||||
noOpPathCheckPort(),
|
||||
noOpProviderService());
|
||||
|
||||
EditorValidationInput blankInput = new EditorValidationInput(
|
||||
"claude",
|
||||
"/src", "/tgt", "/db.sqlite", "/prompt.txt",
|
||||
"3", "10", "2000",
|
||||
"https://api.anthropic.com", "claude-3-sonnet", "30",
|
||||
EffectiveApiKeyDescriptor.absent(),
|
||||
"https://api.openai.com", "gpt-4", "30",
|
||||
EffectiveApiKeyDescriptor.absent());
|
||||
|
||||
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||
orchestrator,
|
||||
() -> blankInput,
|
||||
() -> "",
|
||||
messages,
|
||||
postResultCallback);
|
||||
|
||||
// Override thread factory to run task inline (synchronous, no real thread spawn)
|
||||
coordinator.testThreadFactory = task -> new Thread(task, "sync-test-thread") {
|
||||
@Override
|
||||
public void start() {
|
||||
// Run the task inline instead of starting a new thread
|
||||
run();
|
||||
}
|
||||
};
|
||||
// Override result delivery to be synchronous (direct call)
|
||||
coordinator.resultDelivery = Runnable::run;
|
||||
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Throwable t) {
|
||||
error.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"FX task must complete within timeout");
|
||||
rethrow(error);
|
||||
}
|
||||
|
||||
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
|
||||
Throwable t = error.get();
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
if (t instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw new AssertionError("Unexpected error", t);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingRunnable {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+54
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Button;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Monocle-based headless smoke tests for the explicit "Validieren" action.
|
||||
* <p>
|
||||
* Verifies that clicking the "Validieren" button in the "Tests" section executes
|
||||
* the in-memory validation against the current editor state, reports the findings
|
||||
* via the central message area, and neither writes any file nor triggers remote calls.
|
||||
*
|
||||
* <h2>Covered scenarios</h2>
|
||||
* <ul>
|
||||
* <li>Clicking "Validieren" with an incomplete configuration produces ERROR findings and
|
||||
* an INFO message reporting the finding count.</li>
|
||||
* <li>Clicking "Validieren" with a valid template configuration produces no ERRORs and
|
||||
* an INFO message reporting "Keine Befunde." or a zero count.</li>
|
||||
* <li>Clicking "Validieren" twice replaces the previous action-confirmation INFO message
|
||||
* (replace semantics; the message appears exactly once).</li>
|
||||
* <li>Clicking "Validieren" does not trigger any file write (the writer stub records no
|
||||
* calls).</li>
|
||||
* <li>The button is findable by its CSS ID {@code validate-button}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Threading</h2>
|
||||
* All workspace interactions run on the FX Application Thread via {@link Platform#runLater}.
|
||||
* The Monocle headless configuration is activated by the Surefire JVM arguments.
|
||||
*/
|
||||
class GuiValidateActionSmokeTest {
|
||||
|
||||
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||
|
||||
/** Source tag used by the validation action INFO message. */
|
||||
private static final String ACTION_SOURCE = "Validierung-Aktion";
|
||||
|
||||
@BeforeAll
|
||||
static void setUpJavaFxPlatform() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
latch.countDown();
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"JavaFX Platform must start within timeout");
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
CountDownLatch verifyLatch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
PLATFORM_STARTED.set(true);
|
||||
verifyLatch.countDown();
|
||||
});
|
||||
assertTrue(verifyLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"Existing JavaFX Platform must be reachable within timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownJavaFxPlatform() {
|
||||
// Shared platform – do not call Platform.exit().
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: button is findable by CSS ID
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after constructing a workspace, the "Validieren" button exists
|
||||
* and carries the CSS ID {@code validate-button}.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateButton_hasCssId() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws =
|
||||
new GuiConfigurationEditorWorkspace(Optional.empty());
|
||||
Button btn = ws.validateButton;
|
||||
assertNotNull(btn, "validateButton must not be null");
|
||||
assertEquals("validate-button", btn.getId(),
|
||||
"validateButton must carry CSS ID 'validate-button'");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: incomplete configuration → ERROR findings + INFO message with count
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after clicking "Validieren" on a workspace whose editor state has
|
||||
* an empty active-provider value, the last validation result contains at least one
|
||||
* ERROR and the central message area contains an INFO message with source
|
||||
* "Validierung-Aktion" that reports the number of findings.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateAction_incompleteConfiguration_producesErrorsAndInfoMessage() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||
|
||||
// Force an incomplete state: start with blank (no active provider).
|
||||
// The blank start state already has an empty active provider → errors expected.
|
||||
|
||||
ws.validateButton.fire();
|
||||
|
||||
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||
assertTrue(result.hasErrors(),
|
||||
"Clicking Validieren on incomplete config must produce ERROR findings");
|
||||
|
||||
List<GuiMessageEntry> actionMessages = ws.pendingMessages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& ACTION_SOURCE.equals(m.source().get()))
|
||||
.toList();
|
||||
|
||||
assertEquals(1, actionMessages.size(),
|
||||
"Exactly one action-confirmation INFO message must be present");
|
||||
GuiMessageEntry msg = actionMessages.get(0);
|
||||
assertEquals(GuiMessageSeverity.INFO, msg.severity(),
|
||||
"Action-confirmation message must have INFO severity");
|
||||
assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||
"Action-confirmation message text must start with expected prefix");
|
||||
assertFalse(msg.text().contains("Keine Befunde"),
|
||||
"With errors present the message must NOT say 'Keine Befunde'");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: valid configuration → no ERRORs + INFO message "Keine Befunde."
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: after clicking "Validieren" on a workspace with the standard template
|
||||
* values loaded, the last validation result contains no ERRORs and the action-confirmation
|
||||
* message either contains "Keine Befunde." or a zero finding count.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateAction_validTemplate_noErrorsAndNoBefundeMessage() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
ws.validateButton.fire();
|
||||
|
||||
GuiEditorValidationResult result = ws.lastValidationResult();
|
||||
assertFalse(result.hasErrors(),
|
||||
"Clicking Validieren on standard template must produce no ERROR findings");
|
||||
|
||||
List<GuiMessageEntry> actionMessages = ws.pendingMessages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& ACTION_SOURCE.equals(m.source().get()))
|
||||
.toList();
|
||||
|
||||
assertEquals(1, actionMessages.size(),
|
||||
"Exactly one action-confirmation INFO message must be present");
|
||||
GuiMessageEntry msg = actionMessages.get(0);
|
||||
assertEquals(GuiMessageSeverity.INFO, msg.severity(),
|
||||
"Action-confirmation message must have INFO severity");
|
||||
// Template may have WARNINGs but no ERRORs. The fieldFindings count may be 0.
|
||||
assertTrue(msg.text().startsWith("Aktion Validieren wurde ausgeführt."),
|
||||
"Action-confirmation message text must start with expected prefix");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: "Validieren" with unsaved (dirty) editor state — validates current content
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: when the editor holds unsaved changes that introduce a validation error,
|
||||
* clicking "Validieren" must reflect the <em>current, modified (dirty)</em> editor state,
|
||||
* not the previously saved or baseline state.
|
||||
*
|
||||
* <p>The test loads the standard template (which has a valid active-provider value and
|
||||
* produces no errors), then directly overwrites the in-memory editor state with an
|
||||
* equivalent state whose active-provider value is cleared. This mimics a user editing a
|
||||
* field without saving. A subsequent click on "Validieren" must produce ERROR findings
|
||||
* that originate from the unsaved change — proving that validation is always based on the
|
||||
* current editor content.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateAction_withUnsavedDirtyChange_validatesCurrentState() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||
|
||||
// Load the template so the baseline is valid (no errors expected initially).
|
||||
ws.requestNewConfiguration();
|
||||
|
||||
// Confirm: template produces no errors.
|
||||
ws.validateButton.fire();
|
||||
assertFalse(ws.lastValidationResult().hasErrors(),
|
||||
"Standard template must produce no errors before the dirty change");
|
||||
|
||||
// Simulate an unsaved edit: replace the editor state with a version that has
|
||||
// an empty active-provider value. The baseline remains the template values,
|
||||
// so isDirty() returns true. The workspace field is package-private.
|
||||
GuiConfigurationEditorState currentState = ws.editorState;
|
||||
GuiConfigurationEditorState dirtyState = currentState.withValues(
|
||||
currentState.values().withActiveProviderFamily(""));
|
||||
ws.editorState = dirtyState;
|
||||
|
||||
// Confirm the editor is now dirty (sanity check).
|
||||
assertTrue(ws.editorState.isDirty(),
|
||||
"Editor state must be dirty after the unsaved change");
|
||||
|
||||
// Click "Validieren" again — must validate the dirty (unsaved) state.
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertTrue(ws.lastValidationResult().hasErrors(),
|
||||
"Validieren must produce ERROR findings reflecting the unsaved dirty state, "
|
||||
+ "not the previously clean baseline state");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: clicking twice → message appears exactly once (replace semantics)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: clicking "Validieren" twice must leave exactly one action-confirmation
|
||||
* INFO message in the message list (the second click replaces the first).
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateAction_clickedTwice_infoMessageAppearsExactlyOnce() throws Exception {
|
||||
runOnFx(() -> {
|
||||
GuiConfigurationEditorWorkspace ws = buildWorkspace();
|
||||
|
||||
ws.validateButton.fire();
|
||||
ws.validateButton.fire();
|
||||
|
||||
long count = ws.pendingMessages.stream()
|
||||
.filter(m -> m.source().isPresent()
|
||||
&& ACTION_SOURCE.equals(m.source().get()))
|
||||
.count();
|
||||
assertEquals(1, count,
|
||||
"After two clicks the action-confirmation INFO message must appear exactly once");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scenario: clicking Validieren does not trigger a file write
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Smoke test: clicking "Validieren" must not invoke the configuration file writer.
|
||||
* The writer stub records whether it was called; it must remain uncalled after
|
||||
* the button is fired.
|
||||
*
|
||||
* @throws Exception if the FX thread task fails or times out
|
||||
*/
|
||||
@Test
|
||||
void validateAction_doesNotTriggerFileWrite() throws Exception {
|
||||
runOnFx(() -> {
|
||||
AtomicBoolean writerCalled = new AtomicBoolean(false);
|
||||
|
||||
GuiConfigurationEditorState blankState =
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||
|
||||
GuiStartupContext ctx = new GuiStartupContext(
|
||||
blankState,
|
||||
Optional.empty(),
|
||||
path -> blankState, // no-op loader
|
||||
(values, path) -> {
|
||||
writerCalled.set(true);
|
||||
return GuiConfigurationSaveResult.saved(path);
|
||||
},
|
||||
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.ModelCatalogResult.IncompleteConfiguration(
|
||||
req.providerIdentifier(), "kein Port im Test"),
|
||||
(family, propertyValue) ->
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.EffectiveApiKeyDescriptor.absent(),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@Override public boolean isFileReadable(String p) { return false; }
|
||||
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||
},
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@Override public boolean isFileReadable(String p) { return false; }
|
||||
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||
},
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||
}));
|
||||
|
||||
GuiConfigurationEditorWorkspace ws = new GuiConfigurationEditorWorkspace(ctx);
|
||||
ws.validateButton.fire();
|
||||
|
||||
assertFalse(writerCalled.get(),
|
||||
"Clicking Validieren must not invoke the file writer");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Builds a workspace with no-op loader/writer and absent API-key resolution,
|
||||
* suitable for in-memory validation tests.
|
||||
*/
|
||||
private static GuiConfigurationEditorWorkspace buildWorkspace() {
|
||||
GuiConfigurationEditorState blankState =
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState();
|
||||
|
||||
GuiStartupContext ctx = new GuiStartupContext(
|
||||
blankState,
|
||||
Optional.empty(),
|
||||
path -> blankState,
|
||||
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||
req -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.ModelCatalogResult.IncompleteConfiguration(
|
||||
req.providerIdentifier(), "kein Port im Test"),
|
||||
(family, propertyValue) ->
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||
.EffectiveApiKeyDescriptor.absent(),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||
req2 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req2.providerIdentifier(), "kein Port im Test"),
|
||||
(fam2, pv2) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@Override public boolean isFileReadable(String p) { return false; }
|
||||
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||
},
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator(
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort() {
|
||||
@Override public boolean isDirectoryReadable(String p) { return false; }
|
||||
@Override public boolean isDirectoryWritableOrCreatable(String p) { return false; }
|
||||
@Override public boolean isFileReadable(String p) { return false; }
|
||||
@Override public boolean isSqlitePathUsable(String p) { return false; }
|
||||
},
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreatePromptFile s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.PrepareSqlitePath s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||
}));
|
||||
|
||||
return new GuiConfigurationEditorWorkspace(ctx);
|
||||
}
|
||||
|
||||
private static void runOnFx(ThrowingRunnable task) throws Exception {
|
||||
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
task.run();
|
||||
} catch (Throwable t) {
|
||||
error.set(t);
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||
"FX task must complete within timeout");
|
||||
rethrow(error);
|
||||
}
|
||||
|
||||
private static void rethrow(AtomicReference<Throwable> error) throws Exception {
|
||||
Throwable t = error.get();
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
if (t instanceof Exception ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw new AssertionError("Unexpected error", t);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingRunnable {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests für {@link ConfirmationDialogContent}.
|
||||
*/
|
||||
class ConfirmationDialogContentTest {
|
||||
|
||||
@Test
|
||||
void fromPlan_extractsDescriptionsInOrder() {
|
||||
var s1 = new CorrectionSuggestion.CreateDirectory("/path/a", "Zielordner anlegen");
|
||||
var s2 = new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt-Datei erzeugen");
|
||||
var plan = new CorrectionPlan(List.of(s1, s2));
|
||||
|
||||
var content = ConfirmationDialogContent.fromPlan(plan);
|
||||
|
||||
assertThat(content.correctionLines()).containsExactly(
|
||||
"Zielordner anlegen",
|
||||
"Prompt-Datei erzeugen");
|
||||
assertThat(content.title()).isNotBlank();
|
||||
assertThat(content.introText()).isNotBlank();
|
||||
assertThat(content.hasCorrections()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromPlan_emptyPlan_hasNoCorrections() {
|
||||
var content = ConfirmationDialogContent.fromPlan(CorrectionPlan.empty());
|
||||
assertThat(content.hasCorrections()).isFalse();
|
||||
assertThat(content.correctionLines()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromPlan_nullPlanThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> ConfirmationDialogContent.fromPlan(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void correctionLinesAreImmutable() {
|
||||
var content = new ConfirmationDialogContent("Titel", "Intro", new ArrayList<>(List.of("Zeile 1")));
|
||||
assertThat(content.correctionLines()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullTitleThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ConfirmationDialogContent(null, "intro", List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullIntroTextThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ConfirmationDialogContent("title", null, List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullCorrectionLinesThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new ConfirmationDialogContent("title", "intro", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equality() {
|
||||
var a = new ConfirmationDialogContent("T", "I", List.of("Z1"));
|
||||
var b = new ConfirmationDialogContent("T", "I", List.of("Z1"));
|
||||
assertThat(a).isEqualTo(b);
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||
|
||||
/**
|
||||
* Dateisystem-basierte Implementierung von {@link PathCheckPort}.
|
||||
* <p>
|
||||
* Prüft die Zugänglichkeit von Pfaden für Quellordner, Zielordner, SQLite-Datei
|
||||
* und Prompt-Datei ausschließlich lesend. Es werden keinerlei Dateien, Ordner oder
|
||||
* andere Ressourcen angelegt, verändert oder gelöscht.
|
||||
*
|
||||
* <h2>Windows- und Netzlaufwerk-Unterstützung</h2>
|
||||
* <p>
|
||||
* Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
|
||||
* akzeptiert. Solche Pfade werden nicht allein deshalb abgelehnt, weil dahinter technisch
|
||||
* ein UNC-Pfad stehen könnte. Maßgeblich ist, dass Windows den Pfad als gültig bereitstellt.
|
||||
* UNC-Pfade ({@code \\server\share\...}) werden ebenfalls akzeptiert, sofern das
|
||||
* Betriebssystem sie direkt auflösen kann. Es findet keine Umdeutung zwischen gemappten
|
||||
* Laufwerksbuchstaben und UNC-Pfaden statt.
|
||||
* <p>
|
||||
* Die Implementierung nutzt {@link Paths#get(String)}, {@link Files#exists(Path, java.nio.file.LinkOption...)},
|
||||
* {@link Files#isReadable(Path)} und {@link Files#isWritable(Path)}, die unter Windows
|
||||
* gemappte Laufwerke korrekt respektieren.
|
||||
*
|
||||
* <h2>Thread-Safety</h2>
|
||||
* <p>
|
||||
* Diese Klasse ist zustandslos und damit thread-safe. Jede Methode kann gleichzeitig
|
||||
* von mehreren Threads aufgerufen werden. Der Aufrufer ist dafür verantwortlich, die
|
||||
* Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
|
||||
* blockierend sein kann.
|
||||
*
|
||||
* <h2>Fehlerbehandlung</h2>
|
||||
* <p>
|
||||
* Erwartete Fehlerbedingungen (Pfad nicht vorhanden, keine Leseberechtigung) werden
|
||||
* als {@code boolean}-Rückgabewert kommuniziert. Unerwartete technische Fehler werden
|
||||
* geloggt und als {@code false} zurückgegeben.
|
||||
*/
|
||||
public class FilesystemPathCheckAdapter implements PathCheckPort {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(FilesystemPathCheckAdapter.class);
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen {@code FilesystemPathCheckAdapter}.
|
||||
*/
|
||||
public FilesystemPathCheckAdapter() {
|
||||
// stateless — kein Zustand zu initialisieren
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
|
||||
* <p>
|
||||
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
|
||||
* kein Verzeichnis oder nicht lesbar ist.
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn der Ordner existiert und gelesen werden kann
|
||||
*/
|
||||
@Override
|
||||
public boolean isDirectoryReadable(String path) {
|
||||
LOG.debug("Prüfe Ordner auf Lesbarkeit: {}", path);
|
||||
Path resolved = toPath(path);
|
||||
if (resolved == null) {
|
||||
LOG.warn("Ordner-Lesbarkeit: ungültiger Pfad: {}", path);
|
||||
return false;
|
||||
}
|
||||
boolean result = Files.exists(resolved)
|
||||
&& Files.isDirectory(resolved)
|
||||
&& Files.isReadable(resolved);
|
||||
if (result) {
|
||||
LOG.debug("Ordner lesbar: {}", resolved);
|
||||
} else {
|
||||
LOG.warn("Ordner nicht lesbar oder nicht vorhanden: {}", resolved);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
|
||||
* oder ob dieser Ordner technisch anlegbar wäre.
|
||||
* <p>
|
||||
* Gibt {@code true} zurück, wenn:
|
||||
* <ul>
|
||||
* <li>der Ordner existiert und schreibbar ist, oder</li>
|
||||
* <li>der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und
|
||||
* schreibbar ist (anlegbar).</li>
|
||||
* </ul>
|
||||
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, der Ordner
|
||||
* existiert aber nicht schreibbar ist, oder weder der Ordner noch ein schreibbarer
|
||||
* Elternpfad vorhanden ist.
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
|
||||
*/
|
||||
@Override
|
||||
public boolean isDirectoryWritableOrCreatable(String path) {
|
||||
LOG.debug("Prüfe Ordner auf Schreibbarkeit oder Anlegbarkeit: {}", path);
|
||||
Path resolved = toPath(path);
|
||||
if (resolved == null) {
|
||||
LOG.warn("Ordner-Schreibbarkeit: ungültiger Pfad: {}", path);
|
||||
return false;
|
||||
}
|
||||
if (Files.exists(resolved)) {
|
||||
boolean writable = Files.isDirectory(resolved) && Files.isWritable(resolved);
|
||||
if (writable) {
|
||||
LOG.debug("Ordner vorhanden und schreibbar: {}", resolved);
|
||||
} else {
|
||||
LOG.warn("Ordner vorhanden, aber nicht schreibbar: {}", resolved);
|
||||
}
|
||||
return writable;
|
||||
}
|
||||
// Ordner existiert nicht — prüfen ob Elternpfad schreibbar ist
|
||||
Path parent = resolved.getParent();
|
||||
if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
|
||||
LOG.debug("Ordner nicht vorhanden, aber anlegbar (Elternpfad schreibbar): {}", resolved);
|
||||
return true;
|
||||
}
|
||||
LOG.warn("Ordner nicht vorhanden und nicht anlegbar: {}", resolved);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
|
||||
* <p>
|
||||
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, nicht vorhanden,
|
||||
* kein reguläres File oder nicht lesbar ist.
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn die Datei existiert und gelesen werden kann
|
||||
*/
|
||||
@Override
|
||||
public boolean isFileReadable(String path) {
|
||||
LOG.debug("Prüfe Datei auf Lesbarkeit: {}", path);
|
||||
Path resolved = toPath(path);
|
||||
if (resolved == null) {
|
||||
LOG.warn("Datei-Lesbarkeit: ungültiger Pfad: {}", path);
|
||||
return false;
|
||||
}
|
||||
boolean result = Files.exists(resolved)
|
||||
&& Files.isRegularFile(resolved)
|
||||
&& Files.isReadable(resolved);
|
||||
if (result) {
|
||||
LOG.debug("Datei lesbar: {}", resolved);
|
||||
} else {
|
||||
LOG.warn("Datei nicht lesbar oder nicht vorhanden: {}", resolved);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
|
||||
* <p>
|
||||
* Gibt {@code true} zurück, wenn:
|
||||
* <ul>
|
||||
* <li>die Datei existiert, les- und schreibbar ist, oder</li>
|
||||
* <li>die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden
|
||||
* und schreibbar ist (Datei wäre anlegbar).</li>
|
||||
* </ul>
|
||||
* Gibt {@code false} zurück, wenn der Pfad leer, nicht parsebar, die Datei
|
||||
* existiert aber nicht nutzbar ist, oder weder die Datei noch ein beschreibbarer
|
||||
* Elternordner vorhanden ist.
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
|
||||
*/
|
||||
@Override
|
||||
public boolean isSqlitePathUsable(String path) {
|
||||
LOG.debug("Prüfe SQLite-Pfad auf Nutzbarkeit: {}", path);
|
||||
Path resolved = toPath(path);
|
||||
if (resolved == null) {
|
||||
LOG.warn("SQLite-Pfad: ungültiger Pfad: {}", path);
|
||||
return false;
|
||||
}
|
||||
if (Files.exists(resolved)) {
|
||||
boolean usable = Files.isRegularFile(resolved)
|
||||
&& Files.isReadable(resolved)
|
||||
&& Files.isWritable(resolved);
|
||||
if (usable) {
|
||||
LOG.debug("SQLite-Datei vorhanden und nutzbar: {}", resolved);
|
||||
} else {
|
||||
LOG.warn("SQLite-Datei vorhanden, aber nicht les- und schreibbar: {}", resolved);
|
||||
}
|
||||
return usable;
|
||||
}
|
||||
// Datei existiert nicht — prüfen ob Elternordner schreibbar ist
|
||||
Path parent = resolved.getParent();
|
||||
if (parent != null && Files.exists(parent) && Files.isDirectory(parent) && Files.isWritable(parent)) {
|
||||
LOG.debug("SQLite-Datei nicht vorhanden, aber anlegbar (Elternordner schreibbar): {}", resolved);
|
||||
return true;
|
||||
}
|
||||
LOG.warn("SQLite-Pfad nicht nutzbar und nicht anlegbar: {}", resolved);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
|
||||
* <p>
|
||||
* Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist
|
||||
* (z. B. wegen ungültiger Zeichen auf Windows). Keine Ausnahme wird geworfen.
|
||||
*
|
||||
* @param path der zu konvertierende Pfad-String
|
||||
* @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
|
||||
*/
|
||||
private static Path toPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Paths.get(path);
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warn("Pfad nicht parsebar: '{}' — {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Adapter für Dateisystem-basierte Pfadprüfungen.
|
||||
* <p>
|
||||
* Dieses Paket enthält die konkrete Implementierung des {@code PathCheckPort} auf Basis
|
||||
* der JDK-NIO-Dateisystem-API. Es unterstützt ausdrücklich Windows-Pfade mit gemappten
|
||||
* Laufwerksbuchstaben (z. B. {@code S:\}, {@code H:\}) sowie UNC-Pfade.
|
||||
* <p>
|
||||
* Alle Klassen in diesem Paket sind rein lesend und nehmen keinerlei schreibende
|
||||
* Änderungen am Dateisystem vor.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||
|
||||
/**
|
||||
* Dateisystem-basierte Implementierung von {@link ResourceCreationPort}.
|
||||
* <p>
|
||||
* Führt schreibende technische Korrekturmaßnahmen durch: Ordner anlegen,
|
||||
* SQLite-Elternordner vorbereiten und Prompt-Dateien mit übergebenem Inhalt erzeugen.
|
||||
* Alle Methoden sind idempotent, sofern die Ziel-Ressource bereits vorhanden ist.
|
||||
*
|
||||
* <h2>Windows- und Netzlaufwerk-Unterstützung</h2>
|
||||
* <p>
|
||||
* Gemappte Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} werden ausdrücklich
|
||||
* akzeptiert. Die Implementierung nutzt ausschließlich {@link Paths#get(String)} und
|
||||
* {@link Files}-Methoden, die unter Windows gemappte Laufwerke korrekt respektieren.
|
||||
*
|
||||
* <h2>Thread-Safety</h2>
|
||||
* <p>
|
||||
* Diese Klasse ist zustandslos und thread-safe. Der Aufrufer ist verantwortlich dafür,
|
||||
* Methoden auf einem Hintergrund-Worker-Thread auszuführen, da Dateisystem-I/O
|
||||
* blockierend sein kann.
|
||||
*
|
||||
* <h2>Fehlerbehandlung</h2>
|
||||
* <p>
|
||||
* Jede Methode fängt alle technischen Ausnahmen und gibt ein entsprechendes
|
||||
* {@link CorrectionOutcome.Failed}-Ergebnis zurück. Es werden keine geprüften
|
||||
* Ausnahmen an den Aufrufer weitergegeben.
|
||||
*/
|
||||
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen {@code FilesystemResourceCreationAdapter}.
|
||||
*/
|
||||
public FilesystemResourceCreationAdapter() {
|
||||
// zustandslos — kein Zustand zu initialisieren
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
|
||||
* <p>
|
||||
* Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} zurückgegeben
|
||||
* (idempotente Ausführung). Die Aktion wird mit Zielpfad geloggt.
|
||||
*
|
||||
* @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
|
||||
* @return Ergebnis der Ausführung; nie {@code null}
|
||||
*/
|
||||
@Override
|
||||
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
|
||||
Path path = toPath(suggestion.path());
|
||||
if (path == null) {
|
||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(path)) {
|
||||
if (Files.isDirectory(path)) {
|
||||
LOG.info("Ordner bereits vorhanden (kein Anlegen nötig): {}", path);
|
||||
return new CorrectionOutcome.Applied(suggestion,
|
||||
"Ordner bereits vorhanden: " + path.toAbsolutePath());
|
||||
} else {
|
||||
String msg = "Pfad existiert bereits als Datei (kein Ordner): " + path.toAbsolutePath();
|
||||
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
}
|
||||
Files.createDirectories(path);
|
||||
LOG.info("Ordner erfolgreich angelegt: {}", path.toAbsolutePath());
|
||||
return new CorrectionOutcome.Applied(suggestion,
|
||||
"Ordner angelegt: " + path.toAbsolutePath());
|
||||
} catch (IOException e) {
|
||||
String msg = "Ordner konnte nicht angelegt werden: " + e.getMessage();
|
||||
LOG.warn("Ordner anlegen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine neue Prompt-Datei mit dem übergebenen Inhalt.
|
||||
* <p>
|
||||
* Die Datei wird nur erzeugt, wenn sie noch nicht existiert. Falls die Datei bereits
|
||||
* vorhanden ist, wird {@link CorrectionOutcome.NotAttempted} zurückgegeben (kein
|
||||
* stilles Überschreiben). Der Inhalt wird als UTF-8-Text geschrieben.
|
||||
* Die Aktion wird mit Zielpfad geloggt.
|
||||
* <p>
|
||||
* Der Inhalt der erzeugten Datei wird von {@link DefaultPromptTemplate#defaultContent()} geliefert.
|
||||
* Es handelt sich um einen deutschen Standardprompt, der ohne weitere Anpassung funktioniert.
|
||||
*
|
||||
* @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
|
||||
* @return Ergebnis der Ausführung; nie {@code null}
|
||||
*/
|
||||
@Override
|
||||
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||
Path path = toPath(suggestion.path());
|
||||
if (path == null) {
|
||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(path)) {
|
||||
String msg = "Prompt-Datei bereits vorhanden – kein Überschreiben: " + path.toAbsolutePath();
|
||||
LOG.info("Prompt-Datei erzeugen: Datei bereits vorhanden, wird nicht überschrieben: {}", path);
|
||||
return new CorrectionOutcome.NotAttempted(suggestion, msg);
|
||||
}
|
||||
|
||||
// Elternordner sicherstellen
|
||||
Path parent = path.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
LOG.info("Prompt-Datei: Elternordner angelegt: {}", parent);
|
||||
}
|
||||
|
||||
Files.writeString(path, DefaultPromptTemplate.defaultContent(), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
LOG.info("Prompt-Datei erfolgreich erzeugt: {}", path.toAbsolutePath());
|
||||
return new CorrectionOutcome.Applied(suggestion,
|
||||
"Prompt-Datei erzeugt: " + path.toAbsolutePath());
|
||||
} catch (FileAlreadyExistsException e) {
|
||||
String msg = "Prompt-Datei bereits vorhanden – kein Überschreiben: " + path.toAbsolutePath();
|
||||
LOG.info("Prompt-Datei erzeugen: race condition – Datei bereits vorhanden: {}", path);
|
||||
return new CorrectionOutcome.NotAttempted(suggestion, msg);
|
||||
} catch (IOException e) {
|
||||
String msg = "Prompt-Datei konnte nicht erzeugt werden: " + e.getMessage();
|
||||
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {} — {}", path, e.getMessage(), e);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser fehlt.
|
||||
* <p>
|
||||
* Legt den Elternordner der SQLite-Datei mit allen fehlenden Zwischenordnern an,
|
||||
* falls er noch nicht vorhanden ist. Die SQLite-Datei selbst wird nicht erzeugt;
|
||||
* das übernimmt das JDBC-Layer beim ersten Datenbankzugriff. Die Aktion wird geloggt.
|
||||
*
|
||||
* @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
|
||||
* @return Ergebnis der Ausführung; nie {@code null}
|
||||
*/
|
||||
@Override
|
||||
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||
Path path = toPath(suggestion.path());
|
||||
if (path == null) {
|
||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
||||
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
|
||||
Path parent = path.getParent();
|
||||
if (parent == null) {
|
||||
// Datei liegt direkt im Wurzelverzeichnis — kein Elternordner anlegbar
|
||||
LOG.info("SQLite-Pfad: kein Elternordner vorhanden (Wurzelpfad): {}", path);
|
||||
return new CorrectionOutcome.Applied(suggestion,
|
||||
"SQLite-Pfad liegt im Wurzelverzeichnis, kein Ordner anzulegen: " + path.toAbsolutePath());
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(parent)) {
|
||||
LOG.info("SQLite-Elternordner bereits vorhanden: {}", parent);
|
||||
return new CorrectionOutcome.Applied(suggestion,
|
||||
"SQLite-Elternordner bereits vorhanden: " + parent.toAbsolutePath());
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
LOG.info("SQLite-Elternordner erfolgreich angelegt: {}", parent.toAbsolutePath());
|
||||
return new CorrectionOutcome.Applied(suggestion,
|
||||
"SQLite-Elternordner angelegt: " + parent.toAbsolutePath());
|
||||
} catch (IOException e) {
|
||||
String msg = "SQLite-Elternordner konnte nicht angelegt werden: " + e.getMessage();
|
||||
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {} — {}", parent, e.getMessage(), e);
|
||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert den übergebenen Pfad-String in ein {@link Path}-Objekt.
|
||||
* <p>
|
||||
* Gibt {@code null} zurück, wenn der String {@code null}, leer oder nicht parsebar ist.
|
||||
*
|
||||
* @param pathString der zu konvertierende Pfad-String
|
||||
* @return das {@link Path}-Objekt oder {@code null} bei ungültigem Eingabewert
|
||||
*/
|
||||
private static Path toPath(String pathString) {
|
||||
if (pathString == null || pathString.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Paths.get(pathString);
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warn("Pfad nicht parsebar: '{}' — {}", pathString, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Adapter für schreibende technische Korrekturmaßnahmen am Dateisystem.
|
||||
* <p>
|
||||
* Implementiert den {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort}
|
||||
* über direkten Dateisystemzugriff. Alle Operationen sind schreibend und dürfen nur nach
|
||||
* ausdrücklicher Benutzerbestätigung eines
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan} aufgerufen werden.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.pathcheck;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledOnOs;
|
||||
import org.junit.jupiter.api.condition.OS;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link FilesystemPathCheckAdapter}.
|
||||
* <p>
|
||||
* Prüft alle vier Methoden des Ports unter realen Dateisystem-Bedingungen mit
|
||||
* {@link TempDir}. Windows-spezifische Tests werden auf Nicht-Windows-Systemen
|
||||
* automatisch übersprungen.
|
||||
*/
|
||||
class FilesystemPathCheckAdapterTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private FilesystemPathCheckAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adapter = new FilesystemPathCheckAdapter();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isDirectoryReadable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isDirectoryReadable_existingReadableDirectory_returnsTrue() {
|
||||
assertTrue(adapter.isDirectoryReadable(tempDir.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryReadable_nonExistentPath_returnsFalse() {
|
||||
Path absent = tempDir.resolve("does-not-exist");
|
||||
assertFalse(adapter.isDirectoryReadable(absent.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryReadable_existingFile_returnsFalse() throws IOException {
|
||||
Path file = Files.createFile(tempDir.resolve("some-file.txt"));
|
||||
assertFalse(adapter.isDirectoryReadable(file.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryReadable_emptyString_returnsFalse() {
|
||||
assertFalse(adapter.isDirectoryReadable(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryReadable_nullValue_returnsFalse() {
|
||||
assertFalse(adapter.isDirectoryReadable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledOnOs(OS.WINDOWS)
|
||||
void isDirectoryReadable_invalidWindowsCharacters_returnsFalse() {
|
||||
// Zeichen wie '<', '>', '?' sind auf Windows in Pfaden unzulässig
|
||||
assertFalse(adapter.isDirectoryReadable("C:\\invalid<path>?"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isDirectoryWritableOrCreatable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isDirectoryWritableOrCreatable_existingWritableDirectory_returnsTrue() {
|
||||
assertTrue(adapter.isDirectoryWritableOrCreatable(tempDir.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryWritableOrCreatable_nonExistentDirectoryWithWritableParent_returnsTrue() {
|
||||
Path newDir = tempDir.resolve("new-sub-dir");
|
||||
assertTrue(adapter.isDirectoryWritableOrCreatable(newDir.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryWritableOrCreatable_nonExistentDirectoryAndNonExistentParent_returnsFalse() {
|
||||
Path deepAbsent = tempDir.resolve("ghost").resolve("deeply").resolve("nested");
|
||||
assertFalse(adapter.isDirectoryWritableOrCreatable(deepAbsent.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryWritableOrCreatable_emptyString_returnsFalse() {
|
||||
assertFalse(adapter.isDirectoryWritableOrCreatable(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDirectoryWritableOrCreatable_nullValue_returnsFalse() {
|
||||
assertFalse(adapter.isDirectoryWritableOrCreatable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledOnOs(OS.WINDOWS)
|
||||
void isDirectoryWritableOrCreatable_invalidWindowsCharacters_returnsFalse() {
|
||||
assertFalse(adapter.isDirectoryWritableOrCreatable("C:\\invalid<path>?"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isFileReadable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isFileReadable_existingReadableFile_returnsTrue() throws IOException {
|
||||
Path file = Files.createFile(tempDir.resolve("readable.txt"));
|
||||
assertTrue(adapter.isFileReadable(file.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isFileReadable_nonExistentFile_returnsFalse() {
|
||||
Path absent = tempDir.resolve("missing.txt");
|
||||
assertFalse(adapter.isFileReadable(absent.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isFileReadable_existingDirectory_returnsFalse() {
|
||||
assertFalse(adapter.isFileReadable(tempDir.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isFileReadable_emptyString_returnsFalse() {
|
||||
assertFalse(adapter.isFileReadable(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isFileReadable_nullValue_returnsFalse() {
|
||||
assertFalse(adapter.isFileReadable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledOnOs(OS.WINDOWS)
|
||||
void isFileReadable_invalidWindowsCharacters_returnsFalse() {
|
||||
assertFalse(adapter.isFileReadable("C:\\invalid<file>?.txt"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isSqlitePathUsable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isSqlitePathUsable_existingWritableFile_returnsTrue() throws IOException {
|
||||
Path db = Files.createFile(tempDir.resolve("test.db"));
|
||||
assertTrue(adapter.isSqlitePathUsable(db.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSqlitePathUsable_nonExistentFileWithWritableParentDir_returnsTrue() {
|
||||
Path newDb = tempDir.resolve("new.db");
|
||||
assertTrue(adapter.isSqlitePathUsable(newDb.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSqlitePathUsable_nonExistentFileAndNonExistentParentDir_returnsFalse() {
|
||||
Path deepAbsent = tempDir.resolve("ghost").resolve("sub.db");
|
||||
assertFalse(adapter.isSqlitePathUsable(deepAbsent.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSqlitePathUsable_existingDirectory_returnsFalse() {
|
||||
// Ein Verzeichnis ist kein gültiger SQLite-Dateipfad
|
||||
assertFalse(adapter.isSqlitePathUsable(tempDir.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSqlitePathUsable_emptyString_returnsFalse() {
|
||||
assertFalse(adapter.isSqlitePathUsable(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSqlitePathUsable_nullValue_returnsFalse() {
|
||||
assertFalse(adapter.isSqlitePathUsable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@EnabledOnOs(OS.WINDOWS)
|
||||
void isSqlitePathUsable_invalidWindowsCharacters_returnsFalse() {
|
||||
assertFalse(adapter.isSqlitePathUsable("C:\\invalid<db>?.db"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Windows-Pfad-Semantik (Syntaxprüfung, kein echtes Laufwerk erforderlich)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass Pfade mit gemapptem Laufwerksbuchstaben syntaktisch akzeptiert
|
||||
* werden (kein sofortiger Syntaxfehler). Das Ergebnis ist {@code false}, weil das
|
||||
* Laufwerk in dieser Testumgebung nicht existiert — aber es darf nicht wegen des
|
||||
* Laufwerksbuchstabens allein abgelehnt werden.
|
||||
*/
|
||||
@Test
|
||||
@EnabledOnOs(OS.WINDOWS)
|
||||
void windowsMappedDriveSyntax_isAcceptedByAdapter() {
|
||||
// Ein Pfad mit gemapptem Laufwerksbuchstaben darf nicht wegen der Syntax abgelehnt
|
||||
// werden. Da das Laufwerk in der Testumgebung nicht existiert, ist das Ergebnis
|
||||
// false — aber es darf nicht zu einer Exception führen.
|
||||
assertFalse(adapter.isDirectoryReadable("S:\\nonexistent-in-test"));
|
||||
assertFalse(adapter.isDirectoryWritableOrCreatable("H:\\nonexistent-in-test"));
|
||||
assertFalse(adapter.isFileReadable("X:\\nonexistent-in-test\\file.txt"));
|
||||
assertFalse(adapter.isSqlitePathUsable("Z:\\nonexistent-in-test\\db.db"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass UNC-Pfade syntaktisch akzeptiert werden.
|
||||
* Das Ergebnis ist {@code false}, weil der Server nicht existiert.
|
||||
*/
|
||||
@Test
|
||||
@EnabledOnOs(OS.WINDOWS)
|
||||
void windowsUncPathSyntax_isAcceptedByAdapter() {
|
||||
assertFalse(adapter.isDirectoryReadable("\\\\nonexistent-server\\share\\folder"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass der Adapter auf dem lokalen temporären Verzeichnis korrekt
|
||||
* arbeitet — dieses ist plattformübergreifend immer vorhanden.
|
||||
*/
|
||||
@Test
|
||||
void tmpDirIsReadableAndWritableOrCreatable() {
|
||||
String tmpDir = System.getProperty("java.io.tmpdir");
|
||||
assumeTrue(tmpDir != null && !tmpDir.isBlank(), "java.io.tmpdir must be set");
|
||||
assertTrue(adapter.isDirectoryReadable(tmpDir),
|
||||
"java.io.tmpdir must be readable: " + tmpDir);
|
||||
assertTrue(adapter.isDirectoryWritableOrCreatable(tmpDir),
|
||||
"java.io.tmpdir must be writable: " + tmpDir);
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.resourcecreation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.DefaultPromptTemplate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link FilesystemResourceCreationAdapter}.
|
||||
* <p>
|
||||
* Prüft die drei Kernmethoden auf Erfolgs-, Idempotenz- und Fehlerfälle.
|
||||
*/
|
||||
class FilesystemResourceCreationAdapterTest {
|
||||
|
||||
private final FilesystemResourceCreationAdapter adapter = new FilesystemResourceCreationAdapter();
|
||||
|
||||
// =========================================================================
|
||||
// createDirectory
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void createDirectory_nonExistent_returnsApplied(@TempDir Path tempDir) {
|
||||
Path newDir = tempDir.resolve("neu");
|
||||
CorrectionSuggestion.CreateDirectory suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory(newDir.toString(), "Zielordner anlegen");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
|
||||
"Neues Verzeichnis muss Applied zurückgeben");
|
||||
assertTrue(Files.isDirectory(newDir), "Verzeichnis muss nach dem Anlegen existieren");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDirectory_nestedNonExistent_returnsApplied(@TempDir Path tempDir) {
|
||||
Path nestedDir = tempDir.resolve("a").resolve("b").resolve("c");
|
||||
CorrectionSuggestion.CreateDirectory suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory(nestedDir.toString(), "Tiefer Ordner");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||
assertTrue(Files.isDirectory(nestedDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDirectory_alreadyExists_returnsApplied(@TempDir Path tempDir) {
|
||||
// tempDir exists already — should be idempotent
|
||||
CorrectionSuggestion.CreateDirectory suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory(tempDir.toString(), "Ordner vorhanden");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
|
||||
"Bereits vorhandener Ordner muss Applied zurückgeben (idempotent)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDirectory_existingFileAtPath_returnsFailed(@TempDir Path tempDir) throws IOException {
|
||||
Path filePath = tempDir.resolve("existingFile.txt");
|
||||
Files.createFile(filePath);
|
||||
CorrectionSuggestion.CreateDirectory suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory(filePath.toString(), "Datei statt Ordner");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createDirectory(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Failed.class, outcome,
|
||||
"Pfad zeigt auf Datei — muss Failed zurückgeben");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// prepareSqlitePath
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void prepareSqlitePath_nonExistentParent_createsParentAndReturnsApplied(@TempDir Path tempDir) {
|
||||
Path sqliteFile = tempDir.resolve("data").resolve("db.sqlite");
|
||||
CorrectionSuggestion.PrepareSqlitePath suggestion =
|
||||
new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "SQLite-Pfad vorbereiten");
|
||||
|
||||
CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||
assertTrue(Files.isDirectory(sqliteFile.getParent()),
|
||||
"Elternordner muss nach prepareSqlitePath existieren");
|
||||
assertFalse(Files.exists(sqliteFile),
|
||||
"SQLite-Datei selbst darf NICHT angelegt werden");
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareSqlitePath_existingParent_returnsApplied(@TempDir Path tempDir) {
|
||||
// tempDir already exists — parent is tempDir itself
|
||||
Path sqliteFile = tempDir.resolve("existing.sqlite");
|
||||
CorrectionSuggestion.PrepareSqlitePath suggestion =
|
||||
new CorrectionSuggestion.PrepareSqlitePath(sqliteFile.toString(), "Vorhandener Parent");
|
||||
|
||||
CorrectionOutcome outcome = adapter.prepareSqlitePath(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome,
|
||||
"Bereits vorhandener Elternordner muss Applied zurückgeben (idempotent)");
|
||||
assertFalse(Files.exists(sqliteFile),
|
||||
"SQLite-Datei selbst darf NICHT angelegt werden");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// createPromptFile
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void createPromptFile_nonExistent_createsFileAndReturnsApplied(@TempDir Path tempDir) {
|
||||
Path promptFile = tempDir.resolve("prompt.txt");
|
||||
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||
assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPromptFile_alreadyExists_returnsNotAttempted(@TempDir Path tempDir) throws IOException {
|
||||
Path promptFile = tempDir.resolve("existing_prompt.txt");
|
||||
Files.createFile(promptFile);
|
||||
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Datei vorhanden");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.NotAttempted.class, outcome,
|
||||
"Bereits vorhandene Datei darf nicht überschrieben werden — NotAttempted erwartet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPromptFile_nonExistentParent_createsParentAndFile(@TempDir Path tempDir) {
|
||||
Path promptFile = tempDir.resolve("subdir").resolve("prompt.txt");
|
||||
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt in Unterordner");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||
assertTrue(Files.exists(promptFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPromptFile_nonExistent_contentMatchesDefaultPromptTemplate(@TempDir Path tempDir) throws IOException {
|
||||
Path promptFile = tempDir.resolve("prompt.txt");
|
||||
CorrectionSuggestion.CreatePromptFile suggestion =
|
||||
new CorrectionSuggestion.CreatePromptFile(promptFile.toString(), "Prompt-Datei anlegen");
|
||||
|
||||
CorrectionOutcome outcome = adapter.createPromptFile(suggestion);
|
||||
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, outcome);
|
||||
assertTrue(Files.exists(promptFile), "Prompt-Datei muss nach Erzeugung existieren");
|
||||
String writtenContent = Files.readString(promptFile, StandardCharsets.UTF_8);
|
||||
String expectedContent = DefaultPromptTemplate.defaultContent();
|
||||
// Der geschriebene Inhalt muss dem deutschen Standard-Prompt entsprechen
|
||||
assertTrue(writtenContent.contains("Titel"),
|
||||
"Geschriebener Inhalt muss deutschen Standard-Prompt enthalten");
|
||||
assertTrue(writtenContent.equals(expectedContent),
|
||||
"Geschriebener Inhalt muss exakt DefaultPromptTemplate.defaultContent() entsprechen");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Ungültige Pfade
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void createDirectory_blankPath_returnsFailed() {
|
||||
CorrectionSuggestion.CreateDirectory suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory("C:/valid-placeholder", "Dummy");
|
||||
|
||||
// Simulate invalid path behavior by using an adapter that receives an unusual path.
|
||||
// Here we just verify a valid path works — blank path is caught by CorrectionSuggestion constructor.
|
||||
assertNotNull(suggestion);
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
/**
|
||||
* Eindeutiger Bezeichner für jeden definierten Prüfpunkt des technischen Gesamttests.
|
||||
* <p>
|
||||
* Jeder Wert entspricht genau einem Prüfpunkt, der im Rahmen der Aktion
|
||||
* „Technische Tests ausführen" durchlaufen wird. Die Reihenfolge der Konstanten
|
||||
* ist nicht verbindlich für die Ausführungsreihenfolge; sie dient nur der
|
||||
* Übersichtlichkeit.
|
||||
* <p>
|
||||
* Prüfpunkte sind unabhängig voneinander; ein Fehler in einem Prüfpunkt darf
|
||||
* nicht dazu führen, dass spätere Prüfpunkte übersprungen werden. Wenn ein
|
||||
* Prüfpunkt wegen fehlender Voraussetzungen nicht ausführbar ist (z. B.
|
||||
* API-Key-Test ohne bekannte Base-URL), ist das Ergebnis
|
||||
* {@link CheckpointResult.NotApplicable}, kein Fehler.
|
||||
*/
|
||||
public enum CheckpointId {
|
||||
|
||||
/**
|
||||
* Grundlegende Konfigurationsvalidierung – entspricht der lokalen Editorvalidierung.
|
||||
* Prüft formale Pflichtfelder und Werteformate ohne Dateisystem- oder Netzwerkkontakt.
|
||||
*/
|
||||
CONFIGURATION_BASIC_VALIDATION,
|
||||
|
||||
/**
|
||||
* Provider-Konfiguration prüfen: aktiver Provider bekannt, alle Pflichtfelder
|
||||
* des aktiven Providers formal ausgefüllt.
|
||||
*/
|
||||
PROVIDER_CONFIGURATION,
|
||||
|
||||
/**
|
||||
* Base-URL bzw. Endpunkt des aktiven Providers technisch erreichbar (Netzwerktest).
|
||||
*/
|
||||
BASE_URL_REACHABLE,
|
||||
|
||||
/**
|
||||
* API-Key vorhanden – mindestens eine Quelle (Umgebungsvariable oder Properties-Datei)
|
||||
* liefert einen nicht leeren Wert. Dieser Prüfpunkt trifft keine Aussage über die
|
||||
* Korrektheit des Schlüssels.
|
||||
*/
|
||||
API_KEY_PRESENT,
|
||||
|
||||
/**
|
||||
* API-Key technisch akzeptiert – Authentifizierung am Provider-Endpunkt erfolgreich.
|
||||
* Setzt voraus, dass {@link #API_KEY_PRESENT} bestanden wurde; andernfalls ist dieser
|
||||
* Prüfpunkt {@link CheckpointResult.NotApplicable}.
|
||||
*/
|
||||
API_KEY_ACCEPTED,
|
||||
|
||||
/**
|
||||
* Modellliste abrufbar – der Provider liefert eine nicht leere Liste verfügbarer Modelle.
|
||||
* Nutzt denselben Outbound-Port wie der automatische Modellabruf; keine zweite Implementierung.
|
||||
*/
|
||||
MODEL_LIST_AVAILABLE,
|
||||
|
||||
/**
|
||||
* Ausgewähltes Modell plausibel – der konfigurierte Modellname ist in der zuletzt
|
||||
* geladenen Modellliste vorhanden oder formal zulässig.
|
||||
* Setzt voraus, dass {@link #MODEL_LIST_AVAILABLE} bestanden wurde.
|
||||
*/
|
||||
SELECTED_MODEL_PLAUSIBLE,
|
||||
|
||||
/**
|
||||
* Prompt-Datei vorhanden und lesbar – die konfigurierte Prompt-Datei existiert im
|
||||
* Dateisystem und kann gelesen werden.
|
||||
*/
|
||||
PROMPT_FILE_PRESENT,
|
||||
|
||||
/**
|
||||
* Quellordner vorhanden und lesbar – der konfigurierte Quellordner existiert und
|
||||
* kann vom Prozess gelesen werden.
|
||||
*/
|
||||
SOURCE_FOLDER_PRESENT,
|
||||
|
||||
/**
|
||||
* Zielordner vorhanden oder anlegbar sowie schreibbar – der konfigurierte Zielordner
|
||||
* existiert und ist schreibbar, oder er ist noch nicht vorhanden, aber der Pfad ist
|
||||
* technisch anlegbar.
|
||||
*/
|
||||
TARGET_FOLDER_USABLE,
|
||||
|
||||
/**
|
||||
* SQLite-Datei bzw. SQLite-Pfad technisch nutzbar – der konfigurierte SQLite-Pfad
|
||||
* zeigt auf eine vorhandene Datei oder auf einen beschreibbaren Ordner, in dem die
|
||||
* Datei neu angelegt werden kann.
|
||||
*/
|
||||
SQLITE_PATH_USABLE
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Versiegeltes Ergebnis eines einzelnen Prüfpunkts des technischen Gesamttests.
|
||||
* <p>
|
||||
* Jeder Prüfpunkt liefert genau einen der drei möglichen Zustände:
|
||||
* <ul>
|
||||
* <li>{@link Success} – der Prüfpunkt wurde bestanden.</li>
|
||||
* <li>{@link Failure} – der Prüfpunkt wurde nicht bestanden (Fehler oder Warnung),
|
||||
* optional mit einem Korrekturvorschlag.</li>
|
||||
* <li>{@link NotApplicable} – der Prüfpunkt konnte wegen fehlender Voraussetzungen
|
||||
* nicht ausgeführt werden (z. B. API-Key-Test ohne vorhandenen API-Key).
|
||||
* Dies ist kein Fehler, sondern ein eigenständiger Zustand.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Alle Implementierungen sind immutable und enthalten keine JavaFX-Typen. Sie können
|
||||
* auf beliebigen Threads erzeugt und sicher an den JavaFX Application Thread übergeben werden.
|
||||
*/
|
||||
public sealed interface CheckpointResult
|
||||
permits CheckpointResult.Success,
|
||||
CheckpointResult.Failure,
|
||||
CheckpointResult.NotApplicable {
|
||||
|
||||
/**
|
||||
* Gibt den Bezeichner des Prüfpunkts zurück, zu dem dieses Ergebnis gehört.
|
||||
*
|
||||
* @return Prüfpunkt-Bezeichner; nie {@code null}
|
||||
*/
|
||||
CheckpointId checkpointId();
|
||||
|
||||
/**
|
||||
* Der Prüfpunkt wurde bestanden.
|
||||
*
|
||||
* @param checkpointId Bezeichner des bestandenen Prüfpunkts; nie {@code null}
|
||||
* @param message deutsche Bestätigungsmeldung; nie {@code null}
|
||||
*/
|
||||
record Success(
|
||||
CheckpointId checkpointId,
|
||||
String message) implements CheckpointResult {
|
||||
|
||||
/**
|
||||
* Erstellt ein Erfolgs-Ergebnis.
|
||||
*
|
||||
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||
* @param message deutsche Bestätigungsmeldung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code checkpointId} oder {@code message} {@code null} sind
|
||||
*/
|
||||
public Success {
|
||||
Objects.requireNonNull(checkpointId, "checkpointId must not be null");
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Prüfpunkt wurde nicht bestanden.
|
||||
* <p>
|
||||
* Ein gescheiterter Prüfpunkt hat immer einen Schweregrad ({@link CheckpointSeverity})
|
||||
* und eine deutsche Fehlermeldung. Optional ist ein {@link CorrectionSuggestion}
|
||||
* beigefügt, wenn eine sichere technische Korrektur möglich ist.
|
||||
* <p>
|
||||
* Ein Failure mit Schweregrad {@link CheckpointSeverity#WARNING} markiert eine
|
||||
* riskante, aber formal zulässige Einstellung. Ein Failure mit
|
||||
* {@link CheckpointSeverity#ERROR} zeigt an, dass der Gesamtstand nicht lauffähig ist.
|
||||
*
|
||||
* @param checkpointId Bezeichner des nicht bestandenen Prüfpunkts; nie {@code null}
|
||||
* @param severity Schweregrad; nie {@code null}
|
||||
* @param message deutsche Fehlermeldung; nie {@code null}
|
||||
* @param correctionSuggestion optionaler Korrekturvorschlag; leer wenn keine Korrektur angeboten wird
|
||||
*/
|
||||
record Failure(
|
||||
CheckpointId checkpointId,
|
||||
CheckpointSeverity severity,
|
||||
String message,
|
||||
Optional<CorrectionSuggestion> correctionSuggestion) implements CheckpointResult {
|
||||
|
||||
/**
|
||||
* Erstellt ein Fehler-Ergebnis mit optionalem Korrekturvorschlag.
|
||||
*
|
||||
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||
* @param message deutsche Fehlermeldung; darf nicht {@code null} sein
|
||||
* @param correctionSuggestion optionaler Vorschlag; {@code null} wird zu leerem Optional
|
||||
* @throws NullPointerException wenn {@code checkpointId}, {@code severity} oder {@code message} {@code null} sind
|
||||
*/
|
||||
public Failure {
|
||||
Objects.requireNonNull(checkpointId, "checkpointId must not be null");
|
||||
Objects.requireNonNull(severity, "severity must not be null");
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
correctionSuggestion = correctionSuggestion == null
|
||||
? Optional.empty()
|
||||
: correctionSuggestion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein Fehler-Ergebnis ohne Korrekturvorschlag.
|
||||
*
|
||||
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||
* @param message deutsche Fehlermeldung; darf nicht {@code null} sein
|
||||
* @return ein neues Failure-Ergebnis ohne Korrekturvorschlag
|
||||
*/
|
||||
public static Failure of(CheckpointId checkpointId, CheckpointSeverity severity, String message) {
|
||||
return new Failure(checkpointId, severity, message, Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein Fehler-Ergebnis mit einem Korrekturvorschlag.
|
||||
*
|
||||
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||
* @param severity Schweregrad; darf nicht {@code null} sein
|
||||
* @param message deutsche Fehlermeldung; darf nicht {@code null} sein
|
||||
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||
* @return ein neues Failure-Ergebnis mit Korrekturvorschlag
|
||||
*/
|
||||
public static Failure withCorrection(CheckpointId checkpointId, CheckpointSeverity severity,
|
||||
String message, CorrectionSuggestion suggestion) {
|
||||
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||
return new Failure(checkpointId, severity, message, Optional.of(suggestion));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob zu diesem Befund ein Korrekturvorschlag vorliegt.
|
||||
*
|
||||
* @return {@code true} wenn ein Korrekturvorschlag vorhanden ist
|
||||
*/
|
||||
public boolean hasCorrectionSuggestion() {
|
||||
return correctionSuggestion.isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Prüfpunkt konnte wegen fehlender Voraussetzungen nicht ausgeführt werden.
|
||||
* <p>
|
||||
* Beispiel: Der Prüfpunkt {@link CheckpointId#API_KEY_ACCEPTED} ist nicht ausführbar,
|
||||
* wenn {@link CheckpointId#API_KEY_PRESENT} zuvor als Fehler bewertet wurde.
|
||||
* <p>
|
||||
* {@code NotApplicable} ist kein Fehler; er wird im Meldungsbereich neutral dargestellt
|
||||
* und wird nicht als Korrekturanlass behandelt.
|
||||
*
|
||||
* @param checkpointId Bezeichner des nicht ausgeführten Prüfpunkts; nie {@code null}
|
||||
* @param reason deutsche Begründung, warum der Prüfpunkt übersprungen wurde; nie {@code null}
|
||||
*/
|
||||
record NotApplicable(
|
||||
CheckpointId checkpointId,
|
||||
String reason) implements CheckpointResult {
|
||||
|
||||
/**
|
||||
* Erstellt ein Nicht-Anwendbar-Ergebnis.
|
||||
*
|
||||
* @param checkpointId Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||
* @param reason deutsche Begründung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code checkpointId} oder {@code reason} {@code null} sind
|
||||
*/
|
||||
public NotApplicable {
|
||||
Objects.requireNonNull(checkpointId, "checkpointId must not be null");
|
||||
Objects.requireNonNull(reason, "reason must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
/**
|
||||
* Schweregrade für gescheiterte Prüfpunkte ({@link CheckpointResult.Failure}) des technischen Gesamttests.
|
||||
* <p>
|
||||
* Die Schweregrade sind analog zu den Stufen der editornahen Validierung
|
||||
* ({@code EditorValidationSeverity}), jedoch auf die Semantik des technischen Gesamttests
|
||||
* zugeschnitten:
|
||||
* <ul>
|
||||
* <li>{@link #WARNING} – riskante, aber technisch zulässige Einstellung. Das Speichern und
|
||||
* ein späterer headless-Lauf sind möglich, können aber unerwartetes Verhalten zeigen.</li>
|
||||
* <li>{@link #ERROR} – ungültige oder fehlende Konfiguration. Die Einstellung ist im
|
||||
* aktuellen Zustand nicht lauffähig.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Hinweise und neutrale Informationen werden als {@link CheckpointResult.Success} oder
|
||||
* {@link CheckpointResult.NotApplicable} modelliert, nicht als Failure mit diesem Enum.
|
||||
*/
|
||||
public enum CheckpointSeverity {
|
||||
|
||||
/**
|
||||
* Riskante, aber technisch zulässige Einstellung.
|
||||
* Speichern und ein späterer headless-Lauf bleiben möglich.
|
||||
*/
|
||||
WARNING,
|
||||
|
||||
/**
|
||||
* Ungültige oder fehlende Einstellung – Konfiguration ist im aktuellen Zustand nicht lauffähig.
|
||||
*/
|
||||
ERROR
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Gesamtergebnis der Ausführung eines bestätigten {@link CorrectionPlan}.
|
||||
* <p>
|
||||
* Enthält für jeden im Plan enthaltenen Korrekturvorschlag ein {@link CorrectionOutcome}.
|
||||
* Die Reihenfolge der Ergebnisse entspricht der Reihenfolge der Vorschläge im Plan.
|
||||
* <p>
|
||||
* Dieser Record ist immutable und enthält keine JavaFX-Typen.
|
||||
*
|
||||
* @param outcomes Ergebnisliste in Ausführungsreihenfolge; nie {@code null}
|
||||
*/
|
||||
public record CorrectionExecutionReport(List<CorrectionOutcome> outcomes) {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Ausführungsbericht.
|
||||
*
|
||||
* @param outcomes Liste der Ausführungsergebnisse; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code outcomes} {@code null} ist
|
||||
*/
|
||||
public CorrectionExecutionReport {
|
||||
Objects.requireNonNull(outcomes, "outcomes must not be null");
|
||||
outcomes = List.copyOf(outcomes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob alle Korrekturen erfolgreich angewendet wurden.
|
||||
* <p>
|
||||
* Gibt {@code true} zurück, wenn alle Ergebnisse vom Typ {@link CorrectionOutcome.Applied}
|
||||
* sind und der Bericht mindestens einen Eintrag enthält.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein Eintrag vorhanden ist und alle angewendet wurden
|
||||
*/
|
||||
public boolean allApplied() {
|
||||
return !outcomes.isEmpty()
|
||||
&& outcomes.stream().allMatch(o -> o instanceof CorrectionOutcome.Applied);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens eine Korrektur gescheitert ist.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein {@link CorrectionOutcome.Failed} vorliegt
|
||||
*/
|
||||
public boolean hasFailures() {
|
||||
return outcomes.stream().anyMatch(o -> o instanceof CorrectionOutcome.Failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens eine Korrektur nicht versucht wurde.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein {@link CorrectionOutcome.NotAttempted} vorliegt
|
||||
*/
|
||||
public boolean hasNotAttempted() {
|
||||
return outcomes.stream().anyMatch(o -> o instanceof CorrectionOutcome.NotAttempted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Gesamtzahl der Ergebnisse zurück.
|
||||
*
|
||||
* @return Anzahl der Ergebnisse; nie negativ
|
||||
*/
|
||||
public int size() {
|
||||
return outcomes.size();
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Führt einen bestätigten {@link CorrectionPlan} aus, indem er jeden enthaltenen
|
||||
* {@link CorrectionSuggestion}-Vorschlag über den {@link ResourceCreationPort} ausführt.
|
||||
* <p>
|
||||
* Der Service iteriert alle Vorschläge im Plan und gibt pro Vorschlag ein
|
||||
* {@link CorrectionOutcome} an den {@link ResourceCreationPort} weiter. Das Gesamtergebnis
|
||||
* wird als {@link CorrectionExecutionReport} zurückgegeben.
|
||||
*
|
||||
* <h2>Kein Frühabbruch</h2>
|
||||
* <p>
|
||||
* Wenn eine Korrektur scheitert, laufen alle weiteren Korrekturen trotzdem weiter.
|
||||
* Ein einzelnes {@link CorrectionOutcome.Failed} führt nicht zum Abbruch.
|
||||
*
|
||||
* <h2>Aufrufkonvention</h2>
|
||||
* <p>
|
||||
* Dieser Service darf nur nach ausdrücklicher Benutzerbestätigung des
|
||||
* {@link CorrectionPlan} aufgerufen werden. Es darf keine stille Ausführung im
|
||||
* Hintergrund geben. Da die Ausführung I/O-intensiv sein kann, sollte der Aufruf
|
||||
* auf einem Hintergrund-Worker-Thread erfolgen.
|
||||
*
|
||||
* <h2>Thread-Safety</h2>
|
||||
* <p>
|
||||
* Diese Klasse ist zustandslos und thread-safe, sofern der injizierte
|
||||
* {@link ResourceCreationPort} ebenfalls thread-safe ist.
|
||||
*/
|
||||
public class CorrectionExecutionService {
|
||||
|
||||
private final ResourceCreationPort port;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Ausführungsservice.
|
||||
*
|
||||
* @param port der Port für schreibende Korrekturmaßnahmen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code port} {@code null} ist
|
||||
*/
|
||||
public CorrectionExecutionService(ResourceCreationPort port) {
|
||||
this.port = Objects.requireNonNull(port, "port must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt alle Korrekturvorschläge im übergebenen Plan aus.
|
||||
* <p>
|
||||
* Iteriert die {@link CorrectionSuggestion}s des Plans und dispatcht jeden Vorschlag
|
||||
* an die passende Methode des {@link ResourceCreationPort}. Alle Ergebnisse werden
|
||||
* gesammelt und als {@link CorrectionExecutionReport} zurückgegeben. Ein Fehler bei
|
||||
* einem Vorschlag führt nicht zum Abbruch der Ausführung der nachfolgenden Vorschläge.
|
||||
* <p>
|
||||
* Wenn der Plan leer ist, wird ein leerer Bericht zurückgegeben.
|
||||
*
|
||||
* @param plan der zu ausführende Korrekturplan; darf nicht {@code null} sein
|
||||
* @return Bericht mit einem {@link CorrectionOutcome} pro Vorschlag; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code plan} {@code null} ist
|
||||
*/
|
||||
public CorrectionExecutionReport execute(CorrectionPlan plan) {
|
||||
Objects.requireNonNull(plan, "plan must not be null");
|
||||
List<CorrectionOutcome> outcomes = new ArrayList<>(plan.size());
|
||||
|
||||
for (CorrectionSuggestion suggestion : plan.suggestions()) {
|
||||
CorrectionOutcome outcome = dispatch(suggestion);
|
||||
outcomes.add(outcome);
|
||||
}
|
||||
|
||||
return new CorrectionExecutionReport(outcomes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatcht einen einzelnen {@link CorrectionSuggestion} an die passende Methode
|
||||
* des {@link ResourceCreationPort}.
|
||||
*
|
||||
* @param suggestion der auszuführende Korrekturvorschlag; nie {@code null}
|
||||
* @return Ausführungsergebnis; nie {@code null}
|
||||
*/
|
||||
private CorrectionOutcome dispatch(CorrectionSuggestion suggestion) {
|
||||
return switch (suggestion) {
|
||||
case CorrectionSuggestion.CreateDirectory cd -> port.createDirectory(cd);
|
||||
case CorrectionSuggestion.CreatePromptFile cp -> port.createPromptFile(cp);
|
||||
case CorrectionSuggestion.PrepareSqlitePath ps -> port.prepareSqlitePath(ps);
|
||||
};
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Versiegeltes Ergebnis der Ausführung eines einzelnen {@link CorrectionSuggestion}-Vorschlags.
|
||||
* <p>
|
||||
* Nach Benutzerbestätigung eines {@link CorrectionPlan} wird jeder enthaltene Vorschlag
|
||||
* durch den {@link ResourceCreationPort} ausgeführt. Das Ergebnis jeder Ausführung wird
|
||||
* als eines der drei möglichen Zustände modelliert:
|
||||
* <ul>
|
||||
* <li>{@link Applied} – die Korrektur wurde erfolgreich durchgeführt.</li>
|
||||
* <li>{@link Failed} – die Korrektur wurde versucht, aber ist technisch gescheitert.</li>
|
||||
* <li>{@link NotAttempted} – die Korrektur wurde nicht versucht, obwohl sie im Plan enthalten war.
|
||||
* Typischer Grund: eine Voraussetzung war zur Laufzeit nicht erfüllt.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Alle Implementierungen sind immutable und enthalten keine JavaFX-Typen.
|
||||
*/
|
||||
public sealed interface CorrectionOutcome
|
||||
permits CorrectionOutcome.Applied,
|
||||
CorrectionOutcome.Failed,
|
||||
CorrectionOutcome.NotAttempted {
|
||||
|
||||
/**
|
||||
* Gibt den Korrekturvorschlag zurück, auf den sich dieses Ergebnis bezieht.
|
||||
*
|
||||
* @return Korrekturvorschlag; nie {@code null}
|
||||
*/
|
||||
CorrectionSuggestion suggestion();
|
||||
|
||||
/**
|
||||
* Die Korrektur wurde erfolgreich durchgeführt.
|
||||
*
|
||||
* @param suggestion der ausgeführte Korrekturvorschlag; nie {@code null}
|
||||
* @param message deutsche Bestätigungsmeldung; nie {@code null}
|
||||
*/
|
||||
record Applied(
|
||||
CorrectionSuggestion suggestion,
|
||||
String message) implements CorrectionOutcome {
|
||||
|
||||
/**
|
||||
* Erstellt ein Erfolgs-Ergebnis.
|
||||
*
|
||||
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||
* @param message Bestätigungsmeldung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
*/
|
||||
public Applied {
|
||||
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||
Objects.requireNonNull(message, "message must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Korrektur wurde versucht, aber ist technisch gescheitert.
|
||||
*
|
||||
* @param suggestion der nicht erfolgreich ausgeführte Korrekturvorschlag; nie {@code null}
|
||||
* @param errorMessage deutsche Fehlerbeschreibung; nie {@code null}
|
||||
*/
|
||||
record Failed(
|
||||
CorrectionSuggestion suggestion,
|
||||
String errorMessage) implements CorrectionOutcome {
|
||||
|
||||
/**
|
||||
* Erstellt ein Fehler-Ergebnis.
|
||||
*
|
||||
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||
* @param errorMessage Fehlerbeschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
*/
|
||||
public Failed {
|
||||
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Korrektur wurde nicht versucht, obwohl sie im Plan enthalten war.
|
||||
* <p>
|
||||
* Typischer Grund: Eine Voraussetzung war zur Ausführungszeit nicht erfüllt
|
||||
* (z. B. übergeordneter Ordner nicht erreichbar). Dies ist kein technischer Fehler
|
||||
* des Korrekturprozesses selbst, sondern ein Hinweis auf eine unerfüllbare Bedingung.
|
||||
*
|
||||
* @param suggestion der nicht versuchte Korrekturvorschlag; nie {@code null}
|
||||
* @param reason deutsche Begründung; nie {@code null}
|
||||
*/
|
||||
record NotAttempted(
|
||||
CorrectionSuggestion suggestion,
|
||||
String reason) implements CorrectionOutcome {
|
||||
|
||||
/**
|
||||
* Erstellt ein Nicht-Versucht-Ergebnis.
|
||||
*
|
||||
* @param suggestion Korrekturvorschlag; darf nicht {@code null} sein
|
||||
* @param reason Begründung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
*/
|
||||
public NotAttempted {
|
||||
Objects.requireNonNull(suggestion, "suggestion must not be null");
|
||||
Objects.requireNonNull(reason, "reason must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Gesammelter Korrekturplan, der alle schreibenden Korrekturmaßnahmen enthält,
|
||||
* die nach Benutzerbestätigung ausgeführt werden sollen.
|
||||
* <p>
|
||||
* Ein Korrekturplan wird aus den {@link CorrectionSuggestion}-Einträgen der
|
||||
* gescheiterten Prüfpunkte eines {@link TechnicalTestReport} abgeleitet. Er wird dem
|
||||
* Benutzer in einem gesammelten Bestätigungsdialog präsentiert, bevor eine schreibende
|
||||
* Maßnahme ausgeführt wird. Ohne ausdrückliche Bestätigung werden keine Korrekturen
|
||||
* vorgenommen.
|
||||
* <p>
|
||||
* Dieser Record ist immutable und enthält keine JavaFX-Typen.
|
||||
*
|
||||
* @param suggestions alle Korrekturvorschläge in Ausführungsreihenfolge; nie {@code null}
|
||||
*/
|
||||
public record CorrectionPlan(List<CorrectionSuggestion> suggestions) {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Korrekturplan.
|
||||
*
|
||||
* @param suggestions Liste der Korrekturvorschläge; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn {@code suggestions} {@code null} ist
|
||||
*/
|
||||
public CorrectionPlan {
|
||||
Objects.requireNonNull(suggestions, "suggestions must not be null");
|
||||
suggestions = List.copyOf(suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen leeren Korrekturplan ohne Maßnahmen.
|
||||
* <p>
|
||||
* Ein leerer Plan zeigt an, dass nach einem Gesamttest keine sicheren technischen
|
||||
* Korrekturen angeboten werden können.
|
||||
*
|
||||
* @return ein leerer Korrekturplan; nie {@code null}
|
||||
*/
|
||||
public static CorrectionPlan empty() {
|
||||
return new CorrectionPlan(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob dieser Plan mindestens einen Korrekturvorschlag enthält.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein Vorschlag vorhanden ist
|
||||
*/
|
||||
public boolean hasCorrections() {
|
||||
return !suggestions.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Anzahl der enthaltenen Korrekturvorschläge zurück.
|
||||
*
|
||||
* @return Anzahl der Vorschläge; nie negativ
|
||||
*/
|
||||
public int size() {
|
||||
return suggestions.size();
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Versiegelter Korrekturvorschlag für eine schreibende technische Korrekturmaßnahme.
|
||||
* <p>
|
||||
* Korrekturvorschläge beschreiben <em>was</em> korrigiert werden soll, aber noch nicht
|
||||
* <em>wie</em>. Die konkrete Ausführung übernimmt der {@link ResourceCreationPort}. Ein
|
||||
* Vorschlag wird dem Benutzer vor der Ausführung in einem gesammelten Bestätigungsdialog
|
||||
* angezeigt; ohne Bestätigung wird keine schreibende Änderung vorgenommen.
|
||||
* <p>
|
||||
* Nicht automatisch korrigierbare Probleme (falscher API-Key, unerreichbare Base-URL,
|
||||
* nicht verfügbare Modellliste) werden niemals als {@code CorrectionSuggestion} modelliert.
|
||||
* <p>
|
||||
* Alle Pfade werden als {@code String} übergeben, analog zur Konvention der übrigen
|
||||
* Outbound-Ports dieses Projekts. Der Adapter-Out ist für die Konvertierung in
|
||||
* {@code java.nio.file.Path} zuständig.
|
||||
*/
|
||||
public sealed interface CorrectionSuggestion
|
||||
permits CorrectionSuggestion.CreateDirectory,
|
||||
CorrectionSuggestion.CreatePromptFile,
|
||||
CorrectionSuggestion.PrepareSqlitePath {
|
||||
|
||||
/**
|
||||
* Gibt eine kurze deutsche Beschreibung der vorgeschlagenen Korrektur zurück,
|
||||
* die dem Benutzer im Bestätigungsdialog angezeigt wird.
|
||||
*
|
||||
* @return deutsche Beschreibung; nie {@code null}
|
||||
*/
|
||||
String descriptionForUser();
|
||||
|
||||
/**
|
||||
* Ein fehlender Ordner soll angelegt werden.
|
||||
* <p>
|
||||
* Anwendungsfälle: fehlender Zielordner.
|
||||
* Es werden nur Ordner angelegt, die noch nicht existieren und deren Elternpfad
|
||||
* erreichbar ist.
|
||||
*
|
||||
* @param path Pfad des anzulegenden Ordners als String; nie {@code null}
|
||||
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||
*/
|
||||
record CreateDirectory(
|
||||
String path,
|
||||
String descriptionForUser) implements CorrectionSuggestion {
|
||||
|
||||
/**
|
||||
* Erstellt einen Vorschlag zum Anlegen eines Ordners.
|
||||
*
|
||||
* @param path Pfad des Ordners; darf nicht {@code null} oder leer sein
|
||||
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||
*/
|
||||
public CreateDirectory {
|
||||
Objects.requireNonNull(path, "path must not be null");
|
||||
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
|
||||
if (path.isBlank()) {
|
||||
throw new IllegalArgumentException("path must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine fehlende Prompt-Datei soll mit einem deutschen Standardinhalt erzeugt werden.
|
||||
* <p>
|
||||
* Die Erzeugung erfolgt nur, wenn der Zielpfad beschreibbar ist. Der konkrete
|
||||
* Standardinhalt wird vom {@link ResourceCreationPort} bereitgestellt. Der
|
||||
* Standardpfad liegt im selben Ordner wie die {@code .properties}-Datei.
|
||||
*
|
||||
* @param path Pfad der anzulegenden Prompt-Datei als String; nie {@code null}
|
||||
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||
*/
|
||||
record CreatePromptFile(
|
||||
String path,
|
||||
String descriptionForUser) implements CorrectionSuggestion {
|
||||
|
||||
/**
|
||||
* Erstellt einen Vorschlag zum Erzeugen einer deutschen Standard-Prompt-Datei.
|
||||
*
|
||||
* @param path Pfad der Prompt-Datei; darf nicht {@code null} oder leer sein
|
||||
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||
*/
|
||||
public CreatePromptFile {
|
||||
Objects.requireNonNull(path, "path must not be null");
|
||||
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
|
||||
if (path.isBlank()) {
|
||||
throw new IllegalArgumentException("path must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fehlender oder noch nicht vorbereiteter SQLite-Pfad soll nutzbar gemacht werden.
|
||||
* <p>
|
||||
* Konkret bedeutet das: Falls die SQLite-Datei noch nicht existiert, aber ihr
|
||||
* übergeordneter Ordner vorhanden oder anlegbar ist, wird der Ordner sichergestellt.
|
||||
* Eine leere SQLite-Datei wird nicht manuell erzeugt; das übernimmt das JDBC-Layer
|
||||
* beim ersten Datenbankzugriff.
|
||||
*
|
||||
* @param path Pfad der SQLite-Datei als String; nie {@code null}
|
||||
* @param descriptionForUser deutsche Beschreibung für den Bestätigungsdialog; nie {@code null}
|
||||
*/
|
||||
record PrepareSqlitePath(
|
||||
String path,
|
||||
String descriptionForUser) implements CorrectionSuggestion {
|
||||
|
||||
/**
|
||||
* Erstellt einen Vorschlag zur Vorbereitung des SQLite-Pfads.
|
||||
*
|
||||
* @param path Pfad der SQLite-Datei; darf nicht {@code null} oder leer sein
|
||||
* @param descriptionForUser deutsche Beschreibung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
* @throws IllegalArgumentException wenn {@code path} leer ist
|
||||
*/
|
||||
public PrepareSqlitePath {
|
||||
Objects.requireNonNull(path, "path must not be null");
|
||||
Objects.requireNonNull(descriptionForUser, "descriptionForUser must not be null");
|
||||
if (path.isBlank()) {
|
||||
throw new IllegalArgumentException("path must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
/**
|
||||
* Liefert den deutschen Standardinhalt für neu erzeugte Prompt-Dateien.
|
||||
* <p>
|
||||
* Diese Klasse stellt einen brauchbaren Ausgangspunkt für die Prompt-Datei bereit,
|
||||
* der ohne weitere Anpassung funktioniert. Der Inhalt enthält die Anweisung an die KI,
|
||||
* aus einem bereits extrahierten Dokumenttext einen normierten deutschen Dateinamensvorschlag
|
||||
* zu erzeugen.
|
||||
* <p>
|
||||
* <strong>Abgrenzung:</strong> Diese Klasse enthält ausschließlich den Prompt-Text als
|
||||
* reine Zeichenkette. Kein Dateisystem-I/O, kein Template-Engine, keine Platzhalter
|
||||
* für den Dokumentinhalt (der Dokumenttext wird vom Aufrufer separat angefügt).
|
||||
* <p>
|
||||
* Der gelieferte Inhalt ist ein sinnvoller, funktionsfähiger Standard und nicht für
|
||||
* fachliche Weiterentwicklung oder Versionierung vorgesehen.
|
||||
*/
|
||||
public final class DefaultPromptTemplate {
|
||||
|
||||
private DefaultPromptTemplate() {
|
||||
// Utility-Klasse – keine Instanziierung
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den deutschen Standardinhalt für eine neu erzeugte Prompt-Datei zurück.
|
||||
* <p>
|
||||
* Der zurückgegebene Text enthält:
|
||||
* <ul>
|
||||
* <li>Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)</li>
|
||||
* <li>Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}</li>
|
||||
* <li>Benennungsregeln für Titel (maximal 20 Zeichen, deutsch, keine Sonderzeichen)</li>
|
||||
* <li>Hinweis auf das Datumsformat ({@code YYYY-MM-DD})</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Der Text enthält keinen Platzhalter für den Dokumentinhalt. Der Dokumenttext
|
||||
* wird vom {@link de.gecheckt.pdf.umbenenner.application.service.AiRequestComposer}
|
||||
* separat angehängt.
|
||||
*
|
||||
* @return der deutsche Standard-Prompt-Inhalt; nie {@code null}, nie leer
|
||||
*/
|
||||
public static String defaultContent() {
|
||||
return """
|
||||
Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
|
||||
Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
|
||||
einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
|
||||
|
||||
Antworte ausschließlich mit einem validen JSON-Objekt im folgenden Schema:
|
||||
{
|
||||
"date": "YYYY-MM-DD",
|
||||
"title": "Kurztitel auf Deutsch",
|
||||
"reasoning": "Kurze Begründung auf Deutsch"
|
||||
}
|
||||
|
||||
Regeln:
|
||||
- Das Feld "title" ist verpflichtend.
|
||||
- Das Feld "reasoning" ist verpflichtend.
|
||||
- Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
|
||||
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
|
||||
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
|
||||
- Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
|
||||
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
|
||||
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||
- Eigennamen bleiben unverändert.
|
||||
- Umlaute und ß sind erlaubt.
|
||||
- Kein Text außerhalb des JSON-Objekts.
|
||||
""";
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
/**
|
||||
* Outbound-Port für technische Pfad- und Dateisystemprüfungen.
|
||||
* <p>
|
||||
* Dieser Port ist <strong>ausschließlich lesend</strong>. Er prüft den Zustand von Pfaden,
|
||||
* ohne Dateien, Ordner oder andere Ressourcen anzulegen, zu verändern oder zu löschen.
|
||||
* Schreibende Korrekturen sind über {@link ResourceCreationPort} zu initiieren.
|
||||
* <p>
|
||||
* <strong>Pfad-Konvention:</strong> Alle Pfade werden als {@code String} übergeben, analog
|
||||
* zur Konvention der übrigen Outbound-Ports dieses Projekts (z. B. {@code TargetFolderPort}).
|
||||
* Der Adapter-Out ist für die Konvertierung in plattformspezifische Pfadobjekte zuständig.
|
||||
* <p>
|
||||
* <strong>Windows- und Netzlaufwerke:</strong> Implementierungen müssen gemappte
|
||||
* Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} im Windows-Kontext ausdrücklich
|
||||
* akzeptieren. Solche Pfade dürfen nicht allein deshalb abgelehnt werden, weil dahinter
|
||||
* technisch ein UNC-Pfad stehen könnte.
|
||||
* <p>
|
||||
* <strong>Fehlerbehandlung:</strong> Implementierungen werfen keine geprüften oder
|
||||
* ungeprüften Ausnahmen für erwartete Fehlerbedingungen (Pfad nicht vorhanden,
|
||||
* keine Leseberechtigung). Alle solchen Zustände werden als {@code boolean}-Ergebnis
|
||||
* oder über separate Methoden kommuniziert.
|
||||
*/
|
||||
public interface PathCheckPort {
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad auf einen vorhandenen, lesbaren Ordner zeigt.
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn der Ordner existiert und gelesen werden kann
|
||||
*/
|
||||
boolean isDirectoryReadable(String path);
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad auf einen vorhandenen, schreibbaren Ordner zeigt
|
||||
* oder ob dieser Ordner technisch anlegbar wäre.
|
||||
* <p>
|
||||
* Gibt {@code true} zurück, wenn:
|
||||
* <ul>
|
||||
* <li>der Ordner existiert und schreibbar ist, oder</li>
|
||||
* <li>der Ordner noch nicht existiert, aber sein Elternpfad erreichbar und
|
||||
* schreibbar ist (anlegbar).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn der Ordner vorhanden und schreibbar oder anlegbar ist
|
||||
*/
|
||||
boolean isDirectoryWritableOrCreatable(String path);
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad auf eine vorhandene, lesbare Datei zeigt.
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn die Datei existiert und gelesen werden kann
|
||||
*/
|
||||
boolean isFileReadable(String path);
|
||||
|
||||
/**
|
||||
* Prüft, ob der angegebene Pfad als SQLite-Datenbankpfad technisch nutzbar ist.
|
||||
* <p>
|
||||
* Gibt {@code true} zurück, wenn:
|
||||
* <ul>
|
||||
* <li>die Datei existiert und les- und schreibbar ist, oder</li>
|
||||
* <li>die Datei noch nicht existiert, aber ihr übergeordneter Ordner vorhanden
|
||||
* und schreibbar ist (Datei wäre anlegbar).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param path zu prüfender Pfad als String; darf nicht {@code null} oder leer sein
|
||||
* @return {@code true} wenn der SQLite-Pfad nutzbar oder anlegbar ist
|
||||
*/
|
||||
boolean isSqlitePathUsable(String path);
|
||||
}
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||
|
||||
/**
|
||||
* Application-Service für die provider-nahen technischen Prüfpunkte des Gesamttests.
|
||||
* <p>
|
||||
* Dieser Service führt genau fünf providerbezogene Prüfpunkte aus:
|
||||
* <ul>
|
||||
* <li>{@link CheckpointId#BASE_URL_REACHABLE} – Endpoint technisch erreichbar</li>
|
||||
* <li>{@link CheckpointId#API_KEY_PRESENT} – API-Schlüssel in mindestens einer Quelle vorhanden</li>
|
||||
* <li>{@link CheckpointId#API_KEY_ACCEPTED} – Authentifizierung am Endpoint erfolgreich</li>
|
||||
* <li>{@link CheckpointId#MODEL_LIST_AVAILABLE} – Provider liefert eine Modellliste</li>
|
||||
* <li>{@link CheckpointId#SELECTED_MODEL_PLAUSIBLE} – konfiguriertes Modell in der Liste enthalten</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Port-Wiederverwendung:</strong> Der Service ruft den {@link AiModelCatalogPort}
|
||||
* exakt einmal auf und leitet aus dem Ergebnis alle fünf Prüfpunkte ab. Es findet keine
|
||||
* zweite HTTP-Implementierung statt.
|
||||
* <p>
|
||||
* <strong>API-Key-Vorrangregel:</strong> Der {@link ApiKeyResolutionPort} wird konsultiert,
|
||||
* damit auch reine Umgebungsvariablen-Setups korrekt als „API-Key vorhanden" bewertet werden.
|
||||
* Nur wenn der Deskriptor {@link ApiKeyOrigin#ABSENT} zurückliefert, gilt der Schlüssel als
|
||||
* fehlend. In diesem Fall werden alle Remote-Prüfpunkte als {@link CheckpointResult.NotApplicable}
|
||||
* markiert, ohne einen HTTP-Aufruf durchzuführen.
|
||||
* <p>
|
||||
* <strong>Mapping-Regeln für {@link ModelCatalogResult}-Varianten:</strong>
|
||||
* <ul>
|
||||
* <li>{@link ModelCatalogResult.Success}: alle fünf Prüfpunkte auswertbar; Modellplausibilität
|
||||
* anhand der zurückgegebenen Liste geprüft.</li>
|
||||
* <li>{@link ModelCatalogResult.EmptyList}: Endpoint und Key akzeptiert, aber keine Modellliste;
|
||||
* Modellplausibilität nicht prüfbar.</li>
|
||||
* <li>{@link ModelCatalogResult.IncompleteConfiguration}: Konfiguration unvollständig; kein
|
||||
* HTTP-Aufruf vom Adapter durchgeführt.</li>
|
||||
* <li>{@link ModelCatalogResult.TechnicalFailure}: abhängig vom Fehlerkategorie-String;
|
||||
* Authentifizierungsfehler, Verbindungsfehler, Serverfehler und ungültige Antworten
|
||||
* werden unterschiedlich auf die Prüfpunkte abgebildet.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #runProviderChecks(EditorValidationInput)}
|
||||
* ist <em>synchron blockierend</em>. Sie darf nicht auf dem JavaFX Application Thread aufgerufen
|
||||
* werden. Der Aufrufer (GUI-Orchestrierung) ist verantwortlich, den Aufruf auf einem
|
||||
* Hintergrund-Worker-Thread auszuführen und die Ergebnisse via {@code Platform.runLater}
|
||||
* in die UI zu überführen. Dieser Service enthält kein {@code Platform.runLater} und
|
||||
* startet keine eigenen Threads.
|
||||
* <p>
|
||||
* <strong>Fehlerklasse-Konstanten (TechnicalFailure.errorCategory):</strong>
|
||||
* Die Adapter-Out-Implementierungen verwenden stabile Kategorie-Strings. Dieser Service
|
||||
* erkennt folgende Präfixe bzw. Werte (Groß-/Kleinschreibung ignoriert):
|
||||
* {@code AUTHENTICATION_FAILED}, {@code CONNECTION_FAILURE}, {@code ENDPOINT_NOT_FOUND},
|
||||
* {@code SERVER_ERROR}, {@code INVALID_RESPONSE}. Unbekannte Kategorien werden als
|
||||
* allgemeiner technischer Fehler behandelt.
|
||||
*/
|
||||
public class ProviderTechnicalTestService {
|
||||
|
||||
/** Fehlerkategorie-Konstante für Authentifizierungsfehler (case-insensitive Präfix-Erkennung). */
|
||||
static final String CATEGORY_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED";
|
||||
/** Fehlerkategorie-Konstante für Verbindungsfehler. */
|
||||
static final String CATEGORY_CONNECTION_FAILURE = "CONNECTION_FAILURE";
|
||||
/** Fehlerkategorie-Konstante für nicht gefundenen Endpoint. */
|
||||
static final String CATEGORY_ENDPOINT_NOT_FOUND = "ENDPOINT_NOT_FOUND";
|
||||
/** Fehlerkategorie-Konstante für Serverfehler (5xx). */
|
||||
static final String CATEGORY_SERVER_ERROR = "SERVER_ERROR";
|
||||
/** Fehlerkategorie-Konstante für nicht parsierbare Antworten. */
|
||||
static final String CATEGORY_INVALID_RESPONSE = "INVALID_RESPONSE";
|
||||
|
||||
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
|
||||
private final AiModelCatalogPort modelCatalogPort;
|
||||
private final ApiKeyResolutionPort apiKeyResolutionPort;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Service mit den erforderlichen Ports.
|
||||
*
|
||||
* @param modelCatalogPort Port für den Modellabruf; darf nicht {@code null} sein
|
||||
* @param apiKeyResolutionPort Port für die API-Key-Herkunftsauflösung; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||
*/
|
||||
public ProviderTechnicalTestService(AiModelCatalogPort modelCatalogPort,
|
||||
ApiKeyResolutionPort apiKeyResolutionPort) {
|
||||
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort, "modelCatalogPort must not be null");
|
||||
this.apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
|
||||
"apiKeyResolutionPort must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt alle fünf provider-nahen technischen Prüfpunkte für den aktiven Provider aus.
|
||||
* <p>
|
||||
* Der aktive Provider wird aus {@code input.activeProviderIdentifier()} bestimmt.
|
||||
* Wenn der Bezeichner keiner bekannten Provider-Familie entspricht, werden alle fünf
|
||||
* Prüfpunkte als {@link CheckpointResult.Failure} mit Schweregrad ERROR zurückgegeben.
|
||||
* <p>
|
||||
* Diese Methode blockiert, bis das Ergebnis des Modellabrufs vorliegt oder ein
|
||||
* konfigurierter Timeout abläuft. Sie darf nicht auf dem JavaFX Application Thread
|
||||
* aufgerufen werden.
|
||||
*
|
||||
* @param input aktueller Editorzustand; darf nicht {@code null} sein
|
||||
* @return unveränderliche Liste mit genau fünf {@link CheckpointResult}-Einträgen
|
||||
* (in der Reihenfolge: API_KEY_PRESENT, BASE_URL_REACHABLE, API_KEY_ACCEPTED,
|
||||
* MODEL_LIST_AVAILABLE, SELECTED_MODEL_PLAUSIBLE); nie {@code null}
|
||||
* @throws NullPointerException wenn {@code input} {@code null} ist
|
||||
*/
|
||||
public List<CheckpointResult> runProviderChecks(EditorValidationInput input) {
|
||||
Objects.requireNonNull(input, "input must not be null");
|
||||
|
||||
Optional<AiProviderFamily> familyOpt = AiProviderFamily.fromIdentifier(
|
||||
input.activeProviderIdentifier());
|
||||
|
||||
if (familyOpt.isEmpty()) {
|
||||
String msg = "Aktiver Provider-Bezeichner unbekannt: \""
|
||||
+ input.activeProviderIdentifier() + "\". Provider-Prüfungen können nicht ausgeführt werden.";
|
||||
return List.of(
|
||||
CheckpointResult.Failure.of(CheckpointId.API_KEY_PRESENT, CheckpointSeverity.ERROR, msg),
|
||||
CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, CheckpointSeverity.ERROR, msg),
|
||||
CheckpointResult.Failure.of(CheckpointId.API_KEY_ACCEPTED, CheckpointSeverity.ERROR, msg),
|
||||
CheckpointResult.Failure.of(CheckpointId.MODEL_LIST_AVAILABLE, CheckpointSeverity.ERROR, msg),
|
||||
CheckpointResult.Failure.of(CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.ERROR, msg)
|
||||
);
|
||||
}
|
||||
|
||||
AiProviderFamily family = familyOpt.get();
|
||||
// Den bereits im EditorValidationInput enthaltenen Descriptor verwenden.
|
||||
// EditorValidationInput enthält keinen rohen API-Key-String, sondern nur den
|
||||
// vom GUI-Adapter bereits aufgelösten Descriptor. Dieser spiegelt die
|
||||
// API-Key-Vorrangregel (ENV → Legacy-ENV → Property) wider.
|
||||
EffectiveApiKeyDescriptor apiKeyDescriptor = resolveApiKeyDescriptor(input, family);
|
||||
|
||||
// Prüfpunkt API_KEY_PRESENT: ohne HTTP-Aufruf
|
||||
CheckpointResult apiKeyPresentResult = checkApiKeyPresent(apiKeyDescriptor);
|
||||
|
||||
if (apiKeyDescriptor.isAbsent()) {
|
||||
// Kein API-Key → alle Remote-Prüfpunkte als NotApplicable markieren
|
||||
String reason = "Kein API-Schlüssel vorhanden. Remote-Prüfungen können nicht ausgeführt werden.";
|
||||
return List.of(
|
||||
apiKeyPresentResult,
|
||||
new CheckpointResult.NotApplicable(CheckpointId.BASE_URL_REACHABLE, reason),
|
||||
new CheckpointResult.NotApplicable(CheckpointId.API_KEY_ACCEPTED, reason),
|
||||
new CheckpointResult.NotApplicable(CheckpointId.MODEL_LIST_AVAILABLE, reason),
|
||||
new CheckpointResult.NotApplicable(CheckpointId.SELECTED_MODEL_PLAUSIBLE, reason)
|
||||
);
|
||||
}
|
||||
|
||||
// API-Key vorhanden → Modellabruf durchführen
|
||||
String configuredModel = resolveModelValue(input, family);
|
||||
ModelCatalogRequest catalogRequest = buildCatalogRequest(input, family, apiKeyDescriptor);
|
||||
|
||||
ModelCatalogResult catalogResult = modelCatalogPort.fetchAvailableModels(catalogRequest);
|
||||
|
||||
List<CheckpointResult> results = new ArrayList<>();
|
||||
results.add(apiKeyPresentResult);
|
||||
results.addAll(mapCatalogResultToCheckpoints(catalogResult, configuredModel));
|
||||
return List.copyOf(results);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/**
|
||||
* Erzeugt das {@link CheckpointResult} für {@link CheckpointId#API_KEY_PRESENT}.
|
||||
*
|
||||
* @param descriptor Herkunftsdeskriptor des API-Schlüssels
|
||||
* @return Success wenn ein Schlüssel vorhanden ist, Failure ERROR sonst
|
||||
*/
|
||||
private CheckpointResult checkApiKeyPresent(EffectiveApiKeyDescriptor descriptor) {
|
||||
if (descriptor.isAbsent()) {
|
||||
return CheckpointResult.Failure.of(
|
||||
CheckpointId.API_KEY_PRESENT,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Kein API-Schlüssel vorhanden. Weder Umgebungsvariable noch Properties-Datei liefert einen Wert.");
|
||||
}
|
||||
String sourceInfo = descriptor.isFromEnvironmentVariable()
|
||||
? "Umgebungsvariable " + descriptor.envVarName().orElse("(unbekannt)")
|
||||
: "Properties-Datei";
|
||||
return new CheckpointResult.Success(
|
||||
CheckpointId.API_KEY_PRESENT,
|
||||
"API-Schlüssel vorhanden (Quelle: " + sourceInfo + ").");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet ein {@link ModelCatalogResult} auf die vier Remote-Prüfpunkte ab.
|
||||
*
|
||||
* @param result Ergebnis des Modellabrufs
|
||||
* @param configuredModel konfigurierter Modellname aus dem Editor
|
||||
* @return Liste mit genau vier Prüfpunkt-Ergebnissen
|
||||
*/
|
||||
private List<CheckpointResult> mapCatalogResultToCheckpoints(ModelCatalogResult result,
|
||||
String configuredModel) {
|
||||
return switch (result) {
|
||||
case ModelCatalogResult.Success success -> mapSuccess(success, configuredModel);
|
||||
case ModelCatalogResult.EmptyList emptyList -> mapEmptyList();
|
||||
case ModelCatalogResult.IncompleteConfiguration incomplete -> mapIncompleteConfiguration(incomplete);
|
||||
case ModelCatalogResult.TechnicalFailure failure -> mapTechnicalFailure(failure);
|
||||
};
|
||||
}
|
||||
|
||||
private List<CheckpointResult> mapSuccess(ModelCatalogResult.Success success, String configuredModel) {
|
||||
CheckpointResult baseUrl = new CheckpointResult.Success(
|
||||
CheckpointId.BASE_URL_REACHABLE, "Endpoint erreichbar.");
|
||||
CheckpointResult apiKeyAccepted = new CheckpointResult.Success(
|
||||
CheckpointId.API_KEY_ACCEPTED, "API-Schlüssel akzeptiert.");
|
||||
CheckpointResult modelList = new CheckpointResult.Success(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
"Modellliste verfügbar (" + success.models().size() + " Modell(e)).");
|
||||
CheckpointResult modelPlausible = checkModelPlausible(success.models(), configuredModel);
|
||||
return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible);
|
||||
}
|
||||
|
||||
private List<CheckpointResult> mapEmptyList() {
|
||||
CheckpointResult baseUrl = new CheckpointResult.Success(
|
||||
CheckpointId.BASE_URL_REACHABLE, "Endpoint erreichbar.");
|
||||
CheckpointResult apiKeyAccepted = new CheckpointResult.Success(
|
||||
CheckpointId.API_KEY_ACCEPTED, "API-Schlüssel akzeptiert.");
|
||||
CheckpointResult modelList = CheckpointResult.Failure.of(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointSeverity.WARNING,
|
||||
"Provider liefert keine Modellliste.");
|
||||
CheckpointResult modelPlausible = new CheckpointResult.NotApplicable(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Keine Modellliste vorhanden, Modellplausibilität nicht prüfbar.");
|
||||
return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible);
|
||||
}
|
||||
|
||||
private List<CheckpointResult> mapIncompleteConfiguration(ModelCatalogResult.IncompleteConfiguration incomplete) {
|
||||
String reason = incomplete.missingReason();
|
||||
CheckpointResult baseUrl = new CheckpointResult.NotApplicable(
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
"Konfiguration unvollständig – kein Verbindungsversuch: " + reason);
|
||||
CheckpointResult apiKeyAccepted = new CheckpointResult.NotApplicable(
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
"Konfiguration unvollständig – Authentifizierung nicht prüfbar.");
|
||||
CheckpointResult modelList = CheckpointResult.Failure.of(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Provider-Konfiguration unvollständig: " + reason);
|
||||
CheckpointResult modelPlausible = new CheckpointResult.NotApplicable(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Konfiguration unvollständig – Modellplausibilität nicht prüfbar.");
|
||||
return List.of(baseUrl, apiKeyAccepted, modelList, modelPlausible);
|
||||
}
|
||||
|
||||
private List<CheckpointResult> mapTechnicalFailure(ModelCatalogResult.TechnicalFailure failure) {
|
||||
String category = failure.errorCategory().toUpperCase();
|
||||
String detail = failure.errorDetail();
|
||||
|
||||
if (category.contains(CATEGORY_AUTHENTICATION_FAILED)) {
|
||||
return List.of(
|
||||
new CheckpointResult.Success(
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
"Endpoint hat geantwortet (Authentifizierungsfehler erhalten)."),
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
CheckpointSeverity.ERROR,
|
||||
"API-Schlüssel technisch nicht akzeptiert: " + detail),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
"Authentifizierung fehlgeschlagen – Modellliste nicht abrufbar."),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Authentifizierung fehlgeschlagen – Modellplausibilität nicht prüfbar.")
|
||||
);
|
||||
}
|
||||
|
||||
if (category.contains(CATEGORY_CONNECTION_FAILURE) || category.contains(CATEGORY_ENDPOINT_NOT_FOUND)) {
|
||||
String baseUrlMessage = category.contains(CATEGORY_ENDPOINT_NOT_FOUND)
|
||||
? "Endpoint nicht gefunden: " + detail
|
||||
: "Verbindung zum Endpoint fehlgeschlagen: " + detail;
|
||||
return List.of(
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
baseUrlMessage),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
"Endpoint nicht erreichbar – Authentifizierung nicht prüfbar."),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
"Endpoint nicht erreichbar – Modellliste nicht abrufbar."),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Endpoint nicht erreichbar – Modellplausibilität nicht prüfbar.")
|
||||
);
|
||||
}
|
||||
|
||||
if (category.contains(CATEGORY_SERVER_ERROR)) {
|
||||
return List.of(
|
||||
new CheckpointResult.Success(
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
"Endpoint hat geantwortet (Serverfehler erhalten)."),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
"Serverfehler – Authentifizierung nicht eindeutig prüfbar."),
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointSeverity.WARNING,
|
||||
"Provider antwortet mit Serverfehler: " + detail),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Serverfehler – Modellplausibilität nicht prüfbar.")
|
||||
);
|
||||
}
|
||||
|
||||
if (category.contains(CATEGORY_INVALID_RESPONSE)) {
|
||||
return List.of(
|
||||
new CheckpointResult.Success(
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
"Endpoint hat geantwortet (Antwort nicht verarbeitbar)."),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
"Antwort nicht parsierbar – Authentifizierung nicht eindeutig prüfbar."),
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Antwort des Providers nicht verarbeitbar: " + detail),
|
||||
new CheckpointResult.NotApplicable(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Antwort nicht parsierbar – Modellplausibilität nicht prüfbar.")
|
||||
);
|
||||
}
|
||||
|
||||
// Unbekannte Fehlerkategorie
|
||||
String unknownMsg = "Unbekannter technischer Fehler beim Modellabruf: " + detail;
|
||||
return List.of(
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
unknownMsg),
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
CheckpointSeverity.ERROR,
|
||||
unknownMsg),
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
unknownMsg),
|
||||
CheckpointResult.Failure.of(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
unknownMsg)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob das konfigurierte Modell in der Modellliste enthalten ist.
|
||||
*
|
||||
* @param models verfügbare Modelle vom Provider
|
||||
* @param configuredModel konfigurierter Modellname aus dem Editor
|
||||
* @return Success wenn das Modell enthalten ist, Failure WARNING sonst
|
||||
*/
|
||||
private CheckpointResult checkModelPlausible(List<String> models, String configuredModel) {
|
||||
if (configuredModel.isBlank()) {
|
||||
return CheckpointResult.Failure.of(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Kein Modell konfiguriert.");
|
||||
}
|
||||
if (models.contains(configuredModel)) {
|
||||
return new CheckpointResult.Success(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
"Konfiguriertes Modell \"" + configuredModel + "\" in verfügbarer Liste gefunden.");
|
||||
}
|
||||
return CheckpointResult.Failure.of(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
CheckpointSeverity.WARNING,
|
||||
"Konfiguriertes Modell \"" + configuredModel
|
||||
+ "\" nicht in verfügbarer Liste gefunden. Bitte Modellname prüfen.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den {@link ModelCatalogRequest} aus dem aktuellen Editorzustand auf.
|
||||
* <p>
|
||||
* Da {@link EditorValidationInput} keinen direkten API-Key-String enthält, sondern
|
||||
* nur einen bereits aufgelösten {@link EffectiveApiKeyDescriptor}, wird der Descriptor
|
||||
* aus dem Editorzustand direkt verwendet. Der Adapter-Out-Seitige Dispatcher erwartet
|
||||
* den Key entweder als ENV-Variable (die er selbst liest) oder als optionalen Wert
|
||||
* im Request. Da die Auflösung beim Service bereits über {@link ApiKeyResolutionPort}
|
||||
* erfolgt ist, wird für den Catalog-Request ein leerer Optional-Wert geliefert –
|
||||
* der Adapter verwendet dann intern seine eigene ENV-Variable-Auflösung.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @param family aktive Provider-Familie
|
||||
* @param apiKeyDesc bereits aufgelöster Herkunftsdeskriptor des API-Schlüssels
|
||||
* @return fertiger Request; nie {@code null}
|
||||
*/
|
||||
private ModelCatalogRequest buildCatalogRequest(EditorValidationInput input,
|
||||
AiProviderFamily family,
|
||||
EffectiveApiKeyDescriptor apiKeyDesc) {
|
||||
// EditorValidationInput enthält keinen direkten API-Key-String-Wert, nur den Descriptor.
|
||||
// Für den ModelCatalogRequest übergeben wir einen leeren Optional für den apiKey,
|
||||
// sodass der Adapter seine eigene ENV-Variable-Auflösung durchführt.
|
||||
// Der Adapter liefert dann IncompleteConfiguration, wenn auch er keinen Key findet –
|
||||
// was aber nicht passiert, da wir oben bereits geprüft haben, dass apiKeyDesc nicht ABSENT ist.
|
||||
Optional<String> apiKeyForRequest = Optional.empty();
|
||||
|
||||
String rawBaseUrl = resolveBaseUrlValue(input, family);
|
||||
Optional<String> baseUrl = rawBaseUrl.isBlank() ? Optional.empty() : Optional.of(rawBaseUrl);
|
||||
|
||||
int timeout = parseTimeoutOrDefault(resolveTimeoutValue(input, family));
|
||||
|
||||
return new ModelCatalogRequest(
|
||||
family.getIdentifier(),
|
||||
baseUrl,
|
||||
apiKeyForRequest,
|
||||
timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den bereits aufgelösten {@link EffectiveApiKeyDescriptor} für die aktive Provider-Familie
|
||||
* direkt aus dem {@link EditorValidationInput}.
|
||||
* <p>
|
||||
* {@link EditorValidationInput} enthält keinen rohen API-Key-String, sondern nur den vom
|
||||
* GUI-Adapter bereits aufgelösten Descriptor. Der Descriptor spiegelt die Vorrangregel
|
||||
* (ENV → Legacy-ENV → Property) zum Zeitpunkt des letzten Editor-Refreshs wider.
|
||||
* <p>
|
||||
* Für Tests kann der Descriptor im Eingabeobjekt direkt gesetzt werden.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @param family aktive Provider-Familie
|
||||
* @return der Herkunftsdeskriptor; nie {@code null}
|
||||
*/
|
||||
private EffectiveApiKeyDescriptor resolveApiKeyDescriptor(EditorValidationInput input,
|
||||
AiProviderFamily family) {
|
||||
return switch (family) {
|
||||
case CLAUDE -> input.claudeApiKeyDescriptor();
|
||||
case OPENAI_COMPATIBLE -> input.openaiApiKeyDescriptor();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Base-URL-Wert für die angegebene Provider-Familie aus dem Editorzustand.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @param family aktive Provider-Familie
|
||||
* @return Base-URL-String; nie {@code null}, leer wenn nicht gesetzt
|
||||
*/
|
||||
private String resolveBaseUrlValue(EditorValidationInput input, AiProviderFamily family) {
|
||||
return switch (family) {
|
||||
case CLAUDE -> input.claudeBaseUrl();
|
||||
case OPENAI_COMPATIBLE -> input.openaiBaseUrl();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den konfigurierten Modellnamen für die angegebene Provider-Familie aus dem Editorzustand.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @param family aktive Provider-Familie
|
||||
* @return Modellname; nie {@code null}, leer wenn nicht gesetzt
|
||||
*/
|
||||
private String resolveModelValue(EditorValidationInput input, AiProviderFamily family) {
|
||||
return switch (family) {
|
||||
case CLAUDE -> input.claudeModel();
|
||||
case OPENAI_COMPATIBLE -> input.openaiModel();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Timeout-Wert für die angegebene Provider-Familie aus dem Editorzustand.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @param family aktive Provider-Familie
|
||||
* @return Timeout-String; nie {@code null}, leer wenn nicht gesetzt
|
||||
*/
|
||||
private String resolveTimeoutValue(EditorValidationInput input, AiProviderFamily family) {
|
||||
return switch (family) {
|
||||
case CLAUDE -> input.claudeTimeoutSeconds();
|
||||
case OPENAI_COMPATIBLE -> input.openaiTimeoutSeconds();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst einen Timeout-String zu einem Integer. Liefert den Standard-Timeout, wenn der
|
||||
* String leer ist oder nicht als positive Ganzzahl parsierbar ist.
|
||||
*
|
||||
* @param raw roher Timeout-String
|
||||
* @return geparster Timeout in Sekunden (mindestens 1)
|
||||
*/
|
||||
private int parseTimeoutOrDefault(String raw) {
|
||||
try {
|
||||
int parsed = Integer.parseInt(raw.trim());
|
||||
return parsed > 0 ? parsed : DEFAULT_TIMEOUT_SECONDS;
|
||||
} catch (NumberFormatException e) {
|
||||
return DEFAULT_TIMEOUT_SECONDS;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
/**
|
||||
* Outbound-Port für schreibende technische Korrekturhilfen.
|
||||
* <p>
|
||||
* Dieser Port ist <strong>schreibend</strong> und darf nur nach ausdrücklicher
|
||||
* Benutzerbestätigung eines {@link CorrectionPlan} aufgerufen werden. Es darf keine
|
||||
* stille Ausführung im Hintergrund geben.
|
||||
* <p>
|
||||
* <strong>Abgrenzung zu {@link PathCheckPort}:</strong> {@code PathCheckPort} ist
|
||||
* rein lesend; {@code ResourceCreationPort} ist rein schreibend. Beide Ports werden
|
||||
* niemals für dieselbe Aufgabe verwendet.
|
||||
* <p>
|
||||
* <strong>Pfad-Konvention:</strong> Alle Pfade werden als {@code String} übergeben,
|
||||
* analog zur Konvention der übrigen Outbound-Ports dieses Projekts. Der Adapter-Out
|
||||
* ist für die Konvertierung in plattformspezifische Pfadobjekte zuständig.
|
||||
* <p>
|
||||
* <strong>Fehlerbehandlung:</strong> Implementierungen werfen keine geprüften Ausnahmen.
|
||||
* Jede Methode gibt ein {@link CorrectionOutcome} zurück, das Erfolg, Scheitern oder
|
||||
* Nicht-Durchführbarkeit ausdrückt. Unerwartete technische Fehler werden als
|
||||
* {@link CorrectionOutcome.Failed} zurückgegeben.
|
||||
* <p>
|
||||
* <strong>Windows- und Netzlaufwerke:</strong> Implementierungen müssen gemappte
|
||||
* Laufwerksbuchstaben wie {@code S:\} oder {@code H:\} im Windows-Kontext unterstützen.
|
||||
*/
|
||||
public interface ResourceCreationPort {
|
||||
|
||||
/**
|
||||
* Legt den angegebenen Ordner an, einschließlich aller fehlenden übergeordneten Ordner.
|
||||
* <p>
|
||||
* Falls der Ordner bereits existiert, wird {@link CorrectionOutcome.Applied} mit einem
|
||||
* entsprechenden Hinweis zurückgegeben (idempotente Ausführung).
|
||||
*
|
||||
* @param suggestion der {@link CorrectionSuggestion.CreateDirectory}-Vorschlag; darf nicht {@code null} sein
|
||||
* @return Ergebnis der Ausführung; nie {@code null}
|
||||
*/
|
||||
CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion);
|
||||
|
||||
/**
|
||||
* Erzeugt eine neue Prompt-Datei mit einem deutschen Standardinhalt.
|
||||
* <p>
|
||||
* Der Standardinhalt wird von dieser Implementierung bereitgestellt. Die Datei wird
|
||||
* nur erzeugt, wenn sie noch nicht existiert und ihr übergeordneter Ordner beschreibbar ist.
|
||||
* Wenn der Pfad bereits eine Datei enthält, wird {@link CorrectionOutcome.NotAttempted}
|
||||
* zurückgegeben (kein stilless Überschreiben).
|
||||
*
|
||||
* @param suggestion der {@link CorrectionSuggestion.CreatePromptFile}-Vorschlag; darf nicht {@code null} sein
|
||||
* @return Ergebnis der Ausführung; nie {@code null}
|
||||
*/
|
||||
CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion);
|
||||
|
||||
/**
|
||||
* Bereitet den übergeordneten Ordner einer SQLite-Datei vor, sofern dieser noch nicht
|
||||
* existiert.
|
||||
* <p>
|
||||
* Eine leere SQLite-Datei wird nicht manuell erzeugt; das übernimmt der JDBC-Layer
|
||||
* beim ersten Datenbankzugriff. Diese Methode stellt lediglich sicher, dass der
|
||||
* übergeordnete Ordner vorhanden und schreibbar ist.
|
||||
*
|
||||
* @param suggestion der {@link CorrectionSuggestion.PrepareSqlitePath}-Vorschlag; darf nicht {@code null} sein
|
||||
* @return Ergebnis der Ausführung; nie {@code null}
|
||||
*/
|
||||
CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion);
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationFinding;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationReport;
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationSeverity;
|
||||
|
||||
/**
|
||||
* Orchestrator für den vollständigen technischen Gesamttest der GUI-Konfiguration.
|
||||
* <p>
|
||||
* Führt alle elf definierten Prüfpunkte in drei voneinander unabhängigen Blöcken aus:
|
||||
* <ol>
|
||||
* <li><strong>Lokale Validierung:</strong> Prüft den Editorzustand ohne I/O mithilfe des
|
||||
* {@link EditorConfigurationValidator}. Erzeugt Ergebnisse für
|
||||
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
|
||||
* {@link CheckpointId#PROVIDER_CONFIGURATION}.</li>
|
||||
* <li><strong>Pfadprüfungen:</strong> Prüft Quellordner, Zielordner, Prompt-Datei und
|
||||
* SQLite-Pfad über den {@link PathCheckPort}. Erzeugt Ergebnisse für
|
||||
* {@link CheckpointId#PROMPT_FILE_PRESENT}, {@link CheckpointId#SOURCE_FOLDER_PRESENT},
|
||||
* {@link CheckpointId#TARGET_FOLDER_USABLE} und {@link CheckpointId#SQLITE_PATH_USABLE}.</li>
|
||||
* <li><strong>Provider-Prüfungen:</strong> Prüft Endpoint, API-Key, Modellliste und
|
||||
* Modellplausibilität über den {@link ProviderTechnicalTestService}. Erzeugt Ergebnisse für
|
||||
* {@link CheckpointId#BASE_URL_REACHABLE}, {@link CheckpointId#API_KEY_PRESENT},
|
||||
* {@link CheckpointId#API_KEY_ACCEPTED}, {@link CheckpointId#MODEL_LIST_AVAILABLE}
|
||||
* und {@link CheckpointId#SELECTED_MODEL_PLAUSIBLE}.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* <strong>Kein Frühabbruch:</strong> Alle drei Prüfblöcke werden immer vollständig
|
||||
* ausgeführt, auch wenn ein Block eine Exception wirft. In diesem Fall werden die
|
||||
* betroffenen Checkpoints als {@link CheckpointResult.Failure} mit Schweregrad ERROR
|
||||
* und dem Präfix „Interner Fehler:" markiert. Der Gesamtbericht enthält immer genau
|
||||
* elf Einträge.
|
||||
* <p>
|
||||
* <strong>Threading-Kontrakt:</strong> Die Methode {@link #run(TechnicalTestRequest)}
|
||||
* ist synchron blockierend (der Provider-Prüfblock führt HTTP-Aufrufe durch). Sie darf
|
||||
* nicht auf dem JavaFX Application Thread aufgerufen werden. Der Aufrufer ist für die
|
||||
* Worker-Thread-Verwaltung und die Rückführung via {@code Platform.runLater} verantwortlich.
|
||||
* <p>
|
||||
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
|
||||
* enthält, leitet der Orchestrator einen Standardpfad aus dem Konfigurationsdateipfad ab
|
||||
* ({@code <config-parent>/prompt.txt}). Ist auch kein Konfigurationsdateipfad gesetzt,
|
||||
* wird {@code config/prompt.txt} relativ zum Arbeitsverzeichnis verwendet.
|
||||
* <p>
|
||||
* Dieser Service enthält keine JavaFX-Typen, keine NIO-Pfadobjekte in Signaturen und
|
||||
* keine Infrastrukturabhängigkeiten jenseits der drei injizierten Abhängigkeiten.
|
||||
*/
|
||||
public class TechnicalTestOrchestrator {
|
||||
|
||||
private final EditorConfigurationValidator editorValidator;
|
||||
private final PathCheckPort pathCheckPort;
|
||||
private final ProviderTechnicalTestService providerTestService;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Orchestrator mit den drei erforderlichen Abhängigkeiten.
|
||||
*
|
||||
* @param editorValidator Lokaler Konfigurationsvalidator; darf nicht {@code null} sein
|
||||
* @param pathCheckPort Port für Dateisystem-Pfadprüfungen; darf nicht {@code null} sein
|
||||
* @param providerTestService Service für provider-nahe technische Prüfungen; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||
*/
|
||||
public TechnicalTestOrchestrator(EditorConfigurationValidator editorValidator,
|
||||
PathCheckPort pathCheckPort,
|
||||
ProviderTechnicalTestService providerTestService) {
|
||||
this.editorValidator = Objects.requireNonNull(editorValidator, "editorValidator must not be null");
|
||||
this.pathCheckPort = Objects.requireNonNull(pathCheckPort, "pathCheckPort must not be null");
|
||||
this.providerTestService = Objects.requireNonNull(providerTestService, "providerTestService must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt den vollständigen technischen Gesamttest gegen den angegebenen Editorzustand aus.
|
||||
* <p>
|
||||
* Alle drei Prüfblöcke werden immer vollständig ausgeführt. Ein Fehler in einem Block
|
||||
* führt nicht dazu, dass ein anderer Block übersprungen wird. Der zurückgegebene Bericht
|
||||
* enthält immer genau elf {@link CheckpointResult}-Einträge.
|
||||
* <p>
|
||||
* <strong>Prompt-Datei-Standardpfad:</strong> Wenn der Editorzustand keinen Prompt-Pfad
|
||||
* enthält, wird als Standardpfad der Elternordner der Konfigurationsdatei gewählt
|
||||
* (aus {@link TechnicalTestRequest#configFilePath()}), konkret
|
||||
* {@code <config-parent>/prompt.txt}. Falls kein Konfigurationsdateipfad gesetzt ist,
|
||||
* lautet der Fallback {@code config/prompt.txt} relativ zum Arbeitsverzeichnis.
|
||||
* <p>
|
||||
* Wenn der Zielpfad der Prompt-Datei nicht beschreibbar ist, wird keine
|
||||
* {@link CorrectionSuggestion} erzeugt, sondern eine Failure-Meldung mit dem Hinweis,
|
||||
* die Datei manuell anzulegen.
|
||||
* <p>
|
||||
* <strong>Threading-Kontrakt:</strong> Diese Methode blockiert, bis alle Prüfungen
|
||||
* abgeschlossen sind. Sie darf nicht auf dem JavaFX Application Thread aufgerufen werden.
|
||||
*
|
||||
* @param request Eingabedaten für den Gesamttest; darf nicht {@code null} sein
|
||||
* @return vollständiger Gesamttestbericht mit genau elf Einträgen; nie {@code null}
|
||||
* @throws NullPointerException wenn {@code request} {@code null} ist
|
||||
*/
|
||||
public TechnicalTestReport run(TechnicalTestRequest request) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
Instant startTime = Instant.now();
|
||||
EditorValidationInput input = request.validationInput();
|
||||
|
||||
List<CheckpointResult> results = new ArrayList<>(11);
|
||||
|
||||
// Block 1: Lokale Konfigurationsvalidierung (kein I/O)
|
||||
results.addAll(runLocalValidationBlock(input));
|
||||
|
||||
// Block 2: Pfadprüfungen (Dateisystem-I/O)
|
||||
results.addAll(runPathCheckBlock(input, request.configFilePath()));
|
||||
|
||||
// Block 3: Provider-nahe technische Prüfungen (Netzwerk-I/O)
|
||||
results.addAll(runProviderCheckBlock(input));
|
||||
|
||||
return new TechnicalTestReport(results, startTime);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Block 1: Lokale Konfigurationsvalidierung
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Führt die lokale Konfigurationsvalidierung durch und bildet das Ergebnis auf
|
||||
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} und
|
||||
* {@link CheckpointId#PROVIDER_CONFIGURATION} ab.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @return Liste mit genau zwei Einträgen
|
||||
*/
|
||||
private List<CheckpointResult> runLocalValidationBlock(EditorValidationInput input) {
|
||||
try {
|
||||
EditorValidationReport report = editorValidator.validate(input);
|
||||
return mapLocalValidationToCheckpoints(report);
|
||||
} catch (Exception e) {
|
||||
String errorMsg = "Interner Fehler bei der lokalen Konfigurationsvalidierung: " + e.getMessage();
|
||||
return List.of(
|
||||
CheckpointResult.Failure.of(CheckpointId.CONFIGURATION_BASIC_VALIDATION,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.PROVIDER_CONFIGURATION,
|
||||
CheckpointSeverity.ERROR, errorMsg)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet den {@link EditorValidationReport} auf die zwei lokalen Prüfpunkte ab.
|
||||
* <p>
|
||||
* Befunde ohne Feldbezug oder mit allgemeinen Feldbezügen werden
|
||||
* {@link CheckpointId#CONFIGURATION_BASIC_VALIDATION} zugeordnet. Provider-spezifische
|
||||
* Feldbefunde werden {@link CheckpointId#PROVIDER_CONFIGURATION} zugeordnet.
|
||||
*
|
||||
* @param report Validierungsergebnis
|
||||
* @return Liste mit genau zwei Einträgen
|
||||
*/
|
||||
private static List<CheckpointResult> mapLocalValidationToCheckpoints(EditorValidationReport report) {
|
||||
// Trennen: allgemeine Befunde vs. provider-spezifische Befunde
|
||||
List<EditorValidationFinding> generalFindings = report.findings().stream()
|
||||
.filter(f -> !isProviderSpecificField(f.fieldKey().orElse("")))
|
||||
.toList();
|
||||
List<EditorValidationFinding> providerFindings = report.findings().stream()
|
||||
.filter(f -> isProviderSpecificField(f.fieldKey().orElse("")))
|
||||
.toList();
|
||||
|
||||
CheckpointResult basicValidation = buildCheckpointFromFindings(
|
||||
CheckpointId.CONFIGURATION_BASIC_VALIDATION,
|
||||
generalFindings,
|
||||
"Konfiguration grundsätzlich gültig.");
|
||||
|
||||
CheckpointResult providerValidation = buildCheckpointFromFindings(
|
||||
CheckpointId.PROVIDER_CONFIGURATION,
|
||||
providerFindings,
|
||||
"Provider-Konfiguration vollständig.");
|
||||
|
||||
return List.of(basicValidation, providerValidation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Feldschlüssel zu einem provider-spezifischen Feld gehört.
|
||||
*
|
||||
* @param fieldKey Property-Schlüssel
|
||||
* @return {@code true} wenn es ein provider-spezifisches Feld ist
|
||||
*/
|
||||
private static boolean isProviderSpecificField(String fieldKey) {
|
||||
return fieldKey.startsWith("ai.provider.claude.")
|
||||
|| fieldKey.startsWith("ai.provider.openai-compatible.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein {@link CheckpointResult} aus einer Liste von Befunden.
|
||||
*
|
||||
* @param id Prüfpunkt-ID
|
||||
* @param findings Liste der relevanten Befunde
|
||||
* @param successMessage Meldung bei leerem Befund-Ergebnis
|
||||
* @return Success bei leerer Befund-Liste, Failure andernfalls
|
||||
*/
|
||||
private static CheckpointResult buildCheckpointFromFindings(CheckpointId id,
|
||||
List<EditorValidationFinding> findings,
|
||||
String successMessage) {
|
||||
if (findings.isEmpty()) {
|
||||
return new CheckpointResult.Success(id, successMessage);
|
||||
}
|
||||
|
||||
// Höchsten Schweregrad bestimmen
|
||||
boolean hasError = findings.stream()
|
||||
.anyMatch(f -> f.severity() == EditorValidationSeverity.ERROR);
|
||||
CheckpointSeverity severity = hasError ? CheckpointSeverity.ERROR : CheckpointSeverity.WARNING;
|
||||
|
||||
// Befunde zusammenfassen
|
||||
String summary = findings.size() == 1
|
||||
? findings.get(0).message()
|
||||
: findings.size() + " Befunde: " + findings.get(0).message()
|
||||
+ (findings.size() > 1 ? " (und " + (findings.size() - 1) + " weitere)" : "");
|
||||
|
||||
return CheckpointResult.Failure.of(id, severity, summary);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Block 2: Pfadprüfungen
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Führt die Dateisystem-Pfadprüfungen für Prompt-Datei, Quellordner, Zielordner
|
||||
* und SQLite-Pfad durch.
|
||||
* <p>
|
||||
* Der {@code configFilePath} wird genutzt, um bei fehlendem Prompt-Pfad im Editorzustand
|
||||
* einen sinnvollen Standardpfad zu bestimmen ({@code <config-parent>/prompt.txt}).
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
|
||||
* @return Liste mit genau vier Einträgen
|
||||
*/
|
||||
private List<CheckpointResult> runPathCheckBlock(EditorValidationInput input,
|
||||
String configFilePath) {
|
||||
try {
|
||||
List<CheckpointResult> results = new ArrayList<>(4);
|
||||
results.add(checkPromptFile(input.promptTemplateFile(), configFilePath));
|
||||
results.add(checkSourceFolder(input.sourceFolder()));
|
||||
results.add(checkTargetFolder(input.targetFolder()));
|
||||
results.add(checkSqlitePath(input.sqliteFile()));
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
String errorMsg = "Interner Fehler bei den Pfadprüfungen: " + e.getMessage();
|
||||
return List.of(
|
||||
CheckpointResult.Failure.of(CheckpointId.PROMPT_FILE_PRESENT,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
|
||||
CheckpointSeverity.ERROR, errorMsg)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Prompt-Datei auf Vorhandensein und Lesbarkeit.
|
||||
* <p>
|
||||
* <strong>Pfad-Auflösung:</strong> Wenn der konfigurierte Prompt-Pfad leer ist,
|
||||
* wird ein Standardpfad bestimmt:
|
||||
* <ul>
|
||||
* <li>Wenn {@code configFilePath} gesetzt ist: {@code <configFilePath-Elternordner>/prompt.txt}</li>
|
||||
* <li>Sonst: {@code config/prompt.txt} relativ zum Arbeitsverzeichnis</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Schreibbarkeits-Prüfung:</strong> Wenn der Zielpfad fehlt, wird geprüft, ob der
|
||||
* Elternordner beschreibbar wäre. Nur dann wird eine {@link CorrectionSuggestion.CreatePromptFile}
|
||||
* angeboten. Ist der Elternordner nicht beschreibbar, wird eine Failure ohne Korrekturvorschlag
|
||||
* zurückgegeben, aber mit einem Hinweis, die Datei manuell anzulegen.
|
||||
*
|
||||
* @param configuredPath konfigurierter Prompt-Pfad aus dem Editorzustand; kann leer sein
|
||||
* @param configFilePath Pfad der geladenen Konfigurationsdatei; leer wenn keine geladen
|
||||
* @return Prüfpunkt-Ergebnis
|
||||
*/
|
||||
private CheckpointResult checkPromptFile(String configuredPath, String configFilePath) {
|
||||
// Effektiven Prompt-Pfad bestimmen
|
||||
String effectivePath = resolvePromptPath(configuredPath, configFilePath);
|
||||
|
||||
if (pathCheckPort.isFileReadable(effectivePath)) {
|
||||
return new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT,
|
||||
"Prompt-Datei vorhanden und lesbar: " + effectivePath);
|
||||
}
|
||||
|
||||
// Datei fehlt – Elternordner auf Beschreibbarkeit prüfen
|
||||
String parentPath = extractParentPath(effectivePath);
|
||||
boolean parentWritable = !parentPath.isBlank()
|
||||
&& pathCheckPort.isDirectoryWritableOrCreatable(parentPath);
|
||||
|
||||
if (parentWritable) {
|
||||
// Elternordner beschreibbar → Korrekturvorschlag anbieten
|
||||
CorrectionSuggestion suggestion = new CorrectionSuggestion.CreatePromptFile(
|
||||
effectivePath, "Prompt-Datei anlegen: " + effectivePath);
|
||||
return CheckpointResult.Failure.withCorrection(
|
||||
CheckpointId.PROMPT_FILE_PRESENT,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Prompt-Datei nicht vorhanden oder nicht lesbar: " + effectivePath,
|
||||
suggestion);
|
||||
} else {
|
||||
// Elternordner nicht beschreibbar → kein Korrekturvorschlag, nur Hinweis
|
||||
return CheckpointResult.Failure.of(
|
||||
CheckpointId.PROMPT_FILE_PRESENT,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Prompt-Datei fehlt und kann nicht automatisch erzeugt werden. "
|
||||
+ "Bitte manuell anlegen: " + effectivePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt den effektiven Prompt-Pfad aus dem konfigurierten Pfad und dem Konfigurationsdateipfad.
|
||||
* <p>
|
||||
* Wenn der konfigurierte Pfad nicht leer ist, wird dieser unverändert zurückgegeben.
|
||||
* Andernfalls wird ein Standardpfad aus dem Konfigurationsdateipfad abgeleitet:
|
||||
* {@code <configFilePath-Elternordner>/prompt.txt}. Falls auch der Konfigurationsdateipfad
|
||||
* leer ist, lautet der Fallback {@code config/prompt.txt}.
|
||||
*
|
||||
* @param configuredPath konfigurierter Prompt-Pfad; kann leer sein
|
||||
* @param configFilePath Pfad der geladenen Konfigurationsdatei; kann leer sein
|
||||
* @return effektiver Prompt-Pfad; nie {@code null}, nie leer
|
||||
*/
|
||||
static String resolvePromptPath(String configuredPath, String configFilePath) {
|
||||
if (!configuredPath.isBlank()) {
|
||||
return configuredPath;
|
||||
}
|
||||
// Standardpfad aus dem Konfigurationsdatei-Elternordner ableiten
|
||||
if (!configFilePath.isBlank()) {
|
||||
String parent = extractParentPath(configFilePath);
|
||||
if (!parent.isBlank()) {
|
||||
return parent + File.separator + "prompt.txt";
|
||||
}
|
||||
}
|
||||
// Absoluter Fallback
|
||||
return "config" + File.separator + "prompt.txt";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert den Elternpfad aus einem Dateipfad.
|
||||
* <p>
|
||||
* Gibt eine leere Zeichenkette zurück, wenn kein Elternpfad bestimmbar ist.
|
||||
*
|
||||
* @param filePath Dateipfad als String
|
||||
* @return Elternpfad oder leere Zeichenkette
|
||||
*/
|
||||
private static String extractParentPath(String filePath) {
|
||||
if (filePath == null || filePath.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
java.nio.file.Path path = Paths.get(filePath);
|
||||
java.nio.file.Path parent = path.getParent();
|
||||
return parent != null ? parent.toString() : "";
|
||||
} catch (InvalidPathException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Quellordner auf Vorhandensein und Lesbarkeit.
|
||||
*
|
||||
* @param path Pfad des Quellordners
|
||||
* @return Prüfpunkt-Ergebnis
|
||||
*/
|
||||
private CheckpointResult checkSourceFolder(String path) {
|
||||
if (path.isBlank()) {
|
||||
return CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||
CheckpointSeverity.ERROR, "Quellordner: Kein Pfad konfiguriert.");
|
||||
}
|
||||
if (pathCheckPort.isDirectoryReadable(path)) {
|
||||
return new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||
"Quellordner vorhanden und lesbar: " + path);
|
||||
}
|
||||
return CheckpointResult.Failure.of(CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Quellordner nicht vorhanden oder nicht lesbar: " + path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Zielordner auf Vorhandensein oder Anlegbarkeit und Schreibbarkeit.
|
||||
* Bietet eine {@link CorrectionSuggestion.CreateDirectory} an, wenn der Ordner
|
||||
* fehlt, aber anlegbar wäre.
|
||||
*
|
||||
* @param path Pfad des Zielordners
|
||||
* @return Prüfpunkt-Ergebnis
|
||||
*/
|
||||
private CheckpointResult checkTargetFolder(String path) {
|
||||
if (path.isBlank()) {
|
||||
return CheckpointResult.Failure.of(CheckpointId.TARGET_FOLDER_USABLE,
|
||||
CheckpointSeverity.ERROR, "Zielordner: Kein Pfad konfiguriert.");
|
||||
}
|
||||
if (pathCheckPort.isDirectoryWritableOrCreatable(path)) {
|
||||
return new CheckpointResult.Success(CheckpointId.TARGET_FOLDER_USABLE,
|
||||
"Zielordner vorhanden/anlegbar und schreibbar: " + path);
|
||||
}
|
||||
// Ordner ist weder vorhanden/schreibbar noch anlegbar
|
||||
// Wenn der Ordner fehlt, könnte isDirectoryWritableOrCreatable false liefern weil
|
||||
// auch der Elternpfad fehlt. Trotzdem einen Korrekturvorschlag anbieten.
|
||||
CorrectionSuggestion suggestion = new CorrectionSuggestion.CreateDirectory(
|
||||
path, "Zielordner anlegen: " + path);
|
||||
return CheckpointResult.Failure.withCorrection(
|
||||
CheckpointId.TARGET_FOLDER_USABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
"Zielordner nicht vorhanden oder nicht schreibbar: " + path,
|
||||
suggestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der SQLite-Pfad technisch nutzbar ist.
|
||||
* Bietet eine {@link CorrectionSuggestion.PrepareSqlitePath} an, wenn der Pfad
|
||||
* noch nicht nutzbar, aber vorbereitbar wäre.
|
||||
*
|
||||
* @param path Pfad der SQLite-Datei
|
||||
* @return Prüfpunkt-Ergebnis
|
||||
*/
|
||||
private CheckpointResult checkSqlitePath(String path) {
|
||||
if (path.isBlank()) {
|
||||
return CheckpointResult.Failure.of(CheckpointId.SQLITE_PATH_USABLE,
|
||||
CheckpointSeverity.ERROR, "SQLite-Pfad: Kein Pfad konfiguriert.");
|
||||
}
|
||||
if (pathCheckPort.isSqlitePathUsable(path)) {
|
||||
return new CheckpointResult.Success(CheckpointId.SQLITE_PATH_USABLE,
|
||||
"SQLite-Pfad technisch nutzbar: " + path);
|
||||
}
|
||||
CorrectionSuggestion suggestion = new CorrectionSuggestion.PrepareSqlitePath(
|
||||
path, "SQLite-Pfad vorbereiten: " + path);
|
||||
return CheckpointResult.Failure.withCorrection(
|
||||
CheckpointId.SQLITE_PATH_USABLE,
|
||||
CheckpointSeverity.ERROR,
|
||||
"SQLite-Pfad nicht nutzbar: " + path,
|
||||
suggestion);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Block 3: Provider-nahe technische Prüfungen
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Führt die provider-nahen technischen Prüfungen über den {@link ProviderTechnicalTestService} aus.
|
||||
* <p>
|
||||
* Der Service liefert genau fünf Ergebnisse in der Reihenfolge:
|
||||
* API_KEY_PRESENT, BASE_URL_REACHABLE, API_KEY_ACCEPTED, MODEL_LIST_AVAILABLE, SELECTED_MODEL_PLAUSIBLE.
|
||||
*
|
||||
* @param input aktueller Editorzustand
|
||||
* @return Liste mit genau fünf Einträgen
|
||||
*/
|
||||
private List<CheckpointResult> runProviderCheckBlock(EditorValidationInput input) {
|
||||
try {
|
||||
return providerTestService.runProviderChecks(input);
|
||||
} catch (Exception e) {
|
||||
String errorMsg = "Interner Fehler bei den Provider-Prüfungen: " + e.getMessage();
|
||||
return List.of(
|
||||
CheckpointResult.Failure.of(CheckpointId.API_KEY_PRESENT,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.API_KEY_ACCEPTED,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointSeverity.ERROR, errorMsg),
|
||||
CheckpointResult.Failure.of(CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
CheckpointSeverity.ERROR, errorMsg)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ergebnis eines vollständigen technischen Gesamttests.
|
||||
* <p>
|
||||
* Enthält die {@link CheckpointResult}-Einträge aller durchlaufenen Prüfpunkte in
|
||||
* Ausführungsreihenfolge. Jeder definierte Prüfpunkt ist vertreten – entweder als
|
||||
* {@link CheckpointResult.Success}, {@link CheckpointResult.Failure} oder
|
||||
* {@link CheckpointResult.NotApplicable}.
|
||||
* <p>
|
||||
* Der Gesamttest bricht bei einem Fehler <em>nicht</em> ab; alle Prüfpunkte werden
|
||||
* vollständig durchlaufen.
|
||||
* <p>
|
||||
* Dieser Record ist immutable und enthält keine JavaFX-Typen.
|
||||
*
|
||||
* @param results Prüfpunkt-Ergebnisse in Ausführungsreihenfolge; nie {@code null}
|
||||
* @param evaluatedAt Zeitpunkt, zu dem der Gesamttest gestartet wurde; nie {@code null}
|
||||
*/
|
||||
public record TechnicalTestReport(
|
||||
List<CheckpointResult> results,
|
||||
Instant evaluatedAt) {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Gesamttestbericht.
|
||||
*
|
||||
* @param results Ergebnisliste; darf nicht {@code null} sein
|
||||
* @param evaluatedAt Startzeitpunkt des Tests; darf nicht {@code null} sein
|
||||
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||
*/
|
||||
public TechnicalTestReport {
|
||||
Objects.requireNonNull(results, "results must not be null");
|
||||
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
|
||||
results = List.copyOf(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens ein Prüfpunkt mit Schweregrad {@link CheckpointSeverity#ERROR}
|
||||
* gescheitert ist.
|
||||
* <p>
|
||||
* Wenn {@code true}, gilt die Konfiguration im aktuellen Zustand als nicht lauffähig.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein Fehler-Prüfpunkt vorliegt
|
||||
*/
|
||||
public boolean hasErrors() {
|
||||
return results.stream()
|
||||
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||
.map(r -> (CheckpointResult.Failure) r)
|
||||
.anyMatch(f -> f.severity() == CheckpointSeverity.ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens ein Prüfpunkt mit Schweregrad {@link CheckpointSeverity#WARNING}
|
||||
* gescheitert ist.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein Warn-Prüfpunkt vorliegt
|
||||
*/
|
||||
public boolean hasWarnings() {
|
||||
return results.stream()
|
||||
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||
.map(r -> (CheckpointResult.Failure) r)
|
||||
.anyMatch(f -> f.severity() == CheckpointSeverity.WARNING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob mindestens ein Prüfpunkt einen {@link CorrectionSuggestion} enthält.
|
||||
* <p>
|
||||
* Wenn {@code true}, kann aus diesem Bericht ein nicht leerer {@link CorrectionPlan}
|
||||
* abgeleitet werden.
|
||||
*
|
||||
* @return {@code true} wenn mindestens ein korrigierbarer Befund vorliegt
|
||||
*/
|
||||
public boolean hasCorrectableFindings() {
|
||||
return results.stream()
|
||||
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||
.map(r -> (CheckpointResult.Failure) r)
|
||||
.anyMatch(CheckpointResult.Failure::hasCorrectionSuggestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet einen {@link CorrectionPlan} aus den korrigierbaren Prüfpunkt-Fehlern ab.
|
||||
* <p>
|
||||
* Enthält alle {@link CorrectionSuggestion}-Einträge der gescheiterten Prüfpunkte
|
||||
* in Berichtsreihenfolge.
|
||||
*
|
||||
* @return abgeleiteter Korrekturplan; nie {@code null}; leer wenn keine Korrekturen möglich sind
|
||||
*/
|
||||
public CorrectionPlan deriveCorrectionPlan() {
|
||||
List<CorrectionSuggestion> suggestions = results.stream()
|
||||
.filter(r -> r instanceof CheckpointResult.Failure)
|
||||
.map(r -> (CheckpointResult.Failure) r)
|
||||
.filter(CheckpointResult.Failure::hasCorrectionSuggestion)
|
||||
.map(f -> f.correctionSuggestion().orElseThrow())
|
||||
.toList();
|
||||
return new CorrectionPlan(suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Gesamtzahl der Prüfpunkt-Ergebnisse zurück.
|
||||
*
|
||||
* @return Anzahl der Ergebnisse; nie negativ
|
||||
*/
|
||||
public int size() {
|
||||
return results.size();
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||
|
||||
/**
|
||||
* Eingabedaten für einen vollständigen technischen Gesamttest der GUI-Konfiguration.
|
||||
* <p>
|
||||
* Enthält den aktuellen Editorzustand als {@link EditorValidationInput} (alle String-Werte
|
||||
* so wie sie im Editor vorliegen). Der technische Gesamttest arbeitet ausschließlich auf
|
||||
* diesen Werten; er liest keine Konfigurationsdatei vom Dateisystem und speichert nichts.
|
||||
* <p>
|
||||
* Der optionale Pfad zur Konfigurationsdatei ({@code configFilePath}) ermöglicht es dem
|
||||
* Gesamttest, bei der automatischen Prompt-Erzeugung den Standardpfad relativ zur
|
||||
* Konfigurationsdatei zu bestimmen. Er ist leer, wenn keine Konfigurationsdatei geladen ist.
|
||||
* <p>
|
||||
* Dieser Record enthält keine JavaFX-Typen und keine Infrastrukturabhängigkeiten.
|
||||
*
|
||||
* @param validationInput aktueller Editorzustand; nie {@code null}
|
||||
* @param configFilePath optionaler Pfad der geladenen Konfigurationsdatei als String;
|
||||
* leer wenn keine Datei geladen ist
|
||||
*/
|
||||
public record TechnicalTestRequest(
|
||||
EditorValidationInput validationInput,
|
||||
String configFilePath) {
|
||||
|
||||
/**
|
||||
* Erstellt eine neue Gesamttest-Anforderung.
|
||||
*
|
||||
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
||||
* @param configFilePath Pfad der Konfigurationsdatei; {@code null} wird zu leerem String
|
||||
* @throws NullPointerException wenn {@code validationInput} {@code null} ist
|
||||
*/
|
||||
public TechnicalTestRequest {
|
||||
Objects.requireNonNull(validationInput, "validationInput must not be null");
|
||||
configFilePath = configFilePath == null ? "" : configFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Anforderung ohne geladene Konfigurationsdatei.
|
||||
*
|
||||
* @param validationInput aktueller Editorzustand; darf nicht {@code null} sein
|
||||
* @return eine neue Anforderung ohne Konfigurationsdateipfad
|
||||
*/
|
||||
public static TechnicalTestRequest of(EditorValidationInput validationInput) {
|
||||
return new TechnicalTestRequest(validationInput, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob ein Konfigurationsdateipfad gesetzt ist.
|
||||
*
|
||||
* @return {@code true} wenn ein nicht leerer Pfad vorhanden ist
|
||||
*/
|
||||
public boolean hasConfigFilePath() {
|
||||
return !configFilePath.isBlank();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Typen und Port-Verträge für den technischen Gesamttest der GUI-Konfiguration.
|
||||
* <p>
|
||||
* Dieses Package enthält ausschließlich:
|
||||
* <ul>
|
||||
* <li>Eingabe- und Ergebnismodelle für den vollständigen Gesamttest ({@code TechnicalTestRequest},
|
||||
* {@code TechnicalTestReport}, {@code CheckpointResult}, {@code CheckpointId})</li>
|
||||
* <li>Korrekturmodelle für schreibende Korrekturhilfen ({@code CorrectionSuggestion},
|
||||
* {@code CorrectionPlan}, {@code CorrectionOutcome}, {@code CorrectionExecutionReport})</li>
|
||||
* <li>Outbound-Port-Verträge für Pfadprüfungen ({@code PathCheckPort}) und schreibende
|
||||
* Korrekturen ({@code ResourceCreationPort})</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Abgrenzungen:
|
||||
* <ul>
|
||||
* <li>Dieses Package enthält <strong>keine</strong> konkreten Implementierungen; diese
|
||||
* leben im Adapter-Out-Modul.</li>
|
||||
* <li>Keine JavaFX-, NIO-Framework-, HTTP- oder JDBC-Bibliothekstypen. Standard-JDK-Typen
|
||||
* wie {@code java.nio.file.Path} sind ebenfalls nicht in Port-Signaturen erlaubt;
|
||||
* die Ports verwenden {@code String} als plattformneutralen Pfadtyp.</li>
|
||||
* <li>Die Gesamttest-Orchestrierung und die Bestätigungslogik liegen in späteren
|
||||
* Arbeitspaketen; dieses Package definiert nur die Verträge.</li>
|
||||
* <li>Die automatische Hintergrundvalidierung (Öffnen/Bearbeiten) sowie die explizite
|
||||
* Aktion „Validieren" (nicht schreibend, lokal) sind im Package
|
||||
* {@code validation.editor} definiert und bleiben dort unverändert.</li>
|
||||
* </ul>
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests für den {@link CheckpointId}-Enum.
|
||||
*/
|
||||
class CheckpointIdTest {
|
||||
|
||||
@Test
|
||||
void allMandatoryCheckpointIdsArePresent() {
|
||||
var ids = CheckpointId.values();
|
||||
assertThat(ids).contains(
|
||||
CheckpointId.CONFIGURATION_BASIC_VALIDATION,
|
||||
CheckpointId.PROVIDER_CONFIGURATION,
|
||||
CheckpointId.BASE_URL_REACHABLE,
|
||||
CheckpointId.API_KEY_PRESENT,
|
||||
CheckpointId.API_KEY_ACCEPTED,
|
||||
CheckpointId.MODEL_LIST_AVAILABLE,
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE,
|
||||
CheckpointId.PROMPT_FILE_PRESENT,
|
||||
CheckpointId.SOURCE_FOLDER_PRESENT,
|
||||
CheckpointId.TARGET_FOLDER_USABLE,
|
||||
CheckpointId.SQLITE_PATH_USABLE
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumHasExactlyElevenValues() {
|
||||
assertThat(CheckpointId.values()).hasSize(11);
|
||||
}
|
||||
|
||||
@Test
|
||||
void valueOfRoundtrip() {
|
||||
for (CheckpointId id : CheckpointId.values()) {
|
||||
assertThat(CheckpointId.valueOf(id.name())).isSameAs(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests für das versiegelte Interface {@link CheckpointResult} und seine Untertypen.
|
||||
*/
|
||||
class CheckpointResultTest {
|
||||
|
||||
// --- Success ---
|
||||
|
||||
@Test
|
||||
void success_storesCheckpointIdAndMessage() {
|
||||
var result = new CheckpointResult.Success(CheckpointId.SOURCE_FOLDER_PRESENT, "Ordner vorhanden");
|
||||
assertThat(result.checkpointId()).isEqualTo(CheckpointId.SOURCE_FOLDER_PRESENT);
|
||||
assertThat(result.message()).isEqualTo("Ordner vorhanden");
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_nullCheckpointIdThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CheckpointResult.Success(null, "ok"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_nullMessageThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CheckpointResult.Success(CheckpointId.API_KEY_PRESENT, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void success_equality() {
|
||||
var a = new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT, "ok");
|
||||
var b = new CheckpointResult.Success(CheckpointId.PROMPT_FILE_PRESENT, "ok");
|
||||
assertThat(a).isEqualTo(b);
|
||||
}
|
||||
|
||||
// --- Failure ---
|
||||
|
||||
@Test
|
||||
void failure_of_noCorrectionSuggestion() {
|
||||
var result = CheckpointResult.Failure.of(
|
||||
CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt");
|
||||
assertThat(result.checkpointId()).isEqualTo(CheckpointId.TARGET_FOLDER_USABLE);
|
||||
assertThat(result.severity()).isEqualTo(CheckpointSeverity.ERROR);
|
||||
assertThat(result.message()).isEqualTo("Ordner fehlt");
|
||||
assertThat(result.correctionSuggestion()).isEmpty();
|
||||
assertThat(result.hasCorrectionSuggestion()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failure_withCorrection_storesSuggestion() {
|
||||
var suggestion = new CorrectionSuggestion.CreateDirectory("/some/path", "Ordner anlegen");
|
||||
var result = CheckpointResult.Failure.withCorrection(
|
||||
CheckpointId.TARGET_FOLDER_USABLE, CheckpointSeverity.ERROR, "Ordner fehlt", suggestion);
|
||||
assertThat(result.hasCorrectionSuggestion()).isTrue();
|
||||
assertThat(result.correctionSuggestion()).contains(suggestion);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failure_nullCorrectionSuggestionInConstructorBecomesEmpty() {
|
||||
var result = new CheckpointResult.Failure(
|
||||
CheckpointId.SQLITE_PATH_USABLE, CheckpointSeverity.WARNING, "Pfad auffällig", null);
|
||||
assertThat(result.correctionSuggestion()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failure_nullCheckpointIdThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> CheckpointResult.Failure.of(null, CheckpointSeverity.ERROR, "msg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failure_warningLevel() {
|
||||
var result = CheckpointResult.Failure.of(
|
||||
CheckpointId.SELECTED_MODEL_PLAUSIBLE, CheckpointSeverity.WARNING, "Modell unbekannt");
|
||||
assertThat(result.severity()).isEqualTo(CheckpointSeverity.WARNING);
|
||||
}
|
||||
|
||||
// --- NotApplicable ---
|
||||
|
||||
@Test
|
||||
void notApplicable_storesCheckpointIdAndReason() {
|
||||
var result = new CheckpointResult.NotApplicable(
|
||||
CheckpointId.API_KEY_ACCEPTED, "Kein API-Key vorhanden");
|
||||
assertThat(result.checkpointId()).isEqualTo(CheckpointId.API_KEY_ACCEPTED);
|
||||
assertThat(result.reason()).isEqualTo("Kein API-Key vorhanden");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notApplicable_nullCheckpointIdThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CheckpointResult.NotApplicable(null, "reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notApplicable_nullReasonThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CheckpointResult.NotApplicable(CheckpointId.MODEL_LIST_AVAILABLE, null));
|
||||
}
|
||||
|
||||
// --- Pattern Matching ---
|
||||
|
||||
@Test
|
||||
void patternMatching_coversAllPermittedTypes() {
|
||||
CheckpointResult success = new CheckpointResult.Success(CheckpointId.CONFIGURATION_BASIC_VALIDATION, "ok");
|
||||
CheckpointResult failure = CheckpointResult.Failure.of(CheckpointId.BASE_URL_REACHABLE, CheckpointSeverity.ERROR, "nicht erreichbar");
|
||||
CheckpointResult notApplicable = new CheckpointResult.NotApplicable(CheckpointId.API_KEY_ACCEPTED, "kein key");
|
||||
|
||||
assertThat(classify(success)).isEqualTo("success");
|
||||
assertThat(classify(failure)).isEqualTo("failure");
|
||||
assertThat(classify(notApplicable)).isEqualTo("notApplicable");
|
||||
}
|
||||
|
||||
private String classify(CheckpointResult result) {
|
||||
return switch (result) {
|
||||
case CheckpointResult.Success s -> "success";
|
||||
case CheckpointResult.Failure f -> "failure";
|
||||
case CheckpointResult.NotApplicable n -> "notApplicable";
|
||||
};
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests für {@link CorrectionExecutionReport}.
|
||||
*/
|
||||
class CorrectionExecutionReportTest {
|
||||
|
||||
private final CorrectionSuggestion s1 =
|
||||
new CorrectionSuggestion.CreateDirectory("/path/a", "Ordner A");
|
||||
private final CorrectionSuggestion s2 =
|
||||
new CorrectionSuggestion.CreatePromptFile("/path/prompt.txt", "Prompt erzeugen");
|
||||
|
||||
@Test
|
||||
void emptyReport_allAppliedIsFalse() {
|
||||
var report = new CorrectionExecutionReport(List.of());
|
||||
assertThat(report.allApplied()).isFalse();
|
||||
assertThat(report.hasFailures()).isFalse();
|
||||
assertThat(report.hasNotAttempted()).isFalse();
|
||||
assertThat(report.size()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void report_allApplied() {
|
||||
var report = new CorrectionExecutionReport(List.of(
|
||||
new CorrectionOutcome.Applied(s1, "ok1"),
|
||||
new CorrectionOutcome.Applied(s2, "ok2")));
|
||||
assertThat(report.allApplied()).isTrue();
|
||||
assertThat(report.hasFailures()).isFalse();
|
||||
assertThat(report.hasNotAttempted()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void report_withFailure_hasFailures() {
|
||||
var report = new CorrectionExecutionReport(List.of(
|
||||
new CorrectionOutcome.Applied(s1, "ok"),
|
||||
new CorrectionOutcome.Failed(s2, "Fehler")));
|
||||
assertThat(report.hasFailures()).isTrue();
|
||||
assertThat(report.allApplied()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void report_withNotAttempted_hasNotAttempted() {
|
||||
var report = new CorrectionExecutionReport(List.of(
|
||||
new CorrectionOutcome.NotAttempted(s1, "Grund")));
|
||||
assertThat(report.hasNotAttempted()).isTrue();
|
||||
assertThat(report.allApplied()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void outcomesListIsImmutable() {
|
||||
var mutable = new ArrayList<CorrectionOutcome>();
|
||||
mutable.add(new CorrectionOutcome.Applied(s1, "ok"));
|
||||
var report = new CorrectionExecutionReport(mutable);
|
||||
mutable.add(new CorrectionOutcome.Failed(s2, "err"));
|
||||
assertThat(report.outcomes()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullOutcomesThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CorrectionExecutionReport(null));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link CorrectionExecutionService}.
|
||||
* <p>
|
||||
* Prüft das Dispatch-Verhalten und die Kein-Frühabbruch-Semantik.
|
||||
*/
|
||||
class CorrectionExecutionServiceTest {
|
||||
|
||||
// =========================================================================
|
||||
// No-op Port-Implementierungen für Tests
|
||||
// =========================================================================
|
||||
|
||||
/** Port-Stub, der alle Aufrufe als Applied zurückgibt. */
|
||||
private static ResourceCreationPort allAppliedPort() {
|
||||
return new ResourceCreationPort() {
|
||||
@Override
|
||||
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
|
||||
return new CorrectionOutcome.Applied(s, "Ordner angelegt");
|
||||
}
|
||||
@Override
|
||||
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
|
||||
return new CorrectionOutcome.Applied(s, "Prompt erzeugt");
|
||||
}
|
||||
@Override
|
||||
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
|
||||
return new CorrectionOutcome.Applied(s, "SQLite-Pfad vorbereitet");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Port-Stub, der createDirectory als Failed, den Rest als Applied zurückgibt. */
|
||||
private static ResourceCreationPort firstFailsPort() {
|
||||
return new ResourceCreationPort() {
|
||||
@Override
|
||||
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory s) {
|
||||
return new CorrectionOutcome.Failed(s, "Ordner nicht anlegbar");
|
||||
}
|
||||
@Override
|
||||
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile s) {
|
||||
return new CorrectionOutcome.Applied(s, "Prompt erzeugt");
|
||||
}
|
||||
@Override
|
||||
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath s) {
|
||||
return new CorrectionOutcome.Applied(s, "SQLite-Pfad vorbereitet");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void execute_emptyPlan_returnsEmptyReport() {
|
||||
CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
|
||||
|
||||
CorrectionExecutionReport report = service.execute(CorrectionPlan.empty());
|
||||
|
||||
assertNotNull(report);
|
||||
assertEquals(0, report.size());
|
||||
assertFalse(report.hasFailures());
|
||||
assertFalse(report.hasNotAttempted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_planWithThreeSuggestions_allSucceed_reportHasThreeApplied() {
|
||||
CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
|
||||
|
||||
CorrectionSuggestion.CreateDirectory dir =
|
||||
new CorrectionSuggestion.CreateDirectory("C:/foo", "Zielordner anlegen");
|
||||
CorrectionSuggestion.CreatePromptFile prompt =
|
||||
new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen");
|
||||
CorrectionSuggestion.PrepareSqlitePath sqlite =
|
||||
new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten");
|
||||
|
||||
CorrectionPlan plan = new CorrectionPlan(List.of(dir, prompt, sqlite));
|
||||
CorrectionExecutionReport report = service.execute(plan);
|
||||
|
||||
assertEquals(3, report.size());
|
||||
assertTrue(report.allApplied(), "Alle drei Korrekturen sollen Applied sein");
|
||||
assertFalse(report.hasFailures());
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_planWithOneFailing_othersStillExecuted_noEarlyAbort() {
|
||||
CorrectionExecutionService service = new CorrectionExecutionService(firstFailsPort());
|
||||
|
||||
CorrectionSuggestion.CreateDirectory dir =
|
||||
new CorrectionSuggestion.CreateDirectory("C:/foo", "Ordner anlegen");
|
||||
CorrectionSuggestion.CreatePromptFile prompt =
|
||||
new CorrectionSuggestion.CreatePromptFile("C:/foo/prompt.txt", "Prompt anlegen");
|
||||
CorrectionSuggestion.PrepareSqlitePath sqlite =
|
||||
new CorrectionSuggestion.PrepareSqlitePath("C:/foo/db.sqlite", "SQLite vorbereiten");
|
||||
|
||||
CorrectionPlan plan = new CorrectionPlan(List.of(dir, prompt, sqlite));
|
||||
CorrectionExecutionReport report = service.execute(plan);
|
||||
|
||||
assertEquals(3, report.size(), "Alle 3 Korrekturen müssen versucht worden sein (kein Frühabbruch)");
|
||||
assertTrue(report.hasFailures(), "Erste Korrektur ist fehlgeschlagen");
|
||||
assertFalse(report.allApplied());
|
||||
|
||||
// Erster Eintrag: Failed (CreateDirectory)
|
||||
assertInstanceOf(CorrectionOutcome.Failed.class, report.outcomes().get(0));
|
||||
// Zweiter und dritter Eintrag: Applied (trotz Fehler im ersten)
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, report.outcomes().get(1));
|
||||
assertInstanceOf(CorrectionOutcome.Applied.class, report.outcomes().get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_nullPort_throwsNullPointerException() {
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> new CorrectionExecutionService(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_nullPlan_throwsNullPointerException() {
|
||||
CorrectionExecutionService service = new CorrectionExecutionService(allAppliedPort());
|
||||
assertThrows(NullPointerException.class, () -> service.execute(null));
|
||||
}
|
||||
|
||||
// Helper for instanceOf assertion
|
||||
private static <T> void assertInstanceOf(Class<T> expectedType, Object actual) {
|
||||
assertTrue(expectedType.isInstance(actual),
|
||||
"Expected instance of " + expectedType.getSimpleName()
|
||||
+ " but got " + (actual == null ? "null" : actual.getClass().getSimpleName()));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests: CreatePromptFile-Dispatch prüft DefaultPromptTemplate-Inhalt
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Der {@link CorrectionExecutionService} dispatcht {@link CorrectionSuggestion.CreatePromptFile}
|
||||
* an den Port. Ein Port-Stub, der den Inhalt der Suggestion zurückgibt, muss den
|
||||
* deutschen Standardinhalt aus {@link DefaultPromptTemplate#defaultContent()} enthalten,
|
||||
* wenn der Adapter ihn korrekt befüllt. Hier prüfen wir lediglich, dass
|
||||
* {@link DefaultPromptTemplate#defaultContent()} einen sinnvollen deutschen Text liefert,
|
||||
* der für die Dispatch-Kette geeignet ist.
|
||||
*/
|
||||
@Test
|
||||
void createPromptFile_dispatch_defaultContentIsGermanAndNonEmpty() {
|
||||
// Der Dispatch selbst ist im Service zustandslos.
|
||||
// Wir prüfen hier, dass DefaultPromptTemplate den benötigten Inhalt liefert,
|
||||
// damit der Adapter ihn verwenden kann.
|
||||
String content = DefaultPromptTemplate.defaultContent();
|
||||
assertNotNull(content);
|
||||
assertFalse(content.isBlank(), "DefaultPromptTemplate.defaultContent() darf nicht leer sein");
|
||||
assertTrue(content.contains("Titel"), "Inhalt muss deutsches Schlüsselwort 'Titel' enthalten");
|
||||
assertTrue(content.contains("date"), "Inhalt muss JSON-Feld 'date' beschreiben");
|
||||
assertTrue(content.contains("reasoning"), "Inhalt muss JSON-Feld 'reasoning' beschreiben");
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests für das versiegelte Interface {@link CorrectionOutcome} und seine Untertypen.
|
||||
*/
|
||||
class CorrectionOutcomeTest {
|
||||
|
||||
private final CorrectionSuggestion suggestion =
|
||||
new CorrectionSuggestion.CreateDirectory("/some/dir", "Ordner anlegen");
|
||||
|
||||
@Test
|
||||
void applied_storesSuggestionAndMessage() {
|
||||
var outcome = new CorrectionOutcome.Applied(suggestion, "Ordner wurde angelegt");
|
||||
assertThat(outcome.suggestion()).isSameAs(suggestion);
|
||||
assertThat(outcome.message()).isEqualTo("Ordner wurde angelegt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applied_nullSuggestionThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CorrectionOutcome.Applied(null, "msg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failed_storesSuggestionAndErrorMessage() {
|
||||
var outcome = new CorrectionOutcome.Failed(suggestion, "Zugriff verweigert");
|
||||
assertThat(outcome.suggestion()).isSameAs(suggestion);
|
||||
assertThat(outcome.errorMessage()).isEqualTo("Zugriff verweigert");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failed_nullErrorMessageThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CorrectionOutcome.Failed(suggestion, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notAttempted_storesSuggestionAndReason() {
|
||||
var outcome = new CorrectionOutcome.NotAttempted(suggestion, "Elternordner nicht erreichbar");
|
||||
assertThat(outcome.suggestion()).isSameAs(suggestion);
|
||||
assertThat(outcome.reason()).isEqualTo("Elternordner nicht erreichbar");
|
||||
}
|
||||
|
||||
@Test
|
||||
void patternMatching_coversAllPermittedTypes() {
|
||||
CorrectionOutcome applied = new CorrectionOutcome.Applied(suggestion, "ok");
|
||||
CorrectionOutcome failed = new CorrectionOutcome.Failed(suggestion, "error");
|
||||
CorrectionOutcome notAttempted = new CorrectionOutcome.NotAttempted(suggestion, "reason");
|
||||
|
||||
assertThat(classify(applied)).isEqualTo("applied");
|
||||
assertThat(classify(failed)).isEqualTo("failed");
|
||||
assertThat(classify(notAttempted)).isEqualTo("notAttempted");
|
||||
}
|
||||
|
||||
private String classify(CorrectionOutcome outcome) {
|
||||
return switch (outcome) {
|
||||
case CorrectionOutcome.Applied a -> "applied";
|
||||
case CorrectionOutcome.Failed f -> "failed";
|
||||
case CorrectionOutcome.NotAttempted n -> "notAttempted";
|
||||
};
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests für {@link CorrectionPlan}.
|
||||
*/
|
||||
class CorrectionPlanTest {
|
||||
|
||||
@Test
|
||||
void empty_hasNoCorrections() {
|
||||
var plan = CorrectionPlan.empty();
|
||||
assertThat(plan.hasCorrections()).isFalse();
|
||||
assertThat(plan.size()).isZero();
|
||||
assertThat(plan.suggestions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void plan_withSuggestions_hasCorrections() {
|
||||
var s = new CorrectionSuggestion.CreateDirectory("/path", "desc");
|
||||
var plan = new CorrectionPlan(List.of(s));
|
||||
assertThat(plan.hasCorrections()).isTrue();
|
||||
assertThat(plan.size()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void suggestionsListIsImmutable() {
|
||||
var mutable = new ArrayList<CorrectionSuggestion>();
|
||||
mutable.add(new CorrectionSuggestion.CreateDirectory("/a", "d1"));
|
||||
var plan = new CorrectionPlan(mutable);
|
||||
mutable.add(new CorrectionSuggestion.CreatePromptFile("/b", "d2"));
|
||||
assertThat(plan.suggestions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullSuggestionsThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CorrectionPlan(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plan_equality() {
|
||||
var s = new CorrectionSuggestion.CreateDirectory("/path", "desc");
|
||||
var a = new CorrectionPlan(List.of(s));
|
||||
var b = new CorrectionPlan(List.of(s));
|
||||
assertThat(a).isEqualTo(b);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
|
||||
/**
|
||||
* Tests für das versiegelte Interface {@link CorrectionSuggestion} und seine Untertypen.
|
||||
*/
|
||||
class CorrectionSuggestionTest {
|
||||
|
||||
// --- CreateDirectory ---
|
||||
|
||||
@Test
|
||||
void createDirectory_storesPathAndDescription() {
|
||||
var s = new CorrectionSuggestion.CreateDirectory("/path/to/dir", "Ordner anlegen");
|
||||
assertThat(s.path()).isEqualTo("/path/to/dir");
|
||||
assertThat(s.descriptionForUser()).isEqualTo("Ordner anlegen");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDirectory_blankPathThrows() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> new CorrectionSuggestion.CreateDirectory(" ", "desc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDirectory_nullPathThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CorrectionSuggestion.CreateDirectory(null, "desc"));
|
||||
}
|
||||
|
||||
// --- CreatePromptFile ---
|
||||
|
||||
@Test
|
||||
void createPromptFile_storesPathAndDescription() {
|
||||
var s = new CorrectionSuggestion.CreatePromptFile("/config/prompt.txt", "Prompt-Datei erzeugen");
|
||||
assertThat(s.path()).isEqualTo("/config/prompt.txt");
|
||||
assertThat(s.descriptionForUser()).isEqualTo("Prompt-Datei erzeugen");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPromptFile_blankPathThrows() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> new CorrectionSuggestion.CreatePromptFile("", "desc"));
|
||||
}
|
||||
|
||||
// --- PrepareSqlitePath ---
|
||||
|
||||
@Test
|
||||
void prepareSqlitePath_storesPathAndDescription() {
|
||||
var s = new CorrectionSuggestion.PrepareSqlitePath("/data/store.db", "SQLite-Pfad vorbereiten");
|
||||
assertThat(s.path()).isEqualTo("/data/store.db");
|
||||
assertThat(s.descriptionForUser()).isEqualTo("SQLite-Pfad vorbereiten");
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareSqlitePath_nullDescriptionThrows() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new CorrectionSuggestion.PrepareSqlitePath("/path", null));
|
||||
}
|
||||
|
||||
// --- Pattern Matching ---
|
||||
|
||||
@Test
|
||||
void patternMatching_coversAllPermittedTypes() {
|
||||
CorrectionSuggestion dir = new CorrectionSuggestion.CreateDirectory("/a", "d1");
|
||||
CorrectionSuggestion prompt = new CorrectionSuggestion.CreatePromptFile("/b", "d2");
|
||||
CorrectionSuggestion sqlite = new CorrectionSuggestion.PrepareSqlitePath("/c", "d3");
|
||||
|
||||
assertThat(classify(dir)).isEqualTo("directory");
|
||||
assertThat(classify(prompt)).isEqualTo("promptFile");
|
||||
assertThat(classify(sqlite)).isEqualTo("sqlitePath");
|
||||
}
|
||||
|
||||
private String classify(CorrectionSuggestion s) {
|
||||
return switch (s) {
|
||||
case CorrectionSuggestion.CreateDirectory d -> "directory";
|
||||
case CorrectionSuggestion.CreatePromptFile p -> "promptFile";
|
||||
case CorrectionSuggestion.PrepareSqlitePath sp -> "sqlitePath";
|
||||
};
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.validation.technicaltest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-Tests für {@link DefaultPromptTemplate}.
|
||||
* <p>
|
||||
* Prüft, dass der zurückgegebene Standard-Prompt-Inhalt nicht leer ist,
|
||||
* relevante deutsche Schlüsselwörter enthält und das erwartete JSON-Schema-Format beschreibt.
|
||||
*/
|
||||
class DefaultPromptTemplateTest {
|
||||
|
||||
@Test
|
||||
void defaultContent_isNotNullAndNotEmpty() {
|
||||
String content = DefaultPromptTemplate.defaultContent();
|
||||
assertThat(content).isNotNull();
|
||||
assertThat(content).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultContent_containsGermanKeywords() {
|
||||
String content = DefaultPromptTemplate.defaultContent();
|
||||
assertThat(content).contains("Titel");
|
||||
assertThat(content).contains("Datum");
|
||||
assertThat(content).contains("Deutsch");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultContent_containsJsonSchemaHint() {
|
||||
String content = DefaultPromptTemplate.defaultContent();
|
||||
// JSON-Felder müssen im Prompt beschrieben sein
|
||||
assertThat(content).contains("title");
|
||||
assertThat(content).contains("reasoning");
|
||||
assertThat(content).contains("date");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultContent_containsDateFormatHint() {
|
||||
String content = DefaultPromptTemplate.defaultContent();
|
||||
assertThat(content).contains("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultContent_mentionsTitleMaxLength() {
|
||||
String content = DefaultPromptTemplate.defaultContent();
|
||||
assertThat(content).contains("20");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultContent_isConsistent_calledTwice() {
|
||||
// Idempotenz-Prüfung: zwei Aufrufe liefern denselben Inhalt
|
||||
String first = DefaultPromptTemplate.defaultContent();
|
||||
String second = DefaultPromptTemplate.defaultContent();
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
}
|
||||
+431
@@ -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}.
|
||||
* <p>
|
||||
* 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<CheckpointResult> 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<String> models = List.of("claude-3-sonnet", "claude-3-opus");
|
||||
ProviderTechnicalTestService service = new ProviderTechnicalTestService(
|
||||
catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())),
|
||||
apiKeyPort(keyFromEnv()));
|
||||
|
||||
List<CheckpointResult> 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<String> models = List.of("gpt-4");
|
||||
ProviderTechnicalTestService service = new ProviderTechnicalTestService(
|
||||
catalogPort(new ModelCatalogResult.Success("openai-compatible", models, Instant.now())),
|
||||
apiKeyPort(keyFromEnv()));
|
||||
|
||||
List<CheckpointResult> 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<String> models = List.of("claude-3-opus");
|
||||
ProviderTechnicalTestService service = new ProviderTechnicalTestService(
|
||||
catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())),
|
||||
apiKeyPort(keyFromProperty()));
|
||||
|
||||
List<CheckpointResult> 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<String> models = List.of("claude-3-opus", "claude-3-haiku");
|
||||
ProviderTechnicalTestService service = new ProviderTechnicalTestService(
|
||||
catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())),
|
||||
apiKeyPort(keyFromEnv()));
|
||||
|
||||
List<CheckpointResult> 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<String> models = List.of("claude-3-sonnet");
|
||||
ProviderTechnicalTestService service = new ProviderTechnicalTestService(
|
||||
catalogPort(new ModelCatalogResult.Success("claude", models, Instant.now())),
|
||||
apiKeyPort(keyFromEnv()));
|
||||
|
||||
List<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<CheckpointResult> 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<String> 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<CheckpointResult> 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<CheckpointResult> results, CheckpointId id) {
|
||||
return results.stream()
|
||||
.filter(r -> r.checkpointId() == id)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("No result found for CheckpointId: " + id));
|
||||
}
|
||||
}
|
||||
+516
@@ -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}.
|
||||
* <p>
|
||||
* 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<CheckpointResult> 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<CheckpointId> 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<CheckpointId> 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 <config-parent>/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");
|
||||
}
|
||||
}
|
||||
+108
@@ -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<CheckpointResult>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
+48
@@ -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));
|
||||
}
|
||||
}
|
||||
+32
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user