Erweiterung für V2.0: M9 umgesetzt
This commit is contained in:
+381
@@ -0,0 +1,381 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.appender.AbstractAppender;
|
||||
import org.apache.logging.log4j.core.config.Configuration;
|
||||
import org.apache.logging.log4j.core.config.Property;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
||||
|
||||
/**
|
||||
* Tests for the {@code --config} path semantics enforced by {@link BootstrapRunner}.
|
||||
* <p>
|
||||
* Covers the distinct behavior for GUI and headless startup modes when the path
|
||||
* supplied via {@code --config} refers to a file that does or does not exist:
|
||||
* <ul>
|
||||
* <li><strong>Headless, file missing:</strong> hard startup failure (exit code 1),
|
||||
* no migration or loading attempted, error is logged.</li>
|
||||
* <li><strong>Headless, file exists:</strong> proceeds normally with the supplied path.</li>
|
||||
* <li><strong>Headless, no {@code --config}:</strong> default path is used unchanged.</li>
|
||||
* <li><strong>GUI, file missing:</strong> error is logged, startup notice is forwarded
|
||||
* to the adapter, GUI starts normally (exit code 0 on normal shutdown).</li>
|
||||
* <li><strong>GUI, file exists:</strong> path is confirmed, GUI starts normally.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class BootstrapRunnerConfigPathSemanticsTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
// =========================================================================
|
||||
// Headless: non-existent --config path
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void runHeadless_withNonExistentConfigPath_returnsExitCode1() {
|
||||
String nonExistentPath = tempDir.resolve("does-not-exist.properties").toString();
|
||||
AtomicBoolean configPortCalled = new AtomicBoolean(false);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> {
|
||||
configPortCalled.set(true);
|
||||
return buildValidConfigPort();
|
||||
},
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.of(nonExistentPath)));
|
||||
|
||||
assertEquals(1, exitCode,
|
||||
"Headless start with non-existent --config path must return exit code 1");
|
||||
assertFalse(configPortCalled.get(),
|
||||
"ConfigurationPort must not be called when --config file does not exist");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runHeadless_withNonExistentConfigPath_logsError() {
|
||||
String nonExistentPath = tempDir.resolve("missing.properties").toString();
|
||||
List<LogEvent> capturedEvents = new ArrayList<>();
|
||||
String appenderName = "TestCapture-MissingConfig-" + UUID.randomUUID();
|
||||
|
||||
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
|
||||
Configuration cfg = ctx.getConfiguration();
|
||||
AbstractAppender captureAppender = new AbstractAppender(
|
||||
appenderName, null, null, false, Property.EMPTY_ARRAY) {
|
||||
@Override
|
||||
public void append(LogEvent event) {
|
||||
capturedEvents.add(event.toImmutable());
|
||||
}
|
||||
};
|
||||
captureAppender.start();
|
||||
cfg.addAppender(captureAppender);
|
||||
cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
|
||||
ctx.updateLoggers();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> buildValidConfigPort(),
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
|
||||
try {
|
||||
runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.of(nonExistentPath)));
|
||||
} finally {
|
||||
cfg.getRootLogger().removeAppender(appenderName);
|
||||
ctx.updateLoggers();
|
||||
captureAppender.stop();
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
capturedEvents.stream().anyMatch(e ->
|
||||
e.getLevel() == Level.ERROR
|
||||
&& e.getMessage().getFormattedMessage().contains("not found")),
|
||||
"An ERROR-level log mentioning the missing file must be produced");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runHeadless_withExistingConfigPath_proceedsNormally(@TempDir Path workDir) throws Exception {
|
||||
Path existingConfigFile = Files.createFile(workDir.resolve("custom.properties"));
|
||||
AtomicReference<Path> receivedPath = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> {
|
||||
receivedPath.set(configPath);
|
||||
return buildValidConfigPort();
|
||||
},
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
|
||||
int exitCode = runner.run(
|
||||
new StartupArguments(StartupMode.HEADLESS, Optional.of(existingConfigFile.toString())));
|
||||
|
||||
assertEquals(0, exitCode,
|
||||
"Headless start with existing --config path must proceed normally");
|
||||
assertEquals(existingConfigFile, receivedPath.get(),
|
||||
"The existing config path must be forwarded to the ConfigurationPortFactory");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runHeadless_withoutConfigPath_usesDefaultBehavior() {
|
||||
AtomicReference<Path> receivedPath = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> {
|
||||
receivedPath.set(configPath);
|
||||
return buildValidConfigPort();
|
||||
},
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
SchedulerBatchCommand::new
|
||||
);
|
||||
|
||||
runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
|
||||
|
||||
assertEquals(java.nio.file.Paths.get("config/application.properties"), receivedPath.get(),
|
||||
"Headless without --config must use the default configuration path");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GUI: non-existent --config path
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void runGui_withNonExistentConfigPath_startsGuiAndReturnsZeroOnNormalShutdown() {
|
||||
String nonExistentPath = tempDir.resolve("missing.properties").toString();
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
// normal shutdown: returns without throwing
|
||||
}
|
||||
});
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.of(nonExistentPath)));
|
||||
|
||||
assertEquals(0, exitCode,
|
||||
"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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runGui_withNonExistentConfigPath_logsError() {
|
||||
String nonExistentPath = tempDir.resolve("missing-for-gui.properties").toString();
|
||||
List<LogEvent> capturedEvents = new ArrayList<>();
|
||||
String appenderName = "TestCapture-GuiMissingConfig-" + UUID.randomUUID();
|
||||
|
||||
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
|
||||
Configuration cfg = ctx.getConfiguration();
|
||||
AbstractAppender captureAppender = new AbstractAppender(
|
||||
appenderName, null, null, false, Property.EMPTY_ARRAY) {
|
||||
@Override
|
||||
public void append(LogEvent event) {
|
||||
capturedEvents.add(event.toImmutable());
|
||||
}
|
||||
};
|
||||
captureAppender.start();
|
||||
cfg.addAppender(captureAppender);
|
||||
cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
|
||||
ctx.updateLoggers();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
// normal termination
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
runner.run(new StartupArguments(StartupMode.GUI, Optional.of(nonExistentPath)));
|
||||
} finally {
|
||||
cfg.getRootLogger().removeAppender(appenderName);
|
||||
ctx.updateLoggers();
|
||||
captureAppender.stop();
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
capturedEvents.stream().anyMatch(e ->
|
||||
e.getLevel() == Level.ERROR
|
||||
&& e.getMessage().getFormattedMessage().contains("not found")),
|
||||
"An ERROR-level log mentioning the missing file must be produced for GUI path too");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runGui_withNonExistentConfigPath_startupNoticeContainsPathInfo() {
|
||||
String nonExistentPath = tempDir.resolve("absent.properties").toString();
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
}
|
||||
});
|
||||
|
||||
runner.run(new StartupArguments(StartupMode.GUI, Optional.of(nonExistentPath)));
|
||||
|
||||
assertTrue(receivedNotice.get().isPresent(), "Startup notice must be present");
|
||||
assertTrue(receivedNotice.get().get().contains("absent.properties"),
|
||||
"Startup notice must contain the missing file path for user orientation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runGui_withExistingConfigPath_startsGuiWithEmptyNotice(@TempDir Path workDir) throws Exception {
|
||||
Path existingConfigFile = Files.createFile(workDir.resolve("real.properties"));
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
}
|
||||
});
|
||||
|
||||
int exitCode = runner.run(
|
||||
new StartupArguments(StartupMode.GUI, Optional.of(existingConfigFile.toString())));
|
||||
|
||||
assertEquals(0, exitCode,
|
||||
"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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runGui_withoutConfigPath_startsGuiWithEmptyNotice() {
|
||||
AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
receivedNotice.set(startupNotice);
|
||||
}
|
||||
});
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.empty()));
|
||||
|
||||
assertEquals(0, exitCode, "GUI without --config must return exit code 0 on normal shutdown");
|
||||
assertFalse(receivedNotice.get().isPresent(),
|
||||
"No startup notice must be forwarded when no --config is supplied");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Creates a {@link BootstrapRunner} wired with a controllable GUI adapter factory
|
||||
* and stub factories for all headless-path dependencies.
|
||||
*/
|
||||
private BootstrapRunner runnerWithGuiFactory(BootstrapRunner.GuiAdapterFactory guiAdapterFactory) {
|
||||
return new BootstrapRunner(
|
||||
path -> { /* no-op migration */ },
|
||||
configPath -> { throw new AssertionError("ConfigurationPort must not be called in GUI mode"); },
|
||||
lockFile -> { throw new AssertionError("RunLockPort must not be called in GUI mode"); },
|
||||
() -> { throw new AssertionError("Validator must not be called in GUI mode"); },
|
||||
jdbcUrl -> { throw new AssertionError("SchemaInitPort must not be called in GUI mode"); },
|
||||
(config, lock) -> { throw new AssertionError("UseCaseFactory must not be called in GUI mode"); },
|
||||
useCase -> { throw new AssertionError("CommandFactory must not be called in GUI mode"); },
|
||||
guiAdapterFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal valid {@link ConfigurationPort} backed by real temp-dir paths.
|
||||
*/
|
||||
private ConfigurationPort buildValidConfigPort() {
|
||||
try {
|
||||
Files.createDirectories(tempDir.resolve("source"));
|
||||
Files.createDirectories(tempDir.resolve("target"));
|
||||
Path sqliteFile = tempDir.resolve("db.sqlite");
|
||||
if (!Files.exists(sqliteFile)) {
|
||||
Files.createFile(sqliteFile);
|
||||
}
|
||||
Path promptFile = tempDir.resolve("prompt.txt");
|
||||
if (!Files.exists(promptFile)) {
|
||||
Files.createFile(promptFile);
|
||||
}
|
||||
ProviderConfiguration providerConfig = new ProviderConfiguration(
|
||||
"gpt-4", 30, "https://api.example.com", "test-key");
|
||||
MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
|
||||
AiProviderFamily.OPENAI_COMPATIBLE, providerConfig, null);
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
tempDir.resolve("source"),
|
||||
tempDir.resolve("target"),
|
||||
sqliteFile,
|
||||
multiConfig,
|
||||
3, 100, 50000,
|
||||
promptFile,
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
false
|
||||
);
|
||||
return () -> config;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to build valid ConfigurationPort for test", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MockRunLockPort implements de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort {
|
||||
@Override public void acquire() {}
|
||||
@Override public void release() {}
|
||||
}
|
||||
|
||||
private static class MockSchemaInitializationPort
|
||||
implements de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort {
|
||||
@Override public void initializeSchema() {}
|
||||
}
|
||||
|
||||
private static class MockRunBatchProcessingUseCase
|
||||
implements de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase {
|
||||
private final boolean success;
|
||||
MockRunBatchProcessingUseCase(boolean success) { this.success = success; }
|
||||
@Override
|
||||
public BatchRunOutcome execute(de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context) {
|
||||
return success ? BatchRunOutcome.SUCCESS : BatchRunOutcome.FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -80,7 +80,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
AtomicReference<Path> capturedLockPath = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> () -> configWithNullLock,
|
||||
configPath -> () -> configWithNullLock,
|
||||
lockFile -> {
|
||||
capturedLockPath.set(lockFile);
|
||||
return new MockRunLockPort();
|
||||
@@ -190,7 +190,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -209,7 +209,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
void run_distinguishesBetweenConfigLoadingAndValidationFailure() throws Exception {
|
||||
// Test 1: Configuration loading exception
|
||||
BootstrapRunner runnerLoadFailure = new BootstrapRunner(
|
||||
() -> {
|
||||
configPath -> {
|
||||
throw new ConfigurationLoadingException("Load failed");
|
||||
},
|
||||
lockFile -> new MockRunLockPort(),
|
||||
@@ -224,7 +224,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
|
||||
// Test 2: Configuration validation exception
|
||||
BootstrapRunner runnerValidationFailure = new BootstrapRunner(
|
||||
() -> () -> {
|
||||
configPath -> () -> {
|
||||
try {
|
||||
Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
|
||||
Path targetDir = Files.createDirectories(tempDir.resolve("target"));
|
||||
@@ -259,7 +259,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new PersistenceSchemaInitializationPort() {
|
||||
@@ -286,7 +286,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -302,7 +302,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -318,7 +318,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -377,7 +377,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ctx.updateLoggers();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -429,7 +429,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ctx.updateLoggers();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -466,7 +466,7 @@ class BootstrapRunnerEdgeCasesTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.out.bootstrap.validation.StartConfigurationValidator;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
||||
|
||||
/**
|
||||
* Unit tests for Bootstrap startup mode dispatch in {@link BootstrapRunner}.
|
||||
* <p>
|
||||
* Covers the two startup paths (GUI and headless) and the config path resolution
|
||||
* logic, using controlled factory injection to avoid real JavaFX and file I/O.
|
||||
*/
|
||||
class BootstrapRunnerStartupDispatchTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
// --- GUI dispatch ---
|
||||
|
||||
@Test
|
||||
void run_withGuiMode_returnsZeroWhenGuiStartSucceeds() {
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
// normal termination: returns without throwing
|
||||
}
|
||||
});
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.empty()));
|
||||
|
||||
assertEquals(0, exitCode, "Normal GUI shutdown should return exit code 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void run_withGuiMode_returnsOneWhenGuiStartThrowsException() {
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
|
||||
@Override
|
||||
public void start(Optional<String> startupNotice) {
|
||||
throw new RuntimeException("simulated GUI startup failure");
|
||||
}
|
||||
});
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.empty()));
|
||||
|
||||
assertEquals(1, exitCode, "GUI startup exception should return exit code 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void run_withGuiMode_returnsOneWhenGuiFactoryThrowsException() {
|
||||
BootstrapRunner runner = runnerWithGuiFactory(() -> {
|
||||
throw new IllegalStateException("simulated factory failure");
|
||||
});
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.empty()));
|
||||
|
||||
assertEquals(1, exitCode, "GuiAdapterFactory exception should return exit code 1");
|
||||
}
|
||||
|
||||
// --- Headless dispatch ---
|
||||
|
||||
@Test
|
||||
void run_withHeadlessMode_runsHeadlessBatch() throws Exception {
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> buildValidConfigPort(),
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
useCase -> new SchedulerBatchCommand(useCase)
|
||||
);
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
|
||||
|
||||
assertEquals(0, exitCode, "Successful headless batch should return exit code 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void run_withHeadlessMode_configPathOverrideIsPassedToFactory() throws Exception {
|
||||
AtomicReference<Path> receivedPath = new AtomicReference<>();
|
||||
// The file must exist: headless mode requires the --config file to be present.
|
||||
Path existingConfigFile = Files.createFile(tempDir.resolve("custom-config.properties"));
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> {
|
||||
receivedPath.set(configPath);
|
||||
return buildValidConfigPort();
|
||||
},
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
useCase -> new SchedulerBatchCommand(useCase)
|
||||
);
|
||||
|
||||
runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.of(existingConfigFile.toString())));
|
||||
|
||||
assertEquals(existingConfigFile, receivedPath.get(),
|
||||
"The existing config path override should be passed to the ConfigurationPortFactory");
|
||||
}
|
||||
|
||||
@Test
|
||||
void run_withHeadlessMode_usesDefaultConfigPathWhenNoOverride() {
|
||||
AtomicReference<Path> receivedPath = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
configPath -> {
|
||||
receivedPath.set(configPath);
|
||||
return buildValidConfigPort();
|
||||
},
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
useCase -> new SchedulerBatchCommand(useCase)
|
||||
);
|
||||
|
||||
runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
|
||||
|
||||
assertEquals(Paths.get("config/application.properties"), receivedPath.get(),
|
||||
"Default config path should be used when no --config override is present");
|
||||
}
|
||||
|
||||
// --- Headless isolation: GUI adapter factory must not be invoked ---
|
||||
|
||||
/**
|
||||
* Verifies that the GUI adapter factory is never invoked during a headless startup.
|
||||
* <p>
|
||||
* This is the automated proof that the headless start path does not initialize the
|
||||
* JavaFX Application class. The {@link BootstrapRunner.GuiAdapterFactory} is the sole
|
||||
* gateway through which the JavaFX lifecycle would be entered. If the factory is never
|
||||
* called, the JavaFX Application class is never loaded or initialized in the headless path.
|
||||
* <p>
|
||||
* A tracking factory is injected that records whether it was called. The test runs a
|
||||
* full headless batch cycle and then asserts the factory was not invoked.
|
||||
*/
|
||||
@Test
|
||||
void headlessStart_doesNotInvokeGuiAdapterFactory() {
|
||||
AtomicReference<Boolean> guiFactoryCalled = new AtomicReference<>(false);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
path -> { /* no-op migration */ },
|
||||
configPath -> buildValidConfigPort(),
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
(config, lock) -> new MockRunBatchProcessingUseCase(true),
|
||||
useCase -> new SchedulerBatchCommand(useCase),
|
||||
() -> {
|
||||
guiFactoryCalled.set(true);
|
||||
throw new AssertionError(
|
||||
"GuiAdapterFactory must not be invoked during headless startup — "
|
||||
+ "invoking it would trigger the JavaFX Application class lifecycle");
|
||||
}
|
||||
);
|
||||
|
||||
int exitCode = runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
|
||||
|
||||
assertEquals(0, exitCode, "Headless batch must complete successfully");
|
||||
assertFalse(guiFactoryCalled.get(),
|
||||
"GuiAdapterFactory must not be invoked during headless startup: "
|
||||
+ "the JavaFX Application class must not be initialized in the headless path");
|
||||
}
|
||||
|
||||
// --- resolveEffectiveConfigPath static helper ---
|
||||
|
||||
@Test
|
||||
void resolveEffectiveConfigPath_returnsOverrideWhenPresent() {
|
||||
Path result = BootstrapRunner.resolveEffectiveConfigPath(Optional.of("custom/my.properties"));
|
||||
assertEquals(Paths.get("custom/my.properties"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveEffectiveConfigPath_returnsDefaultWhenEmpty() {
|
||||
Path result = BootstrapRunner.resolveEffectiveConfigPath(Optional.empty());
|
||||
assertEquals(Paths.get("config/application.properties"), result);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Builds a minimal valid {@link ConfigurationPort} backed by real temp-dir paths
|
||||
* so that headless dispatch tests can pass schema initialization and validation.
|
||||
*/
|
||||
private ConfigurationPort buildValidConfigPort() {
|
||||
try {
|
||||
java.nio.file.Files.createDirectories(tempDir.resolve("source"));
|
||||
java.nio.file.Files.createDirectories(tempDir.resolve("target"));
|
||||
Path sqliteFile = tempDir.resolve("db.sqlite");
|
||||
if (!java.nio.file.Files.exists(sqliteFile)) {
|
||||
java.nio.file.Files.createFile(sqliteFile);
|
||||
}
|
||||
Path promptFile = tempDir.resolve("prompt.txt");
|
||||
if (!java.nio.file.Files.exists(promptFile)) {
|
||||
java.nio.file.Files.createFile(promptFile);
|
||||
}
|
||||
ProviderConfiguration providerConfig = new ProviderConfiguration(
|
||||
"gpt-4", 30, "https://api.example.com", "test-key");
|
||||
MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
|
||||
AiProviderFamily.OPENAI_COMPATIBLE, providerConfig, null);
|
||||
StartConfiguration config = new StartConfiguration(
|
||||
tempDir.resolve("source"),
|
||||
tempDir.resolve("target"),
|
||||
sqliteFile,
|
||||
multiConfig,
|
||||
3, 100, 50000,
|
||||
promptFile,
|
||||
tempDir.resolve("lock.lock"),
|
||||
tempDir.resolve("logs"),
|
||||
"INFO",
|
||||
false
|
||||
);
|
||||
return () -> config;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to build valid ConfigurationPort for test", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link BootstrapRunner} wired with a controllable GUI factory and
|
||||
* stub factories for all headless-path dependencies (which are not exercised in GUI tests).
|
||||
*/
|
||||
private BootstrapRunner runnerWithGuiFactory(BootstrapRunner.GuiAdapterFactory guiAdapterFactory) {
|
||||
return new BootstrapRunner(
|
||||
path -> { /* no-op migration */ },
|
||||
configPath -> { throw new AssertionError("ConfigurationPort must not be called in GUI mode"); },
|
||||
lockFile -> { throw new AssertionError("RunLockPort must not be called in GUI mode"); },
|
||||
() -> { throw new AssertionError("Validator must not be called in GUI mode"); },
|
||||
jdbcUrl -> { throw new AssertionError("SchemaInitPort must not be called in GUI mode"); },
|
||||
(config, lock) -> { throw new AssertionError("UseCaseFactory must not be called in GUI mode"); },
|
||||
useCase -> { throw new AssertionError("CommandFactory must not be called in GUI mode"); },
|
||||
guiAdapterFactory
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared mock helpers (mirroring BootstrapRunnerTest pattern) ---
|
||||
|
||||
private static class MockRunLockPort implements de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort {
|
||||
@Override public void acquire() {}
|
||||
@Override public void release() {}
|
||||
}
|
||||
|
||||
private static class MockSchemaInitializationPort
|
||||
implements de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort {
|
||||
@Override public void initializeSchema() {}
|
||||
}
|
||||
|
||||
private static class MockRunBatchProcessingUseCase
|
||||
implements de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase {
|
||||
private final boolean success;
|
||||
MockRunBatchProcessingUseCase(boolean success) { this.success = success; }
|
||||
@Override
|
||||
public BatchRunOutcome execute(de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context) {
|
||||
return success ? BatchRunOutcome.SUCCESS : BatchRunOutcome.FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -58,7 +58,7 @@ class BootstrapRunnerTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -83,7 +83,7 @@ class BootstrapRunnerTest {
|
||||
};
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
() -> failingValidator,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -103,7 +103,7 @@ class BootstrapRunnerTest {
|
||||
};
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> failingConfigPort,
|
||||
configPath -> failingConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -123,7 +123,7 @@ class BootstrapRunnerTest {
|
||||
};
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> throwingConfigPort,
|
||||
configPath -> throwingConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -142,7 +142,7 @@ class BootstrapRunnerTest {
|
||||
BatchRunProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE;
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -162,7 +162,7 @@ class BootstrapRunnerTest {
|
||||
BatchRunProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE;
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -209,7 +209,7 @@ class BootstrapRunnerTest {
|
||||
// ConfigurationPortFactory returns a ConfigurationPort; ConfigurationPort returns StartConfiguration
|
||||
ConfigurationPort configPort = () -> configWithEmptyLock;
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> configPort,
|
||||
configPath -> configPort,
|
||||
lockFile -> {
|
||||
capturedLockPath.set(lockFile);
|
||||
return new MockRunLockPort();
|
||||
@@ -241,7 +241,7 @@ class BootstrapRunnerTest {
|
||||
BatchRunProcessingUseCase useCaseWithDocumentFailures = (context) -> BatchRunOutcome.SUCCESS;
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -288,7 +288,7 @@ class BootstrapRunnerTest {
|
||||
);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> () -> configWithZeroRetries, // ConfigurationPortFactory → ConfigurationPort → StartConfiguration
|
||||
configPath -> () -> configWithZeroRetries,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new, // use the real validator
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -310,7 +310,7 @@ class BootstrapRunnerTest {
|
||||
@Test
|
||||
void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() {
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> {
|
||||
configPath -> {
|
||||
throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException(
|
||||
"Invalid value for log.ai.sensitive: 'maybe'. "
|
||||
+ "Must be either 'true' or 'false' (case-insensitive).");
|
||||
@@ -339,7 +339,7 @@ class BootstrapRunnerTest {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new PersistenceSchemaInitializationPort() {
|
||||
@@ -369,7 +369,7 @@ class BootstrapRunnerTest {
|
||||
void activeProviderIsLoggedAtRunStart() throws Exception {
|
||||
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> mockConfigPort,
|
||||
configPath -> mockConfigPort,
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
@@ -445,8 +445,8 @@ class BootstrapRunnerTest {
|
||||
Files.writeString(configFile, legacyConfig);
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile),
|
||||
() -> new PropertiesConfigurationPortAdapter(configFile),
|
||||
path -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile),
|
||||
path -> new PropertiesConfigurationPortAdapter(configFile),
|
||||
lockFile -> new MockRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
jdbcUrl -> new MockSchemaInitializationPort(),
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ class BootstrapSmokeTest {
|
||||
AtomicReference<AiInvocationPort> capturedPort = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE,
|
||||
configPath -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE,
|
||||
openAiConfig(), null),
|
||||
lockFile -> new NoOpRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
@@ -104,7 +104,7 @@ class BootstrapSmokeTest {
|
||||
AtomicReference<AiInvocationPort> capturedPort = new AtomicReference<>();
|
||||
|
||||
BootstrapRunner runner = new BootstrapRunner(
|
||||
() -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE,
|
||||
configPath -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE,
|
||||
null, claudeConfig()),
|
||||
lockFile -> new NoOpRunLockPort(),
|
||||
StartConfigurationValidator::new,
|
||||
|
||||
+4
-2
@@ -110,11 +110,12 @@ class ExecutableJarSmokeTestIT {
|
||||
|
||||
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar);
|
||||
|
||||
// Build the java -jar command
|
||||
// Build the java -jar command — use --headless because the GUI is not yet functional
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(JAVA_EXECUTABLE);
|
||||
command.add("-jar");
|
||||
command.add(shadedJar.toString());
|
||||
command.add("--headless");
|
||||
|
||||
// Run the process in the work directory where config/application.properties will be found
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
@@ -233,11 +234,12 @@ class ExecutableJarSmokeTestIT {
|
||||
|
||||
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar);
|
||||
|
||||
// Build the java -jar command
|
||||
// Build the java -jar command — use --headless because the GUI is not yet functional
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(JAVA_EXECUTABLE);
|
||||
command.add("-jar");
|
||||
command.add(shadedJar.toString());
|
||||
command.add("--headless");
|
||||
|
||||
// Run the process
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link CliArgumentParser}.
|
||||
* <p>
|
||||
* Covers all supported option combinations and all documented error cases.
|
||||
*/
|
||||
class CliArgumentParserTest {
|
||||
|
||||
private final CliArgumentParser parser = new CliArgumentParser();
|
||||
|
||||
// =========================================================================
|
||||
// Valid combinations
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void noArgs_returnsGuiModeWithNoConfigPath() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{});
|
||||
|
||||
StartupArguments args = assertValid(result);
|
||||
assertEquals(StartupMode.GUI, args.mode(), "No args must default to GUI mode");
|
||||
assertEquals(Optional.empty(), args.configPath(), "No args must produce empty configPath");
|
||||
}
|
||||
|
||||
@Test
|
||||
void headlessOnly_returnsHeadlessModeWithNoConfigPath() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--headless"});
|
||||
|
||||
StartupArguments args = assertValid(result);
|
||||
assertEquals(StartupMode.HEADLESS, args.mode(), "--headless must set HEADLESS mode");
|
||||
assertEquals(Optional.empty(), args.configPath(), "--headless without --config must produce empty configPath");
|
||||
}
|
||||
|
||||
@Test
|
||||
void configOnly_returnsGuiModeWithConfigPath() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--config", "config/app.properties"});
|
||||
|
||||
StartupArguments args = assertValid(result);
|
||||
assertEquals(StartupMode.GUI, args.mode(), "--config alone must default to GUI mode");
|
||||
assertEquals(Optional.of("config/app.properties"), args.configPath(),
|
||||
"--config must supply the given path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void headlessThenConfig_returnsHeadlessModeWithConfigPath() {
|
||||
StartupArgumentsParseResult result = parser.parse(
|
||||
new String[]{"--headless", "--config", "C:\\config\\app.properties"});
|
||||
|
||||
StartupArguments args = assertValid(result);
|
||||
assertEquals(StartupMode.HEADLESS, args.mode());
|
||||
assertEquals(Optional.of("C:\\config\\app.properties"), args.configPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configThenHeadless_returnsHeadlessModeWithConfigPath() {
|
||||
StartupArgumentsParseResult result = parser.parse(
|
||||
new String[]{"--config", "my.properties", "--headless"});
|
||||
|
||||
StartupArguments args = assertValid(result);
|
||||
assertEquals(StartupMode.HEADLESS, args.mode(),
|
||||
"--headless after --config must still produce HEADLESS mode");
|
||||
assertEquals(Optional.of("my.properties"), args.configPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configWithAbsolutePath_acceptsWindowsPathWithDriveLetter() {
|
||||
StartupArgumentsParseResult result = parser.parse(
|
||||
new String[]{"--config", "S:\\shared\\config\\application.properties"});
|
||||
|
||||
StartupArguments args = assertValid(result);
|
||||
assertEquals(Optional.of("S:\\shared\\config\\application.properties"), args.configPath(),
|
||||
"Mapped drive paths must be accepted as config path values");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Invalid combinations
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
void configWithoutValue_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--config"});
|
||||
|
||||
assertInvalid(result, "--config");
|
||||
}
|
||||
|
||||
@Test
|
||||
void configFollowedByAnotherOption_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--config", "--headless"});
|
||||
|
||||
assertInvalid(result, "--config");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownArgument_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--unknown"});
|
||||
|
||||
assertInvalid(result, "--unknown");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPositionalArgument_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"somevalue"});
|
||||
|
||||
assertInvalid(result, "somevalue");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateHeadless_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--headless", "--headless"});
|
||||
|
||||
assertInvalid(result, "--headless");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateConfig_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(
|
||||
new String[]{"--config", "a.properties", "--config", "b.properties"});
|
||||
|
||||
assertInvalid(result, "--config");
|
||||
}
|
||||
|
||||
@Test
|
||||
void configWithBlankValue_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(new String[]{"--config", " "});
|
||||
|
||||
assertInvalid(result, "--config");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownArgAfterValidArgs_returnsInvalid() {
|
||||
StartupArgumentsParseResult result = parser.parse(
|
||||
new String[]{"--headless", "--config", "app.properties", "--extra"});
|
||||
|
||||
assertInvalid(result, "--extra");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Asserts the result is Valid and returns the contained {@link StartupArguments}.
|
||||
*/
|
||||
private static StartupArguments assertValid(StartupArgumentsParseResult result) {
|
||||
assertInstanceOf(StartupArgumentsParseResult.Valid.class, result,
|
||||
"Expected Valid parse result but got: " + result);
|
||||
return ((StartupArgumentsParseResult.Valid) result).arguments();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the result is Invalid and that the error message contains the given fragment.
|
||||
*/
|
||||
private static void assertInvalid(StartupArgumentsParseResult result, String expectedFragment) {
|
||||
assertInstanceOf(StartupArgumentsParseResult.Invalid.class, result,
|
||||
"Expected Invalid parse result but got: " + result);
|
||||
String errorMessage = ((StartupArgumentsParseResult.Invalid) result).errorMessage();
|
||||
assertTrue(errorMessage.contains(expectedFragment),
|
||||
"Error message should contain '" + expectedFragment + "' but was: " + errorMessage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user