diff --git a/pdf-umbenenner-adapter-in-gui/pom.xml b/pdf-umbenenner-adapter-in-gui/pom.xml new file mode 100644 index 0000000..179440e --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/pom.xml @@ -0,0 +1,180 @@ + + + 4.0.0 + + de.gecheckt + pdf-umbenenner-parent + 0.0.1-SNAPSHOT + + pdf-umbenenner-adapter-in-gui + jar + + + + + de.gecheckt + pdf-umbenenner-application + ${project.version} + + + de.gecheckt + pdf-umbenenner-domain + ${project.version} + + + + + org.openjfx + javafx-base + win + + + org.openjfx + javafx-graphics + win + + + org.openjfx + javafx-controls + win + + + + + org.apache.logging.log4j + log4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + org.testfx + openjfx-monocle + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${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 + + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-check + verify + + check + + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.10 + + + BRANCH + COVEREDRATIO + 0.00 + + + + + + + + + + org.pitest + pitest-maven + + + pitest + verify + + mutationCoverage + + + + 0 + 0 + + + + + + + diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java new file mode 100644 index 0000000..6fca5a8 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapter.java @@ -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. + *

+ * 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}. + *

+ * Responsibilities of this adapter: + *

+ *

+ * 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. + *

+ * 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. + * + *

Current scope

+ *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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). + *

+ * 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. + *

+ * 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 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."); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java new file mode 100644 index 0000000..3ec93c7 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java @@ -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. + *

+ * 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. + * + *

Startup notice

+ *

+ * 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. + * + *

Current scope

+ *

+ * 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. + * + *

Explicit non-goals

+ * + * + *

Threading

+ *

+ * 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[])}. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 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. + *

+ * 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 extractStartupNotice() { + List 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. + *

+ * 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."); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/package-info.java new file mode 100644 index 0000000..d1f0353 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/package-info.java @@ -0,0 +1,24 @@ +/** + * Inbound adapter for the JavaFX desktop GUI. + *

+ * 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. + *

+ * Architectural position: + *

+ *

+ * 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. + *

+ * 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; diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java new file mode 100644 index 0000000..b3a045f --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java @@ -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. + *

+ * 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. + * + *

Scope

+ *

+ * This class covers the headless GUI startup path defined for the GUI infrastructure: + *

+ * + *

Excluded from this scope

+ *

+ * {@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. + * + *

Threading

+ *

+ * 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. + * + *

Monocle configuration

+ *

+ * 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 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. + *

+ * 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 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 + ">"); + } + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterTest.java new file mode 100644 index 0000000..944a1a3 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterTest.java @@ -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}. + *

+ * 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. + *

+ * 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); + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplicationTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplicationTest.java new file mode 100644 index 0000000..bc14694 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplicationTest.java @@ -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}. + *

