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:
+161
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+55
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+280
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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));
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
+74
@@ -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"; // ⏭️
|
||||
};
|
||||
}
|
||||
}
|
||||
+532
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
+15
-6
@@ -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 {
|
||||
|
||||
+352
@@ -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));
|
||||
}
|
||||
}
|
||||
+266
@@ -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 */ });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user