d725cafd
Plan 1 of 3 for scenario runner: imgdiff extraction
a73x 2026-04-19 08:40
Commit message
docs/superpowers/plans/2026-04-19-imgdiff-extraction.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,434 @@ | |||
| 1 | # Imgdiff Extraction 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:** Extract the pure RMSE + max-pixel + diff-heatmap math from `src/tools/imgdiff.zig` into a new reusable module `src/imgdiff.zig`, so the scenario runner (later plans) can call the same code as the existing CLI. | ||
| 6 | |||
| 7 | **Architecture:** Pure-math module with `compare`, `makeDiffImage`, `DiffResult`, and the two default threshold constants (`RMSE_DEFAULT`, `PIXEL_MAX_DEFAULT`). The existing CLI at `src/tools/imgdiff.zig` shrinks to a thin `main` + env-var parsing that imports the new module. Zero behavior change for the CLI. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.15, existing `png` module. | ||
| 10 | |||
| 11 | **Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md` — implementation phasing step 1 of 3. | ||
| 12 | |||
| 13 | --- | ||
| 14 | |||
| 15 | ## Reference facts (grep once, reuse) | ||
| 16 | |||
| 17 | - Existing CLI + math lives in `src/tools/imgdiff.zig` (152 lines). The pure math is the `compare` function + `DiffResult` struct + `makeDiffImage` helper. The CLI-side logic is `main`, `readFloatEnv`, env-var defaults (`RMSE` default `0.005`, `PIXEL_MAX` default `0.125`), argument parsing, PNG encode/decode, and stderr reporting. | ||
| 18 | - Build wiring in `build.zig:368-388` creates `imgdiff_mod` (executable) and `imgdiff_test_mod` (tests). Both use `src/tools/imgdiff.zig` as `root_source_file`. | ||
| 19 | - `png.Image` struct is `{ width: u32, height: u32, pixels: []u8 }` (BGRA/RGBA 4 bytes-per-pixel). | ||
| 20 | - This plan has no Vulkan, no Wayland, no main-loop touchpoints. Pure refactor. | ||
| 21 | - Zig's `zig build test` runs all test modules registered on `test_step`. The existing `imgdiff_tests` contributes two tests; this plan adds one more to the new module. | ||
| 22 | |||
| 23 | --- | ||
| 24 | |||
| 25 | ## File structure after this plan | ||
| 26 | |||
| 27 | - **`src/imgdiff.zig`** (NEW) — pure module. Public: `DiffResult`, `compare(a, b)`, `makeDiffImage(alloc, a, b)`, `RMSE_DEFAULT`, `PIXEL_MAX_DEFAULT`. Inline unit tests stay with the code. | ||
| 28 | - **`src/tools/imgdiff.zig`** (MODIFIED, shrunk) — CLI only. `main`, `readFloatEnv`. Imports `imgdiff` module for the math + constants. | ||
| 29 | - **`build.zig`** (MODIFIED) — add a new `imgdiff_mod` module (name collision with the existing variable; we'll rename — see task 2); wire it as an import on both the CLI exe and its test module. | ||
| 30 | |||
| 31 | --- | ||
| 32 | |||
| 33 | ## Task 1: Create `src/imgdiff.zig` as a pure module | ||
| 34 | |||
| 35 | **Files:** | ||
| 36 | - Create: `src/imgdiff.zig` | ||
| 37 | - Modify: `build.zig:368-388` — register the new module and its tests | ||
| 38 | |||
| 39 | **Goal of this task:** Stand up the new module alongside the existing CLI. The CLI keeps working unchanged. The new module has its own tests and runs green. | ||
| 40 | |||
| 41 | - [ ] **Step 1: Create `src/imgdiff.zig` with the pure math + constants + three tests.** | ||
| 42 | |||
| 43 | ```zig | ||
| 44 | //! Pure-math PNG image diff used by both the `imgdiff` CLI | ||
| 45 | //! (src/tools/imgdiff.zig) and the scenario runner (to be added later). | ||
| 46 | //! Threshold constants live here so both callers agree. | ||
| 47 | |||
| 48 | const std = @import("std"); | ||
| 49 | const png = @import("png"); | ||
| 50 | |||
| 51 | pub const RMSE_DEFAULT: f64 = 0.005; | ||
| 52 | pub const PIXEL_MAX_DEFAULT: f64 = 0.125; | ||
| 53 | |||
| 54 | pub const DiffResult = struct { | ||
| 55 | rmse: f64, // [0, 1] | ||
| 56 | max_pixel: f64, // [0, 1] | ||
| 57 | pixel_count: usize, | ||
| 58 | }; | ||
| 59 | |||
| 60 | pub fn compare(a: png.Image, b: png.Image) !DiffResult { | ||
| 61 | if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer; | ||
| 62 | std.debug.assert(a.pixels.len == b.pixels.len); | ||
| 63 | |||
| 64 | const px_count = @as(usize, a.width) * a.height; | ||
| 65 | var sum_sq: f64 = 0; | ||
| 66 | var max_d: f64 = 0; | ||
| 67 | |||
| 68 | var i: usize = 0; | ||
| 69 | while (i < px_count) : (i += 1) { | ||
| 70 | const off = i * 4; | ||
| 71 | const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0; | ||
| 72 | const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0; | ||
| 73 | const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0; | ||
| 74 | const d_sq = (dr * dr + dg * dg + db * db) / 3.0; | ||
| 75 | sum_sq += d_sq; | ||
| 76 | const d = @sqrt(d_sq); | ||
| 77 | if (d > max_d) max_d = d; | ||
| 78 | } | ||
| 79 | |||
| 80 | return .{ | ||
| 81 | .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))), | ||
| 82 | .max_pixel = max_d, | ||
| 83 | .pixel_count = px_count, | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | pub fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image { | ||
| 88 | // Side-by-side: [actual | reference | delta-heatmap] | ||
| 89 | const w = a.width * 3; | ||
| 90 | const h = a.height; | ||
| 91 | const pixels = try alloc.alloc(u8, w * h * 4); | ||
| 92 | var y: u32 = 0; | ||
| 93 | while (y < h) : (y += 1) { | ||
| 94 | const row_off = @as(usize, y) * w * 4; | ||
| 95 | const a_off = @as(usize, y) * a.width * 4; | ||
| 96 | @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]); | ||
| 97 | @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]); | ||
| 98 | var x: u32 = 0; | ||
| 99 | while (x < a.width) : (x += 1) { | ||
| 100 | const off = a_off + x * 4; | ||
| 101 | const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0; | ||
| 102 | const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0; | ||
| 103 | const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0; | ||
| 104 | const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0); | ||
| 105 | const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0)); | ||
| 106 | const dst = row_off + 2 * a.width * 4 + x * 4; | ||
| 107 | pixels[dst + 0] = brightness; | ||
| 108 | pixels[dst + 1] = brightness; | ||
| 109 | pixels[dst + 2] = brightness; | ||
| 110 | pixels[dst + 3] = 255; | ||
| 111 | } | ||
| 112 | } | ||
| 113 | return .{ .width = w, .height = h, .pixels = pixels }; | ||
| 114 | } | ||
| 115 | |||
| 116 | test "identical images produce zero RMSE" { | ||
| 117 | var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 }; | ||
| 118 | var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 }; | ||
| 119 | const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a }; | ||
| 120 | const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b }; | ||
| 121 | const r = try compare(a, b); | ||
| 122 | try std.testing.expectEqual(@as(f64, 0.0), r.rmse); | ||
| 123 | try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel); | ||
| 124 | } | ||
| 125 | |||
| 126 | test "fully saturated difference produces rmse=1.0 and max=1.0" { | ||
| 127 | var pixels_a = [_]u8{ 0, 0, 0, 255 }; | ||
| 128 | var pixels_b = [_]u8{ 255, 255, 255, 255 }; | ||
| 129 | const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a }; | ||
| 130 | const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b }; | ||
| 131 | const r = try compare(a, b); | ||
| 132 | try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9); | ||
| 133 | try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9); | ||
| 134 | } | ||
| 135 | |||
| 136 | test "makeDiffImage: width triples, heatmap brighter where delta is larger" { | ||
| 137 | const alloc = std.testing.allocator; | ||
| 138 | // 2x1 image pair where one column differs more than the other. | ||
| 139 | var pixels_a = [_]u8{ 0, 0, 0, 255, 100, 100, 100, 255 }; | ||
| 140 | var pixels_b = [_]u8{ 0, 0, 0, 255, 200, 200, 200, 255 }; | ||
| 141 | const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a }; | ||
| 142 | const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b }; | ||
| 143 | const out = try makeDiffImage(alloc, a, b); | ||
| 144 | defer alloc.free(out.pixels); | ||
| 145 | try std.testing.expectEqual(@as(u32, 6), out.width); | ||
| 146 | try std.testing.expectEqual(@as(u32, 1), out.height); | ||
| 147 | // Heatmap is the last third. Column 0 (no delta) should be black; | ||
| 148 | // column 1 (delta) should be non-zero. | ||
| 149 | const heatmap_offset = 4 * 4; // bytes before heatmap (2 images × 2 pixels × 4 bytes = 16) | ||
| 150 | try std.testing.expectEqual(@as(u8, 0), out.pixels[heatmap_offset + 0]); // R at heatmap col 0 | ||
| 151 | try std.testing.expect(out.pixels[heatmap_offset + 4] > 0); // R at heatmap col 1 | ||
| 152 | } | ||
| 153 | ``` | ||
| 154 | |||
| 155 | - [ ] **Step 2: Wire `src/imgdiff.zig` as a module in `build.zig`.** | ||
| 156 | |||
| 157 | Current state of `build.zig:368-388`: | ||
| 158 | |||
| 159 | ```zig | ||
| 160 | // imgdiff — standalone PNG comparison tool | ||
| 161 | const imgdiff_mod = b.createModule(.{ | ||
| 162 | .root_source_file = b.path("src/tools/imgdiff.zig"), | ||
| 163 | .target = target, | ||
| 164 | .optimize = optimize, | ||
| 165 | }); | ||
| 166 | imgdiff_mod.addImport("png", png_mod); | ||
| 167 | const imgdiff_exe = b.addExecutable(.{ | ||
| 168 | .name = "imgdiff", | ||
| 169 | .root_module = imgdiff_mod, | ||
| 170 | }); | ||
| 171 | b.installArtifact(imgdiff_exe); | ||
| 172 | |||
| 173 | const imgdiff_test_mod = b.createModule(.{ | ||
| 174 | .root_source_file = b.path("src/tools/imgdiff.zig"), | ||
| 175 | .target = target, | ||
| 176 | .optimize = optimize, | ||
| 177 | }); | ||
| 178 | imgdiff_test_mod.addImport("png", png_mod); | ||
| 179 | const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod }); | ||
| 180 | test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step); | ||
| 181 | ``` | ||
| 182 | |||
| 183 | Replace with: | ||
| 184 | |||
| 185 | ```zig | ||
| 186 | // imgdiff — pure library used by the CLI + the scenario runner | ||
| 187 | const imgdiff_lib_mod = b.createModule(.{ | ||
| 188 | .root_source_file = b.path("src/imgdiff.zig"), | ||
| 189 | .target = target, | ||
| 190 | .optimize = optimize, | ||
| 191 | }); | ||
| 192 | imgdiff_lib_mod.addImport("png", png_mod); | ||
| 193 | |||
| 194 | const imgdiff_lib_test_mod = b.createModule(.{ | ||
| 195 | .root_source_file = b.path("src/imgdiff.zig"), | ||
| 196 | .target = target, | ||
| 197 | .optimize = optimize, | ||
| 198 | }); | ||
| 199 | imgdiff_lib_test_mod.addImport("png", png_mod); | ||
| 200 | const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod }); | ||
| 201 | test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step); | ||
| 202 | |||
| 203 | // imgdiff — standalone PNG comparison CLI | ||
| 204 | const imgdiff_mod = b.createModule(.{ | ||
| 205 | .root_source_file = b.path("src/tools/imgdiff.zig"), | ||
| 206 | .target = target, | ||
| 207 | .optimize = optimize, | ||
| 208 | }); | ||
| 209 | imgdiff_mod.addImport("png", png_mod); | ||
| 210 | imgdiff_mod.addImport("imgdiff", imgdiff_lib_mod); | ||
| 211 | const imgdiff_exe = b.addExecutable(.{ | ||
| 212 | .name = "imgdiff", | ||
| 213 | .root_module = imgdiff_mod, | ||
| 214 | }); | ||
| 215 | b.installArtifact(imgdiff_exe); | ||
| 216 | |||
| 217 | const imgdiff_test_mod = b.createModule(.{ | ||
| 218 | .root_source_file = b.path("src/tools/imgdiff.zig"), | ||
| 219 | .target = target, | ||
| 220 | .optimize = optimize, | ||
| 221 | }); | ||
| 222 | imgdiff_test_mod.addImport("png", png_mod); | ||
| 223 | imgdiff_test_mod.addImport("imgdiff", imgdiff_lib_mod); | ||
| 224 | const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod }); | ||
| 225 | test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step); | ||
| 226 | ``` | ||
| 227 | |||
| 228 | The library module (`imgdiff_lib_mod`) is a separate build unit from the CLI module (`imgdiff_mod`). Both reach `png`; the CLI additionally imports the library. Both have their tests registered. | ||
| 229 | |||
| 230 | At this point the CLI still has its own copy of `compare` / `makeDiffImage` / tests — Task 2 removes them. This task only *adds* the new module. Intermediate state: duplication between `src/imgdiff.zig` and `src/tools/imgdiff.zig`. That's fine for one commit; Task 2 removes the duplication. | ||
| 231 | |||
| 232 | - [ ] **Step 3: Build and run tests to verify the new module compiles and its tests pass.** | ||
| 233 | |||
| 234 | Run: | ||
| 235 | ```bash | ||
| 236 | cd /home/xanderle/code/rad/waystty | ||
| 237 | zig build test 2>&1 | tail -30 | ||
| 238 | ``` | ||
| 239 | |||
| 240 | Expected: | ||
| 241 | - Build succeeds. | ||
| 242 | - All existing tests still pass. | ||
| 243 | - Three new tests (from `src/imgdiff.zig`) show up in the count. | ||
| 244 | |||
| 245 | If the test runner reports a test-count mismatch relative to before, that's the expected addition of three new tests. If there are *fewer* tests, the old `src/tools/imgdiff.zig` tests didn't get picked up — investigate before committing. | ||
| 246 | |||
| 247 | - [ ] **Step 4: Commit.** | ||
| 248 | |||
| 249 | ```bash | ||
| 250 | git add src/imgdiff.zig build.zig | ||
| 251 | git commit -m "$(cat <<'EOF' | ||
| 252 | imgdiff: add src/imgdiff.zig pure-math module | ||
| 253 | |||
| 254 | Copies compare + DiffResult + makeDiffImage into a standalone | ||
| 255 | module so it can be reused by the upcoming scenario runner. | ||
| 256 | CLI at src/tools/imgdiff.zig is untouched this commit; dedup | ||
| 257 | happens in the next commit. | ||
| 258 | |||
| 259 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 260 | EOF | ||
| 261 | )" | ||
| 262 | ``` | ||
| 263 | |||
| 264 | --- | ||
| 265 | |||
| 266 | ## Task 2: Shrink `src/tools/imgdiff.zig` to a thin CLI | ||
| 267 | |||
| 268 | **Files:** | ||
| 269 | - Modify: `src/tools/imgdiff.zig` — remove `DiffResult`, `compare`, `makeDiffImage`, inline tests, replace with `@import("imgdiff")` usage. | ||
| 270 | |||
| 271 | - [ ] **Step 1: Replace `src/tools/imgdiff.zig` with a CLI-only version.** | ||
| 272 | |||
| 273 | Full new contents of `src/tools/imgdiff.zig`: | ||
| 274 | |||
| 275 | ```zig | ||
| 276 | const std = @import("std"); | ||
| 277 | const png = @import("png"); | ||
| 278 | const imgdiff = @import("imgdiff"); | ||
| 279 | |||
| 280 | pub fn main() !void { | ||
| 281 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 282 | defer _ = gpa.deinit(); | ||
| 283 | const alloc = gpa.allocator(); | ||
| 284 | |||
| 285 | const args = try std.process.argsAlloc(alloc); | ||
| 286 | defer std.process.argsFree(alloc, args); | ||
| 287 | |||
| 288 | if (args.len < 3) { | ||
| 289 | std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{}); | ||
| 290 | std.process.exit(2); | ||
| 291 | } | ||
| 292 | const actual_path = args[1]; | ||
| 293 | const reference_path = args[2]; | ||
| 294 | const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null; | ||
| 295 | |||
| 296 | const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", imgdiff.RMSE_DEFAULT); | ||
| 297 | const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", imgdiff.PIXEL_MAX_DEFAULT); | ||
| 298 | |||
| 299 | const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024); | ||
| 300 | defer alloc.free(actual_bytes); | ||
| 301 | const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024); | ||
| 302 | defer alloc.free(reference_bytes); | ||
| 303 | |||
| 304 | var actual = try png.decode(alloc, actual_bytes); | ||
| 305 | defer actual.deinit(alloc); | ||
| 306 | var reference = try png.decode(alloc, reference_bytes); | ||
| 307 | defer reference.deinit(alloc); | ||
| 308 | |||
| 309 | if (actual.width != reference.width or actual.height != reference.height) { | ||
| 310 | std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n", .{ actual.width, actual.height, reference.width, reference.height }); | ||
| 311 | std.process.exit(3); | ||
| 312 | } | ||
| 313 | |||
| 314 | const r = try imgdiff.compare(actual, reference); | ||
| 315 | const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max; | ||
| 316 | |||
| 317 | if (pass) { | ||
| 318 | std.debug.print("OK: {s} RMSE={d:.4}% worst={d:.4}%\n", .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 }); | ||
| 319 | std.process.exit(0); | ||
| 320 | } | ||
| 321 | |||
| 322 | std.debug.print("FAIL: {s}\n RMSE: {d:.4}% (max {d:.4}%)\n worst pixel: {d:.4}% (max {d:.4}%)\n", .{ reference_path, r.rmse * 100.0, rmse_max * 100.0, r.max_pixel * 100.0, pixel_max * 100.0 }); | ||
| 323 | |||
| 324 | if (diff_path) |p| { | ||
| 325 | const diff_img = try imgdiff.makeDiffImage(alloc, actual, reference); | ||
| 326 | defer alloc.free(diff_img.pixels); | ||
| 327 | |||
| 328 | var buf: std.ArrayList(u8) = .empty; | ||
| 329 | defer buf.deinit(alloc); | ||
| 330 | try png.encode(alloc, diff_img, buf.writer(alloc)); | ||
| 331 | |||
| 332 | const out = try std.fs.cwd().createFile(p, .{ .truncate = true }); | ||
| 333 | defer out.close(); | ||
| 334 | try out.writeAll(buf.items); | ||
| 335 | |||
| 336 | std.debug.print(" diff: {s}\n", .{p}); | ||
| 337 | } | ||
| 338 | std.debug.print(" actual: {s}\n", .{actual_path}); | ||
| 339 | std.process.exit(1); | ||
| 340 | } | ||
| 341 | |||
| 342 | fn readFloatEnv(name: []const u8, default: f64) f64 { | ||
| 343 | const val = std.posix.getenv(name) orelse return default; | ||
| 344 | return std.fmt.parseFloat(f64, val) catch default; | ||
| 345 | } | ||
| 346 | ``` | ||
| 347 | |||
| 348 | What's gone from the old file: `pub const DiffResult`, `pub fn compare`, `fn makeDiffImage`, both `test "identical images ..."` and `test "fully saturated ..."` blocks. What's new: the `imgdiff` import and calls to `imgdiff.compare` / `imgdiff.makeDiffImage` / `imgdiff.RMSE_DEFAULT` / `imgdiff.PIXEL_MAX_DEFAULT`. | ||
| 349 | |||
| 350 | - [ ] **Step 2: Build everything.** | ||
| 351 | |||
| 352 | Run: | ||
| 353 | ```bash | ||
| 354 | cd /home/xanderle/code/rad/waystty | ||
| 355 | zig build 2>&1 | tail -20 | ||
| 356 | ``` | ||
| 357 | |||
| 358 | Expected: build succeeds, `imgdiff` binary still links. | ||
| 359 | |||
| 360 | - [ ] **Step 3: Run unit tests.** | ||
| 361 | |||
| 362 | Run: | ||
| 363 | ```bash | ||
| 364 | zig build test 2>&1 | tail -30 | ||
| 365 | ``` | ||
| 366 | |||
| 367 | Expected: all tests pass. Note: the `imgdiff_tests` test module (the CLI's test module) now has zero tests inside it because we moved them all — that's fine; it just means the test runner reports zero tests for that module while the new `imgdiff_lib_tests` reports three. The total count should be **unchanged minus 2 plus 3 = net +1** relative to before Task 1. | ||
| 368 | |||
| 369 | - [ ] **Step 4: Smoke test the CLI against an existing golden.** | ||
| 370 | |||
| 371 | Run: | ||
| 372 | ```bash | ||
| 373 | cd /home/xanderle/code/rad/waystty | ||
| 374 | zig build | ||
| 375 | ./zig-out/bin/imgdiff tests/golden/reference/basic_ascii.png tests/golden/reference/basic_ascii.png | ||
| 376 | ``` | ||
| 377 | |||
| 378 | Expected: `OK: tests/golden/reference/basic_ascii.png RMSE=0.0000% worst=0.0000%`, exit code 0. Identical image to itself → zero diff. | ||
| 379 | |||
| 380 | Then a failing-diff smoke: | ||
| 381 | ```bash | ||
| 382 | ./zig-out/bin/imgdiff tests/golden/reference/basic_ascii.png tests/golden/reference/bold_colors.png || echo "exit=$?" | ||
| 383 | ``` | ||
| 384 | |||
| 385 | Expected: `FAIL: ...` output, exit code 1 (or 3 if dimensions differ — `basic_ascii.png` and `bold_colors.png` are both 80×24 grids so dimensions match; exit 1 is the normal-diff fail). | ||
| 386 | |||
| 387 | If both these smoke cases behave the same as before the refactor, the CLI has not regressed. | ||
| 388 | |||
| 389 | - [ ] **Step 5: Run the full render-test suite (which uses imgdiff CLI).** | ||
| 390 | |||
| 391 | ```bash | ||
| 392 | zig build test-render 2>&1 | tail -20 | ||
| 393 | ``` | ||
| 394 | |||
| 395 | Expected: all golden scripts pass (`basic_ascii`, `bold_colors`, `box_drawing` at least, as of spec-writing time). If this step fails, the CLI refactor broke the existing regression gate — investigate before committing. | ||
| 396 | |||
| 397 | - [ ] **Step 6: Commit.** | ||
| 398 | |||
| 399 | ```bash | ||
| 400 | git add src/tools/imgdiff.zig | ||
| 401 | git commit -m "$(cat <<'EOF' | ||
| 402 | imgdiff: shrink CLI to thin wrapper over src/imgdiff.zig | ||
| 403 | |||
| 404 | Removes duplicated pure-math (compare, makeDiffImage, DiffResult, | ||
| 405 | inline tests) from the CLI. CLI now imports the library module | ||
| 406 | for both the comparison and the default threshold constants. | ||
| 407 | |||
| 408 | No behavior change. test-render still passes against existing | ||
| 409 | golden PNGs. | ||
| 410 | |||
| 411 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 412 | EOF | ||
| 413 | )" | ||
| 414 | ``` | ||
| 415 | |||
| 416 | --- | ||
| 417 | |||
| 418 | ## Post-task verification | ||
| 419 | |||
| 420 | - [ ] `zig build` passes with no warnings. | ||
| 421 | - [ ] `zig build test` passes (three `imgdiff_lib_tests` + zero `imgdiff_tests` + everything else unchanged). | ||
| 422 | - [ ] `zig build test-render` still passes against existing goldens. | ||
| 423 | - [ ] `./zig-out/bin/imgdiff` CLI output format is byte-identical to pre-refactor (re-run the two smoke cases in Task 2 Step 4 if unsure). | ||
| 424 | - [ ] `src/imgdiff.zig` is importable by future code as `@import("imgdiff")` on any module that declares it — this is what plan 2 (scenario parser) will rely on. | ||
| 425 | |||
| 426 | --- | ||
| 427 | |||
| 428 | ## Self-review coverage check | ||
| 429 | |||
| 430 | Spec "Implementation phasing" step 1 says: move pure math into `src/imgdiff.zig`, keep CLI as thin wrapper. Task 1 adds the module and its tests; Task 2 shrinks the CLI. Post-task verification confirms no CLI behavior change. ✅ covered. | ||
| 431 | |||
| 432 | Spec "Files touched" lists `src/imgdiff.zig` as new and `src/tools/imgdiff.zig` as the thin wrapper. ✅ matches. | ||
| 433 | |||
| 434 | Spec doesn't require any changes outside imgdiff for plan 1 — plan 2 and plan 3 will consume `src/imgdiff.zig`. | ||