diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-02 20:06:25 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | bead9f0b78380d1428f40fdab339c1c8f88fa626 (patch) | |
| tree | 960255de5a2cef661ec5d0ad3220a4b2b1738842 /e2e_harness_sdl.zig | |
| parent | 62ef1882ab7878210f05f06504796849e412c318 (diff) | |
| download | pardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.tar.gz pardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.zip | |
sdl ui backend
Diffstat (limited to 'e2e_harness_sdl.zig')
| -rw-r--r-- | e2e_harness_sdl.zig | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/e2e_harness_sdl.zig b/e2e_harness_sdl.zig new file mode 100644 index 00000000..cac25473 --- /dev/null +++ b/e2e_harness_sdl.zig @@ -0,0 +1,332 @@ +// E2E harness for the SDL3 GPU backend's rendered-image test mode. +// +// The child app runs the real SDL/GPU renderer. Input is still delivered through +// a pty so the same terminal escape sequences exercise the same app input path, +// but rendered frames are captured by the app as PPM image files in a temp +// directory. Assertions inspect pixels, not the retained cell grid. +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; + +extern "c" fn forkpty( + amaster: *c_int, + name: ?[*:0]u8, + termp: ?*const anyopaque, + winp: ?*const posix.winsize, +) c_int; +extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn _exit(status: c_int) noreturn; +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + +fn nowMs() i64 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); + return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000); +} + +pub const Image = struct { + width: u32, + height: u32, + pixels: []u8, // RGB, row-major + + pub fn deinit(self: *Image, gpa: std.mem.Allocator) void { + gpa.free(self.pixels); + self.* = .{ .width = 0, .height = 0, .pixels = &.{} }; + } + + pub fn hash(self: Image) u64 { + return std.hash.Wyhash.hash(0, self.pixels); + } + + pub fn brightPixels(self: Image, y0_in: u32, y1_in: u32, threshold: u8) usize { + const y0 = @min(y0_in, self.height); + const y1 = @min(y1_in, self.height); + var n: usize = 0; + var y = y0; + while (y < y1) : (y += 1) { + var x: u32 = 0; + while (x < self.width) : (x += 1) { + const i = (@as(usize, y) * @as(usize, self.width) + @as(usize, x)) * 3; + const lum = (@as(u16, self.pixels[i]) + @as(u16, self.pixels[i + 1]) + @as(u16, self.pixels[i + 2])) / 3; + if (lum >= threshold) n += 1; + } + } + return n; + } + + pub fn changedPixels(self: Image, other: Image, threshold: u8) usize { + if (self.width != other.width or self.height != other.height) return std.math.maxInt(usize); + var n: usize = 0; + var i: usize = 0; + while (i < self.pixels.len) : (i += 3) { + const dr = absDiff(self.pixels[i], other.pixels[i]); + const dg = absDiff(self.pixels[i + 1], other.pixels[i + 1]); + const db = absDiff(self.pixels[i + 2], other.pixels[i + 2]); + if (dr + dg + db >= threshold) n += 1; + } + return n; + } +}; + +pub const SdlHarness = struct { + gpa: std.mem.Allocator, + io: std.Io, + master: c_int, + pid: posix.pid_t, + rows: u16, + cols: u16, + trace: bool = false, + capture_dir: [:0]u8, + latest_path: []u8, + image: ?Image = null, + rbuf: std.ArrayList(u8) = .empty, + + pub fn init(gpa: std.mem.Allocator, io: std.Io, exe: [*:0]const u8, rows: u16, cols: u16) !SdlHarness { + return initArgs(gpa, io, exe, rows, cols, null); + } + + pub fn initArgs(gpa: std.mem.Allocator, io: std.Io, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !SdlHarness { + const base = ".zig-cache/pardes-sdl-e2e"; + try std.Io.Dir.cwd().createDirPath(io, base); + const capture_dir_plain = try std.fmt.allocPrint(gpa, "{s}/{d}-{d}", .{ base, linux.getpid(), nowMs() }); + defer gpa.free(capture_dir_plain); + const capture_dir = try gpa.dupeZ(u8, capture_dir_plain); + errdefer gpa.free(capture_dir); + try std.Io.Dir.cwd().createDirPath(io, capture_dir); + const latest_path = try std.fmt.allocPrint(gpa, "{s}/latest.ppm", .{capture_dir}); + errdefer gpa.free(latest_path); + + var master: c_int = undefined; + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid == 0) { + _ = setenv("PARDES_TEST", "1", 1); + _ = setenv("PARDES_TEST_CAPTURE_DIR", capture_dir.ptr, 1); + var rows_buf: [16]u8 = undefined; + var cols_buf: [16]u8 = undefined; + const rows_z = std.fmt.bufPrintZ(&rows_buf, "{d}", .{rows}) catch unreachable; + const cols_z = std.fmt.bufPrintZ(&cols_buf, "{d}", .{cols}) catch unreachable; + _ = setenv("PARDES_TEST_ROWS", rows_z.ptr, 1); + _ = setenv("PARDES_TEST_COLS", cols_z.ptr, 1); + const argv: [3:null]?[*:0]const u8 = .{ exe, arg, null }; + _ = execvp(exe, &argv); + _ = _exit(127); + } + return .{ + .gpa = gpa, + .io = io, + .master = master, + .pid = pid, + .rows = rows, + .cols = cols, + .capture_dir = capture_dir, + .latest_path = latest_path, + }; + } + + pub fn deinit(self: *SdlHarness) void { + posix.kill(self.pid, posix.SIG.KILL) catch {}; + _ = linux.close(self.master); + if (self.image) |*img| img.deinit(self.gpa); + self.rbuf.deinit(self.gpa); + self.gpa.free(self.latest_path); + self.gpa.free(self.capture_dir); + } + + pub fn send(self: *SdlHarness, bytes: []const u8) !void { + var off: usize = 0; + while (off < bytes.len) { + const rc = linux.write(self.master, bytes[off..].ptr, bytes.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return error.WriteFailed; + } + off += @intCast(n); + } + } + + pub fn pump(self: *SdlHarness, ms: i64) !void { + const deadline = nowMs() + ms; + var buf: [4096]u8 = undefined; + while (nowMs() < deadline) { + var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }}; + _ = posix.poll(&fds, 50) catch {}; + if ((fds[0].revents & posix.POLL.IN) != 0) { + const n = posix.read(self.master, &buf) catch break; + if (n == 0) break; + self.rbuf.appendSlice(self.gpa, buf[0..n]) catch {}; + } + self.loadLatest() catch {}; + } + self.loadLatest() catch {}; + if (self.trace) self.traceImage("pump"); + } + + pub fn resize(self: *SdlHarness, rows: u16, cols: u16) !void { + self.rows = rows; + self.cols = cols; + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + } + + pub fn waitForImage(self: *SdlHarness, ms: i64) !bool { + if (self.image != null) return true; + const deadline = nowMs() + ms; + while (nowMs() < deadline) { + try self.pump(100); + if (self.image != null) return true; + } + return false; + } + + pub fn waitForChange(self: *SdlHarness, old_hash: u64, ms: i64) !bool { + const deadline = nowMs() + ms; + while (nowMs() < deadline) { + try self.pump(100); + if (self.image) |img| if (img.hash() != old_hash) return true; + } + return false; + } + + pub fn imageHash(self: *SdlHarness) u64 { + return if (self.image) |img| img.hash() else 0; + } + + pub fn imageCopy(self: *SdlHarness) !Image { + const img = self.image orelse return error.NoImage; + return .{ + .width = img.width, + .height = img.height, + .pixels = try self.gpa.dupe(u8, img.pixels), + }; + } + + pub fn cellWidth(self: *SdlHarness) u32 { + const img = self.image orelse return 0; + return img.width / @as(u32, self.cols); + } + + pub fn cellHeight(self: *SdlHarness) u32 { + const img = self.image orelse return 0; + return img.height / @as(u32, self.rows); + } + + pub fn expectImage(self: *SdlHarness, msg: []const u8) !void { + if (self.image == null) { + self.dump(msg); + return error.ExpectFailed; + } + } + + pub fn expectWaitForImage(self: *SdlHarness, ms: i64, msg: []const u8) !void { + if (!try self.waitForImage(ms)) { + self.dump(msg); + return error.ExpectFailed; + } + } + + pub fn expectWaitForChange(self: *SdlHarness, old_hash: u64, ms: i64, msg: []const u8) !void { + if (!try self.waitForChange(old_hash, ms)) { + self.dump(msg); + return error.ExpectFailed; + } + } + + pub fn expectGridPixels(self: *SdlHarness, msg: []const u8) !void { + const img = self.image orelse { + self.dump(msg); + return error.ExpectFailed; + }; + if (self.cols == 0 or self.rows == 0 or img.width % self.cols != 0 or img.height % self.rows != 0) { + self.dump(msg); + return error.ExpectFailed; + } + if (img.width / self.cols < 6 or img.height / self.rows < 10) { + self.dump(msg); + return error.ExpectFailed; + } + } + + pub fn expectBrightRows(self: *SdlHarness, row0: u16, row1: u16, min_bright: usize, msg: []const u8) !void { + const img = self.image orelse { + self.dump(msg); + return error.ExpectFailed; + }; + const ch = self.cellHeight(); + const y0 = @as(u32, row0) * ch; + const y1 = @as(u32, row1) * ch; + if (img.brightPixels(y0, y1, 60) < min_bright) { + self.dump(msg); + return error.ExpectFailed; + } + } + + fn loadLatest(self: *SdlHarness) !void { + const data = std.Io.Dir.cwd().readFileAlloc(self.io, self.latest_path, self.gpa, .limited(128 * 1024 * 1024)) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer self.gpa.free(data); + const img = try parsePpm(self.gpa, data); + if (self.image) |*old| old.deinit(self.gpa); + self.image = img; + } + + pub fn dump(self: *SdlHarness, msg: []const u8) void { + if (self.image) |img| { + std.debug.print("E2E FAIL: {s}\nimage {d}x{d} hash={x} path={s}\nraw tail:\n{s}\n", .{ + msg, img.width, img.height, img.hash(), self.latest_path, rawTail(self.rbuf.items), + }); + } else { + std.debug.print("E2E FAIL: {s}\n(no captured image yet) path={s}\nraw tail:\n{s}\n", .{ + msg, self.latest_path, rawTail(self.rbuf.items), + }); + } + } + + pub fn traceImage(self: *SdlHarness, tag: []const u8) void { + if (self.image) |img| { + std.debug.print("[trace {s}] image {d}x{d} hash={x} path={s}\n", .{ + tag, img.width, img.height, img.hash(), self.latest_path, + }); + } else { + std.debug.print("[trace {s}] no image yet path={s}\n", .{ tag, self.latest_path }); + } + } +}; + +fn parsePpm(gpa: std.mem.Allocator, data: []const u8) !Image { + if (!std.mem.startsWith(u8, data, "P6")) return error.BadPpm; + var idx: usize = 2; + const w = try parsePpmInt(data, &idx); + const h = try parsePpmInt(data, &idx); + const max = try parsePpmInt(data, &idx); + if (max != 255) return error.BadPpm; + if (idx >= data.len or !isWs(data[idx])) return error.BadPpm; + idx += 1; + const len = @as(usize, w) * @as(usize, h) * 3; + if (data.len < idx + len) return error.BadPpm; + return .{ .width = w, .height = h, .pixels = try gpa.dupe(u8, data[idx .. idx + len]) }; +} + +fn parsePpmInt(data: []const u8, idx: *usize) !u32 { + while (idx.* < data.len and isWs(data[idx.*])) idx.* += 1; + const start = idx.*; + while (idx.* < data.len and data[idx.*] >= '0' and data[idx.*] <= '9') idx.* += 1; + if (idx.* == start) return error.BadPpm; + return std.fmt.parseInt(u32, data[start..idx.*], 10); +} + +fn isWs(b: u8) bool { + return b == ' ' or b == '\n' or b == '\r' or b == '\t'; +} + +fn absDiff(a: u8, b: u8) u16 { + return if (a >= b) @as(u16, a - b) else @as(u16, b - a); +} + +fn rawTail(raw: []const u8) []const u8 { + const max = 4096; + return if (raw.len > max) raw[raw.len - max ..] else raw; +} |
