Skip to content

Timer harness - #217

Merged
rorygraves merged 1 commit into
mainfrom
task/timer-harness
Jun 15, 2026
Merged

Timer harness#217
rorygraves merged 1 commit into
mainfrom
task/timer-harness

Conversation

@rorygraves

@rorygraves rorygraves commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Deterministic timer harness for Sub.Every (issue #140)

What changed

  • Clock interface (termflow-app, Clock.scala): abstracts wall-clock
    reads (instant(), zone) and the periodic scheduler
    (schedulePeriodic(periodMillis, task) -> Cancelable) behind Sub.Every.
    SystemClock is the production impl (real time + per-schedule executor).
  • RuntimeCtx.clock: new member with default = SystemClock (non-breaking
    for existing implementers). TestRuntimeCtx overrides it with a ManualClock.
  • Sub.Every rewired to schedule via the sink's clock (resolved from
    RuntimeCtx.clock, else SystemClock for bare sinks) instead of building its
    own ScheduledExecutorService. Also tagged with a new Sub.TimerSub marker
    (mirrors the existing InputSub marker) so the testkit can start exactly the
    clock-driven subs.
  • ManualClock (termflow-testkit): virtual clock, fires scheduled tasks
    synchronously on advance(...). Default start = epoch 0, zone = UTC.
  • TuiTestDriver.advanceTime(duration | millis): starts registered
    TimerSubs against the manual clock (no threads), advances it, then drains &
    applies the resulting ticks through app.update.
  • DigitalClock now reads time via ctx.clock.instant()/zone instead of
    LocalTime.now() — required to make its display deterministic.
  • Tests: DigitalClockSnapshotSpec, SineWaveAppSnapshotSpec (golden +
    structural, including time-advance), ManualClockSpec (direct clock unit
    coverage). Goldens recorded under termflow-sample test resources.
  • Docs: new "Driving Sub.Every with virtual time" section in
    docs/guide/testing.md; updated notes in RENDER_PIPELINE.md.

Key trade-offs / decisions

  • Tick semantics: ManualClock fires a task once per full elapsed period
    (first fire one period after scheduling; advance(N*p) -> exactly N ticks).
    Deliberately no immediate fire at scheduling time, so the advance→tick mapping
    is exact and easy to reason about in tests.
  • Asymmetry with SystemClock: production SystemClock keeps initialDelay=0
    (immediate first tick) to preserve existing runtime behaviour exactly; the
    ManualClock's "no immediate fire" differs only on the very first tick. Apps
    refresh display each tick, so this is observationally irrelevant; documented.
  • clock has a default on the RuntimeCtx trait so the change is
    source-compatible for the published SPI (downstream RuntimeCtx impls).
  • TimerSub marker vs. introspection: a marker is the idiomatic match for
    the existing InputSub pattern and lets advanceTime start timers without
    also starting thread-spawning input/resize subs.
  • Tie-break when two timers are due at the same virtual instant: scheduling
    order (asserted in ManualClockSpec).

Rejected alternatives

  • java.time.Clock: covers wall time but not the periodic scheduler that
    Sub.Every actually needs, so a custom Clock was cleaner than bolting a
    scheduler beside it.
  • Firing ticks from the test ctx without a clock abstraction: wouldn't make
    DigitalClock's displayed time deterministic (it read LocalTime.now()), and
    the brief explicitly asked to abstract the scheduler.
  • Leaving SystemClock to defer first tick by one period: avoided — keeps a
    real behaviour change out of the production hot path.

Risk

  • Production timer path now flows through SystemClock.schedulePeriodic. Same
    thread-per-schedule model and initialDelay=0 as before; SubSpec real-timer
    tests still pass. Lowest-risk spot is cancellation, which delegates to the same
    executor-shutdown logic as the old inline code.
  • advanceTime only fires Sub.Every; Sub.TerminalResize is intentionally
    left dormant in tests (it would spawn a real executor), matching prior testkit
    behaviour.

Known limitations / follow-ups

Code review (codex) surfaced two items deliberately scoped out of this PR — the
issue's acceptance criteria (ManualClock used by the test driver; DigitalClock
time-advance snapshot test) are met, and these are separable design work:

Abstract the Sub.Every scheduler behind a Clock interface so timer-driven
apps become snapshot-testable without real-time delays.

- Clock (instant/zone + schedulePeriodic) with production SystemClock;
  RuntimeCtx.clock defaults to SystemClock.
- Sub.Every schedules through the ctx clock and is tagged Sub.TimerSub.
- ManualClock (testkit) advances virtual time on explicit calls;
  TestRuntimeCtx uses it and TuiTestDriver exposes advanceTime.
- DigitalClock reads time via ctx.clock for deterministic display.
- Snapshot/time-advance tests for DigitalClock and SineWaveApp, plus
  ManualClock unit tests and golden frames.
- Document virtual-time testing in testing.md / RENDER_PIPELINE.md.
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...rmflow-app/src/main/scala/termflow/tui/Clock.scala 60.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@rorygraves
rorygraves merged commit 98e0faf into main Jun 15, 2026
4 checks passed
rorygraves added a commit that referenced this pull request Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant