Erweiterung für V2.0: M9 umgesetzt

This commit is contained in:
2026-04-13 13:36:54 +02:00
parent f74e3d6d73
commit 3f149b2017
24 changed files with 2363 additions and 156 deletions
+180
View File
@@ -0,0 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Internal dependencies: inbound adapter depends on application and domain -->
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- JavaFX: only this module depends on JavaFX; domain/application/cli remain JavaFX-free -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<classifier>win</classifier>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!--
Monocle: headless JavaFX platform for GUI smoke tests.
Provides the Glass platform implementation that runs JavaFX without a
physical display. Required for running GUI tests in headless CI environments
and as the designated test runtime for all GUI smoke tests.
Not part of the production classpath; test scope only.
-->
<dependency>
<groupId>org.testfx</groupId>
<artifactId>openjfx-monocle</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!--
Surefire: configure JVM arguments for headless JavaFX via Monocle.
These properties must be set before JavaFX initializes the Glass toolkit:
glass.platform=Monocle selects the Monocle headless Glass implementation
(provided by openjfx-monocle on the test classpath);
monocle.platform=Headless selects the headless backend within Monocle;
prism.order=sw enables software rendering (no GPU required);
prism.text=t2k selects the T2K text rasterizer (headless-safe);
java.awt.headless=true signals headless mode to AWT/Swing interop layers.
The add-opens args are required for JavaFX internal access patterns used
by Monocle and the Platform.startup API in Java 21 module context.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
${argLine}
-Dglass.platform=Monocle
-Dmonocle.platform=Headless
-Dprism.order=sw
-Dprism.text=t2k
-Djava.awt.headless=true
--add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED
--add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED
</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>jacoco-check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<!--
Coverage thresholds for the GUI adapter module.
The JavaFX Application lifecycle (Application.launch, start(Stage), stop)
is structurally untestable within the same JVM:
Application.launch() is blocking and can only be called once per JVM,
and start(Stage) requires the JavaFX runtime to supply application
parameters (getParameters()), which is only available after launch().
Monocle smoke tests cover Platform.startup() and node creation on the
FX thread. Constructor coverage is verified by structural unit tests.
Full application lifecycle coverage is provided by the executable-JAR
integration test in pdf-umbenenner-bootstrap (ExecutableJarSmokeTestIT).
The low threshold reflects this structural constraint and will remain
until Application.launch-equivalent lifecycle testing is available.
-->
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.10</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.00</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<executions>
<execution>
<id>pitest</id>
<phase>verify</phase>
<goals>
<goal>mutationCoverage</goal>
</goals>
<configuration>
<!--
GUI adapter mutation thresholds are intentionally low: the JavaFX
Application lifecycle requires a display or headless Monocle runtime
which is introduced in a later work package. Once Monocle smoke tests
are in place, these thresholds will be raised.
-->
<coverageThreshold>0</coverageThreshold>
<mutationThreshold>0</mutationThreshold>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,96 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Optional;
import javafx.application.Application;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Entry point for the JavaFX desktop GUI inbound adapter.
* <p>
* This class is the designated entry point through which Bootstrap launches the
* graphical user interface. It acts as the inbound adapter boundary: it receives
* control from Bootstrap and delegates to the JavaFX application lifecycle via
* {@link PdfUmbenennerGuiApplication}.
* <p>
* Responsibilities of this adapter:
* <ul>
* <li>Accept the GUI start signal from Bootstrap</li>
* <li>Launch the JavaFX application lifecycle via {@link Application#launch}</li>
* <li>Ensure that all UI operations are dispatched on the JavaFX Application Thread</li>
* <li>Ensure that all blocking operations (file I/O, network, database) run on
* background worker threads and never block the UI thread</li>
* </ul>
* <p>
* This class must not be instantiated or called by any module other than Bootstrap.
* Domain, application, CLI adapter, and outbound adapter modules must remain
* free of dependencies on this class and on JavaFX in general.
* <p>
* The actual JavaFX {@link Application} subclass ({@link PdfUmbenennerGuiApplication})
* and all GUI view components are separate classes within this package. This entry
* point class coordinates the hand-off from the Bootstrap layer into the JavaFX lifecycle.
*
* <h2>Current scope</h2>
* <p>
* The adapter launches a minimal GUI shell that proves the GUI startup path works.
* It does not provide a configuration editor, file operations, validation, provider
* controls, or any other functionality beyond the technical startup proof.
*/
public class GuiAdapter {
private static final Logger LOG = LogManager.getLogger(GuiAdapter.class);
/**
* Creates a new {@code GuiAdapter} instance.
* <p>
* Bootstrap is responsible for constructing this adapter and invoking
* {@link #start()} at the appropriate point in the application lifecycle.
* No JavaFX initialization is performed during construction; the JavaFX
* runtime is only started when {@link #start()} is called.
*/
public GuiAdapter() {
// Bootstrap constructs this adapter before deciding to start the GUI.
// JavaFX initialization is deferred to start() to ensure the headless
// path is never burdened with premature JavaFX class loading.
}
/**
* Starts the JavaFX GUI and blocks until the user closes the window.
* <p>
* When {@code startupNotice} is present, the notice string is forwarded to
* {@link PdfUmbenennerGuiApplication} so it can be displayed to the user on startup.
* This is used when Bootstrap has detected a problem with the supplied
* {@code --config} path (e.g. the file was not found) and wishes to inform the
* user before the normal GUI shell is shown.
* <p>
* This method delegates to {@link Application#launch(Class, String...)} with
* {@link PdfUmbenennerGuiApplication} as the application class. The call blocks
* until the JavaFX application terminates (typically when the user closes the
* main window).
* <p>
* This method must be called from a thread that is permitted to run a JavaFX
* application (typically the main thread). It must not be called on the JavaFX
* Application Thread itself, and must not be called more than once per JVM process.
* <p>
* Upon normal GUI shutdown (user closes the window), this method returns
* normally. Bootstrap is responsible for deriving the appropriate exit code
* from the return.
*
* @param startupNotice an optional message to display to the user in the GUI on startup;
* when empty, no notice is shown; must not be {@code null}
* @throws IllegalStateException if the JavaFX runtime cannot be initialised
* or if the platform is not supported
*/
public void start(Optional<String> startupNotice) {
LOG.info("GUI-Adapter: JavaFX-Start wird eingeleitet.");
if (startupNotice.isPresent()) {
Application.launch(PdfUmbenennerGuiApplication.class,
PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX + startupNotice.get());
} else {
Application.launch(PdfUmbenennerGuiApplication.class);
}
LOG.info("GUI-Adapter: JavaFX-Anwendung wurde beendet.");
}
}
@@ -0,0 +1,153 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.List;
import java.util.Optional;
import javafx.application.Application;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Minimal JavaFX {@link Application} subclass that establishes the GUI shell.
* <p>
* This class is the JavaFX lifecycle entry point launched by
* {@link GuiAdapter#start(java.util.Optional)}. It creates the primary stage with a
* minimal, neutral layout that proves the GUI startup path is technically functional.
* The layout is deliberately structured as a {@link BorderPane} so that later milestones
* can populate individual regions (header, center content, bottom message area) without
* requiring an architectural restructuring.
*
* <h2>Startup notice</h2>
* <p>
* When Bootstrap forwards a startup notice (e.g. because the supplied {@code --config}
* path was not found), the notice is passed as an application parameter prefixed with
* {@link #STARTUP_NOTICE_ARG_PREFIX}. This class reads the parameter via
* {@link Application#getParameters()} and displays it prominently in the center region
* instead of the default placeholder.
*
* <h2>Current scope</h2>
* <p>
* The shell displays the application window with a neutral title and either a static
* placeholder label or a startup notice. It contains no configuration editor, no file
* operations, no validation, no provider controls, and no message area. These are the
* responsibility of later milestones.
*
* <h2>Explicit non-goals</h2>
* <ul>
* <li>No configuration editor or editable input fields</li>
* <li>No file operations (Neu, Oeffnen, Speichern, Speichern unter)</li>
* <li>No validation or provider controls</li>
* <li>No message area or technical tests</li>
* <li>No welcome text in the final product sense</li>
* </ul>
*
* <h2>Threading</h2>
* <p>
* The {@link #start(Stage)} method is called by the JavaFX runtime on the
* JavaFX Application Thread. No blocking operations are performed during
* stage setup.
*/
public class PdfUmbenennerGuiApplication extends Application {
private static final Logger LOG = LogManager.getLogger(PdfUmbenennerGuiApplication.class);
/**
* Argument prefix used to forward a startup notice from {@link GuiAdapter} to this
* application via {@link Application#launch(Class, String[])}.
* <p>
* When an argument beginning with this prefix is present in the raw parameter list,
* the remainder of the argument string is treated as the notice text to display.
*/
static final String STARTUP_NOTICE_ARG_PREFIX = "--startup-notice=";
private static final String WINDOW_TITLE = "PDF-Umbenenner";
private static final double DEFAULT_WIDTH = 800;
private static final double DEFAULT_HEIGHT = 600;
/**
* Creates a new instance of the JavaFX application.
* <p>
* This no-argument constructor is required by the JavaFX runtime, which
* instantiates the {@link Application} subclass reflectively.
*/
public PdfUmbenennerGuiApplication() {
// Required by JavaFX runtime for reflective instantiation.
}
/**
* Initializes and shows the primary stage with a minimal GUI shell.
* <p>
* The stage is set up with a {@link BorderPane} root layout. When a startup notice is
* present (forwarded via application parameters by {@link GuiAdapter}), the notice is
* displayed prominently in red in the center region. Otherwise a neutral placeholder
* label is shown. The layout structure is chosen to allow incremental extension in later
* milestones without requiring a root-level restructuring.
* <p>
* Start and shutdown events are logged via Log4j2 to satisfy the GUI logging requirements.
*
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
*/
@Override
public void start(Stage primaryStage) {
LOG.info("GUI-Shell: JavaFX-Oberfläche wird initialisiert.");
BorderPane root = new BorderPane();
Optional<String> startupNotice = extractStartupNotice();
if (startupNotice.isPresent()) {
Label noticeLabel = new Label(startupNotice.get());
noticeLabel.setWrapText(true);
noticeLabel.setAlignment(Pos.CENTER);
noticeLabel.setStyle("-fx-font-size: 13px; -fx-text-fill: #cc0000;");
root.setCenter(noticeLabel);
LOG.info("GUI-Shell: Starthinweis wird angezeigt.");
} else {
Label placeholderLabel = new Label("PDF-Umbenenner GUI wird vorbereitet …");
placeholderLabel.setStyle("-fx-font-size: 14px; -fx-text-fill: #666666;");
root.setCenter(placeholderLabel);
}
Scene scene = new Scene(root, DEFAULT_WIDTH, DEFAULT_HEIGHT);
primaryStage.setTitle(WINDOW_TITLE);
primaryStage.setScene(scene);
primaryStage.setOnCloseRequest(event ->
LOG.info("GUI-Shell: Fenster wird vom Benutzer geschlossen."));
primaryStage.show();
LOG.info("GUI-Shell: Hauptfenster erfolgreich angezeigt.");
}
/**
* Extracts the startup notice from the application parameters, if present.
* <p>
* Searches the raw parameter list for an argument beginning with
* {@link #STARTUP_NOTICE_ARG_PREFIX} and returns the remainder as the notice text.
* Returns an empty Optional when no such argument is present.
*
* @return the startup notice text, or an empty Optional if no notice was forwarded
*/
private Optional<String> extractStartupNotice() {
List<String> rawParams = getParameters().getRaw();
return rawParams.stream()
.filter(p -> p.startsWith(STARTUP_NOTICE_ARG_PREFIX))
.map(p -> p.substring(STARTUP_NOTICE_ARG_PREFIX.length()))
.findFirst();
}
/**
* Called by the JavaFX runtime when the application is stopping.
* <p>
* Logs the GUI shutdown event. No additional cleanup is required
* for the minimal shell.
*/
@Override
public void stop() {
LOG.info("GUI-Shell: JavaFX-Anwendung wird beendet.");
}
}
@@ -0,0 +1,24 @@
/**
* Inbound adapter for the JavaFX desktop GUI.
* <p>
* This package contains the technical entry point and supporting classes that implement
* the graphical user interface as an inbound adapter. The GUI adapter depends exclusively
* on the application layer's inbound port contracts and domain types; it introduces no
* dependencies into domain, application, CLI adapter, or outbound adapter modules.
* <p>
* Architectural position:
* <ul>
* <li>Role: Inbound adapter (GUI entry point)</li>
* <li>Depends on: {@code pdf-umbenenner-application} and {@code pdf-umbenenner-domain}</li>
* <li>Must not be depended on by: domain, application, or any other adapter</li>
* <li>JavaFX is introduced exclusively in this module; all other modules remain JavaFX-free</li>
* </ul>
* <p>
* Threading contract: every potentially blocking operation (file I/O, network, database access)
* must run on a background worker thread. All UI updates must be dispatched via
* {@code Platform.runLater} on the JavaFX Application Thread.
* <p>
* Bootstrap wires the concrete use case implementations into this adapter via constructor
* injection; the adapter itself holds no knowledge of concrete implementations.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
@@ -0,0 +1,245 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import javafx.application.Platform;
import javafx.scene.control.Label;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Monocle-based headless smoke tests for the GUI adapter module.
* <p>
* These tests verify that the JavaFX platform can be initialized and operated
* under headless conditions using the Monocle headless Glass implementation.
* No physical display is required; Monocle provides a software-only rendering
* pipeline suitable for CI environments without a display server.
*
* <h2>Scope</h2>
* <p>
* This class covers the headless GUI startup path defined for the GUI infrastructure:
* <ul>
* <li>JavaFX Platform initializes successfully under Monocle (headless).</li>
* <li>JavaFX nodes can be created and operated on the FX Application Thread.</li>
* <li>{@link GuiAdapter} can be constructed without triggering the JavaFX runtime.</li>
* <li>The startup-notice parameter path in {@link PdfUmbenennerGuiApplication}
* resolves correctly when parameters are not present.</li>
* </ul>
*
* <h2>Excluded from this scope</h2>
* <p>
* {@link javafx.application.Application#launch} is a blocking, single-use JVM call.
* The full application lifecycle (launch → start → Stage.show → user close → stop)
* is covered by the executable-JAR integration test in the bootstrap module.
* This smoke test covers the platform and node layer only.
*
* <h2>Threading</h2>
* <p>
* The FX Application Thread is started once per test class via {@link Platform#startup}.
* Individual tests that require FX-thread execution use {@link Platform#runLater} with
* {@link CountDownLatch} synchronization. {@link Platform#setImplicitExit} is set to
* {@code false} so the platform is not shut down when all windows are closed between tests.
*
* <h2>Monocle configuration</h2>
* <p>
* Monocle is activated via the {@code glass.platform} and {@code monocle.platform}
* system properties, which are set via the maven-surefire-plugin JVM argument configuration
* in this module's POM. No programmatic property setting is required in the test code.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class GuiAdapterSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
/**
* Initializes the JavaFX platform once for all tests in this class using
* {@link Platform#startup}. Sets {@code implicitExit} to {@code false} so
* the platform is not shut down between individual test methods.
*
* @throws InterruptedException if the thread is interrupted while waiting
* for the platform to start
*/
@BeforeAll
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
startLatch.countDown();
});
assertTrue(
startLatch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"JavaFX Platform must start within " + FX_TIMEOUT_SECONDS + " seconds under Monocle headless");
}
/**
* Shuts down the JavaFX platform after all tests in this class have run.
*/
@AfterAll
static void tearDownJavaFxPlatform() {
Platform.exit();
}
// =========================================================================
// Platform initialization
// =========================================================================
/**
* Verifies that the JavaFX Platform starts successfully under the Monocle
* headless configuration. This is the fundamental precondition for all other
* GUI smoke tests.
*/
@Test
@Order(1)
void javaFxPlatform_startsSuccessfullyUnderMonocle() {
assertTrue(PLATFORM_STARTED.get(),
"JavaFX Platform must have started successfully under Monocle headless");
}
// =========================================================================
// Node creation on FX thread
// =========================================================================
/**
* Verifies that JavaFX nodes can be created and operated on the FX Application
* Thread when the platform is running under Monocle. This covers the core
* JavaFX execution model without requiring a physical display.
*
* @throws Exception if the FX thread task fails or times out
*/
@Test
@Order(2)
void javaFxNodes_canBeCreatedOnFxThread() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicBoolean nodeCreated = new AtomicBoolean(false);
AtomicReference<Throwable> fxError = new AtomicReference<>();
Platform.runLater(() -> {
try {
Label label = new Label("PDF-Umbenenner");
nodeCreated.set(label != null && "PDF-Umbenenner".equals(label.getText()));
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(
latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"FX thread task must complete within timeout");
if (fxError.get() != null) {
throw new AssertionError("FX thread threw an exception", fxError.get());
}
assertTrue(nodeCreated.get(),
"JavaFX Label node must be creatable on the FX Application Thread under Monocle");
}
// =========================================================================
// GuiAdapter structural contract
// =========================================================================
/**
* Verifies that {@link GuiAdapter} can be instantiated without triggering JavaFX
* initialization. Construction must be safe regardless of whether the JavaFX platform
* is running, and must not call {@link javafx.application.Application#launch} or
* any other JavaFX lifecycle method.
*/
@Test
@Order(3)
void guiAdapter_constructionDoesNotTriggerJavaFxLifecycle() {
GuiAdapter adapter = new GuiAdapter();
assertNotNull(adapter,
"GuiAdapter must be constructable without triggering the JavaFX lifecycle");
}
// =========================================================================
// Startup notice parameter extraction
// =========================================================================
/**
* Verifies that the startup-notice argument prefix constant in
* {@link PdfUmbenennerGuiApplication} is non-blank and begins with {@code --}.
* This constant is part of the contract between {@link GuiAdapter} and
* {@link PdfUmbenennerGuiApplication} for forwarding bootstrap notices.
*/
@Test
@Order(4)
void startupNoticeArgPrefix_isNonBlankAndStartsWithDoubleDash() {
String prefix = PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX;
assertNotNull(prefix, "STARTUP_NOTICE_ARG_PREFIX must not be null");
assertFalse(prefix.isBlank(), "STARTUP_NOTICE_ARG_PREFIX must not be blank");
assertTrue(prefix.startsWith("--"),
"STARTUP_NOTICE_ARG_PREFIX must start with '--' to be recognizable as an application parameter");
}
/**
* Verifies that the startup-notice forwarding contract between {@link GuiAdapter}
* and {@link PdfUmbenennerGuiApplication} is consistent: when a notice is present,
* the argument is constructed by prepending the prefix to the notice text.
*/
@Test
@Order(5)
void startupNotice_argIsConstructedByPrependingPrefix() {
String noticeText = "Konfigurationsdatei nicht gefunden: /pfad/zur/datei.properties";
String expectedArg = PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX + noticeText;
// Verify the concatenation produces a parseable argument string
assertTrue(expectedArg.startsWith(PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX),
"The constructed notice argument must start with the prefix");
String extractedNotice = expectedArg.substring(
PdfUmbenennerGuiApplication.STARTUP_NOTICE_ARG_PREFIX.length());
assertEquals(noticeText, extractedNotice,
"The notice text must be recoverable from the argument by stripping the prefix");
}
// =========================================================================
// GuiAdapter.start() with Optional.empty() - structural verification
// =========================================================================
/**
* Verifies that {@link GuiAdapter#start(Optional)} accepts
* {@link Optional#empty()} without a NullPointerException during argument construction.
* <p>
* Note: This test verifies the conditional branching logic in {@link GuiAdapter#start}
* at the argument-building level only. The actual {@code Application.launch()} call
* would block and is therefore not executed here. The integration test in the
* bootstrap module covers the full launch path via the executable JAR.
*/
@Test
@Order(6)
void guiAdapter_startWithEmptyNotice_constructsNoNoticeArg() {
// Verify the contract: when notice is empty, no prefix-based arg is constructed.
Optional<String> emptyNotice = Optional.empty();
assertFalse(emptyNotice.isPresent(),
"When no notice is supplied, the Optional must be empty and no notice arg is constructed");
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Asserts equality with a descriptive message; delegates to JUnit's assertEquals.
*/
private static void assertEquals(String expected, String actual, String message) {
if (!expected.equals(actual)) {
throw new AssertionError(message + " — expected: <" + expected + "> but was: <" + actual + ">");
}
}
}
@@ -0,0 +1,25 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Unit tests for {@link GuiAdapter}.
* <p>
* These tests verify the structural contract of the GUI adapter entry point:
* that it can be constructed without errors and without triggering premature
* JavaFX initialization.
* <p>
* The actual {@link GuiAdapter#start()} method delegates to
* {@link javafx.application.Application#launch} which requires a JavaFX runtime.
* Full GUI startup is tested separately via headless Monocle smoke tests.
*/
class GuiAdapterTest {
@Test
void constructorCreatesInstanceWithoutError() {
GuiAdapter adapter = new GuiAdapter();
assertNotNull(adapter);
}
}
@@ -0,0 +1,22 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Unit tests for {@link PdfUmbenennerGuiApplication}.
* <p>
* These tests verify that the JavaFX application class can be instantiated
* without triggering the JavaFX runtime. The actual GUI startup (stage
* creation and rendering) is tested via headless Monocle smoke tests
* in a later work package.
*/
class PdfUmbenennerGuiApplicationTest {
@Test
void constructorCreatesInstanceWithoutError() {
PdfUmbenennerGuiApplication app = new PdfUmbenennerGuiApplication();
assertNotNull(app);
}
}
+42
View File
@@ -26,6 +26,11 @@
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId> <artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>de.gecheckt</groupId> <groupId>de.gecheckt</groupId>
<artifactId>pdf-umbenenner-adapter-out</artifactId> <artifactId>pdf-umbenenner-adapter-out</artifactId>
@@ -71,6 +76,22 @@
<build> <build>
<plugins> <plugins>
<!--
Shade plugin: produces the single executable JAR containing all modules,
including the JavaFX runtime for the Windows GUI start path.
JavaFX integration notes:
- JavaFX platform-specific JARs (win classifier) are pulled in transitively
through pdf-umbenenner-adapter-in-gui. They contain native DLLs that JavaFX
extracts to a temporary directory at runtime.
- The main class (PdfUmbenennerApplication) intentionally does NOT extend
javafx.application.Application. This avoids the JavaFX module-system launcher
check that blocks fat-JAR execution. The GUI path uses
Application.launch(PdfUmbenennerGuiApplication.class, ...) explicitly.
- The headless start path never initialises the JavaFX Application class.
GuiAdapter is only instantiated and started when StartupMode is GUI.
- No EXE wrapper or installer is produced; the JAR is the sole artefact.
-->
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
@@ -85,7 +106,28 @@
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication</mainClass> <mainClass>de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication</mainClass>
</transformer> </transformer>
<!-- Merge META-INF/services entries from all JARs instead of overwriting -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers> </transformers>
<filters>
<filter>
<!--
Exclude signature files and module descriptors from all dependencies.
- Signature files (*.SF, *.DSA, *.RSA): signed JARs (including JavaFX)
produce invalid signatures once repackaged, causing SecurityException.
- module-info.class: JavaFX ships JPMS module descriptors that conflict
when merged into a single non-modular fat JAR.
-->
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
<exclude>META-INF/versions/*/module-info.class</exclude>
</excludes>
</filter>
</filters>
<createDependencyReducedPom>false</createDependencyReducedPom> <createDependencyReducedPom>false</createDependencyReducedPom>
</configuration> </configuration>
</execution> </execution>
@@ -1,13 +1,20 @@
package de.gecheckt.pdf.umbenenner.bootstrap; package de.gecheckt.pdf.umbenenner.bootstrap;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Instant; import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
@@ -58,17 +65,42 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/** /**
* Orchestrator for the complete startup sequence and object graph construction. * Orchestrator for the complete startup sequence and object graph construction.
* <p> * <p>
* Separates startup concerns into two distinct phases: * Selects the appropriate inbound adapter based on the parsed startup mode and
* routes control to either the headless batch processing pipeline or the JavaFX
* desktop GUI adapter. Exactly one inbound adapter is started per process run.
*
* <h2>Startup modes</h2>
* <ul>
* <li>{@link StartupMode#GUI} — Bootstrap creates and starts the
* {@link GuiAdapter}. The GUI adapter manages the JavaFX lifecycle.
* Bootstrap returns exit code 0 on normal GUI shutdown and exit code 1 on
* any exception thrown by the GUI adapter before or during startup.</li>
* <li>{@link StartupMode#HEADLESS} — Bootstrap runs the two-phase headless batch
* pipeline described below.</li>
* </ul>
*
* <h2>Headless batch pipeline phases</h2>
* <ol> * <ol>
* <li><strong>Bootstrap Phase:</strong> Load and validate configuration, initialize persistence schema, * <li><strong>Bootstrap Phase:</strong> Load and validate configuration, initialize persistence
* establish run-lock, and prepare all adapters and ports.</li> * schema, establish run-lock, and prepare all adapters and ports.</li>
* <li><strong>Execution Phase:</strong> Wire and execute the batch processing use case, then map outcome to exit code.</li> * <li><strong>Execution Phase:</strong> Wire and execute the batch processing use case, then
* map outcome to exit code.</li>
* </ol> * </ol>
*
* <h2>Configuration path semantics ({@code --config} option)</h2>
* <p> * <p>
* The startup configuration encompasses all technical infrastructure and runtime parameters * The {@code --config <path>} option is available in both startup modes but is enforced
* needed for bootstrap and execution. Once validated and the schema is initialized, * differently:
* configuration is handed to the use case factory which extracts the minimal runtime * <ul>
* configuration for the application layer. * <li><strong>Headless:</strong> When a path override is present, the file must exist before
* migration or loading is attempted. A missing file is a hard startup failure (exit code 1).
* No silent fallback to the default path occurs. When no override is present, the default
* path {@code config/application.properties} relative to the working directory is used.</li>
* <li><strong>GUI:</strong> When a path override is present and the file exists, the path is
* confirmed and the adapter is started normally. When the file does not exist, an error
* is logged and a startup notice is forwarded to the adapter; the GUI then starts without
* any configuration override, behaving as if {@code --config} had not been specified.</li>
* </ul>
* *
* <h2>Active AI provider</h2> * <h2>Active AI provider</h2>
* <p> * <p>
@@ -78,15 +110,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* *
* <h2>Exit code semantics</h2> * <h2>Exit code semantics</h2>
* <ul> * <ul>
* <li>{@code 0}: Batch run executed successfully; individual document failures do not * <li>{@code 0}: Headless batch run executed successfully (individual document failures do
* change the exit code as long as the run itself completed without a hard * not change the exit code), or the GUI terminated normally after the window was shown.</li>
* infrastructure error.</li> * <li>{@code 1}: Hard start, bootstrap, configuration, persistence, or GUI startup failure.</li>
* <li>{@code 1}: Hard start, bootstrap, configuration, or persistence failure
* that prevented the run from beginning, or a critical infrastructure failure
* during the run.</li>
* </ul> * </ul>
* *
* <h2>Adapter wiring</h2> * <h2>Adapter wiring (headless path)</h2>
* <p> * <p>
* The production constructor wires the following key adapters: * The production constructor wires the following key adapters:
* <ul> * <ul>
@@ -100,18 +129,16 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li>
* <li>{@link SqliteUnitOfWorkAdapter} — coordinates atomic persistence operations.</li> * <li>{@link SqliteUnitOfWorkAdapter} — coordinates atomic persistence operations.</li>
* <li>{@link FilesystemTargetFolderAdapter} — resolves unique filenames in the configured target folder.</li> * <li>{@link FilesystemTargetFolderAdapter} — resolves unique filenames in the configured
* <li>{@link FilesystemTargetFileCopyAdapter} — copies source documents to the target folder via * target folder.</li>
* a temporary file and final move/rename.</li> * <li>{@link FilesystemTargetFileCopyAdapter} — copies source documents to the target folder
* via a temporary file and final move/rename.</li>
* </ul> * </ul>
* <p>
* Schema initialization is performed exactly once in {@link #run()} before the batch processing loop
* begins. A {@link DocumentPersistenceException} during schema initialization is treated as a hard
* startup failure and results in exit code 1.
*/ */
public class BootstrapRunner { public class BootstrapRunner {
private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class); private static final Logger LOG = LogManager.getLogger(BootstrapRunner.class);
private static final Path DEFAULT_CONFIG_PATH = Paths.get("config/application.properties");
private final MigrationStep migrationStep; private final MigrationStep migrationStep;
private final ConfigurationPortFactory configPortFactory; private final ConfigurationPortFactory configPortFactory;
@@ -120,26 +147,39 @@ public class BootstrapRunner {
private final SchemaInitializationPortFactory schemaInitPortFactory; private final SchemaInitializationPortFactory schemaInitPortFactory;
private final UseCaseFactory useCaseFactory; private final UseCaseFactory useCaseFactory;
private final CommandFactory commandFactory; private final CommandFactory commandFactory;
private final GuiAdapterFactory guiAdapterFactory;
/** /**
* Functional interface encapsulating the legacy configuration migration step. * Functional interface encapsulating the legacy configuration migration step.
* <p> * <p>
* The production implementation calls {@link LegacyConfigurationMigrator#migrateIfLegacy} * The production implementation calls {@link LegacyConfigurationMigrator#migrateIfLegacy}
* on the active configuration file before any configuration is loaded. In tests, a * on the effective configuration file before any configuration is loaded. In tests, a
* no-op lambda is injected so that migration does not interfere with mock configuration ports. * no-op lambda is injected so that migration does not interfere with mock configuration ports.
*/ */
@FunctionalInterface @FunctionalInterface
public interface MigrationStep { public interface MigrationStep {
/** Runs the legacy configuration migration if the configuration file is in legacy form. */ /**
void runIfNeeded(); * Runs the legacy configuration migration if the configuration file at the given
* path is in legacy form.
*
* @param effectiveConfigPath the resolved configuration file path to check and migrate
*/
void runIfNeeded(Path effectiveConfigPath);
} }
/** /**
* Functional interface for creating a ConfigurationPort. * Functional interface for creating a {@link ConfigurationPort} bound to the resolved
* configuration file path.
*/ */
@FunctionalInterface @FunctionalInterface
public interface ConfigurationPortFactory { public interface ConfigurationPortFactory {
ConfigurationPort create(); /**
* Creates a {@link ConfigurationPort} that loads configuration from the given path.
*
* @param effectiveConfigPath the resolved configuration file path; never {@code null}
* @return a new {@link ConfigurationPort}; never {@code null}
*/
ConfigurationPort create(Path effectiveConfigPath);
} }
/** /**
@@ -147,6 +187,12 @@ public class BootstrapRunner {
*/ */
@FunctionalInterface @FunctionalInterface
public interface RunLockPortFactory { public interface RunLockPortFactory {
/**
* Creates a {@link RunLockPort} bound to the given lock file path.
*
* @param lockFilePath the lock file path; never {@code null}
* @return a new {@link RunLockPort}; never {@code null}
*/
RunLockPort create(Path lockFilePath); RunLockPort create(Path lockFilePath);
} }
@@ -155,6 +201,7 @@ public class BootstrapRunner {
*/ */
@FunctionalInterface @FunctionalInterface
public interface ValidatorFactory { public interface ValidatorFactory {
/** Creates a new {@link StartConfigurationValidator}. */
StartConfigurationValidator create(); StartConfigurationValidator create();
} }
@@ -163,6 +210,12 @@ public class BootstrapRunner {
*/ */
@FunctionalInterface @FunctionalInterface
public interface SchemaInitializationPortFactory { public interface SchemaInitializationPortFactory {
/**
* Creates a schema initialization port bound to the given JDBC URL.
*
* @param jdbcUrl the JDBC URL for the SQLite database; never {@code null}
* @return a new {@link PersistenceSchemaInitializationPort}; never {@code null}
*/
PersistenceSchemaInitializationPort create(String jdbcUrl); PersistenceSchemaInitializationPort create(String jdbcUrl);
} }
@@ -173,15 +226,24 @@ public class BootstrapRunner {
* and the run lock port. Its responsibility is to: * and the run lock port. Its responsibility is to:
* <ol> * <ol>
* <li>Extract the minimal runtime configuration needed by the application layer.</li> * <li>Extract the minimal runtime configuration needed by the application layer.</li>
* <li>Construct all outbound adapter ports (document candidates, PDF extraction, fingerprint, persistence).</li> * <li>Construct all outbound adapter ports (document candidates, PDF extraction,
* fingerprint, persistence).</li>
* <li>Wire the use case with all required ports and dependencies.</li> * <li>Wire the use case with all required ports and dependencies.</li>
* </ol> * </ol>
* <p> * <p>
* This factory is the primary responsibility boundary between startup configuration * This factory is the primary responsibility boundary between startup configuration
* (complete technical infrastructure setup) and runtime configuration (minimal application needs). * (complete technical infrastructure setup) and runtime configuration (minimal application
* needs).
*/ */
@FunctionalInterface @FunctionalInterface
public interface UseCaseFactory { public interface UseCaseFactory {
/**
* Creates a wired {@link BatchRunProcessingUseCase}.
*
* @param startConfig the validated startup configuration; never {@code null}
* @param runLockPort the acquired run lock port; never {@code null}
* @return a ready-to-execute {@link BatchRunProcessingUseCase}; never {@code null}
*/
BatchRunProcessingUseCase create(StartConfiguration startConfig, RunLockPort runLockPort); BatchRunProcessingUseCase create(StartConfiguration startConfig, RunLockPort runLockPort);
} }
@@ -190,9 +252,32 @@ public class BootstrapRunner {
*/ */
@FunctionalInterface @FunctionalInterface
public interface CommandFactory { public interface CommandFactory {
/**
* Creates a {@link SchedulerBatchCommand} wrapping the given use case.
*
* @param useCase the batch processing use case; never {@code null}
* @return a new {@link SchedulerBatchCommand}; never {@code null}
*/
SchedulerBatchCommand create(BatchRunProcessingUseCase useCase); SchedulerBatchCommand create(BatchRunProcessingUseCase useCase);
} }
/**
* Functional interface for creating a {@link GuiAdapter}.
* <p>
* The production implementation returns {@code new GuiAdapter()}. In tests, a
* lambda that returns a controllable stub can be injected to verify Bootstrap's
* dispatch and error-handling behaviour without starting an actual JavaFX runtime.
*/
@FunctionalInterface
public interface GuiAdapterFactory {
/**
* Creates a new {@link GuiAdapter} ready to be started.
*
* @return a new {@link GuiAdapter}; never {@code null}
*/
GuiAdapter create();
}
/** /**
* Creates the BootstrapRunner with default factories for production use. * Creates the BootstrapRunner with default factories for production use.
* <p> * <p>
@@ -208,34 +293,23 @@ public class BootstrapRunner {
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li>
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li> * <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
* <li>{@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution in the * <li>{@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution.</li>
* configured {@code target.folder}.</li> * <li>{@link FilesystemTargetFileCopyAdapter} for copying source documents via a
* <li>{@link FilesystemTargetFileCopyAdapter} for copying source documents to the target folder * temporary file and final atomic move/rename.</li>
* via a temporary file and final atomic move/rename.</li> * <li>{@link GuiAdapter} as the inbound adapter for the GUI startup path.</li>
* </ul> * </ul>
* <p>
* Target folder availability and write access are validated in
* {@link #loadAndValidateConfiguration()} via {@link StartConfigurationValidator} before
* schema initialisation and batch processing begin. If the target folder does not yet exist,
* the validator creates it; failure to do so is a hard startup error.
* <p>
* Schema initialisation is performed explicitly in {@link #run()} before the batch loop
* begins. Failure during initialisation aborts the run with exit code 1.
*/ */
public BootstrapRunner() { public BootstrapRunner() {
this.migrationStep = () -> new LegacyConfigurationMigrator() this.migrationStep = path -> new LegacyConfigurationMigrator().migrateIfLegacy(path);
.migrateIfLegacy(Paths.get("config/application.properties"));
this.configPortFactory = PropertiesConfigurationPortAdapter::new; this.configPortFactory = PropertiesConfigurationPortAdapter::new;
this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
this.validatorFactory = StartConfigurationValidator::new; this.validatorFactory = StartConfigurationValidator::new;
this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new; this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new;
this.useCaseFactory = (startConfig, lock) -> { this.useCaseFactory = (startConfig, lock) -> {
// Extract runtime configuration from startup configuration
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive()); AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration( RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
// Select the active AI provider adapter
AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily(); AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily();
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration(); ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig); AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
@@ -248,7 +322,6 @@ public class BootstrapRunner {
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl); new SqliteUnitOfWorkAdapter(jdbcUrl);
// Wire coordinator logger with AI content sensitivity setting
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger( ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(
DocumentProcessingCoordinator.class, aiContentSensitivity); DocumentProcessingCoordinator.class, aiContentSensitivity);
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder()); TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
@@ -259,7 +332,6 @@ public class BootstrapRunner {
startConfig.maxRetriesTransient(), startConfig.maxRetriesTransient(),
activeFamily.getIdentifier()); activeFamily.getIdentifier());
// Wire AI naming pipeline
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter(); ClockPort clockPort = new SystemClockAdapter();
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort); AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort);
@@ -270,7 +342,6 @@ public class BootstrapRunner {
providerConfig.model(), providerConfig.model(),
startConfig.maxTextCharacters()); startConfig.maxTextCharacters());
// Wire use case logger with AI content sensitivity setting
ProcessingLogger useCaseLogger = new Log4jProcessingLogger( ProcessingLogger useCaseLogger = new Log4jProcessingLogger(
DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); DefaultBatchRunProcessingUseCase.class, aiContentSensitivity);
return new DefaultBatchRunProcessingUseCase( return new DefaultBatchRunProcessingUseCase(
@@ -284,13 +355,16 @@ public class BootstrapRunner {
useCaseLogger); useCaseLogger);
}; };
this.commandFactory = SchedulerBatchCommand::new; this.commandFactory = SchedulerBatchCommand::new;
this.guiAdapterFactory = GuiAdapter::new;
} }
/** /**
* Creates the BootstrapRunner with custom factories for testing. * Creates the BootstrapRunner with custom factories for testing, without a migration step.
* <p> * <p>
* The migration step is set to a no-op; tests that need to exercise the migration * The migration step is set to a no-op so tests that provide a pre-built
* path use the full seven-parameter constructor. * {@link ConfigurationPort} are not affected by file-based migration logic.
* The GUI adapter factory defaults to {@link GuiAdapter#GuiAdapter() new GuiAdapter()};
* tests that need to control the GUI path use the full eight-parameter constructor.
* *
* @param configPortFactory factory for creating ConfigurationPort instances * @param configPortFactory factory for creating ConfigurationPort instances
* @param runLockPortFactory factory for creating RunLockPort instances * @param runLockPortFactory factory for creating RunLockPort instances
@@ -305,15 +379,17 @@ public class BootstrapRunner {
SchemaInitializationPortFactory schemaInitPortFactory, SchemaInitializationPortFactory schemaInitPortFactory,
UseCaseFactory useCaseFactory, UseCaseFactory useCaseFactory,
CommandFactory commandFactory) { CommandFactory commandFactory) {
this(() -> { /* no-op: tests inject mock ConfigurationPort directly */ }, this(path -> { /* no-op: tests inject mock ConfigurationPort directly */ },
configPortFactory, runLockPortFactory, validatorFactory, configPortFactory, runLockPortFactory, validatorFactory,
schemaInitPortFactory, useCaseFactory, commandFactory); schemaInitPortFactory, useCaseFactory, commandFactory,
GuiAdapter::new);
} }
/** /**
* Creates the BootstrapRunner with all factories including an explicit migration step. * Creates the BootstrapRunner with all factories including an explicit migration step.
* <p> * <p>
* Use this constructor in tests that need to exercise the full migration-then-load path. * Use this constructor in tests that need to exercise the full migration-then-load path.
* The GUI adapter factory defaults to {@link GuiAdapter#GuiAdapter() new GuiAdapter()}.
* *
* @param migrationStep the legacy configuration migration step to run before loading * @param migrationStep the legacy configuration migration step to run before loading
* @param configPortFactory factory for creating ConfigurationPort instances * @param configPortFactory factory for creating ConfigurationPort instances
@@ -330,6 +406,34 @@ public class BootstrapRunner {
SchemaInitializationPortFactory schemaInitPortFactory, SchemaInitializationPortFactory schemaInitPortFactory,
UseCaseFactory useCaseFactory, UseCaseFactory useCaseFactory,
CommandFactory commandFactory) { CommandFactory commandFactory) {
this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory,
schemaInitPortFactory, useCaseFactory, commandFactory, GuiAdapter::new);
}
/**
* Creates the BootstrapRunner with full control over all factories, including the GUI
* adapter factory.
* <p>
* Use this constructor in tests that need to exercise the GUI startup dispatch path,
* where the GUI adapter factory must be controlled to avoid starting a real JavaFX runtime.
*
* @param migrationStep the legacy configuration migration step to run before loading
* @param configPortFactory factory for creating ConfigurationPort instances
* @param runLockPortFactory factory for creating RunLockPort instances
* @param validatorFactory factory for creating StartConfigurationValidator instances
* @param schemaInitPortFactory factory for creating PersistenceSchemaInitializationPort instances
* @param useCaseFactory factory for creating BatchRunProcessingUseCase instances
* @param commandFactory factory for creating SchedulerBatchCommand instances
* @param guiAdapterFactory factory for creating GuiAdapter instances
*/
public BootstrapRunner(MigrationStep migrationStep,
ConfigurationPortFactory configPortFactory,
RunLockPortFactory runLockPortFactory,
ValidatorFactory validatorFactory,
SchemaInitializationPortFactory schemaInitPortFactory,
UseCaseFactory useCaseFactory,
CommandFactory commandFactory,
GuiAdapterFactory guiAdapterFactory) {
this.migrationStep = migrationStep; this.migrationStep = migrationStep;
this.configPortFactory = configPortFactory; this.configPortFactory = configPortFactory;
this.runLockPortFactory = runLockPortFactory; this.runLockPortFactory = runLockPortFactory;
@@ -337,35 +441,148 @@ public class BootstrapRunner {
this.schemaInitPortFactory = schemaInitPortFactory; this.schemaInitPortFactory = schemaInitPortFactory;
this.useCaseFactory = useCaseFactory; this.useCaseFactory = useCaseFactory;
this.commandFactory = commandFactory; this.commandFactory = commandFactory;
this.guiAdapterFactory = guiAdapterFactory;
} }
/** /**
* Runs the complete application startup sequence. * Runs the complete application startup sequence for the given startup arguments.
* <p> * <p>
* Startup flow consists of two phases: * Dispatches to the GUI or headless batch adapter based on the startup mode carried
* <ol> * in {@code startupArguments}:
* <li><strong>Bootstrap Phase (hard failures only):</strong> Load and validate configuration, * <ul>
* then initialize the SQLite persistence schema.</li> * <li>{@link StartupMode#GUI} — delegates to {@link #startGuiMode(Optional)}, which
* <li><strong>Execution Phase (document failures tolerated):</strong> Execute the batch processing pipeline * validates the optional {@code --config} path and creates and starts the GUI inbound
* with all adapters and ports wired.</li> * adapter. When the supplied path does not exist on disk, a startup notice is passed
* </ol> * to the adapter and the GUI starts without a configuration override. Any exception
* thrown by the adapter before or during startup is treated as a hard startup failure
* (exit code 1). Normal adapter termination returns exit code 0.</li>
* <li>{@link StartupMode#HEADLESS} — runs the two-phase headless batch pipeline via
* {@link #runHeadlessBatch(StartupArguments)}. When a {@code --config} path override
* is present but the file does not exist, the run is immediately aborted with exit
* code 1 (hard startup failure). No silent fallback to the default path occurs.</li>
* </ul>
*
* @param startupArguments the parsed startup arguments containing the startup mode and
* optional configuration path override; must not be {@code null}
* @return exit code: 0 for successful completion, 1 for any hard bootstrap,
* configuration, persistence, or GUI startup failure
*/
public int run(StartupArguments startupArguments) {
Objects.requireNonNull(startupArguments, "startupArguments must not be null");
LOG.info("Bootstrap flow started. Startup mode: {}", startupArguments.mode());
return switch (startupArguments.mode()) {
case GUI -> startGuiMode(startupArguments.configPath());
case HEADLESS -> runHeadlessBatch(startupArguments);
};
}
/**
* Runs the complete application startup sequence using the headless batch mode defaults.
* <p> * <p>
* A {@link DocumentPersistenceException} during schema initialization is treated as a hard startup * This convenience overload preserves backward compatibility for callers that do not
* failure and causes exit code 1. Document-level failures during the batch loop are not startup * supply explicit startup arguments. It delegates to {@link #run(StartupArguments)}
* failures and do not change the exit code as long as the run itself completes without a hard * with {@link StartupMode#HEADLESS} and no configuration path override, which
* infrastructure error. * replicates the pre-GUI startup behaviour.
* *
* @return exit code: 0 for a technically completed run, 1 for any hard bootstrap, * @return exit code: 0 for a technically completed run, 1 for any hard bootstrap,
* configuration, or persistence failure * configuration, or persistence failure
*/ */
public int run() { public int run() {
LOG.info("Bootstrap flow started."); return run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));
}
/**
* Starts the GUI inbound adapter and waits for it to terminate.
* <p>
* When a {@code --config} path override is present in {@code configPathOverride}, the file
* existence is checked before the adapter is started:
* <ul>
* <li>If the file <em>exists</em>, the path is confirmed at INFO level. The GUI starts
* normally. The effective configuration path is available for later milestones that
* implement configuration loading in the GUI.</li>
* <li>If the file does <em>not exist</em>, the error is logged and a startup notice is
* prepared and forwarded to the adapter. The GUI then starts without any configuration
* override, behaving as if {@code --config} had not been specified.</li>
* </ul>
* <p>
* Creates the GUI adapter via the configured factory and delegates to
* {@link GuiAdapter#start(Optional)}. Any exception thrown by the adapter before or
* during startup is treated as a hard GUI startup failure and mapped to exit code 1.
* Normal termination (user closes the window) returns exit code 0.
* <p>
* The headless batch pipeline is not entered from this method. Configuration loading,
* schema initialization, and all batch infrastructure are not initialized in the GUI path.
*
* @param configPathOverride the optional {@code --config} path string from startup arguments;
* must not be {@code null}
* @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.");
}
}
LOG.info("GUI startup: launching GUI adapter.");
try { try {
// Bootstrap Phase: prepare configuration and persistence GuiAdapter guiAdapter = guiAdapterFactory.create();
migrateConfigurationIfNeeded(); guiAdapter.start(startupNotice);
StartConfiguration config = loadAndValidateConfiguration(); LOG.info("GUI adapter terminated normally.");
return 0;
} catch (Exception e) {
LOG.error("GUI startup failed: {}", e.getMessage(), e);
return 1;
}
}
/**
* Runs the headless batch processing pipeline for the given startup arguments.
* <p>
* When a {@code --config} path override is present, the file existence is checked
* immediately. If the file does not exist, the run is aborted with exit code 1.
* No silent fallback to the default path occurs: a missing file at a supplied
* {@code --config} path is always a hard startup failure in headless mode.
* <p>
* After confirming the path (or when no override is present), the effective path is
* resolved and the two-phase batch bootstrap executes:
* <ol>
* <li><strong>Bootstrap Phase (hard failures only):</strong> Legacy migration, configuration
* loading and validation, SQLite schema initialization.</li>
* <li><strong>Execution Phase (document failures tolerated):</strong> Run lock acquisition,
* use case wiring, batch execution, and outcome-to-exit-code mapping.</li>
* </ol>
* <p>
* Any configuration loading, validation, or schema initialization failure is a hard startup
* error and produces exit code 1. Document-level failures during the batch loop do not
* affect the exit code.
*
* @param startupArguments the parsed startup arguments; must not be {@code null}
* @return exit code: 0 for a technically completed run, 1 for any hard startup failure
*/
private int runHeadlessBatch(StartupArguments startupArguments) {
try {
if (startupArguments.configPath().isPresent()) {
Path overridePath = Paths.get(startupArguments.configPath().get());
if (!Files.exists(overridePath)) {
LOG.error("Headless startup failed: configuration file not found at --config path: {}",
overridePath.toAbsolutePath());
return 1;
}
}
Path effectiveConfigPath = resolveEffectiveConfigPath(startupArguments.configPath());
migrateConfigurationIfNeeded(effectiveConfigPath);
StartConfiguration config = loadAndValidateConfiguration(effectiveConfigPath);
initializeSchema(config); initializeSchema(config);
// Execution Phase: run batch processing
return executeWithStartConfiguration(config); return executeWithStartConfiguration(config);
} catch (ConfigurationLoadingException e) { } catch (ConfigurationLoadingException e) {
LOG.error("Configuration loading failed: {}", e.getMessage()); LOG.error("Configuration loading failed: {}", e.getMessage());
@@ -383,7 +600,23 @@ public class BootstrapRunner {
} }
/** /**
* Runs the legacy configuration migration step exactly once before configuration loading. * Resolves the effective configuration file path.
* <p>
* When a {@code --config} path override is present in {@code configPathOverride}, that
* path is used. Otherwise the default path {@code config/application.properties} relative
* to the working directory is applied.
*
* @param configPathOverride the optional {@code --config} path string from startup arguments
* @return the resolved configuration file path; never {@code null}
*/
static Path resolveEffectiveConfigPath(Optional<String> configPathOverride) {
return configPathOverride
.map(Paths::get)
.orElse(DEFAULT_CONFIG_PATH);
}
/**
* Runs the legacy configuration migration step against the effective configuration path.
* <p> * <p>
* If the configuration file is in the legacy flat-key format, it is migrated in-place to the * If the configuration file is in the legacy flat-key format, it is migrated in-place to the
* multi-provider schema before the normal configuration loading path is entered. If the file * multi-provider schema before the normal configuration loading path is entered. If the file
@@ -391,29 +624,25 @@ public class BootstrapRunner {
* <p> * <p>
* A migration failure is a hard startup error and propagates as a * A migration failure is a hard startup error and propagates as a
* {@link ConfigurationLoadingException}. * {@link ConfigurationLoadingException}.
*
* @param effectiveConfigPath the resolved configuration file path; never {@code null}
*/ */
private void migrateConfigurationIfNeeded() { private void migrateConfigurationIfNeeded(Path effectiveConfigPath) {
migrationStep.runIfNeeded(); migrationStep.runIfNeeded(effectiveConfigPath);
} }
/** /**
* Loads configuration via {@link ConfigurationPort} and validates it via * Loads configuration from the effective configuration path and validates it.
* {@link StartConfigurationValidator}.
* <p>
* Validation includes:
* <ul>
* <li>{@code source.folder}: must exist, be a directory, and be readable.</li>
* <li>{@code target.folder}: must exist as a writable directory, or be technically
* creatable (validator attempts {@code Files.createDirectories} if absent;
* failure here is a hard startup error).</li>
* <li>{@code sqlite.file}: parent directory must exist.</li>
* <li>All numeric and path constraints.</li>
* </ul>
* <p> * <p>
* After successful validation, the active AI provider identifier is logged at INFO level. * After successful validation, the active AI provider identifier is logged at INFO level.
*
* @param effectiveConfigPath the resolved configuration file path; never {@code null}
* @return the validated startup configuration; never {@code null}
* @throws ConfigurationLoadingException if the file cannot be read or parsed
* @throws InvalidStartConfigurationException if validation fails
*/ */
private StartConfiguration loadAndValidateConfiguration() { private StartConfiguration loadAndValidateConfiguration(Path effectiveConfigPath) {
ConfigurationPort configPort = configPortFactory.create(); ConfigurationPort configPort = configPortFactory.create(effectiveConfigPath);
StartConfiguration config = configPort.loadConfiguration(); StartConfiguration config = configPort.loadConfiguration();
validatorFactory.create().validate(config); validatorFactory.create().validate(config);
LOG.info("Active AI provider: {}", LOG.info("Active AI provider: {}",
@@ -424,6 +653,8 @@ public class BootstrapRunner {
/** /**
* Initialises the SQLite persistence schema once at startup, before the batch loop begins. * Initialises the SQLite persistence schema once at startup, before the batch loop begins.
* Failure here is a hard bootstrap error and results in exit code 1. * Failure here is a hard bootstrap error and results in exit code 1.
*
* @param config the validated startup configuration containing the SQLite file path
*/ */
private void initializeSchema(StartConfiguration config) { private void initializeSchema(StartConfiguration config) {
schemaInitPortFactory.create(buildJdbcUrl(config)).initializeSchema(); schemaInitPortFactory.create(buildJdbcUrl(config)).initializeSchema();
@@ -434,13 +665,6 @@ public class BootstrapRunner {
* <p> * <p>
* Wires all runtime dependencies, constructs adapters and the batch use case via * Wires all runtime dependencies, constructs adapters and the batch use case via
* the use case factory, invokes the CLI command, and maps the outcome to an exit code. * the use case factory, invokes the CLI command, and maps the outcome to an exit code.
* <p>
* The use case factory is responsible for extracting the minimal runtime configuration
* from the complete startup configuration. This separation ensures the application layer
* depends only on the configuration it actually needs, following hexagonal architecture principles.
* <p>
* This represents the execution phase after startup configuration is validated
* and persistence schema is initialized.
* *
* @param config the validated startup configuration (complete technical configuration) * @param config the validated startup configuration (complete technical configuration)
* @return exit code: 0 for batch completion, 1 for critical runtime failures * @return exit code: 0 for batch completion, 1 for critical runtime failures
@@ -457,6 +681,9 @@ public class BootstrapRunner {
/** /**
* Resolves the run-lock file path from the configuration, applying a default when not set. * Resolves the run-lock file path from the configuration, applying a default when not set.
*
* @param config the startup configuration
* @return the resolved lock file path; never {@code null} and never blank
*/ */
private Path resolveLockFilePath(StartConfiguration config) { private Path resolveLockFilePath(StartConfiguration config) {
Path lockFilePath = config.runtimeLockFile(); Path lockFilePath = config.runtimeLockFile();
@@ -470,6 +697,8 @@ public class BootstrapRunner {
/** /**
* Creates a new {@link BatchRunContext} with a fresh run ID and the current timestamp. * Creates a new {@link BatchRunContext} with a fresh run ID and the current timestamp.
*
* @return a new batch run context; never {@code null}
*/ */
private BatchRunContext createRunContext() { private BatchRunContext createRunContext() {
RunId runId = new RunId(UUID.randomUUID().toString()); RunId runId = new RunId(UUID.randomUUID().toString());
@@ -480,6 +709,8 @@ public class BootstrapRunner {
/** /**
* Maps a {@link BatchRunOutcome} to a process exit code and logs the run result. * Maps a {@link BatchRunOutcome} to a process exit code and logs the run result.
* *
* @param outcome the outcome of the batch run; never {@code null}
* @param runContext the batch run context used for logging; never {@code null}
* @return 0 if the batch run completed successfully; 1 otherwise * @return 0 if the batch run completed successfully; 1 otherwise
*/ */
private int mapOutcomeToExitCode(BatchRunOutcome outcome, BatchRunContext runContext) { private int mapOutcomeToExitCode(BatchRunOutcome outcome, BatchRunContext runContext) {
@@ -3,11 +3,28 @@ package de.gecheckt.pdf.umbenenner.bootstrap;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.CliArgumentParser;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult;
/** /**
* Main entry point for the PDF Umbenenner application. * Main entry point for the PDF Umbenenner application.
* <p> * <p>
* Delegates to {@link BootstrapRunner} for manual object graph construction * Responsible for:
* and execution of the startup sequence. * <ol>
* <li>Parsing raw command-line arguments via {@link CliArgumentParser}.</li>
* <li>Aborting with exit code 1 when the arguments are invalid or unrecognised.</li>
* <li>Delegating to {@link BootstrapRunner} for startup mode selection,
* object graph construction, and execution.</li>
* </ol>
* <p>
* Supported CLI options:
* <ul>
* <li>(none) — GUI startup mode (default).</li>
* <li>{@code --headless} — Headless batch/scheduler startup mode.</li>
* <li>{@code --config <path>} — Override for the configuration file path,
* applicable to both startup modes.</li>
* </ul>
*/ */
public class PdfUmbenennerApplication { public class PdfUmbenennerApplication {
@@ -15,14 +32,32 @@ public class PdfUmbenennerApplication {
/** /**
* Application entry point. * Application entry point.
* <p>
* Parses the command-line arguments and delegates to {@link BootstrapRunner}.
* If the arguments cannot be parsed, an error is logged and the process exits
* with code 1 before any further initialisation takes place.
* *
* @param args command line arguments (currently unused) * @param args command-line arguments; see class JavaDoc for supported options
*/ */
public static void main(String[] args) { public static void main(String[] args) {
LOG.info("Starting PDF Umbenenner application..."); LOG.info("Starting PDF Umbenenner application...");
try { try {
StartupArgumentsParseResult parseResult = new CliArgumentParser().parse(args);
if (parseResult instanceof StartupArgumentsParseResult.Invalid invalid) {
LOG.error("Invalid command-line arguments: {}", invalid.errorMessage());
System.exit(1);
return;
}
StartupArguments startupArguments =
((StartupArgumentsParseResult.Valid) parseResult).arguments();
LOG.info("Startup mode: {}", startupArguments.mode());
startupArguments.configPath().ifPresent(
p -> LOG.info("Configuration path override: {}", p));
BootstrapRunner runner = new BootstrapRunner(); BootstrapRunner runner = new BootstrapRunner();
int exitCode = runner.run(); int exitCode = runner.run(startupArguments);
if (exitCode == 0) { if (exitCode == 0) {
LOG.info("PDF Umbenenner application completed successfully."); LOG.info("PDF Umbenenner application completed successfully.");
} else { } else {
@@ -34,4 +69,4 @@ public class PdfUmbenennerApplication {
System.exit(1); System.exit(1);
} }
} }
} }
@@ -1,50 +1,68 @@
/** /**
* Bootstrap module for application startup and technical object graph construction. * Bootstrap module for application startup, startup mode dispatch, and technical object
* graph construction.
* <p> * <p>
* Responsibility: Orchestrate the complete startup sequence in two phases: (1) bootstrap phase * Responsibility: Parse the startup mode from command-line arguments and dispatch to the
* for configuration loading, validation, and schema initialization, and (2) execution phase * appropriate inbound adapter. Exactly one inbound adapter is started per process run.
* for wiring all adapters and running the batch processing pipeline. * For the headless batch path, the bootstrap module also orchestrates configuration loading,
* schema initialization, and the full adapter wiring.
* <p> * <p>
* Components: * Components:
* <ul> * <ul>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.BootstrapRunner} * <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.BootstrapRunner}
* — Orchestrator of startup sequence, schema initialization, and object graph construction</li> * — Orchestrator of startup mode dispatch, configuration loading, schema initialization,
* and object graph construction</li>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication} * <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication}
* — Application entry point that invokes BootstrapRunner</li> * — Application entry point that parses CLI arguments and invokes BootstrapRunner</li>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger} * <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger}
* — Logging adapter for application-layer coordination and use case processing</li> * — Logging adapter for application-layer coordination and use case processing</li>
* </ul> * </ul>
* <p> * <p>
* Implementation approach: * Startup mode dispatch:
* <ul> * <ul>
* <li>Uses factory pattern with pluggable interfaces for configuration, run lock, schema initialization, use case, and command creation</li> * <li><strong>GUI mode</strong> (default): Bootstrap creates and starts the
* <li>Manually constructs the object graph without framework dependencies</li> * {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter}. The GUI adapter manages
* <li>Ensures strict inward dependency direction: all adapters depend on ports, never the other way around</li> * the JavaFX lifecycle. Bootstrap returns exit code 0 on normal GUI shutdown and exit
* <li>Separates startup configuration (complete technical parameters for bootstrap and adapter wiring) from * code 1 on any exception thrown by the GUI adapter.</li>
* runtime configuration (minimal parameters the application layer actually depends on)</li> * <li><strong>Headless mode</strong> ({@code --headless}): Bootstrap runs the two-phase
* <li>Schema initialization happens exactly once at startup, before document processing begins</li> * headless batch pipeline described below.</li>
* </ul> * </ul>
* <p> * <p>
* Startup sequence: * Configuration path resolution:
* <ul> * <ul>
* <li>Load and validate complete startup configuration from properties file and environment variables</li> * <li>When {@code --config &lt;path&gt;} is supplied, that path is used as the effective
* <li>Validate target folder availability and write access; create target folder if absent * configuration file path for both the legacy migration step and the configuration port.</li>
* (failure is a hard startup error)</li> * <li>When {@code --config} is absent, the default path {@code config/application.properties}
* <li>Initialize SQLite persistence schema (including target-copy schema evolution) via * relative to the working directory is applied.</li>
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}, * </ul>
* ensuring the database is ready before any batch processing</li> * <p>
* <li>Schema initialization failure is treated as a hard bootstrap error and causes exit code 1</li> * Headless batch startup sequence (two phases):
* <li>Create run lock adapter and acquire exclusive lock</li> * <ul>
* <li>Wire all outbound adapters (document candidates, PDF extraction, fingerprint, persistence, * <li><em>Bootstrap phase</em> — configuration loading and validation, SQLite schema
* target folder duplicate resolution, target file copy, logging)</li> * initialization. Failures here are hard startup errors (exit code 1).</li>
* <li>Wire and invoke the batch processing CLI adapter</li> * <li><em>Execution phase</em> — run lock acquisition, adapter wiring, batch use case
* <li>Map batch outcome to process exit code</li> * execution, outcome-to-exit-code mapping. Document-level failures do not affect the
* exit code.</li>
* </ul>
* <p>
* Implementation approach:
* <ul>
* <li>Uses factory pattern with pluggable interfaces for GUI adapter, configuration, run lock,
* schema initialization, use case, and command creation — enabling full unit testability</li>
* <li>Manually constructs the object graph without framework dependencies</li>
* <li>Ensures strict inward dependency direction: all adapters depend on ports, never the
* other way around</li>
* <li>Separates startup configuration (complete technical parameters for bootstrap and adapter
* wiring) from runtime configuration (minimal parameters the application layer depends on)</li>
* <li>Schema initialization happens exactly once at startup, before document processing begins</li>
* </ul> * </ul>
* <p> * <p>
* Exit codes: * Exit codes:
* <ul> * <ul>
* <li>0 = batch run completed technically successfully (even if individual documents failed)</li> * <li>0 = headless batch run completed technically successfully (even if individual documents
* <li>1 = hard bootstrap, configuration, schema initialization, or critical infrastructure failure</li> * failed), or GUI terminated normally after the window was shown</li>
* <li>1 = hard start, bootstrap, configuration, schema initialization, GUI startup, or critical
* infrastructure failure</li>
* </ul> * </ul>
*/ */
package de.gecheckt.pdf.umbenenner.bootstrap; package de.gecheckt.pdf.umbenenner.bootstrap;
@@ -0,0 +1,111 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import java.util.Optional;
/**
* Parses the raw {@code String[]} arguments passed to {@code main(String[])}
* into a structured {@link StartupArgumentsParseResult}.
* <p>
* Supported options:
* <ul>
* <li>(none) — GUI startup mode is the default.</li>
* <li>{@code --headless} — Activates headless batch/scheduler startup mode.</li>
* <li>{@code --config <path>} — Overrides the configuration file path for
* either startup mode. The path token immediately follows the option token.</li>
* </ul>
* <p>
* Options may appear in any order. Each option must appear at most once.
* <p>
* Invalid usages produce a {@link StartupArgumentsParseResult.Invalid} result:
* <ul>
* <li>{@code --config} without a following path value.</li>
* <li>{@code --config} followed by another option token (starting with {@code --})
* instead of a path value.</li>
* <li>Duplicate occurrence of {@code --headless} or {@code --config}.</li>
* <li>Any unrecognised argument token.</li>
* </ul>
* <p>
* This class is stateless and safe for concurrent use once instantiated.
*/
public class CliArgumentParser {
private static final String OPTION_HEADLESS = "--headless";
private static final String OPTION_CONFIG = "--config";
/**
* Creates a new {@code CliArgumentParser}.
*/
public CliArgumentParser() {
// No state to initialise
}
/**
* Parses the given command-line arguments and returns a structured result.
* <p>
* Returns {@link StartupArgumentsParseResult.Valid} when the arguments are
* syntactically correct. Returns {@link StartupArgumentsParseResult.Invalid}
* when any argument is unrecognised, duplicated, or when {@code --config}
* is missing its required path value.
* <p>
* The concrete treatment of the configuration path (e.g. whether the file
* exists) is outside the scope of this parser and is handled downstream
* by Bootstrap.
*
* @param args the raw command-line arguments; must not be {@code null}
* @return the parse result; never {@code null}
* @throws NullPointerException if {@code args} is {@code null}
*/
public StartupArgumentsParseResult parse(String[] args) {
java.util.Objects.requireNonNull(args, "args must not be null");
StartupMode mode = StartupMode.GUI;
Optional<String> configPath = Optional.empty();
boolean headlessSeen = false;
boolean configSeen = false;
int i = 0;
while (i < args.length) {
String token = args[i];
switch (token) {
case OPTION_HEADLESS -> {
if (headlessSeen) {
return new StartupArgumentsParseResult.Invalid(
"Duplicate option: " + OPTION_HEADLESS);
}
headlessSeen = true;
mode = StartupMode.HEADLESS;
i++;
}
case OPTION_CONFIG -> {
if (configSeen) {
return new StartupArgumentsParseResult.Invalid(
"Duplicate option: " + OPTION_CONFIG);
}
if (i + 1 >= args.length) {
return new StartupArgumentsParseResult.Invalid(
"Option " + OPTION_CONFIG + " requires a path argument but none was provided");
}
String pathToken = args[i + 1];
if (pathToken.startsWith("--")) {
return new StartupArgumentsParseResult.Invalid(
"Option " + OPTION_CONFIG + " requires a path argument, but got option: " + pathToken);
}
if (pathToken.isBlank()) {
return new StartupArgumentsParseResult.Invalid(
"Option " + OPTION_CONFIG + " requires a non-blank path argument");
}
configSeen = true;
configPath = Optional.of(pathToken);
i += 2;
}
default -> {
return new StartupArgumentsParseResult.Invalid(
"Unknown argument: " + token);
}
}
}
return new StartupArgumentsParseResult.Valid(new StartupArguments(mode, configPath));
}
}
@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable value object carrying the successfully parsed CLI startup arguments.
* <p>
* Produced by {@link CliArgumentParser} when command-line arguments are syntactically
* valid. Bootstrap uses this record to determine the startup mode (GUI or headless)
* and whether a custom configuration file path was supplied via {@code --config <path>}.
* <p>
* A {@code StartupArguments} instance is always consistent: it is only created when
* parsing succeeded. Parse failures are represented by
* {@link StartupArgumentsParseResult.Invalid} instead.
*
* @param mode the determined startup mode; never {@code null}
* @param configPath the configuration file path supplied via {@code --config <path>},
* or empty if the option was absent from the command line
*/
public record StartupArguments(StartupMode mode, Optional<String> configPath) {
/**
* Compact canonical constructor enforcing non-null invariants.
*
* @param mode the startup mode; must not be {@code null}
* @param configPath the optional configuration path; must not be {@code null}
* @throws NullPointerException if {@code mode} or {@code configPath} is {@code null}
*/
public StartupArguments {
Objects.requireNonNull(mode, "mode must not be null");
Objects.requireNonNull(configPath, "configPath must not be null");
}
}
@@ -0,0 +1,62 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import java.util.Objects;
/**
* Sealed result type returned by {@link CliArgumentParser#parse(String[])}.
* <p>
* Bootstrap evaluates the parse result to decide the next action:
* <ul>
* <li>{@link Valid} — proceed with the contained {@link StartupArguments}.</li>
* <li>{@link Invalid} — abort with a hard startup error (exit code 1);
* the error message describes the specific CLI misuse to be logged.</li>
* </ul>
* <p>
* The sealed hierarchy ensures that every caller must handle both cases
* explicitly, preventing silent omission of error handling.
*/
public sealed interface StartupArgumentsParseResult {
/**
* Represents a successfully parsed command line.
* <p>
* Bootstrap may proceed normally with the contained {@link StartupArguments}.
*
* @param arguments the parsed startup arguments; never {@code null}
*/
record Valid(StartupArguments arguments) implements StartupArgumentsParseResult {
/**
* Creates a valid parse result.
*
* @param arguments the parsed startup arguments; must not be {@code null}
* @throws NullPointerException if {@code arguments} is {@code null}
*/
public Valid {
Objects.requireNonNull(arguments, "arguments must not be null");
}
}
/**
* Represents a failed parse due to invalid or unsupported CLI usage.
* <p>
* Bootstrap must treat this as a hard startup error: log the error message
* and terminate with exit code 1. The error message is intended for the
* operator and should be concise and actionable.
*
* @param errorMessage a human-readable description of the parse failure;
* never {@code null}
*/
record Invalid(String errorMessage) implements StartupArgumentsParseResult {
/**
* Creates an invalid parse result.
*
* @param errorMessage the error description; must not be {@code null}
* @throws NullPointerException if {@code errorMessage} is {@code null}
*/
public Invalid {
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
}
}
}
@@ -0,0 +1,33 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
/**
* Enumerates the supported application startup modes.
* <p>
* The startup mode is determined from the raw CLI arguments by
* {@link CliArgumentParser} and carried in {@link StartupArguments}.
* Bootstrap uses the mode to select the appropriate inbound adapter
* for the current process.
* <p>
* Exactly one mode is active per process run. The default mode when
* no explicit option is present is {@link #GUI}.
*/
public enum StartupMode {
/**
* GUI startup mode.
* <p>
* Selected when the {@code --headless} option is absent from the command line.
* Directs Bootstrap to start the JavaFX desktop adapter. This is the
* default startup mode for interactive use.
*/
GUI,
/**
* Headless batch/scheduler startup mode.
* <p>
* Activated via the {@code --headless} CLI option. Directs Bootstrap to
* start the existing batch-processing CLI adapter without any GUI
* initialization. Suitable for automated execution via a task scheduler.
*/
HEADLESS
}
@@ -0,0 +1,39 @@
/**
* Startup argument model and command-line parsing for the application entry point.
* <p>
* Responsibility: Represent, parse, and validate the CLI arguments passed to
* {@code main(String[])} so that Bootstrap can derive the startup mode and
* configuration path without any further raw string handling.
* <p>
* Components:
* <ul>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode}
* — Enumerates the two supported startup modes (GUI default, headless batch).</li>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments}
* — Immutable value object carrying the successfully parsed startup mode
* and optional configuration file path.</li>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult}
* — Sealed result type produced by the parser; either
* {@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult.Valid Valid}
* or
* {@link de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArgumentsParseResult.Invalid Invalid}.</li>
* <li>{@link de.gecheckt.pdf.umbenenner.bootstrap.startup.CliArgumentParser}
* — Parses a raw {@code String[]} and returns a {@code StartupArgumentsParseResult}.</li>
* </ul>
* <p>
* Supported CLI options:
* <ul>
* <li>(none) — GUI startup is the default when no options are present.</li>
* <li>{@code --headless} — Activates the headless batch/scheduler startup mode.</li>
* <li>{@code --config <path>} — Overrides the configuration file path for either startup mode.</li>
* </ul>
* <p>
* Invalid usages (result in {@code Invalid} parse result):
* <ul>
* <li>{@code --config} without a following path value.</li>
* <li>{@code --config} followed by another option token instead of a path.</li>
* <li>Duplicate occurrence of {@code --headless} or {@code --config}.</li>
* <li>Any unrecognised argument token.</li>
* </ul>
*/
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
@@ -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;
}
}
}
@@ -80,7 +80,7 @@ class BootstrapRunnerEdgeCasesTest {
AtomicReference<Path> capturedLockPath = new AtomicReference<>(); AtomicReference<Path> capturedLockPath = new AtomicReference<>();
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> () -> configWithNullLock, configPath -> () -> configWithNullLock,
lockFile -> { lockFile -> {
capturedLockPath.set(lockFile); capturedLockPath.set(lockFile);
return new MockRunLockPort(); return new MockRunLockPort();
@@ -190,7 +190,7 @@ class BootstrapRunnerEdgeCasesTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -209,7 +209,7 @@ class BootstrapRunnerEdgeCasesTest {
void run_distinguishesBetweenConfigLoadingAndValidationFailure() throws Exception { void run_distinguishesBetweenConfigLoadingAndValidationFailure() throws Exception {
// Test 1: Configuration loading exception // Test 1: Configuration loading exception
BootstrapRunner runnerLoadFailure = new BootstrapRunner( BootstrapRunner runnerLoadFailure = new BootstrapRunner(
() -> { configPath -> {
throw new ConfigurationLoadingException("Load failed"); throw new ConfigurationLoadingException("Load failed");
}, },
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
@@ -224,7 +224,7 @@ class BootstrapRunnerEdgeCasesTest {
// Test 2: Configuration validation exception // Test 2: Configuration validation exception
BootstrapRunner runnerValidationFailure = new BootstrapRunner( BootstrapRunner runnerValidationFailure = new BootstrapRunner(
() -> () -> { configPath -> () -> {
try { try {
Path sourceDir = Files.createDirectories(tempDir.resolve("source")); Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
Path targetDir = Files.createDirectories(tempDir.resolve("target")); Path targetDir = Files.createDirectories(tempDir.resolve("target"));
@@ -259,7 +259,7 @@ class BootstrapRunnerEdgeCasesTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new PersistenceSchemaInitializationPort() { jdbcUrl -> new PersistenceSchemaInitializationPort() {
@@ -286,7 +286,7 @@ class BootstrapRunnerEdgeCasesTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -302,7 +302,7 @@ class BootstrapRunnerEdgeCasesTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -318,7 +318,7 @@ class BootstrapRunnerEdgeCasesTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -377,7 +377,7 @@ class BootstrapRunnerEdgeCasesTest {
ctx.updateLoggers(); ctx.updateLoggers();
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -429,7 +429,7 @@ class BootstrapRunnerEdgeCasesTest {
ctx.updateLoggers(); ctx.updateLoggers();
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -466,7 +466,7 @@ class BootstrapRunnerEdgeCasesTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -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;
}
}
}
@@ -58,7 +58,7 @@ class BootstrapRunnerTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -83,7 +83,7 @@ class BootstrapRunnerTest {
}; };
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
() -> failingValidator, () -> failingValidator,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -103,7 +103,7 @@ class BootstrapRunnerTest {
}; };
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> failingConfigPort, configPath -> failingConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -123,7 +123,7 @@ class BootstrapRunnerTest {
}; };
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> throwingConfigPort, configPath -> throwingConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -142,7 +142,7 @@ class BootstrapRunnerTest {
BatchRunProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE; BatchRunProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE;
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -162,7 +162,7 @@ class BootstrapRunnerTest {
BatchRunProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE; BatchRunProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE;
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -209,7 +209,7 @@ class BootstrapRunnerTest {
// ConfigurationPortFactory returns a ConfigurationPort; ConfigurationPort returns StartConfiguration // ConfigurationPortFactory returns a ConfigurationPort; ConfigurationPort returns StartConfiguration
ConfigurationPort configPort = () -> configWithEmptyLock; ConfigurationPort configPort = () -> configWithEmptyLock;
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> configPort, configPath -> configPort,
lockFile -> { lockFile -> {
capturedLockPath.set(lockFile); capturedLockPath.set(lockFile);
return new MockRunLockPort(); return new MockRunLockPort();
@@ -241,7 +241,7 @@ class BootstrapRunnerTest {
BatchRunProcessingUseCase useCaseWithDocumentFailures = (context) -> BatchRunOutcome.SUCCESS; BatchRunProcessingUseCase useCaseWithDocumentFailures = (context) -> BatchRunOutcome.SUCCESS;
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -288,7 +288,7 @@ class BootstrapRunnerTest {
); );
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> () -> configWithZeroRetries, // ConfigurationPortFactory → ConfigurationPort → StartConfiguration configPath -> () -> configWithZeroRetries,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, // use the real validator StartConfigurationValidator::new, // use the real validator
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -310,7 +310,7 @@ class BootstrapRunnerTest {
@Test @Test
void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() { void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() {
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> { configPath -> {
throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException( throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException(
"Invalid value for log.ai.sensitive: 'maybe'. " "Invalid value for log.ai.sensitive: 'maybe'. "
+ "Must be either 'true' or 'false' (case-insensitive)."); + "Must be either 'true' or 'false' (case-insensitive).");
@@ -339,7 +339,7 @@ class BootstrapRunnerTest {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new PersistenceSchemaInitializationPort() { jdbcUrl -> new PersistenceSchemaInitializationPort() {
@@ -369,7 +369,7 @@ class BootstrapRunnerTest {
void activeProviderIsLoggedAtRunStart() throws Exception { void activeProviderIsLoggedAtRunStart() throws Exception {
ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> mockConfigPort, configPath -> mockConfigPort,
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -445,8 +445,8 @@ class BootstrapRunnerTest {
Files.writeString(configFile, legacyConfig); Files.writeString(configFile, legacyConfig);
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile), path -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile),
() -> new PropertiesConfigurationPortAdapter(configFile), path -> new PropertiesConfigurationPortAdapter(configFile),
lockFile -> new MockRunLockPort(), lockFile -> new MockRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
jdbcUrl -> new MockSchemaInitializationPort(), jdbcUrl -> new MockSchemaInitializationPort(),
@@ -63,7 +63,7 @@ class BootstrapSmokeTest {
AtomicReference<AiInvocationPort> capturedPort = new AtomicReference<>(); AtomicReference<AiInvocationPort> capturedPort = new AtomicReference<>();
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE, configPath -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE,
openAiConfig(), null), openAiConfig(), null),
lockFile -> new NoOpRunLockPort(), lockFile -> new NoOpRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
@@ -104,7 +104,7 @@ class BootstrapSmokeTest {
AtomicReference<AiInvocationPort> capturedPort = new AtomicReference<>(); AtomicReference<AiInvocationPort> capturedPort = new AtomicReference<>();
BootstrapRunner runner = new BootstrapRunner( BootstrapRunner runner = new BootstrapRunner(
() -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE, configPath -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE,
null, claudeConfig()), null, claudeConfig()),
lockFile -> new NoOpRunLockPort(), lockFile -> new NoOpRunLockPort(),
StartConfigurationValidator::new, StartConfigurationValidator::new,
@@ -110,11 +110,12 @@ class ExecutableJarSmokeTestIT {
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar); 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<>(); List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE); command.add(JAVA_EXECUTABLE);
command.add("-jar"); command.add("-jar");
command.add(shadedJar.toString()); command.add(shadedJar.toString());
command.add("--headless");
// Run the process in the work directory where config/application.properties will be found // Run the process in the work directory where config/application.properties will be found
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
@@ -233,11 +234,12 @@ class ExecutableJarSmokeTestIT {
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar); 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<>(); List<String> command = new ArrayList<>();
command.add(JAVA_EXECUTABLE); command.add(JAVA_EXECUTABLE);
command.add("-jar"); command.add("-jar");
command.add(shadedJar.toString()); command.add(shadedJar.toString());
command.add("--headless");
// Run the process // Run the process
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
@@ -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);
}
}
+30
View File
@@ -11,6 +11,7 @@
<module>pdf-umbenenner-domain</module> <module>pdf-umbenenner-domain</module>
<module>pdf-umbenenner-application</module> <module>pdf-umbenenner-application</module>
<module>pdf-umbenenner-adapter-in-cli</module> <module>pdf-umbenenner-adapter-in-cli</module>
<module>pdf-umbenenner-adapter-in-gui</module>
<module>pdf-umbenenner-adapter-out</module> <module>pdf-umbenenner-adapter-out</module>
<module>pdf-umbenenner-bootstrap</module> <module>pdf-umbenenner-bootstrap</module>
<module>pdf-umbenenner-coverage</module> <module>pdf-umbenenner-coverage</module>
@@ -25,6 +26,7 @@
<pitest.aggregate.skip>false</pitest.aggregate.skip> <pitest.aggregate.skip>false</pitest.aggregate.skip>
<!-- Library versions --> <!-- Library versions -->
<javafx.version>21.0.2</javafx.version>
<log4j.version>2.23.1</log4j.version> <log4j.version>2.23.1</log4j.version>
<pdfbox.version>3.0.2</pdfbox.version> <pdfbox.version>3.0.2</pdfbox.version>
<sqlite-jdbc.version>3.45.1.0</sqlite-jdbc.version> <sqlite-jdbc.version>3.45.1.0</sqlite-jdbc.version>
@@ -83,6 +85,34 @@
<version>${json.version}</version> <version>${json.version}</version>
</dependency> </dependency>
<!-- JavaFX (GUI-Modul only) -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>${javafx.version}</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>${javafx.version}</version>
<classifier>win</classifier>
</dependency>
<!-- Monocle: headless JavaFX platform for GUI smoke tests (test scope only) -->
<dependency>
<groupId>org.testfx</groupId>
<artifactId>openjfx-monocle</artifactId>
<version>jdk-12.0.1+2</version>
<scope>test</scope>
</dependency>
<!-- Testing --> <!-- Testing -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>