statusRef;
+ private final int intervalSeconds;
+
+ /**
+ * Erstellt einen neuen Use Case.
+ *
+ * Das Scheduler-Intervall wird sofort aus {@code settingsPort} gelesen;
+ * Werte unter {@value MINIMUM_INTERVAL_SECONDS} Sekunden werden auf diesen
+ * Mindestwert angehoben.
+ *
+ * @param schedulerPort technischer Scheduler-Mechanismus
+ * @param lockPort OS-Lock auf die Konfigurationsdatei
+ * @param settingsPort Lese-/Schreibzugriff auf Scheduler-Einstellungen
+ * @param batchRunTrigger Auslöser für den eigentlichen Verarbeitungslauf
+ */
+ public DefaultSchedulerControlUseCase(
+ SchedulerPort schedulerPort,
+ ConfigurationFileLockPort lockPort,
+ SchedulerSettingsPort settingsPort,
+ BatchRunTrigger batchRunTrigger) {
+ this.schedulerPort = Objects.requireNonNull(schedulerPort, "schedulerPort darf nicht null sein");
+ this.lockPort = Objects.requireNonNull(lockPort, "lockPort darf nicht null sein");
+ this.settingsPort = Objects.requireNonNull(settingsPort, "settingsPort darf nicht null sein");
+ this.batchRunTrigger = Objects.requireNonNull(batchRunTrigger, "batchRunTrigger darf nicht null sein");
+
+ SchedulerSettings settings = settingsPort.loadSettings();
+ this.intervalSeconds = Math.max(settings.intervalSeconds(), MINIMUM_INTERVAL_SECONDS);
+ this.statusRef = new AtomicReference<>(SchedulerStatus.initial());
+ }
+
+ // -------------------------------------------------------------------------
+ // SchedulerControlUseCase
+ // -------------------------------------------------------------------------
+
+ /**
+ * Startet den automatischen Scheduler.
+ *
+ * Ist der Scheduler bereits aktiv, hat dieser Aufruf keine Wirkung (idempotent).
+ * Schlägt ein Startschritt fehl, wird ein vollständiger Rollback durchgeführt
+ * und der Zustand auf {@link SchedulerState#STOPPED} zurückgesetzt.
+ *
+ * @throws SchedulerStartException wenn der Start fehlschlägt
+ */
+ @Override
+ public void start() throws SchedulerStartException {
+ SchedulerStatus current = statusRef.get();
+ if (current.state().isActive()) {
+ logger.debug("Scheduler ist bereits aktiv – Start-Aufruf wird ignoriert.");
+ return;
+ }
+
+ // Schritt 1: Zustand auf STARTING setzen
+ statusRef.set(withState(current, SchedulerState.STARTING, Optional.empty()));
+
+ // Schritt 2: scheduler.enabled=true persistieren
+ try {
+ settingsPort.saveEnabled(true);
+ } catch (Exception e) {
+ logger.error("Scheduler konnte nicht gestartet werden: Einstellung nicht persistierbar.", e);
+ statusRef.set(SchedulerStatus.initial());
+ throw new SchedulerStartException(
+ "Scheduler-Einstellung konnte nicht gespeichert werden.", e);
+ }
+
+ // Schritt 3: OS-Lock erwerben
+ try {
+ lockPort.acquireLock();
+ } catch (Exception e) {
+ logger.error("Scheduler konnte nicht gestartet werden: Lock nicht erwerbbar.", e);
+ tryPersistDisabled();
+ statusRef.set(SchedulerStatus.initial());
+ throw new SchedulerStartException(
+ "Konfigurationsdatei konnte nicht gesperrt werden.", e);
+ }
+
+ // Schritt 4: Scheduler-Adapter starten
+ try {
+ schedulerPort.startScheduler(new SchedulerConfig(intervalSeconds), this::executeWrappedTick);
+ } catch (Exception e) {
+ logger.error("Scheduler konnte nicht gestartet werden: Adapter-Start fehlgeschlagen.", e);
+ lockPort.releaseLock();
+ tryPersistDisabled();
+ statusRef.set(SchedulerStatus.initial());
+ throw new SchedulerStartException(
+ "Scheduler-Adapter konnte nicht gestartet werden.", e);
+ }
+
+ // Schritt 5: Zustand auf RUNNING_IDLE setzen
+ Instant nextTick = Instant.now().plusSeconds(intervalSeconds);
+ statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_IDLE, Optional.of(nextTick)));
+ logger.info("Scheduler gestartet. Intervall: {} Sekunden.", intervalSeconds);
+ }
+
+ /**
+ * Stoppt den automatischen Scheduler.
+ *
+ * Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine Wirkung
+ * (idempotent). Läuft gerade ein Batch-Tick, wechselt der Zustand zu
+ * {@link SchedulerState#STOPPING_BATCH_ACTIVE}; der laufende Batch wird
+ * regulär zu Ende geführt, danach wird der Zustand auf
+ * {@link SchedulerState#STOPPED} gesetzt.
+ */
+ @Override
+ public void stop() {
+ SchedulerStatus previous;
+ SchedulerStatus updated;
+ do {
+ previous = statusRef.get();
+ if (!previous.state().isActive()) {
+ logger.debug("Scheduler ist bereits gestoppt – Stop-Aufruf wird ignoriert.");
+ return;
+ }
+ SchedulerState nextState = previous.state().isBatchRunning()
+ ? SchedulerState.STOPPING_BATCH_ACTIVE
+ : SchedulerState.STOPPED;
+ updated = withState(previous, nextState, Optional.empty());
+ } while (!statusRef.compareAndSet(previous, updated));
+
+ boolean batchWasRunning = previous.state().isBatchRunning();
+ schedulerPort.stopScheduler();
+
+ if (!batchWasRunning) {
+ tryPersistDisabled();
+ lockPort.releaseLock();
+ logger.info("Scheduler gestoppt.");
+ } else {
+ logger.info("Stop angefordert – laufender Batch wird abgewartet.");
+ }
+ }
+
+ /**
+ * Gibt den aktuellen Scheduler-Zustand als unveränderlichen Snapshot zurück.
+ *
+ * @return aktueller Scheduler-Status; nie {@code null}
+ */
+ @Override
+ public SchedulerStatus getStatus() {
+ return statusRef.get();
+ }
+
+ // -------------------------------------------------------------------------
+ // Tick-Wrapper (package-private für Testbarkeit)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Wrapping-Implementierung des {@link BatchRunTrigger}, die der
+ * {@link SchedulerPort} bei jedem Tick synchron aufruft.
+ *
+ * Setzt vor dem Aufruf den Zustand auf
+ * {@link SchedulerState#RUNNING_BATCH_ACTIVE} und leitet nach dem
+ * Abschluss den Folgezustand ab:
+ *
+ * - {@link SchedulerState#RUNNING_IDLE} bei normalem Weiterlauf
+ * - {@link SchedulerState#STOPPED} wenn ein Stopp-Befehl empfangen wurde
+ *
+ * Im Fall {@link SchedulerState#STOPPED} werden außerdem {@code enabled=false}
+ * persistiert und der OS-Lock freigegeben.
+ *
+ * Package-private, damit Unit-Tests den Tick-Wrapper direkt aufrufen können
+ * ohne den Scheduler-Executor zu starten.
+ *
+ * @return das Ergebnis des delegierten {@link BatchRunTrigger#triggerRun()}
+ */
+ BatchRunTriggerResult executeWrappedTick() {
+ // Zustand auf RUNNING_BATCH_ACTIVE setzen
+ statusRef.updateAndGet(s -> withState(s, SchedulerState.RUNNING_BATCH_ACTIVE, Optional.empty()));
+
+ // Eigentlichen Batch ausführen
+ BatchRunTriggerResult result = batchRunTrigger.triggerRun();
+
+ // Folgezustand aus Ergebnis und aktuellem Zustand ableiten
+ SchedulerStatus afterBatch = statusRef.get();
+ boolean stopping = afterBatch.state() == SchedulerState.STOPPING_BATCH_ACTIVE;
+ SchedulerState nextState = stopping ? SchedulerState.STOPPED : SchedulerState.RUNNING_IDLE;
+ Optional nextTickAt = stopping
+ ? Optional.empty()
+ : Optional.of(Instant.now().plusSeconds(intervalSeconds));
+
+ Optional lastRunEndedAt = afterBatch.lastRunEndedAt();
+ Optional lastRunSummary = afterBatch.lastRunSummary();
+ Optional lastError = afterBatch.lastError();
+
+ switch (result) {
+ case BatchRunTriggerResult.Started started -> {
+ lastRunEndedAt = Optional.of(started.endedAt());
+ lastRunSummary = Optional.of(started.summary());
+ lastError = Optional.empty();
+ logger.info("Scheduler-Tick abgeschlossen: {} erfolgreich, {} fehlgeschlagen, {} übersprungen.",
+ started.summary().successCount(),
+ started.summary().failedCount(),
+ started.summary().skippedCount());
+ }
+ case BatchRunTriggerResult.SkippedBusy ignored ->
+ logger.debug("Scheduler-Tick übersprungen: anderer Lauf aktiv.");
+ case BatchRunTriggerResult.Failed failed -> {
+ lastError = Optional.of(failed.userMessage());
+ logger.warn("Scheduler-Tick fehlgeschlagen: {}", failed.technicalMessage());
+ }
+ }
+
+ statusRef.set(new SchedulerStatus(
+ nextState,
+ lastRunEndedAt,
+ lastRunSummary,
+ nextTickAt,
+ lastError,
+ afterBatch.autostartFailed()));
+
+ if (stopping) {
+ tryPersistDisabled();
+ lockPort.releaseLock();
+ logger.info("Scheduler gestoppt nach Abschluss des laufenden Batches.");
+ }
+
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethoden
+ // -------------------------------------------------------------------------
+
+ private void tryPersistDisabled() {
+ try {
+ settingsPort.saveEnabled(false);
+ } catch (Exception e) {
+ logger.warn("Fehler beim Zurücksetzen von scheduler.enabled auf false.", e);
+ }
+ }
+
+ private static SchedulerStatus withState(
+ SchedulerStatus base, SchedulerState state, Optional nextTickAt) {
+ return new SchedulerStatus(
+ state,
+ base.lastRunEndedAt(),
+ base.lastRunSummary(),
+ nextTickAt,
+ base.lastError(),
+ base.autostartFailed());
+ }
+}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java
new file mode 100644
index 0000000..385ab45
--- /dev/null
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java
@@ -0,0 +1,499 @@
+package de.gecheckt.pdf.umbenenner.application.usecase;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
+import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
+import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
+import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger;
+import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
+import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
+import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException;
+
+/**
+ * Unit-Tests für {@link DefaultSchedulerControlUseCase}.
+ *
+ * Teststrategien:
+ *
+ * - Lifecycle-Tests (Start, Stop, Idempotenz) prüfen Zustandsübergänge
+ * und Interaktionen mit gemockten Ports.
+ * - Tick-Tests rufen {@code executeWrappedTick()} direkt auf (package-private)
+ * und verifizieren die Zustandsübergänge für alle drei
+ * {@link BatchRunTriggerResult}-Varianten.
+ * - Rollback-Tests prüfen, dass fehlgeschlagene Startschritte den Zustand
+ * vollständig auf {@link SchedulerState#STOPPED} zurücksetzen.
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+class DefaultSchedulerControlUseCaseTest {
+
+ @Mock
+ private SchedulerPort schedulerPort;
+ @Mock
+ private ConfigurationFileLockPort lockPort;
+ @Mock
+ private SchedulerSettingsPort settingsPort;
+ @Mock
+ private BatchRunTrigger batchRunTrigger;
+
+ @BeforeEach
+ void setUp() {
+ when(settingsPort.loadSettings()).thenReturn(new SchedulerSettings(false, 180));
+ }
+
+ // =========================================================================
+ // Initialer Zustand
+ // =========================================================================
+
+ @Test
+ void getStatus_returnsInitialStatusOnCreation() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ SchedulerStatus status = useCase.getStatus();
+
+ assertThat(status.state()).isEqualTo(SchedulerState.STOPPED);
+ assertThat(status.lastRunEndedAt()).isEmpty();
+ assertThat(status.lastRunSummary()).isEmpty();
+ assertThat(status.nextTickAt()).isEmpty();
+ assertThat(status.lastError()).isEmpty();
+ assertThat(status.autostartFailed()).isFalse();
+ }
+
+ @Test
+ void constructor_loadsSettingsIntervalSeconds() {
+ when(settingsPort.loadSettings()).thenReturn(new SchedulerSettings(false, 300));
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ // Verify interval is 300 by starting and checking the SchedulerConfig passed to schedulerPort
+ useCase.start();
+ ArgumentCaptor configCaptor = ArgumentCaptor.forClass(SchedulerConfig.class);
+ verify(schedulerPort).startScheduler(configCaptor.capture(), any());
+ assertThat(configCaptor.getValue().intervalSeconds()).isEqualTo(300);
+ }
+
+ @Test
+ void constructor_clampsIntervalBelowMinimumTo30() {
+ when(settingsPort.loadSettings()).thenReturn(new SchedulerSettings(false, 10));
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ useCase.start();
+ ArgumentCaptor configCaptor = ArgumentCaptor.forClass(SchedulerConfig.class);
+ verify(schedulerPort).startScheduler(configCaptor.capture(), any());
+ assertThat(configCaptor.getValue().intervalSeconds()).isEqualTo(30);
+ }
+
+ // =========================================================================
+ // start(): Normalfall
+ // =========================================================================
+
+ @Test
+ void start_setsStateToRunningIdle() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ useCase.start();
+
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.RUNNING_IDLE);
+ }
+
+ @Test
+ void start_setsNextTickAt() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ Instant before = Instant.now();
+
+ useCase.start();
+
+ Instant after = Instant.now();
+ Optional nextTickAt = useCase.getStatus().nextTickAt();
+ assertThat(nextTickAt).isPresent();
+ assertThat(nextTickAt.get()).isAfterOrEqualTo(before.plusSeconds(179));
+ assertThat(nextTickAt.get()).isBeforeOrEqualTo(after.plusSeconds(181));
+ }
+
+ @Test
+ void start_persistsEnabledTrue() {
+ createUseCase().start();
+
+ verify(settingsPort).saveEnabled(true);
+ }
+
+ @Test
+ void start_acquiresLock() {
+ createUseCase().start();
+
+ verify(lockPort).acquireLock();
+ }
+
+ @Test
+ void start_startsSchedulerWithCorrectInterval() {
+ createUseCase().start();
+
+ ArgumentCaptor configCaptor = ArgumentCaptor.forClass(SchedulerConfig.class);
+ verify(schedulerPort).startScheduler(configCaptor.capture(), any());
+ assertThat(configCaptor.getValue().intervalSeconds()).isEqualTo(180);
+ }
+
+ // =========================================================================
+ // start(): Idempotenz
+ // =========================================================================
+
+ @Test
+ void start_whenAlreadyActive_isIdempotent() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ assertThatCode(useCase::start).doesNotThrowAnyException();
+ // Second start must NOT persist, acquire lock or start scheduler again
+ verify(settingsPort).saveEnabled(true); // only once
+ verify(lockPort).acquireLock(); // only once
+ verify(schedulerPort).startScheduler(any(), any()); // only once
+ }
+
+ // =========================================================================
+ // start(): Rollback bei Fehlern
+ // =========================================================================
+
+ @Test
+ void start_rollsBackToStoppedWhenSaveEnabledFails() {
+ doThrow(new SchedulerSettingsWriteException("Schreibfehler"))
+ .when(settingsPort).saveEnabled(true);
+
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ verify(lockPort, never()).acquireLock();
+ verify(schedulerPort, never()).startScheduler(any(), any());
+ }
+
+ @Test
+ void start_rollsBackToStoppedWhenAcquireLockFails() {
+ doThrow(new ConfigurationFileLockException("Lock nicht verfügbar"))
+ .when(lockPort).acquireLock();
+
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ verify(settingsPort).saveEnabled(false); // rollback
+ verify(schedulerPort, never()).startScheduler(any(), any());
+ }
+
+ @Test
+ void start_rollsBackToStoppedWhenSchedulerPortFails() {
+ doThrow(new RuntimeException("Executor-Startfehler"))
+ .when(schedulerPort).startScheduler(any(), any());
+
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ verify(lockPort).releaseLock();
+ verify(settingsPort).saveEnabled(false); // rollback
+ }
+
+ @Test
+ void start_rollbackPersistsEnabledFalseEvenWhenRollbackFails() {
+ doThrow(new ConfigurationFileLockException("Timeout"))
+ .when(lockPort).acquireLock();
+ doThrow(new SchedulerSettingsWriteException("Rollback-Fehler"))
+ .when(settingsPort).saveEnabled(false);
+
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ // Even if rollback persist fails, SchedulerStartException is thrown and state is STOPPED
+ assertThatThrownBy(useCase::start).isInstanceOf(SchedulerStartException.class);
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ }
+
+ // =========================================================================
+ // stop(): Normalfall (kein laufender Batch)
+ // =========================================================================
+
+ @Test
+ void stop_whenRunningIdle_stopsImmediately() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ useCase.stop();
+
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ }
+
+ @Test
+ void stop_persistsEnabledFalse() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ useCase.stop();
+
+ verify(settingsPort).saveEnabled(false);
+ }
+
+ @Test
+ void stop_releasesLock() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ useCase.stop();
+
+ verify(lockPort).releaseLock();
+ }
+
+ @Test
+ void stop_stopsSchedulerAdapter() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ useCase.stop();
+
+ verify(schedulerPort).stopScheduler();
+ }
+
+ @Test
+ void stop_clearsNextTickAt() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ useCase.stop();
+
+ assertThat(useCase.getStatus().nextTickAt()).isEmpty();
+ }
+
+ // =========================================================================
+ // stop(): Idempotenz
+ // =========================================================================
+
+ @Test
+ void stop_whenAlreadyStopped_isIdempotent() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+
+ assertThatCode(useCase::stop).doesNotThrowAnyException();
+ verify(schedulerPort, never()).stopScheduler();
+ verify(settingsPort, never()).saveEnabled(anyBoolean());
+ }
+
+ @Test
+ void stop_calledTwice_isIdempotent() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ useCase.stop();
+
+ assertThatCode(useCase::stop).doesNotThrowAnyException();
+ verify(schedulerPort).stopScheduler(); // only once from the first stop
+ }
+
+ // =========================================================================
+ // stop(): STOPPING_BATCH_ACTIVE-Szenario (via executeWrappedTick direkt)
+ // =========================================================================
+
+ @Test
+ void stop_whenBatchRunning_setsStoppingBatchActiveAndDefersCleaup() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ // Während des triggerRun()-Aufrufs ist der Zustand RUNNING_BATCH_ACTIVE.
+ // Ein dazwischen gerufenes stop() setzt STOPPING_BATCH_ACTIVE.
+ // executeWrappedTick() erkennt das danach und setzt STOPPED.
+ when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
+ // stop() während RUNNING_BATCH_ACTIVE → setzt STOPPING_BATCH_ACTIVE
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.RUNNING_BATCH_ACTIVE);
+ useCase.stop();
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPING_BATCH_ACTIVE);
+ // Lock und saveEnabled(false) dürfen hier noch nicht aufgerufen worden sein
+ verify(lockPort, never()).releaseLock();
+ verify(settingsPort, never()).saveEnabled(false);
+ return new BatchRunTriggerResult.SkippedBusy();
+ });
+
+ useCase.executeWrappedTick();
+
+ // Erst nach Abschluss des Batches: STOPPED und Cleanup
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ verify(lockPort).releaseLock();
+ verify(settingsPort).saveEnabled(false);
+ }
+
+ // =========================================================================
+ // executeWrappedTick(): Zustandsübergänge für alle Ergebnisvarianten
+ // =========================================================================
+
+ @Test
+ void tick_started_setsRunningIdleWithLastRunData() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ Instant endedAt = Instant.now();
+ RunSummary summary = new RunSummary(3, 1, 2);
+ when(batchRunTrigger.triggerRun())
+ .thenReturn(new BatchRunTriggerResult.Started(endedAt, summary));
+
+ useCase.executeWrappedTick();
+
+ SchedulerStatus status = useCase.getStatus();
+ assertThat(status.state()).isEqualTo(SchedulerState.RUNNING_IDLE);
+ assertThat(status.lastRunEndedAt()).contains(endedAt);
+ assertThat(status.lastRunSummary()).contains(summary);
+ assertThat(status.lastError()).isEmpty();
+ assertThat(status.nextTickAt()).isPresent();
+ }
+
+ @Test
+ void tick_started_clearsLastError() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ // Erst einen fehlgeschlagenen Tick erzeugen
+ when(batchRunTrigger.triggerRun())
+ .thenReturn(new BatchRunTriggerResult.Failed("GUI-Fehler", "technisch"));
+ useCase.executeWrappedTick();
+ assertThat(useCase.getStatus().lastError()).isPresent();
+
+ // Nun erfolgreich
+ when(batchRunTrigger.triggerRun())
+ .thenReturn(new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp()));
+ useCase.executeWrappedTick();
+
+ assertThat(useCase.getStatus().lastError()).isEmpty();
+ }
+
+ @Test
+ void tick_skippedBusy_setsRunningIdleWithoutUpdatingLastRun() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ when(batchRunTrigger.triggerRun()).thenReturn(new BatchRunTriggerResult.SkippedBusy());
+
+ useCase.executeWrappedTick();
+
+ SchedulerStatus status = useCase.getStatus();
+ assertThat(status.state()).isEqualTo(SchedulerState.RUNNING_IDLE);
+ assertThat(status.lastRunEndedAt()).isEmpty();
+ assertThat(status.lastRunSummary()).isEmpty();
+ assertThat(status.nextTickAt()).isPresent();
+ }
+
+ @Test
+ void tick_skippedBusy_preservesLastError() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ // Erst einen Fehler setzen
+ when(batchRunTrigger.triggerRun())
+ .thenReturn(new BatchRunTriggerResult.Failed("Vorheriger Fehler", "technisch"));
+ useCase.executeWrappedTick();
+ assertThat(useCase.getStatus().lastError()).isPresent();
+
+ // Dann SkippedBusy
+ when(batchRunTrigger.triggerRun()).thenReturn(new BatchRunTriggerResult.SkippedBusy());
+ useCase.executeWrappedTick();
+
+ assertThat(useCase.getStatus().lastError()).isPresent();
+ assertThat(useCase.getStatus().lastError().get()).isEqualTo("Vorheriger Fehler");
+ }
+
+ @Test
+ void tick_failed_setsRunningIdleWithLastError() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ when(batchRunTrigger.triggerRun())
+ .thenReturn(new BatchRunTriggerResult.Failed("Benutzer-Fehlermeldung", "Technische Details"));
+
+ useCase.executeWrappedTick();
+
+ SchedulerStatus status = useCase.getStatus();
+ assertThat(status.state()).isEqualTo(SchedulerState.RUNNING_IDLE);
+ assertThat(status.lastError()).contains("Benutzer-Fehlermeldung");
+ assertThat(status.nextTickAt()).isPresent();
+ }
+
+ @Test
+ void tick_setsRunningBatchActiveBeforeDelegating() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
+ // Innerhalb des Batch-Aufrufs muss der Zustand RUNNING_BATCH_ACTIVE sein
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.RUNNING_BATCH_ACTIVE);
+ return new BatchRunTriggerResult.SkippedBusy();
+ });
+
+ useCase.executeWrappedTick();
+ }
+
+ // =========================================================================
+ // executeWrappedTick(): STOPPING_BATCH_ACTIVE → STOPPED
+ // =========================================================================
+
+ @Test
+ void tick_whenStoppingBatchActive_setsStoppedAndCleansUp() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+
+ // Simuliere: stop() wurde aufgerufen während ein Batch lief.
+ // stop() setzt den Zustand auf STOPPING_BATCH_ACTIVE wenn isBatchRunning().
+ // Da kein echter Executor läuft, testen wir den Wrapper direkt:
+ // Wir müssen den Zustand auf STOPPING_BATCH_ACTIVE bringen.
+ // Dazu nutzen wir, dass executeWrappedTick() zuerst RUNNING_BATCH_ACTIVE setzt,
+ // dann den Trigger aufruft. Wenn stop() dazwischen aufgerufen würde,
+ // würde es STOPPING_BATCH_ACTIVE setzen.
+ // Wir simulieren das, indem wir stop() innerhalb des triggerRun()-Aufrufs rufen:
+ when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
+ // stop() während laufendem Batch aufrufen
+ useCase.stop();
+ return new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp());
+ });
+
+ useCase.executeWrappedTick();
+
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ assertThat(useCase.getStatus().nextTickAt()).isEmpty();
+ verify(settingsPort).saveEnabled(false);
+ verify(lockPort).releaseLock();
+ }
+
+ @Test
+ void tick_whenStoppingBatchActive_setsLastRunDataBeforeStopping() {
+ DefaultSchedulerControlUseCase useCase = createUseCase();
+ useCase.start();
+ Instant endedAt = Instant.now();
+ RunSummary summary = new RunSummary(1, 0, 0);
+
+ when(batchRunTrigger.triggerRun()).thenAnswer(invocation -> {
+ useCase.stop();
+ return new BatchRunTriggerResult.Started(endedAt, summary);
+ });
+
+ useCase.executeWrappedTick();
+
+ assertThat(useCase.getStatus().state()).isEqualTo(SchedulerState.STOPPED);
+ assertThat(useCase.getStatus().lastRunEndedAt()).contains(endedAt);
+ assertThat(useCase.getStatus().lastRunSummary()).contains(summary);
+ }
+
+ // =========================================================================
+ // Hilfsmethoden
+ // =========================================================================
+
+ private DefaultSchedulerControlUseCase createUseCase() {
+ return new DefaultSchedulerControlUseCase(schedulerPort, lockPort, settingsPort, batchRunTrigger);
+ }
+}