From 33bbdb5d9113c65ebe719f3cd5b0a059e7b4bc2b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 13 Aug 2026 18:02:58 +0200 Subject: [PATCH] feat: headless test harness for models 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. --- REFERENCE.md | 46 ++++++ build.zig | 1 + src/core/model.zig | 11 ++ src/root.zig | 4 + src/testing/harness.zig | 341 ++++++++++++++++++++++++++++++++++++++++ tests/harness_tests.zig | 231 +++++++++++++++++++++++++++ 6 files changed, 634 insertions(+) create mode 100644 src/testing/harness.zig create mode 100644 tests/harness_tests.zig diff --git a/REFERENCE.md b/REFERENCE.md index 5722ccb..287c2c3 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -956,6 +956,52 @@ self-correcting at the cost of a screenful of output per frame. The renderer is usable on its own — `zz.FrameRenderer` over any `std.Io.Writer` — if you drive the terminal yourself. +### Testing a model + +`zz.testing.Harness` runs the Model-Update-View cycle with no terminal +attached, so an application's own tests can drive it like a user would: + +```zig +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(key)` / `pressChar(c)` / `typeText(s)` | 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 | +| `model` / `context` | direct access, for setup and assertions | + +Commands that only mean something to a terminal — `set_title`, `println`, +images, mouse toggles — are recorded rather than executed, so a test can assert +on them. + +The frame allocator is reset by `nextFrame` and `advance`, exactly as the +runtime resets it each tick. A model that holds on to a frame-allocated slice +across frames therefore fails here rather than in production. + +Pair it with `expectSnapshot` for golden-file rendering tests: + +```zig +try zz.testing.expectSnapshot(allocator, "tests/snapshots/counter.snap", try h.view()); +``` ### Allocator lifetimes diff --git a/build.zig b/build.zig index bc93d67..776277f 100644 --- a/build.zig +++ b/build.zig @@ -99,6 +99,7 @@ pub fn build(b: *std.Build) void { "tests/program_tests.zig", "tests/command_tests.zig", "tests/render_tests.zig", + "tests/harness_tests.zig", "tests/focus_tests.zig", "tests/modal_tests.zig", "tests/tooltip_tests.zig", diff --git a/src/core/model.zig b/src/core/model.zig index eacdcd0..a40f370 100644 --- a/src/core/model.zig +++ b/src/core/model.zig @@ -30,6 +30,17 @@ pub fn Result(comptime Func: type, comptime Payload: type) type { }; } +/// The error set `Func` can fail with, or an empty set when it cannot fail. +/// Lets a wrapper name its own error type instead of relying on inference, +/// which mutually recursive helpers cannot do. +pub fn ErrorSet(comptime Func: type) type { + const return_type = returnType(Func) orelse return error{}; + return switch (@typeInfo(return_type)) { + .error_union => |eu| eu.error_set, + else => error{}, + }; +} + fn returnType(comptime Func: type) ?type { return switch (@typeInfo(Func)) { .@"fn" => |f| f.return_type, diff --git a/src/root.zig b/src/root.zig index cf50832..d06fdef 100644 --- a/src/root.zig +++ b/src/root.zig @@ -149,6 +149,10 @@ pub const testing = struct { pub const snapshot = @import("testing/snapshot.zig"); pub const expectSnapshot = snapshot.expectSnapshot; pub const expectSnapshotOpts = snapshot.expectSnapshotOpts; + pub const harness = @import("testing/harness.zig"); + pub const Harness = harness.Harness; + pub const HarnessOptions = harness.Options; + pub const stripAnsi = harness.stripAnsi; }; // Components diff --git a/src/testing/harness.zig b/src/testing/harness.zig new file mode 100644 index 0000000..39abdb4 --- /dev/null +++ b/src/testing/harness.zig @@ -0,0 +1,341 @@ +//! Headless driver for a model, for tests. +//! +//! `Program` needs a terminal, which a test does not have. `Harness` runs the +//! same Model-Update-View cycle without one: send messages, render, assert on +//! the frame. Commands the runtime would hand to the terminal are recorded +//! instead of executed, so a test can check that pressing `q` really did +//! return `.quit` or that the title was set. +//! +//! var h = try zz.testing.Harness(Model).init(testing.allocator, testing.io, .{}); +//! defer h.deinit(); +//! +//! try h.start(); +//! try h.pressChar('+'); +//! try testing.expectEqualStrings("Count: 1", try h.plainView()); +//! +//! The frame allocator is reset on every `nextFrame`, exactly as the real +//! runtime resets it every tick, so a model that holds on to a frame-allocated +//! slice across frames shows up here rather than in production. + +const std = @import("std"); +const Context = @import("../core/context.zig").Context; +const Environment = @import("../core/environment.zig").Environment; +const command = @import("../core/command.zig"); +const model_contract = @import("../core/model.zig"); +const keys = @import("../input/keys.zig"); +const mouse_input = @import("../input/mouse.zig"); +const message = @import("../core/message.zig"); + +pub const Options = struct { + width: u16 = 80, + height: u16 = 24, + /// Terminal environment the context is derived from. The default is a + /// blank environment, which resolves to a conservative colour profile. + environment: Environment = .{}, +}; + +/// Drives `Model` through the Elm cycle with no terminal attached. +pub fn Harness(comptime Model: type) type { + model_contract.validate(Model, "Model"); + + const init_fallible = model_contract.returnsError(@TypeOf(Model.init)); + const update_fallible = model_contract.returnsError(@TypeOf(Model.update)); + const view_fallible = model_contract.returnsError(@TypeOf(Model.view)); + + const UserMsg = Model.Msg; + const UserCmd = command.Cmd(UserMsg); + + return struct { + allocator: std.mem.Allocator, + arena: std.heap.ArenaAllocator, + environment: Environment, + context: Context, + model: Model, + + /// Set once the model has returned `.quit`. + quit: bool = false, + /// Commands the runtime would have handed to the terminal, in order. + effects: std.array_list.Managed(UserCmd), + + /// Pending one-shot timer, as a `context.elapsed` deadline. + pending_tick: ?u64 = null, + pending_tick_scheduled_at: u64 = 0, + /// Repeating timer interval, if one was requested. + every_interval: ?u64 = null, + last_every_tick: u64 = 0, + + started: bool = false, + + const Self = @This(); + + /// Everything a harness call can fail with: the model's own errors, + /// plus allocation. Named explicitly because `send` and `process` call + /// each other, and Zig cannot infer two error sets that depend on one + /// another. + pub const Error = std.mem.Allocator.Error || + error{InvalidUtf8} || + model_contract.ErrorSet(@TypeOf(Model.init)) || + model_contract.ErrorSet(@TypeOf(Model.update)) || + model_contract.ErrorSet(@TypeOf(Model.view)); + + pub fn init(allocator: std.mem.Allocator, io: std.Io, options: Options) !Self { + var self = Self{ + .allocator = allocator, + .arena = std.heap.ArenaAllocator.init(allocator), + .environment = options.environment, + .context = undefined, + .model = undefined, + .effects = std.array_list.Managed(UserCmd).init(allocator), + }; + + // Bind the frame allocator lazily: `self` is returned by value, so + // an arena allocator taken here would point at this stack copy. + self.context = Context.init(allocator, allocator, io, &self.environment); + self.context.width = options.width; + self.context.height = options.height; + + return self; + } + + pub fn deinit(self: *Self) void { + if (self.started and @hasDecl(Model, "deinit")) { + self.model.deinit(); + } + self.effects.deinit(); + self.arena.deinit(); + } + + /// Run `Model.init` and process the command it returns. + pub fn start(self: *Self) Error!void { + self.bindFrameAllocator(); + const cmd = if (comptime init_fallible) + try self.model.init(&self.context) + else + self.model.init(&self.context); + self.started = true; + try self.process(cmd); + } + + /// Begin a new frame: advances the clock by `delta_ns` and resets the + /// frame allocator, dropping everything the previous frame allocated. + pub fn nextFrame(self: *Self, delta_ns: u64) void { + _ = self.arena.reset(.retain_capacity); + self.bindFrameAllocator(); + self.context.frame += 1; + self.context.delta = delta_ns; + self.context.elapsed += delta_ns; + } + + /// Deliver a message to `Model.update` and process the command it + /// returns, including any messages that command produces. + pub fn send(self: *Self, user_msg: UserMsg) Error!void { + self.bindFrameAllocator(); + const cmd = if (comptime update_fallible) + try self.model.update(user_msg, &self.context) + else + self.model.update(user_msg, &self.context); + try self.process(cmd); + } + + /// Send a key press. Requires `Msg` to have a `key` field. + pub fn press(self: *Self, key: keys.KeyEvent) Error!void { + comptime requireField("key", "press"); + try self.send(@unionInit(UserMsg, "key", key)); + } + + /// Send a bare character key press. + pub fn pressChar(self: *Self, c: u21) Error!void { + try self.press(.{ .key = .{ .char = c } }); + } + + /// Send each codepoint of `text` as its own key press. + pub fn typeText(self: *Self, text: []const u8) Error!void { + var it = (try std.unicode.Utf8View.init(text)).iterator(); + while (it.nextCodepoint()) |c| { + try self.pressChar(c); + } + } + + /// Send a mouse event. Requires `Msg` to have a `mouse` field. + pub fn mouse(self: *Self, event: mouse_input.MouseEvent) Error!void { + comptime requireField("mouse", "mouse"); + try self.send(@unionInit(UserMsg, "mouse", event)); + } + + /// Resize the terminal. Sends `window_size` when `Msg` has that field. + pub fn resize(self: *Self, width: u16, height: u16) Error!void { + self.context.width = width; + self.context.height = height; + if (@hasField(UserMsg, "window_size")) { + try self.send(@unionInit(UserMsg, "window_size", .{ + .width = width, + .height = height, + })); + } + } + + /// Advance time and deliver whatever timers came due, the way a run of + /// the real event loop would. Requires `Msg` to have a `tick` field. + pub fn advance(self: *Self, delta_ns: u64) Error!void { + comptime requireField("tick", "advance"); + self.nextFrame(delta_ns); + + if (self.pending_tick) |deadline| { + if (self.context.elapsed >= deadline) { + self.pending_tick = null; + try self.sendTick(self.context.elapsed -| self.pending_tick_scheduled_at); + } + } + + if (self.every_interval) |interval| { + if (self.context.elapsed - self.last_every_tick >= interval) { + const tick_delta = self.context.elapsed -| self.last_every_tick; + self.last_every_tick = self.context.elapsed; + try self.sendTick(tick_delta); + } + } + } + + /// Render the current frame. The result lives until the next + /// `nextFrame`/`advance`. + pub fn view(self: *Self) Error![]const u8 { + self.bindFrameAllocator(); + return if (comptime view_fallible) + try self.model.view(&self.context) + else + self.model.view(&self.context); + } + + /// Render the current frame with ANSI escape sequences removed, which + /// is usually what an assertion wants to look at. + pub fn plainView(self: *Self) Error![]const u8 { + return stripAnsi(self.context.allocator, try self.view()); + } + + /// Whether the view contains `needle`, ignoring styling. + pub fn viewContains(self: *Self, needle: []const u8) Error!bool { + return std.mem.indexOf(u8, try self.plainView(), needle) != null; + } + + /// Whether the model has asked to quit. + pub fn hasQuit(self: *const Self) bool { + return self.quit; + } + + /// Commands that were recorded rather than executed. + pub fn recordedEffects(self: *const Self) []const UserCmd { + return self.effects.items; + } + + /// The most recent `.set_title`, if any. + pub fn title(self: *const Self) ?[]const u8 { + var i = self.effects.items.len; + while (i > 0) { + i -= 1; + if (self.effects.items[i] == .set_title) return self.effects.items[i].set_title; + } + return null; + } + + /// Forget the recorded effects, so a later assertion only sees what + /// happened after this point. + pub fn clearEffects(self: *Self) void { + self.effects.clearRetainingCapacity(); + } + + fn sendTick(self: *Self, delta: u64) Error!void { + try self.send(@unionInit(UserMsg, "tick", .{ + .timestamp = @intCast(self.context.elapsed), + .delta = delta, + })); + } + + fn process(self: *Self, cmd: UserCmd) Error!void { + switch (cmd) { + .none => {}, + .quit => self.quit = true, + .tick => |ns| { + self.pending_tick = self.context.elapsed + ns; + self.pending_tick_scheduled_at = self.context.elapsed; + }, + .every => |ns| { + self.every_interval = ns; + self.last_every_tick = self.context.elapsed; + }, + .batch, .sequence => |cmds| { + for (cmds) |c| try self.process(c); + }, + .msg => |m| try self.send(m), + .perform => |func| { + if (func()) |m| try self.send(m); + }, + // Everything else only means something to a terminal. + else => try self.effects.append(cmd), + } + } + + fn bindFrameAllocator(self: *Self) void { + self.context.allocator = self.arena.allocator(); + } + + fn requireField(comptime name: []const u8, comptime method: []const u8) void { + if (!@hasField(UserMsg, name)) { + @compileError("Harness." ++ method ++ "() needs '" ++ @typeName(UserMsg) ++ + "' to have a '" ++ name ++ "' field"); + } + } + }; +} + +/// Copy `input` with ANSI escape sequences removed. +pub fn stripAnsi(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + var out = try std.array_list.Managed(u8).initCapacity(allocator, input.len); + errdefer out.deinit(); + + var i: usize = 0; + while (i < input.len) { + if (input[i] != 0x1b) { + try out.append(input[i]); + i += 1; + continue; + } + + i += 1; + if (i >= input.len) break; + + switch (input[i]) { + '[' => { + i += 1; + while (i < input.len) { + const b = input[i]; + i += 1; + if (b >= 0x40 and b <= 0x7e) break; + } + }, + ']' => { + i += 1; + while (i < input.len) { + if (input[i] == 0x07) { + i += 1; + break; + } + if (input[i] == 0x1b and i + 1 < input.len and input[i + 1] == '\\') { + i += 2; + break; + } + i += 1; + } + }, + else => i += 1, + } + } + + return out.toOwnedSlice(); +} + +test "stripAnsi drops CSI and OSC sequences" { + const allocator = std.testing.allocator; + const out = try stripAnsi(allocator, "\x1b[1;31mred\x1b[0m \x1b]0;title\x07tail"); + defer allocator.free(out); + try std.testing.expectEqualStrings("red tail", out); +} diff --git a/tests/harness_tests.zig b/tests/harness_tests.zig new file mode 100644 index 0000000..7b49269 --- /dev/null +++ b/tests/harness_tests.zig @@ -0,0 +1,231 @@ +//! Tests for the headless model harness — and, through it, a worked example of +//! how an application built on ZigZag can test its own model. + +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); + +/// A small but complete app: keys, a timer, a quit path, and a title command. +const Counter = struct { + count: i32 = 0, + ticks: u32 = 0, + + pub const Msg = union(enum) { + key: zz.KeyEvent, + tick: zz.msg.Tick, + window_size: zz.msg.WindowSize, + bump: i32, + }; + + /// Declared at container scope so the slice `.batch` points at outlives + /// `init`. A `&.{ ... }` literal built inside the function is a stack + /// temporary as soon as any element carries a runtime value. + const startup = [_]zz.Cmd(Msg){ + .{ .set_title = "Counter" }, + .{ .every = 100 * std.time.ns_per_ms }, + }; + + pub fn init(self: *Counter, _: *zz.Context) zz.Cmd(Msg) { + self.* = .{}; + return .{ .batch = &startup }; + } + + pub fn update(self: *Counter, msg: Msg, _: *zz.Context) zz.Cmd(Msg) { + switch (msg) { + .key => |k| switch (k.key) { + .char => |c| switch (c) { + 'q' => return .quit, + '+' => self.count += 1, + '-' => self.count -= 1, + 'r' => return .{ .msg = .{ .bump = -self.count } }, + else => {}, + }, + .up => self.count += 1, + else => {}, + }, + .tick => self.ticks += 1, + .window_size => {}, + .bump => |by| self.count += by, + } + return .none; + } + + pub fn view(self: *const Counter, ctx: *const zz.Context) ![]const u8 { + var style = zz.Style{}; + style = style.bold(true); + style = style.fg(zz.Color.cyan); + style = style.inline_style(true); + + const label = try style.render(ctx.allocator, "Count"); + return std.fmt.allocPrint(ctx.allocator, "{s}: {d}\nticks: {d}\nsize: {d}x{d}", .{ + label, + self.count, + self.ticks, + ctx.width, + ctx.height, + }); + } +}; + +fn newHarness() !zz.testing.Harness(Counter) { + return zz.testing.Harness(Counter).init(testing.allocator, testing.io, .{}); +} + +test "harness renders the initial view" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try testing.expectEqualStrings("Count: 0\nticks: 0\nsize: 80x24", try h.plainView()); +} + +test "key presses reach update" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try h.pressChar('+'); + try h.pressChar('+'); + try h.press(.{ .key = .up }); + + try testing.expectEqual(@as(i32, 3), h.model.count); + try testing.expect(try h.viewContains("Count: 3")); +} + +test "typeText sends one key per codepoint" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try h.typeText("+++-"); + + try testing.expectEqual(@as(i32, 2), h.model.count); +} + +test "a quit command is observable" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try testing.expect(!h.hasQuit()); + try h.pressChar('q'); + try testing.expect(h.hasQuit()); +} + +test "commands that produce messages are followed" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try h.typeText("++++"); + try testing.expectEqual(@as(i32, 4), h.model.count); + + // 'r' returns `.{ .msg = ... }`, which has to be dispatched back into + // update rather than recorded. + try h.pressChar('r'); + try testing.expectEqual(@as(i32, 0), h.model.count); +} + +test "terminal commands are recorded instead of executed" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try testing.expectEqualStrings("Counter", h.title().?); + try testing.expectEqual(@as(usize, 1), h.recordedEffects().len); + + h.clearEffects(); + try testing.expectEqual(@as(usize, 0), h.recordedEffects().len); +} + +test "advance delivers repeating timers" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try h.advance(50 * std.time.ns_per_ms); + try testing.expectEqual(@as(u32, 0), h.model.ticks); + + try h.advance(50 * std.time.ns_per_ms); + try testing.expectEqual(@as(u32, 1), h.model.ticks); + + try h.advance(100 * std.time.ns_per_ms); + try testing.expectEqual(@as(u32, 2), h.model.ticks); +} + +test "resize updates the context and notifies the model" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + try h.resize(120, 40); + + try testing.expectEqual(@as(u16, 120), h.context.width); + try testing.expect(try h.viewContains("size: 120x40")); +} + +test "view keeps its styling; plainView does not" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + const styled = try h.view(); + try testing.expect(std.mem.indexOf(u8, styled, "\x1b[") != null); + try testing.expect(std.mem.indexOf(u8, try h.plainView(), "\x1b[") == null); +} + +test "each frame starts with a fresh allocator" { + var h = try newHarness(); + defer h.deinit(); + try h.start(); + + // Rendering many frames must not grow without bound; the arena is reset + // rather than accumulating one view per frame. + for (0..500) |_| { + h.nextFrame(16 * std.time.ns_per_ms); + _ = try h.view(); + } + + try testing.expectEqual(@as(u64, 500), h.context.frame); +} + +test "harness drives a model with plain (non-fallible) callbacks" { + const Plain = struct { + pressed: bool = false, + + pub const Msg = union(enum) { key: zz.KeyEvent }; + + pub fn init(self: *@This(), _: *zz.Context) zz.Cmd(Msg) { + self.* = .{}; + return .none; + } + + pub fn update(self: *@This(), _: Msg, _: *zz.Context) zz.Cmd(Msg) { + self.pressed = true; + return .none; + } + + pub fn view(self: *const @This(), _: *const zz.Context) []const u8 { + return if (self.pressed) "pressed" else "idle"; + } + }; + + var h = try zz.testing.Harness(Plain).init(testing.allocator, testing.io, .{}); + defer h.deinit(); + try h.start(); + + try testing.expectEqualStrings("idle", try h.view()); + try h.pressChar('x'); + try testing.expectEqualStrings("pressed", try h.view()); +} + +test "harness size is configurable" { + var h = try zz.testing.Harness(Counter).init(testing.allocator, testing.io, .{ + .width = 40, + .height = 10, + }); + defer h.deinit(); + try h.start(); + + try testing.expect(try h.viewContains("size: 40x10")); +}