Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7a40298df | |||
| a3ed5329af | |||
| eb970d7c59 | |||
| 76daddbda2 | |||
| 3da2bcc561 |
+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 oder ein Pflichtfeld fehlt). "
|
||||||
|
+ "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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+78
-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,14 @@ 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();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 des zuletzt angeforderten Detail-Dokuments. Dient dazu, verspätet
|
||||||
|
* eintreffende Detail-Ergebnisse einer inzwischen abgewählten oder gewechselten
|
||||||
|
* Selektion zu verwerfen. Zugriff ausschließlich auf dem JavaFX Application Thread.
|
||||||
|
*/
|
||||||
|
private String pendingDetailFingerprintHex;
|
||||||
|
|
||||||
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 +462,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);
|
||||||
|
|
||||||
@@ -700,7 +722,10 @@ public final class GuiHistoryTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void loadDetails(DocumentFingerprint fingerprint) {
|
private void loadDetails(DocumentFingerprint fingerprint) {
|
||||||
|
pendingDetailFingerprintHex = fingerprint.sha256Hex();
|
||||||
reasoningArea.setText(LOADING_TEXT);
|
reasoningArea.setText(LOADING_TEXT);
|
||||||
|
rawResponseArea.setText("");
|
||||||
|
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
|
||||||
attemptsItems.clear();
|
attemptsItems.clear();
|
||||||
clearDetailFields();
|
clearDetailFields();
|
||||||
|
|
||||||
@@ -714,6 +739,9 @@ public final class GuiHistoryTab {
|
|||||||
try {
|
try {
|
||||||
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
|
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
|
if (isSupersededDetail(fingerprint)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (result.isEmpty()) {
|
if (result.isEmpty()) {
|
||||||
clearDetailPane();
|
clearDetailPane();
|
||||||
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
|
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
|
||||||
@@ -725,6 +753,10 @@ public final class GuiHistoryTab {
|
|||||||
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
|
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
|
||||||
fingerprint.sha256Hex(), ex.getMessage(), ex);
|
fingerprint.sha256Hex(), ex.getMessage(), ex);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
|
if (isSupersededDetail(fingerprint)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearDetailPane();
|
||||||
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
|
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
|
||||||
statusBarLabel.setText("Fehler beim Laden der Details.");
|
statusBarLabel.setText("Fehler beim Laden der Details.");
|
||||||
});
|
});
|
||||||
@@ -913,15 +945,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,6 +974,7 @@ public final class GuiHistoryTab {
|
|||||||
if (!failureRelevant || attempts.isEmpty()) {
|
if (!failureRelevant || attempts.isEmpty()) {
|
||||||
failureArea.setText("");
|
failureArea.setText("");
|
||||||
failureArea.setPromptText("Keine Fehlerdetails für diesen Status.");
|
failureArea.setPromptText("Keine Fehlerdetails für diesen Status.");
|
||||||
|
failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +991,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 +1009,46 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob ein asynchron geladenes Detail-Ergebnis inzwischen überholt ist, weil
|
||||||
|
* mittlerweile ein anderes (oder kein) Dokument ausgewählt wurde.
|
||||||
|
*
|
||||||
|
* @param fingerprint der Fingerprint, für den das Ergebnis geladen wurde
|
||||||
|
* @return {@code true}, wenn das Ergebnis verworfen werden soll
|
||||||
|
*/
|
||||||
|
private boolean isSupersededDetail(DocumentFingerprint fingerprint) {
|
||||||
|
return !fingerprint.sha256Hex().equals(pendingDetailFingerprintHex);
|
||||||
|
}
|
||||||
|
|
||||||
private void clearDetailPane() {
|
private void clearDetailPane() {
|
||||||
|
pendingDetailFingerprintHex = null;
|
||||||
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
|
||||||
|
|||||||
+4
-1
@@ -215,7 +215,10 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
|
|||||||
status, failure_class, failure_message, retryable,
|
status, failure_class, failure_message, retryable,
|
||||||
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
|
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
|
||||||
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
|
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
|
||||||
final_target_file_name
|
final_target_file_name,
|
||||||
|
input_tokens, output_tokens,
|
||||||
|
cache_creation_input_tokens, cache_read_input_tokens,
|
||||||
|
price_input_per_token_nano_usd, price_output_per_token_nano_usd
|
||||||
FROM processing_attempt
|
FROM processing_attempt
|
||||||
WHERE fingerprint = ?
|
WHERE fingerprint = ?
|
||||||
ORDER BY attempt_number ASC
|
ORDER BY attempt_number ASC
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link SqliteHistoryQueryAdapter#findAttemptsByFingerprint}.
|
||||||
|
*
|
||||||
|
* <p>Deckt insbesondere die Regression ab, dass der Historien-Leseadapter die
|
||||||
|
* Token- und Preis-Snapshot-Spalten mappen kann. Die zugehörige SELECT-Liste muss
|
||||||
|
* dieselben Spalten projizieren, die der Mapper liest – andernfalls schlägt jede
|
||||||
|
* Detailabfrage mit {@code no such column: 'input_tokens'} fehl.
|
||||||
|
*/
|
||||||
|
class SqliteHistoryQueryAdapterTest {
|
||||||
|
|
||||||
|
private String jdbcUrl;
|
||||||
|
private SqliteSchemaInitializationAdapter schemaAdapter;
|
||||||
|
private SqliteProcessingAttemptRepositoryAdapter repository;
|
||||||
|
private SqliteHistoryQueryAdapter historyQuery;
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Path dbFile = tempDir.resolve("history-query-test.db");
|
||||||
|
jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
|
||||||
|
schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
|
repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
|
||||||
|
historyQuery = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
|
schemaAdapter.initializeSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Versuch mit gesetzten Token- und Preis-Werten muss ohne Exception geladen und
|
||||||
|
* die Werte müssen korrekt zurückgemappt werden.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void findAttemptsByFingerprint_liestTokenUndPreisSpalten() {
|
||||||
|
DocumentFingerprint fp = fingerprint("aa");
|
||||||
|
insertDocumentRecord(fp);
|
||||||
|
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
ProcessingAttempt attempt = new ProcessingAttempt(
|
||||||
|
fp, new RunId("run-tokens"), 1, now, now.plusSeconds(1),
|
||||||
|
ProcessingStatus.PROPOSAL_READY,
|
||||||
|
null, null, false,
|
||||||
|
"claude",
|
||||||
|
"claude-haiku-4-5-20251001", "prompt-v1", 3, 4200,
|
||||||
|
"{\"title\":\"x\"}", "reasoning",
|
||||||
|
null, null, "Titel", "2026-07-01 - Titel.pdf",
|
||||||
|
1500L, 800L, 200L, 100L, 250L, 2000L);
|
||||||
|
repository.save(attempt);
|
||||||
|
|
||||||
|
List<ProcessingAttempt> loaded = historyQuery.findAttemptsByFingerprint(fp);
|
||||||
|
|
||||||
|
assertThat(loaded).hasSize(1);
|
||||||
|
ProcessingAttempt result = loaded.get(0);
|
||||||
|
assertThat(result.inputTokens()).isEqualTo(1500L);
|
||||||
|
assertThat(result.outputTokens()).isEqualTo(800L);
|
||||||
|
assertThat(result.cacheCreationInputTokens()).isEqualTo(200L);
|
||||||
|
assertThat(result.cacheReadInputTokens()).isEqualTo(100L);
|
||||||
|
assertThat(result.priceInputPerTokenNanoUsd()).isEqualTo(250L);
|
||||||
|
assertThat(result.priceOutputPerTokenNanoUsd()).isEqualTo(2000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Versuch ohne Token-/Preis-Werte (typisch für Skip-/Pre-Check-Pfade und
|
||||||
|
* historische Bestände) muss {@code null} für alle Token-Felder zurückliefern,
|
||||||
|
* ohne dass die Abfrage fehlschlägt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void findAttemptsByFingerprint_nullTokenWerteBleibenNull() {
|
||||||
|
DocumentFingerprint fp = fingerprint("bb");
|
||||||
|
insertDocumentRecord(fp);
|
||||||
|
|
||||||
|
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
|
||||||
|
fp, new RunId("run-null"), 1, now, now.plusSeconds(1),
|
||||||
|
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true);
|
||||||
|
repository.save(attempt);
|
||||||
|
|
||||||
|
List<ProcessingAttempt> loaded = historyQuery.findAttemptsByFingerprint(fp);
|
||||||
|
|
||||||
|
assertThat(loaded).hasSize(1);
|
||||||
|
ProcessingAttempt result = loaded.get(0);
|
||||||
|
assertThat(result.inputTokens()).isNull();
|
||||||
|
assertThat(result.outputTokens()).isNull();
|
||||||
|
assertThat(result.cacheCreationInputTokens()).isNull();
|
||||||
|
assertThat(result.cacheReadInputTokens()).isNull();
|
||||||
|
assertThat(result.priceInputPerTokenNanoUsd()).isNull();
|
||||||
|
assertThat(result.priceOutputPerTokenNanoUsd()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrere Versuche müssen aufsteigend nach {@code attempt_number} zurückgegeben werden.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void findAttemptsByFingerprint_sortiertAufsteigend() {
|
||||||
|
DocumentFingerprint fp = fingerprint("cc");
|
||||||
|
insertDocumentRecord(fp);
|
||||||
|
|
||||||
|
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||||
|
repository.save(ProcessingAttempt.withoutAiFields(
|
||||||
|
fp, new RunId("run-1"), 1, base, base.plusSeconds(1),
|
||||||
|
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true));
|
||||||
|
repository.save(new ProcessingAttempt(
|
||||||
|
fp, new RunId("run-2"), 2, base.plusSeconds(10), base.plusSeconds(11),
|
||||||
|
ProcessingStatus.PROPOSAL_READY,
|
||||||
|
null, null, false, "claude",
|
||||||
|
"claude-haiku-4-5-20251001", "prompt-v1", 1, 100,
|
||||||
|
"{}", "r", null, null, "T", "2026-07-01 - T.pdf",
|
||||||
|
10L, 20L, null, null, 250L, 2000L));
|
||||||
|
|
||||||
|
List<ProcessingAttempt> loaded = historyQuery.findAttemptsByFingerprint(fp);
|
||||||
|
|
||||||
|
assertThat(loaded).hasSize(2);
|
||||||
|
assertThat(loaded.get(0).attemptNumber()).isEqualTo(1);
|
||||||
|
assertThat(loaded.get(1).attemptNumber()).isEqualTo(2);
|
||||||
|
assertThat(loaded.get(1).inputTokens()).isEqualTo(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void insertDocumentRecord(DocumentFingerprint fp) {
|
||||||
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
|
PreparedStatement ps = conn.prepareStatement("""
|
||||||
|
INSERT INTO document_record
|
||||||
|
(fingerprint, last_known_source_locator, last_known_source_file_name,
|
||||||
|
overall_status, created_at, updated_at)
|
||||||
|
VALUES (?, '/tmp/test.pdf', 'test.pdf', 'READY_FOR_AI',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))""")) {
|
||||||
|
ps.setString(1, fp.sha256Hex());
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("Failed to insert test document record", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentFingerprint fingerprint(String suffix) {
|
||||||
|
return new DocumentFingerprint("0".repeat(64 - suffix.length()) + suffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-12
@@ -14,10 +14,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
|
|||||||
/**
|
/**
|
||||||
* Parses the raw AI response body into a structurally validated {@link ParsedAiResponse}.
|
* Parses the raw AI response body into a structurally validated {@link ParsedAiResponse}.
|
||||||
* <p>
|
* <p>
|
||||||
* This parser enforces the technical contract: the AI must respond with exactly one
|
* This parser enforces the technical contract: the AI must provide exactly one
|
||||||
* parseable JSON object containing the mandatory fields {@code title} and {@code reasoning},
|
* parseable JSON object containing the mandatory fields {@code title} and {@code reasoning},
|
||||||
* and an optional {@code date} field. Any extra free-text outside the JSON object makes
|
* and an optional {@code date} field. Surrounding free-text or Markdown code fences around
|
||||||
* the response technically invalid.
|
* that JSON object are tolerated; the object is extracted before parsing.
|
||||||
*
|
*
|
||||||
* <h2>Parsing rules</h2>
|
* <h2>Parsing rules</h2>
|
||||||
* <ul>
|
* <ul>
|
||||||
@@ -26,9 +26,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
|
|||||||
* <li>{@code reasoning} must be present (may be empty in degenerate cases, but must exist).</li>
|
* <li>{@code reasoning} must be present (may be empty in degenerate cases, but must exist).</li>
|
||||||
* <li>{@code date} is optional; if absent the field is modelled as an empty Optional.</li>
|
* <li>{@code date} is optional; if absent the field is modelled as an empty Optional.</li>
|
||||||
* <li>Additional JSON fields are tolerated and silently ignored.</li>
|
* <li>Additional JSON fields are tolerated and silently ignored.</li>
|
||||||
* <li>Any free-text outside the outermost JSON object makes the response technically
|
* <li>Leading or trailing free-text and Markdown code fences around the JSON object are
|
||||||
* unacceptable; this is detected by attempting to parse the trimmed body directly
|
* tolerated: the first balanced JSON object is extracted and parsed. A response that
|
||||||
* as a JSON object and rejecting inputs that are not pure JSON objects.</li>
|
* contains no JSON object, or where an array (or other non-object) appears at the top
|
||||||
|
* level, is rejected as {@code NOT_JSON_OBJECT}.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h2>Architecture boundary</h2>
|
* <h2>Architecture boundary</h2>
|
||||||
@@ -67,11 +68,11 @@ public final class AiResponseParser {
|
|||||||
return new AiResponseParsingFailure("EMPTY_RESPONSE", "AI response body is empty or blank");
|
return new AiResponseParsingFailure("EMPTY_RESPONSE", "AI response body is empty or blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
String trimmed = body.trim();
|
// Extract the JSON object from the response even when it is surrounded by free-text
|
||||||
|
// or Markdown code fences. Pure JSON-object responses are unaffected. A top-level
|
||||||
// Reject if the body does not start with '{' and end with '}' (i.e., not a pure JSON object).
|
// array (or other non-object structure) is still rejected.
|
||||||
// This catches responses that embed a JSON object within surrounding prose.
|
String jsonObject = extractJsonObject(body);
|
||||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
if (jsonObject == null) {
|
||||||
return new AiResponseParsingFailure(
|
return new AiResponseParsingFailure(
|
||||||
"NOT_JSON_OBJECT",
|
"NOT_JSON_OBJECT",
|
||||||
"AI response is not a pure JSON object (contains extra text or is not an object)");
|
"AI response is not a pure JSON object (contains extra text or is not an object)");
|
||||||
@@ -79,7 +80,7 @@ public final class AiResponseParser {
|
|||||||
|
|
||||||
JSONObject json;
|
JSONObject json;
|
||||||
try {
|
try {
|
||||||
json = new JSONObject(trimmed);
|
json = new JSONObject(jsonObject);
|
||||||
} catch (JSONException e) {
|
} catch (JSONException e) {
|
||||||
return new AiResponseParsingFailure("INVALID_JSON", "AI response is not valid JSON: " + e.getMessage());
|
return new AiResponseParsingFailure("INVALID_JSON", "AI response is not valid JSON: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -108,4 +109,74 @@ public final class AiResponseParser {
|
|||||||
ParsedAiResponse parsed = ParsedAiResponse.of(title, reasoning, dateString);
|
ParsedAiResponse parsed = ParsedAiResponse.of(title, reasoning, dateString);
|
||||||
return new AiResponseParsingSuccess(parsed);
|
return new AiResponseParsingSuccess(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the first complete JSON object from the response text.
|
||||||
|
* <p>
|
||||||
|
* Tolerates leading and trailing free-text as well as Markdown code fences
|
||||||
|
* (e.g. {@code ```json … ```}) by reading from the first {@code '{'} up to its matching
|
||||||
|
* {@code '}'}. Brace counting ignores curly braces inside JSON string literals and honours
|
||||||
|
* escape sequences.
|
||||||
|
* <p>
|
||||||
|
* If a {@code '['} appears at the top level – before the first {@code '{'} – the response is
|
||||||
|
* treated as an array (or other non-object structure) and {@code null} is returned.
|
||||||
|
*
|
||||||
|
* @param body the raw response body; must not be {@code null} or blank
|
||||||
|
* @return the substring containing the JSON object, or {@code null} if no complete object was found
|
||||||
|
*/
|
||||||
|
private static String extractJsonObject(String body) {
|
||||||
|
int firstBrace = body.indexOf('{');
|
||||||
|
if (firstBrace < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int firstBracket = body.indexOf('[');
|
||||||
|
if (firstBracket >= 0 && firstBracket < firstBrace) {
|
||||||
|
// Array or other non-object structure at the top level.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return balancedObject(body, firstBrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the balanced JSON object starting at {@code start} (the index of an opening {@code '{'}).
|
||||||
|
* <p>
|
||||||
|
* Counts curly braces while respecting string literals and escape sequences, and returns the
|
||||||
|
* substring from the opening {@code '{'} up to and including its matching {@code '}'}.
|
||||||
|
*
|
||||||
|
* @param body the text to scan
|
||||||
|
* @param start the index of the opening {@code '{'}
|
||||||
|
* @return the object substring including both braces, or {@code null} if no balanced object was found
|
||||||
|
*/
|
||||||
|
private static String balancedObject(String body, int start) {
|
||||||
|
boolean inString = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = start; i < body.length(); i++) {
|
||||||
|
char c = body.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (c == '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (c) {
|
||||||
|
case '"' -> inString = true;
|
||||||
|
case '{' -> depth++;
|
||||||
|
case '}' -> {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
return body.substring(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
// ordinary character outside a string – ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-23
@@ -41,7 +41,9 @@ import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
|||||||
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
|
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
|
||||||
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
|
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
|
||||||
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
|
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
|
||||||
* Vergleich case-insensitive.
|
* Vergleich case-insensitive. Ist aktuell keine aktive Datenbank in Betrieb (der
|
||||||
|
* {@link ActiveDatabasePathSupplier} liefert {@code null}), entfällt die Prüfung und die
|
||||||
|
* Anlage fährt fort – es gibt dann keine in Benutzung befindliche Datei zu schützen.
|
||||||
*/
|
*/
|
||||||
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
|
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
|
||||||
|
|
||||||
@@ -66,7 +68,9 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
|
|||||||
/**
|
/**
|
||||||
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
|
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
|
||||||
*
|
*
|
||||||
* @return den absoluten Pfad der aktiven Datei; nie {@code null}
|
* @return den absoluten Pfad der aktiven Datei oder {@code null}, wenn aktuell keine
|
||||||
|
* aktive Datenbank in Betrieb ist. Bei {@code null} entfällt die
|
||||||
|
* Selbst-Überschreib-Prüfung und die Anlage darf fortfahren.
|
||||||
*/
|
*/
|
||||||
Path get();
|
Path get();
|
||||||
}
|
}
|
||||||
@@ -95,6 +99,9 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestriert den vollständigen Anlage- und Wechselvorgang.
|
* Orchestriert den vollständigen Anlage- und Wechselvorgang.
|
||||||
|
* <p>
|
||||||
|
* Liefert der {@link ActiveDatabasePathSupplier} {@code null} (keine aktive Datenbank in
|
||||||
|
* Betrieb), entfällt die Selbst-Überschreib-Prüfung und die Anlage fährt fort.
|
||||||
*
|
*
|
||||||
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
|
||||||
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
@@ -106,29 +113,29 @@ public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase
|
|||||||
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
|
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
|
||||||
|
|
||||||
// Schritt 1: Pfad-Sicherheitsprüfung
|
// Schritt 1: Pfad-Sicherheitsprüfung
|
||||||
|
// Ohne aktive Datenbank (Supplier liefert null) gibt es nichts zu schützen; die
|
||||||
|
// Selbst-Überschreib-Prüfung entfällt und die Anlage darf fortfahren.
|
||||||
Path activeDb = activeDatabasePathSupplier.get();
|
Path activeDb = activeDatabasePathSupplier.get();
|
||||||
if (activeDb == null) {
|
if (activeDb == null) {
|
||||||
LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar – Anlage abgebrochen.");
|
LOG.info("Keine aktive Datenbank in Betrieb – Selbst-Überschreib-Prüfung entfällt, "
|
||||||
return new CreateNewDatabaseResult.CreationFailed(
|
+ "Anlage fährt fort.");
|
||||||
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
} else {
|
||||||
"Aktiver Datenbankpfad konnte nicht ermittelt werden.",
|
Path absoluteActive = activeDb.toAbsolutePath().normalize();
|
||||||
null);
|
boolean sameFile;
|
||||||
}
|
try {
|
||||||
Path absoluteActive = activeDb.toAbsolutePath().normalize();
|
sameFile = isSameFile(absoluteActive, absoluteTarget);
|
||||||
boolean sameFile;
|
} catch (IOException e) {
|
||||||
try {
|
LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
sameFile = isSameFile(absoluteActive, absoluteTarget);
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
} catch (IOException e) {
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
|
"Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
|
||||||
return new CreateNewDatabaseResult.CreationFailed(
|
e);
|
||||||
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
}
|
||||||
"Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
|
if (sameFile) {
|
||||||
e);
|
LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
|
||||||
}
|
absoluteTarget);
|
||||||
if (sameFile) {
|
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
|
||||||
LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
|
}
|
||||||
absoluteTarget);
|
|
||||||
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
|
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
|
||||||
|
|||||||
+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));
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-13
@@ -15,7 +15,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
|
|||||||
* Unit tests for {@link AiResponseParser}.
|
* Unit tests for {@link AiResponseParser}.
|
||||||
* <p>
|
* <p>
|
||||||
* Covers structural parsing rules: valid JSON objects, mandatory fields,
|
* Covers structural parsing rules: valid JSON objects, mandatory fields,
|
||||||
* optional date, extra fields, and rejection of non-JSON or mixed-content responses.
|
* optional date, extra fields, extraction of JSON objects embedded in surrounding
|
||||||
|
* text or Markdown code fences, and rejection of non-JSON or non-object responses.
|
||||||
*/
|
*/
|
||||||
class AiResponseParserTest {
|
class AiResponseParserTest {
|
||||||
|
|
||||||
@@ -96,6 +97,81 @@ class AiResponseParserTest {
|
|||||||
assertThat(parsed.dateString()).isEmpty();
|
assertThat(parsed.dateString()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tolerance: JSON object embedded in surrounding text / Markdown fences
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_jsonWrappedInMarkdownFence_extractsObject() {
|
||||||
|
AiRawResponse raw = new AiRawResponse(
|
||||||
|
"```json\n{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\"}\n```");
|
||||||
|
|
||||||
|
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
|
||||||
|
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
|
||||||
|
assertThat(parsed.title()).isEqualTo("Rechnung");
|
||||||
|
assertThat(parsed.reasoning()).isEqualTo("Invoice");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_jsonWithLeadingProse_extractsObjectIncludingDate() {
|
||||||
|
AiRawResponse raw = new AiRawResponse(
|
||||||
|
"Hier ist das Ergebnis:\n{\"title\":\"Stromabrechnung\",\"reasoning\":\"r\",\"date\":\"2026-01-15\"}");
|
||||||
|
|
||||||
|
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
|
||||||
|
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
|
||||||
|
assertThat(parsed.title()).isEqualTo("Stromabrechnung");
|
||||||
|
assertThat(parsed.dateString()).contains("2026-01-15");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_jsonWithTrailingProse_extractsObject() {
|
||||||
|
AiRawResponse raw = new AiRawResponse(
|
||||||
|
"{\"title\":\"Vertrag\",\"reasoning\":\"r\"}\n\nHope that helps!");
|
||||||
|
|
||||||
|
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
|
||||||
|
assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("Vertrag");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_bracketInsideStringValue_isNotTreatedAsArray() {
|
||||||
|
AiRawResponse raw = new AiRawResponse(
|
||||||
|
"{\"title\":\"Rechnung [Entwurf]\",\"reasoning\":\"contains a [ bracket\"}");
|
||||||
|
|
||||||
|
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
|
||||||
|
assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("Rechnung [Entwurf]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_nestedObject_bracesAreBalancedCorrectly() {
|
||||||
|
AiRawResponse raw = new AiRawResponse(
|
||||||
|
"prefix {\"title\":\"X\",\"reasoning\":\"r\",\"meta\":{\"a\":1,\"b\":{\"c\":2}}} suffix");
|
||||||
|
|
||||||
|
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
|
||||||
|
assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("X");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parse_unbalancedTruncatedObject_returnsFailure() {
|
||||||
|
AiRawResponse raw = new AiRawResponse(
|
||||||
|
"{\"title\":\"X\",\"reasoning\":\"r\"");
|
||||||
|
|
||||||
|
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
|
||||||
|
assertThat(((AiResponseParsingFailure) result).failureReason())
|
||||||
|
.isEqualTo("NOT_JSON_OBJECT");
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Failure cases – structural
|
// Failure cases – structural
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -131,18 +207,6 @@ class AiResponseParserTest {
|
|||||||
.isEqualTo("NOT_JSON_OBJECT");
|
.isEqualTo("NOT_JSON_OBJECT");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void parse_jsonEmbeddedInProse_returnsFailure() {
|
|
||||||
AiRawResponse raw = new AiRawResponse(
|
|
||||||
"Here is the result: {\"title\":\"Rechnung\",\"reasoning\":\"r\"} Hope that helps!");
|
|
||||||
|
|
||||||
AiResponseParsingResult result = AiResponseParser.parse(raw);
|
|
||||||
|
|
||||||
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
|
|
||||||
assertThat(((AiResponseParsingFailure) result).failureReason())
|
|
||||||
.isEqualTo("NOT_JSON_OBJECT");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parse_jsonArray_returnsFailure() {
|
void parse_jsonArray_returnsFailure() {
|
||||||
AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");
|
AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");
|
||||||
|
|||||||
+57
@@ -168,6 +168,63 @@ class DefaultCreateNewDatabaseUseCaseTest {
|
|||||||
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
|
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldProceed_whenNoActiveDatabase(@TempDir Path tempDir) throws IOException {
|
||||||
|
// Szenario: GUI ohne geladene Konfiguration und ohne Override → Supplier liefert null.
|
||||||
|
// Es gibt keine aktive DB zu schützen; die Anlage muss durchlaufen.
|
||||||
|
Path target = tempDir.resolve("neu.sqlite");
|
||||||
|
|
||||||
|
AtomicReference<Path> capturedTemp = new AtomicReference<>();
|
||||||
|
DatabaseCreationPort creation = tempFile -> {
|
||||||
|
try {
|
||||||
|
Files.writeString(tempFile, "migrated-content");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
capturedTemp.set(tempFile);
|
||||||
|
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
|
||||||
|
};
|
||||||
|
AtomicReference<Path> contextOverride = new AtomicReference<>();
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
creation, trackingActiveContext(contextOverride), () -> null);
|
||||||
|
|
||||||
|
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
|
||||||
|
assertThat(Files.exists(target)).isTrue();
|
||||||
|
assertThat(Files.readString(target)).isEqualTo("migrated-content");
|
||||||
|
assertThat(capturedTemp.get()).isNotNull();
|
||||||
|
assertThat(Files.exists(capturedTemp.get())).isFalse();
|
||||||
|
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldOverwriteExistingTarget_whenNoActiveDatabase(@TempDir Path tempDir)
|
||||||
|
throws IOException {
|
||||||
|
// Kein aktiver DB-Kontext (Supplier liefert null), aber Zieldatei existiert bereits →
|
||||||
|
// Überschreiben ist erlaubt, weil keine in Benutzung befindliche DB betroffen ist.
|
||||||
|
Path target = tempDir.resolve("existing.sqlite");
|
||||||
|
Files.writeString(target, "alter-inhalt");
|
||||||
|
|
||||||
|
DatabaseCreationPort creation = tempFile -> {
|
||||||
|
try {
|
||||||
|
Files.writeString(tempFile, "neu-und-leer");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
|
||||||
|
};
|
||||||
|
AtomicReference<Path> contextOverride = new AtomicReference<>();
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
creation, trackingActiveContext(contextOverride), () -> null);
|
||||||
|
|
||||||
|
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
|
||||||
|
assertThat(Files.readString(target)).isEqualTo("neu-und-leer");
|
||||||
|
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException {
|
void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException {
|
||||||
Path a = tempDir.resolve("data.sqlite");
|
Path a = tempDir.resolve("data.sqlite");
|
||||||
|
|||||||
+14
-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))));
|
||||||
@@ -2042,6 +2047,9 @@ public class BootstrapRunner {
|
|||||||
* {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
|
* {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
|
||||||
* DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
|
* DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
|
||||||
* {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
|
* {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
|
||||||
|
* Existiert weder ein Override noch eine ladbare Konfiguration, gibt es keine aktive
|
||||||
|
* Datenbank: die Lambda liefert dann {@code null}, wodurch der Use-Case die
|
||||||
|
* Selbst-Überschreib-Prüfung überspringt und die Anlage zulässt.
|
||||||
* <p>
|
* <p>
|
||||||
* Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
|
* Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
|
||||||
* Anlage, Migration und Verbindungstest aus.
|
* Anlage, Migration und Verbindungstest aus.
|
||||||
@@ -2061,9 +2069,10 @@ public class BootstrapRunner {
|
|||||||
return override.get();
|
return override.get();
|
||||||
}
|
}
|
||||||
if (configFilePath == null || !Files.exists(configFilePath)) {
|
if (configFilePath == null || !Files.exists(configFilePath)) {
|
||||||
// Ohne Konfiguration und ohne Override: Zielpfad selbst als „aktiv" werten,
|
// Ohne Override und ohne geladene Konfiguration existiert keine aktive
|
||||||
// damit die Pfadprüfung nur das Selbst-Überschreiben verhindert.
|
// Datenbank. null signalisiert dem Use-Case: keine Selbst-Überschreib-Prüfung
|
||||||
return targetFile.toAbsolutePath().normalize();
|
// nötig, die Anlage darf fortfahren.
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
@@ -2071,9 +2080,9 @@ public class BootstrapRunner {
|
|||||||
return config.sqliteFile().toAbsolutePath().normalize();
|
return config.sqliteFile().toAbsolutePath().normalize();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — "
|
LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — "
|
||||||
+ "Zielpfad wird stattdessen als aktiv angenommen.",
|
+ "es wird keine aktive DB angenommen.",
|
||||||
configFilePath, e.getMessage());
|
configFilePath, e.getMessage());
|
||||||
return targetFile.toAbsolutePath().normalize();
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
|||||||
+30
-1
@@ -43,9 +43,11 @@ public class PdfUmbenennerApplication {
|
|||||||
* @param args command-line arguments; see class JavaDoc for supported options
|
* @param args command-line arguments; see class JavaDoc for supported options
|
||||||
*/
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
EarlyLogDirectoryInitializer.applyFromArgs(args);
|
EarlyLogDirectoryInitializer.LogDirectorySetupResult logSetup =
|
||||||
|
EarlyLogDirectoryInitializer.applyFromArgs(args);
|
||||||
Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class);
|
Logger LOG = LogManager.getLogger(PdfUmbenennerApplication.class);
|
||||||
LOG.info("Starting PDF Umbenenner application...");
|
LOG.info("Starting PDF Umbenenner application...");
|
||||||
|
logEffectiveLogDirectory(LOG, logSetup);
|
||||||
try {
|
try {
|
||||||
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
|
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
|
||||||
|
|
||||||
@@ -74,4 +76,31 @@ public class PdfUmbenennerApplication {
|
|||||||
System.exit(1);
|
System.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokolliert nach der Log4j2-Initialisierung sichtbar, welches Log-Verzeichnis
|
||||||
|
* effektiv genutzt wird.
|
||||||
|
* <p>
|
||||||
|
* Musste die Anwendung wegen eines nicht anlegbaren konfigurierten Verzeichnisses auf
|
||||||
|
* das Standard-Log-Verzeichnis ausweichen, wird dies als Warnung mit konfiguriertem und
|
||||||
|
* tatsächlichem Pfad ausgegeben, damit der frühere stille Fehlschlag nachvollziehbar ist.
|
||||||
|
* Diese erste Logzeile landet im effektiv genutzten Log und macht den Speicherort auffindbar.
|
||||||
|
*
|
||||||
|
* @param log der bereits initialisierte Logger; darf nicht {@code null} sein
|
||||||
|
* @param logSetup das Ergebnis der frühen Log-Verzeichnis-Auflösung; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private static void logEffectiveLogDirectory(Logger log,
|
||||||
|
EarlyLogDirectoryInitializer.LogDirectorySetupResult logSetup) {
|
||||||
|
String fallbackDirectory = System.getProperty("user.home") + "/pdf-umbenenner/logs";
|
||||||
|
if (logSetup.fallbackUsed()) {
|
||||||
|
log.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht angelegt oder genutzt werden – "
|
||||||
|
+ "Ausweichen auf Standard-Log-Verzeichnis '{}'.",
|
||||||
|
logSetup.configuredValue(), fallbackDirectory);
|
||||||
|
} else if (logSetup.appliedDirectory() != null) {
|
||||||
|
log.info("Effektives Log-Verzeichnis: {}", logSetup.appliedDirectory());
|
||||||
|
} else {
|
||||||
|
log.info("Kein Log-Verzeichnis konfiguriert – Standard-Log-Verzeichnis '{}' wird genutzt.",
|
||||||
|
fallbackDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-21
@@ -1,7 +1,8 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.StringReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
@@ -19,9 +20,22 @@ import java.util.Properties;
|
|||||||
* Default-Verzeichnis zurück, damit auch im MSI-Betrieb (Arbeitsverzeichnis unter
|
* Default-Verzeichnis zurück, damit auch im MSI-Betrieb (Arbeitsverzeichnis unter
|
||||||
* {@code Program Files}, typischerweise nicht beschreibbar) eine Log-Datei entsteht.
|
* {@code Program Files}, typischerweise nicht beschreibbar) eine Log-Datei entsteht.
|
||||||
* <p>
|
* <p>
|
||||||
* Diese Klasse vermeidet bewusst jede Logger-Nutzung und schluckt sämtliche Fehler:
|
* <strong>Backslash-Behandlung:</strong> Wie der reguläre Konfigurationsleser werden
|
||||||
* Sie soll niemals den Programmstart verhindern, sondern lediglich einen frühen
|
* Backslashes vor dem {@link Properties}-Laden verdoppelt, damit Windows-Pfade wie
|
||||||
* Best-Effort-Hinweis an Log4j2 liefern.
|
* {@code C:\Users\...\logs} nicht durch die Escape-Interpretation von
|
||||||
|
* {@link Properties} verstümmelt werden. Der aufgelöste Pfad wird zusätzlich auf
|
||||||
|
* Forward-Slashes normalisiert.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verzeichnisanlage:</strong> Das konfigurierte Log-Verzeichnis wird per
|
||||||
|
* Best-Effort mit {@link Files#createDirectories(Path, java.nio.file.attribute.FileAttribute[])}
|
||||||
|
* angelegt. Gelingt das nicht (z. B. fehlende Rechte oder ungültiger Pfad), bleibt die
|
||||||
|
* System-Property ungesetzt, sodass der in {@code log4j2.xml} hinterlegte, garantiert
|
||||||
|
* beschreibbare Fallback greift. Der Start wird niemals verhindert.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse vermeidet bewusst jede Logger-Nutzung (sie läuft vor der Log4j2-Init).
|
||||||
|
* Das Ergebnis wird als {@link LogDirectorySetupResult} zurückgegeben, damit der Aufrufer
|
||||||
|
* nach erfolgter Log4j2-Initialisierung eine sichtbare Meldung über das effektive
|
||||||
|
* Log-Verzeichnis bzw. einen genutzten Fallback protokollieren kann.
|
||||||
*/
|
*/
|
||||||
public final class EarlyLogDirectoryInitializer {
|
public final class EarlyLogDirectoryInitializer {
|
||||||
|
|
||||||
@@ -34,28 +48,82 @@ public final class EarlyLogDirectoryInitializer {
|
|||||||
// utility
|
// utility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis der frühen Log-Verzeichnis-Auflösung.
|
||||||
|
* <p>
|
||||||
|
* Dient ausschließlich der nachgelagerten, sichtbaren Protokollierung nach der
|
||||||
|
* Log4j2-Initialisierung. Er trifft keine weitere Entscheidung.
|
||||||
|
*
|
||||||
|
* @param configuredValue der aus der Konfiguration aufgelöste, normalisierte Wert von
|
||||||
|
* {@code log.directory}, oder {@code null}, wenn keiner ermittelt
|
||||||
|
* werden konnte
|
||||||
|
* @param appliedDirectory das tatsächlich als System-Property gesetzte und angelegte
|
||||||
|
* Verzeichnis, oder {@code null}, wenn kein konfiguriertes
|
||||||
|
* Verzeichnis angewandt wurde (Fallback greift)
|
||||||
|
* @param fallbackUsed {@code true}, wenn ein konfiguriertes Log-Verzeichnis vorlag,
|
||||||
|
* aber nicht angelegt/genutzt werden konnte und deshalb auf das
|
||||||
|
* Standard-Log-Verzeichnis ausgewichen wird
|
||||||
|
*/
|
||||||
|
public record LogDirectorySetupResult(String configuredValue, String appliedDirectory,
|
||||||
|
boolean fallbackUsed) {
|
||||||
|
|
||||||
|
private static LogDirectorySetupResult notConfigured() {
|
||||||
|
return new LogDirectorySetupResult(null, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogDirectorySetupResult applied(String configuredValue, String appliedDirectory) {
|
||||||
|
return new LogDirectorySetupResult(configuredValue, appliedDirectory, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogDirectorySetupResult fallback(String configuredValue) {
|
||||||
|
return new LogDirectorySetupResult(configuredValue, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Versucht, aus der aktiven Konfigurationsdatei den Wert von {@code log.directory}
|
* Versucht, aus der aktiven Konfigurationsdatei den Wert von {@code log.directory}
|
||||||
* zu lesen, und setzt ihn als System-Property, sofern er ein nicht-leerer String ist.
|
* zu lesen, das Verzeichnis anzulegen und den Pfad als System-Property zu setzen.
|
||||||
* <p>
|
* <p>
|
||||||
* Greift {@code --config <pfad>} auf, ansonsten {@code config/application.properties}
|
* Greift {@code --config <pfad>} auf, ansonsten {@code config/application.properties}
|
||||||
* relativ zum Arbeitsverzeichnis. Ist kein Wert ableitbar, bleibt die System-Property
|
* relativ zum Arbeitsverzeichnis. Ist bereits eine System-Property gesetzt, wird sie
|
||||||
* unverändert; in diesem Fall greift der in {@code log4j2.xml} hinterlegte Fallback.
|
* respektiert. Kann kein Wert ermittelt oder das Verzeichnis nicht angelegt werden,
|
||||||
|
* bleibt die System-Property ungesetzt; in diesem Fall greift der in {@code log4j2.xml}
|
||||||
|
* hinterlegte Fallback.
|
||||||
*
|
*
|
||||||
* @param args Kommandozeilenargumente, dürfen {@code null} sein
|
* @param args Kommandozeilenargumente, dürfen {@code null} sein
|
||||||
|
* @return das Ergebnis der Auflösung für die nachgelagerte, sichtbare Protokollierung;
|
||||||
|
* nie {@code null}
|
||||||
*/
|
*/
|
||||||
public static void applyFromArgs(String[] args) {
|
public static LogDirectorySetupResult applyFromArgs(String[] args) {
|
||||||
|
if (isLogPropertyAlreadySet()) {
|
||||||
|
return LogDirectorySetupResult.applied(null, System.getProperty(SYSTEM_PROPERTY_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
String configuredValue = null;
|
||||||
try {
|
try {
|
||||||
if (isLogPropertyAlreadySet()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Path configPath = resolveConfigPath(args);
|
Path configPath = resolveConfigPath(args);
|
||||||
if (configPath == null || !Files.isRegularFile(configPath)) {
|
if (configPath == null || !Files.isRegularFile(configPath)) {
|
||||||
return;
|
return LogDirectorySetupResult.notConfigured();
|
||||||
}
|
}
|
||||||
applyLogDirectoryFromConfig(configPath);
|
configuredValue = readLogDirectoryValue(configPath);
|
||||||
} catch (IOException | RuntimeException ignored) {
|
if (configuredValue == null || configuredValue.isBlank()) {
|
||||||
// bewusst still: Log4j2-Fallback aus log4j2.xml übernimmt ansonsten
|
return LogDirectorySetupResult.notConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path directory = Paths.get(configuredValue);
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
String applied = directory.toString();
|
||||||
|
System.setProperty(SYSTEM_PROPERTY_KEY, applied);
|
||||||
|
return LogDirectorySetupResult.applied(configuredValue, applied);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
// Bewusst still: kein Logger verfügbar. Bei bekanntem, aber nicht nutzbarem
|
||||||
|
// konfiguriertem Wert wird ein Fallback signalisiert, damit der Aufrufer nach
|
||||||
|
// der Log4j2-Init eine sichtbare Warnung ausgeben kann. Die System-Property
|
||||||
|
// bleibt ungesetzt, sodass der log4j2.xml-Fallback greift.
|
||||||
|
if (configuredValue != null && !configuredValue.isBlank()) {
|
||||||
|
return LogDirectorySetupResult.fallback(configuredValue);
|
||||||
|
}
|
||||||
|
return LogDirectorySetupResult.notConfigured();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,15 +132,26 @@ public final class EarlyLogDirectoryInitializer {
|
|||||||
return val != null && !val.isBlank();
|
return val != null && !val.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void applyLogDirectoryFromConfig(Path configPath) throws IOException {
|
/**
|
||||||
|
* Liest den Wert von {@code log.directory} aus der Konfigurationsdatei, verdoppelt zuvor
|
||||||
|
* Backslashes (analog zum regulären Konfigurationsleser) und normalisiert den Pfad auf
|
||||||
|
* Forward-Slashes.
|
||||||
|
*
|
||||||
|
* @param configPath Pfad zur Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @return der normalisierte Wert, oder {@code null}, wenn nicht vorhanden oder leer
|
||||||
|
* @throws IOException wenn die Datei nicht gelesen werden kann
|
||||||
|
*/
|
||||||
|
private static String readLogDirectoryValue(Path configPath) throws IOException {
|
||||||
|
String content = Files.readString(configPath, StandardCharsets.UTF_8);
|
||||||
|
// Backslashes verdoppeln, sonst interpretiert Properties z. B. \U/\D/\t als Escape.
|
||||||
|
String escapedContent = content.replace("\\", "\\\\");
|
||||||
Properties properties = new Properties();
|
Properties properties = new Properties();
|
||||||
try (InputStream in = Files.newInputStream(configPath)) {
|
properties.load(new StringReader(escapedContent));
|
||||||
properties.load(in);
|
|
||||||
}
|
|
||||||
String value = properties.getProperty(CONFIG_PROPERTY_KEY);
|
String value = properties.getProperty(CONFIG_PROPERTY_KEY);
|
||||||
if (value != null && !value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
System.setProperty(SYSTEM_PROPERTY_KEY, value.trim());
|
return null;
|
||||||
}
|
}
|
||||||
|
return value.trim().replace('\\', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Path resolveConfigPath(String[] args) {
|
private static Path resolveConfigPath(String[] args) {
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
String previous = System.getProperty(SYSTEM_PROPERTY_KEY);
|
||||||
|
System.setProperty(SYSTEM_PROPERTY_KEY, target);
|
||||||
|
try {
|
||||||
|
reconfigure();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Property auf den vorherigen Wert zurücksetzen: Bleibt sie auf dem neuen (nicht
|
||||||
|
// aktivierten) Ziel stehen, würde die Idempotenz-Prüfung oben jeden weiteren
|
||||||
|
// Umstellungsversuch fälschlich als „bereits aktiv" überspringen.
|
||||||
|
restoreProperty(previous);
|
||||||
|
LOG.warn("Log4j2 konnte nicht auf Log-Verzeichnis '{}' umgestellt werden: {} – "
|
||||||
|
+ "bisheriges Log-Verzeichnis bleibt aktiv.", target, e.getMessage());
|
||||||
|
return LogDirectoryChange.unchanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Log-Verzeichnis zur Laufzeit auf das konfigurierte Verzeichnis umgestellt: {}", target);
|
||||||
|
return LogDirectoryChange.changedTo(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void restoreProperty(String previous) {
|
||||||
|
if (previous == null) {
|
||||||
|
System.clearProperty(SYSTEM_PROPERTY_KEY);
|
||||||
|
} else {
|
||||||
|
System.setProperty(SYSTEM_PROPERTY_KEY, previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
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.condition.EnabledOnOs;
|
||||||
|
import org.junit.jupiter.api.condition.OS;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.EarlyLogDirectoryInitializer.LogDirectorySetupResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link EarlyLogDirectoryInitializer}.
|
||||||
|
*
|
||||||
|
* <p>Deckt insbesondere die Regression ab, dass Windows-Pfade mit Backslashes nicht durch
|
||||||
|
* die Escape-Interpretation von {@link java.util.Properties} verstümmelt werden, sowie das
|
||||||
|
* Best-Effort-Anlegen des Verzeichnisses und den sichtbaren Fallback, wenn das konfigurierte
|
||||||
|
* Verzeichnis nicht nutzbar ist.
|
||||||
|
*
|
||||||
|
* <p>Die System-Property {@code log.directory} ist globaler Prozesszustand; sie wird pro Test
|
||||||
|
* gesichert, geleert und anschließend wiederhergestellt.
|
||||||
|
*/
|
||||||
|
class EarlyLogDirectoryInitializerTest {
|
||||||
|
|
||||||
|
private static final String KEY = "log.directory";
|
||||||
|
|
||||||
|
private String originalProperty;
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clearLogDirectoryProperty() {
|
||||||
|
originalProperty = System.getProperty(KEY);
|
||||||
|
System.clearProperty(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void restoreLogDirectoryProperty() {
|
||||||
|
if (originalProperty == null) {
|
||||||
|
System.clearProperty(KEY);
|
||||||
|
} else {
|
||||||
|
System.setProperty(KEY, originalProperty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: Ein Windows-Pfad mit einfachen Backslashes darf beim frühen Einlesen nicht
|
||||||
|
* verstümmelt werden; das Verzeichnis muss angelegt und exakt referenziert werden.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@EnabledOnOs(OS.WINDOWS)
|
||||||
|
void windowsBackslashPfad_wirdNichtVerstuemmeltUndAngelegt() throws IOException {
|
||||||
|
Path expected = tempDir.resolve("winlogs");
|
||||||
|
String backslashValue = expected.toString().replace('/', '\\');
|
||||||
|
Path config = writeConfig("log.directory=" + backslashValue);
|
||||||
|
|
||||||
|
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
|
||||||
|
new String[]{"--config", config.toString()});
|
||||||
|
|
||||||
|
assertFalse(result.fallbackUsed(), "Ein anlegbares Verzeichnis darf keinen Fallback auslösen");
|
||||||
|
assertTrue(Files.isDirectory(expected), "Konfiguriertes Log-Verzeichnis muss angelegt worden sein");
|
||||||
|
String property = System.getProperty(KEY);
|
||||||
|
assertNotNull(property, "System-Property log.directory muss gesetzt sein");
|
||||||
|
assertTrue(Files.isSameFile(expected, Paths.get(property)),
|
||||||
|
"System-Property muss auf das konfigurierte Verzeichnis zeigen, nicht auf einen verstümmelten Pfad");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein noch nicht existentes, aber anlegbares Verzeichnis wird angelegt und als
|
||||||
|
* System-Property gesetzt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anlegbaresVerzeichnis_wirdAngelegtUndGesetzt() throws IOException {
|
||||||
|
Path expected = tempDir.resolve("logs-sub");
|
||||||
|
String value = expected.toString().replace('\\', '/');
|
||||||
|
Path config = writeConfig("log.directory=" + value);
|
||||||
|
|
||||||
|
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
|
||||||
|
new String[]{"--config", config.toString()});
|
||||||
|
|
||||||
|
assertFalse(result.fallbackUsed());
|
||||||
|
assertTrue(Files.isDirectory(expected));
|
||||||
|
String property = System.getProperty(KEY);
|
||||||
|
assertNotNull(property);
|
||||||
|
assertTrue(Files.isSameFile(expected, Paths.get(property)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein nicht anlegbares Verzeichnis (Unterverzeichnis unterhalb einer regulären Datei)
|
||||||
|
* darf keine System-Property setzen und muss den Fallback signalisieren.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nichtAnlegbaresVerzeichnis_signalisiertFallbackOhneProperty() throws IOException {
|
||||||
|
Path blockingFile = tempDir.resolve("blocker.txt");
|
||||||
|
Files.writeString(blockingFile, "x", StandardCharsets.UTF_8);
|
||||||
|
String value = blockingFile.toString().replace('\\', '/') + "/sub";
|
||||||
|
Path config = writeConfig("log.directory=" + value);
|
||||||
|
|
||||||
|
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
|
||||||
|
new String[]{"--config", config.toString()});
|
||||||
|
|
||||||
|
assertTrue(result.fallbackUsed(), "Nicht anlegbares Verzeichnis muss Fallback signalisieren");
|
||||||
|
assertNull(System.getProperty(KEY), "Bei Fallback darf keine System-Property gesetzt sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist {@code log.directory} nicht konfiguriert, bleibt die System-Property ungesetzt und es
|
||||||
|
* wird kein Fallback signalisiert.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void keinLogVerzeichnis_setztKeineProperty() throws IOException {
|
||||||
|
Path config = writeConfig("source.folder=/tmp");
|
||||||
|
|
||||||
|
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
|
||||||
|
new String[]{"--config", config.toString()});
|
||||||
|
|
||||||
|
assertFalse(result.fallbackUsed());
|
||||||
|
assertNull(result.appliedDirectory());
|
||||||
|
assertNull(System.getProperty(KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine bereits gesetzte System-Property wird respektiert und nicht überschrieben.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void bereitsGesetztePropertyWirdRespektiert() {
|
||||||
|
System.setProperty(KEY, "C:/vorgegeben/logs");
|
||||||
|
|
||||||
|
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(new String[]{});
|
||||||
|
|
||||||
|
assertFalse(result.fallbackUsed());
|
||||||
|
assertEquals("C:/vorgegeben/logs", result.appliedDirectory());
|
||||||
|
assertEquals("C:/vorgegeben/logs", System.getProperty(KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt {@code --config} auf eine nicht existente Datei, bleibt die System-Property ungesetzt.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void fehlendeKonfigdatei_setztKeineProperty() {
|
||||||
|
LogDirectorySetupResult result = EarlyLogDirectoryInitializer.applyFromArgs(
|
||||||
|
new String[]{"--config", tempDir.resolve("does-not-exist.properties").toString()});
|
||||||
|
|
||||||
|
assertFalse(result.fallbackUsed());
|
||||||
|
assertNull(System.getProperty(KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path writeConfig(String content) throws IOException {
|
||||||
|
Path config = tempDir.resolve("application.properties");
|
||||||
|
Files.writeString(config, content + "\n", StandardCharsets.UTF_8);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
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_reconfigureFails_restoresPreviousProperty() {
|
||||||
|
System.setProperty(KEY, "C:/vorher/logs");
|
||||||
|
RuntimeLogDirectoryReconfigurer failing = new RuntimeLogDirectoryReconfigurer() {
|
||||||
|
@Override
|
||||||
|
protected void reconfigure() {
|
||||||
|
throw new IllegalStateException("reconfigure kaputt");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Path target = tempDir.resolve("neu");
|
||||||
|
|
||||||
|
LogDirectoryChange change = failing.apply(target);
|
||||||
|
|
||||||
|
assertFalse(change.changed());
|
||||||
|
assertEquals("C:/vorher/logs", System.getProperty(KEY),
|
||||||
|
"Nach fehlgeschlagener Neukonfiguration muss der vorherige Property-Wert wiederhergestellt sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
@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