Skip to content

Commit 98e0faf

Browse files
rorygravesRory Graves
andauthored
Timer harness (#217)
Co-authored-by: Rory Graves <rory.graves@thetradedesk.com>
1 parent 52fb28f commit 98e0faf

16 files changed

Lines changed: 557 additions & 36 deletions

File tree

docs/contrib/RENDER_PIPELINE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ class MyAppSnapshotSpec extends AnyFunSuite with GoldenSupport:
5454
assertGoldenFrame(d.frame, "after-do-something")
5555
```
5656

57-
- `TuiTestDriver` exposes `model`, `frame`, `send(msg)`, `cmds`, `exited`, and `observedErrors`.
58-
- 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.
57+
- `TuiTestDriver` exposes `model`, `frame`, `send(msg)`, `advanceTime(duration)`, `cmds`, `exited`, and `observedErrors`.
58+
- 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)).
5959
- `Cmd.FCmd` must wrap a pre-resolved `Future.successful(...)`; the driver will not block on unresolved futures.
6060

6161
### Golden file format

docs/guide/testing.md

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,76 @@ The driver:
3232
recursively so chained transitions complete before `send` returns.
3333
- Renders the current model after every `send``driver.frame`
3434
returns the latest `RenderFrame`.
35-
- Suppresses subscription start-up — `Sub.Every` timers never tick,
36-
`Sub.InputKey` never reads stdin, so tests stay deterministic.
35+
- Suppresses real subscription start-up — `Sub.InputKey` never reads
36+
stdin and `Sub.Every` timers never tick on a real scheduler, so tests
37+
stay deterministic. Timer ticks are instead driven explicitly with
38+
`advanceTime` (see [Driving `Sub.Every` with virtual time](#driving-subevery-with-virtual-time)).
3739

3840
Key methods:
3941

4042
```scala
4143
class TuiTestDriver[Model, Msg]:
4244
def init(): Unit
4345
def send(msg: Msg): Unit
46+
def advanceTime(duration: FiniteDuration): Unit
47+
def advanceTime(durationMillis: Long): Unit
4448
def model: Model
4549
def exited: Boolean
4650
def cmds: List[Cmd[Msg]]
4751
def observedErrors: List[TermFlowError]
4852
def frame: RenderFrame
4953
```
5054

55+
## Driving `Sub.Every` with virtual time
56+
57+
Apps that animate or poll via `Sub.Every` (`DigitalClock`, `SineWaveApp`, …)
58+
used to be untestable: their ticks fired on a real background scheduler. The
59+
testkit removes that dependency on wall-clock time with a virtual clock.
60+
61+
`RuntimeCtx.clock` abstracts both wall-clock reads and the periodic scheduler
62+
behind `Sub.Every`. Production uses `SystemClock` (real time, background
63+
executor). `TestRuntimeCtx` substitutes a `ManualClock` whose time only moves
64+
when you call `advanceTime`:
65+
66+
```scala
67+
val driver = TuiTestDriver(DigitalClock.App, width = 44, height = 20)
68+
driver.init()
69+
assert(driver.model.clock.value == "00:00") // ManualClock starts at epoch 0, UTC
70+
71+
driver.advanceTime(1.second) // fires exactly one 1s tick
72+
assert(driver.model.clock.value == "00:00:01")
73+
74+
driver.advanceTime(3.seconds) // three more ticks
75+
assert(driver.model.clock.value == "00:00:04")
76+
```
77+
78+
`advanceTime` starts the registered timer subs against the `ManualClock` (no
79+
threads spawn), fires every tick that falls due, and applies the resulting
80+
messages through `app.update` — just as the runtime would when its scheduler
81+
ticks. Input and resize subs stay dormant.
82+
83+
Tick semantics: a timer with period `p` first fires `p` ms after the advance
84+
that starts it, then every `p` thereafter, so `advanceTime(N * p)` produces
85+
exactly `N` ticks. Unlike the real scheduler there is no immediate fire at
86+
scheduling time — this gives an exact advance→tick correspondence.
87+
88+
For apps that **display** wall-clock time (e.g. `DigitalClock`), read it
89+
through `ctx.clock` rather than `LocalTime.now()` so the `ManualClock` drives
90+
it deterministically:
91+
92+
```scala
93+
private def currentTime(ctx: RuntimeCtx[Msg]): String =
94+
LocalTime.ofInstant(ctx.clock.instant(), ctx.clock.zone).toString
95+
```
96+
97+
`ManualClock` can be constructed directly in lower-level tests:
98+
99+
```scala
100+
val clock = new ManualClock(startMillis = 0L) // zone defaults to UTC
101+
clock.schedulePeriodic(100L, () => tick())
102+
clock.advance(300.millis) // tick() fires 3 times
103+
```
104+
51105
## Asserting on the frame
52106

53107
The simplest assertion is "did the rendered text contain X?":
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package termflow.tui
2+
3+
import java.time.Instant
4+
import java.time.ZoneId
5+
import java.util.concurrent.Executors
6+
import java.util.concurrent.TimeUnit
7+
8+
/**
9+
* Abstraction over wall-clock time and the periodic scheduler that drives
10+
* timer subscriptions ([[Sub.Every]]).
11+
*
12+
* Two concerns are deliberately bundled behind one interface because both are
13+
* "the passage of time" from an app's point of view:
14+
*
15+
* - reading the current instant (apps such as `DigitalClock` display it), and
16+
* - scheduling a callback to run once per elapsed period (how `Sub.Every`
17+
* ticks).
18+
*
19+
* Production code uses [[SystemClock]], which reads the real system time and
20+
* schedules ticks on a background executor. Tests use
21+
* `termflow.testkit.ManualClock`, whose virtual time only advances on explicit
22+
* `advance` calls — making `Sub.Every`-driven apps snapshot-testable without
23+
* real-time delays.
24+
*
25+
* @note SPI. A clock is obtained from [[RuntimeCtx.clock]]; apps and
26+
* subscriptions read it rather than calling `System`/`java.time`
27+
* directly so that the testkit can substitute virtual time.
28+
*/
29+
trait Clock:
30+
31+
/** Current instant. Apps that display wall-clock time read this. */
32+
def instant(): Instant
33+
34+
/** Zone used to derive local date/time values from [[instant]]. */
35+
def zone: ZoneId
36+
37+
/**
38+
* Schedule `task` to run repeatedly, once per `periodMillis` of elapsed
39+
* time. Returns a handle that cancels the schedule.
40+
*
41+
* `periodMillis` must be strictly positive.
42+
*/
43+
def schedulePeriodic(periodMillis: Long, task: () => Unit): Clock.Cancelable
44+
45+
object Clock:
46+
47+
/** Handle returned by [[Clock.schedulePeriodic]] used to stop a schedule. */
48+
trait Cancelable:
49+
def cancel(): Unit
50+
51+
/**
52+
* Default [[Clock]] backed by the real system clock and a per-schedule
53+
* single-threaded executor — the production scheduler behind [[Sub.Every]].
54+
*
55+
* Each call to [[schedulePeriodic]] owns one scheduler thread, matching the
56+
* previous inline `Executors.newSingleThreadScheduledExecutor` wiring; the
57+
* first tick fires immediately (initial delay `0`) exactly as before.
58+
*/
59+
object SystemClock extends Clock:
60+
61+
override def instant(): Instant = Instant.now()
62+
63+
override def zone: ZoneId = ZoneId.systemDefault()
64+
65+
override def schedulePeriodic(periodMillis: Long, task: () => Unit): Clock.Cancelable =
66+
val scheduler = Executors.newSingleThreadScheduledExecutor(ThreadUtils.newThreadFactory())
67+
val handle = scheduler.scheduleAtFixedRate(() => task(), 0L, periodMillis, TimeUnit.MILLISECONDS)
68+
new Clock.Cancelable:
69+
override def cancel(): Unit =
70+
handle.cancel(true): Unit
71+
scheduler.shutdownNow(): Unit
72+
try scheduler.awaitTermination(200L, TimeUnit.MILLISECONDS): Unit
73+
catch {
74+
case _: InterruptedException =>
75+
Thread.currentThread().interrupt()
76+
}

modules/termflow-app/src/main/scala/termflow/tui/Sub.scala

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ object Sub:
7070
*/
7171
trait InputSub[+Msg] extends Sub[Msg]
7272

73+
/**
74+
* Marker for timer subscriptions whose ticks are driven by a [[Clock]]
75+
* ([[Every]] is the only one today).
76+
*
77+
* The testkit uses this marker to tell timers apart from input-/resize-style
78+
* subs so it can start exactly the clock-driven ones against a `ManualClock`
79+
* during `advanceTime`, while leaving thread-spawning subs dormant.
80+
*/
81+
trait TimerSub[+Msg] extends Sub[Msg]
82+
7383
private def autoRegisterIfRuntimeCtx[Msg](sub: Sub[Msg], sink: EventSink[Msg]): Sub[Msg] =
7484
sink match
7585
case ctx: RuntimeCtx[?] =>
@@ -90,53 +100,44 @@ object Sub:
90100
* Create a timer subscription that fires at a fixed interval.
91101
*
92102
* Each tick publishes `Cmd.GCmd(msg())` through `sink`. The thunk runs on
93-
* a single background scheduler thread, so `msg()` must not block.
103+
* the scheduler owned by the sink's [[Clock]], so `msg()` must not block.
94104
*
95-
* The scheduler is constructed lazily by [[Sub.start]] rather than in this
96-
* factory, so `TestRuntimeCtx` can keep timers dormant during snapshot
97-
* tests (it does not call `start()` on registered subs). For all
98-
* production paths (`LocalCmdBus.registerSub`, bare `EventSink` sinks)
99-
* `start()` runs synchronously immediately after construction, preserving
100-
* the original eager-start behaviour.
105+
* Ticks are scheduled through the clock resolved from `sink`: a
106+
* [[RuntimeCtx]] supplies its [[RuntimeCtx.clock]] (the real [[SystemClock]]
107+
* in production, a `ManualClock` under the testkit), while a bare
108+
* `EventSink` falls back to [[SystemClock]]. The schedule is created lazily
109+
* by [[Sub.start]] rather than in this factory, so `TestRuntimeCtx` can keep
110+
* timers dormant during snapshot tests (it does not call `start()` on
111+
* registered subs). For all production paths (`LocalCmdBus.registerSub`,
112+
* bare `EventSink` sinks) `start()` runs synchronously immediately after
113+
* construction, preserving the original eager-start behaviour.
101114
*
102115
* @param millis Interval between ticks, in milliseconds.
103116
* @param msg Thunk producing the next message on each tick.
104117
* @param sink Where to publish ticks. When called with a [[RuntimeCtx]]
105118
* the subscription auto-registers for cleanup on exit.
106119
*/
107120
def Every[Msg](millis: Long, msg: () => Msg, sink: EventSink[Msg]): Sub[Msg] =
108-
val sub = new Sub[Msg]:
109-
private val lock = new Object
110-
@volatile private var active = true
111-
@volatile private var scheduler: java.util.concurrent.ScheduledExecutorService = null
112-
@volatile private var handle: java.util.concurrent.ScheduledFuture[?] = null
121+
val clock = sink match
122+
case ctx: RuntimeCtx[?] => ctx.clock
123+
case _ => SystemClock
124+
val sub = new TimerSub[Msg]:
125+
private val lock = new Object
126+
@volatile private var active = true
127+
@volatile private var handle: Clock.Cancelable = null
113128

114129
override def start(): Unit =
115130
lock.synchronized {
116-
if scheduler == null && active then
117-
val s = Executors.newSingleThreadScheduledExecutor(ThreadUtils.newThreadFactory())
118-
scheduler = s
119-
handle = s.scheduleAtFixedRate(
120-
() => sink.publish(Cmd.GCmd[Msg](msg())),
121-
0L,
122-
millis,
123-
TimeUnit.MILLISECONDS
124-
)
131+
if handle == null && active then
132+
handle = clock.schedulePeriodic(millis, () => sink.publish(Cmd.GCmd[Msg](msg())))
125133
}
126134

127135
override def isActive: Boolean = active
128136

129137
override def cancel(): Unit =
130138
lock.synchronized {
131139
active = false
132-
if handle != null then handle.cancel(true): Unit
133-
if scheduler != null then
134-
scheduler.shutdownNow(): Unit
135-
try scheduler.awaitTermination(200L, TimeUnit.MILLISECONDS): Unit
136-
catch {
137-
case _: InterruptedException =>
138-
Thread.currentThread().interrupt()
139-
}
140+
if handle != null then handle.cancel()
140141
}
141142
autoRegisterIfRuntimeCtx(sub, sink)
142143

modules/termflow-app/src/main/scala/termflow/tui/TuiRuntime.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ trait RuntimeCtx[Msg] extends EventSink[Msg]:
6363
*/
6464
def registerSub(sub: Sub[Msg]): Sub[Msg]
6565

66+
/**
67+
* Clock used to read wall-clock time and to schedule timer subscriptions
68+
* ([[Sub.Every]]). Defaults to the real [[SystemClock]]; the testkit's
69+
* `TestRuntimeCtx` overrides it with a `ManualClock` so timers advance
70+
* deterministically under test.
71+
*/
72+
def clock: Clock = SystemClock
73+
6674
/**
6775
* Read side of the command bus consumed by the runtime loop.
6876
*

modules/termflow-sample/src/main/scala/termflow/apps/clock/DigitalClock.scala

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ object DigitalClock:
4141
import Msg._
4242

4343
object App extends TuiApp[Model, Msg]:
44+
// Read wall-clock time through the runtime's Clock rather than
45+
// LocalTime.now() directly, so the testkit's ManualClock drives it
46+
// deterministically (see Sub.Every / TuiTestDriver.advanceTime).
47+
private def currentTime(ctx: RuntimeCtx[Msg]): String =
48+
LocalTime.ofInstant(ctx.clock.instant(), ctx.clock.zone).toString
49+
4450
private def syncTerminalSize(m: Model, ctx: RuntimeCtx[Msg]): Model =
4551
val w = ctx.terminal.width
4652
val h = ctx.terminal.height
@@ -53,7 +59,7 @@ object DigitalClock:
5359
terminalHeight = ctx.terminal.height,
5460
SubSource[String](
5561
Sub.Every(1000, () => Tick, ctx),
56-
LocalTime.now().toString
62+
currentTime(ctx)
5763
),
5864
List.empty,
5965
None,
@@ -65,7 +71,7 @@ object DigitalClock:
6571
val sized = syncTerminalSize(m, ctx)
6672
msg match
6773
case Tick =>
68-
sized.copy(clock = sized.clock.copy(value = LocalTime.now().toString)).tui
74+
sized.copy(clock = sized.clock.copy(value = currentTime(ctx))).tui
6975

7076
case StartClock =>
7177
if sized.clock.sub.isActive then sized.copy(error = Some("Clock already running")).tui
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# width=44 height=12
2+
# cursor=6,11
3+
|┌──────────────────────────────────────┐ |
4+
|│Time: 00:00:01 │ |
5+
|│──────────────────────────────────────│ |
6+
|│Type a message and press Enter │ |
7+
|│──────────────────────────────────────│ |
8+
|│Commands: │ |
9+
|│ start | startclock -> start ticking │ |
10+
|│ stop | stopclock -> stop ticking │ |
11+
|│ exit -> quit │ |
12+
|└──────────────────────────────────────┘ |
13+
| []> |
14+
| |
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# width=44 height=12
2+
# cursor=6,11
3+
|┌──────────────────────────────────────┐ |
4+
|│Time: 00:00 │ |
5+
|│──────────────────────────────────────│ |
6+
|│Type a message and press Enter │ |
7+
|│──────────────────────────────────────│ |
8+
|│Commands: │ |
9+
|│ start | startclock -> start ticking │ |
10+
|│ stop | stopclock -> stop ticking │ |
11+
|│ exit -> quit │ |
12+
|└──────────────────────────────────────┘ |
13+
| []> |
14+
| |
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# width=60 height=20
2+
# cursor=6,19
3+
|┌──────────────────────────────────────────────────────┐ |
4+
|│ │ |
5+
|│ │ |
6+
|│ **** **** │ |
7+
|│ ** ** ** ** │ |
8+
|│ .... * *... ...* * ....│ |
9+
|│ . .* . * . . * . *. │ |
10+
|│ . *. . * . . * . .* │ |
11+
|│ .. * .. .. * .. .. * .. .. * │ |
12+
|│. ** ... ** ... ** ... ** │ |
13+
|│ * * * *│ |
14+
|│***** ***** │ |
15+
|│ │ |
16+
|│ │ |
17+
|└──────────────────────────────────────────────────────┘ |
18+
| Commands: pause -> pause animation |
19+
| resume -> resume animation |
20+
| faster | slower | exit |
21+
| []> |
22+
| |
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# width=60 height=20
2+
# cursor=6,19
3+
|┌──────────────────────────────────────────────────────┐ |
4+
|│ │ |
5+
|│ │ |
6+
|│ **** **** │ |
7+
|│ ** ** ** ** │ |
8+
|│ ... * *.. ...* * │ |
9+
|│ . ..* . * .. . *.. *│ |
10+
|│* .. **. .. * . .. ** . ..│ |
11+
|│ *. . * .. . * .. . * .. . │ |
12+
|│ *... * ... * ... * ... │ |
13+
|│ ** ** ** ** │ |
14+
|│ **** **** │ |
15+
|│ │ |
16+
|│ │ |
17+
|└──────────────────────────────────────────────────────┘ |
18+
| Commands: pause -> pause animation |
19+
| resume -> resume animation |
20+
| faster | slower | exit |
21+
| []> |
22+
| |

0 commit comments

Comments
 (0)