a73x

46c96419

Plan 2 of 3 for scenario runner: parser + state + predicates

a73x   2026-04-19 09:12

Commit message
Plan 2 of 3 for scenario runner: parser + state + predicates

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>

docs/superpowers/plans/2026-04-19-scenario-parser-state.md
Old New
@@ -0,0 +1,1134 @@
1 # Scenario Parser + State Implementation Plan
2
3 > **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.
4
5 **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.
6
7 **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`.
8
9 **Tech Stack:** Zig 0.15, stdlib, `src/imgdiff.zig` (from Plan 1), `png` module.
10
11 **Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md`
12
13 ---
14
15 ## Reference facts
16
17 - Base commit: `9fae352`. `src/imgdiff.zig` exists and is importable as `@import("imgdiff")` (Plan 1 already wired this).
18 - `png.Image` struct is `{ width: u32, height: u32, pixels: []u8 }` where pixels are BGRA or RGBA (4 bytes per pixel).
19 - 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).
20 - No existing scenario or similar scaffolding in the repo.
21 - Tests live inline in the same file (see `src/vk_sync.zig`, `src/imgdiff.zig` for the pattern).
22 - Scenario file format is defined in the spec's **Scenario file format** section. Review before implementing.
23
24 ---
25
26 ## File structure after this plan
27
28 - **`src/scenario.zig`** (NEW) — `Directive` enum, `Scenario` struct, `parse`, `ScenarioState`, `tick`, `isDone`, `evalPredicate`, inline tests.
29 - **`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).
30
31 ---
32
33 ## Task 1: Directive types + parser
34
35 **Files:**
36 - Create: `src/scenario.zig`
37 - Modify: `build.zig` — wire `scenario_mod` as a module + its test target
38
39 **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.
40
41 - [ ] **Step 1: Create `src/scenario.zig` with the types, parser signature, and failing tests.**
42
43 Full initial contents:
44
45 ```zig
46 //! Scenario runner — parser, state machine, and cell predicate evaluator.
47 //!
48 //! All APIs in this file are pure. Side-effectful work (pty writes,
49 //! offscreen renders) is represented as callbacks that the caller
50 //! supplies. See docs/superpowers/specs/2026-04-19-scenario-runner-design.md.
51
52 const std = @import("std");
53 const png = @import("png");
54 const imgdiff = @import("imgdiff");
55
56 // ---------------------------------------------------------------
57 // Directive types
58 // ---------------------------------------------------------------
59
60 pub const Predicate = enum {
61 cell_matches_golden,
62 cursor_block_at,
63 cursor_bar_at,
64 cursor_underline_at,
65 cell_empty,
66 };
67
68 pub const Directive = union(enum) {
69 sleep: u64, // ms
70 sleep_until_flip,
71 bytes: []const u8, // owned by Scenario.arena
72 capture: []const u8, // label, owned by Scenario.arena
73 assert_cell: AssertCell,
74 assert_cell_at: AssertCellAt,
75
76 pub const AssertCell = struct {
77 row: u16,
78 col: u16,
79 pred: Predicate,
80 };
81 pub const AssertCellAt = struct {
82 label: []const u8, // owned by Scenario.arena
83 row: u16,
84 col: u16,
85 pred: Predicate,
86 };
87 };
88
89 pub const Scenario = struct {
90 cols: u16,
91 rows: u16,
92 timeout_ms: u64,
93 directives: []const Directive,
94
95 // Owns directive payload slices.
96 arena: std.heap.ArenaAllocator,
97
98 pub fn deinit(self: *Scenario) void {
99 self.arena.deinit();
100 }
101 };
102
103 pub const Diagnostic = struct {
104 line: usize = 0,
105 message: []const u8 = "", // static string
106 };
107
108 pub const ParseError = error{ParseFailed} || std.mem.Allocator.Error;
109
110 pub fn parse(
111 gpa: std.mem.Allocator,
112 source: []const u8,
113 diag: *Diagnostic,
114 ) ParseError!Scenario {
115 _ = gpa;
116 _ = source;
117 _ = diag;
118 @compileError("parse: not yet implemented");
119 }
120
121 // ---------------------------------------------------------------
122 // Parser tests
123 // ---------------------------------------------------------------
124
125 fn parseOk(source: []const u8) !Scenario {
126 var diag: Diagnostic = .{};
127 return parse(std.testing.allocator, source, &diag) catch |err| {
128 std.debug.print("unexpected parse error at line {}: {s}\n", .{ diag.line, diag.message });
129 return err;
130 };
131 }
132
133 fn parseErr(source: []const u8) !Diagnostic {
134 var diag: Diagnostic = .{};
135 const result = parse(std.testing.allocator, source, &diag);
136 try std.testing.expectError(error.ParseFailed, result);
137 return diag;
138 }
139
140 test "parse: minimum valid scenario" {
141 var s = try parseOk(
142 \\size 80 24
143 \\timeout 1000ms
144 );
145 defer s.deinit();
146 try std.testing.expectEqual(@as(u16, 80), s.cols);
147 try std.testing.expectEqual(@as(u16, 24), s.rows);
148 try std.testing.expectEqual(@as(u64, 1000), s.timeout_ms);
149 try std.testing.expectEqual(@as(usize, 0), s.directives.len);
150 }
151
152 test "parse: comments and blank lines ignored" {
153 var s = try parseOk(
154 \\# leading comment
155 \\
156 \\size 80 24
157 \\ # indented comment
158 \\timeout 500ms
159 \\
160 );
161 defer s.deinit();
162 try std.testing.expectEqual(@as(u16, 80), s.cols);
163 }
164
165 test "parse: missing size directive fails at line 1" {
166 const diag = try parseErr(
167 \\timeout 500ms
168 );
169 try std.testing.expectEqual(@as(usize, 1), diag.line);
170 try std.testing.expect(std.mem.indexOf(u8, diag.message, "size") != null);
171 }
172
173 test "parse: missing timeout directive fails" {
174 const diag = try parseErr(
175 \\size 80 24
176 );
177 try std.testing.expect(std.mem.indexOf(u8, diag.message, "timeout") != null);
178 }
179
180 test "parse: unknown directive fails at its line" {
181 const diag = try parseErr(
182 \\size 80 24
183 \\timeout 500ms
184 \\what-is-this
185 );
186 try std.testing.expectEqual(@as(usize, 3), diag.line);
187 try std.testing.expect(std.mem.indexOf(u8, diag.message, "unknown directive") != null);
188 }
189
190 test "parse: sleep directive" {
191 var s = try parseOk(
192 \\size 80 24
193 \\timeout 5000ms
194 \\sleep 250ms
195 \\sleep 1s
196 );
197 defer s.deinit();
198 try std.testing.expectEqual(@as(usize, 2), s.directives.len);
199 try std.testing.expectEqual(@as(u64, 250), s.directives[0].sleep);
200 try std.testing.expectEqual(@as(u64, 1000), s.directives[1].sleep);
201 }
202
203 test "parse: sleep-until-flip directive" {
204 var s = try parseOk(
205 \\size 80 24
206 \\timeout 5000ms
207 \\sleep-until-flip
208 );
209 defer s.deinit();
210 try std.testing.expectEqual(@as(usize, 1), s.directives.len);
211 try std.testing.expect(s.directives[0] == .sleep_until_flip);
212 }
213
214 test "parse: bytes directive — plain ASCII" {
215 var s = try parseOk(
216 \\size 80 24
217 \\timeout 5000ms
218 \\bytes "hello"
219 );
220 defer s.deinit();
221 try std.testing.expectEqualSlices(u8, "hello", s.directives[0].bytes);
222 }
223
224 test "parse: bytes directive — escape set" {
225 var s = try parseOk(
226 \\size 80 24
227 \\timeout 5000ms
228 \\bytes "a\eb\nc\td\\e\"f\r"
229 );
230 defer s.deinit();
231 // Expected bytes: 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D
232 try std.testing.expectEqualSlices(
233 u8,
234 &[_]u8{ 'a', 0x1B, 'b', 0x0A, 'c', 0x09, 'd', '\\', 'e', '"', 'f', 0x0D },
235 s.directives[0].bytes,
236 );
237 }
238
239 test "parse: bytes directive — unclosed string fails" {
240 const diag = try parseErr(
241 \\size 80 24
242 \\timeout 5000ms
243 \\bytes "no-close
244 );
245 try std.testing.expectEqual(@as(usize, 3), diag.line);
246 }
247
248 test "parse: bytes directive — unknown escape fails" {
249 const diag = try parseErr(
250 \\size 80 24
251 \\timeout 5000ms
252 \\bytes "bad \q escape"
253 );
254 try std.testing.expectEqual(@as(usize, 3), diag.line);
255 try std.testing.expect(std.mem.indexOf(u8, diag.message, "escape") != null);
256 }
257
258 test "parse: bytes-hex directive" {
259 var s = try parseOk(
260 \\size 80 24
261 \\timeout 5000ms
262 \\bytes-hex 1B 5B 35 20 71
263 );
264 defer s.deinit();
265 try std.testing.expectEqualSlices(u8, &[_]u8{ 0x1B, 0x5B, 0x35, 0x20, 0x71 }, s.directives[0].bytes);
266 }
267
268 test "parse: bytes-hex accepts mixed case" {
269 var s = try parseOk(
270 \\size 80 24
271 \\timeout 5000ms
272 \\bytes-hex aB cD eF
273 );
274 defer s.deinit();
275 try std.testing.expectEqualSlices(u8, &[_]u8{ 0xAB, 0xCD, 0xEF }, s.directives[0].bytes);
276 }
277
278 test "parse: bytes-hex rejects odd-length token" {
279 const diag = try parseErr(
280 \\size 80 24
281 \\timeout 5000ms
282 \\bytes-hex 1B 5
283 );
284 try std.testing.expectEqual(@as(usize, 3), diag.line);
285 }
286
287 test "parse: bytes-hex rejects non-hex characters" {
288 const diag = try parseErr(
289 \\size 80 24
290 \\timeout 5000ms
291 \\bytes-hex 1B ZZ
292 );
293 try std.testing.expectEqual(@as(usize, 3), diag.line);
294 }
295
296 test "parse: capture directive — label stored" {
297 var s = try parseOk(
298 \\size 80 24
299 \\timeout 5000ms
300 \\capture on-phase
301 );
302 defer s.deinit();
303 try std.testing.expectEqualSlices(u8, "on-phase", s.directives[0].capture);
304 }
305
306 test "parse: capture directive — empty label fails" {
307 const diag = try parseErr(
308 \\size 80 24
309 \\timeout 5000ms
310 \\capture
311 );
312 try std.testing.expectEqual(@as(usize, 3), diag.line);
313 }
314
315 test "parse: assert-cell directive" {
316 var s = try parseOk(
317 \\size 80 24
318 \\timeout 5000ms
319 \\assert-cell 5 3 cursor-bar-at
320 );
321 defer s.deinit();
322 const a = s.directives[0].assert_cell;
323 try std.testing.expectEqual(@as(u16, 5), a.row);
324 try std.testing.expectEqual(@as(u16, 3), a.col);
325 try std.testing.expectEqual(Predicate.cursor_bar_at, a.pred);
326 }
327
328 test "parse: assert-cell-at directive" {
329 var s = try parseOk(
330 \\size 80 24
331 \\timeout 5000ms
332 \\assert-cell-at on-phase 5 3 cursor-block-at
333 );
334 defer s.deinit();
335 const a = s.directives[0].assert_cell_at;
336 try std.testing.expectEqualSlices(u8, "on-phase", a.label);
337 try std.testing.expectEqual(@as(u16, 5), a.row);
338 try std.testing.expectEqual(@as(u16, 3), a.col);
339 try std.testing.expectEqual(Predicate.cursor_block_at, a.pred);
340 }
341
342 test "parse: all five predicates round-trip through assert-cell" {
343 var s = try parseOk(
344 \\size 80 24
345 \\timeout 5000ms
346 \\assert-cell 0 0 cell-matches-golden
347 \\assert-cell 0 0 cursor-block-at
348 \\assert-cell 0 0 cursor-bar-at
349 \\assert-cell 0 0 cursor-underline-at
350 \\assert-cell 0 0 cell-empty
351 );
352 defer s.deinit();
353 try std.testing.expectEqual(Predicate.cell_matches_golden, s.directives[0].assert_cell.pred);
354 try std.testing.expectEqual(Predicate.cursor_block_at, s.directives[1].assert_cell.pred);
355 try std.testing.expectEqual(Predicate.cursor_bar_at, s.directives[2].assert_cell.pred);
356 try std.testing.expectEqual(Predicate.cursor_underline_at, s.directives[3].assert_cell.pred);
357 try std.testing.expectEqual(Predicate.cell_empty, s.directives[4].assert_cell.pred);
358 }
359
360 test "parse: unknown predicate fails at line" {
361 const diag = try parseErr(
362 \\size 80 24
363 \\timeout 5000ms
364 \\assert-cell 0 0 totally-bogus
365 );
366 try std.testing.expectEqual(@as(usize, 3), diag.line);
367 }
368 ```
369
370 - [ ] **Step 2: Wire `src/scenario.zig` as a module in build.zig.**
371
372 Insert after the imgdiff library wiring (after `build.zig:388` or equivalent, adjust if line numbers shifted):
373
374 ```zig
375 // scenario — DSL parser, state machine, predicate evaluator (pure)
376 const scenario_mod = b.createModule(.{
377 .root_source_file = b.path("src/scenario.zig"),
378 .target = target,
379 .optimize = optimize,
380 });
381 scenario_mod.addImport("png", png_mod);
382 scenario_mod.addImport("imgdiff", imgdiff_lib_mod);
383
384 const scenario_test_mod = b.createModule(.{
385 .root_source_file = b.path("src/scenario.zig"),
386 .target = target,
387 .optimize = optimize,
388 });
389 scenario_test_mod.addImport("png", png_mod);
390 scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod);
391 const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod });
392 test_step.dependOn(&b.addRunArtifact(scenario_tests).step);
393 ```
394
395 - [ ] **Step 3: Run tests to confirm compile failure on the `parse` stub.**
396
397 Run:
398 ```bash
399 cd /home/xanderle/code/rad/waystty
400 zig build test 2>&1 | tail -10
401 ```
402
403 Expected: `@compileError("parse: not yet implemented")` fires. That's the intentional failure proving the test module links against the new symbols.
404
405 - [ ] **Step 4: Implement `parse`.**
406
407 Replace the stubbed `parse` body with a working implementation. Guidance:
408
409 - Line-based: split source on `\n`, track line number starting at 1.
410 - Skip lines whose trimmed content is empty or begins with `#`.
411 - Tokenize the first word of each line; dispatch on it.
412 - `size` must be the first non-comment/non-blank directive. If another directive appears first, diag.line = that line, message = "expected size directive first".
413 - `timeout` must appear before any body directive. Track `has_size` and `has_timeout` booleans.
414 - `sleep 250ms` / `sleep 1s` / `sleep 2500ms` — accept `NNms` or `NNs`. Units mandatory.
415 - `bytes "STR"` — strict double-quoted string; escape set `\n`, `\t`, `\r`, `\e` (→ 0x1B), `\\`, `\"`. Any other backslash is an error.
416 - `bytes-hex HH HH HH ...` — each token must be exactly 2 hex chars. Mixed case allowed.
417 - `capture LABEL` — label is a single token, `[a-zA-Z0-9_-]+` (tight — rejects `/` and whitespace). Non-empty.
418 - `assert-cell R C PRED` — three args, PRED parsed from a small table.
419 - `assert-cell-at LABEL R C PRED` — four args.
420 - `sleep-until-flip` — no args.
421 - Directive payload slices (bytes, capture labels, assert-cell-at labels) are allocated from `Scenario.arena` via `arena.allocator().dupe`.
422 - On any error, set `diag.line` and `diag.message` (static string) and return `error.ParseFailed`. Deinit the arena cleanly before returning.
423 - On success, the directive list slice is allocated from the arena and stored on `Scenario`.
424
425 Keep the implementation straightforward; don't build a full lexer framework. ~200-300 lines is the target.
426
427 - [ ] **Step 5: Run tests.**
428
429 ```bash
430 zig build test 2>&1 | tail -30
431 ```
432
433 Expected: all scenario-parser tests pass. Existing tests unaffected.
434
435 - [ ] **Step 6: Commit.**
436
437 ```bash
438 git add src/scenario.zig build.zig
439 git commit -m "$(cat <<'EOF'
440 scenario: add parser for the scenario runner DSL
441
442 Parses size/timeout header + sleep/sleep-until-flip/bytes/
443 bytes-hex/capture/assert-cell/assert-cell-at directives. Errors
444 return a Diagnostic with line number and static message.
445
446 Pure — no I/O, no state machine yet. Plan 2 Task 2 builds on this.
447
448 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
449 EOF
450 )"
451 ```
452
453 ---
454
455 ## Task 2: ScenarioState + tick + isDone
456
457 **Files:**
458 - Modify: `src/scenario.zig` — add `ScenarioState`, `tick`, `isDone`, and tests with a fake-clock + mock IO.
459
460 **Goal:** A deterministic tick-driven state machine that walks the parsed directive list on a monotonic timeline, invoking caller-supplied callbacks for side effects.
461
462 - [ ] **Step 1: Append types, stubs, and failing tests to `src/scenario.zig`.**
463
464 Add the following after the existing code:
465
466 ```zig
467 // ---------------------------------------------------------------
468 // Tick state machine
469 // ---------------------------------------------------------------
470
471 /// Caller-supplied side-effects. The state machine itself is pure.
472 pub const TickIO = struct {
473 ctx: *anyopaque,
474 /// Write bytes into the terminal's VT parser (caller decides whether
475 /// that goes through pty master or direct term.write).
476 write_bytes: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!void,
477 /// Perform an offscreen render and return an owned png.Image.
478 /// The Scenario will take ownership; caller must not free.
479 capture: *const fn (ctx: *anyopaque, label: []const u8) anyerror!png.Image,
480 /// Return true if the blink timer flipped since the previous tick.
481 /// Used by sleep-until-flip. If blink isn't armed at all, calls
482 /// to this return false forever and sleep-until-flip will time out.
483 blink_just_flipped: *const fn (ctx: *anyopaque) bool,
484 };
485
486 pub const TickError = error{
487 ScenarioTimeout,
488 SleepUntilFlipTimeout,
489 AssertFailed,
490 PredicateOnMissingLabel,
491 } || std.mem.Allocator.Error || anyerror; // callbacks can surface anyerror
492
493 pub const TickOutcome = enum {
494 working, // directives remain; call tick again later
495 done, // no more directives
496 };
497
498 pub const ScenarioState = struct {
499 scenario: *const Scenario,
500 cursor: usize, // index into scenario.directives
501 origin_ns: i128, // monotonic wall-clock at start
502 deadline_ns: i128, // origin + timeout + slack; exceeding this → TickError.ScenarioTimeout
503 scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
504 sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
505 captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
506 alloc: std.mem.Allocator,
507
508 pub fn init(
509 alloc: std.mem.Allocator,
510 scenario: *const Scenario,
511 origin_ns: i128,
512 ) ScenarioState {
513 return .{
514 .scenario = scenario,
515 .cursor = 0,
516 .origin_ns = origin_ns,
517 .deadline_ns = origin_ns + @as(i128, scenario.timeout_ms) * std.time.ns_per_ms,
518 .scheduled_offset_ns = 0,
519 .sleep_until_flip_started_ns = null,
520 .captures = .{},
521 .alloc = alloc,
522 };
523 }
524
525 pub fn deinit(self: *ScenarioState) void {
526 var it = self.captures.iterator();
527 while (it.next()) |entry| {
528 self.alloc.free(entry.value_ptr.pixels);
529 }
530 self.captures.deinit(self.alloc);
531 }
532
533 pub fn isDone(self: *const ScenarioState) bool {
534 return self.cursor >= self.scenario.directives.len;
535 }
536
537 pub fn tick(self: *ScenarioState, now_ns: i128, io: TickIO) TickError!TickOutcome {
538 _ = self;
539 _ = now_ns;
540 _ = io;
541 @compileError("tick: not yet implemented");
542 }
543 };
544
545 // ---------------------------------------------------------------
546 // Tick tests
547 // ---------------------------------------------------------------
548
549 const TestIO = struct {
550 writes: std.ArrayListUnmanaged(u8) = .{},
551 captures_called: std.ArrayListUnmanaged([]const u8) = .{},
552 flip_stub: bool = false,
553 alloc: std.mem.Allocator,
554
555 pub fn writeBytes(ctx: *anyopaque, bytes: []const u8) anyerror!void {
556 const self: *TestIO = @ptrCast(@alignCast(ctx));
557 try self.writes.appendSlice(self.alloc, bytes);
558 }
559
560 pub fn captureCb(ctx: *anyopaque, label: []const u8) anyerror!png.Image {
561 const self: *TestIO = @ptrCast(@alignCast(ctx));
562 const label_copy = try self.alloc.dupe(u8, label);
563 try self.captures_called.append(self.alloc, label_copy);
564 // Return a 1x1 white pixel that the Scenario takes ownership of.
565 const pixels = try self.alloc.alloc(u8, 4);
566 pixels[0] = 255;
567 pixels[1] = 255;
568 pixels[2] = 255;
569 pixels[3] = 255;
570 return .{ .width = 1, .height = 1, .pixels = pixels };
571 }
572
573 pub fn flipCb(ctx: *anyopaque) bool {
574 const self: *TestIO = @ptrCast(@alignCast(ctx));
575 const v = self.flip_stub;
576 self.flip_stub = false;
577 return v;
578 }
579
580 pub fn io(self: *TestIO) TickIO {
581 return .{
582 .ctx = self,
583 .write_bytes = writeBytes,
584 .capture = captureCb,
585 .blink_just_flipped = flipCb,
586 };
587 }
588
589 pub fn deinit(self: *TestIO) void {
590 for (self.captures_called.items) |lbl| self.alloc.free(lbl);
591 self.captures_called.deinit(self.alloc);
592 self.writes.deinit(self.alloc);
593 }
594 };
595
596 test "tick: empty scenario is immediately done" {
597 var s = try parseOk(
598 \\size 80 24
599 \\timeout 1000ms
600 );
601 defer s.deinit();
602 var state = ScenarioState.init(std.testing.allocator, &s, 0);
603 defer state.deinit();
604
605 try std.testing.expect(state.isDone());
606 }
607
608 test "tick: bytes directive fires write_bytes immediately" {
609 var s = try parseOk(
610 \\size 80 24
611 \\timeout 1000ms
612 \\bytes "abc"
613 );
614 defer s.deinit();
615 var state = ScenarioState.init(std.testing.allocator, &s, 0);
616 defer state.deinit();
617
618 var tio = TestIO{ .alloc = std.testing.allocator };
619 defer tio.deinit();
620
621 const r = try state.tick(0, tio.io());
622 try std.testing.expectEqual(TickOutcome.done, r);
623 try std.testing.expectEqualSlices(u8, "abc", tio.writes.items);
624 try std.testing.expect(state.isDone());
625 }
626
627 test "tick: sleep holds advancement until time passes" {
628 var s = try parseOk(
629 \\size 80 24
630 \\timeout 5000ms
631 \\sleep 500ms
632 \\bytes "x"
633 );
634 defer s.deinit();
635 var state = ScenarioState.init(std.testing.allocator, &s, 0);
636 defer state.deinit();
637
638 var tio = TestIO{ .alloc = std.testing.allocator };
639 defer tio.deinit();
640
641 // At t=0, sleep is consumed (moves scheduled offset forward); bytes is held.
642 const r1 = try state.tick(0, tio.io());
643 try std.testing.expectEqual(TickOutcome.working, r1);
644 try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
645
646 // At t=250ms, still under scheduled offset — no advance.
647 const r2 = try state.tick(250 * std.time.ns_per_ms, tio.io());
648 try std.testing.expectEqual(TickOutcome.working, r2);
649 try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
650
651 // At t=500ms, bytes fires.
652 const r3 = try state.tick(500 * std.time.ns_per_ms, tio.io());
653 try std.testing.expectEqual(TickOutcome.done, r3);
654 try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
655 }
656
657 test "tick: capture calls io.capture and stores the result under the label" {
658 var s = try parseOk(
659 \\size 80 24
660 \\timeout 5000ms
661 \\capture snap1
662 );
663 defer s.deinit();
664 var state = ScenarioState.init(std.testing.allocator, &s, 0);
665 defer state.deinit();
666
667 var tio = TestIO{ .alloc = std.testing.allocator };
668 defer tio.deinit();
669
670 _ = try state.tick(0, tio.io());
671 try std.testing.expect(state.isDone());
672 try std.testing.expectEqual(@as(usize, 1), tio.captures_called.items.len);
673 try std.testing.expectEqualSlices(u8, "snap1", tio.captures_called.items[0]);
674 try std.testing.expect(state.captures.contains("snap1"));
675 }
676
677 test "tick: scenario timeout fires" {
678 var s = try parseOk(
679 \\size 80 24
680 \\timeout 100ms
681 \\sleep 500ms
682 \\bytes "x"
683 );
684 defer s.deinit();
685 var state = ScenarioState.init(std.testing.allocator, &s, 0);
686 defer state.deinit();
687
688 var tio = TestIO{ .alloc = std.testing.allocator };
689 defer tio.deinit();
690
691 // 200ms is past the 100ms scenario timeout.
692 const r = state.tick(200 * std.time.ns_per_ms, tio.io());
693 try std.testing.expectError(error.ScenarioTimeout, r);
694 }
695
696 test "tick: sleep-until-flip holds until flip callback returns true" {
697 var s = try parseOk(
698 \\size 80 24
699 \\timeout 5000ms
700 \\sleep-until-flip
701 \\bytes "x"
702 );
703 defer s.deinit();
704 var state = ScenarioState.init(std.testing.allocator, &s, 0);
705 defer state.deinit();
706
707 var tio = TestIO{ .alloc = std.testing.allocator };
708 defer tio.deinit();
709
710 // No flip yet — tick holds at the sleep-until-flip directive.
711 tio.flip_stub = false;
712 const r1 = try state.tick(100 * std.time.ns_per_ms, tio.io());
713 try std.testing.expectEqual(TickOutcome.working, r1);
714 try std.testing.expectEqual(@as(usize, 0), tio.writes.items.len);
715
716 // Flip observed — sleep-until-flip advances; bytes fires.
717 tio.flip_stub = true;
718 const r2 = try state.tick(200 * std.time.ns_per_ms, tio.io());
719 try std.testing.expectEqual(TickOutcome.done, r2);
720 try std.testing.expectEqualSlices(u8, "x", tio.writes.items);
721 }
722
723 test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
724 var s = try parseOk(
725 \\size 80 24
726 \\timeout 10000ms
727 \\sleep-until-flip
728 );
729 defer s.deinit();
730 var state = ScenarioState.init(std.testing.allocator, &s, 0);
731 defer state.deinit();
732
733 var tio = TestIO{ .alloc = std.testing.allocator };
734 defer tio.deinit();
735
736 // After 2 * 500ms = 1s with no flip observed, fail.
737 // First tick enters the directive, stamps the start time.
738 _ = try state.tick(0, tio.io());
739 // Second tick at t=1.1s — over the 2x blink_period budget.
740 const r = state.tick(1_100 * std.time.ns_per_ms, tio.io());
741 try std.testing.expectError(error.SleepUntilFlipTimeout, r);
742 }
743 ```
744
745 - [ ] **Step 2: Run tests to confirm compile failure on the `tick` stub.**
746
747 Run:
748 ```bash
749 zig build test 2>&1 | tail -10
750 ```
751
752 Expected: `@compileError("tick: not yet implemented")` fires.
753
754 - [ ] **Step 3: Implement `tick`.**
755
756 Replace the stubbed body with a working implementation. Guidance:
757
758 - Every entry to `tick` first checks `now_ns > deadline_ns` → return `error.ScenarioTimeout`.
759 - Then loop: while there's a current directive AND its scheduled time has arrived, execute it and advance cursor.
760 - Directive execution:
761 - `.sleep N` — advance `scheduled_offset_ns` by `N * ns_per_ms`. Advance cursor.
762 - `.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`.
763 - `.bytes slice` — call `io.write_bytes(io.ctx, slice)`. Advance cursor.
764 - `.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.
765 - `.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.
766 - Return `.working` if cursor < len after the loop; else `.done`.
767
768 - [ ] **Step 4: Run tests.**
769
770 ```bash
771 zig build test 2>&1 | tail -30
772 ```
773
774 Expected: all scenario-tick tests pass. Existing tests unaffected.
775
776 - [ ] **Step 5: Commit.**
777
778 ```bash
779 git add src/scenario.zig
780 git commit -m "$(cat <<'EOF'
781 scenario: add ScenarioState + tick state machine
782
783 Tick advances the directive cursor on a monotonic timeline,
784 invoking caller-supplied callbacks for bytes/capture/flip.
785 sleep-until-flip rendezvouses with the blink timer via a
786 caller-observed flag; times out at 2x blink period.
787
788 Assert-cell predicates still stubbed — Task 3 wires the
789 predicate evaluator.
790
791 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
792 EOF
793 )"
794 ```
795
796 ---
797
798 ## Task 3: Cell-region predicate evaluator
799
800 **Files:**
801 - Modify: `src/scenario.zig` — add `evalPredicate` and predicate-specific tests; wire predicates into the `tick` assert branches.
802
803 **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.
804
805 - [ ] **Step 1: Add predicate evaluator signature + failing tests.**
806
807 Append to `src/scenario.zig`:
808
809 ```zig
810 // ---------------------------------------------------------------
811 // Cell-region predicate evaluator
812 // ---------------------------------------------------------------
813
814 pub const EvalResult = struct {
815 pass: bool,
816 /// Short diagnostic for failure case (static string where possible).
817 reason: []const u8 = "",
818 };
819
820 pub const CellGeom = struct {
821 cell_w_px: u32,
822 cell_h_px: u32,
823 };
824
825 /// `golden_for_cell_matches` is only consulted by `cell_matches_golden`;
826 /// callers may pass null otherwise. When null and pred needs it, result
827 /// is pass=false with reason="missing golden".
828 pub fn evalPredicate(
829 image: png.Image,
830 row: u16,
831 col: u16,
832 geom: CellGeom,
833 pred: Predicate,
834 golden_for_cell_matches: ?png.Image,
835 ) EvalResult {
836 _ = image;
837 _ = row;
838 _ = col;
839 _ = geom;
840 _ = pred;
841 _ = golden_for_cell_matches;
842 @compileError("evalPredicate: not yet implemented");
843 }
844
845 // ---------------------------------------------------------------
846 // Predicate evaluator tests
847 // ---------------------------------------------------------------
848
849 /// Build a width×height PNG where every pixel is `color` (RGBA).
850 fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image {
851 const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4);
852 var i: usize = 0;
853 while (i < pixels.len) : (i += 4) {
854 pixels[i + 0] = color[0];
855 pixels[i + 1] = color[1];
856 pixels[i + 2] = color[2];
857 pixels[i + 3] = color[3];
858 }
859 return .{ .width = w, .height = h, .pixels = pixels };
860 }
861
862 test "evalPredicate: cell-empty passes on all-black image" {
863 const alloc = std.testing.allocator;
864 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
865 defer alloc.free(img.pixels);
866 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
867 try std.testing.expect(r.pass);
868 }
869
870 test "evalPredicate: cell-empty fails on image with bright cell" {
871 const alloc = std.testing.allocator;
872 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
873 defer alloc.free(img.pixels);
874 // Make cell (0,0)'s first pixel bright.
875 img.pixels[0] = 255;
876 img.pixels[1] = 255;
877 img.pixels[2] = 255;
878 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
879 try std.testing.expect(!r.pass);
880 }
881
882 test "evalPredicate: cursor-block-at passes when cell is mostly bright" {
883 const alloc = std.testing.allocator;
884 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
885 defer alloc.free(img.pixels);
886 // Fill cell (0,0)'s 8x16 rect with white.
887 fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 });
888 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
889 try std.testing.expect(r.pass);
890 }
891
892 test "evalPredicate: cursor-block-at fails when cell is dark" {
893 const alloc = std.testing.allocator;
894 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
895 defer alloc.free(img.pixels);
896 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
897 try std.testing.expect(!r.pass);
898 }
899
900 test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" {
901 const alloc = std.testing.allocator;
902 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
903 defer alloc.free(img.pixels);
904 // Paint only the leftmost 2 pixels of cell (0,0), full height.
905 var y: u32 = 0;
906 while (y < 16) : (y += 1) {
907 var x: u32 = 0;
908 while (x < 2) : (x += 1) {
909 const off = (@as(usize, y) * 80 + x) * 4;
910 img.pixels[off + 0] = 255;
911 img.pixels[off + 1] = 255;
912 img.pixels[off + 2] = 255;
913 }
914 }
915 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
916 try std.testing.expect(r.pass);
917 }
918
919 test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" {
920 const alloc = std.testing.allocator;
921 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
922 defer alloc.free(img.pixels);
923 // Paint only the rightmost 2 pixels of cell (0,0), full height.
924 var y: u32 = 0;
925 while (y < 16) : (y += 1) {
926 var x: u32 = 6;
927 while (x < 8) : (x += 1) {
928 const off = (@as(usize, y) * 80 + x) * 4;
929 img.pixels[off + 0] = 255;
930 img.pixels[off + 1] = 255;
931 img.pixels[off + 2] = 255;
932 }
933 }
934 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
935 try std.testing.expect(!r.pass);
936 }
937
938 test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" {
939 const alloc = std.testing.allocator;
940 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
941 defer alloc.free(img.pixels);
942 // Paint only the bottom 2 rows of cell (0,0), full width.
943 var y: u32 = 14;
944 while (y < 16) : (y += 1) {
945 var x: u32 = 0;
946 while (x < 8) : (x += 1) {
947 const off = (@as(usize, y) * 80 + x) * 4;
948 img.pixels[off + 0] = 255;
949 img.pixels[off + 1] = 255;
950 img.pixels[off + 2] = 255;
951 }
952 }
953 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null);
954 try std.testing.expect(r.pass);
955 }
956
957 test "evalPredicate: cell-matches-golden passes when images match in the cell rect" {
958 const alloc = std.testing.allocator;
959 var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
960 defer alloc.free(img.pixels);
961 var golden = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
962 defer alloc.free(golden.pixels);
963 const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
964 try std.testing.expect(r.pass);
965 }
966
967 test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" {
968 const alloc = std.testing.allocator;
969 var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
970 defer alloc.free(img.pixels);
971 var golden = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
972 defer alloc.free(golden.pixels);
973 // Corrupt cell (5,3) in img.
974 fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 });
975 const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
976 try std.testing.expect(!r.pass);
977 }
978
979 test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" {
980 const alloc = std.testing.allocator;
981 var img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
982 defer alloc.free(img.pixels);
983 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null);
984 try std.testing.expect(!r.pass);
985 try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null);
986 }
987
988 fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void {
989 const start_x: u32 = @as(u32, col) * geom.cell_w_px;
990 const start_y: u32 = @as(u32, row) * geom.cell_h_px;
991 var y: u32 = start_y;
992 while (y < start_y + geom.cell_h_px) : (y += 1) {
993 var x: u32 = start_x;
994 while (x < start_x + geom.cell_w_px) : (x += 1) {
995 const off = (@as(usize, y) * img.width + x) * 4;
996 img.pixels[off + 0] = color[0];
997 img.pixels[off + 1] = color[1];
998 img.pixels[off + 2] = color[2];
999 img.pixels[off + 3] = color[3];
1000 }
1001 }
1002 }
1003 ```
1004
1005 - [ ] **Step 2: Run tests to confirm compile failure on the `evalPredicate` stub.**
1006
1007 ```bash
1008 zig build test 2>&1 | tail -10
1009 ```
1010
1011 Expected: `@compileError("evalPredicate: not yet implemented")` fires.
1012
1013 - [ ] **Step 3: Implement `evalPredicate`.**
1014
1015 Guidance:
1016
1017 - Cell pixel rect: `x0 = col * cell_w_px`, `y0 = row * cell_h_px`, span = `cell_w_px × cell_h_px`.
1018 - Brightness of a pixel: `(R + G + B) / 3` normalized [0, 1].
1019 - `cell_empty`: every pixel in the cell rect has brightness ≤ 0.1. Pass if so; fail with reason "bright pixel in supposedly empty cell".
1020 - `cursor_block_at`: mean brightness over the cell rect is ≥ 0.5. Pass if so; fail with reason "cell too dark for block cursor".
1021 - `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".
1022 - `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.
1023 - `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`.
1024
1025 Keep the implementation short. Rough target ~80-100 lines for this entire function + any helpers.
1026
1027 - [ ] **Step 4: Wire evalPredicate into the `tick` assert-cell branches.**
1028
1029 Replace the `unreachable` stubs in `tick`'s `.assert_cell` and `.assert_cell_at` cases with real code:
1030
1031 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".
1032
1033 For `.assert_cell_at`: look up `self.captures.get(label)`; if missing, return `error.PredicateOnMissingLabel`.
1034
1035 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.
1036
1037 Assertions that fail print a diagnostic via `std.debug.print` and return `error.AssertFailed`. Caller (Plan 3) decides whether to stop or accumulate.
1038
1039 Add a final test that combines parse + state + predicate end-to-end:
1040
1041 ```zig
1042 test "tick+eval: assert-cell evaluates against last capture" {
1043 var s = try parseOk(
1044 \\size 80 24
1045 \\timeout 5000ms
1046 \\capture snap
1047 \\assert-cell 0 0 cursor-block-at
1048 );
1049 defer s.deinit();
1050 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1051 defer state.deinit();
1052
1053 var tio = TestIO{ .alloc = std.testing.allocator };
1054 defer tio.deinit();
1055
1056 // TestIO.captureCb returns a 1x1 all-white PNG; cursor_block_at should pass
1057 // because a 1x1 white image has mean brightness ~1.0 >= 0.5.
1058 const r = try state.tick(0, tio.io());
1059 try std.testing.expectEqual(TickOutcome.done, r);
1060 }
1061
1062 test "tick+eval: assert-cell-at on missing label errors" {
1063 var s = try parseOk(
1064 \\size 80 24
1065 \\timeout 5000ms
1066 \\assert-cell-at nope 0 0 cell-empty
1067 );
1068 defer s.deinit();
1069 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1070 defer state.deinit();
1071
1072 var tio = TestIO{ .alloc = std.testing.allocator };
1073 defer tio.deinit();
1074
1075 const r = state.tick(0, tio.io());
1076 try std.testing.expectError(error.PredicateOnMissingLabel, r);
1077 }
1078 ```
1079
1080 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.
1081
1082 Recommended adjustment: change `TestIO.captureCb` to return a 640×384 all-white PNG. Then `cursor-block-at` on cell (0,0) will pass.
1083
1084 - [ ] **Step 5: Run all tests.**
1085
1086 ```bash
1087 zig build test 2>&1 | tail -30
1088 ```
1089
1090 Expected: all scenario tests pass. Existing tests unaffected.
1091
1092 - [ ] **Step 6: Commit.**
1093
1094 ```bash
1095 git add src/scenario.zig
1096 git commit -m "$(cat <<'EOF'
1097 scenario: add cell-region predicate evaluator + wire into tick
1098
1099 Pure predicate evaluation over a captured PNG. Predicates
1100 cover: cell-empty, cursor-block/bar/underline-at, and
1101 cell-matches-golden (stubbed pass=false until Plan 3
1102 loads goldens from disk).
1103
1104 Tick's assert-cell and assert-cell-at branches now invoke
1105 the evaluator and return AssertFailed / PredicateOnMissingLabel
1106 on failure.
1107
1108 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1109 EOF
1110 )"
1111 ```
1112
1113 ---
1114
1115 ## Post-plan verification
1116
1117 - [ ] `zig build` succeeds.
1118 - [ ] `zig build test` — all existing tests plus all new scenario tests pass (estimated 20-25 scenario tests total).
1119 - [ ] `src/scenario.zig` is importable as `@import("scenario")` by any module declaring it as a dep. Plan 3 will consume this.
1120 - [ ] No main-loop / Vulkan / wayland touchpoints were modified.
1121
1122 ---
1123
1124 ## Self-review coverage check
1125
1126 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.
1127
1128 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.
1129
1130 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.
1131
1132 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.
1133
1134 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`.