a73x

1f681745

scenario: add cell-region predicate evaluator + wire into tick

a73x   2026-04-19 09:35

Commit message
scenario: add cell-region predicate evaluator + wire into tick

Pure predicate evaluation over a captured PNG. Predicates
cover: cell-empty, cursor-block/bar/underline-at, and
cell-matches-golden (stubbed pass=false until Plan 3
loads goldens from disk).

Tick's assert-cell and assert-cell-at branches now invoke
the evaluator and return AssertFailed / PredicateOnMissingLabel
on failure. 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>

build.zig
Old New
@@ -400,6 +400,8 @@ pub fn build(b: *std.Build) void {
400 scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod); 400 scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod);
401 const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod }); 401 const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod });
402 test_step.dependOn(&b.addRunArtifact(scenario_tests).step); 402 test_step.dependOn(&b.addRunArtifact(scenario_tests).step);
403 const scenario_test_step = b.step("test-scenario", "Run scenario unit tests only (no Vulkan)");
404 scenario_test_step.dependOn(&b.addRunArtifact(scenario_tests).step);
403 405
404 // imgdiff — standalone PNG comparison CLI 406 // imgdiff — standalone PNG comparison CLI
405 const imgdiff_mod = b.createModule(.{ 407 const imgdiff_mod = b.createModule(.{
src/scenario.zig
Old New
@@ -798,6 +798,7 @@ pub const ScenarioState = struct {
798 scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this" 798 scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
799 sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive 799 sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
800 captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG 800 captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
801 last_capture_label: ?[]const u8, // most recently captured label (owned by alloc)
801 alloc: std.mem.Allocator, 802 alloc: std.mem.Allocator,
802 803
803 pub fn init( 804 pub fn init(
@@ -813,6 +814,7 @@ pub const ScenarioState = struct {
813 .scheduled_offset_ns = 0, 814 .scheduled_offset_ns = 0,
814 .sleep_until_flip_started_ns = null, 815 .sleep_until_flip_started_ns = null,
815 .captures = .{}, 816 .captures = .{},
817 .last_capture_label = null,
816 .alloc = alloc, 818 .alloc = alloc,
817 }; 819 };
818 } 820 }
@@ -824,6 +826,7 @@ pub const ScenarioState = struct {
824 self.alloc.free(entry.value_ptr.pixels); 826 self.alloc.free(entry.value_ptr.pixels);
825 } 827 }
826 self.captures.deinit(self.alloc); 828 self.captures.deinit(self.alloc);
829 if (self.last_capture_label) |lbl| self.alloc.free(lbl);
827 } 830 }
828 831
829 pub fn isDone(self: *const ScenarioState) bool { 832 pub fn isDone(self: *const ScenarioState) bool {
@@ -884,10 +887,41 @@ pub const ScenarioState = struct {
884 const label_copy = try self.alloc.dupe(u8, label); 887 const label_copy = try self.alloc.dupe(u8, label);
885 errdefer self.alloc.free(label_copy); 888 errdefer self.alloc.free(label_copy);
886 try self.captures.put(self.alloc, label_copy, img); 889 try self.captures.put(self.alloc, label_copy, img);
890 // Update last_capture_label: free previous, dupe new from original label.
891 if (self.last_capture_label) |old| self.alloc.free(old);
892 self.last_capture_label = try self.alloc.dupe(u8, label);
893 self.cursor += 1;
894 },
895 .assert_cell => |ac| {
896 const lbl = self.last_capture_label orelse {
897 std.debug.print("scenario: assert-cell with no prior capture\n", .{});
898 return error.AssertFailed;
899 };
900 const img = self.captures.get(lbl).?;
901 const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 };
902 const result = evalPredicate(img, ac.row, ac.col, geom, ac.pred, null);
903 if (!result.pass) {
904 std.debug.print("scenario: assert-cell ({d},{d}) {s} failed: {s}\n", .{
905 ac.row, ac.col, @tagName(ac.pred), result.reason,
906 });
907 return error.AssertFailed;
908 }
909 self.cursor += 1;
910 },
911 .assert_cell_at => |aca| {
912 const img = self.captures.get(aca.label) orelse {
913 return error.PredicateOnMissingLabel;
914 };
915 const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 };
916 const result = evalPredicate(img, aca.row, aca.col, geom, aca.pred, null);
917 if (!result.pass) {
918 std.debug.print("scenario: assert-cell-at {s} ({d},{d}) {s} failed: {s}\n", .{
919 aca.label, aca.row, aca.col, @tagName(aca.pred), result.reason,
920 });
921 return error.AssertFailed;
922 }
887 self.cursor += 1; 923 self.cursor += 1;
888 }, 924 },
889 .assert_cell => unreachable, // Task 3 wires the predicate evaluator.
890 .assert_cell_at => unreachable, // Task 3 wires the predicate evaluator.
891 } 925 }
892 } 926 }
893 927
@@ -914,13 +948,13 @@ const TestIO = struct {
914 const self: *TestIO = @ptrCast(@alignCast(ctx)); 948 const self: *TestIO = @ptrCast(@alignCast(ctx));
915 const label_copy = try self.alloc.dupe(u8, label); 949 const label_copy = try self.alloc.dupe(u8, label);
916 try self.captures_called.append(self.alloc, label_copy); 950 try self.captures_called.append(self.alloc, label_copy);
917 // Return a 1x1 white pixel that the Scenario takes ownership of. 951 // Return a 640×384 all-white PNG (80 cols × 8px, 24 rows × 16px).
918 const pixels = try self.alloc.alloc(u8, 4); 952 // Large enough for predicate tests on any cell in the 80×24 grid.
919 pixels[0] = 255; 953 const w: u32 = 640;
920 pixels[1] = 255; 954 const h: u32 = 384;
921 pixels[2] = 255; 955 const pixels = try self.alloc.alloc(u8, @as(usize, w) * h * 4);
922 pixels[3] = 255; 956 @memset(pixels, 255);
923 return .{ .width = 1, .height = 1, .pixels = pixels }; 957 return .{ .width = w, .height = h, .pixels = pixels };
924 } 958 }
925 959
926 pub fn flipCb(ctx: *anyopaque) bool { 960 pub fn flipCb(ctx: *anyopaque) bool {
@@ -1132,3 +1166,374 @@ test "tick: write_bytes callback failure surfaces as CallbackFailed" {
1132 const r = state.tick(0, io); 1166 const r = state.tick(0, io);
1133 try std.testing.expectError(error.CallbackFailed, r); 1167 try std.testing.expectError(error.CallbackFailed, r);
1134 } 1168 }
1169
1170 // ---------------------------------------------------------------
1171 // Cell-region predicate evaluator
1172 // ---------------------------------------------------------------
1173
1174 pub const EvalResult = struct {
1175 pass: bool,
1176 /// Short diagnostic for failure case (static string where possible).
1177 reason: []const u8 = "",
1178 };
1179
1180 pub const CellGeom = struct {
1181 cell_w_px: u32,
1182 cell_h_px: u32,
1183 };
1184
1185 /// Pixel brightness in [0, 1]: mean of R, G, B channels.
1186 fn pixelBrightness(r: u8, g: u8, b: u8) f64 {
1187 return (@as(f64, @floatFromInt(r)) + @as(f64, @floatFromInt(g)) + @as(f64, @floatFromInt(b))) / (3.0 * 255.0);
1188 }
1189
1190 /// `golden_for_cell_matches` is only consulted by `cell_matches_golden`;
1191 /// callers may pass null otherwise. When null and pred needs it, result
1192 /// is pass=false with reason="missing golden".
1193 pub fn evalPredicate(
1194 image: png.Image,
1195 row: u16,
1196 col: u16,
1197 geom: CellGeom,
1198 pred: Predicate,
1199 golden_for_cell_matches: ?png.Image,
1200 ) EvalResult {
1201 const x0: u32 = @as(u32, col) * geom.cell_w_px;
1202 const y0: u32 = @as(u32, row) * geom.cell_h_px;
1203
1204 // Bounds check: if cell rect extends past image, fail gracefully.
1205 if (x0 + geom.cell_w_px > image.width or y0 + geom.cell_h_px > image.height) {
1206 return .{ .pass = false, .reason = "cell out of bounds" };
1207 }
1208
1209 switch (pred) {
1210 .cell_empty => {
1211 // Pass if every pixel in the cell has brightness ≤ 0.1.
1212 var y: u32 = y0;
1213 while (y < y0 + geom.cell_h_px) : (y += 1) {
1214 var x: u32 = x0;
1215 while (x < x0 + geom.cell_w_px) : (x += 1) {
1216 const off = (@as(usize, y) * image.width + x) * 4;
1217 const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
1218 if (brightness > 0.1) {
1219 return .{ .pass = false, .reason = "bright pixel in supposedly empty cell" };
1220 }
1221 }
1222 }
1223 return .{ .pass = true };
1224 },
1225 .cursor_block_at => {
1226 // Pass if mean brightness over the cell rect is ≥ 0.5.
1227 var sum: f64 = 0;
1228 const total_px: u32 = geom.cell_w_px * geom.cell_h_px;
1229 var y: u32 = y0;
1230 while (y < y0 + geom.cell_h_px) : (y += 1) {
1231 var x: u32 = x0;
1232 while (x < x0 + geom.cell_w_px) : (x += 1) {
1233 const off = (@as(usize, y) * image.width + x) * 4;
1234 sum += pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
1235 }
1236 }
1237 const mean = sum / @as(f64, @floatFromInt(total_px));
1238 if (mean >= 0.5) {
1239 return .{ .pass = true };
1240 }
1241 return .{ .pass = false, .reason = "cell too dark for block cursor" };
1242 },
1243 .cursor_bar_at => {
1244 // Pass if centroid x of bright pixels (brightness ≥ 0.5) is in left third
1245 // AND bright pixel count ≥ cell_h_px * 2 (matching 2px bar width).
1246 var bright_count: u32 = 0;
1247 var centroid_x_sum: f64 = 0;
1248 var y: u32 = y0;
1249 while (y < y0 + geom.cell_h_px) : (y += 1) {
1250 var x: u32 = x0;
1251 while (x < x0 + geom.cell_w_px) : (x += 1) {
1252 const off = (@as(usize, y) * image.width + x) * 4;
1253 const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
1254 if (brightness >= 0.5) {
1255 bright_count += 1;
1256 // x relative to cell origin
1257 centroid_x_sum += @as(f64, @floatFromInt(x - x0));
1258 }
1259 }
1260 }
1261 const threshold: u32 = geom.cell_h_px * 2;
1262 if (bright_count < threshold) {
1263 return .{ .pass = false, .reason = "bar cursor centroid not at cell left" };
1264 }
1265 const centroid_x = centroid_x_sum / @as(f64, @floatFromInt(bright_count));
1266 const left_third = @as(f64, @floatFromInt(geom.cell_w_px)) / 3.0;
1267 if (centroid_x < left_third) {
1268 return .{ .pass = true };
1269 }
1270 return .{ .pass = false, .reason = "bar cursor centroid not at cell left" };
1271 },
1272 .cursor_underline_at => {
1273 // Pass if centroid y of bright pixels is in bottom third
1274 // AND bright pixel count ≥ cell_w_px * 2.
1275 var bright_count: u32 = 0;
1276 var centroid_y_sum: f64 = 0;
1277 var y: u32 = y0;
1278 while (y < y0 + geom.cell_h_px) : (y += 1) {
1279 var x: u32 = x0;
1280 while (x < x0 + geom.cell_w_px) : (x += 1) {
1281 const off = (@as(usize, y) * image.width + x) * 4;
1282 const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
1283 if (brightness >= 0.5) {
1284 bright_count += 1;
1285 // y relative to cell origin
1286 centroid_y_sum += @as(f64, @floatFromInt(y - y0));
1287 }
1288 }
1289 }
1290 const threshold: u32 = geom.cell_w_px * 2;
1291 if (bright_count < threshold) {
1292 return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" };
1293 }
1294 const centroid_y = centroid_y_sum / @as(f64, @floatFromInt(bright_count));
1295 const bottom_third_start = @as(f64, @floatFromInt(geom.cell_h_px)) * 2.0 / 3.0;
1296 if (centroid_y >= bottom_third_start) {
1297 return .{ .pass = true };
1298 }
1299 return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" };
1300 },
1301 .cell_matches_golden => {
1302 const golden = golden_for_cell_matches orelse {
1303 return .{ .pass = false, .reason = "missing golden" };
1304 };
1305 // Compute RMSE over the cell rect inline.
1306 var sum_sq: f64 = 0;
1307 const total_px: u32 = geom.cell_w_px * geom.cell_h_px;
1308 var y: u32 = y0;
1309 while (y < y0 + geom.cell_h_px) : (y += 1) {
1310 var x: u32 = x0;
1311 while (x < x0 + geom.cell_w_px) : (x += 1) {
1312 const off = (@as(usize, y) * image.width + x) * 4;
1313 // Also bounds-check golden image.
1314 if (off + 3 >= golden.pixels.len) {
1315 return .{ .pass = false, .reason = "golden image too small for cell rect" };
1316 }
1317 const dr = (@as(f64, @floatFromInt(image.pixels[off + 0])) - @as(f64, @floatFromInt(golden.pixels[off + 0]))) / 255.0;
1318 const dg = (@as(f64, @floatFromInt(image.pixels[off + 1])) - @as(f64, @floatFromInt(golden.pixels[off + 1]))) / 255.0;
1319 const db = (@as(f64, @floatFromInt(image.pixels[off + 2])) - @as(f64, @floatFromInt(golden.pixels[off + 2]))) / 255.0;
1320 sum_sq += (dr * dr + dg * dg + db * db) / 3.0;
1321 }
1322 }
1323 const rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(total_px)));
1324 if (rmse <= imgdiff.RMSE_DEFAULT) {
1325 return .{ .pass = true };
1326 }
1327 return .{ .pass = false, .reason = "cell RMSE exceeds threshold" };
1328 },
1329 }
1330 }
1331
1332 // ---------------------------------------------------------------
1333 // Predicate evaluator tests
1334 // ---------------------------------------------------------------
1335
1336 /// Build a width×height PNG where every pixel is `color` (RGBA).
1337 fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image {
1338 const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4);
1339 var i: usize = 0;
1340 while (i < pixels.len) : (i += 4) {
1341 pixels[i + 0] = color[0];
1342 pixels[i + 1] = color[1];
1343 pixels[i + 2] = color[2];
1344 pixels[i + 3] = color[3];
1345 }
1346 return .{ .width = w, .height = h, .pixels = pixels };
1347 }
1348
1349 fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void {
1350 const start_x: u32 = @as(u32, col) * geom.cell_w_px;
1351 const start_y: u32 = @as(u32, row) * geom.cell_h_px;
1352 var y: u32 = start_y;
1353 while (y < start_y + geom.cell_h_px) : (y += 1) {
1354 var x: u32 = start_x;
1355 while (x < start_x + geom.cell_w_px) : (x += 1) {
1356 const off = (@as(usize, y) * img.width + x) * 4;
1357 img.pixels[off + 0] = color[0];
1358 img.pixels[off + 1] = color[1];
1359 img.pixels[off + 2] = color[2];
1360 img.pixels[off + 3] = color[3];
1361 }
1362 }
1363 }
1364
1365 test "evalPredicate: cell out of bounds returns pass=false" {
1366 const alloc = std.testing.allocator;
1367 const img = try makeSolid(alloc, 8, 16, .{ 0, 0, 0, 255 });
1368 defer alloc.free(img.pixels);
1369 // Cell (0,1) would require x=8..16 but image is only 8 wide.
1370 const r = evalPredicate(img, 0, 1, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
1371 try std.testing.expect(!r.pass);
1372 try std.testing.expect(std.mem.indexOf(u8, r.reason, "out of bounds") != null);
1373 }
1374
1375 test "evalPredicate: cell-empty passes on all-black image" {
1376 const alloc = std.testing.allocator;
1377 const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1378 defer alloc.free(img.pixels);
1379 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
1380 try std.testing.expect(r.pass);
1381 }
1382
1383 test "evalPredicate: cell-empty fails on image with bright cell" {
1384 const alloc = std.testing.allocator;
1385 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1386 defer alloc.free(img.pixels);
1387 // Make cell (0,0)'s first pixel bright.
1388 img.pixels[0] = 255;
1389 img.pixels[1] = 255;
1390 img.pixels[2] = 255;
1391 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
1392 try std.testing.expect(!r.pass);
1393 }
1394
1395 test "evalPredicate: cursor-block-at passes when cell is mostly bright" {
1396 const alloc = std.testing.allocator;
1397 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1398 defer alloc.free(img.pixels);
1399 // Fill cell (0,0)'s 8x16 rect with white.
1400 fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 });
1401 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
1402 try std.testing.expect(r.pass);
1403 }
1404
1405 test "evalPredicate: cursor-block-at fails when cell is dark" {
1406 const alloc = std.testing.allocator;
1407 const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1408 defer alloc.free(img.pixels);
1409 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
1410 try std.testing.expect(!r.pass);
1411 }
1412
1413 test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" {
1414 const alloc = std.testing.allocator;
1415 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1416 defer alloc.free(img.pixels);
1417 // Paint only the leftmost 2 pixels of cell (0,0), full height.
1418 var y: u32 = 0;
1419 while (y < 16) : (y += 1) {
1420 var x: u32 = 0;
1421 while (x < 2) : (x += 1) {
1422 const off = (@as(usize, y) * 80 + x) * 4;
1423 img.pixels[off + 0] = 255;
1424 img.pixels[off + 1] = 255;
1425 img.pixels[off + 2] = 255;
1426 }
1427 }
1428 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
1429 try std.testing.expect(r.pass);
1430 }
1431
1432 test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" {
1433 const alloc = std.testing.allocator;
1434 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1435 defer alloc.free(img.pixels);
1436 // Paint only the rightmost 2 pixels of cell (0,0), full height.
1437 var y: u32 = 0;
1438 while (y < 16) : (y += 1) {
1439 var x: u32 = 6;
1440 while (x < 8) : (x += 1) {
1441 const off = (@as(usize, y) * 80 + x) * 4;
1442 img.pixels[off + 0] = 255;
1443 img.pixels[off + 1] = 255;
1444 img.pixels[off + 2] = 255;
1445 }
1446 }
1447 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
1448 try std.testing.expect(!r.pass);
1449 }
1450
1451 test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" {
1452 const alloc = std.testing.allocator;
1453 var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
1454 defer alloc.free(img.pixels);
1455 // Paint only the bottom 2 rows of cell (0,0), full width.
1456 var y: u32 = 14;
1457 while (y < 16) : (y += 1) {
1458 var x: u32 = 0;
1459 while (x < 8) : (x += 1) {
1460 const off = (@as(usize, y) * 80 + x) * 4;
1461 img.pixels[off + 0] = 255;
1462 img.pixels[off + 1] = 255;
1463 img.pixels[off + 2] = 255;
1464 }
1465 }
1466 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null);
1467 try std.testing.expect(r.pass);
1468 }
1469
1470 test "evalPredicate: cell-matches-golden passes when images match in the cell rect" {
1471 const alloc = std.testing.allocator;
1472 // 640×384: 80 cols × 8px wide, 24 rows × 16px tall — enough for cell (5,3).
1473 const img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
1474 defer alloc.free(img.pixels);
1475 const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
1476 defer alloc.free(golden.pixels);
1477 const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
1478 try std.testing.expect(r.pass);
1479 }
1480
1481 test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" {
1482 const alloc = std.testing.allocator;
1483 var img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
1484 defer alloc.free(img.pixels);
1485 const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
1486 defer alloc.free(golden.pixels);
1487 // Corrupt cell (5,3) in img.
1488 fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 });
1489 const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
1490 try std.testing.expect(!r.pass);
1491 }
1492
1493 test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" {
1494 const alloc = std.testing.allocator;
1495 const img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
1496 defer alloc.free(img.pixels);
1497 const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null);
1498 try std.testing.expect(!r.pass);
1499 try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null);
1500 }
1501
1502 // End-to-end tests: parse + state + predicate
1503
1504 test "tick+eval: assert-cell evaluates against last capture" {
1505 var s = try parseOk(
1506 \\size 80 24
1507 \\timeout 5000ms
1508 \\capture snap
1509 \\assert-cell 0 0 cursor-block-at
1510 );
1511 defer s.deinit();
1512 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1513 defer state.deinit();
1514
1515 var tio = TestIO{ .alloc = std.testing.allocator };
1516 defer tio.deinit();
1517
1518 // TestIO.captureCb returns a 640×384 all-white PNG; cursor_block_at on cell (0,0)
1519 // will pass because mean brightness of an all-white cell is 1.0 >= 0.5.
1520 const r = try state.tick(0, tio.io());
1521 try std.testing.expectEqual(TickOutcome.done, r);
1522 }
1523
1524 test "tick+eval: assert-cell-at on missing label errors" {
1525 var s = try parseOk(
1526 \\size 80 24
1527 \\timeout 5000ms
1528 \\assert-cell-at nope 0 0 cell-empty
1529 );
1530 defer s.deinit();
1531 var state = ScenarioState.init(std.testing.allocator, &s, 0);
1532 defer state.deinit();
1533
1534 var tio = TestIO{ .alloc = std.testing.allocator };
1535 defer tio.deinit();
1536
1537 const r = state.tick(0, tio.io());
1538 try std.testing.expectError(error.PredicateOnMissingLabel, r);
1539 }