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