46c96419
Plan 2 of 3 for scenario runner: parser + state + predicates
a73x 2026-04-19 09:12
Three tasks in one file (src/scenario.zig): 1. DSL parser returning typed Directive list with line-numbered errors 2. ScenarioState + tick() state machine, callback-driven, fake-clock tested 3. Cell-region predicate evaluator + wiring into tick's assert branches All pure — no main.zig, no Vulkan, no wayland touches. Plan 3 wires this into the real runtime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/docs/superpowers/plans/2026-04-19-scenario-parser-state.md b/docs/superpowers/plans/2026-04-19-scenario-parser-state.md new file mode 100644 index 0000000..9d1bac6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-scenario-parser-state.md @@ -0,0 +1,1134 @@ # 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`.