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:
+10
-5
@@ -16,10 +16,11 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationEditorWorkspace;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationFileSnapshot;
|
||||
@@ -614,12 +615,14 @@ public class BootstrapRunner {
|
||||
|
||||
private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
|
||||
GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
|
||||
GuiConfigurationFileWriter writer = new GuiConfigurationPropertiesWriter();
|
||||
|
||||
if (configPathOverride.isEmpty()) {
|
||||
return new GuiStartupContext(
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
Optional.empty(),
|
||||
loader);
|
||||
loader,
|
||||
writer);
|
||||
}
|
||||
|
||||
Path configPath = Paths.get(configPathOverride.get());
|
||||
@@ -630,20 +633,22 @@ public class BootstrapRunner {
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
Optional.of("Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
|
||||
+ "\nDie GUI startet ohne Konfigurationsdatei."),
|
||||
loader);
|
||||
loader,
|
||||
writer);
|
||||
}
|
||||
|
||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||
try {
|
||||
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
||||
return new GuiStartupContext(loadedState, Optional.empty(), loader);
|
||||
return new GuiStartupContext(loadedState, Optional.empty(), loader, writer);
|
||||
} catch (GuiConfigurationLoadException e) {
|
||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||
e.getMessage(), e);
|
||||
return new GuiStartupContext(
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
Optional.of("Konfiguration konnte nicht geladen werden: " + e.getMessage()),
|
||||
loader);
|
||||
loader,
|
||||
writer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
||||
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.GuiProviderConfigurationState;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
|
||||
/**
|
||||
* Writes a normalized {@code .properties} file from the current GUI editor values.
|
||||
* <p>
|
||||
* This adapter implements the {@link GuiConfigurationFileWriter} port and is wired by
|
||||
* Bootstrap. It performs two main responsibilities:
|
||||
* <ol>
|
||||
* <li>Creates a {@code .bak} backup of any existing file before overwriting it, using
|
||||
* the same rotation schema as the legacy configuration migrator:
|
||||
* {@code <filename>.bak}, and on collision {@code .bak.1}, {@code .bak.2}, …
|
||||
* Existing backups are never overwritten.</li>
|
||||
* <li>Writes the editor values as a canonically ordered, grouped and commented
|
||||
* {@code .properties} file via a temporary file and an atomic rename, so the
|
||||
* existing file is never partially overwritten.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* API-key preservation logic (detecting empty fields with a non-empty baseline) is handled
|
||||
* by the caller (workspace) before invoking this writer. The writer simply serializes the
|
||||
* values it receives.
|
||||
*
|
||||
* <h2>Normalized output order</h2>
|
||||
* <pre>{@code
|
||||
* # Provider
|
||||
* ai.provider.active=...
|
||||
* # Claude
|
||||
* ai.provider.claude.*
|
||||
* # OpenAI-kompatibel
|
||||
* ai.provider.openai-compatible.*
|
||||
* # Pfade
|
||||
* source.folder=...
|
||||
* target.folder=...
|
||||
* sqlite.file=...
|
||||
* # Verarbeitung
|
||||
* max.retries.transient=...
|
||||
* max.pages=...
|
||||
* max.text.characters=...
|
||||
* prompt.template.file=...
|
||||
* # Logging
|
||||
* log.ai.sensitive=...
|
||||
* log.directory=...
|
||||
* log.level=...
|
||||
* # Laufzeit
|
||||
* runtime.lock.file=...
|
||||
* }</pre>
|
||||
*/
|
||||
public final class GuiConfigurationPropertiesWriter implements GuiConfigurationFileWriter {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(GuiConfigurationPropertiesWriter.class);
|
||||
|
||||
/**
|
||||
* Creates a new properties writer.
|
||||
*/
|
||||
public GuiConfigurationPropertiesWriter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the editor values to the target path as a normalized {@code .properties} file.
|
||||
* <p>
|
||||
* When the target file already exists, a backup is created before the file is overwritten.
|
||||
* The write is performed via a temporary file followed by an atomic rename.
|
||||
*
|
||||
* <p><strong>Threading contract:</strong> This method performs blocking file-system I/O
|
||||
* ({@link java.nio.file.Files#exists}, backup copy, directory creation, file write, atomic
|
||||
* move). It must be invoked from a background worker thread. It must never be called from
|
||||
* the JavaFX Application Thread.
|
||||
*
|
||||
* @param values the current editor values; must not be {@code null}
|
||||
* @param targetPath the file to write; must not be {@code null}
|
||||
* @return the save result containing the written path
|
||||
* @throws GuiConfigurationWriteException if the file cannot be written
|
||||
*/
|
||||
@Override
|
||||
public GuiConfigurationSaveResult write(GuiConfigurationValues values, Path targetPath) {
|
||||
if (Files.exists(targetPath)) {
|
||||
createBakBackup(targetPath);
|
||||
}
|
||||
|
||||
String content = buildPropertiesContent(values);
|
||||
writeAtomically(targetPath, content);
|
||||
|
||||
LOG.info("Konfigurationsdatei geschrieben: {}", targetPath.toAbsolutePath());
|
||||
return GuiConfigurationSaveResult.saved(targetPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rotating backup of the file at the given path.
|
||||
* <p>
|
||||
* The first backup uses the suffix {@code .bak}. When that file already exists,
|
||||
* numbered suffixes are tried in ascending order ({@code .bak.1}, {@code .bak.2}, …)
|
||||
* until a free slot is found. Existing backups are never overwritten.
|
||||
*
|
||||
* @param targetPath the file to back up; must exist
|
||||
* @throws GuiConfigurationWriteException if the backup cannot be created
|
||||
*/
|
||||
void createBakBackup(Path targetPath) {
|
||||
Path bakPath = targetPath.resolveSibling(targetPath.getFileName() + ".bak");
|
||||
if (!Files.exists(bakPath)) {
|
||||
copyFile(targetPath, bakPath);
|
||||
LOG.info("Sicherungskopie erstellt: {}", bakPath);
|
||||
return;
|
||||
}
|
||||
for (int i = 1; ; i++) {
|
||||
Path numbered = targetPath.resolveSibling(targetPath.getFileName() + ".bak." + i);
|
||||
if (!Files.exists(numbered)) {
|
||||
copyFile(targetPath, numbered);
|
||||
LOG.info("Sicherungskopie erstellt: {}", numbered);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the content to the target path via a temporary file and an atomic rename.
|
||||
*
|
||||
* @param target the destination path; must not be {@code null}
|
||||
* @param content the content to write; must not be {@code null}
|
||||
* @throws GuiConfigurationWriteException if the file cannot be written
|
||||
*/
|
||||
private void writeAtomically(Path target, String content) {
|
||||
Path tmpPath = target.resolveSibling(target.getFileName() + ".tmp");
|
||||
try {
|
||||
Path parentDir = target.getParent();
|
||||
if (parentDir != null) {
|
||||
Files.createDirectories(parentDir);
|
||||
}
|
||||
Files.writeString(tmpPath, content, StandardCharsets.UTF_8);
|
||||
Files.move(tmpPath, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new GuiConfigurationWriteException(
|
||||
"Konfigurationsdatei konnte nicht geschrieben werden: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyFile(Path source, Path destination) {
|
||||
try {
|
||||
Files.copy(source, destination);
|
||||
} catch (IOException e) {
|
||||
throw new GuiConfigurationWriteException(
|
||||
"Sicherungskopie konnte nicht erstellt werden: " + destination, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the normalized {@code .properties} file content from the given values.
|
||||
*
|
||||
* @param values the values to serialize; must not be {@code null}
|
||||
* @return the complete file content as a UTF-8 string
|
||||
*/
|
||||
String buildPropertiesContent(GuiConfigurationValues values) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
appendLine(sb, "# Aktiver KI-Provider (claude oder openai-compatible)");
|
||||
appendKeyValue(sb, "ai.provider.active", values.activeProviderFamily());
|
||||
appendLine(sb, "");
|
||||
|
||||
appendLine(sb, "# Provider-Konfiguration: Claude");
|
||||
GuiProviderConfigurationState claude = values.providerConfiguration(AiProviderFamily.CLAUDE);
|
||||
if (claude != null) {
|
||||
appendKeyValue(sb, "ai.provider.claude.baseUrl", claude.baseUrl());
|
||||
appendKeyValue(sb, "ai.provider.claude.model", claude.model());
|
||||
appendKeyValue(sb, "ai.provider.claude.timeoutSeconds", claude.timeoutSeconds());
|
||||
appendKeyValue(sb, "ai.provider.claude.apiKey", claude.apiKey().propertyValue());
|
||||
}
|
||||
appendLine(sb, "");
|
||||
|
||||
appendLine(sb, "# Provider-Konfiguration: OpenAI-kompatibel");
|
||||
GuiProviderConfigurationState openai = values.providerConfiguration(AiProviderFamily.OPENAI_COMPATIBLE);
|
||||
if (openai != null) {
|
||||
appendKeyValue(sb, "ai.provider.openai-compatible.baseUrl", openai.baseUrl());
|
||||
appendKeyValue(sb, "ai.provider.openai-compatible.model", openai.model());
|
||||
appendKeyValue(sb, "ai.provider.openai-compatible.timeoutSeconds", openai.timeoutSeconds());
|
||||
appendKeyValue(sb, "ai.provider.openai-compatible.apiKey", openai.apiKey().propertyValue());
|
||||
}
|
||||
appendLine(sb, "");
|
||||
|
||||
appendLine(sb, "# Pfade");
|
||||
appendKeyValue(sb, "source.folder", values.sourceFolder());
|
||||
appendKeyValue(sb, "target.folder", values.targetFolder());
|
||||
appendKeyValue(sb, "sqlite.file", values.sqliteFile());
|
||||
appendLine(sb, "");
|
||||
|
||||
appendLine(sb, "# Verarbeitung");
|
||||
appendKeyValue(sb, "max.retries.transient", values.maxRetriesTransient());
|
||||
appendKeyValue(sb, "max.pages", values.maxPages());
|
||||
appendKeyValue(sb, "max.text.characters", values.maxTextCharacters());
|
||||
appendKeyValue(sb, "prompt.template.file", values.promptTemplateFile());
|
||||
appendLine(sb, "");
|
||||
|
||||
appendLine(sb, "# Logging");
|
||||
appendKeyValue(sb, "log.ai.sensitive", values.logAiSensitive());
|
||||
appendKeyValue(sb, "log.directory", values.logDirectory());
|
||||
appendKeyValue(sb, "log.level", values.logLevel());
|
||||
appendLine(sb, "");
|
||||
|
||||
appendLine(sb, "# Laufzeit");
|
||||
appendKeyValue(sb, "runtime.lock.file", values.runtimeLockFile());
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static void appendLine(StringBuilder sb, String line) {
|
||||
sb.append(line).append("\n");
|
||||
}
|
||||
|
||||
private static void appendKeyValue(StringBuilder sb, String key, String value) {
|
||||
sb.append(key).append("=").append(value == null ? "" : value).append("\n");
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Technical adapters wired exclusively by the Bootstrap module.
|
||||
* <p>
|
||||
* This package contains adapter implementations that are not part of any other module's
|
||||
* public contract. They are instantiated and wired by Bootstrap and injected into the
|
||||
* appropriate ports. Adapter classes in this package may depend on both inbound and
|
||||
* outbound module contracts, but must not introduce circular dependencies.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
|
||||
+311
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user