1f681745
scenario: add cell-region predicate evaluator + wire into tick
a73x 2026-04-19 09:35
Pure predicate evaluation over a captured PNG. Predicates cover: cell-empty, cursor-block/bar/underline-at, and cell-matches-golden (stubbed pass=false until Plan 3 loads goldens from disk). Tick's assert-cell and assert-cell-at branches now invoke the evaluator and return AssertFailed / PredicateOnMissingLabel on failure. ScenarioState gains last_capture_label to track the most recent capture for assert-cell resolution. TestIO.captureCb updated to return a 640×384 all-white PNG (80 cols × 8px, 24 rows × 16px) so end-to-end predicate tests can address any cell in the grid. 46 tests pass (33 Task 1+2 + 11 predicate unit + 2 end-to-end). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/build.zig b/build.zig index 880ec3a..479c8c1 100644 --- a/build.zig +++ b/build.zig @@ -400,6 +400,8 @@ pub fn build(b: *std.Build) void { scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod); const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod }); test_step.dependOn(&b.addRunArtifact(scenario_tests).step); const scenario_test_step = b.step("test-scenario", "Run scenario unit tests only (no Vulkan)"); scenario_test_step.dependOn(&b.addRunArtifact(scenario_tests).step); // imgdiff — standalone PNG comparison CLI const imgdiff_mod = b.createModule(.{ diff --git a/src/scenario.zig b/src/scenario.zig index 42c766c..042249e 100644 --- a/src/scenario.zig +++ b/src/scenario.zig @@ -798,6 +798,7 @@ pub const ScenarioState = struct { scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this" sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG last_capture_label: ?[]const u8, // most recently captured label (owned by alloc) alloc: std.mem.Allocator, pub fn init( @@ -813,6 +814,7 @@ pub const ScenarioState = struct { .scheduled_offset_ns = 0, .sleep_until_flip_started_ns = null, .captures = .{}, .last_capture_label = null, .alloc = alloc, }; } @@ -824,6 +826,7 @@ pub const ScenarioState = struct { self.alloc.free(entry.value_ptr.pixels); } self.captures.deinit(self.alloc); if (self.last_capture_label) |lbl| self.alloc.free(lbl); } pub fn isDone(self: *const ScenarioState) bool { @@ -884,10 +887,41 @@ pub const ScenarioState = struct { const label_copy = try self.alloc.dupe(u8, label); errdefer self.alloc.free(label_copy); try self.captures.put(self.alloc, label_copy, img); // Update last_capture_label: free previous, dupe new from original label. if (self.last_capture_label) |old| self.alloc.free(old); self.last_capture_label = try self.alloc.dupe(u8, label); self.cursor += 1; }, .assert_cell => |ac| { const lbl = self.last_capture_label orelse { std.debug.print("scenario: assert-cell with no prior capture\n", .{}); return error.AssertFailed; }; const img = self.captures.get(lbl).?; const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 }; const result = evalPredicate(img, ac.row, ac.col, geom, ac.pred, null); if (!result.pass) { std.debug.print("scenario: assert-cell ({d},{d}) {s} failed: {s}\n", .{ ac.row, ac.col, @tagName(ac.pred), result.reason, }); return error.AssertFailed; } self.cursor += 1; }, .assert_cell_at => |aca| { const img = self.captures.get(aca.label) orelse { return error.PredicateOnMissingLabel; }; const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 }; const result = evalPredicate(img, aca.row, aca.col, geom, aca.pred, null); if (!result.pass) { std.debug.print("scenario: assert-cell-at {s} ({d},{d}) {s} failed: {s}\n", .{ aca.label, aca.row, aca.col, @tagName(aca.pred), result.reason, }); return error.AssertFailed; } self.cursor += 1; }, .assert_cell => unreachable, // Task 3 wires the predicate evaluator. .assert_cell_at => unreachable, // Task 3 wires the predicate evaluator. } } @@ -914,13 +948,13 @@ const TestIO = struct { const self: *TestIO = @ptrCast(@alignCast(ctx)); const label_copy = try self.alloc.dupe(u8, label); try self.captures_called.append(self.alloc, label_copy); // Return a 1x1 white pixel that the Scenario takes ownership of. const pixels = try self.alloc.alloc(u8, 4); pixels[0] = 255; pixels[1] = 255; pixels[2] = 255; pixels[3] = 255; return .{ .width = 1, .height = 1, .pixels = pixels }; // Return a 640×384 all-white PNG (80 cols × 8px, 24 rows × 16px). // Large enough for predicate tests on any cell in the 80×24 grid. const w: u32 = 640; const h: u32 = 384; const pixels = try self.alloc.alloc(u8, @as(usize, w) * h * 4); @memset(pixels, 255); return .{ .width = w, .height = h, .pixels = pixels }; } pub fn flipCb(ctx: *anyopaque) bool { @@ -1132,3 +1166,374 @@ test "tick: write_bytes callback failure surfaces as CallbackFailed" { const r = state.tick(0, io); try std.testing.expectError(error.CallbackFailed, r); } // --------------------------------------------------------------- // Cell-region predicate evaluator // --------------------------------------------------------------- pub const EvalResult = struct { pass: bool, /// Short diagnostic for failure case (static string where possible). reason: []const u8 = "", }; pub const CellGeom = struct { cell_w_px: u32, cell_h_px: u32, }; /// Pixel brightness in [0, 1]: mean of R, G, B channels. fn pixelBrightness(r: u8, g: u8, b: u8) f64 { return (@as(f64, @floatFromInt(r)) + @as(f64, @floatFromInt(g)) + @as(f64, @floatFromInt(b))) / (3.0 * 255.0); } /// `golden_for_cell_matches` is only consulted by `cell_matches_golden`; /// callers may pass null otherwise. When null and pred needs it, result /// is pass=false with reason="missing golden". pub fn evalPredicate( image: png.Image, row: u16, col: u16, geom: CellGeom, pred: Predicate, golden_for_cell_matches: ?png.Image, ) EvalResult { const x0: u32 = @as(u32, col) * geom.cell_w_px; const y0: u32 = @as(u32, row) * geom.cell_h_px; // Bounds check: if cell rect extends past image, fail gracefully. if (x0 + geom.cell_w_px > image.width or y0 + geom.cell_h_px > image.height) { return .{ .pass = false, .reason = "cell out of bounds" }; } switch (pred) { .cell_empty => { // Pass if every pixel in the cell has brightness ≤ 0.1. var y: u32 = y0; while (y < y0 + geom.cell_h_px) : (y += 1) { var x: u32 = x0; while (x < x0 + geom.cell_w_px) : (x += 1) { const off = (@as(usize, y) * image.width + x) * 4; const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]); if (brightness > 0.1) { return .{ .pass = false, .reason = "bright pixel in supposedly empty cell" }; } } } return .{ .pass = true }; }, .cursor_block_at => { // Pass if mean brightness over the cell rect is ≥ 0.5. var sum: f64 = 0; const total_px: u32 = geom.cell_w_px * geom.cell_h_px; var y: u32 = y0; while (y < y0 + geom.cell_h_px) : (y += 1) { var x: u32 = x0; while (x < x0 + geom.cell_w_px) : (x += 1) { const off = (@as(usize, y) * image.width + x) * 4; sum += pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]); } } const mean = sum / @as(f64, @floatFromInt(total_px)); if (mean >= 0.5) { return .{ .pass = true }; } return .{ .pass = false, .reason = "cell too dark for block cursor" }; }, .cursor_bar_at => { // Pass if centroid x of bright pixels (brightness ≥ 0.5) is in left third // AND bright pixel count ≥ cell_h_px * 2 (matching 2px bar width). var bright_count: u32 = 0; var centroid_x_sum: f64 = 0; var y: u32 = y0; while (y < y0 + geom.cell_h_px) : (y += 1) { var x: u32 = x0; while (x < x0 + geom.cell_w_px) : (x += 1) { const off = (@as(usize, y) * image.width + x) * 4; const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]); if (brightness >= 0.5) { bright_count += 1; // x relative to cell origin centroid_x_sum += @as(f64, @floatFromInt(x - x0)); } } } const threshold: u32 = geom.cell_h_px * 2; if (bright_count < threshold) { return .{ .pass = false, .reason = "bar cursor centroid not at cell left" }; } const centroid_x = centroid_x_sum / @as(f64, @floatFromInt(bright_count)); const left_third = @as(f64, @floatFromInt(geom.cell_w_px)) / 3.0; if (centroid_x < left_third) { return .{ .pass = true }; } return .{ .pass = false, .reason = "bar cursor centroid not at cell left" }; }, .cursor_underline_at => { // Pass if centroid y of bright pixels is in bottom third // AND bright pixel count ≥ cell_w_px * 2. var bright_count: u32 = 0; var centroid_y_sum: f64 = 0; var y: u32 = y0; while (y < y0 + geom.cell_h_px) : (y += 1) { var x: u32 = x0; while (x < x0 + geom.cell_w_px) : (x += 1) { const off = (@as(usize, y) * image.width + x) * 4; const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]); if (brightness >= 0.5) { bright_count += 1; // y relative to cell origin centroid_y_sum += @as(f64, @floatFromInt(y - y0)); } } } const threshold: u32 = geom.cell_w_px * 2; if (bright_count < threshold) { return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" }; } const centroid_y = centroid_y_sum / @as(f64, @floatFromInt(bright_count)); const bottom_third_start = @as(f64, @floatFromInt(geom.cell_h_px)) * 2.0 / 3.0; if (centroid_y >= bottom_third_start) { return .{ .pass = true }; } return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" }; }, .cell_matches_golden => { const golden = golden_for_cell_matches orelse { return .{ .pass = false, .reason = "missing golden" }; }; // Compute RMSE over the cell rect inline. var sum_sq: f64 = 0; const total_px: u32 = geom.cell_w_px * geom.cell_h_px; var y: u32 = y0; while (y < y0 + geom.cell_h_px) : (y += 1) { var x: u32 = x0; while (x < x0 + geom.cell_w_px) : (x += 1) { const off = (@as(usize, y) * image.width + x) * 4; // Also bounds-check golden image. if (off + 3 >= golden.pixels.len) { return .{ .pass = false, .reason = "golden image too small for cell rect" }; } const dr = (@as(f64, @floatFromInt(image.pixels[off + 0])) - @as(f64, @floatFromInt(golden.pixels[off + 0]))) / 255.0; const dg = (@as(f64, @floatFromInt(image.pixels[off + 1])) - @as(f64, @floatFromInt(golden.pixels[off + 1]))) / 255.0; const db = (@as(f64, @floatFromInt(image.pixels[off + 2])) - @as(f64, @floatFromInt(golden.pixels[off + 2]))) / 255.0; sum_sq += (dr * dr + dg * dg + db * db) / 3.0; } } const rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(total_px))); if (rmse <= imgdiff.RMSE_DEFAULT) { return .{ .pass = true }; } return .{ .pass = false, .reason = "cell RMSE exceeds threshold" }; }, } } // --------------------------------------------------------------- // Predicate evaluator tests // --------------------------------------------------------------- /// Build a width×height PNG where every pixel is `color` (RGBA). fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image { const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4); var i: usize = 0; while (i < pixels.len) : (i += 4) { pixels[i + 0] = color[0]; pixels[i + 1] = color[1]; pixels[i + 2] = color[2]; pixels[i + 3] = color[3]; } return .{ .width = w, .height = h, .pixels = pixels }; } fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void { const start_x: u32 = @as(u32, col) * geom.cell_w_px; const start_y: u32 = @as(u32, row) * geom.cell_h_px; var y: u32 = start_y; while (y < start_y + geom.cell_h_px) : (y += 1) { var x: u32 = start_x; while (x < start_x + geom.cell_w_px) : (x += 1) { const off = (@as(usize, y) * img.width + x) * 4; img.pixels[off + 0] = color[0]; img.pixels[off + 1] = color[1]; img.pixels[off + 2] = color[2]; img.pixels[off + 3] = color[3]; } } } test "evalPredicate: cell out of bounds returns pass=false" { const alloc = std.testing.allocator; const img = try makeSolid(alloc, 8, 16, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); // Cell (0,1) would require x=8..16 but image is only 8 wide. const r = evalPredicate(img, 0, 1, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null); try std.testing.expect(!r.pass); try std.testing.expect(std.mem.indexOf(u8, r.reason, "out of bounds") != null); } test "evalPredicate: cell-empty passes on all-black image" { const alloc = std.testing.allocator; const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null); try std.testing.expect(r.pass); } test "evalPredicate: cell-empty fails on image with bright cell" { const alloc = std.testing.allocator; var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); // Make cell (0,0)'s first pixel bright. img.pixels[0] = 255; img.pixels[1] = 255; img.pixels[2] = 255; const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null); try std.testing.expect(!r.pass); } test "evalPredicate: cursor-block-at passes when cell is mostly bright" { const alloc = std.testing.allocator; var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); // Fill cell (0,0)'s 8x16 rect with white. fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 }); const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null); try std.testing.expect(r.pass); } test "evalPredicate: cursor-block-at fails when cell is dark" { const alloc = std.testing.allocator; const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null); try std.testing.expect(!r.pass); } test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" { const alloc = std.testing.allocator; var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); // Paint only the leftmost 2 pixels of cell (0,0), full height. var y: u32 = 0; while (y < 16) : (y += 1) { var x: u32 = 0; while (x < 2) : (x += 1) { const off = (@as(usize, y) * 80 + x) * 4; img.pixels[off + 0] = 255; img.pixels[off + 1] = 255; img.pixels[off + 2] = 255; } } const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null); try std.testing.expect(r.pass); } test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" { const alloc = std.testing.allocator; var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); // Paint only the rightmost 2 pixels of cell (0,0), full height. var y: u32 = 0; while (y < 16) : (y += 1) { var x: u32 = 6; while (x < 8) : (x += 1) { const off = (@as(usize, y) * 80 + x) * 4; img.pixels[off + 0] = 255; img.pixels[off + 1] = 255; img.pixels[off + 2] = 255; } } const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null); try std.testing.expect(!r.pass); } test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" { const alloc = std.testing.allocator; var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 }); defer alloc.free(img.pixels); // Paint only the bottom 2 rows of cell (0,0), full width. var y: u32 = 14; while (y < 16) : (y += 1) { var x: u32 = 0; while (x < 8) : (x += 1) { const off = (@as(usize, y) * 80 + x) * 4; img.pixels[off + 0] = 255; img.pixels[off + 1] = 255; img.pixels[off + 2] = 255; } } const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null); try std.testing.expect(r.pass); } test "evalPredicate: cell-matches-golden passes when images match in the cell rect" { const alloc = std.testing.allocator; // 640×384: 80 cols × 8px wide, 24 rows × 16px tall — enough for cell (5,3). const img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 }); defer alloc.free(img.pixels); const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 }); defer alloc.free(golden.pixels); const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden); try std.testing.expect(r.pass); } test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" { const alloc = std.testing.allocator; var img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 }); defer alloc.free(img.pixels); const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 }); defer alloc.free(golden.pixels); // Corrupt cell (5,3) in img. fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 }); const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden); try std.testing.expect(!r.pass); } test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" { const alloc = std.testing.allocator; const img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 }); defer alloc.free(img.pixels); const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null); try std.testing.expect(!r.pass); try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null); } // End-to-end tests: parse + state + predicate test "tick+eval: assert-cell evaluates against last capture" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\capture snap \\assert-cell 0 0 cursor-block-at ); defer s.deinit(); var state = ScenarioState.init(std.testing.allocator, &s, 0); defer state.deinit(); var tio = TestIO{ .alloc = std.testing.allocator }; defer tio.deinit(); // TestIO.captureCb returns a 640×384 all-white PNG; cursor_block_at on cell (0,0) // will pass because mean brightness of an all-white cell is 1.0 >= 0.5. const r = try state.tick(0, tio.io()); try std.testing.expectEqual(TickOutcome.done, r); } test "tick+eval: assert-cell-at on missing label errors" { var s = try parseOk( \\size 80 24 \\timeout 5000ms \\assert-cell-at nope 0 0 cell-empty ); defer s.deinit(); var state = ScenarioState.init(std.testing.allocator, &s, 0); defer state.deinit(); var tio = TestIO{ .alloc = std.testing.allocator }; defer tio.deinit(); const r = state.tick(0, tio.io()); try std.testing.expectError(error.PredicateOnMissingLabel, r); }