From a3ed5329af8595f35fedf14bba80b74d902b87e0 Mon Sep 17 00:00:00 2001 From: Marcus Date: Wed, 1 Jul 2026 18:34:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20KI-Rohantwort=20im=20Verlauf-Tab,=20ech?= =?UTF-8?q?te=20Fehlermeldung,=20GUI-Log-Verzeichnis,=20Prompt-H=C3=A4rtun?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnose-Verbesserungen rund um die KI-Verarbeitung: - Verlauf-Tab zeigt die vollständige KI-Rohantwort des ausgewählten Versuchs an (neues Feld "KI-Rohantwort"). Die technische Original-Fehlermeldung ist zusätzlich als Tooltip der Fehlerursache verfügbar. - AiFailureMessageTranslator: eigene, freundliche Meldung für strukturell nicht verwertbare KI-Antworten (NOT_JSON_OBJECT/INVALID_JSON u.a.); der bisher generische Fallback zeigt jetzt den echten technischen Grund an, statt ihn zu verbergen. - Log-Verzeichnis wird im GUI-Modus zur Laufzeit auf das in der geladenen Konfiguration konfigurierte Verzeichnis umgestellt (RuntimeLogDirectoryReconfigurer, Best-Effort, Log4j2-Reconfigure). Bisher landeten GUI-Logs im user.home-Fallback, weil die GUI ohne --config startet. Der headless Pfad bleibt unverändert. - Standard-Prompt: explizit keine Markdown-Codeblöcke/Zäune, nur reines JSON; Unsicherheiten gehören ins reasoning-Feld. Tests fuer alle vier Punkte ergaenzt/aktualisiert. Co-Authored-By: Claude Opus 4.8 --- .../batchrun/AiFailureMessageTranslator.java | 18 ++- .../adapter/in/gui/history/GuiHistoryTab.java | 50 +++++++- .../AiFailureMessageTranslatorTest.java | 25 +++- .../technicaltest/DefaultPromptTemplate.java | 6 +- .../umbenenner/bootstrap/BootstrapRunner.java | 5 + .../RuntimeLogDirectoryReconfigurer.java | 118 ++++++++++++++++++ .../RuntimeLogDirectoryReconfigurerTest.java | 112 +++++++++++++++++ 7 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurer.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurerTest.java diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslator.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslator.java index e503e77..37c0964 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslator.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslator.java @@ -25,7 +25,9 @@ public final class AiFailureMessageTranslator { * technische Fehlerbeschreibung. *

* Ist {@code technicalMessage} {@code null} oder leer, wird der allgemeine - * Fallback-Text zurückgegeben. + * Fallback-Text zurückgegeben. Für eine nicht leere, aber keinem Muster zuordenbare + * Meldung wird die technische Originalmeldung sichtbar angehängt, damit die tatsächliche + * Ursache nicht hinter einem generischen Platzhalter verborgen bleibt. * * @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein * @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol @@ -75,7 +77,19 @@ public final class AiFailureMessageTranslator { return "KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen."; } - return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten."; + // KI-Antwort strukturell nicht verwertbar (kein/ungültiges JSON, Zusatztext, Pflichtfeld fehlt) + if (lower.contains("not_json_object") || lower.contains("not a pure json") + || lower.contains("invalid_json") || lower.contains("could not be parsed") + || lower.contains("empty_response") + || lower.contains("missing_title") || lower.contains("blank_title") + || lower.contains("missing_reasoning")) { + return "KI-Antwort war nicht im erwarteten Format (kein gültiges JSON). " + + "Bitte erneut verarbeiten; hält der Fehler an, den Prompt anpassen."; + } + + // Unbekannter technischer Fehler: die echte technische Meldung sichtbar machen, + // statt sie hinter einem generischen Platzhalter zu verbergen. + return "Verarbeitung fehlgeschlagen. Technischer Grund: " + technicalMessage; } /** diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java index b385d9e..8a4d891 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java @@ -63,7 +63,8 @@ import javafx.scene.layout.VBox; *

* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer * zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%), - * rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%). + * rechts ein Detailbereich mit Stammsatz, Versuchstabelle, KI-Begründung und + * KI-Rohantwort (~45%). * Das Suchfeld ist mit einem 300-ms-Debounce ausgestattet (Live-Filter). * *

