diff --git a/docs/contrib/RENDER_PIPELINE.md b/docs/contrib/RENDER_PIPELINE.md index 4b4e755..46776a2 100644 --- a/docs/contrib/RENDER_PIPELINE.md +++ b/docs/contrib/RENDER_PIPELINE.md @@ -54,8 +54,8 @@ class MyAppSnapshotSpec extends AnyFunSuite with GoldenSupport: assertGoldenFrame(d.frame, "after-do-something") ``` -- `TuiTestDriver` exposes `model`, `frame`, `send(msg)`, `cmds`, `exited`, and `observedErrors`. -- Subscriptions (`Sub.Every`, `Sub.InputKey`, `Sub.TerminalResize`) are never started — tests stay deterministic. For prompt-driven apps, construct the wrapping `Msg` directly (e.g. `Msg.ConsoleInputKey(KeyDecoder.InputKey.CharKey('+'))`) instead of waiting on the input thread. +- `TuiTestDriver` exposes `model`, `frame`, `send(msg)`, `advanceTime(duration)`, `cmds`, `exited`, and `observedErrors`. +- Subscriptions (`Sub.Every`, `Sub.InputKey`, `Sub.TerminalResize`) are never started on a real scheduler — tests stay deterministic. For prompt-driven apps, construct the wrapping `Msg` directly (e.g. `Msg.ConsoleInputKey(KeyDecoder.InputKey.CharKey('+'))`) instead of waiting on the input thread. `Sub.Every` timers are driven explicitly via `TuiTestDriver.advanceTime`, backed by a virtual `ManualClock` (see [the testing guide](../guide/testing.md#driving-subevery-with-virtual-time)). - `Cmd.FCmd` must wrap a pre-resolved `Future.successful(...)`; the driver will not block on unresolved futures. ### Golden file format diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 1d9fbf2..a5670b0 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -32,8 +32,10 @@ The driver: recursively so chained transitions complete before `send` returns. - Renders the current model after every `send` — `driver.frame` returns the latest `RenderFrame`. -- Suppresses subscription start-up — `Sub.Every` timers never tick, - `Sub.InputKey` never reads stdin, so tests stay deterministic. +- Suppresses real subscription start-up — `Sub.InputKey` never reads + stdin and `Sub.Every` timers never tick on a real scheduler, so tests + stay deterministic. Timer ticks are instead driven explicitly with + `advanceTime` (see [Driving `Sub.Every` with virtual time](#driving-subevery-with-virtual-time)). Key methods: @@ -41,6 +43,8 @@ Key methods: class TuiTestDriver[Model, Msg]: def init(): Unit def send(msg: Msg): Unit + def advanceTime(duration: FiniteDuration): Unit + def advanceTime(durationMillis: Long): Unit def model: Model def exited: Boolean def cmds: List[Cmd[Msg]] @@ -48,6 +52,56 @@ class TuiTestDriver[Model, Msg]: def frame: RenderFrame ``` +## Driving `Sub.Every` with virtual time + +Apps that animate or poll via `Sub.Every` (`DigitalClock`, `SineWaveApp`, …) +used to be untestable: their ticks fired on a real background scheduler. The +testkit removes that dependency on wall-clock time with a virtual clock. + +`RuntimeCtx.clock` abstracts both wall-clock reads and the periodic scheduler +behind `Sub.Every`. Production uses `SystemClock` (real time, background +executor). `TestRuntimeCtx` substitutes a `ManualClock` whose time only moves +when you call `advanceTime`: + +```scala +val driver = TuiTestDriver(DigitalClock.App, width = 44, height = 20) +driver.init() +assert(driver.model.clock.value == "00:00") // ManualClock starts at epoch 0, UTC + +driver.advanceTime(1.second) // fires exactly one 1s tick +assert(driver.model.clock.value == "00:00:01") + +driver.advanceTime(3.seconds) // three more ticks +assert(driver.model.clock.value == "00:00:04") +``` + +`advanceTime` starts the registered timer subs against the `ManualClock` (no +threads spawn), fires every tick that falls due, and applies the resulting +messages through `app.update` — just as the runtime would when its scheduler +ticks. Input and resize subs stay dormant. + +Tick semantics: a timer with period `p` first fires `p` ms after the advance +that starts it, then every `p` thereafter, so `advanceTime(N * p)` produces +exactly `N` ticks. Unlike the real scheduler there is no immediate fire at +scheduling time — this gives an exact advance→tick correspondence. + +For apps that **display** wall-clock time (e.g. `DigitalClock`), read it +through `ctx.clock` rather than `LocalTime.now()` so the `ManualClock` drives +it deterministically: + +```scala +private def currentTime(ctx: RuntimeCtx[Msg]): String = + LocalTime.ofInstant(ctx.clock.instant(), ctx.clock.zone).toString +``` + +`ManualClock` can be constructed directly in lower-level tests: + +```scala +val clock = new ManualClock(startMillis = 0L) // zone defaults to UTC +clock.schedulePeriodic(100L, () => tick()) +clock.advance(300.millis) // tick() fires 3 times +``` + ## Asserting on the frame The simplest assertion is "did the rendered text contain X?": diff --git a/modules/termflow-app/src/main/scala/termflow/tui/Clock.scala b/modules/termflow-app/src/main/scala/termflow/tui/Clock.scala new file mode 100644 index 0000000..6dab95b --- /dev/null +++ b/modules/termflow-app/src/main/scala/termflow/tui/Clock.scala @@ -0,0 +1,76 @@ +package termflow.tui + +import java.time.Instant +import java.time.ZoneId +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Abstraction over wall-clock time and the periodic scheduler that drives + * timer subscriptions ([[Sub.Every]]). + * + * Two concerns are deliberately bundled behind one interface because both are + * "the passage of time" from an app's point of view: + * + * - reading the current instant (apps such as `DigitalClock` display it), and + * - scheduling a callback to run once per elapsed period (how `Sub.Every` + * ticks). + * + * Production code uses [[SystemClock]], which reads the real system time and + * schedules ticks on a background executor. Tests use + * `termflow.testkit.ManualClock`, whose virtual time only advances on explicit + * `advance` calls — making `Sub.Every`-driven apps snapshot-testable without + * real-time delays. + * + * @note SPI. A clock is obtained from [[RuntimeCtx.clock]]; apps and + * subscriptions read it rather than calling `System`/`java.time` + * directly so that the testkit can substitute virtual time. + */ +trait Clock: + + /** Current instant. Apps that display wall-clock time read this. */ + def instant(): Instant + + /** Zone used to derive local date/time values from [[instant]]. */ + def zone: ZoneId + + /** + * Schedule `task` to run repeatedly, once per `periodMillis` of elapsed + * time. Returns a handle that cancels the schedule. + * + * `periodMillis` must be strictly positive. + */ + def schedulePeriodic(periodMillis: Long, task: () => Unit): Clock.Cancelable + +object Clock: + + /** Handle returned by [[Clock.schedulePeriodic]] used to stop a schedule. */ + trait Cancelable: + def cancel(): Unit + +/** + * Default [[Clock]] backed by the real system clock and a per-schedule + * single-threaded executor — the production scheduler behind [[Sub.Every]]. + * + * Each call to [[schedulePeriodic]] owns one scheduler thread, matching the + * previous inline `Executors.newSingleThreadScheduledExecutor` wiring; the + * first tick fires immediately (initial delay `0`) exactly as before. + */ +object SystemClock extends Clock: + + override def instant(): Instant = Instant.now() + + override def zone: ZoneId = ZoneId.systemDefault() + + override def schedulePeriodic(periodMillis: Long, task: () => Unit): Clock.Cancelable = + val scheduler = Executors.newSingleThreadScheduledExecutor(ThreadUtils.newThreadFactory()) + val handle = scheduler.scheduleAtFixedRate(() => task(), 0L, periodMillis, TimeUnit.MILLISECONDS) + new Clock.Cancelable: + override def cancel(): Unit = + handle.cancel(true): Unit + scheduler.shutdownNow(): Unit + try scheduler.awaitTermination(200L, TimeUnit.MILLISECONDS): Unit + catch { + case _: InterruptedException => + Thread.currentThread().interrupt() + } diff --git a/modules/termflow-app/src/main/scala/termflow/tui/Sub.scala b/modules/termflow-app/src/main/scala/termflow/tui/Sub.scala index 28de15e..90df849 100644 --- a/modules/termflow-app/src/main/scala/termflow/tui/Sub.scala +++ b/modules/termflow-app/src/main/scala/termflow/tui/Sub.scala @@ -70,6 +70,16 @@ object Sub: */ trait InputSub[+Msg] extends Sub[Msg] + /** + * Marker for timer subscriptions whose ticks are driven by a [[Clock]] + * ([[Every]] is the only one today). + * + * The testkit uses this marker to tell timers apart from input-/resize-style + * subs so it can start exactly the clock-driven ones against a `ManualClock` + * during `advanceTime`, while leaving thread-spawning subs dormant. + */ + trait TimerSub[+Msg] extends Sub[Msg] + private def autoRegisterIfRuntimeCtx[Msg](sub: Sub[Msg], sink: EventSink[Msg]): Sub[Msg] = sink match case ctx: RuntimeCtx[?] => @@ -90,14 +100,17 @@ object Sub: * Create a timer subscription that fires at a fixed interval. * * Each tick publishes `Cmd.GCmd(msg())` through `sink`. The thunk runs on - * a single background scheduler thread, so `msg()` must not block. + * the scheduler owned by the sink's [[Clock]], so `msg()` must not block. * - * The scheduler is constructed lazily by [[Sub.start]] rather than in this - * factory, so `TestRuntimeCtx` can keep timers dormant during snapshot - * tests (it does not call `start()` on registered subs). For all - * production paths (`LocalCmdBus.registerSub`, bare `EventSink` sinks) - * `start()` runs synchronously immediately after construction, preserving - * the original eager-start behaviour. + * Ticks are scheduled through the clock resolved from `sink`: a + * [[RuntimeCtx]] supplies its [[RuntimeCtx.clock]] (the real [[SystemClock]] + * in production, a `ManualClock` under the testkit), while a bare + * `EventSink` falls back to [[SystemClock]]. The schedule is created lazily + * by [[Sub.start]] rather than in this factory, so `TestRuntimeCtx` can keep + * timers dormant during snapshot tests (it does not call `start()` on + * registered subs). For all production paths (`LocalCmdBus.registerSub`, + * bare `EventSink` sinks) `start()` runs synchronously immediately after + * construction, preserving the original eager-start behaviour. * * @param millis Interval between ticks, in milliseconds. * @param msg Thunk producing the next message on each tick. @@ -105,23 +118,18 @@ object Sub: * the subscription auto-registers for cleanup on exit. */ def Every[Msg](millis: Long, msg: () => Msg, sink: EventSink[Msg]): Sub[Msg] = - val sub = new Sub[Msg]: - private val lock = new Object - @volatile private var active = true - @volatile private var scheduler: java.util.concurrent.ScheduledExecutorService = null - @volatile private var handle: java.util.concurrent.ScheduledFuture[?] = null + val clock = sink match + case ctx: RuntimeCtx[?] => ctx.clock + case _ => SystemClock + val sub = new TimerSub[Msg]: + private val lock = new Object + @volatile private var active = true + @volatile private var handle: Clock.Cancelable = null override def start(): Unit = lock.synchronized { - if scheduler == null && active then - val s = Executors.newSingleThreadScheduledExecutor(ThreadUtils.newThreadFactory()) - scheduler = s - handle = s.scheduleAtFixedRate( - () => sink.publish(Cmd.GCmd[Msg](msg())), - 0L, - millis, - TimeUnit.MILLISECONDS - ) + if handle == null && active then + handle = clock.schedulePeriodic(millis, () => sink.publish(Cmd.GCmd[Msg](msg()))) } override def isActive: Boolean = active @@ -129,14 +137,7 @@ object Sub: override def cancel(): Unit = lock.synchronized { active = false - if handle != null then handle.cancel(true): Unit - if scheduler != null then - scheduler.shutdownNow(): Unit - try scheduler.awaitTermination(200L, TimeUnit.MILLISECONDS): Unit - catch { - case _: InterruptedException => - Thread.currentThread().interrupt() - } + if handle != null then handle.cancel() } autoRegisterIfRuntimeCtx(sub, sink) diff --git a/modules/termflow-app/src/main/scala/termflow/tui/TuiRuntime.scala b/modules/termflow-app/src/main/scala/termflow/tui/TuiRuntime.scala index fe7b70a..9d142dd 100644 --- a/modules/termflow-app/src/main/scala/termflow/tui/TuiRuntime.scala +++ b/modules/termflow-app/src/main/scala/termflow/tui/TuiRuntime.scala @@ -63,6 +63,14 @@ trait RuntimeCtx[Msg] extends EventSink[Msg]: */ def registerSub(sub: Sub[Msg]): Sub[Msg] + /** + * Clock used to read wall-clock time and to schedule timer subscriptions + * ([[Sub.Every]]). Defaults to the real [[SystemClock]]; the testkit's + * `TestRuntimeCtx` overrides it with a `ManualClock` so timers advance + * deterministically under test. + */ + def clock: Clock = SystemClock + /** * Read side of the command bus consumed by the runtime loop. * diff --git a/modules/termflow-sample/src/main/scala/termflow/apps/clock/DigitalClock.scala b/modules/termflow-sample/src/main/scala/termflow/apps/clock/DigitalClock.scala index d8424b0..c059da1 100644 --- a/modules/termflow-sample/src/main/scala/termflow/apps/clock/DigitalClock.scala +++ b/modules/termflow-sample/src/main/scala/termflow/apps/clock/DigitalClock.scala @@ -41,6 +41,12 @@ object DigitalClock: import Msg._ object App extends TuiApp[Model, Msg]: + // Read wall-clock time through the runtime's Clock rather than + // LocalTime.now() directly, so the testkit's ManualClock drives it + // deterministically (see Sub.Every / TuiTestDriver.advanceTime). + private def currentTime(ctx: RuntimeCtx[Msg]): String = + LocalTime.ofInstant(ctx.clock.instant(), ctx.clock.zone).toString + private def syncTerminalSize(m: Model, ctx: RuntimeCtx[Msg]): Model = val w = ctx.terminal.width val h = ctx.terminal.height @@ -53,7 +59,7 @@ object DigitalClock: terminalHeight = ctx.terminal.height, SubSource[String]( Sub.Every(1000, () => Tick, ctx), - LocalTime.now().toString + currentTime(ctx) ), List.empty, None, @@ -65,7 +71,7 @@ object DigitalClock: val sized = syncTerminalSize(m, ctx) msg match case Tick => - sized.copy(clock = sized.clock.copy(value = LocalTime.now().toString)).tui + sized.copy(clock = sized.clock.copy(value = currentTime(ctx))).tui case StartClock => if sized.clock.sub.isActive then sized.copy(error = Some("Clock already running")).tui diff --git a/modules/termflow-sample/src/test/resources/termflow/golden/DigitalClockSnapshotSpec/after-1s.golden b/modules/termflow-sample/src/test/resources/termflow/golden/DigitalClockSnapshotSpec/after-1s.golden new file mode 100644 index 0000000..0329277 --- /dev/null +++ b/modules/termflow-sample/src/test/resources/termflow/golden/DigitalClockSnapshotSpec/after-1s.golden @@ -0,0 +1,14 @@ +# width=44 height=12 +# cursor=6,11 +|┌──────────────────────────────────────┐ | +|│Time: 00:00:01 │ | +|│──────────────────────────────────────│ | +|│Type a message and press Enter │ | +|│──────────────────────────────────────│ | +|│Commands: │ | +|│ start | startclock -> start ticking │ | +|│ stop | stopclock -> stop ticking │ | +|│ exit -> quit │ | +|└──────────────────────────────────────┘ | +| []> | +| | diff --git a/modules/termflow-sample/src/test/resources/termflow/golden/DigitalClockSnapshotSpec/initial.golden b/modules/termflow-sample/src/test/resources/termflow/golden/DigitalClockSnapshotSpec/initial.golden new file mode 100644 index 0000000..8536c35 --- /dev/null +++ b/modules/termflow-sample/src/test/resources/termflow/golden/DigitalClockSnapshotSpec/initial.golden @@ -0,0 +1,14 @@ +# width=44 height=12 +# cursor=6,11 +|┌──────────────────────────────────────┐ | +|│Time: 00:00 │ | +|│──────────────────────────────────────│ | +|│Type a message and press Enter │ | +|│──────────────────────────────────────│ | +|│Commands: │ | +|│ start | startclock -> start ticking │ | +|│ stop | stopclock -> stop ticking │ | +|│ exit -> quit │ | +|└──────────────────────────────────────┘ | +| []> | +| | diff --git a/modules/termflow-sample/src/test/resources/termflow/golden/SineWaveAppSnapshotSpec/after-5-ticks.golden b/modules/termflow-sample/src/test/resources/termflow/golden/SineWaveAppSnapshotSpec/after-5-ticks.golden new file mode 100644 index 0000000..83acf84 --- /dev/null +++ b/modules/termflow-sample/src/test/resources/termflow/golden/SineWaveAppSnapshotSpec/after-5-ticks.golden @@ -0,0 +1,22 @@ +# width=60 height=20 +# cursor=6,19 +|┌──────────────────────────────────────────────────────┐ | +|│ │ | +|│ │ | +|│ **** **** │ | +|│ ** ** ** ** │ | +|│ .... * *... ...* * ....│ | +|│ . .* . * . . * . *. │ | +|│ . *. . * . . * . .* │ | +|│ .. * .. .. * .. .. * .. .. * │ | +|│. ** ... ** ... ** ... ** │ | +|│ * * * *│ | +|│***** ***** │ | +|│ │ | +|│ │ | +|└──────────────────────────────────────────────────────┘ | +| Commands: pause -> pause animation | +| resume -> resume animation | +| faster | slower | exit | +| []> | +| | diff --git a/modules/termflow-sample/src/test/resources/termflow/golden/SineWaveAppSnapshotSpec/initial.golden b/modules/termflow-sample/src/test/resources/termflow/golden/SineWaveAppSnapshotSpec/initial.golden new file mode 100644 index 0000000..22e897b --- /dev/null +++ b/modules/termflow-sample/src/test/resources/termflow/golden/SineWaveAppSnapshotSpec/initial.golden @@ -0,0 +1,22 @@ +# width=60 height=20 +# cursor=6,19 +|┌──────────────────────────────────────────────────────┐ | +|│ │ | +|│ │ | +|│ **** **** │ | +|│ ** ** ** ** │ | +|│ ... * *.. ...* * │ | +|│ . ..* . * .. . *.. *│ | +|│* .. **. .. * . .. ** . ..│ | +|│ *. . * .. . * .. . * .. . │ | +|│ *... * ... * ... * ... │ | +|│ ** ** ** ** │ | +|│ **** **** │ | +|│ │ | +|│ │ | +|└──────────────────────────────────────────────────────┘ | +| Commands: pause -> pause animation | +| resume -> resume animation | +| faster | slower | exit | +| []> | +| | diff --git a/modules/termflow-sample/src/test/scala/termflow/apps/clock/DigitalClockSnapshotSpec.scala b/modules/termflow-sample/src/test/scala/termflow/apps/clock/DigitalClockSnapshotSpec.scala new file mode 100644 index 0000000..f5911d1 --- /dev/null +++ b/modules/termflow-sample/src/test/scala/termflow/apps/clock/DigitalClockSnapshotSpec.scala @@ -0,0 +1,57 @@ +package termflow.apps.clock + +import org.scalatest.funsuite.AnyFunSuite +import termflow.testkit.GoldenSupport +import termflow.testkit.TuiTestDriver + +import scala.concurrent.duration.* + +/** + * Time-advance coverage for the `Sub.Every`-driven `DigitalClock`. + * + * The app reads wall-clock time through `RuntimeCtx.clock`, which the testkit + * backs with a `ManualClock` (UTC, starting at epoch 0). Driving + * `advanceTime` fires the 1-second timer deterministically — no real sleeps — + * so the rendered clock value is reproducible in goldens. + */ +class DigitalClockSnapshotSpec extends AnyFunSuite with GoldenSupport: + + private val Width = 44 + private val Height = 20 + + private def driver(): TuiTestDriver[DigitalClock.Model, DigitalClock.Msg] = + val d = TuiTestDriver(DigitalClock.App, width = Width, height = Height) + d.init() + d + + test("initial frame shows the clock at the manual clock's start instant"): + val d = driver() + assert(d.model.clock.value == "00:00") + assertGoldenFrame(d.frame, "initial") + + test("advancing one second produces exactly one tick"): + val d = driver() + d.advanceTime(1.second) + assert(d.model.clock.value == "00:00:01") + assertGoldenFrame(d.frame, "after-1s") + + test("advancing several seconds advances the displayed time accordingly"): + val d = driver() + d.advanceTime(1.second) + d.advanceTime(3.seconds) + assert(d.model.clock.value == "00:00:04") + + test("a fractional advance below the tick interval does not tick"): + val d = driver() + d.advanceTime(500.millis) + assert(d.model.clock.value == "00:00") // still the initial value + d.advanceTime(500.millis) + assert(d.model.clock.value == "00:00:01") // boundary crossed once across the two advances + + test("StopClock halts ticking so further advances leave the time frozen"): + val d = driver() + d.advanceTime(2.seconds) + assert(d.model.clock.value == "00:00:02") + d.send(DigitalClock.Msg.StopClock) + d.advanceTime(5.seconds) + assert(d.model.clock.value == "00:00:02") // frozen after the clock was stopped diff --git a/modules/termflow-sample/src/test/scala/termflow/apps/stress/SineWaveAppSnapshotSpec.scala b/modules/termflow-sample/src/test/scala/termflow/apps/stress/SineWaveAppSnapshotSpec.scala new file mode 100644 index 0000000..ee83229 --- /dev/null +++ b/modules/termflow-sample/src/test/scala/termflow/apps/stress/SineWaveAppSnapshotSpec.scala @@ -0,0 +1,49 @@ +package termflow.apps.stress + +import org.scalatest.funsuite.AnyFunSuite +import termflow.testkit.GoldenSupport +import termflow.testkit.TuiTestDriver + +import scala.concurrent.duration.* + +/** + * Time-advance coverage for the `Sub.Every`-driven `SineWaveApp` animation. + * + * Each 50ms tick advances the wave's phase by `step`. The testkit's + * `ManualClock` lets `advanceTime` fire those ticks deterministically, so the + * animation can be snapshotted at a known phase without real-time delays. + */ +class SineWaveAppSnapshotSpec extends AnyFunSuite with GoldenSupport: + + private val Width = 60 + private val Height = 20 + + private def driver(): TuiTestDriver[SineWaveApp.Model, SineWaveApp.Msg] = + val d = TuiTestDriver(SineWaveApp.App, width = Width, height = Height) + d.init() + d + + test("initial frame renders the wave at phase 0"): + val d = driver() + assert(d.model.phase == 0.0) + assertGoldenFrame(d.frame, "initial") + + test("one tick interval advances the phase by one step"): + val d = driver() + d.advanceTime(50.millis) + assert(d.model.phase == d.model.step) + + test("advancing five intervals applies five steps and animates the wave"): + val d = driver() + d.advanceTime(250.millis) + assert(math.abs(d.model.phase - 5 * 0.22) < 1e-9) + assertGoldenFrame(d.frame, "after-5-ticks") + + test("Pause stops the animation so further advances leave the phase fixed"): + val d = driver() + d.advanceTime(100.millis) + val pausedPhase = d.model.phase + d.send(SineWaveApp.Msg.Pause) + d.advanceTime(1.second) + assert(d.model.phase == pausedPhase) + assert(!d.model.running) diff --git a/modules/termflow-testkit/src/main/scala/termflow/testkit/ManualClock.scala b/modules/termflow-testkit/src/main/scala/termflow/testkit/ManualClock.scala new file mode 100644 index 0000000..faeac22 --- /dev/null +++ b/modules/termflow-testkit/src/main/scala/termflow/testkit/ManualClock.scala @@ -0,0 +1,91 @@ +package termflow.testkit + +import termflow.tui.Clock + +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import scala.collection.mutable +import scala.concurrent.duration.FiniteDuration + +/** + * Deterministic [[Clock]] for snapshot tests: virtual time only moves forward + * when [[advance]] is called, and scheduled tasks fire synchronously on the + * advancing thread. No real timers, no sleeps. + * + * `Sub.Every` subscriptions registered against a `TestRuntimeCtx` (whose + * [[termflow.tui.RuntimeCtx.clock]] is a `ManualClock`) schedule their ticks + * here. [[TuiTestDriver.advanceTime]] then advances this clock to fire those + * ticks, so an app driven by a timer can be observed frame-by-frame. + * + * Tick semantics: a task scheduled with period `p` first fires `p` + * milliseconds after it was scheduled, then every `p` thereafter — so + * `advance(N * p)` produces exactly `N` ticks. Unlike the real + * [[termflow.tui.SystemClock]], there is no immediate fire at scheduling + * time; this gives tests an exact advance→tick correspondence. + * + * @param startMillis Initial epoch-millis value reported by [[instant]]. + * @param zone Zone used when deriving local date/time from [[instant]]; + * defaults to UTC so goldens are machine-independent. + */ +final class ManualClock(startMillis: Long = 0L, override val zone: ZoneId = ZoneOffset.UTC) extends Clock: + + final private class Task(val periodMillis: Long, val run: () => Unit): + var nextFireAt: Long = 0L + var active: Boolean = true + + private val lock = new Object + private var nowMillis = startMillis + private val tasks = mutable.ArrayBuffer.empty[Task] + + /** Current virtual time in epoch milliseconds. */ + def currentMillis: Long = lock.synchronized(nowMillis) + + override def instant(): Instant = Instant.ofEpochMilli(currentMillis) + + override def schedulePeriodic(periodMillis: Long, task: () => Unit): Clock.Cancelable = + require(periodMillis > 0, s"periodMillis must be > 0, was $periodMillis") + val t = new Task(periodMillis, task) + lock.synchronized { + t.nextFireAt = nowMillis + periodMillis + val _ = tasks.append(t) + } + new Clock.Cancelable: + override def cancel(): Unit = + lock.synchronized { + t.active = false + val _ = tasks.subtractOne(t) + } + + /** Advance virtual time by `duration`. See [[advance(deltaMillis:Long)*]]. */ + def advance(duration: FiniteDuration): Unit = advance(duration.toMillis) + + /** + * Advance virtual time by `deltaMillis`, firing every scheduled task once + * for each whole period that elapses, in chronological order across all + * tasks. Each task's thunk runs while [[instant]] reports that task's own + * fire time; after the advance completes the clock reads `start + delta`. + */ + def advance(deltaMillis: Long): Unit = + require(deltaMillis >= 0, s"deltaMillis must be >= 0, was $deltaMillis") + val target = lock.synchronized(nowMillis + deltaMillis) + var more = true + while more do + // Pick the earliest task due at or before `target`. Tasks may publish to + // the bus when fired, but never schedule new tasks mid-advance (the + // driver applies model updates only after advance returns), so a fresh + // scan each iteration is safe and simple. + val due = lock.synchronized { + tasks.iterator.filter(t => t.active && t.nextFireAt <= target).minByOption(_.nextFireAt) + } + due match + case None => more = false + case Some(t) => + lock.synchronized { + nowMillis = t.nextFireAt + t.nextFireAt += t.periodMillis + } + t.run() + lock.synchronized { + if nowMillis < target then nowMillis = target + } diff --git a/modules/termflow-testkit/src/main/scala/termflow/testkit/TestRuntimeCtx.scala b/modules/termflow-testkit/src/main/scala/termflow/testkit/TestRuntimeCtx.scala index d8fc738..d293286 100644 --- a/modules/termflow-testkit/src/main/scala/termflow/testkit/TestRuntimeCtx.scala +++ b/modules/termflow-testkit/src/main/scala/termflow/testkit/TestRuntimeCtx.scala @@ -1,5 +1,6 @@ package termflow.testkit +import termflow.tui.Clock import termflow.tui.Cmd import termflow.tui.LogPath import termflow.tui.LoggingConfig @@ -26,13 +27,17 @@ import scala.collection.mutable */ final class TestRuntimeCtx[Msg]( override val terminal: TerminalBackend, - override val config: TermFlowConfig + override val config: TermFlowConfig, + val manualClock: ManualClock = new ManualClock() ) extends RuntimeCtx[Msg]: private val lock = new Object private val cmdBuffer = mutable.Queue.empty[Cmd[Msg]] private val subs = mutable.ArrayBuffer.empty[Sub[Msg]] + /** Virtual clock backing `Sub.Every`; advanced via [[TuiTestDriver.advanceTime]]. */ + override def clock: Clock = manualClock + override def publish(cmd: Cmd[Msg]): Unit = lock.synchronized { val _ = cmdBuffer.enqueue(cmd) diff --git a/modules/termflow-testkit/src/main/scala/termflow/testkit/TuiTestDriver.scala b/modules/termflow-testkit/src/main/scala/termflow/testkit/TuiTestDriver.scala index ac80d6c..76ffe89 100644 --- a/modules/termflow-testkit/src/main/scala/termflow/testkit/TuiTestDriver.scala +++ b/modules/termflow-testkit/src/main/scala/termflow/testkit/TuiTestDriver.scala @@ -3,11 +3,13 @@ package termflow.testkit import termflow.tui.AnsiRenderer import termflow.tui.AnsiRenderer.RenderFrame import termflow.tui.Cmd +import termflow.tui.Sub import termflow.tui.TermFlowError import termflow.tui.TuiApp import scala.collection.mutable import scala.compiletime.uninitialized +import scala.concurrent.duration.FiniteDuration import scala.util.Failure import scala.util.Success @@ -106,6 +108,32 @@ final class TuiTestDriver[Model, Msg]( dispatch(msg) processPending() + /** Advance virtual time by `duration`. See [[advanceTime(durationMillis:Long)*]]. */ + def advanceTime(duration: FiniteDuration): Unit = advanceTime(duration.toMillis) + + /** + * Advance the test [[ManualClock]] by `durationMillis`, firing every + * `Sub.Every` tick that falls due, then drain and apply the resulting + * messages through `app.update` — exactly as the real runtime would when + * its scheduler ticks, but deterministically and without any real-time wait. + * + * Registered timer subs are started lazily here (against the manual clock, + * so no threads spawn); input and resize subs stay dormant, matching the + * driver's usual determinism guarantees. A timer registered with period `p` + * first ticks `p` ms after the advance that starts it, so + * `advanceTime(N * p)` yields `N` ticks. + */ + def advanceTime(durationMillis: Long): Unit = + if !_initialized then throw new IllegalStateException("TuiTestDriver.advanceTime() called before init()") + if _exited then throw new IllegalStateException("TuiTestDriver.advanceTime() called after Cmd.Exit") + ctx.registeredSubs.foreach { + case t: Sub.TimerSub[?] => t.start() + case _ => () + } + ctx.manualClock.advance(durationMillis) + enqueueCtxQueue() + processPending() + private def dispatch(msg: Msg): Unit = val next = app.update(_model, msg, ctx) _model = next.model diff --git a/modules/termflow-testkit/src/test/scala/termflow/testkit/ManualClockSpec.scala b/modules/termflow-testkit/src/test/scala/termflow/testkit/ManualClockSpec.scala new file mode 100644 index 0000000..aaeda11 --- /dev/null +++ b/modules/termflow-testkit/src/test/scala/termflow/testkit/ManualClockSpec.scala @@ -0,0 +1,74 @@ +package termflow.testkit + +import org.scalatest.funsuite.AnyFunSuite + +import java.time.ZoneOffset +import scala.collection.mutable +import scala.concurrent.duration.* + +class ManualClockSpec extends AnyFunSuite: + + test("instant reflects the start millis and advances with the clock"): + val clock = new ManualClock(startMillis = 1000L) + assert(clock.instant().toEpochMilli == 1000L) + clock.advance(500L) + assert(clock.instant().toEpochMilli == 1500L) + assert(clock.zone == ZoneOffset.UTC) + + test("a periodic task fires once per elapsed period"): + val clock = new ManualClock() + var ticks = 0 + clock.schedulePeriodic(100L, () => ticks += 1) + clock.advance(100L) + assert(ticks == 1) + clock.advance(300L) + assert(ticks == 4) + + test("no fire occurs before a full period elapses"): + val clock = new ManualClock() + var ticks = 0 + clock.schedulePeriodic(100L, () => ticks += 1) + clock.advance(99L) + assert(ticks == 0) + clock.advance(1L) // crosses the boundary + assert(ticks == 1) + + test("tasks observe instant() at their own fire time"): + val clock = new ManualClock() + val seen = mutable.ListBuffer.empty[Long] + clock.schedulePeriodic(100L, () => seen += clock.currentMillis) + clock.advance(300L) + assert(seen.toList == List(100L, 200L, 300L)) + + test("multiple tasks fire in chronological order"): + val clock = new ManualClock() + val order = mutable.ListBuffer.empty[String] + clock.schedulePeriodic(100L, () => order += s"a@${clock.currentMillis}") + clock.schedulePeriodic(150L, () => order += s"b@${clock.currentMillis}") + clock.advance(300L) + // At the t=300 tie, tasks fire in scheduling order (a registered before b). + assert(order.toList == List("a@100", "b@150", "a@200", "a@300", "b@300")) + + test("cancel stops further ticks"): + val clock = new ManualClock() + var ticks = 0 + val handle = clock.schedulePeriodic(100L, () => ticks += 1) + clock.advance(100L) + handle.cancel() + clock.advance(500L) + assert(ticks == 1) + + test("advance accepts a FiniteDuration"): + val clock = new ManualClock() + var ticks = 0 + clock.schedulePeriodic(50L, () => ticks += 1) + clock.advance(250.millis) + assert(ticks == 5) + + test("schedulePeriodic rejects a non-positive period"): + val clock = new ManualClock() + assertThrows[IllegalArgumentException](clock.schedulePeriodic(0L, () => ())) + + test("advance rejects a negative duration"): + val clock = new ManualClock() + assertThrows[IllegalArgumentException](clock.advance(-1L))