From 8bd25d06c034faa25430b78569fa59314f244161 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Wed, 6 May 2026 14:10:10 +0200 Subject: [PATCH] =?UTF-8?q?Implementiere=20DefaultSchedulerControlUseCase?= =?UTF-8?q?=20f=C3=BCr=20Scheduler-Orchestrierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert SchedulerControlUseCase als zentralen Orchestrator: - start()-Sequenz mit STARTING → RUNNING_IDLE und vollständigem Rollback - stop()-Sequenz mit CAS-gesichertem STOPPING_BATCH_ACTIVE für laufende Batches - executeWrappedTick() (package-private) setzt RUNNING_BATCH_ACTIVE vor dem Trigger und leitet Folgezustand aus BatchRunTriggerResult-Variante ab - AtomicReference für threadsichere Zustandsverwaltung - Intervall wird beim Start aus SchedulerSettingsPort geladen, Minimum 30 s Co-Authored-By: Claude Sonnet 4.6 --- .../DefaultSchedulerControlUseCase.java | 299 +++++++++++ .../DefaultSchedulerControlUseCaseTest.java | 499 ++++++++++++++++++ 2 files changed, 798 insertions(+) create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCaseTest.java diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java new file mode 100644 index 0000000..205173d --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java @@ -0,0 +1,299 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase; +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.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 org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Implementiert {@link SchedulerControlUseCase} als zentraler Orchestrator + * des automatischen Schedulers. + *

+ * Dieser Use Case: + *

    + *
  • Verwaltet den Scheduler-Lebenszyklus (Start, Stop) über einen + * {@link SchedulerPort} und persistiert dabei den {@code enabled}-Wert.
  • + *
  • Hält den exklusiven OS-Lock auf die Konfigurationsdatei über + * {@link ConfigurationFileLockPort}, solange der Scheduler aktiv ist.
  • + *
  • Liest beim Erstellen das konfigurierte Intervall via + * {@link SchedulerSettingsPort}.
  • + *
  • Publiziert unveränderliche Zustandssnapshots via {@link #getStatus()}, + * die threadsicher über eine {@link AtomicReference} verwaltet werden.
  • + *
+ *

+ * Alle Zustandsübergänge erfolgen atomar. Der {@link BatchRunTrigger} wird + * pro Tick in einem internen Wrapper ausgeführt, der vor dem Aufruf den + * Zustand auf {@link SchedulerState#RUNNING_BATCH_ACTIVE} setzt und nach + * Abschluss den Folgezustand ableitet. + *

+ * {@link #start()} und {@link #stop()} sind idempotent und dürfen serialisiert + * vom GUI-Worker-Thread aufgerufen werden. {@link #getStatus()} ist jederzeit + * von beliebigen Threads lesbar. + */ +public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase { + + private static final Logger logger = + LogManager.getLogger(DefaultSchedulerControlUseCase.class); + + private static final int MINIMUM_INTERVAL_SECONDS = 30; + + private final SchedulerPort schedulerPort; + private final ConfigurationFileLockPort lockPort; + private final SchedulerSettingsPort settingsPort; + private final BatchRunTrigger batchRunTrigger; + private final AtomicReference 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); + } +}