+ * 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); + } +} diff --git a/pdf-umbenenner-bootstrap/pom.xml b/pdf-umbenenner-bootstrap/pom.xml index b1813b3..eb787e0 100644 --- a/pdf-umbenenner-bootstrap/pom.xml +++ b/pdf-umbenenner-bootstrap/pom.xml @@ -26,6 +26,11 @@ pdf-umbenenner-adapter-in-cli ${project.version} + + de.gecheckt + pdf-umbenenner-adapter-in-gui + ${project.version} + de.gecheckt pdf-umbenenner-adapter-out @@ -71,6 +76,22 @@ + org.apache.maven.plugins maven-shade-plugin @@ -85,7 +106,28 @@ de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + META-INF/versions/*/module-info.class + + + false diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 7a73f50..e00f18b 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -1,13 +1,20 @@ package de.gecheckt.pdf.umbenenner.bootstrap; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; +import java.util.Objects; +import java.util.Optional; import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter; +import de.gecheckt.pdf.umbenenner.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.out.bootstrap.validation.InvalidStartConfigurationException; 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. *

- * 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. + * + *

Startup modes

+ * + * + *

Headless batch pipeline phases

*
    - *
  1. Bootstrap Phase: Load and validate configuration, initialize persistence schema, - * establish run-lock, and prepare all adapters and ports.
  2. - *
  3. Execution Phase: Wire and execute the batch processing use case, then map outcome to exit code.
  4. + *
  5. Bootstrap Phase: Load and validate configuration, initialize persistence + * schema, establish run-lock, and prepare all adapters and ports.
  6. + *
  7. Execution Phase: Wire and execute the batch processing use case, then + * map outcome to exit code.
  8. *
+ * + *

Configuration path semantics ({@code --config} option)

*

- * The startup configuration encompasses all technical infrastructure and runtime parameters - * needed for bootstrap and execution. Once validated and the schema is initialized, - * configuration is handed to the use case factory which extracts the minimal runtime - * configuration for the application layer. + * The {@code --config } option is available in both startup modes but is enforced + * differently: + *

* *

Active AI provider

*

@@ -78,15 +110,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; * *

Exit code semantics

* * - *

Adapter wiring

+ *

Adapter wiring (headless path)

*

* The production constructor wires the following key adapters: *

- *

- * 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 { 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 ConfigurationPortFactory configPortFactory; @@ -120,26 +147,39 @@ public class BootstrapRunner { private final SchemaInitializationPortFactory schemaInitPortFactory; private final UseCaseFactory useCaseFactory; private final CommandFactory commandFactory; + private final GuiAdapterFactory guiAdapterFactory; /** * Functional interface encapsulating the legacy configuration migration step. *

* 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. */ @FunctionalInterface 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 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 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); } @@ -155,6 +201,7 @@ public class BootstrapRunner { */ @FunctionalInterface public interface ValidatorFactory { + /** Creates a new {@link StartConfigurationValidator}. */ StartConfigurationValidator create(); } @@ -163,6 +210,12 @@ public class BootstrapRunner { */ @FunctionalInterface 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); } @@ -173,15 +226,24 @@ public class BootstrapRunner { * and the run lock port. Its responsibility is to: *

    *
  1. Extract the minimal runtime configuration needed by the application layer.
  2. - *
  3. Construct all outbound adapter ports (document candidates, PDF extraction, fingerprint, persistence).
  4. + *
  5. Construct all outbound adapter ports (document candidates, PDF extraction, + * fingerprint, persistence).
  6. *
  7. Wire the use case with all required ports and dependencies.
  8. *
*

* 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 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); } @@ -190,9 +252,32 @@ public class BootstrapRunner { */ @FunctionalInterface 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); } + /** + * Functional interface for creating a {@link GuiAdapter}. + *

+ * 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. *

@@ -208,34 +293,23 @@ public class BootstrapRunner { *

  • {@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.
  • *
  • {@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.
  • *
  • {@link SqliteUnitOfWorkAdapter} for atomic persistence operations.
  • - *
  • {@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution in the - * configured {@code target.folder}.
  • - *
  • {@link FilesystemTargetFileCopyAdapter} for copying source documents to the target folder - * via a temporary file and final atomic move/rename.
  • + *
  • {@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution.
  • + *
  • {@link FilesystemTargetFileCopyAdapter} for copying source documents via a + * temporary file and final atomic move/rename.
  • + *
  • {@link GuiAdapter} as the inbound adapter for the GUI startup path.
  • * - *

    - * 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. - *

    - * 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() { - this.migrationStep = () -> new LegacyConfigurationMigrator() - .migrateIfLegacy(Paths.get("config/application.properties")); + this.migrationStep = path -> new LegacyConfigurationMigrator().migrateIfLegacy(path); this.configPortFactory = PropertiesConfigurationPortAdapter::new; this.runLockPortFactory = FilesystemRunLockPortAdapter::new; this.validatorFactory = StartConfigurationValidator::new; this.schemaInitPortFactory = SqliteSchemaInitializationAdapter::new; this.useCaseFactory = (startConfig, lock) -> { - // Extract runtime configuration from startup configuration AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive()); RuntimeConfiguration runtimeConfig = new RuntimeConfiguration( startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity); - // Select the active AI provider adapter AiProviderFamily activeFamily = startConfig.multiProviderConfiguration().activeProviderFamily(); ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration(); AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig); @@ -248,7 +322,6 @@ public class BootstrapRunner { new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl); UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl); - // Wire coordinator logger with AI content sensitivity setting ProcessingLogger coordinatorLogger = new Log4jProcessingLogger( DocumentProcessingCoordinator.class, aiContentSensitivity); TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder()); @@ -259,7 +332,6 @@ public class BootstrapRunner { startConfig.maxRetriesTransient(), activeFamily.getIdentifier()); - // Wire AI naming pipeline PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile()); ClockPort clockPort = new SystemClockAdapter(); AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort); @@ -270,7 +342,6 @@ public class BootstrapRunner { providerConfig.model(), startConfig.maxTextCharacters()); - // Wire use case logger with AI content sensitivity setting ProcessingLogger useCaseLogger = new Log4jProcessingLogger( DefaultBatchRunProcessingUseCase.class, aiContentSensitivity); return new DefaultBatchRunProcessingUseCase( @@ -284,13 +355,16 @@ public class BootstrapRunner { useCaseLogger); }; 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. *

    - * The migration step is set to a no-op; tests that need to exercise the migration - * path use the full seven-parameter constructor. + * The migration step is set to a no-op so tests that provide a pre-built + * {@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 runLockPortFactory factory for creating RunLockPort instances @@ -305,15 +379,17 @@ public class BootstrapRunner { SchemaInitializationPortFactory schemaInitPortFactory, UseCaseFactory useCaseFactory, CommandFactory commandFactory) { - this(() -> { /* no-op: tests inject mock ConfigurationPort directly */ }, + this(path -> { /* no-op: tests inject mock ConfigurationPort directly */ }, configPortFactory, runLockPortFactory, validatorFactory, - schemaInitPortFactory, useCaseFactory, commandFactory); + schemaInitPortFactory, useCaseFactory, commandFactory, + GuiAdapter::new); } /** * Creates the BootstrapRunner with all factories including an explicit migration step. *

    * 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 configPortFactory factory for creating ConfigurationPort instances @@ -330,6 +406,34 @@ public class BootstrapRunner { SchemaInitializationPortFactory schemaInitPortFactory, UseCaseFactory useCaseFactory, 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. + *

    + * 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.configPortFactory = configPortFactory; this.runLockPortFactory = runLockPortFactory; @@ -337,35 +441,148 @@ public class BootstrapRunner { this.schemaInitPortFactory = schemaInitPortFactory; this.useCaseFactory = useCaseFactory; this.commandFactory = commandFactory; + this.guiAdapterFactory = guiAdapterFactory; } /** - * Runs the complete application startup sequence. + * Runs the complete application startup sequence for the given startup arguments. *

    - * Startup flow consists of two phases: - *

      - *
    1. Bootstrap Phase (hard failures only): Load and validate configuration, - * then initialize the SQLite persistence schema.
    2. - *
    3. Execution Phase (document failures tolerated): Execute the batch processing pipeline - * with all adapters and ports wired.
    4. - *
    + * Dispatches to the GUI or headless batch adapter based on the startup mode carried + * in {@code startupArguments}: + * + * + * @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. *

    - * A {@link DocumentPersistenceException} during schema initialization is treated as a hard startup - * failure and causes exit code 1. Document-level failures during the batch loop are not startup - * failures and do not change the exit code as long as the run itself completes without a hard - * infrastructure error. + * This convenience overload preserves backward compatibility for callers that do not + * supply explicit startup arguments. It delegates to {@link #run(StartupArguments)} + * with {@link StartupMode#HEADLESS} and no configuration path override, which + * replicates the pre-GUI startup behaviour. * * @return exit code: 0 for a technically completed run, 1 for any hard bootstrap, * configuration, or persistence failure */ 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. + *

    + * When a {@code --config} path override is present in {@code configPathOverride}, the file + * existence is checked before the adapter is started: + *

    + *

    + * 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. + *

    + * 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 configPathOverride) { + Optional 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 { - // Bootstrap Phase: prepare configuration and persistence - migrateConfigurationIfNeeded(); - StartConfiguration config = loadAndValidateConfiguration(); + GuiAdapter guiAdapter = guiAdapterFactory.create(); + guiAdapter.start(startupNotice); + 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. + *

    + * 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. + *

    + * After confirming the path (or when no override is present), the effective path is + * resolved and the two-phase batch bootstrap executes: + *

      + *
    1. Bootstrap Phase (hard failures only): Legacy migration, configuration + * loading and validation, SQLite schema initialization.
    2. + *
    3. Execution Phase (document failures tolerated): Run lock acquisition, + * use case wiring, batch execution, and outcome-to-exit-code mapping.
    4. + *
    + *

    + * 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); - // Execution Phase: run batch processing return executeWithStartConfiguration(config); } catch (ConfigurationLoadingException e) { 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. + *

    + * 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 configPathOverride) { + return configPathOverride + .map(Paths::get) + .orElse(DEFAULT_CONFIG_PATH); + } + + /** + * Runs the legacy configuration migration step against the effective configuration path. *

    * 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 @@ -391,29 +624,25 @@ public class BootstrapRunner { *

    * A migration failure is a hard startup error and propagates as a * {@link ConfigurationLoadingException}. + * + * @param effectiveConfigPath the resolved configuration file path; never {@code null} */ - private void migrateConfigurationIfNeeded() { - migrationStep.runIfNeeded(); + private void migrateConfigurationIfNeeded(Path effectiveConfigPath) { + migrationStep.runIfNeeded(effectiveConfigPath); } /** - * Loads configuration via {@link ConfigurationPort} and validates it via - * {@link StartConfigurationValidator}. - *

    - * Validation includes: - *

    + * Loads configuration from the effective configuration path and validates it. *

    * 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() { - ConfigurationPort configPort = configPortFactory.create(); + private StartConfiguration loadAndValidateConfiguration(Path effectiveConfigPath) { + ConfigurationPort configPort = configPortFactory.create(effectiveConfigPath); StartConfiguration config = configPort.loadConfiguration(); validatorFactory.create().validate(config); 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. * 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) { schemaInitPortFactory.create(buildJdbcUrl(config)).initializeSchema(); @@ -434,13 +665,6 @@ public class BootstrapRunner { *

    * 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 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. - *

    - * This represents the execution phase after startup configuration is validated - * and persistence schema is initialized. * * @param config the validated startup configuration (complete technical configuration) * @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. + * + * @param config the startup configuration + * @return the resolved lock file path; never {@code null} and never blank */ private Path resolveLockFilePath(StartConfiguration config) { 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. + * + * @return a new batch run context; never {@code null} */ private BatchRunContext createRunContext() { 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. * + * @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 */ private int mapOutcomeToExitCode(BatchRunOutcome outcome, BatchRunContext runContext) { diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java index aad7288..e2da4d9 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/PdfUmbenennerApplication.java @@ -3,11 +3,28 @@ package de.gecheckt.pdf.umbenenner.bootstrap; import org.apache.logging.log4j.LogManager; 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. *

    - * Delegates to {@link BootstrapRunner} for manual object graph construction - * and execution of the startup sequence. + * Responsible for: + *

      + *
    1. Parsing raw command-line arguments via {@link CliArgumentParser}.
    2. + *
    3. Aborting with exit code 1 when the arguments are invalid or unrecognised.
    4. + *
    5. Delegating to {@link BootstrapRunner} for startup mode selection, + * object graph construction, and execution.
    6. + *
    + *

    + * Supported CLI options: + *

    */ public class PdfUmbenennerApplication { @@ -15,14 +32,32 @@ public class PdfUmbenennerApplication { /** * Application entry point. + *

    + * 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) { LOG.info("Starting PDF Umbenenner application..."); 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(); - int exitCode = runner.run(); + int exitCode = runner.run(startupArguments); if (exitCode == 0) { LOG.info("PDF Umbenenner application completed successfully."); } else { @@ -34,4 +69,4 @@ public class PdfUmbenennerApplication { System.exit(1); } } -} \ No newline at end of file +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java index 83dc24f..2d5d6f3 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/package-info.java @@ -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. *

    - * Responsibility: Orchestrate the complete startup sequence in two phases: (1) bootstrap phase - * for configuration loading, validation, and schema initialization, and (2) execution phase - * for wiring all adapters and running the batch processing pipeline. + * Responsibility: Parse the startup mode from command-line arguments and dispatch to the + * appropriate inbound adapter. Exactly one inbound adapter is started per process run. + * For the headless batch path, the bootstrap module also orchestrates configuration loading, + * schema initialization, and the full adapter wiring. *

    * Components: *

    *

    - * Implementation approach: + * Startup mode dispatch: *

    *

    - * Startup sequence: + * Configuration path resolution: *

    + *

    + * Headless batch startup sequence (two phases): + *

    + *

    + * Implementation approach: + *

    *

    * Exit codes: *

    */ package de.gecheckt.pdf.umbenenner.bootstrap; \ No newline at end of file diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParser.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParser.java new file mode 100644 index 0000000..d52d247 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParser.java @@ -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}. + *

    + * Supported options: + *

    + *

    + * Options may appear in any order. Each option must appear at most once. + *

    + * Invalid usages produce a {@link StartupArgumentsParseResult.Invalid} result: + *

    + *

    + * 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. + *

    + * 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. + *

    + * 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 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)); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupArguments.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupArguments.java new file mode 100644 index 0000000..360ba25 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupArguments.java @@ -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. + *

    + * 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 }. + *

    + * 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 }, + * or empty if the option was absent from the command line + */ +public record StartupArguments(StartupMode mode, Optional 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"); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupArgumentsParseResult.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupArgumentsParseResult.java new file mode 100644 index 0000000..820af4d --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupArgumentsParseResult.java @@ -0,0 +1,62 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.startup; + +import java.util.Objects; + +/** + * Sealed result type returned by {@link CliArgumentParser#parse(String[])}. + *

    + * Bootstrap evaluates the parse result to decide the next action: + *

    + *

    + * 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. + *

    + * 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. + *

    + * 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"); + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupMode.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupMode.java new file mode 100644 index 0000000..9d123c9 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/StartupMode.java @@ -0,0 +1,33 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.startup; + +/** + * Enumerates the supported application startup modes. + *

    + * 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. + *

    + * 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. + *

    + * 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. + *

    + * 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 +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/package-info.java new file mode 100644 index 0000000..a9c169d --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/package-info.java @@ -0,0 +1,39 @@ +/** + * Startup argument model and command-line parsing for the application entry point. + *

    + * 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. + *

    + * Components: + *

    + *

    + * Supported CLI options: + *

    + *

    + * Invalid usages (result in {@code Invalid} parse result): + *

    + */ +package de.gecheckt.pdf.umbenenner.bootstrap.startup; diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java new file mode 100644 index 0000000..68a5b7e --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerConfigPathSemanticsTest.java @@ -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}. + *

    + * 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: + *

    + */ +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 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 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 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> receivedNotice = new AtomicReference<>(); + + BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { + @Override + public void start(Optional 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 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 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> receivedNotice = new AtomicReference<>(); + + BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { + @Override + public void start(Optional 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> receivedNotice = new AtomicReference<>(); + + BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { + @Override + public void start(Optional 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> receivedNotice = new AtomicReference<>(); + + BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() { + @Override + public void start(Optional 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; + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java index b5c2318..6231e46 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerEdgeCasesTest.java @@ -80,7 +80,7 @@ class BootstrapRunnerEdgeCasesTest { AtomicReference capturedLockPath = new AtomicReference<>(); BootstrapRunner runner = new BootstrapRunner( - () -> () -> configWithNullLock, + configPath -> () -> configWithNullLock, lockFile -> { capturedLockPath.set(lockFile); return new MockRunLockPort(); @@ -190,7 +190,7 @@ class BootstrapRunnerEdgeCasesTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -209,7 +209,7 @@ class BootstrapRunnerEdgeCasesTest { void run_distinguishesBetweenConfigLoadingAndValidationFailure() throws Exception { // Test 1: Configuration loading exception BootstrapRunner runnerLoadFailure = new BootstrapRunner( - () -> { + configPath -> { throw new ConfigurationLoadingException("Load failed"); }, lockFile -> new MockRunLockPort(), @@ -224,7 +224,7 @@ class BootstrapRunnerEdgeCasesTest { // Test 2: Configuration validation exception BootstrapRunner runnerValidationFailure = new BootstrapRunner( - () -> () -> { + configPath -> () -> { try { Path sourceDir = Files.createDirectories(tempDir.resolve("source")); Path targetDir = Files.createDirectories(tempDir.resolve("target")); @@ -259,7 +259,7 @@ class BootstrapRunnerEdgeCasesTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new PersistenceSchemaInitializationPort() { @@ -286,7 +286,7 @@ class BootstrapRunnerEdgeCasesTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -302,7 +302,7 @@ class BootstrapRunnerEdgeCasesTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -318,7 +318,7 @@ class BootstrapRunnerEdgeCasesTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -377,7 +377,7 @@ class BootstrapRunnerEdgeCasesTest { ctx.updateLoggers(); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -429,7 +429,7 @@ class BootstrapRunnerEdgeCasesTest { ctx.updateLoggers(); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -466,7 +466,7 @@ class BootstrapRunnerEdgeCasesTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java new file mode 100644 index 0000000..bce80b6 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerStartupDispatchTest.java @@ -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}. + *

    + * 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 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 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 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 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. + *

    + * 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. + *

    + * 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 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; + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java index 3e0d29a..8c5bf59 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java @@ -58,7 +58,7 @@ class BootstrapRunnerTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -83,7 +83,7 @@ class BootstrapRunnerTest { }; BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), () -> failingValidator, jdbcUrl -> new MockSchemaInitializationPort(), @@ -103,7 +103,7 @@ class BootstrapRunnerTest { }; BootstrapRunner runner = new BootstrapRunner( - () -> failingConfigPort, + configPath -> failingConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -123,7 +123,7 @@ class BootstrapRunnerTest { }; BootstrapRunner runner = new BootstrapRunner( - () -> throwingConfigPort, + configPath -> throwingConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -142,7 +142,7 @@ class BootstrapRunnerTest { BatchRunProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE; BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -162,7 +162,7 @@ class BootstrapRunnerTest { BatchRunProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE; BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -209,7 +209,7 @@ class BootstrapRunnerTest { // ConfigurationPortFactory returns a ConfigurationPort; ConfigurationPort returns StartConfiguration ConfigurationPort configPort = () -> configWithEmptyLock; BootstrapRunner runner = new BootstrapRunner( - () -> configPort, + configPath -> configPort, lockFile -> { capturedLockPath.set(lockFile); return new MockRunLockPort(); @@ -241,7 +241,7 @@ class BootstrapRunnerTest { BatchRunProcessingUseCase useCaseWithDocumentFailures = (context) -> BatchRunOutcome.SUCCESS; BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -288,7 +288,7 @@ class BootstrapRunnerTest { ); BootstrapRunner runner = new BootstrapRunner( - () -> () -> configWithZeroRetries, // ConfigurationPortFactory → ConfigurationPort → StartConfiguration + configPath -> () -> configWithZeroRetries, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, // use the real validator jdbcUrl -> new MockSchemaInitializationPort(), @@ -310,7 +310,7 @@ class BootstrapRunnerTest { @Test void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() { BootstrapRunner runner = new BootstrapRunner( - () -> { + configPath -> { throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException( "Invalid value for log.ai.sensitive: 'maybe'. " + "Must be either 'true' or 'false' (case-insensitive)."); @@ -339,7 +339,7 @@ class BootstrapRunnerTest { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new PersistenceSchemaInitializationPort() { @@ -369,7 +369,7 @@ class BootstrapRunnerTest { void activeProviderIsLoggedAtRunStart() throws Exception { ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true); BootstrapRunner runner = new BootstrapRunner( - () -> mockConfigPort, + configPath -> mockConfigPort, lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), @@ -445,8 +445,8 @@ class BootstrapRunnerTest { Files.writeString(configFile, legacyConfig); BootstrapRunner runner = new BootstrapRunner( - () -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile), - () -> new PropertiesConfigurationPortAdapter(configFile), + path -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile), + path -> new PropertiesConfigurationPortAdapter(configFile), lockFile -> new MockRunLockPort(), StartConfigurationValidator::new, jdbcUrl -> new MockSchemaInitializationPort(), diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java index d95d996..47037c9 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapSmokeTest.java @@ -63,7 +63,7 @@ class BootstrapSmokeTest { AtomicReference capturedPort = new AtomicReference<>(); BootstrapRunner runner = new BootstrapRunner( - () -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE, + configPath -> buildConfigPort(tempDir, AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig(), null), lockFile -> new NoOpRunLockPort(), StartConfigurationValidator::new, @@ -104,7 +104,7 @@ class BootstrapSmokeTest { AtomicReference capturedPort = new AtomicReference<>(); BootstrapRunner runner = new BootstrapRunner( - () -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE, + configPath -> buildConfigPort(tempDir, AiProviderFamily.CLAUDE, null, claudeConfig()), lockFile -> new NoOpRunLockPort(), StartConfigurationValidator::new, diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java index 8cac5f7..e076bc6 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/ExecutableJarSmokeTestIT.java @@ -110,11 +110,12 @@ class ExecutableJarSmokeTestIT { assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar); - // Build the java -jar command + // Build the java -jar command — use --headless because the GUI is not yet functional List command = new ArrayList<>(); command.add(JAVA_EXECUTABLE); command.add("-jar"); command.add(shadedJar.toString()); + command.add("--headless"); // Run the process in the work directory where config/application.properties will be found ProcessBuilder pb = new ProcessBuilder(command); @@ -233,11 +234,12 @@ class ExecutableJarSmokeTestIT { assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar); - // Build the java -jar command + // Build the java -jar command — use --headless because the GUI is not yet functional List command = new ArrayList<>(); command.add(JAVA_EXECUTABLE); command.add("-jar"); command.add(shadedJar.toString()); + command.add("--headless"); // Run the process ProcessBuilder pb = new ProcessBuilder(command); diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParserTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParserTest.java new file mode 100644 index 0000000..4254eae --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/startup/CliArgumentParserTest.java @@ -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}. + *

    + * 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); + } +} diff --git a/pom.xml b/pom.xml index ca4b85f..c6c1dbe 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ pdf-umbenenner-domain pdf-umbenenner-application pdf-umbenenner-adapter-in-cli + pdf-umbenenner-adapter-in-gui pdf-umbenenner-adapter-out pdf-umbenenner-bootstrap pdf-umbenenner-coverage @@ -25,6 +26,7 @@ false + 21.0.2 2.23.1 3.0.2 3.45.1.0 @@ -83,6 +85,34 @@ ${json.version} + + + org.openjfx + javafx-base + ${javafx.version} + win + + + org.openjfx + javafx-controls + ${javafx.version} + win + + + org.openjfx + javafx-graphics + ${javafx.version} + win + + + + + org.testfx + openjfx-monocle + jdk-12.0.1+2 + test + + org.junit.jupiter