M10 vollständig abgeschlossen (AP-004 bis AP-007)

- AP-004: Speichern und Speichern unter mit .bak-Rotation,
  normalisierte .properties-Ausgabe, API-Key-Erhaltung bei leerem Feld
- AP-005: Dirty-State aus Editorzustand, Fenstertitel- und
  Header-Marker, Schutzdialog (Speichern/Verwerfen/Abbrechen)
  vor Neu/Öffnen/Schließen inkl. Close-Request-Handler
- AP-006: Vollständige Editoroberfläche mit allen Konfigurationswerten,
  native Pfad-Picker für Quell-/Zielordner, SQLite- und Prompt-Datei,
  Files.exists-Pfadprüfung auf Worker-Thread verlagert
- AP-007: Integrations- und Regressionstests für alle zentralen
  Bedienpfade, Writer-Threading-Contract dokumentiert und getestet

Hexagonale Architektur, Threadingmodell und Naming-Regel durchgehend
eingehalten. Keine Vorgriffe auf M11/M12.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:51:13 +02:00
parent 6d4654f482
commit bbb5c4da3a
22 changed files with 5221 additions and 37 deletions
@@ -0,0 +1,311 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationWriteException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
/**
* Unit tests for {@link GuiConfigurationPropertiesWriter}.
* <p>
* Tests cover: normalized output content and order, backup rotation schema, backup
* non-overwrite guarantee, and atomic write behavior.
*/
class GuiConfigurationPropertiesWriterTest {
private final GuiConfigurationPropertiesWriter writer = new GuiConfigurationPropertiesWriter();
@TempDir
Path tempDir;
// =========================================================================
// Backup rotation
// =========================================================================
@Test
void backup_createsFirstBakWhenNoneExists() throws IOException {
Path file = tempDir.resolve("config.properties");
Files.writeString(file, "existing=content", StandardCharsets.UTF_8);
writer.createBakBackup(file);
Path bak = tempDir.resolve("config.properties.bak");
assertTrue(Files.exists(bak), "First backup must be created as .bak");
assertEquals("existing=content", Files.readString(bak, StandardCharsets.UTF_8));
}
@Test
void backup_createsNumberedBakWhenBakAlreadyExists() throws IOException {
Path file = tempDir.resolve("config.properties");
Files.writeString(file, "new=content", StandardCharsets.UTF_8);
Path bak = tempDir.resolve("config.properties.bak");
Files.writeString(bak, "old=content", StandardCharsets.UTF_8);
writer.createBakBackup(file);
Path bak1 = tempDir.resolve("config.properties.bak.1");
assertTrue(Files.exists(bak1), "Second backup must be created as .bak.1");
assertEquals("new=content", Files.readString(bak1, StandardCharsets.UTF_8));
// Existing .bak must not be overwritten.
assertEquals("old=content", Files.readString(bak, StandardCharsets.UTF_8));
}
@Test
void backup_incrementsNumberUntilFreeSlotFound() throws IOException {
Path file = tempDir.resolve("config.properties");
Files.writeString(file, "data", StandardCharsets.UTF_8);
Files.writeString(tempDir.resolve("config.properties.bak"), "bak", StandardCharsets.UTF_8);
Files.writeString(tempDir.resolve("config.properties.bak.1"), "bak1", StandardCharsets.UTF_8);
Files.writeString(tempDir.resolve("config.properties.bak.2"), "bak2", StandardCharsets.UTF_8);
writer.createBakBackup(file);
Path bak3 = tempDir.resolve("config.properties.bak.3");
assertTrue(Files.exists(bak3), "Third numbered backup must be created as .bak.3");
// Previous backups must remain unchanged.
assertEquals("bak2", Files.readString(tempDir.resolve("config.properties.bak.2"),
StandardCharsets.UTF_8));
}
@Test
void backup_neverOverwritesExistingBackups() throws IOException {
Path file = tempDir.resolve("c.properties");
Files.writeString(file, "current", StandardCharsets.UTF_8);
Path bak = tempDir.resolve("c.properties.bak");
Files.writeString(bak, "precious", StandardCharsets.UTF_8);
writer.createBakBackup(file);
assertEquals("precious", Files.readString(bak, StandardCharsets.UTF_8),
"Existing .bak content must not be overwritten");
}
// =========================================================================
// Normalized output content
// =========================================================================
@Test
void write_newFile_createsFileWithNormalizedContent() throws IOException {
Path target = tempDir.resolve("application.properties");
GuiConfigurationValues values = buildTestValues("claude", "sk-claude", "sk-openai");
GuiConfigurationSaveResult result = writer.write(values, target);
assertEquals(target, result.savedPath());
assertFalse(result.hasApiKeyPreservationNote());
assertTrue(Files.exists(target), "Target file must exist after write");
Properties props = loadProperties(target);
assertEquals("claude", props.getProperty("ai.provider.active"));
assertEquals("https://api.anthropic.com", props.getProperty("ai.provider.claude.baseUrl"));
assertEquals("claude-3-5-sonnet-20241022", props.getProperty("ai.provider.claude.model"));
assertEquals("60", props.getProperty("ai.provider.claude.timeoutSeconds"));
assertEquals("sk-claude", props.getProperty("ai.provider.claude.apiKey"));
assertEquals("https://api.openai.com/v1", props.getProperty("ai.provider.openai-compatible.baseUrl"));
assertEquals("gpt-4o-mini", props.getProperty("ai.provider.openai-compatible.model"));
assertEquals("30", props.getProperty("ai.provider.openai-compatible.timeoutSeconds"));
assertEquals("sk-openai", props.getProperty("ai.provider.openai-compatible.apiKey"));
assertEquals("./source", props.getProperty("source.folder"));
assertEquals("./target", props.getProperty("target.folder"));
assertEquals("./db.sqlite", props.getProperty("sqlite.file"));
assertEquals("3", props.getProperty("max.retries.transient"));
assertEquals("10", props.getProperty("max.pages"));
assertEquals("5000", props.getProperty("max.text.characters"));
assertEquals("./prompt.txt", props.getProperty("prompt.template.file"));
assertEquals("false", props.getProperty("log.ai.sensitive"));
assertEquals("./logs", props.getProperty("log.directory"));
assertEquals("INFO", props.getProperty("log.level"));
assertEquals("./app.lock", props.getProperty("runtime.lock.file"));
}
@Test
void write_existingFile_createsBackupBeforeOverwriting() throws IOException {
Path target = tempDir.resolve("application.properties");
Files.writeString(target, "old=value", StandardCharsets.UTF_8);
GuiConfigurationValues values = buildTestValues("claude", "", "");
writer.write(values, target);
Path bak = tempDir.resolve("application.properties.bak");
assertTrue(Files.exists(bak), "Backup must be created when overwriting an existing file");
assertEquals("old=value", Files.readString(bak, StandardCharsets.UTF_8));
}
@Test
void write_noBackupCreatedForNewFile() throws IOException {
Path target = tempDir.resolve("new.properties");
GuiConfigurationValues values = buildTestValues("claude", "", "");
writer.write(values, target);
Path bak = tempDir.resolve("new.properties.bak");
assertFalse(Files.exists(bak), "No backup must be created when writing a new file");
}
@Test
void write_createsParentDirectoriesWhenMissing() throws IOException {
Path target = tempDir.resolve("nested/dir/config.properties");
GuiConfigurationValues values = buildTestValues("claude", "", "");
writer.write(values, target);
assertTrue(Files.exists(target), "File must be created even when parent directories are missing");
}
// =========================================================================
// Normalized property order
// =========================================================================
@Test
void buildPropertiesContent_includesExpectedSections() {
GuiConfigurationValues values = buildTestValues("openai-compatible", "sk-a", "sk-b");
String content = writer.buildPropertiesContent(values);
// Verify section grouping order.
int providerActivePos = content.indexOf("ai.provider.active=");
int claudePos = content.indexOf("ai.provider.claude.baseUrl=");
int openaiPos = content.indexOf("ai.provider.openai-compatible.baseUrl=");
int sourceFolderPos = content.indexOf("source.folder=");
int maxRetriesPos = content.indexOf("max.retries.transient=");
int logAiPos = content.indexOf("log.ai.sensitive=");
int lockPos = content.indexOf("runtime.lock.file=");
assertTrue(providerActivePos < claudePos, "ai.provider.active must appear before claude block");
assertTrue(claudePos < openaiPos, "Claude block must appear before openai-compatible block");
assertTrue(openaiPos < sourceFolderPos, "Provider blocks must appear before paths");
assertTrue(sourceFolderPos < maxRetriesPos, "Paths must appear before processing section");
assertTrue(maxRetriesPos < logAiPos, "Processing section must appear before logging section");
assertTrue(logAiPos < lockPos, "Logging section must appear before runtime section");
}
@Test
void buildPropertiesContent_containsGroupingComments() {
GuiConfigurationValues values = buildTestValues("claude", "", "");
String content = writer.buildPropertiesContent(values);
assertTrue(content.contains("# Pfade"), "Paths section must have a comment");
assertTrue(content.contains("# Verarbeitung"), "Processing section must have a comment");
assertTrue(content.contains("# Logging"), "Logging section must have a comment");
assertTrue(content.contains("# Laufzeit"), "Runtime section must have a comment");
}
@Test
void buildPropertiesContent_emptyValuesProduceParsableOutput() throws IOException {
GuiConfigurationValues values = buildTestValues("claude", "", "");
String content = writer.buildPropertiesContent(values);
Properties props = new Properties();
props.load(new StringReader(content));
// Should not throw, and values should be parseable.
assertEquals("claude", props.getProperty("ai.provider.active"));
}
// =========================================================================
// Threading invariant: writer must not be called from the FX thread
// =========================================================================
/**
* Verifies that {@link GuiConfigurationPropertiesWriter#write} is called from a background
* worker thread and not from the JavaFX Application Thread, as required by the threading
* contract documented on the method.
* <p>
* The test invokes the writer directly from a named non-FX thread and captures the thread
* name inside the call to confirm the threading invariant. A thread named anything other than
* "JavaFX Application Thread" satisfies the invariant.
*
* @throws Exception if the background thread fails or times out
*/
@Test
void write_isCalledFromWorkerThread_notFromFxApplicationThread() throws Exception {
Path target = tempDir.resolve("threading-test.properties");
GuiConfigurationValues values = buildTestValues("claude", "", "");
java.util.concurrent.atomic.AtomicReference<String> callerThreadName =
new java.util.concurrent.atomic.AtomicReference<>();
java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
Thread workerThread = new Thread(() -> {
try {
callerThreadName.set(Thread.currentThread().getName());
writer.write(values, target);
} finally {
latch.countDown();
}
}, "gui-config-writer-test");
workerThread.setDaemon(true);
workerThread.start();
assertTrue(latch.await(10, java.util.concurrent.TimeUnit.SECONDS),
"Writer thread must complete within timeout");
String threadName = callerThreadName.get();
assertFalse(threadName == null || threadName.contains("JavaFX Application Thread"),
"Writer must be called from a background worker thread, not the FX Application Thread. "
+ "Actual thread: " + threadName);
assertEquals("gui-config-writer-test", threadName,
"Writer must have been called on the expected worker thread");
}
// =========================================================================
// Helpers
// =========================================================================
private GuiConfigurationValues buildTestValues(String activeProvider,
String claudeApiKey,
String openaiApiKey) {
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations = new LinkedHashMap<>();
providerConfigurations.put(AiProviderFamily.CLAUDE, new GuiProviderConfigurationState(
"https://api.anthropic.com",
"claude-3-5-sonnet-20241022",
"60",
GuiProviderApiKeyState.unresolved(claudeApiKey)));
providerConfigurations.put(AiProviderFamily.OPENAI_COMPATIBLE, new GuiProviderConfigurationState(
"https://api.openai.com/v1",
"gpt-4o-mini",
"30",
GuiProviderApiKeyState.unresolved(openaiApiKey)));
return new GuiConfigurationValues(
"./source",
"./target",
"./db.sqlite",
"./prompt.txt",
"./app.lock",
"./logs",
"INFO",
"3",
"10",
"5000",
"false",
activeProvider,
providerConfigurations);
}
private Properties loadProperties(Path path) throws IOException {
String content = Files.readString(path, StandardCharsets.UTF_8);
Properties props = new Properties();
props.load(new StringReader(content));
return props;
}
}