Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/contrib/RENDER_PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 56 additions & 2 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,76 @@ 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:

```scala
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]]
def observedErrors: List[TermFlowError]
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?":
Expand Down
76 changes: 76 additions & 0 deletions modules/termflow-app/src/main/scala/termflow/tui/Clock.scala
Original file line number Diff line number Diff line change
@@ -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()
}
59 changes: 30 additions & 29 deletions modules/termflow-app/src/main/scala/termflow/tui/Sub.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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[?] =>
Expand All @@ -90,53 +100,44 @@ 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.
* @param sink Where to publish ticks. When called with a [[RuntimeCtx]]
* 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

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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 │ |
|└──────────────────────────────────────┘ |
| []> |
| |
Original file line number Diff line number Diff line change
@@ -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 │ |
|└──────────────────────────────────────┘ |
| []> |
| |
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# width=60 height=20
# cursor=6,19
|┌──────────────────────────────────────────────────────┐ |
|│ │ |
|│ │ |
|│ **** **** │ |
|│ ** ** ** ** │ |
|│ .... * *... ...* * ....│ |
|│ . .* . * . . * . *. │ |
|│ . *. . * . . * . .* │ |
|│ .. * .. .. * .. .. * .. .. * │ |
|│. ** ... ** ... ** ... ** │ |
|│ * * * *│ |
|│***** ***** │ |
|│ │ |
|│ │ |
|└──────────────────────────────────────────────────────┘ |
| Commands: pause -> pause animation |
| resume -> resume animation |
| faster | slower | exit |
| []> |
| |
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# width=60 height=20
# cursor=6,19
|┌──────────────────────────────────────────────────────┐ |
|│ │ |
|│ │ |
|│ **** **** │ |
|│ ** ** ** ** │ |
|│ ... * *.. ...* * │ |
|│ . ..* . * .. . *.. *│ |
|│* .. **. .. * . .. ** . ..│ |
|│ *. . * .. . * .. . * .. . │ |
|│ *... * ... * ... * ... │ |
|│ ** ** ** ** │ |
|│ **** **** │ |
|│ │ |
|│ │ |
|└──────────────────────────────────────────────────────┘ |
| Commands: pause -> pause animation |
| resume -> resume animation |
| faster | slower | exit |
| []> |
| |
Loading
Loading