1f681745
scenario: add cell-region predicate evaluator + wire into tick
a73x 2026-04-19 09:35
Commit message
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 | } | ||