Layout

@@ -101,6 +102,7 @@ public final class GuiHistoryTab { "Weitere Einträge vorhanden – Filter verwenden um die Trefferliste einzuschränken."; private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details"; private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden."; + private static final String NO_RAW_RESPONSE_TEXT = "Keine KI-Rohantwort für diesen Versuch gespeichert."; private static final String LOADING_TEXT = "Wird geladen …"; private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich."; @@ -142,6 +144,7 @@ public final class GuiHistoryTab { private final ObservableList attemptsItems = FXCollections.observableArrayList(); private final TextArea failureArea = new TextArea(); private final TextArea reasoningArea = new TextArea(); + private final TextArea rawResponseArea = new TextArea(); private final Button resetButton = new Button("Status zurücksetzen"); private final Button deleteButton = new Button("Eintrag löschen"); @@ -452,11 +455,23 @@ public final class GuiHistoryTab { Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)"); reasoningTitle.setStyle(BOLD_STYLE); + // KI-Rohantwort (vollständige, unveränderte Antwort der KI für den Versuch) + rawResponseArea.setEditable(false); + rawResponseArea.setWrapText(true); + rawResponseArea.setPrefRowCount(6); + rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT); + rawResponseArea.setTooltip(new Tooltip( + "Die vollständige, unveränderte Antwort der KI für diesen Versuch – " + + "inklusive eventuellem Text um das JSON herum. Nützlich zur Fehlersuche.")); + Label rawResponseTitle = new Label("KI-Rohantwort (ausgewählter Versuch)"); + rawResponseTitle.setStyle(BOLD_STYLE); + VBox rightPane = new VBox(8, detailTitle, detailGrid, attemptsTitle, attemptsTable, failureTitle, failureArea, - reasoningTitle, reasoningArea); + reasoningTitle, reasoningArea, + rawResponseTitle, rawResponseArea); rightPane.setPadding(new Insets(4, 8, 4, 4)); VBox.setVgrow(attemptsTable, Priority.ALWAYS); @@ -913,15 +928,19 @@ public final class GuiHistoryTab { ProcessingAttempt last = attempts.get(attempts.size() - 1); attemptsTable.getSelectionModel().select(last); showReasoning(last); + showRawResponse(last); } else { reasoningArea.setText(""); reasoningArea.setPromptText(NO_REASONING_TEXT); + rawResponseArea.setText(""); + rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT); } } private void onAttemptSelected(ProcessingAttempt attempt) { if (attempt != null) { showReasoning(attempt); + showRawResponse(attempt); } } @@ -954,6 +973,11 @@ public final class GuiHistoryTab { failureArea.setText(failureMessage != null ? AiFailureMessageTranslator.translate(failureMessage) : ""); failureArea.setPromptText(NO_ERROR_DETAILS_MSG); + // Die technische Originalmeldung als Tooltip bereitstellen, damit die exakte + // Ursache jederzeit einsehbar ist, ohne den lesbaren Anzeigetext zu überladen. + failureArea.setTooltip(new Tooltip(failureMessage != null + ? failureMessage + : GuiTooltipTexts.VERLAUF_FAILURE_AREA)); } private void showReasoning(ProcessingAttempt attempt) { @@ -967,12 +991,34 @@ public final class GuiHistoryTab { } } + /** + * Zeigt die vollständige KI-Rohantwort des ausgewählten Versuchs an. + *

