docs/superpowers/plans/2026-04-19-scenario-parser-state.md
Ref: Size: 41.7 KiB
# Scenario Parser + State Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build `src/scenario.zig` — the parser, state machine, and cell-region predicate evaluator for the scenario runner. Everything in this plan is **pure** (no Vulkan, no Wayland, no main loop). All side-effectful work (pty writes, offscreen renders) is represented as callbacks that tests mock and Plan 3 wires for real.
**Architecture:** Three independently-testable units in one file — `Scenario` (the parsed directive list), `ScenarioState` (the tick-driven execution state), and `evalPredicate` (cell-region assertions). Parser uses a hand-rolled line-based tokenizer. State machine is callback-driven so it has zero runtime dependencies beyond `std` + `imgdiff` + `png`.
**Tech Stack:** Zig 0.15, stdlib, `src/imgdiff.zig` (from Plan 1), `png` module.
**Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md`
---
## Reference facts
- Base commit: `9fae352`. `src/imgdiff.zig` exists and is importable as `@import("imgdiff")` (Plan 1 already wired this).
- `png.Image` struct is `{ width: u32, height: u32, pixels: []u8 }` where pixels are BGRA or RGBA (4 bytes per pixel).
- Existing harness `src/capture.zig` forces `CAPTURE_COLS=80, CAPTURE_ROWS=24`. Scenario runner v1 pins to the same dimensions (spec says `size` is informational in v1).
- No existing scenario or similar scaffolding in the repo.
- Tests live inline in the same file (see `src/vk_sync.zig`, `src/imgdiff.zig` for the pattern).
- Scenario file format is defined in the spec's **Scenario file format** section. Review before implementing.
---
## File structure after this plan
- **`src/scenario.zig`** (NEW) — `Directive` enum, `Scenario` struct, `parse`, `ScenarioState`, `tick`, `isDone`, `evalPredicate`, inline tests.
- **`build.zig`** (MODIFIED) — new `scenario_mod` module import for inline tests; wired into `test_step`. No consumers yet (main.zig touches happen in Plan 3).
---
## Task 1: Directive types + parser
**Files:**
- Create: `src/scenario.zig`
- Modify: `build.zig` — wire `scenario_mod` as a module + its test target
**Goal:** `parse(alloc, source, *Diagnostic) !Scenario` produces a typed directive list from a scenario text buffer. Every directive from the spec is recognized. Parse errors are returned with line number and static message.
- [ ] **Step 1: Create `src/scenario.zig` with the types, parser signature, and failing tests.**
Full initial contents:
```zig
//! Scenario runner — parser, state machine, and cell predicate evaluator.
//!
//! All APIs in this file are pure. Side-effectful work (pty writes,
//! offscreen renders) is represented as callbacks that the caller
//! supplies. See docs/superpowers/specs/2026-04-19-scenario-runner-design.md.
const std = @import("std");
const png = @import("png");
const imgdiff = @import("imgdiff");
// ---------------------------------------------------------------
// Directive types
// ---------------------------------------------------------------
pub const Predicate = enum {
cell_matches_golden,
cursor_block_at,
cursor_bar_at,
cursor_underline_at,
cell_empty,
};
pub const Directive = union(enum) {
sleep: u64, // ms
sleep_until_flip,
bytes: []const u8, // owned by Scenario.arena
capture: []const u8, // label, owned by Scenario.arena
assert_cell: AssertCell,
assert_cell_at: AssertCellAt,
pub const AssertCell = struct {
row: u16,
col: u16,
pred: Predicate,
};
pub const AssertCellAt = struct {
label: []const u8, // owned by Scenario.arena
row: u16,
col: u16,
pred: Predicate,
};
};
pub const Scenario = struct {
cols: u16,
rows: u16,
timeout_ms: u64,
directives: []const Directive,
// Owns directive payload slices.
arena: std.heap.ArenaAllocator,
pub fn deinit(self: *Scenario) void {
self.arena.deinit();
}
};
pub const Diagnostic = struct {
line: usize = 0,
message: []const u8 = "", // static string
};
pub const ParseError = error{ParseFailed} || std.mem.Allocator.Error;
pub fn parse(
gpa: std.mem.Allocator,
source: []const u8,
diag: *Diagnostic,
) ParseError!Scenario {
_ = gpa;
_ = source;
_ = diag;
@compileError("parse: not yet implemented");
}
// ---------------------------------------------------------------
// Parser tests
// ---------------------------------------------------------------
fn parseOk(source: []const u8) !Scenario {
var diag: Diagnostic = .{};
return parse(std.testing.allocator, source, &diag) catch |err| {
std.debug.print("unexpected parse error at line {}: {s}\n", .{ diag.line, diag.message });
return err;
};
}
fn parseErr(source: []const u8) !Diagnostic {
var diag: Diagnostic = .{};
const result = parse(std.testing.allocator, source, &diag);
try std.testing.expectError(error.ParseFailed, result);
return diag;
}
test "parse: minimum valid scenario" {
var s = try parseOk(
\\size 80 24
\\timeout 1000ms
);
defer s.deinit();
try std.testing.expectEqual(@as(u16, 80), s.cols);
try std.testing.expectEqual(@as(u16, 24), s.rows);
try std.testing.expectEqual(@as(u64, 1000), s.timeout_ms);
try std.testing.expectEqual(@as(usize, 0), s.directives.len);
}
test "parse: comments and blank lines ignored" {
var s = try parseOk(
\\# leading comment
\\
\\size 80 24
\\ # indented comment
\\timeout 500ms
\\
);
defer s.deinit();
try std.testing.expectEqual(@as(u16, 80), s.cols);
}
test "parse: missing size directive fails at line 1" {
const diag = try parseErr(
\\timeout 500ms
);
try std.testing.expectEqual(@as(usize, 1), diag.line);
try std.testing.expect(std.mem.indexOf(u8, diag.message, "size") != null);
}
test "parse: missing timeout directive fails" {
const diag = try parseErr(
\\size 80 24
);
try std.testing.expect(std.mem.indexOf(u8, diag.message, "timeout") != null);
}
test "parse: unknown directive fails at its line" {
const diag = try parseErr(
\\size 80 24
\\timeout 500ms
\\what-is-this
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
try std.testing.expect(std.mem.indexOf(u8, diag.message, "unknown directive") != null);
}
test "parse: sleep directive" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\sleep 250ms
\\sleep 1s
);
defer s.deinit();
try std.testing.expectEqual(@as(usize, 2), s.directives.len);
try std.testing.expectEqual(@as(u64, 250), s.directives[0].sleep);
try std.testing.expectEqual(@as(u64, 1000), s.directives[1].sleep);
}
test "parse: sleep-until-flip directive" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\sleep-until-flip
);
defer s.deinit();
try std.testing.expectEqual(@as(usize, 1), s.directives.len);
try std.testing.expect(s.directives[0] == .sleep_until_flip);
}
test "parse: bytes directive — plain ASCII" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\bytes "hello"
);
defer s.deinit();
try std.testing.expectEqualSlices(u8, "hello", s.directives[0].bytes);
}
test "parse: bytes directive — escape set" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\bytes "a\eb\nc\td\\e\"f\r"
);
defer s.deinit();
// Expected bytes: 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D
try std.testing.expectEqualSlices(
u8,
&[_]u8{ 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D },
s.directives[0].bytes,
);
}
test "parse: bytes directive — unclosed string fails" {
const diag = try parseErr(
\\size 80 24
\\timeout 5000ms
\\bytes "no-close
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
}
test "parse: bytes directive — unknown escape fails" {
const diag = try parseErr(
\\size 80 24
\\timeout 5000ms
\\bytes "bad \q escape"
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
try std.testing.expect(std.mem.indexOf(u8, diag.message, "escape") != null);
}
test "parse: bytes-hex directive" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\bytes-hex 1B 5B 35 20 71
);
defer s.deinit();
try std.testing.expectEqualSlices(u8, &[_]u8{ 0x1B, 0x5B, 0x35, 0x20, 0x71 }, s.directives[0].bytes);
}
test "parse: bytes-hex accepts mixed case" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\bytes-hex aB cD eF
);
defer s.deinit();
try std.testing.expectEqualSlices(u8, &[_]u8{ 0xAB, 0xCD, 0xEF }, s.directives[0].bytes);
}
test "parse: bytes-hex rejects odd-length token" {
const diag = try parseErr(
\\size 80 24
\\timeout 5000ms
\\bytes-hex 1B 5
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
}
test "parse: bytes-hex rejects non-hex characters" {
const diag = try parseErr(
\\size 80 24
\\timeout 5000ms
\\bytes-hex 1B ZZ
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
}
test "parse: capture directive — label stored" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\capture on-phase
);
defer s.deinit();
try std.testing.expectEqualSlices(u8, "on-phase", s.directives[0].capture);
}
test "parse: capture directive — empty label fails" {
const diag = try parseErr(
\\size 80 24
\\timeout 5000ms
\\capture
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
}
test "parse: assert-cell directive" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\assert-cell 5 3 cursor-bar-at
);
defer s.deinit();
const a = s.directives[0].assert_cell;
try std.testing.expectEqual(@as(u16, 5), a.row);
try std.testing.expectEqual(@as(u16, 3), a.col);
try std.testing.expectEqual(Predicate.cursor_bar_at, a.pred);
}
test "parse: assert-cell-at directive" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\assert-cell-at on-phase 5 3 cursor-block-at
);
defer s.deinit();
const a = s.directives[0].assert_cell_at;
try std.testing.expectEqualSlices(u8, "on-phase", a.label);
try std.testing.expectEqual(@as(u16, 5), a.row);
try std.testing.expectEqual(@as(u16, 3), a.col);
try std.testing.expectEqual(Predicate.cursor_block_at, a.pred);
}
test "parse: all five predicates round-trip through assert-cell" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\assert-cell 0 0 cell-matches-golden
\\assert-cell 0 0 cursor-block-at
\\assert-cell 0 0 cursor-bar-at
\\assert-cell 0 0 cursor-underline-at
\\assert-cell 0 0 cell-empty
);
defer s.deinit();
try std.testing.expectEqual(Predicate.cell_matches_golden, s.directives[0].assert_cell.pred);
try std.testing.expectEqual(Predicate.cursor_block_at, s.directives[1].assert_cell.pred);
try std.testing.expectEqual(Predicate.cursor_bar_at, s.directives[2].assert_cell.pred);
try std.testing.expectEqual(Predicate.cursor_underline_at, s.directives[3].assert_cell.pred);
try std.testing.expectEqual(Predicate.cell_empty, s.directives[4].assert_cell.pred);
}
test "parse: unknown predicate fails at line" {
const diag = try parseErr(
\\size 80 24
\\timeout 5000ms
\\assert-cell 0 0 totally-bogus
);
try std.testing.expectEqual(@as(usize, 3), diag.line);
}
```
- [ ] **Step 2: Wire `src/scenario.zig` as a module in build.zig.**
Insert after the imgdiff library wiring (after `build.zig:388` or equivalent, adjust if line numbers shifted):
```zig
// scenario — DSL parser, state machine, predicate evaluator (pure)
const scenario_mod = b.createModule(.{
.root_source_file = b.path("src/scenario.zig"),
.target = target,
.optimize = optimize,
});
scenario_mod.addImport("png", png_mod);
scenario_mod.addImport("imgdiff", imgdiff_lib_mod);
const scenario_test_mod = b.createModule(.{
.root_source_file = b.path("src/scenario.zig"),
.target = target,
.optimize = optimize,
});
scenario_test_mod.addImport("png", png_mod);
scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod);
const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod });
test_step.dependOn(&b.addRunArtifact(scenario_tests).step);
```
- [ ] **Step 3: Run tests to confirm compile failure on the `parse` stub.**
Run:
```bash
cd /home/xanderle/code/rad/waystty
zig build test 2>&1 | tail -10
```
Expected: `@compileError("parse: not yet implemented")` fires. That's the intentional failure proving the test module links against the new symbols.
- [ ] **Step 4: Implement `parse`.**
Replace the stubbed `parse` body with a working implementation. Guidance:
- Line-based: split source on `\n`, track line number starting at 1.
- Skip lines whose trimmed content is empty or begins with `#`.
- Tokenize the first word of each line; dispatch on it.
- `size` must be the first non-comment/non-blank directive. If another directive appears first, diag.line = that line, message = "expected size directive first".
- `timeout` must appear before any body directive. Track `has_size` and `has_timeout` booleans.
- `sleep 250ms` / `sleep 1s` / `sleep 2500ms` — accept `NNms` or `NNs`. Units mandatory.
- `bytes "STR"` — strict double-quoted string; escape set `\n`, `\t`, `\r`, `\e` (→ 0x1B), `\\`, `\"`. Any other backslash is an error.
- `bytes-hex HH HH HH ...` — each token must be exactly 2 hex chars. Mixed case allowed.
- `capture LABEL` — label is a single token, `[a-zA-Z0-9_-]+` (tight — rejects `/` and whitespace). Non-empty.
- `assert-cell R C PRED` — three args, PRED parsed from a small table.
- `assert-cell-at LABEL R C PRED` — four args.
- `sleep-until-flip` — no args.
- Directive payload slices (bytes, capture labels, assert-cell-at labels) are allocated from `Scenario.arena` via `arena.allocator().dupe`.
- On any error, set `diag.line` and `diag.message` (static string) and return `error.ParseFailed`. Deinit the arena cleanly before returning.
- On success, the directive list slice is allocated from the arena and stored on `Scenario`.
Keep the implementation straightforward; don't build a full lexer framework. ~200-300 lines is the target.
- [ ] **Step 5: Run tests.**
```bash
zig build test 2>&1 | tail -30
```
Expected: all scenario-parser tests pass. Existing tests unaffected.
- [ ] **Step 6: Commit.**
```bash
git add src/scenario.zig build.zig
git commit -m "$(cat <<'EOF'
scenario: add parser for the scenario runner DSL
Parses size/timeout header + sleep/sleep-until-flip/bytes/
bytes-hex/capture/assert-cell/assert-cell-at directives. Errors
return a Diagnostic with line number and static message.
Pure — no I/O, no state machine yet. Plan 2 Task 2 builds on this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: ScenarioState + tick + isDone
**Files:**
- Modify: `src/scenario.zig` — add `ScenarioState`, `tick`, `isDone`, and tests with a fake-clock + mock IO.
**Goal:** A deterministic tick-driven state machine that walks the parsed directive list on a monotonic timeline, invoking caller-supplied callbacks for side effects.
- [ ] **Step 1: Append types, stubs, and failing tests to `src/scenario.zig`.**
Add the following after the existing code:
```zig
// ---------------------------------------------------------------
// Tick state machine
// ---------------------------------------------------------------
/// Caller-supplied side-effects. The state machine itself is pure.
pub const TickIO = struct {
ctx: *anyopaque,
/// Write bytes into the terminal's VT parser (caller decides whether
/// that goes through pty master or direct term.write).
write_bytes: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!void,
/// Perform an offscreen render and return an owned png.Image.
/// The Scenario will take ownership; caller must not free.
capture: *const fn (ctx: *anyopaque, label: []const u8) anyerror!png.Image,
/// Return true if the blink timer flipped since the previous tick.
/// Used by sleep-until-flip. If blink isn't armed at all, calls
/// to this return false forever and sleep-until-flip will time out.
blink_just_flipped: *const fn (ctx: *anyopaque) bool,
};
pub const TickError = error{
ScenarioTimeout,
SleepUntilFlipTimeout,
AssertFailed,
PredicateOnMissingLabel,
} || std.mem.Allocator.Error || anyerror; // callbacks can surface anyerror
pub const TickOutcome = enum {
working, // directives remain; call tick again later
done, // no more directives
};
pub const ScenarioState = struct {
scenario: *const Scenario,
cursor: usize, // index into scenario.directives
origin_ns: i128, // monotonic wall-clock at start
deadline_ns: i128, // origin + timeout + slack; exceeding this → TickError.ScenarioTimeout
scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
alloc: std.mem.Allocator,
pub fn init(
alloc: std.mem.Allocator,
scenario: *const Scenario,
origin_ns: i128,
) ScenarioState {
return .{
.scenario = scenario,
.cursor = 0,
.origin_ns = origin_ns,
.deadline_ns = origin_ns + @as(i128, scenario.timeout_ms) * std.time.ns_per_ms,
.scheduled_offset_ns = 0,
.sleep_until_flip_started_ns = null,
.captures = .{},
.alloc = alloc,
};
}
pub fn deinit(self: *ScenarioState) void {
var it = self.captures.iterator();
while (it.next()) |entry| {
self.alloc.free(entry.value_ptr.pixels);
}
self.captures.deinit(self.alloc);
}
pub fn isDone(self: *const ScenarioState) bool {
return self.cursor >= self.scenario.directives.len;
}
pub fn tick(self: *ScenarioState, now_ns: i128, io: TickIO) TickError!TickOutcome {
_ = self;
_ = now_ns;
_ = io;
@compileError("tick: not yet implemented");
}
};
// ---------------------------------------------------------------
// Tick tests
// ---------------------------------------------------------------
const TestIO = struct {
writes: std.ArrayListUnmanaged(u8) = .{},
captures_called: std.ArrayListUnmanaged([]const u8) = .{},
flip_stub: bool = false,
alloc: std.mem.Allocator,
pub fn writeBytes(ctx: *anyopaque, bytes: []const u8) anyerror!void {
const self: *TestIO = @ptrCast(@alignCast(ctx));
try self.writes.appendSlice(self.alloc, bytes);
}
pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
const self: *TestIO = @ptrCast(@alignCast(ctx));
const label_copy = try self.alloc.dupe(u8, label);
try self.captures_called.append(self.alloc, label_copy);
// Return a 1x1 white pixel that the Scenario takes ownership of.
const pixels = try self.alloc.alloc(u8, 4);
pixels[0] = 255;
pixels[1] = 255;
pixels[2] = 255;
pixels[3] = 255;
return .{ .width = 1, .height = 1, .pixels = pixels };
}
pub fn flipCb(ctx: *anyopaque) bool {
const self: *TestIO = @ptrCast(@alignCast(ctx));
const v = self.flip_stub;
self.flip_stub = false;
return v;
}
pub fn io(self: *TestIO) TickIO {
return .{
.ctx = self,
.write_bytes = writeBytes,
.capture = captureCb,
.blink_just_flipped = flipCb,
};
}
pub fn deinit(self: *TestIO) void {
for (self.captures_called.items) |lbl| self.alloc.free(lbl);
self.captures_called.deinit(self.alloc);
self.writes.deinit(self.alloc);
}
};
test "tick: empty scenario is immediately done" {
var s = try parseOk(
\\size 80 24
\\timeout 1000ms
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
try std.testing.expect(state.isDone());
}
test "tick: bytes directive fires write_bytes immediately" {
var s = try parseOk(
\\size 80 24
\\timeout 1000ms
\\bytes "abc"
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
const r = try state.tick(0, tio.io());
try std.testing.expectEqual(TickOutcome.done, r);
try std.testing.expectEqualSlices(u8, "abc", tio.writes.items);
try std.testing.expect(state.isDone());
}
test "tick: sleep holds advancement until time passes" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\sleep 500ms
\\bytes "x"
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
// At t=0, sleep is consumed (moves scheduled offset forward); bytes is held.
const r1 = try state.tick(0, tio.io());
try std.testing.expectEqual(TickOutcome.working, r1);
try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
// At t=250ms, still under scheduled offset — no advance.
const r2 = try state.tick(250 * std.time.ns_per_ms, tio.io());
try std.testing.expectEqual(TickOutcome.working, r2);
try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
// At t=500ms, bytes fires.
const r3 = try state.tick(500 * std.time.ns_per_ms, tio.io());
try std.testing.expectEqual(TickOutcome.done, r3);
try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
}
test "tick: capture calls io.capture and stores the result under the label" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\capture snap1
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
_ = try state.tick(0, tio.io());
try std.testing.expect(state.isDone());
try std.testing.expectEqual(@as(usize, 1), tio.captures_called.items.len);
try std.testing.expectEqualSlices(u8, "snap1", tio.captures_called.items[0]);
try std.testing.expect(state.captures.contains("snap1"));
}
test "tick: scenario timeout fires" {
var s = try parseOk(
\\size 80 24
\\timeout 100ms
\\sleep 500ms
\\bytes "x"
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
// 200ms is past the 100ms scenario timeout.
const r = state.tick(200 * std.time.ns_per_ms, tio.io());
try std.testing.expectError(error.ScenarioTimeout, r);
}
test "tick: sleep-until-flip holds until flip callback returns true" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\sleep-until-flip
\\bytes "x"
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
// No flip yet — tick holds at the sleep-until-flip directive.
tio.flip_stub = false;
const r1 = try state.tick(100 * std.time.ns_per_ms, tio.io());
try std.testing.expectEqual(TickOutcome.working, r1);
try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
// Flip observed — sleep-until-flip advances; bytes fires.
tio.flip_stub = true;
const r2 = try state.tick(200 * std.time.ns_per_ms, tio.io());
try std.testing.expectEqual(TickOutcome.done, r2);
try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
}
test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
var s = try parseOk(
\\size 80 24
\\timeout 10000ms
\\sleep-until-flip
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
// After 2 * 500ms = 1s with no flip observed, fail.
// First tick enters the directive, stamps the start time.
_ = try state.tick(0, tio.io());
// Second tick at t=1.1s — over the 2x blink_period budget.
const r = state.tick(1_100 * std.time.ns_per_ms, tio.io());
try std.testing.expectError(error.SleepUntilFlipTimeout, r);
}
```
- [ ] **Step 2: Run tests to confirm compile failure on the `tick` stub.**
Run:
```bash
zig build test 2>&1 | tail -10
```
Expected: `@compileError("tick: not yet implemented")` fires.
- [ ] **Step 3: Implement `tick`.**
Replace the stubbed body with a working implementation. Guidance:
- Every entry to `tick` first checks `now_ns > deadline_ns` → return `error.ScenarioTimeout`.
- Then loop: while there's a current directive AND its scheduled time has arrived, execute it and advance cursor.
- Directive execution:
- `.sleep N` — advance `scheduled_offset_ns` by `N * ns_per_ms`. Advance cursor.
- `.sleep_until_flip` — on first encounter, set `sleep_until_flip_started_ns = now_ns` and return `.working`. On subsequent encounters: if `io.blink_just_flipped()` returns true, clear the stamp, advance cursor. Else if `now_ns - started > 2 * blink_period_ns` (use a local constant, `500 * ns_per_ms * 2`), return `error.SleepUntilFlipTimeout`. Else return `.working`.
- `.bytes slice` — call `io.write_bytes(io.ctx, slice)`. Advance cursor.
- `.capture label` — call `io.capture(io.ctx, label)`. Store the returned image in `self.captures` (keyed by a duped label — the scenario's arena-owned label may have different lifetime). Advance cursor.
- `.assert_cell` and `.assert_cell_at` — Task 3 handles predicate evaluation; for now stub these as `unreachable`. Tests don't exercise them at this step.
- Return `.working` if cursor < len after the loop; else `.done`.
- [ ] **Step 4: Run tests.**
```bash
zig build test 2>&1 | tail -30
```
Expected: all scenario-tick tests pass. Existing tests unaffected.
- [ ] **Step 5: Commit.**
```bash
git add src/scenario.zig
git commit -m "$(cat <<'EOF'
scenario: add ScenarioState + tick state machine
Tick advances the directive cursor on a monotonic timeline,
invoking caller-supplied callbacks for bytes/capture/flip.
sleep-until-flip rendezvouses with the blink timer via a
caller-observed flag; times out at 2x blink period.
Assert-cell predicates still stubbed — Task 3 wires the
predicate evaluator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Cell-region predicate evaluator
**Files:**
- Modify: `src/scenario.zig` — add `evalPredicate` and predicate-specific tests; wire predicates into the `tick` assert branches.
**Goal:** A pure function `evalPredicate` that takes a captured PNG, a cell coordinate, cell pixel dimensions, and a predicate, and returns a pass/fail outcome. Plug it into `tick`'s assert-cell branches.
- [ ] **Step 1: Add predicate evaluator signature + failing tests.**
Append to `src/scenario.zig`:
```zig
// ---------------------------------------------------------------
// Cell-region predicate evaluator
// ---------------------------------------------------------------
pub const EvalResult = struct {
pass: bool,
/// Short diagnostic for failure case (static string where possible).
reason: []const u8 = "",
};
pub const CellGeom = struct {
cell_w_px: u32,
cell_h_px: u32,
};
/// `golden_for_cell_matches` is only consulted by `cell_matches_golden`;
/// callers may pass null otherwise. When null and pred needs it, result
/// is pass=false with reason="missing golden".
pub fn evalPredicate(
image: png.Image,
row: u16,
col: u16,
geom: CellGeom,
pred: Predicate,
golden_for_cell_matches: ?png.Image,
) EvalResult {
_ = image;
_ = row;
_ = col;
_ = geom;
_ = pred;
_ = golden_for_cell_matches;
@compileError("evalPredicate: not yet implemented");
}
// ---------------------------------------------------------------
// Predicate evaluator tests
// ---------------------------------------------------------------
/// Build a width×height PNG where every pixel is `color` (RGBA).
fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image {
const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4);
var i: usize = 0;
while (i < pixels.len) : (i += 4) {
pixels[i + 0] = color[0];
pixels[i + 1] = color[1];
pixels[i + 2] = color[2];
pixels[i + 3] = color[3];
}
return .{ .width = w, .height = h, .pixels = pixels };
}
test "evalPredicate: cell-empty passes on all-black image" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
try std.testing.expect(r.pass);
}
test "evalPredicate: cell-empty fails on image with bright cell" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
// Make cell (0,0)'s first pixel bright.
img.pixels[0] = 255;
img.pixels[1] = 255;
img.pixels[2] = 255;
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
try std.testing.expect(!r.pass);
}
test "evalPredicate: cursor-block-at passes when cell is mostly bright" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
// Fill cell (0,0)'s 8x16 rect with white.
fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 });
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
try std.testing.expect(r.pass);
}
test "evalPredicate: cursor-block-at fails when cell is dark" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
try std.testing.expect(!r.pass);
}
test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
// Paint only the leftmost 2 pixels of cell (0,0), full height.
var y: u32 = 0;
while (y < 16) : (y += 1) {
var x: u32 = 0;
while (x < 2) : (x += 1) {
const off = (@as(usize, y) * 80 + x) * 4;
img.pixels[off + 0] = 255;
img.pixels[off + 1] = 255;
img.pixels[off + 2] = 255;
}
}
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
try std.testing.expect(r.pass);
}
test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
// Paint only the rightmost 2 pixels of cell (0,0), full height.
var y: u32 = 0;
while (y < 16) : (y += 1) {
var x: u32 = 6;
while (x < 8) : (x += 1) {
const off = (@as(usize, y) * 80 + x) * 4;
img.pixels[off + 0] = 255;
img.pixels[off + 1] = 255;
img.pixels[off + 2] = 255;
}
}
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
try std.testing.expect(!r.pass);
}
test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
defer alloc.free(img.pixels);
// Paint only the bottom 2 rows of cell (0,0), full width.
var y: u32 = 14;
while (y < 16) : (y += 1) {
var x: u32 = 0;
while (x < 8) : (x += 1) {
const off = (@as(usize, y) * 80 + x) * 4;
img.pixels[off + 0] = 255;
img.pixels[off + 1] = 255;
img.pixels[off + 2] = 255;
}
}
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null);
try std.testing.expect(r.pass);
}
test "evalPredicate: cell-matches-golden passes when images match in the cell rect" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
defer alloc.free(img.pixels);
var golden = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
defer alloc.free(golden.pixels);
const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
try std.testing.expect(r.pass);
}
test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
defer alloc.free(img.pixels);
var golden = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
defer alloc.free(golden.pixels);
// Corrupt cell (5,3) in img.
fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 });
const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
try std.testing.expect(!r.pass);
}
test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" {
const alloc = std.testing.allocator;
var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
defer alloc.free(img.pixels);
const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null);
try std.testing.expect(!r.pass);
try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null);
}
fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void {
const start_x: u32 = @as(u32, col) * geom.cell_w_px;
const start_y: u32 = @as(u32, row) * geom.cell_h_px;
var y: u32 = start_y;
while (y < start_y + geom.cell_h_px) : (y += 1) {
var x: u32 = start_x;
while (x < start_x + geom.cell_w_px) : (x += 1) {
const off = (@as(usize, y) * img.width + x) * 4;
img.pixels[off + 0] = color[0];
img.pixels[off + 1] = color[1];
img.pixels[off + 2] = color[2];
img.pixels[off + 3] = color[3];
}
}
}
```
- [ ] **Step 2: Run tests to confirm compile failure on the `evalPredicate` stub.**
```bash
zig build test 2>&1 | tail -10
```
Expected: `@compileError("evalPredicate: not yet implemented")` fires.
- [ ] **Step 3: Implement `evalPredicate`.**
Guidance:
- Cell pixel rect: `x0 = col * cell_w_px`, `y0 = row * cell_h_px`, span = `cell_w_px × cell_h_px`.
- Brightness of a pixel: `(R + G + B) / 3` normalized [0, 1].
- `cell_empty`: every pixel in the cell rect has brightness ≤ 0.1. Pass if so; fail with reason "bright pixel in supposedly empty cell".
- `cursor_block_at`: mean brightness over the cell rect is ≥ 0.5. Pass if so; fail with reason "cell too dark for block cursor".
- `cursor_bar_at`: centroid x of bright pixels (those with brightness ≥ 0.5) falls in the left third of the cell AND at least a threshold count of pixels are bright (`cell_h_px * 2`, matching the 2px bar width). Fail otherwise with reason "bar cursor centroid not at cell left".
- `cursor_underline_at`: centroid y of bright pixels falls in the bottom third of the cell AND bright pixel count ≥ `cell_w_px * 2`. Fail otherwise.
- `cell_matches_golden`: if `golden_for_cell_matches` is null, return pass=false with reason "missing golden". Else compute RMSE over the cell rect using `imgdiff.compare`-style math (copy the pixel-wise math into a helper or reuse `imgdiff.compare` on a cropped rect — simplest is an inline loop). Pass if RMSE ≤ `imgdiff.RMSE_DEFAULT`.
Keep the implementation short. Rough target ~80-100 lines for this entire function + any helpers.
- [ ] **Step 4: Wire evalPredicate into the `tick` assert-cell branches.**
Replace the `unreachable` stubs in `tick`'s `.assert_cell` and `.assert_cell_at` cases with real code:
For `.assert_cell`: evaluate against `self.captures.get(<last_captured_label>)` — need to track the last captured label. Add a field `last_capture_label: ?[]const u8 = null` to `ScenarioState` and update it on every successful `capture`. If null at assert time, return `error.AssertFailed` with a diagnostic (stderr print) noting "assert-cell with no prior capture".
For `.assert_cell_at`: look up `self.captures.get(label)`; if missing, return `error.PredicateOnMissingLabel`.
For `cell_matches_golden`: v1 does NOT load a golden file. Pass `null` for `golden_for_cell_matches`. The predicate will return pass=false with reason "missing golden". This is expected — cell-matches-golden is not useful until Plan 3 wires golden-PNG loading from disk; for Plan 2 scope we just confirm the plumbing exists.
Assertions that fail print a diagnostic via `std.debug.print` and return `error.AssertFailed`. Caller (Plan 3) decides whether to stop or accumulate.
Add a final test that combines parse + state + predicate end-to-end:
```zig
test "tick+eval: assert-cell evaluates against last capture" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\capture snap
\\assert-cell 0 0 cursor-block-at
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
// TestIO.captureCb returns a 1x1 all-white PNG; cursor_block_at should pass
// because a 1x1 white image has mean brightness ~1.0 >= 0.5.
const r = try state.tick(0, tio.io());
try std.testing.expectEqual(TickOutcome.done, r);
}
test "tick+eval: assert-cell-at on missing label errors" {
var s = try parseOk(
\\size 80 24
\\timeout 5000ms
\\assert-cell-at nope 0 0 cell-empty
);
defer s.deinit();
var state = ScenarioState.init(std.testing.allocator, &s, 0);
defer state.deinit();
var tio = TestIO{ .alloc = std.testing.allocator };
defer tio.deinit();
const r = state.tick(0, tio.io());
try std.testing.expectError(error.PredicateOnMissingLabel, r);
}
```
Note: the first test's `cursor-block-at` assertion on a 1×1 white PNG with `cell_w_px=8, cell_h_px=16` is actually going to fail because cell (0,0) reaches pixel coordinates (0..8, 0..16) but the image is only 1×1. Handle image-too-small as a predicate failure gracefully rather than panicking — add a bounds check at the top of `evalPredicate`: if `(col+1)*cell_w_px > image.width or (row+1)*cell_h_px > image.height`, return pass=false with reason "cell out of bounds". Remove or adjust the end-to-end test accordingly — simpler: feed a larger PNG in `TestIO.captureCb` (e.g. 80×24 = 640×384 at 8×16 cells) so real assertions can run against it.
Recommended adjustment: change `TestIO.captureCb` to return a 640×384 all-white PNG. Then `cursor-block-at` on cell (0,0) will pass.
- [ ] **Step 5: Run all tests.**
```bash
zig build test 2>&1 | tail -30
```
Expected: all scenario tests pass. Existing tests unaffected.
- [ ] **Step 6: Commit.**
```bash
git add src/scenario.zig
git commit -m "$(cat <<'EOF'
scenario: add cell-region predicate evaluator + wire into tick
Pure predicate evaluation over a captured PNG. Predicates
cover: cell-empty, cursor-block/bar/underline-at, and
cell-matches-golden (stubbed pass=false until Plan 3
loads goldens from disk).
Tick's assert-cell and assert-cell-at branches now invoke
the evaluator and return AssertFailed / PredicateOnMissingLabel
on failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Post-plan verification
- [ ] `zig build` succeeds.
- [ ] `zig build test` — all existing tests plus all new scenario tests pass (estimated 20-25 scenario tests total).
- [ ] `src/scenario.zig` is importable as `@import("scenario")` by any module declaring it as a dep. Plan 3 will consume this.
- [ ] No main-loop / Vulkan / wayland touchpoints were modified.
---
## Self-review coverage check
Spec "Implementation phasing" step 2: "src/scenario.zig parser + state + tick (pure, no integration). All helpers unit-tested with a fake clock. isDone(), directive scheduling, predicate evaluation — all pure. No main.zig touch yet." — Task 1 (parser), Task 2 (state + tick with fake clock), Task 3 (predicates + wire). Matches.
Spec "Scenario file format" directives all covered: size, timeout, sleep, sleep-until-flip, bytes, bytes-hex, capture, assert-cell, assert-cell-at. Task 1 tests explicitly cover each.
Spec "Timing tolerance" sleep-until-flip semantics: tick-level conditional advance based on flip observation, timeout at 2x blink period. Task 2 tests cover both advance and timeout paths.
Spec "Predicates": cursor-block-at, cursor-bar-at, cursor-underline-at, cell-empty, cell-matches-golden. All in Task 3 with per-predicate unit tests.
Spec "Error handling" contracts (parse errors with line number, timeout, capture errors, predicate failures) — mapped to `error.ParseFailed` + Diagnostic, `error.ScenarioTimeout`, `error.SleepUntilFlipTimeout`, `error.AssertFailed`, `error.PredicateOnMissingLabel`.