M10 bis AP-003
This commit is contained in:
+90
-16
@@ -1,17 +1,28 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
|
||||
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.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
|
||||
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.GuiConfigurationLoadException;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||
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;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
||||
|
||||
@@ -518,25 +529,11 @@ public class BootstrapRunner {
|
||||
* @return exit code: 0 for normal GUI shutdown, 1 for any GUI startup failure
|
||||
*/
|
||||
private int startGuiMode(Optional<String> configPathOverride) {
|
||||
Optional<String> startupNotice = Optional.empty();
|
||||
|
||||
if (configPathOverride.isPresent()) {
|
||||
Path configPath = Paths.get(configPathOverride.get());
|
||||
if (Files.exists(configPath)) {
|
||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||
} else {
|
||||
LOG.error("GUI startup: --config path not found: {}. Starting GUI without configuration override.",
|
||||
configPath.toAbsolutePath());
|
||||
startupNotice = Optional.of(
|
||||
"Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
|
||||
+ "\nDie GUI startet ohne Konfigurationsdatei.");
|
||||
}
|
||||
}
|
||||
|
||||
GuiStartupContext startupContext = buildGuiStartupContext(configPathOverride);
|
||||
LOG.info("GUI startup: launching GUI adapter.");
|
||||
try {
|
||||
GuiAdapter guiAdapter = guiAdapterFactory.create();
|
||||
guiAdapter.start(startupNotice);
|
||||
guiAdapter.start(startupContext);
|
||||
LOG.info("GUI adapter terminated normally.");
|
||||
return 0;
|
||||
} catch (Exception e) {
|
||||
@@ -615,6 +612,83 @@ public class BootstrapRunner {
|
||||
.orElse(DEFAULT_CONFIG_PATH);
|
||||
}
|
||||
|
||||
private GuiStartupContext buildGuiStartupContext(Optional<String> configPathOverride) {
|
||||
GuiConfigurationFileLoader loader = this::loadGuiConfigurationState;
|
||||
|
||||
if (configPathOverride.isEmpty()) {
|
||||
return new GuiStartupContext(
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
Optional.empty(),
|
||||
loader);
|
||||
}
|
||||
|
||||
Path configPath = Paths.get(configPathOverride.get());
|
||||
if (!Files.exists(configPath)) {
|
||||
LOG.error("GUI startup: --config path not found: {}. Starting GUI without configuration override.",
|
||||
configPath.toAbsolutePath());
|
||||
return new GuiStartupContext(
|
||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||
Optional.of("Konfigurationsdatei nicht gefunden: " + configPath.toAbsolutePath()
|
||||
+ "\nDie GUI startet ohne Konfigurationsdatei."),
|
||||
loader);
|
||||
}
|
||||
|
||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||
try {
|
||||
GuiConfigurationEditorState loadedState = loadGuiConfigurationState(configPath);
|
||||
return new GuiStartupContext(loadedState, Optional.empty(), loader);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
private GuiConfigurationEditorState loadGuiConfigurationState(Path configFilePath) {
|
||||
try {
|
||||
boolean legacyDetected = detectedLegacyConfiguration(configFilePath);
|
||||
migrateConfigurationIfNeeded(configFilePath);
|
||||
GuiConfigurationFileSnapshot snapshot = new GuiConfigurationFileSnapshot(
|
||||
configFilePath,
|
||||
readPropertiesSnapshot(configFilePath));
|
||||
Optional<String> migrationMessage = legacyDetected
|
||||
? Optional.of("Konfiguration wurde aus einer Legacy-Datei übernommen.")
|
||||
: Optional.empty();
|
||||
return GuiConfigurationEditorStateFactory.fromPropertiesSnapshot(snapshot, migrationMessage);
|
||||
} catch (ConfigurationLoadingException e) {
|
||||
throw new GuiConfigurationLoadException("Failed to load configuration from " + configFilePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean detectedLegacyConfiguration(Path configFilePath) {
|
||||
try {
|
||||
String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
|
||||
Properties props = new Properties();
|
||||
props.load(new StringReader(content.replace("\\", "\\\\")));
|
||||
boolean hasLegacyKey = props.containsKey("api.baseUrl")
|
||||
|| props.containsKey("api.model")
|
||||
|| props.containsKey("api.timeoutSeconds")
|
||||
|| props.containsKey("api.key");
|
||||
return hasLegacyKey && !props.containsKey("ai.provider.active");
|
||||
} catch (IOException e) {
|
||||
throw new GuiConfigurationLoadException("Failed to inspect legacy configuration at " + configFilePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Properties readPropertiesSnapshot(Path configFilePath) {
|
||||
try {
|
||||
String content = Files.readString(configFilePath, StandardCharsets.UTF_8);
|
||||
Properties props = new Properties();
|
||||
props.load(new StringReader(content.replace("\\", "\\\\")));
|
||||
return props;
|
||||
} catch (IOException e) {
|
||||
throw new GuiConfigurationLoadException("Failed to create snapshot for " + configFilePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the legacy configuration migration step against the effective configuration path.
|
||||
* <p>
|
||||
|
||||
+89
-9
@@ -25,6 +25,10 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
@@ -184,11 +188,13 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
void runGui_withNonExistentConfigPath_startsGuiAndReturnsZeroOnNormalShutdown() {
|
||||
String nonExistentPath = tempDir.resolve("missing.properties").toString();
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
receivedNotice.set(startupContext.startupNotice());
|
||||
receivedState.set(startupContext.initialState());
|
||||
// normal shutdown: returns without throwing
|
||||
}
|
||||
});
|
||||
@@ -199,6 +205,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
"GUI with non-existent --config path must still return exit code 0 on normal shutdown");
|
||||
assertTrue(receivedNotice.get().isPresent(),
|
||||
"A startup notice must be forwarded to the GUI adapter when the config file is missing");
|
||||
assertFalse(receivedState.get().hasLoadedFileSnapshot(),
|
||||
"GUI must start without a loaded configuration when the supplied file is missing");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -223,7 +231,7 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
// normal termination
|
||||
}
|
||||
});
|
||||
@@ -250,8 +258,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
receivedNotice.set(startupContext.startupNotice());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -266,11 +274,13 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
void runGui_withExistingConfigPath_startsGuiWithEmptyNotice(@TempDir Path workDir) throws Exception {
|
||||
Path existingConfigFile = Files.createFile(workDir.resolve("real.properties"));
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
receivedNotice.set(startupContext.startupNotice());
|
||||
receivedState.set(startupContext.initialState());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -281,6 +291,76 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
"GUI with existing --config path must return exit code 0 on normal shutdown");
|
||||
assertFalse(receivedNotice.get().isPresent(),
|
||||
"No startup notice must be forwarded when the config file exists");
|
||||
assertTrue(receivedState.get().hasLoadedFileSnapshot(),
|
||||
"GUI must receive a loaded editor state when the supplied file exists");
|
||||
assertEquals(existingConfigFile.toString(), receivedState.get().configurationPathText(),
|
||||
"The loaded editor state must point to the supplied configuration file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runGui_withLegacyConfigPath_loadsMigratedStateAndKeepsMigrationNote(@TempDir Path workDir) throws Exception {
|
||||
Path legacyConfigFile = workDir.resolve("legacy.properties");
|
||||
Files.createDirectories(workDir.resolve("source"));
|
||||
Files.createDirectories(workDir.resolve("target"));
|
||||
Files.createDirectories(workDir.resolve("logs"));
|
||||
Files.createFile(workDir.resolve("db.sqlite"));
|
||||
Files.createFile(workDir.resolve("prompt.txt"));
|
||||
Files.createFile(workDir.resolve("lock.pid"));
|
||||
Files.writeString(legacyConfigFile, """
|
||||
source.folder=%s
|
||||
target.folder=%s
|
||||
sqlite.file=%s
|
||||
api.baseUrl=https://api.openai.com/v1
|
||||
api.model=gpt-4o-mini
|
||||
api.timeoutSeconds=30
|
||||
api.key=test-api-key
|
||||
max.retries.transient=3
|
||||
max.pages=10
|
||||
max.text.characters=5000
|
||||
prompt.template.file=%s
|
||||
runtime.lock.file=%s
|
||||
log.directory=%s
|
||||
log.level=INFO
|
||||
log.ai.sensitive=false
|
||||
""".formatted(
|
||||
workDir.resolve("source"),
|
||||
workDir.resolve("target"),
|
||||
workDir.resolve("db.sqlite"),
|
||||
workDir.resolve("prompt.txt"),
|
||||
workDir.resolve("lock.pid"),
|
||||
workDir.resolve("logs")));
|
||||
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
path -> new LegacyConfigurationMigrator().migrateIfLegacy(path),
|
||||
PropertiesConfigurationPortAdapter::new,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
SchedulerBatchCommand::new,
|
||||
() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
receivedNotice.set(startupContext.startupNotice());
|
||||
receivedState.set(startupContext.initialState());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.of(legacyConfigFile.toString())));
|
||||
|
||||
assertEquals(0, exitCode, "GUI with legacy config path must still return exit code 0 on normal shutdown");
|
||||
assertFalse(receivedNotice.get().isPresent(),
|
||||
"No startup notice is needed when a legacy config can be migrated and loaded");
|
||||
assertTrue(receivedState.get().hasLoadedFileSnapshot(),
|
||||
"The migrated editor state must carry the loaded file snapshot");
|
||||
assertTrue(receivedState.get().hasPendingMigrationMessage(),
|
||||
"The migrated editor state must retain the pending migration message");
|
||||
assertEquals(legacyConfigFile.toString(), receivedState.get().configurationPathText(),
|
||||
"The migrated editor state must report the full loaded path");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -289,8 +369,8 @@ class BootstrapRunnerConfigPathSemanticsTest {
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
receivedNotice.set(startupContext.startupNotice());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+4
-2
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
@@ -41,7 +43,7 @@ class BootstrapRunnerStartupDispatchTest {
|
||||
void run_withGuiMode_returnsZeroWhenGuiStartSucceeds() {
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
// normal termination: returns without throwing
|
||||
}
|
||||
});
|
||||
@@ -55,7 +57,7 @@ class BootstrapRunnerStartupDispatchTest {
|
||||
void run_withGuiMode_returnsOneWhenGuiStartThrowsException() {
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
public void start(GuiStartupContext startupContext) {
|
||||
throw new RuntimeException("simulated GUI startup failure");
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user