+ * Enthält der Versuch keine gespeicherte Rohantwort (z. B. Skip-/Pre-Check-Versuche + * oder Netzwerkfehler vor der Antwort), wird ein Platzhaltertext angezeigt. + * + * @param attempt der ausgewählte Verarbeitungsversuch; darf nicht {@code null} sein + */ + private void showRawResponse(ProcessingAttempt attempt) { + String raw = attempt.aiRawResponse(); + if (raw != null && !raw.isBlank()) { + rawResponseArea.setText(raw); + rawResponseArea.setPromptText(""); + } else { + rawResponseArea.setText(""); + rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT); + } + } + private void clearDetailPane() { clearDetailFields(); attemptsItems.clear(); failureArea.setText(""); failureArea.setPromptText(NO_ERROR_DETAILS_MSG); + failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA)); reasoningArea.setText(DETAIL_PLACEHOLDER); + rawResponseArea.setText(""); + rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT); } private void clearDetailFields() { diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslatorTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslatorTest.java index 804ec0d..04bab69 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslatorTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/AiFailureMessageTranslatorTest.java @@ -164,9 +164,30 @@ class AiFailureMessageTranslatorTest { // ------------------------------------------------------------------------- @Test - void unbekannterFehler_liefertFallback() { + void unbekannterFehler_haengtTechnischeMeldungAn() { String result = AiFailureMessageTranslator.translate("some completely unknown error text"); - assertEquals("Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.", result); + assertEquals("Verarbeitung fehlgeschlagen. Technischer Grund: some completely unknown error text", + result); + } + + // ------------------------------------------------------------------------- + // KI-Antwort strukturell nicht verwertbar (JSON/Parse) + // ------------------------------------------------------------------------- + + @Test + void notJsonObject_liefertFormatMeldung() { + String result = AiFailureMessageTranslator.translate( + "AI response could not be parsed [NOT_JSON_OBJECT]: AI response is not a pure JSON object" + + " (contains extra text or is not an object)"); + assertTrue(result.contains("nicht im erwarteten Format"), result); + assertTrue(result.contains("JSON"), result); + } + + @Test + void invalidJson_liefertFormatMeldung() { + String result = AiFailureMessageTranslator.translate( + "AI response could not be parsed [INVALID_JSON]: AI response is not valid JSON: ..."); + assertTrue(result.contains("nicht im erwarteten Format"), result); } @Test diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java index 5e68d2a..ac6feb9 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/validation/technicaltest/DefaultPromptTemplate.java @@ -89,7 +89,11 @@ public final class DefaultPromptTemplate { - Keine Sonderzeichen außer Leerzeichen im Titel. - Eigennamen bleiben unverändert. - Umlaute und ß sind erlaubt. - - Kein Text außerhalb des JSON-Objekts. + - Kein Text außerhalb des JSON-Objekts. Keine Markdown-Codeblöcke und keine + ```-Zäune, keine Einleitung und keine Nachbemerkung. Gib ausschließlich das + reine JSON-Objekt aus, beginnend mit { und endend mit }. + - Unsicherheiten, Einschränkungen oder Hinweise gehören ausschließlich in das + Feld "reasoning", niemals in Text außerhalb des JSON-Objekts. """; return template.replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength)); } diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 90e98af..5a0b269 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -1277,6 +1277,11 @@ public class BootstrapRunner { try { migrateConfigurationIfNeeded(configFilePath); StartConfiguration config = loadAndValidateConfiguration(configFilePath); + // Im GUI-Modus wird ohne --config gestartet; das konfigurierte Log-Verzeichnis + // wird erst hier bekannt. Log4j2 (Best-Effort) darauf umstellen, damit die Logs + // im konfigurierten Verzeichnis landen und nicht im Standard-Fallback. + new de.gecheckt.pdf.umbenenner.bootstrap.startup.RuntimeLogDirectoryReconfigurer() + .apply(config.logDirectory()); initializeSchema(config); guiApplicationRunContext.set(Optional.of( new ApplicationRunContext(config, resolveActiveJdbcUrl(config)))); diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurer.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurer.java new file mode 100644 index 0000000..41aa88b --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurer.java @@ -0,0 +1,118 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.startup; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.core.LoggerContext; + +/** + * Stellt das Log4j2-Log-Verzeichnis zur Laufzeit auf das in der geladenen Konfiguration + * hinterlegte {@code log.directory} um. + *

+ * Hintergrund: Im GUI-Modus wird die Anwendung typischerweise ohne {@code --config} + * gestartet. {@link EarlyLogDirectoryInitializer} kann die Property {@code log.directory} + * dann nicht aus der später in der GUI geladenen Konfigurationsdatei ableiten, sodass Log4j2 + * auf sein Standard-Log-Verzeichnis zurückfällt. Sobald die GUI eine Konfiguration lädt + * (Auto-Reload beim Start oder „Öffnen"), stellt diese Klasse das Log-Verzeichnis + * nachträglich auf den konfigurierten Wert um, indem sie die System-Property setzt und + * den laufenden {@link LoggerContext} neu konfiguriert (Log4j2 löst {@code ${sys:log.directory}} + * dabei erneut auf). + *

+ * Die Umstellung ist Best-Effort: Fehler (nicht anlegbares Verzeichnis, + * Reconfigure-Fehler) werden protokolliert und geschluckt; das bisherige Log-Verzeichnis + * bleibt in diesem Fall aktiv. Der Programmablauf wird nie unterbrochen. + *

+ * Diese Klasse betrifft ausschließlich den GUI-Pfad. Der headless Betrieb erhält das + * Log-Verzeichnis unverändert früh über {@link EarlyLogDirectoryInitializer}. + */ +public class RuntimeLogDirectoryReconfigurer { + + private static final String SYSTEM_PROPERTY_KEY = "log.directory"; + private static final Logger LOG = LogManager.getLogger(RuntimeLogDirectoryReconfigurer.class); + + /** + * Erstellt einen neuen Reconfigurer. + */ + public RuntimeLogDirectoryReconfigurer() { + // zustandslos + } + + /** + * Ergebnis einer Umstellungsanfrage. + * + * @param changed {@code true}, wenn das Log-Verzeichnis tatsächlich umgestellt wurde + * @param appliedDirectory das nun aktive Log-Verzeichnis, oder {@code null}, wenn nichts geändert wurde + */ + public record LogDirectoryChange(boolean changed, String appliedDirectory) { + + private static LogDirectoryChange unchanged() { + return new LogDirectoryChange(false, null); + } + + private static LogDirectoryChange changedTo(String appliedDirectory) { + return new LogDirectoryChange(true, appliedDirectory); + } + } + + /** + * Stellt das Log-Verzeichnis auf {@code logDirectory} um, sofern nötig und möglich. + *

+ * Ist {@code logDirectory} {@code null} oder leer, oder entspricht es bereits dem aktiven + * Wert, geschieht nichts. Andernfalls wird das Verzeichnis angelegt (Best-Effort), die + * System-Property {@code log.directory} gesetzt und Log4j2 neu konfiguriert. + * + * @param logDirectory das konfigurierte Log-Verzeichnis; darf {@code null} sein + * @return das Ergebnis der Umstellung; nie {@code null} + */ + public LogDirectoryChange apply(Path logDirectory) { + if (logDirectory == null || logDirectory.toString().isBlank()) { + return LogDirectoryChange.unchanged(); + } + + String target; + try { + target = logDirectory.toAbsolutePath().toString(); + } catch (RuntimeException e) { + LOG.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht aufgelöst werden: {} – " + + "bisheriges Log-Verzeichnis bleibt aktiv.", logDirectory, e.getMessage()); + return LogDirectoryChange.unchanged(); + } + + if (target.equals(System.getProperty(SYSTEM_PROPERTY_KEY))) { + return LogDirectoryChange.unchanged(); + } + + try { + Files.createDirectories(logDirectory.toAbsolutePath()); + } catch (Exception e) { + LOG.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht angelegt werden: {} – " + + "bisheriges Log-Verzeichnis bleibt aktiv.", target, e.getMessage()); + return LogDirectoryChange.unchanged(); + } + + System.setProperty(SYSTEM_PROPERTY_KEY, target); + try { + reconfigure(); + } catch (RuntimeException e) { + LOG.warn("Log4j2 konnte nicht auf Log-Verzeichnis '{}' umgestellt werden: {}", + target, e.getMessage()); + return LogDirectoryChange.unchanged(); + } + + LOG.info("Log-Verzeichnis zur Laufzeit auf das konfigurierte Verzeichnis umgestellt: {}", target); + return LogDirectoryChange.changedTo(target); + } + + /** + * Konfiguriert den laufenden Log4j2-{@link LoggerContext} neu, sodass + * {@code ${sys:log.directory}} erneut aufgelöst wird. + *

+ * Als eigene Methode ausgelegt, damit Tests die Log4j2-Neukonfiguration überschreiben können. + */ + protected void reconfigure() { + LoggerContext ctx = (LoggerContext) LogManager.getContext(false); + ctx.reconfigure(); + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurerTest.java new file mode 100644 index 0000000..d1885d4 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/RuntimeLogDirectoryReconfigurerTest.java @@ -0,0 +1,112 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.startup; + +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.assertNull; +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 java.nio.file.Paths; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.gecheckt.pdf.umbenenner.bootstrap.startup.RuntimeLogDirectoryReconfigurer.LogDirectoryChange; + +/** + * Tests für {@link RuntimeLogDirectoryReconfigurer}. + * + *

Die tatsächliche Log4j2-Neukonfiguration wird über eine Test-Unterklasse unterdrückt, + * damit die Tests den globalen Log4j2-Zustand nicht verändern. Die System-Property + * {@code log.directory} wird pro Test gesichert, geleert und wiederhergestellt. + */ +class RuntimeLogDirectoryReconfigurerTest { + + private static final String KEY = "log.directory"; + + private String originalProperty; + + @TempDir + Path tempDir; + + /** Variante, die die echte Log4j2-Neukonfiguration durch bloßes Zählen ersetzt. */ + private static final class RecordingReconfigurer extends RuntimeLogDirectoryReconfigurer { + private int reconfigureCalls = 0; + + @Override + protected void reconfigure() { + reconfigureCalls++; + } + } + + @BeforeEach + void clearLogDirectoryProperty() { + originalProperty = System.getProperty(KEY); + System.clearProperty(KEY); + } + + @AfterEach + void restoreLogDirectoryProperty() { + if (originalProperty == null) { + System.clearProperty(KEY); + } else { + System.setProperty(KEY, originalProperty); + } + } + + @Test + void apply_creatableDirectory_setsPropertyCreatesDirAndReconfigures() { + RecordingReconfigurer reconf = new RecordingReconfigurer(); + Path target = tempDir.resolve("logs-sub"); + + LogDirectoryChange change = reconf.apply(target); + + assertTrue(change.changed()); + assertNotNull(change.appliedDirectory()); + assertTrue(Files.isDirectory(target), "Konfiguriertes Log-Verzeichnis muss angelegt worden sein"); + assertEquals(target.toAbsolutePath().toString(), System.getProperty(KEY)); + assertEquals(1, reconf.reconfigureCalls); + } + + @Test + void apply_nullOrBlank_doesNothing() { + RecordingReconfigurer reconf = new RecordingReconfigurer(); + + assertFalse(reconf.apply(null).changed()); + assertFalse(reconf.apply(Paths.get("")).changed()); + assertNull(System.getProperty(KEY)); + assertEquals(0, reconf.reconfigureCalls); + } + + @Test + void apply_alreadyActiveDirectory_isIdempotent() { + RecordingReconfigurer reconf = new RecordingReconfigurer(); + Path target = tempDir.resolve("logs"); + System.setProperty(KEY, target.toAbsolutePath().toString()); + + LogDirectoryChange change = reconf.apply(target); + + assertFalse(change.changed(), "Bereits aktives Verzeichnis darf keine Umstellung auslösen"); + assertEquals(0, reconf.reconfigureCalls); + } + + @Test + void apply_nonCreatableDirectory_leavesPropertyUnchanged() throws IOException { + RecordingReconfigurer reconf = new RecordingReconfigurer(); + Path blocker = tempDir.resolve("blocker.txt"); + Files.writeString(blocker, "x", StandardCharsets.UTF_8); + Path target = blocker.resolve("sub"); // Unterordner unterhalb einer Datei -> nicht anlegbar + + LogDirectoryChange change = reconf.apply(target); + + assertFalse(change.changed()); + assertNull(System.getProperty(KEY), "Bei Fehlschlag darf keine System-Property gesetzt werden"); + assertEquals(0, reconf.reconfigureCalls); + } +}