Implementiere DefaultSchedulerControlUseCase für Scheduler-Orchestrierung

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<SchedulerStatus> für threadsichere Zustandsverwaltung
- Intervall wird beim Start aus SchedulerSettingsPort geladen, Minimum 30 s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 14:10:10 +02:00
parent 3022a9a16f
commit 8bd25d06c0
2 changed files with 798 additions and 0 deletions
@@ -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.
* <p>
* Dieser Use Case:
* <ul>
* <li>Verwaltet den Scheduler-Lebenszyklus (Start, Stop) über einen
* {@link SchedulerPort} und persistiert dabei den {@code enabled}-Wert.</li>
* <li>Hält den exklusiven OS-Lock auf die Konfigurationsdatei über
* {@link ConfigurationFileLockPort}, solange der Scheduler aktiv ist.</li>
* <li>Liest beim Erstellen das konfigurierte Intervall via
* {@link SchedulerSettingsPort}.</li>
* <li>Publiziert unveränderliche Zustandssnapshots via {@link #getStatus()},
* die threadsicher über eine {@link AtomicReference} verwaltet werden.</li>
* </ul>
* <p>
* 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.
* <p>
* {@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<SchedulerStatus> statusRef;
private final int intervalSeconds;
/**
* Erstellt einen neuen Use Case.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Setzt vor dem Aufruf den Zustand auf
* {@link SchedulerState#RUNNING_BATCH_ACTIVE} und leitet nach dem
* Abschluss den Folgezustand ab:
* <ul>
* <li>{@link SchedulerState#RUNNING_IDLE} bei normalem Weiterlauf</li>
* <li>{@link SchedulerState#STOPPED} wenn ein Stopp-Befehl empfangen wurde</li>
* </ul>
* Im Fall {@link SchedulerState#STOPPED} werden außerdem {@code enabled=false}
* persistiert und der OS-Lock freigegeben.
* <p>
* 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<Instant> nextTickAt = stopping
? Optional.empty()
: Optional.of(Instant.now().plusSeconds(intervalSeconds));
Optional<Instant> lastRunEndedAt = afterBatch.lastRunEndedAt();
Optional<RunSummary> lastRunSummary = afterBatch.lastRunSummary();
Optional<String> 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<Instant> nextTickAt) {
return new SchedulerStatus(
state,
base.lastRunEndedAt(),
base.lastRunSummary(),
nextTickAt,
base.lastError(),
base.autostartFailed());
}
}
@@ -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}.
* <p>
* Teststrategien:
* <ul>
* <li>Lifecycle-Tests (Start, Stop, Idempotenz) prüfen Zustandsübergänge
* und Interaktionen mit gemockten Ports.</li>
* <li>Tick-Tests rufen {@code executeWrappedTick()} direkt auf (package-private)
* und verifizieren die Zustandsübergänge für alle drei
* {@link BatchRunTriggerResult}-Varianten.</li>
* <li>Rollback-Tests prüfen, dass fehlgeschlagene Startschritte den Zustand
* vollständig auf {@link SchedulerState#STOPPED} zurücksetzen.</li>
* </ul>
*/
@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<SchedulerConfig> 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<SchedulerConfig> 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<Instant> 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<SchedulerConfig> 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);
}
}