Ergaenze zweiten GUI-Tab fuer Verarbeitungslauf mit Live-Fortschritt

- Fuehrt neuen Inbound-Adapter-Subpfad batchrun/ mit Tab, Koordinator,
  Launcher-Port und Ergebniszeilen-Model ein; der Batch-Lauf laeuft auf
  einem Hintergrund-Worker, UI-Updates ausschliesslich via FX-Dispatcher.
- Ergaenzt application.port.in um BatchRunProgressObserver,
  BatchRunCancellationToken, DocumentCompletionEvent/-Status und
  RunSummary; DefaultBatchRunProcessingUseCase und
  DocumentProcessingCoordinator melden Lauf-/Dokument-Ereignisse an den
  Beobachter und unterstuetzen Soft-Stop zwischen Kandidaten.
- Verdrahtet BootstrapRunner so, dass die GUI den vollstaendigen
  Headless-Pipelinepfad (Migration, Validierung, Schema-Init, Lock,
  Use-Case) mit Observer und Cancellation ausfuehrt; headless-Verhalten
  bleibt unveraendert.
- Editor-Workspace bettet den zweiten Tab ein, sperrt Tab 1 mit
  Hinweisbanner waehrend eines Laufs und fragt den Benutzer beim
  Schliessen waehrend eines laufenden Batches.
- Fuegt Tests fuer Observer-Wiring, Koordinator-Lebenszyklus und
  Tab-Smoke-Verhalten ein; aktualisiert die GUI-Bedienanleitung und
  docs/betrieb.md auf den neuen Tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 15:29:06 +02:00
