diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 13:13:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 487434a0b55fa7cf479f39481b51da0a7eda79e1 (patch) | |
| tree | ce410e0c52eb030e1de76079834e6697975c9c17 /test | |
| parent | d59397d4146ffa23adbbe6dc99c64ebb22a93b2d (diff) | |
| download | pardes-487434a0b55fa7cf479f39481b51da0a7eda79e1.tar.gz pardes-487434a0b55fa7cf479f39481b51da0a7eda79e1.zip | |
gui: draw image surfaces as GPU quads via glsl shaders
Diffstat (limited to 'test')
| -rw-r--r-- | test/image_harness.zig | 166 |
1 files changed, 166 insertions, 0 deletions
diff --git a/test/image_harness.zig b/test/image_harness.zig new file mode 100644 index 00000000..5ec192c2 --- /dev/null +++ b/test/image_harness.zig @@ -0,0 +1,166 @@ +//! Native-image backend harness. +//! +//! zig build image-harness fake a Kitty terminal and check +//! real transmit + placement APCs +//! zig build image-harness -Dplatform=gui run the real SDL GPU renderer, +//! capture a PPM, inspect pixels +//! +//! Unlike the text snapshot suite, both arms observe the thing the native +//! backend actually emits. The TTY arm completes vaxis's asynchronous +//! capability handshake over a PTY; the GUI arm consumes PARDES_TEST's GPU +//! readback, so a PETSCII fallback cannot accidentally satisfy either one. +const std = @import("std"); +const libc = std.c; +const eh = @import("e2e_harness.zig"); + +pub const std_options: std.Options = .{ .log_level = .err }; + +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: [*:0]const u8) c_int; + +const gpa = std.heap.page_allocator; +const fixture_rgb = [3]u8{ 13, 77, 231 }; + +pub fn main(init: std.process.Init) !void { + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const args = try init.minimal.args.toSlice(arena); + if (args.len != 3) return error.BadArgs; + + const exe = try arena.dupeZ(u8, args[1]); + const backend = args[2]; + const base = try std.fmt.allocPrintSentinel(arena, "/tmp/pardes-image-harness-{d}", .{libc.getpid()}, 0); + if (libc.mkdir(base, 0o755) != 0 and libc.errno(-1) != .EXIST) return error.MkdirFailed; + defer cleanupBase(arena, base); + _ = setenv("HOME", base, 1); + _ = setenv("HISTFILE", "/dev/null", 1); + _ = setenv("TERM", "xterm-256color", 1); + _ = setenv("LC_ALL", "C", 1); + + if (std.mem.eql(u8, backend, "tty")) { + try runKitty(arena, exe, base); + } else if (std.mem.eql(u8, backend, "gui")) { + try runGui(arena, exe, base); + } else return error.BadArgs; +} + +fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { + const names = [_][]const u8{ "native.ppm", "latest.ppm", "latest.ppm.tmp", ".bash_history" }; + for (names) |name| { + const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}", .{ base, name }, 0) catch continue; + _ = libc.unlink(path); + } + _ = libc.rmdir(base); +} + +fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void { + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height })); + for (0..width * height) |_| try out.appendSlice(arena, &fixture_rgb); + try eh.writeFile(path, out.items); +} + +fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { + _ = unsetenv("PARDES_TEST"); + _ = unsetenv("PARDES_TEST_CAPTURE_DIR"); + const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); + try writePpm(arena, image_path, 2, 2); + + var h = try eh.Harness.initArgs(gpa, exe, 12, 30, image_path); + defer h.deinit(); + try h.pump(150); + try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query"); + + // APC says graphics is present; DA1 is the last capability response and + // closes the asynchronous handshake. Arrow-up is an ordinary queued event + // that wakes the main loop even on a terminal which supplied no in-band + // resize report, matching the documented idle-handshake edge case. + try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); + const deadline = eh.nowMs() + 3000; + while (eh.nowMs() < deadline) { + _ = try h.pumpOnce(50); + const sent = std.mem.indexOf(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") != null; + const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; + if (sent and placed) break; + } + try h.expectRawContains("\x1b_Gf=32,s=2,v=2,i=", "decoded RGBA was not transmitted to the Kitty terminal"); + try h.expectRawContains("\x1b_Ga=p,i=", "transmitted Kitty image was not placed in the pane body"); + + // SPC t p toggles the attachment off and back on. The first transition + // must release terminal-side storage; the second must transmit again + // instead of resurrecting a stale pane-slot handle. + try h.send(" tp"); + const delete_deadline = eh.nowMs() + 2000; + while (eh.nowMs() < delete_deadline and std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") == null) + _ = try h.pumpOnce(50); + try h.expectRawContains("\x1b_Ga=d,d=I,i=", "PETSCII toggle did not release the cached Kitty image"); + try h.send(" tp"); + const retransmit_deadline = eh.nowMs() + 2000; + while (eh.nowMs() < retransmit_deadline and std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2) + _ = try h.pumpOnce(50); + if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2) + return error.KittyRetransmitMissing; + std.debug.print("native image harness ok: kitty transmit + placement\n", .{}); +} + +fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { + const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); + const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); + try writePpm(arena, image_path, 32, 16); + _ = libc.unlink(capture_path); + _ = setenv("PARDES_TEST", "1", 1); + _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1); + _ = setenv("PARDES_TEST_COLS", "20", 1); + _ = setenv("PARDES_TEST_ROWS", "10", 1); + + var h = try eh.Harness.initArgs(gpa, exe, 10, 20, image_path); + defer h.deinit(); + try h.pump(1200); + + const capture = readFile(gpa, capture_path) catch |err| { + std.debug.print("SDL image harness produced no capture ({t}); raw child output: {s}\n", .{ err, h.raw.items }); + return err; + }; + defer gpa.free(capture); + const pixels = ppmPixels(capture) orelse return error.BadCapture; + var exact: usize = 0; + var i: usize = 0; + while (i + 2 < pixels.len) : (i += 3) { + if (std.mem.eql(u8, pixels[i .. i + 3], &fixture_rgb)) exact += 1; + } + // The source contributes 512 aligned pixels. Leave ample room for driver + // filtering while still excluding glyph-art fallback and incidental UI + // colors. + if (exact < 200) { + std.debug.print("SDL image harness found only {d} native-color pixels in the GPU capture\n", .{exact}); + return error.NativePixelsMissing; + } + std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{exact}); +} + +fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); + if (fd < 0) return error.OpenFailed; + defer _ = libc.close(fd); + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + var buf: [8192]u8 = undefined; + while (true) { + const n = libc.read(fd, &buf, buf.len); + if (n < 0) return error.ReadFailed; + if (n == 0) break; + try out.appendSlice(allocator, buf[0..@intCast(n)]); + } + return out.toOwnedSlice(allocator); +} + +fn ppmPixels(bytes: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, bytes, "P6\n")) return null; + var newlines: usize = 0; + for (bytes, 0..) |byte, i| if (byte == '\n') { + newlines += 1; + if (newlines == 3) return bytes[i + 1 ..]; + }; + return null; +} |
