46c96419
Plan 2 of 3 for scenario runner: parser + state + predicates
a73x 2026-04-19 09:12
Commit message
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`. | ||