parent eacc205865
commit f4cfb5cbc0
27 changed files with 3621 additions and 93 deletions
@@ -14,6 +14,8 @@ import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiApiKeyMerger;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiChangeState;
@@ -341,6 +343,33 @@ public final class GuiConfigurationEditorWorkspace {
private final EditorConfigurationValidator editorValidator = new EditorConfigurationValidator();
private final ApiKeyResolutionPort apiKeyResolutionPort;
/**
* Launcher used by the processing-run tab to execute a batch run against the saved
* configuration file. Supplied by Bootstrap via the startup context.
*/
private final GuiBatchRunLauncher batchRunLauncher;
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
* the existing configuration tab.
*/
private final GuiBatchRunTab batchRunTab;
/**
* Hint banner shown at the top of the configuration tab while a processing run is
* active. Visible + managed state are flipped from the batch run tab's listener when
* the running flag toggles.
*/
final Label configurationLockBanner = new Label(
"Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar");
/**
* Reference to the configuration tab so the running-state listener can disable its
* content while a batch run is active.
*/
Tab configurationTab;
/**
* Creates a new workspace with the unloaded start state.
*
@@ -391,12 +420,72 @@ public final class GuiConfigurationEditorWorkspace {
this.unsavedChangesGuard = new GuiUnsavedChangesGuard(
triggerLabel -> showUnsavedChangesDialog(triggerLabel));
this.batchRunLauncher = effectiveContext.batchRunLauncher();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
this::loadedConfigurationPath,
this::isSavedConfigurationReady,
this::applyBatchRunLockState);
configureRoot();
configureHeader(effectiveContext.startupNotice());
configureTabs();
configureActionBar();
configureActions();
refreshView();
applyBatchRunLockState();
}
/**
* Returns the processing-run tab; package-private so smoke tests can drive start/cancel
* actions and read the result table.
*/
GuiBatchRunTab batchRunTab() {
return batchRunTab;
}
/**
* Returns the currently loaded configuration file path, or {@code null} when the
* editor has never loaded a file from disk. The processing-run tab uses this value to
* derive the path a run should execute against.
*/
private Path loadedConfigurationPath() {
return editorState.loadedFileSnapshot()
.map(GuiConfigurationFileSnapshot::filePath)
.orElse(null);
}
/**
* Returns whether the editor currently has a saved configuration that can be used as
* the source of a processing run.
* <p>
* A configuration is considered run-ready when the editor was loaded from disk (i.e.
* a file snapshot exists). Unsaved editor changes are intentionally ignored — the
* run always uses the last saved state of the {@code .properties} file as required by
* the specification.
*/
private boolean isSavedConfigurationReady() {
return editorState.hasLoadedFileSnapshot();
}
/**
* Applies the "batch run active" UI lock state to the configuration tab and the
* action bar.
* <p>
* While a run is active the configuration editor is made non-interactive, the lock
* banner is shown at the top of Tab 1, and the main action buttons (Neu, Öffnen,
* Speichern, Speichern unter) are disabled. When the run ends, the locks are
* released and the editor returns to its normal state.
*/
void applyBatchRunLockState() {
boolean running = batchRunTab != null && batchRunTab.isRunning();
configurationLockBanner.setVisible(running);
configurationLockBanner.setManaged(running);
sectionsBox.setDisable(running);
newButton.setDisable(running);
openButton.setDisable(running);
saveButton.setDisable(running);
saveAsButton.setDisable(running);
}
/**
@@ -411,6 +500,11 @@ public final class GuiConfigurationEditorWorkspace {
*/
public void installCloseRequestHandler(Stage stage) {
stage.setOnCloseRequest(event -> {
if (batchRunTab != null && batchRunTab.isRunning()) {
event.consume();
handleCloseWhileRunRunning(stage);
return;
}
if (!editorState.isDirty()) {
return;
}
@@ -425,6 +519,59 @@ public final class GuiConfigurationEditorWorkspace {
});
}
/**
* Supplier for the "Lauf läuft noch" dialog invoked by
* {@link #handleCloseWhileRunRunning(Stage)}. Package-private so tests can substitute
* a deterministic choice without showing a native dialog. The default implementation
* displays a blocking confirmation alert with the two options mandated by the spec.
*/
Supplier<CloseWhileRunningChoice> closeWhileRunningDialog = this::showCloseWhileRunningDialog;
/**
* Distinct outcomes of the "Lauf läuft noch" dialog.
*/
enum CloseWhileRunningChoice {
/** User chose "Nicht schließen"; the run continues and the window stays open. */
KEEP_OPEN,
/** User chose "Lauf beenden und schließen"; a soft-stop is requested. */
CANCEL_AND_CLOSE
}
private void handleCloseWhileRunRunning(Stage stage) {
CloseWhileRunningChoice choice;
try {
choice = closeWhileRunningDialog.get();
} catch (RuntimeException e) {
LOG.warn("GUI-Editor: Fehler im Schließen-Dialog: {}", e.getMessage(), e);
return;
}
if (choice == CloseWhileRunningChoice.CANCEL_AND_CLOSE) {
LOG.info("GUI-Editor: Soft-Stop angefordert; Fenster schließt nach Laufende.");
batchRunTab.requestCancellation();
batchRunTab.runningProperty().addListener((obs, wasRunning, running) -> {
if (!running) {
Platform.runLater(stage::close);
}
});
} else {
LOG.info("GUI-Editor: Schließen abgebrochen; Verarbeitungslauf läuft weiter.");
}
}
private CloseWhileRunningChoice showCloseWhileRunningDialog() {
Alert dialog = new Alert(Alert.AlertType.CONFIRMATION);
dialog.setTitle("Verarbeitungslauf läuft");
dialog.setHeaderText("Es läuft aktuell ein Verarbeitungslauf.");
dialog.setContentText("Was soll geschehen?");
ButtonType keepOpen = new ButtonType("Nicht schließen");
ButtonType cancelAndClose = new ButtonType("Lauf beenden und schließen");
dialog.getButtonTypes().setAll(cancelAndClose, keepOpen);
Optional<ButtonType> result = dialog.showAndWait();
return result.filter(cancelAndClose::equals).isPresent()
? CloseWhileRunningChoice.CANCEL_AND_CLOSE
: CloseWhileRunningChoice.KEEP_OPEN;
}
/**
* Returns the root node used by the JavaFX scene.
*
@@ -946,18 +1093,30 @@ public final class GuiConfigurationEditorWorkspace {
private void configureTabs() {
Tab editorTab = new Tab("Konfiguration");
editorTab.setClosable(false);
configurationTab = editorTab;
sectionsBox.setSpacing(12);
sectionsBox.setFillWidth(true);
ScrollPane scrollPane = new ScrollPane(sectionsBox);
configurationLockBanner.setId("configuration-lock-banner");
configurationLockBanner.setStyle(
"-fx-font-weight: bold; -fx-text-fill: #b45309; -fx-padding: 8 12 8 12;"
+ " -fx-background-color: #fef3c7; -fx-background-radius: 4;");
configurationLockBanner.setMaxWidth(Double.MAX_VALUE);
configurationLockBanner.setVisible(false);
configurationLockBanner.setManaged(false);
VBox tabContent = new VBox(8, configurationLockBanner, sectionsBox);
VBox.setVgrow(sectionsBox, Priority.ALWAYS);
ScrollPane scrollPane = new ScrollPane(tabContent);
scrollPane.setFitToWidth(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane);
tabPane.getTabs().add(editorTab);
tabPane.getTabs().setAll(editorTab, batchRunTab.tab());
root.setCenter(tabPane);
}
@@ -3,6 +3,8 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
@@ -41,7 +43,8 @@ public record GuiStartupContext(
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService) {
CorrectionExecutionService correctionExecutionService,
GuiBatchRunLauncher batchRunLauncher) {
/**
* Creates a startup context.
@@ -56,6 +59,9 @@ public record GuiStartupContext(
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
* @param batchRunLauncher bridge that executes a batch run against a stored
* configuration path for the processing-run tab;
* must not be {@code null}
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -76,6 +82,49 @@ public record GuiStartupContext(
"technicalTestOrchestrator must not be null");
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
"correctionExecutionService must not be null");
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
"batchRunLauncher must not be null");
}
/**
* Backward-compatible constructor that fills the processing-run launcher with a
* no-op implementation.
* <p>
* Preserves existing callers that were written before the processing-run tab was added.
* The no-op launcher rejects every start request with a clear German message so the
* UI never enters an unsafe state in legacy test wiring.
*
* @param initialState initial editor state; must not be {@code null}
* @param startupNotice optional startup notice; {@code null} becomes empty
* @param configurationFileLoader file-loading callback; must not be {@code null}
* @param configurationFileWriter file-writing callback; must not be {@code null}
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
*/
public GuiStartupContext(
GuiConfigurationEditorState initialState,
Optional<String> startupNotice,
GuiConfigurationFileLoader configurationFileLoader,
GuiConfigurationFileWriter configurationFileWriter,
AiModelCatalogPort modelCatalogPort,
ApiKeyResolutionPort apiKeyResolutionPort,
ProviderTechnicalTestService providerTechnicalTestService,
PathCheckPort pathCheckPort,
TechnicalTestOrchestrator technicalTestOrchestrator,
CorrectionExecutionService correctionExecutionService) {
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
technicalTestOrchestrator, correctionExecutionService,
rejectingBatchRunLauncher());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
return (configPath, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
}
/**
@@ -145,6 +194,9 @@ public record GuiStartupContext(
}
};
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) ->
GuiBatchRunLaunchOutcome.rejected(
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
return new GuiStartupContext(
GuiConfigurationEditorStateFactory.createBlankStartState(),
startupNotice,
@@ -155,6 +207,7 @@ public record GuiStartupContext(
noOpTestService,
noOpPathCheckPort,
noOpOrchestrator,
noOpCorrectionService);
noOpCorrectionService,
noOpBatchRunLauncher);
}
}
@@ -0,0 +1,280 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.application.Platform;
/**
* Coordinates a single batch run triggered from the JavaFX GUI.
* <p>
* The coordinator owns the background worker thread that executes the run, maintains the
* cancellation flag, and translates the
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
* callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
*
* <h2>Threading</h2>
* <ul>
* <li>The batch run executes on a daemon worker thread created by
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
* {@code Control}s without taking any further precautions.</li>
* <li>{@link #requestCancellation()} sets a volatile flag that the use case polls
* between candidates (soft-stop). It never interrupts the worker thread; the
* currently-processed candidate always completes in full.</li>
* </ul>
*
* <h2>Lifecycle</h2>
* <ol>
* <li>Construct with a launcher, a thread factory and a listener.</li>
* <li>Call {@link #start(Path)} to begin a run against a configuration file.</li>
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop.</li>
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} on the
* FX thread.</li>
* <li>Start a new run only after the previous one has ended.</li>
* </ol>
*/
public final class GuiBatchRunCoordinator {
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
private static final String WORKER_THREAD_NAME = "gui-batch-run";
/**
* Listener interface invoked on the JavaFX Application Thread during a run.
*/
public interface Listener {
/**
* Invoked once, after the batch use case has scanned the source folder and knows
* the total candidate count.
*
* @param runId the identifier of the run; never {@code null}
* @param totalCandidates the number of candidates detected in the source folder;
* never negative
*/
void onRunStarted(RunId runId, int totalCandidates);
/**
* Invoked once per candidate whose processing reached a terminal resolution.
*
* @param row the row describing the candidate result; never {@code null}
*/
void onDocumentCompleted(GuiBatchRunResultRow row);
/**
* Invoked once after the run has fully terminated on the worker thread.
*
* @param summary the final outcome counts; never {@code null}
* @param outcome a description of how the run terminated; never {@code null}
*/
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
}
private final GuiBatchRunLauncher launcher;
private final Function<Runnable, Thread> threadFactory;
private final Consumer<Runnable> fxDispatcher;
private final Listener listener;
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
/**
* Creates the coordinator with the default worker-thread factory and the default
* JavaFX Application Thread dispatcher.
*
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
* @param listener GUI listener invoked on the FX thread; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
this(launcher, defaultThreadFactory(), defaultFxDispatcher(), listener);
}
/**
* Creates the coordinator with custom hooks for the worker-thread factory and the
* UI-thread dispatcher.
* <p>
* Tests use this constructor to execute batches synchronously or to verify which
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
* initialised.
*
* @param launcher bridge to Bootstrap; must not be null
* @param threadFactory factory returning a ready-to-start worker thread; must not
* be null
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
* Thread; must not be null
* @param listener GUI listener; must not be null
*/
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
Function<Runnable, Thread> threadFactory,
Consumer<Runnable> fxDispatcher,
Listener listener) {
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
this.listener = Objects.requireNonNull(listener, "listener must not be null");
}
/**
* Returns whether a run is currently active.
*
* @return {@code true} while a worker thread is processing a run
*/
public boolean isRunning() {
Thread worker = activeWorker.get();
return worker != null && worker.isAlive();
}
/**
* Starts a new run for the supplied configuration file.
* <p>
* Immediately returns once the worker thread has been started. All further progress
* is communicated through the configured {@link Listener} on the JavaFX Application
* Thread. An attempt to start a new run while another is still active is rejected
* with {@code false} and leaves the currently running batch untouched.
*
* @param configFilePath the configuration file the run shall read from; must not be
* {@code null}
* @return {@code true} when a new worker thread was started, {@code false} when a run
* was already in progress
* @throws NullPointerException if {@code configFilePath} is {@code null}
*/
public boolean start(Path configFilePath) {
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
if (isRunning()) {
return false;
}
cancellationRequested.set(false);
Runnable task = () -> executeRun(configFilePath);
Thread worker = threadFactory.apply(task);
Objects.requireNonNull(worker, "threadFactory must not return null");
activeWorker.set(worker);
worker.start();
return true;
}
/**
* Requests soft-stop cancellation of the currently running batch.
* <p>
* The flag is honoured between candidates — the candidate that is currently being
* processed is always completed in full and persisted before the run ends. Calling
* this method when no run is active has no effect.
*/
public void requestCancellation() {
if (isRunning()) {
cancellationRequested.set(true);
}
}
/**
* Returns whether cancellation has been requested for the current (or last) run.
*
* @return {@code true} when a cancellation request is pending or was pending when
* the last run ended; {@code false} before the first run
*/
public boolean isCancellationRequested() {
return cancellationRequested.get();
}
private void executeRun(Path configFilePath) {
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
configFilePath);
BatchRunProgressObserver observer = buildDispatchingObserver();
BatchRunCancellationToken token = cancellationRequested::get;
GuiBatchRunLaunchOutcome outcome;
try {
outcome = launcher.launch(configFilePath, observer, token);
if (outcome == null) {
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Launcher hat kein Ergebnis geliefert.");
}
} catch (RuntimeException e) {
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
e.getMessage(), e);
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
"Unerwarteter technischer Fehler: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
RunSummary summary = observerSummary.get();
if (summary == null) {
summary = new RunSummary(0, 0, 0);
}
GuiBatchRunLaunchOutcome finalOutcome = outcome;
RunSummary finalSummary = summary;
activeWorker.set(null);
fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
}
/**
* Captures the final summary supplied by the application layer. Written on the
* worker thread; read only after the run has ended.
*/
private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();
private BatchRunProgressObserver buildDispatchingObserver() {
return new BatchRunProgressObserver() {
@Override
public void onRunStarted(RunId runId, int totalCandidates) {
fxDispatcher.accept(() -> listener.onRunStarted(runId, totalCandidates));
}
@Override
public void onDocumentCompleted(DocumentCompletionEvent event) {
GuiBatchRunResultRow row = toRow(event);
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
}
@Override
public void onRunEnded(RunSummary summary) {
observerSummary.set(summary);
// No FX dispatch here: the worker thread invokes the listener's
// onRunEnded via executeRun() once the launcher has returned, ensuring
// the outcome carries the launcher's terminal verdict.
}
};
}
private static GuiBatchRunResultRow toRow(DocumentCompletionEvent event) {
Optional<String> finalName = event.finalFileName() == null
? Optional.empty() : Optional.of(event.finalFileName());
Optional<LocalDate> date = event.resolvedDate() == null
? Optional.empty() : Optional.of(event.resolvedDate());
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
? Optional.empty() : Optional.of(event.aiReasoning());
Duration duration = event.processingDuration();
return new GuiBatchRunResultRow(
event.originalFileName(),
event.status(),
finalName,
date,
reasoning,
duration);
}
private static Function<Runnable, Thread> defaultThreadFactory() {
return task -> {
Thread thread = new Thread(task, WORKER_THREAD_NAME);
thread.setDaemon(true);
return thread;
};
}
private static Consumer<Runnable> defaultFxDispatcher() {
return Platform::runLater;
}
}
@@ -0,0 +1,77 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable result of a single batch run launched from the GUI.
* <p>
* The outcome reports to the tab whether the run finished normally, could not even be
* started (hard failure), or ended because of an unexpected exception. The GUI uses this
* to transition between its "laufend" and "bereit"/"Fehler" states.
*
* <h2>Fields</h2>
* <ul>
* <li>{@link #successfullyStarted()} — {@code true} when the launcher managed to enter
* the batch execution phase; {@code false} when the run was rejected before any
* candidate could be processed (e.g. configuration invalid, lock held, SQLite
* unavailable).</li>
* <li>{@link #batchCompletedNormally()} — {@code true} when the run returned from the
* batch use case with a normal outcome (whether empty, partial, or full). Only
* meaningful when {@link #successfullyStarted()} is also {@code true}.</li>
* <li>{@link #failureMessage()} — present when either the run could not start or an
* unexpected technical exception terminated it. Empty when the run completed
* normally.</li>
* </ul>
*/
public record GuiBatchRunLaunchOutcome(
boolean successfullyStarted,
boolean batchCompletedNormally,
Optional<String> failureMessage) {
/**
* Compact constructor normalising the failure message holder.
*/
public GuiBatchRunLaunchOutcome {
failureMessage = failureMessage == null ? Optional.empty() : failureMessage;
}
/**
* Returns an outcome describing a run that finished normally.
*
* @return a started + completed outcome without failure message
*/
public static GuiBatchRunLaunchOutcome completed() {
return new GuiBatchRunLaunchOutcome(true, true, Optional.empty());
}
/**
* Returns an outcome describing a run that could not start because of a hard
* configuration, persistence, or lock failure.
*
* @param failureMessage the user-visible German failure description; must not be blank
* @return a rejected-startup outcome carrying the supplied message
*/
public static GuiBatchRunLaunchOutcome rejected(String failureMessage) {
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
if (failureMessage.isBlank()) {
throw new IllegalArgumentException("failureMessage must not be blank");
}
return new GuiBatchRunLaunchOutcome(false, false, Optional.of(failureMessage));
}
/**
* Returns an outcome describing a run that started but ended due to an unexpected
* technical exception.
*
* @param failureMessage the user-visible German failure description; must not be blank
* @return an aborted-after-start outcome carrying the supplied message
*/
public static GuiBatchRunLaunchOutcome failedAfterStart(String failureMessage) {
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
if (failureMessage.isBlank()) {
throw new IllegalArgumentException("failureMessage must not be blank");
}
return new GuiBatchRunLaunchOutcome(true, false, Optional.of(failureMessage));
}
}
@@ -0,0 +1,51 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
/**
* Inbound bridge implemented by Bootstrap to let the GUI execute a batch run against a
* stored configuration file.
* <p>
* The launcher performs the complete headless startup sequence (legacy migration, config
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, execution)
* for the supplied configuration path while forwarding progress callbacks and honouring
* the supplied cancellation token. It reuses the very same application ports and
* persistence pipeline as a Task-Scheduler-triggered headless run; only the presentation
* side (the GUI) differs.
*
* <h2>Threading</h2>
* <p>
* Implementations must be safe to call from a non-UI worker thread. They must not touch
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
* caller's concern. The call blocks until the run terminates (normally, after a
* cancellation, or after a hard failure).
*
* <h2>Exception contract</h2>
* <p>
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
* should be caught, logged, and returned as a
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
* well-defined terminal state.
*/
@FunctionalInterface
public interface GuiBatchRunLauncher {
/**
* Executes exactly one batch run against the supplied configuration file.
*
* @param configFilePath path of the {@code .properties} file to run against;
* must not be {@code null}; must exist and be readable
* @param observer observer receiving start/completion/end callbacks; must
* not be {@code null}
* @param cancellationToken cancellation token the run polls between candidates; must
* not be {@code null}
* @return a description of how the run terminated; never {@code null}
*/
GuiBatchRunLaunchOutcome launch(
Path configFilePath,
BatchRunProgressObserver observer,
BatchRunCancellationToken cancellationToken);
}
@@ -0,0 +1,74 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
/**
* Immutable view model for a single row in the processing-run result list.
* <p>
* Each completed candidate becomes exactly one row. The row carries only the information
* that is shown in the list and the side panel; it is decoupled from the persistence
* model so later GUI layers can render it without reaching back into the application
* layer.
*
* @param originalFileName the source filename as reported by the use case; never
* {@code null} or blank
* @param status the aggregated completion status; never {@code null}
* @param finalFileName the final target filename when the row represents a successful
* rename; empty otherwise
* @param resolvedDate the resolved document date when the row represents a successful
* rename; empty otherwise
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
* reasoning is available for this row
* @param processingDuration wall-clock duration spent on the candidate in this run;
* never {@code null} and never negative
*/
public record GuiBatchRunResultRow(
String originalFileName,
DocumentCompletionStatus status,
Optional<String> finalFileName,
Optional<LocalDate> resolvedDate,
Optional<String> aiReasoning,
Duration processingDuration) {
/**
* Compact constructor normalising optional holders and validating mandatory fields.
*
* @throws NullPointerException if {@code originalFileName}, {@code status} or
* {@code processingDuration} is {@code null}
* @throws IllegalArgumentException if {@code originalFileName} is blank or
* {@code processingDuration} is negative
*/
public GuiBatchRunResultRow {
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
if (originalFileName.isBlank()) {
throw new IllegalArgumentException("originalFileName must not be blank");
}
Objects.requireNonNull(status, "status must not be null");
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
if (processingDuration.isNegative()) {
throw new IllegalArgumentException("processingDuration must not be negative");
}
}
/**
* Returns the status icon for this row, mirroring the specification.
*
* @return the corresponding emoji icon
*/
public String statusIcon() {
return switch (status) {
case SUCCESS -> "\u2705"; // ✅
case FAILED_RETRYABLE -> "\u26A0\uFE0F"; // ⚠️
case FAILED_PERMANENT -> "\u274C"; // ❌
case SKIPPED -> "\u23ED\uFE0F"; // ⏭️
};
}
}
@@ -0,0 +1,532 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.nio.file.Path;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
/**
* Second main-tab of the JavaFX editor window: the live processing-run view.
* <p>
* The tab encapsulates all UI for starting, observing, and stopping a batch run from
* inside the GUI. It collaborates with a {@link GuiBatchRunCoordinator} which owns the
* background worker thread and forwards progress callbacks here on the JavaFX Application
* Thread.
*
* <h2>Layout</h2>
* <pre>
* ┌──────────────────────────────────────────────────────┐
* │ [Fortschrittsbalken] 12 / 47 Dateien │
* ├──────────────────────────────────┬───────────────────┤
* │ Ergebnisliste │ Seitenbereich │
* │ (TableView) │ (Reasoning) │
* ├──────────────────────────────────┴───────────────────┤
* │ Meldungs- und Zusammenfassungsbereich │
* ├──────────────────────────────────────────────────────┤
* │ [Starten] [Abbrechen] │
* └──────────────────────────────────────────────────────┘
* </pre>
*
* <h2>Threading</h2>
* <p>
* All public methods of this class must be invoked on the JavaFX Application Thread. The
* class is not thread-safe; the coordinator is responsible for dispatching background
* events onto the FX thread before calling back into the tab.
*/
public final class GuiBatchRunTab {
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
/** Spec: "Datei auswählen für Details". Shown in the detail pane before the first row is selected. */
static final String DETAIL_PLACEHOLDER = "Datei auswählen für Details";
/** Spec: hint shown when no AI reasoning is available for the selected row. */
static final String NO_REASONING_TEXT = "Für diesen Eintrag liegt kein KI-Reasoning vor.";
/** Spec: hint shown when the start button is pressed against an empty source folder. */
static final String EMPTY_SOURCE_FOLDER_HINT =
"Keine verarbeitbaren Dateien im Quellordner gefunden";
/** Spec: hint shown when a second start attempt is made while a run is active. */
static final String ALREADY_RUNNING_HINT = "Ein Verarbeitungslauf ist bereits aktiv.";
/** Spec: German startup error shown when the saved configuration is unusable. */
static final String NO_SAVED_CONFIGURATION_HINT =
"Bitte speichern Sie die Konfiguration, bevor ein Verarbeitungslauf gestartet wird.";
/** Icon-to-placeholder rendering for empty columns in failure and skip rows. */
static final String EMPTY_CELL_TEXT = "\u2014"; // —
private static final String TAB_TITLE = "Verarbeitungslauf";
private static final double PROGRESS_BAR_MAX_WIDTH = Double.MAX_VALUE;
private static final double PROGRESS_BAR_PREF_HEIGHT = 20;
private static final double DETAIL_PANE_MIN_WIDTH = 280;
private static final double LIST_MIN_HEIGHT = 240;
private static final double DETAIL_AREA_MIN_HEIGHT = 240;
private static final int SECONDARY_SPACING = 12;
private final Tab tab = new Tab(TAB_TITLE);
private final ProgressBar progressBar = new ProgressBar(0);
private final Label counterLabel = new Label("0 / 0 Dateien");
private final TableView<GuiBatchRunResultRow> resultTable = new TableView<>();
private final ObservableList<GuiBatchRunResultRow> resultItems = FXCollections.observableArrayList();
private final TextArea detailArea = new TextArea(DETAIL_PLACEHOLDER);
private final TextArea messageArea = new TextArea();
private final Button startButton = new Button("Starten");
private final Button cancelButton = new Button("Abbrechen");
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
private final Supplier<Path> configPathSupplier;
private final BooleanSupplier savedConfigurationReadyCheck;
private final Runnable onRunStateChanged;
private final GuiBatchRunCoordinator coordinator;
private int totalCandidates;
private int completedCandidates;
private int successCount;
private int failedCount;
private int skippedCount;
/**
* Creates the processing-run tab and wires all UI controls.
*
* @param launcherSupplier supplier returning the active
* {@link GuiBatchRunLauncher}; called when the
* user presses "Starten"; must not be null
* @param configPathSupplier supplier returning the last saved configuration
* path to run against; may return {@code null}
* when no configuration is loaded
* @param savedConfigurationReadyCheck check invoked before each start attempt; must
* return {@code true} only when the editor state
* contains a saved configuration and no unsaved
* edit has made it unusable; must not be null
* @param onRunStateChanged callback invoked on the FX thread whenever the
* running flag flips; typically used by the
* workspace to sperren/entsperren Tab 1 and to
* rewire the close-request handler; must not be
* null
*/
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
Supplier<Path> configPathSupplier,
BooleanSupplier savedConfigurationReadyCheck,
Runnable onRunStateChanged) {
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier must not be null");
this.savedConfigurationReadyCheck = Objects.requireNonNull(
savedConfigurationReadyCheck, "savedConfigurationReadyCheck must not be null");
this.onRunStateChanged = Objects.requireNonNull(onRunStateChanged, "onRunStateChanged must not be null");
this.coordinator = new GuiBatchRunCoordinator(
(configPath, observer, token) ->
launcherSupplier.get().launch(configPath, observer, token),
new CoordinatorListener());
this.tab.setClosable(false);
this.tab.setContent(buildContent());
resetMetrics();
updateCounterLabel();
updateButtonStates();
}
/**
* Returns the JavaFX {@link Tab} node that hosts the processing-run view.
*
* @return the tab; never {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Returns a read-only property that is {@code true} while a run is active.
*
* @return read-only running property
*/
public ReadOnlyBooleanProperty runningProperty() {
return runningProperty.getReadOnlyProperty();
}
/**
* Returns whether a run is currently in progress on the worker thread.
*
* @return {@code true} while the coordinator is processing a run
*/
public boolean isRunning() {
return coordinator.isRunning();
}
/**
* Requests soft-stop cancellation of the currently running batch.
* <p>
* When no run is active the call has no effect. Cancellation is honoured between
* candidates — the currently processed candidate always finishes first.
*/
public void requestCancellation() {
coordinator.requestCancellation();
cancelButton.setDisable(true);
}
/** Visible for tests. */
Button startButton() {
return startButton;
}
/** Visible for tests. */
Button cancelButton() {
return cancelButton;
}
/** Visible for tests. */
ProgressBar progressBar() {
return progressBar;
}
/** Visible for tests. */
TableView<GuiBatchRunResultRow> resultTable() {
return resultTable;
}
/** Visible for tests. */
TextArea messageArea() {
return messageArea;
}
/** Visible for tests. */
TextArea detailArea() {
return detailArea;
}
/** Visible for tests. */
Label counterLabel() {
return counterLabel;
}
/** Visible for tests. */
GuiBatchRunCoordinator coordinator() {
return coordinator;
}
private BorderPane buildContent() {
BorderPane layout = new BorderPane();
layout.setPadding(new Insets(12));
layout.setTop(buildProgressHeader());
layout.setCenter(buildCenterSplit());
layout.setBottom(buildFooter());
return layout;
}
private Region buildProgressHeader() {
progressBar.setMaxWidth(PROGRESS_BAR_MAX_WIDTH);
progressBar.setPrefHeight(PROGRESS_BAR_PREF_HEIGHT);
HBox.setHgrow(progressBar, Priority.ALWAYS);
counterLabel.setId("batch-run-counter");
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
header.setAlignment(Pos.CENTER_LEFT);
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
return header;
}
private Region buildCenterSplit() {
configureResultTable();
ScrollPane tableScroll = new ScrollPane(resultTable);
tableScroll.setFitToWidth(true);
tableScroll.setFitToHeight(true);
tableScroll.setId("batch-run-result-scroll");
resultTable.setMinHeight(LIST_MIN_HEIGHT);
detailArea.setId("batch-run-detail");
detailArea.setEditable(false);
detailArea.setWrapText(true);
detailArea.setMinHeight(DETAIL_AREA_MIN_HEIGHT);
detailArea.setMinWidth(DETAIL_PANE_MIN_WIDTH);
Label detailTitle = new Label("KI-Begründung");
detailTitle.setStyle("-fx-font-weight: bold;");
VBox detailBox = new VBox(SECONDARY_SPACING / 2.0, detailTitle, detailArea);
detailBox.setPadding(new Insets(0, 0, 0, SECONDARY_SPACING));
detailBox.setMinWidth(DETAIL_PANE_MIN_WIDTH);
VBox.setVgrow(detailArea, Priority.ALWAYS);
HBox centerSplit = new HBox(tableScroll, detailBox);
HBox.setHgrow(tableScroll, Priority.ALWAYS);
HBox.setHgrow(detailBox, Priority.NEVER);
return centerSplit;
}
private void configureResultTable() {
resultTable.setItems(resultItems);
resultTable.setId("batch-run-result-table");
resultTable.setPlaceholder(new Label("Noch kein Verarbeitungslauf gestartet."));
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status");
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
iconCol.setPrefWidth(64);
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>("Originaldateiname");
nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
nameCol.setPrefWidth(280);
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname");
newNameCol.setCellValueFactory(data -> new SimpleStringProperty(
data.getValue().finalFileName().orElse(EMPTY_CELL_TEXT)));
newNameCol.setPrefWidth(280);
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum");
dateCol.setCellValueFactory(data -> new SimpleStringProperty(
data.getValue().resolvedDate()
.map(DateTimeFormatter.ISO_LOCAL_DATE::format)
.orElse(EMPTY_CELL_TEXT)));
dateCol.setPrefWidth(100);
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>("Dauer");
durationCol.setCellValueFactory(data -> new SimpleStringProperty(
formatDuration(data.getValue().processingDuration())));
durationCol.setPrefWidth(80);
durationCol.setCellFactory(col -> new TableCell<>() {
@Override
protected void updateItem(String value, boolean empty) {
super.updateItem(value, empty);
setText(empty || value == null ? null : value);
setStyle(empty ? null : "-fx-alignment: CENTER_RIGHT;");
}
});
List<TableColumn<GuiBatchRunResultRow, String>> columns =
List.of(iconCol, nameCol, newNameCol, dateCol, durationCol);
resultTable.getColumns().setAll(columns);
resultTable.getSelectionModel().selectedItemProperty().addListener((obs, old, row) -> {
if (row == null) {
detailArea.setText(DETAIL_PLACEHOLDER);
return;
}
detailArea.setText(buildDetailText(row));
});
}
private static String formatDuration(Duration duration) {
double seconds = duration.toMillis() / 1000.0;
if (seconds < 10) {
return String.format("%.2f s", seconds);
}
return String.format("%.1f s", seconds);
}
private static String buildDetailText(GuiBatchRunResultRow row) {
StringBuilder builder = new StringBuilder();
builder.append("Originaldateiname: ").append(row.originalFileName()).append('\n');
row.finalFileName()
.ifPresent(name -> builder.append("Neuer Dateiname: ").append(name).append('\n'));
row.resolvedDate()
.ifPresent(date -> builder.append("Datum: ")
.append(DateTimeFormatter.ISO_LOCAL_DATE.format(date)).append('\n'));
builder.append('\n');
row.aiReasoning().ifPresentOrElse(
reasoning -> builder.append(reasoning),
() -> builder.append(NO_REASONING_TEXT));
return builder.toString();
}
private Region buildFooter() {
messageArea.setId("batch-run-message-area");
messageArea.setEditable(false);
messageArea.setWrapText(true);
messageArea.setPrefRowCount(3);
startButton.setId("batch-run-start");
startButton.setOnAction(event -> handleStart());
cancelButton.setId("batch-run-cancel");
cancelButton.setOnAction(event -> requestCancellation());
cancelButton.setDisable(true);
HBox buttonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
buttonBar.setPadding(new Insets(SECONDARY_SPACING, 0, 0, 0));
VBox footer = new VBox(SECONDARY_SPACING, messageArea, buttonBar);
return footer;
}
private void handleStart() {
if (isRunning()) {
showMessage(ALREADY_RUNNING_HINT);
return;
}
if (!savedConfigurationReadyCheck.getAsBoolean()) {
showMessage(NO_SAVED_CONFIGURATION_HINT);
return;
}
Path configPath = configPathSupplier.get();
if (configPath == null) {
showMessage(NO_SAVED_CONFIGURATION_HINT);
return;
}
// Reset all UI state before starting a new run.
resultItems.clear();
detailArea.setText(DETAIL_PLACEHOLDER);
messageArea.clear();
resetMetrics();
updateCounterLabel();
progressBar.setProgress(0);
boolean started = coordinator.start(configPath);
if (!started) {
showMessage(ALREADY_RUNNING_HINT);
return;
}
LOG.info("GUI-Verarbeitungslauf: Start ausgelöst für Konfiguration {}.", configPath);
runningProperty.set(true);
notifyRunStateChanged();
updateButtonStates();
}
private void showMessage(String message) {
messageArea.setText(message);
}
private void appendMessage(String message) {
if (messageArea.getText() == null || messageArea.getText().isBlank()) {
messageArea.setText(message);
} else {
messageArea.setText(messageArea.getText() + System.lineSeparator() + message);
}
}
private void updateCounterLabel() {
counterLabel.setText(completedCandidates + " / " + totalCandidates + " Dateien");
}
private void updateProgressBar() {
if (totalCandidates <= 0) {
progressBar.setProgress(0);
return;
}
progressBar.setProgress((double) completedCandidates / (double) totalCandidates);
}
private void updateButtonStates() {
boolean running = coordinator.isRunning();
startButton.setDisable(running);
if (!running) {
cancelButton.setDisable(true);
} else {
cancelButton.setDisable(coordinator.isCancellationRequested());
}
}
private void resetMetrics() {
totalCandidates = 0;
completedCandidates = 0;
successCount = 0;
failedCount = 0;
skippedCount = 0;
}
private void notifyRunStateChanged() {
try {
onRunStateChanged.run();
} catch (RuntimeException e) {
LOG.warn("GUI-Verarbeitungslauf: Listener für Laufzustand warf eine Exception: {}",
e.getMessage(), e);
}
}
private final class CoordinatorListener implements GuiBatchRunCoordinator.Listener {
@Override
public void onRunStarted(RunId runId, int totalCandidatesFromObserver) {
totalCandidates = Math.max(0, totalCandidatesFromObserver);
completedCandidates = 0;
successCount = 0;
failedCount = 0;
skippedCount = 0;
updateCounterLabel();
updateProgressBar();
if (totalCandidates == 0) {
showMessage(EMPTY_SOURCE_FOLDER_HINT);
}
LOG.info("GUI-Verarbeitungslauf: RunId={} mit {} Kandidat(en) gestartet.",
runId, totalCandidates);
}
@Override
public void onDocumentCompleted(GuiBatchRunResultRow row) {
resultItems.add(row);
completedCandidates = Math.min(totalCandidates, completedCandidates + 1);
switch (row.status()) {
case SUCCESS -> successCount++;
case FAILED_RETRYABLE, FAILED_PERMANENT -> failedCount++;
case SKIPPED -> skippedCount++;
default -> throw new IllegalStateException(
"Unerwarteter Status: " + row.status());
}
updateCounterLabel();
updateProgressBar();
}
@Override
public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
runningProperty.set(false);
appendSummary(summary, outcome);
updateButtonStates();
notifyRunStateChanged();
LOG.info("GUI-Verarbeitungslauf: Lauf beendet. successfullyStarted={}, completed={}, "
+ "erfolgreich={}, fehlgeschlagen={}, übersprungen={}.",
outcome.successfullyStarted(), outcome.batchCompletedNormally(),
summary.successCount(), summary.failedCount(), summary.skippedCount());
}
private void appendSummary(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
if (!outcome.successfullyStarted()) {
outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
return;
}
if (!outcome.batchCompletedNormally()) {
outcome.failureMessage().ifPresent(GuiBatchRunTab.this::appendMessage);
}
String summaryText = summary.successCount() + " erfolgreich, "
+ summary.failedCount() + " fehlgeschlagen, "
+ summary.skippedCount() + " übersprungen";
appendMessage(summaryText);
}
}
/** Classification used by {@link #updateButtonStates()} in tests. */
DocumentCompletionStatus sentinelForTests() {
return DocumentCompletionStatus.SUCCESS;
}
}
@@ -0,0 +1,30 @@
/**
* Inbound adapter components that drive the GUI's processing-run tab.
* <p>
* The classes in this package build the second tab of the main window, translate
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
* callbacks into JavaFX UI updates, and manage the worker thread that executes a
* single run against a stored {@code .properties} configuration.
*
* <h2>Threading contract</h2>
* <p>
* The batch run itself always executes on a dedicated background worker thread obtained
* from {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}.
* Every UI mutation (progress bar value, result rows, button states, tab sperre) is
* dispatched onto the JavaFX Application Thread via {@code Platform.runLater}. No class
* in this package mutates a JavaFX {@code Control} from the worker thread.
*
* <h2>Cancellation</h2>
* <p>
* The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag
* causes the use case to stop <em>before</em> starting the next candidate; the candidate
* currently being processed is always completed in full so the SQLite persistence remains
* consistent.
*
* <h2>Configuration source</h2>
* <p>
* A run is always started against the {@code .properties} file currently on disk (the
* last saved state of the editor). Unsaved editor content is intentionally not forwarded
* to the launcher — the run must match what a parallel headless launch would see.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
@@ -86,10 +86,17 @@ class GuiAdapterSmokeTest {
static void setUpJavaFxPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch startLatch = new CountDownLatch(1);
Platform.startup(() -> {
try {
Platform.startup(() -> {
PLATFORM_STARTED.set(true);
startLatch.countDown();
});
} catch (IllegalStateException alreadyInitialised) {
// Another smoke test in the same Surefire fork already started the JavaFX
// runtime; treat the toolkit as available and proceed.
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");
@@ -237,14 +244,16 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(1, workspace.tabPane().getTabs().size(),
"Exactly one configuration tab must be present");
assertEquals(2, workspace.tabPane().getTabs().size(),
"Configuration tab and processing-run tab must both be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The single tab must use the configuration label");
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
"The second tab must host the processing-run view");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
"The single tab must expose the fixed section structure in the documented order");
"The configuration tab must expose the fixed section structure in the documented order");
} catch (Throwable t) {
fxError.set(t);
} finally {
@@ -0,0 +1,352 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* Synchronous tests for {@link GuiBatchRunCoordinator}.
* <p>
* These tests substitute the background worker thread and the FX dispatcher with
* in-thread runners so behaviour can be verified deterministically without a running
* JavaFX runtime.
*/
class GuiBatchRunCoordinatorTest {
private static final Path ANY_CONFIG = Paths.get("ignored.properties");
@Test
void start_withNullPath_throws() {
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
(path, observer, token) -> GuiBatchRunLaunchOutcome.completed(),
syncThreadFactory(), syncDispatcher(), noOpListener());
assertFalse(coordinator.isRunning());
try {
coordinator.start(null);
} catch (NullPointerException expected) {
// expected
return;
}
throw new AssertionError("Expected NullPointerException");
}
@Test
void completedRun_dispatchesEventsAndSummaryOnFxThread() {
List<String> events = new ArrayList<>();
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) {
events.add("started:" + totalCandidates);
}
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
events.add("row:" + row.status() + ":" + row.originalFileName());
}
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
events.add("ended:started=" + outcome.successfullyStarted()
+ ",completed=" + outcome.batchCompletedNormally()
+ ",summary=" + summary.successCount() + "/" + summary.failedCount()
+ "/" + summary.skippedCount());
}
};
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
observer.onRunStarted(new RunId("run-1"), 2);
observer.onDocumentCompleted(new DocumentCompletionEvent(
"a.pdf", DocumentCompletionStatus.SUCCESS,
"2026-03-01 - Titel.pdf", LocalDate.of(2026, 3, 1), "gut", Duration.ofMillis(20)));
observer.onDocumentCompleted(new DocumentCompletionEvent(
"b.pdf", DocumentCompletionStatus.FAILED_PERMANENT,
null, null, null, Duration.ofMillis(10)));
observer.onRunEnded(new RunSummary(1, 1, 0));
return GuiBatchRunLaunchOutcome.completed();
};
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(), listener);
boolean started = coordinator.start(ANY_CONFIG);
assertTrue(started);
assertEquals(List.of(
"started:2",
"row:SUCCESS:a.pdf",
"row:FAILED_PERMANENT:b.pdf",
"ended:started=true,completed=true,summary=1/1/0"), events);
assertFalse(coordinator.isRunning());
}
@Test
void startWhileRunning_returnsFalseWithoutDoubleDispatch() {
// Launcher installs a rendezvous so the first run is still "running" while we
// attempt the second start.
CountDownLatch firstRunActive = new CountDownLatch(1);
CountDownLatch releaseFirstRun = new CountDownLatch(1);
AtomicBoolean firstStarted = new AtomicBoolean();
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
firstStarted.set(true);
firstRunActive.countDown();
try {
releaseFirstRun.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return GuiBatchRunLaunchOutcome.completed();
};
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher,
task -> {
Thread thread = new Thread(task, "test-worker");
thread.setDaemon(true);
return thread;
},
Runnable::run, // direct FX dispatch
noOpListener());
try {
assertTrue(coordinator.start(ANY_CONFIG));
assertTrue(firstRunActive.await(5, TimeUnit.SECONDS));
assertTrue(coordinator.isRunning());
assertFalse(coordinator.start(ANY_CONFIG), "second start must be rejected");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
} finally {
releaseFirstRun.countDown();
waitUntilIdle(coordinator);
}
assertTrue(firstStarted.get());
assertFalse(coordinator.isRunning());
}
@Test
void requestCancellation_setsFlagForLauncherToObserve() {
AtomicReference<BatchRunCancellationToken> seenToken = new AtomicReference<>();
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
seenToken.set(token);
return GuiBatchRunLaunchOutcome.completed();
};
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(), noOpListener());
// Start must run synchronously via our sync thread factory.
coordinator.start(ANY_CONFIG);
BatchRunCancellationToken token = seenToken.get();
assertNotNull(token);
// Token is polled by the launcher only; after the run the flag has been consumed
// and reset to false for the next run.
assertFalse(coordinator.isCancellationRequested(),
"Cancellation flag must reset back to false before the run starts");
// Starting a second run and cancelling before the launcher observes → flag true.
CountDownLatch launcherRunning = new CountDownLatch(1);
CountDownLatch cancelBeforeReturn = new CountDownLatch(1);
AtomicBoolean sawCancelled = new AtomicBoolean();
GuiBatchRunLauncher slowLauncher = (configPath, observer, token1) -> {
launcherRunning.countDown();
try {
cancelBeforeReturn.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
sawCancelled.set(token1.isCancellationRequested());
return GuiBatchRunLaunchOutcome.completed();
};
GuiBatchRunCoordinator coord2 = new GuiBatchRunCoordinator(
slowLauncher,
task -> {
Thread thread = new Thread(task, "test-worker-2");
thread.setDaemon(true);
return thread;
},
Runnable::run, noOpListener());
try {
coord2.start(ANY_CONFIG);
assertTrue(launcherRunning.await(5, TimeUnit.SECONDS));
coord2.requestCancellation();
assertTrue(coord2.isCancellationRequested());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
} finally {
cancelBeforeReturn.countDown();
waitUntilIdle(coord2);
}
assertTrue(sawCancelled.get(), "Launcher must see isCancellationRequested=true");
}
@Test
void launcherException_yieldsFailedAfterStartOutcome() {
AtomicReference<GuiBatchRunLaunchOutcome> captured = new AtomicReference<>();
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
throw new IllegalStateException("boom");
};
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(),
new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
captured.set(outcome);
}
});
coordinator.start(ANY_CONFIG);
GuiBatchRunLaunchOutcome outcome = captured.get();
assertNotNull(outcome);
assertTrue(outcome.successfullyStarted());
assertFalse(outcome.batchCompletedNormally());
assertTrue(outcome.failureMessage().isPresent());
}
@Test
void nullLauncherResult_mapsToFailedAfterStart() {
AtomicReference<GuiBatchRunLaunchOutcome> captured = new AtomicReference<>();
GuiBatchRunLauncher launcher = (configPath, observer, token) -> null;
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
launcher, syncThreadFactory(), syncDispatcher(),
new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
captured.set(outcome);
}
});
coordinator.start(ANY_CONFIG);
GuiBatchRunLaunchOutcome outcome = captured.get();
assertNotNull(outcome);
assertTrue(outcome.successfullyStarted());
assertFalse(outcome.batchCompletedNormally());
}
@Test
void resultRowIcons_matchSpecification() {
assertEquals("\u2705", row(DocumentCompletionStatus.SUCCESS).statusIcon());
assertEquals("\u26A0\uFE0F", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
assertEquals("\u274C", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
assertEquals("\u23ED\uFE0F", row(DocumentCompletionStatus.SKIPPED).statusIcon());
}
@Test
void launchOutcomeFactories_populateFailureMessages() {
GuiBatchRunLaunchOutcome completed = GuiBatchRunLaunchOutcome.completed();
assertTrue(completed.successfullyStarted());
assertTrue(completed.batchCompletedNormally());
assertTrue(completed.failureMessage().isEmpty());
GuiBatchRunLaunchOutcome rejected = GuiBatchRunLaunchOutcome.rejected("nope");
assertFalse(rejected.successfullyStarted());
assertFalse(rejected.batchCompletedNormally());
assertEquals("nope", rejected.failureMessage().orElseThrow());
GuiBatchRunLaunchOutcome failedAfter = GuiBatchRunLaunchOutcome.failedAfterStart("boom");
assertTrue(failedAfter.successfullyStarted());
assertFalse(failedAfter.batchCompletedNormally());
assertEquals("boom", failedAfter.failureMessage().orElseThrow());
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static GuiBatchRunResultRow row(DocumentCompletionStatus status) {
return new GuiBatchRunResultRow(
"x.pdf", status, null, null, null, Duration.ofMillis(1));
}
private static GuiBatchRunCoordinator.Listener noOpListener() {
return new GuiBatchRunCoordinator.Listener() {
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
};
}
private static java.util.function.Function<Runnable, Thread> syncThreadFactory() {
return task -> new Thread(task) {
@Override public synchronized void start() {
// Execute the task on the current thread so the test stays fully synchronous.
task.run();
}
};
}
private static Consumer<Runnable> syncDispatcher() {
return Runnable::run;
}
private static void waitUntilIdle(GuiBatchRunCoordinator coordinator) {
long deadline = System.currentTimeMillis() + 5_000;
while (coordinator.isRunning() && System.currentTimeMillis() < deadline) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
@Test
void noOpObserverAndNeverCancelled_singletonsCallableWithoutEffects() {
BatchRunProgressObserver noOp = BatchRunProgressObserver.noOp();
noOp.onRunStarted(new RunId("x"), 0);
noOp.onDocumentCompleted(new DocumentCompletionEvent(
"a.pdf", DocumentCompletionStatus.SUCCESS, null, null, null, Duration.ZERO));
noOp.onRunEnded(new RunSummary(0, 0, 0));
assertSame(noOp, BatchRunProgressObserver.noOp());
assertFalse(BatchRunCancellationToken.neverCancelled().isCancellationRequested());
assertSame(BatchRunCancellationToken.neverCancelled(),
BatchRunCancellationToken.neverCancelled());
}
@Test
void resultRow_rejectsInvalidInput() {
try {
new GuiBatchRunResultRow(" ", DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ZERO);
throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) { /* ok */ }
try {
new GuiBatchRunResultRow("x.pdf", DocumentCompletionStatus.SUCCESS,
null, null, null, Duration.ofSeconds(-1));
throw new AssertionError("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) { /* ok */ }
}
@Test
void resultRow_optionalHoldersNormaliseNullToEmpty() {
GuiBatchRunResultRow row = new GuiBatchRunResultRow(
"x.pdf", DocumentCompletionStatus.FAILED_PERMANENT,
null, null, null, Duration.ZERO);
assertNull(row.finalFileName().orElse(null));
assertNull(row.resolvedDate().orElse(null));
assertNull(row.aiReasoning().orElse(null));
}
}
@@ -0,0 +1,266 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.LocalDate;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import javafx.application.Platform;
/**
* Headless (Monocle) smoke tests for {@link GuiBatchRunTab}. These tests drive the tab
* end-to-end via a stubbed launcher, asserting the observable UI state transitions on
* the JavaFX Application Thread.
*/
class GuiBatchRunTabSmokeTest {
private static final long FX_TIMEOUT_SECONDS = 10;
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
@BeforeAll
static void startPlatform() throws InterruptedException {
Platform.setImplicitExit(false);
if (PLATFORM_STARTED.compareAndSet(false, true)) {
CountDownLatch latch = new CountDownLatch(1);
try {
Platform.startup(latch::countDown);
} catch (IllegalStateException alreadyStarted) {
// JavaFX is already running; reuse it.
latch.countDown();
}
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
}
}
@Test
void start_withoutSavedConfiguration_showsHint() throws Exception {
onFxAndWait(tab -> {
tab.startButton().fire();
assertEquals(GuiBatchRunTab.NO_SAVED_CONFIGURATION_HINT,
tab.messageArea().getText());
assertFalse(tab.coordinator().isRunning());
}, /*savedReady*/ false, /*configPath*/ null, /*launcher*/ null);
}
@Test
void start_withEmptyFolder_showsEmptyFolderHintAndSummary() throws Exception {
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch launcherInvoked = new CountDownLatch(1);
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
observer.onRunStarted(new RunId("empty"), 0);
observer.onRunEnded(new RunSummary(0, 0, 0));
launcherInvoked.countDown();
return GuiBatchRunLaunchOutcome.completed();
};
CountDownLatch latch = runOnFxAndWaitUntilDone(tab -> {
try {
tab.startButton().fire();
} catch (Throwable t) {
error.set(t);
}
}, /*savedReady*/ true, Paths.get("ignored.properties"), launcher,
() -> !tab().coordinator().isRunning() && !tab().messageArea().getText().isBlank());
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "tab must quiesce");
if (error.get() != null) throw new AssertionError(error.get());
assertTrue(launcherInvoked.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
runOnFx(() -> {
assertTrue(tab().messageArea().getText().contains(GuiBatchRunTab.EMPTY_SOURCE_FOLDER_HINT),
() -> "Missing empty-folder hint in: " + tab().messageArea().getText());
assertTrue(tab().messageArea().getText().contains("0 erfolgreich"),
() -> "Missing summary counters in: " + tab().messageArea().getText());
assertEquals("0 / 0 Dateien", tab().counterLabel().getText());
});
}
@Test
void completedRun_populatesListProgressBarAndSummary() throws Exception {
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
observer.onRunStarted(new RunId("run"), 3);
observer.onDocumentCompleted(new DocumentCompletionEvent(
"a.pdf", DocumentCompletionStatus.SUCCESS,
"2026-03-01 - Titel.pdf",
LocalDate.of(2026, 3, 1),
"gut begründet",
Duration.ofMillis(42)));
observer.onDocumentCompleted(new DocumentCompletionEvent(
"b.pdf", DocumentCompletionStatus.FAILED_RETRYABLE,
null, null, null, Duration.ofMillis(10)));
observer.onDocumentCompleted(new DocumentCompletionEvent(
"c.pdf", DocumentCompletionStatus.SKIPPED,
null, null, null, Duration.ofMillis(5)));
observer.onRunEnded(new RunSummary(1, 1, 1));
return GuiBatchRunLaunchOutcome.completed();
};
CountDownLatch latch = runOnFxAndWaitUntilDone(
tab -> tab.startButton().fire(),
true, Paths.get("ok.properties"), launcher,
() -> !tab().coordinator().isRunning() && tab().resultTable().getItems().size() == 3);
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS),
"tab must quiesce with 3 rows");
runOnFx(() -> {
assertEquals(3, tab().resultTable().getItems().size());
assertEquals("3 / 3 Dateien", tab().counterLabel().getText());
assertEquals(1.0, tab().progressBar().getProgress(), 0.001);
String messageText = tab().messageArea().getText();
assertTrue(messageText.contains("1 erfolgreich"), messageText);
assertTrue(messageText.contains("1 fehlgeschlagen"), messageText);
assertTrue(messageText.contains("1 übersprungen"), messageText);
// Clicking the first row populates the detail pane with the AI reasoning.
tab().resultTable().getSelectionModel().select(0);
String detail = tab().detailArea().getText();
assertTrue(detail.contains("a.pdf"), detail);
assertTrue(detail.contains("2026-03-01 - Titel.pdf"), detail);
assertTrue(detail.contains("gut begründet"), detail);
// Clicking a row without reasoning shows the no-reasoning placeholder.
tab().resultTable().getSelectionModel().select(1);
assertTrue(tab().detailArea().getText().contains(GuiBatchRunTab.NO_REASONING_TEXT));
});
}
@Test
void detailPane_initiallyShowsPlaceholder() throws Exception {
onFxAndWait(tab -> {
assertEquals(GuiBatchRunTab.DETAIL_PLACEHOLDER, tab.detailArea().getText());
}, /*savedReady*/ false, /*configPath*/ null, /*launcher*/ null);
}
@Test
void runLauncherFailure_showsFailureMessageInTerminalState() throws Exception {
GuiBatchRunLauncher launcher = (configPath, observer, token) -> {
observer.onRunStarted(new RunId("x"), 0);
observer.onRunEnded(new RunSummary(0, 0, 0));
return GuiBatchRunLaunchOutcome.rejected("SQLite unvailable (Testnachricht)");
};
CountDownLatch latch = runOnFxAndWaitUntilDone(
tab -> tab.startButton().fire(),
true, Paths.get("ignore.properties"), launcher,
() -> !tab().coordinator().isRunning() && !tab().messageArea().getText().isBlank());
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
runOnFx(() -> {
String message = tab().messageArea().getText();
assertTrue(message.contains("SQLite unvailable (Testnachricht)"),
() -> "Expected failure message in: " + message);
// Start must be enabled again, cancel disabled.
assertFalse(tab().startButton().isDisabled());
assertTrue(tab().cancelButton().isDisabled());
});
}
// -------------------------------------------------------------------------
// FX helpers
// -------------------------------------------------------------------------
private static final ThreadLocal<GuiBatchRunTab> CURRENT_TAB = new ThreadLocal<>();
private GuiBatchRunTab tab() {
return CURRENT_TAB.get();
}
private void onFxAndWait(java.util.function.Consumer<GuiBatchRunTab> action,
boolean savedReady,
Path configPath,
GuiBatchRunLauncher launcher) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiBatchRunTab tab = makeTab(savedReady, configPath, launcher);
CURRENT_TAB.set(tab);
action.accept(tab);
} catch (Throwable t) {
error.set(t);
} finally {
done.countDown();
}
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (error.get() != null) throw new AssertionError(error.get());
}
private void runOnFx(Runnable action) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try { action.run(); } catch (Throwable t) { error.set(t); }
finally { done.countDown(); }
});
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (error.get() != null) throw new AssertionError(error.get());
}
/**
* Creates a tab on the FX thread, fires the supplied action, and returns a latch that
* is counted down as soon as the supplied predicate becomes true on a subsequent
* FX-thread tick.
*/
private CountDownLatch runOnFxAndWaitUntilDone(
java.util.function.Consumer<GuiBatchRunTab> action,
boolean savedReady,
Path configPath,
GuiBatchRunLauncher launcher,
java.util.function.BooleanSupplier quiesced) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Platform.runLater(() -> {
try {
GuiBatchRunTab tab = makeTab(savedReady, configPath, launcher);
CURRENT_TAB.set(tab);
action.accept(tab);
schedulePoll(latch, quiesced);
} catch (Throwable t) {
error.set(t);
latch.countDown();
}
});
if (error.get() != null) throw new AssertionError(error.get());
return latch;
}
private static void schedulePoll(CountDownLatch latch, java.util.function.BooleanSupplier predicate) {
Platform.runLater(() -> {
if (predicate.getAsBoolean()) {
latch.countDown();
} else {
schedulePoll(latch, predicate);
}
});
}
private static GuiBatchRunTab makeTab(boolean savedReady, Path configPath, GuiBatchRunLauncher launcher) {
GuiBatchRunLauncher effective = launcher == null
? (configPath1, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
"Test-Stub sollte nicht aufgerufen werden.")
: launcher;
return new GuiBatchRunTab(
() -> effective,
() -> configPath,
() -> savedReady,
() -> { /* state-change hook not needed for these tests */ });
}
}