Skip to content

feat: headless test harness for models - #133

Merged
meszmate merged 1 commit into
mainfrom
feat/headless-test-harness
Aug 14, 2026
Merged

meszmate merged 1 commit into
mainfrom
feat/headless-test-harness

Conversation

@meszmate

Copy link
Copy Markdown
Owner

Stacked on #132 — base branch is feat/fallible-model-callbacks, so this diff only shows the harness. Merge #132 first and GitHub will retarget this to main.

Also from the codebase review.

Why

Testing a ZigZag application means standing up a terminal, so in practice apps only test their components — never the model that wires them together. The library's own tests/program_tests.zig can only assert on allocator plumbing for the same reason.

zz.testing.Harness runs the same Model-Update-View cycle without a terminal:

test "pressing + increments the counter" {
    var h = try zz.testing.Harness(Model).init(testing.allocator, testing.io, .{});
    defer h.deinit();

    try h.start();
    try h.pressChar('+');
    try h.pressChar('+');

    try testing.expectEqual(@as(i32, 2), h.model.count);
    try testing.expect(try h.viewContains("Count: 2"));
}
start() runs Model.init and processes the command it returns
send(msg) delivers a message, following any command it produces
press / pressChar / typeText key input
mouse(event) mouse input
resize(w, h) changes the context size, sends window_size
advance(ns) moves the clock and delivers timers that came due
nextFrame(ns) starts a frame, resetting the frame allocator
view() / plainView() / viewContains(s) the rendered frame, styled or not
hasQuit() whether the model returned .quit
recordedEffects() / title() commands the terminal would have run

Terminal-only commands (set_title, println, images, mouse toggles) are recorded rather than executed, so a test can assert on them. Timers run off an explicit clock, so a repeating tick is exercised without waiting for it.

The frame allocator is reset by nextFrame/advance exactly as the runtime resets it each tick — a model that keeps a frame-allocated slice across frames fails here instead of in production. (That is the bug fixed in #126.)

Pairs with the existing expectSnapshot for golden-file rendering tests.

Note on the design

The harness deliberately does not reuse Program's command loop. It only handles the commands a model can observe (quit, tick, every, batch, sequence, msg, perform) and records the rest — which is what a test double should do, and keeps it independent of terminal state.

model.ErrorSet names a callback's error set. The harness needs it to declare its own Error type: send and process call each other, and Zig cannot infer two error sets that depend on one another.

Tests

tests/harness_tests.zig — 12 cases covering initial render, key/mouse/type input, quit, .msg commands being re-dispatched rather than recorded, recorded effects, repeating timers, resize, styled vs plain view, frame-allocator reset across 500 frames, and a model with plain (non-fallible) callbacks.

Writing them turned up a separate real bug in the library's batch API — see the follow-up PR.

zig build test and zig build clean on 0.16.0.

@meszmate
meszmate force-pushed the feat/fallible-model-callbacks branch from 1fb58a9 to 7d94d85 Compare August 14, 2026 04:37
@meszmate
meszmate changed the base branch from feat/fallible-model-callbacks to main August 14, 2026 04:42
Testing a ZigZag application meant standing up a terminal, so in
practice applications only tested their components -- never the model
that wires them together. `zz.testing.Harness` runs the same
Model-Update-View cycle without one: send messages, render, assert on
the frame.

Commands that only mean something to a terminal are recorded rather than
executed, so a test can check that pressing 'q' really did return
`.quit`, or that the title was set. Timers are driven by an explicit
clock, so a repeating tick is exercised without waiting for it.

The frame allocator is reset by `nextFrame`/`advance` exactly as the
runtime resets it each tick, so a model that keeps a frame-allocated
slice across frames fails in a test instead of in production.

`model.ErrorSet` names a callback's error set, which lets the harness
declare its own `Error` type -- `send` and `process` call each other,
and Zig cannot infer two error sets that depend on one another.
@meszmate
meszmate force-pushed the feat/headless-test-harness branch from bf9933e to 33bbdb5 Compare August 14, 2026 04:57
@meszmate
meszmate merged commit 99dd623 into main Aug 14, 2026
12 checks passed
@meszmate
meszmate deleted the feat/headless-test-harness branch August 28, 2026 12:09
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