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
46 changes: 46 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/core/model.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading