feat: KI-Rohantwort im Verlauf-Tab, echte Fehlermeldung, GUI-Log-Verzeichnis, Prompt-Härtung
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 <noreply@anthropic.com>
This commit is contained in:
+16
-2
@@ -25,7 +25,9 @@ public final class AiFailureMessageTranslator {
|
|||||||
* technische Fehlerbeschreibung.
|
* technische Fehlerbeschreibung.
|
||||||
* <p>
|
* <p>
|
||||||
* Ist {@code technicalMessage} {@code null} oder leer, wird der allgemeine
|
* 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
|
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
|
||||||
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
|
* @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 "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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+48
-2
@@ -63,7 +63,8 @@ import javafx.scene.layout.VBox;
|
|||||||
* <p>
|
* <p>
|
||||||
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
||||||
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
|
* 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).
|
* Das Suchfeld ist mit einem 300-ms-Debounce ausgestattet (Live-Filter).
|
||||||
*
|
*
|
||||||
* <h2>Layout</h2>
|
* <h2>Layout</h2>
|
||||||
@@ -101,6 +102,7 @@ public final class GuiHistoryTab {
|
|||||||
"Weitere Einträge vorhanden – Filter verwenden um die Trefferliste einzuschränken.";
|
"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 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_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 LOADING_TEXT = "Wird geladen …";
|
||||||
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
|
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<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
||||||
private final TextArea failureArea = new TextArea();
|
private final TextArea failureArea = new TextArea();
|
||||||
private final TextArea reasoningArea = 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 resetButton = new Button("Status zurücksetzen");
|
||||||
private final Button deleteButton = new Button("Eintrag löschen");
|
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)");
|
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
|
||||||
reasoningTitle.setStyle(BOLD_STYLE);
|
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,
|
VBox rightPane = new VBox(8,
|
||||||
detailTitle, detailGrid,
|
detailTitle, detailGrid,
|
||||||
attemptsTitle, attemptsTable,
|
attemptsTitle, attemptsTable,
|
||||||
failureTitle, failureArea,
|
failureTitle, failureArea,
|
||||||
reasoningTitle, reasoningArea);
|
reasoningTitle, reasoningArea,
|
||||||
|
rawResponseTitle, rawResponseArea);
|
||||||
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
||||||
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
||||||
|
|
||||||
@@ -913,15 +928,19 @@ public final class GuiHistoryTab {
|
|||||||
ProcessingAttempt last = attempts.get(attempts.size() - 1);
|
ProcessingAttempt last = attempts.get(attempts.size() - 1);
|
||||||
attemptsTable.getSelectionModel().select(last);
|
attemptsTable.getSelectionModel().select(last);
|
||||||
showReasoning(last);
|
showReasoning(last);
|
||||||
|
showRawResponse(last);
|
||||||
} else {
|
} else {
|
||||||
reasoningArea.setText("");
|
reasoningArea.setText("");
|
||||||
reasoningArea.setPromptText(NO_REASONING_TEXT);
|
reasoningArea.setPromptText(NO_REASONING_TEXT);
|
||||||
|
rawResponseArea.setText("");
|
||||||
|
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onAttemptSelected(ProcessingAttempt attempt) {
|
private void onAttemptSelected(ProcessingAttempt attempt) {
|
||||||
if (attempt != null) {
|
if (attempt != null) {
|
||||||
showReasoning(attempt);
|
showReasoning(attempt);
|
||||||
|
showRawResponse(attempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +973,11 @@ public final class GuiHistoryTab {
|
|||||||
failureArea.setText(failureMessage != null
|
failureArea.setText(failureMessage != null
|
||||||
? AiFailureMessageTranslator.translate(failureMessage) : "");
|
? AiFailureMessageTranslator.translate(failureMessage) : "");
|
||||||
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
|
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) {
|
private void showReasoning(ProcessingAttempt attempt) {
|
||||||
@@ -967,12 +991,34 @@ public final class GuiHistoryTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die vollständige KI-Rohantwort des ausgewählten Versuchs an.
|
||||||
|
* <p>
|
||||||
|
* 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() {
|
private void clearDetailPane() {
|
||||||
clearDetailFields();
|
clearDetailFields();
|
||||||
attemptsItems.clear();
|
attemptsItems.clear();
|
||||||
failureArea.setText("");
|
failureArea.setText("");
|
||||||
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
|
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
|
||||||
|
failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA));
|
||||||
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
rawResponseArea.setText("");
|
||||||
|
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearDetailFields() {
|
private void clearDetailFields() {
|
||||||
|
|||||||
+23
-2
@@ -164,9 +164,30 @@ class AiFailureMessageTranslatorTest {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unbekannterFehler_liefertFallback() {
|
void unbekannterFehler_haengtTechnischeMeldungAn() {
|
||||||
String result = AiFailureMessageTranslator.translate("some completely unknown error text");
|
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
|
@Test
|
||||||
|
|||||||
+5
-1
@@ -89,7 +89,11 @@ public final class DefaultPromptTemplate {
|
|||||||
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||||
- Eigennamen bleiben unverändert.
|
- Eigennamen bleiben unverändert.
|
||||||
- Umlaute und ß sind erlaubt.
|
- 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));
|
return template.replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength));
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1277,6 +1277,11 @@ public class BootstrapRunner {
|
|||||||
try {
|
try {
|
||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
StartConfiguration config = loadAndValidateConfiguration(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);
|
initializeSchema(config);
|
||||||
guiApplicationRunContext.set(Optional.of(
|
guiApplicationRunContext.set(Optional.of(
|
||||||
new ApplicationRunContext(config, resolveActiveJdbcUrl(config))));
|
new ApplicationRunContext(config, resolveActiveJdbcUrl(config))));
|
||||||
|
|||||||
+118
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* Hintergrund: Im GUI-Modus wird die Anwendung typischerweise <em>ohne</em> {@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).
|
||||||
|
* <p>
|
||||||
|
* Die Umstellung ist <strong>Best-Effort</strong>: Fehler (nicht anlegbares Verzeichnis,
|
||||||
|
* Reconfigure-Fehler) werden protokolliert und geschluckt; das bisherige Log-Verzeichnis
|
||||||
|
* bleibt in diesem Fall aktiv. Der Programmablauf wird nie unterbrochen.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Als eigene Methode ausgelegt, damit Tests die Log4j2-Neukonfiguration überschreiben können.
|
||||||
|
*/
|
||||||
|
protected void reconfigure() {
|
||||||
|
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
|
||||||
|
ctx.reconfigure();
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user