summaryrefslogtreecommitdiff
path: root/test/image_harness.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 17:17:35 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit5961587b227e5fa097fb033e32c29da08a23fb18 (patch)
tree359883d5531e595d47f75824a77a0c1323973e2e /test/image_harness.zig
parent487434a0b55fa7cf479f39481b51da0a7eda79e1 (diff)
downloadpardes-5961587b227e5fa097fb033e32c29da08a23fb18.tar.gz
pardes-5961587b227e5fa097fb033e32c29da08a23fb18.zip
tty/image: big harness + golden coverage pass
Diffstat (limited to 'test/image_harness.zig')
-rw-r--r--test/image_harness.zig356
1 files changed, 346 insertions, 10 deletions
diff --git a/test/image_harness.zig b/test/image_harness.zig
index 5ec192c2..390c14e8 100644
--- a/test/image_harness.zig
+++ b/test/image_harness.zig
@@ -4,6 +4,8 @@
//! real transmit + placement APCs
//! zig build image-harness -Dplatform=gui run the real SDL GPU renderer,
//! capture a PPM, inspect pixels
+//! zig build pdf-harness render a generated two-page PDF
+//! zig build pdf-harness -Dplatform=gui
//!
//! Unlike the text snapshot suite, both arms observe the thing the native
//! backend actually emits. The TTY arm completes vaxis's asynchronous
@@ -20,16 +22,20 @@ extern "c" fn unsetenv(name: [*:0]const u8) c_int;
const gpa = std.heap.page_allocator;
const fixture_rgb = [3]u8{ 13, 77, 231 };
+const pdf_red = [3]u8{ 255, 0, 0 };
+const pdf_green = [3]u8{ 0, 255, 0 };
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;
+ if (args.len != 3 and args.len != 4) return error.BadArgs;
const exe = try arena.dupeZ(u8, args[1]);
const backend = args[2];
+ const pdf_mode = args.len == 4 and std.mem.eql(u8, args[3], "pdf");
+ if (args.len == 4 and !pdf_mode) return error.BadArgs;
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);
@@ -39,14 +45,20 @@ pub fn main(init: std.process.Init) !void {
_ = setenv("LC_ALL", "C", 1);
if (std.mem.eql(u8, backend, "tty")) {
- try runKitty(arena, exe, base);
+ if (pdf_mode)
+ try runKittyPdf(arena, exe, base)
+ else
+ try runKitty(arena, exe, base);
} else if (std.mem.eql(u8, backend, "gui")) {
- try runGui(arena, exe, base);
+ if (pdf_mode)
+ try runGuiPdf(arena, exe, base)
+ else
+ 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" };
+ const names = [_][]const u8{ "native.ppm", "pages.pdf", "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);
@@ -54,6 +66,65 @@ fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void {
_ = libc.rmdir(base);
}
+fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []const u8, args: anytype) !void {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args));
+}
+
+/// A self-contained, valid PDF fixture. Page one is solid red and contains
+/// FIRST NEEDLE; page two is solid green and contains SECOND TARGET. Building
+/// it here keeps the backend test independent of mutool, fonts, and checked-in
+/// binary fixtures while the xref offsets remain exact.
+fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
+ const first_stream =
+ \\1 0 0 rg
+ \\0 0 72 48 re
+ \\f
+ \\0 0 0 rg
+ \\BT
+ \\/F1 10 Tf
+ \\4 20 Td
+ \\(FIRST NEEDLE) Tj
+ \\ET
+ ;
+ const second_stream =
+ \\0 1 0 rg
+ \\0 0 72 48 re
+ \\f
+ \\0 0 0 rg
+ \\BT
+ \\/F1 10 Tf
+ \\4 20 Td
+ \\(SECOND TARGET) Tj
+ \\ET
+ ;
+
+ var out: std.ArrayList(u8) = .empty;
+ var offsets: [8]usize = @splat(0);
+ try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
+
+ offsets[1] = out.items.len;
+ try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ offsets[2] = out.items.len;
+ try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R 5 0 R] >>\nendobj\n");
+ offsets[3] = out.items.len;
+ try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>\nendobj\n");
+ offsets[4] = out.items.len;
+ try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream });
+ offsets[5] = out.items.len;
+ try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>\nendobj\n");
+ offsets[6] = out.items.len;
+ try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream });
+ offsets[7] = out.items.len;
+ try out.appendSlice(arena, "7 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
+
+ const xref = out.items.len;
+ try out.appendSlice(arena, "xref\n0 8\n0000000000 65535 f \n");
+ for (offsets[1..]) |offset|
+ try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset});
+ try appendFmt(arena, &out, "trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
+ try eh.writeFile(path, out.items);
+}
+
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 }));
@@ -104,6 +175,148 @@ fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !vo
std.debug.print("native image harness ok: kitty transmit + placement\n", .{});
}
+fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
+ _ = unsetenv("PARDES_TEST");
+ _ = unsetenv("PARDES_TEST_CAPTURE_DIR");
+ const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0);
+ try writePdf(arena, pdf_path);
+
+ var h = try eh.Harness.initArgs(gpa, exe, 16, 80, pdf_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");
+
+ try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A");
+ const transmit = "\x1b_Gf=32,s=144,v=96,i=";
+ const first_deadline = eh.nowMs() + 5000;
+ while (eh.nowMs() < first_deadline) {
+ _ = try h.pumpOnce(50);
+ const sent = std.mem.indexOf(u8, h.raw.items, transmit) != null;
+ const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null;
+ if (sent and placed and h.contains("pdf 1/2 width PdfFit")) break;
+ }
+ try h.expectRawContains(transmit, "first PDF page was not transmitted as 144x96 RGBA");
+ try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body");
+ try h.expectContains("pdf 1/2 width PdfFit", "PDF pane did not expose its initial width fit");
+
+ // Fit-width is the initial PDF policy. It must use Kitty's explicit column
+ // scaling rather than Vaxis contain. The PDF shares this test session with
+ // the initial shell pane, making its body slightly narrower than the page:
+ // the full source is retained and vertically letterboxed by a pixel offset.
+ const width_apc = kittyPlacementApc(h.raw.items, 0) orelse return error.KittyPdfWidthPlacementMissing;
+ if (kittyParam(width_apc, 'x') != 0 or kittyParam(width_apc, 'y') != 0 or
+ kittyParam(width_apc, 'w') != 144)
+ {
+ std.debug.print("unexpected Kitty fit-width placement: {s}\n", .{width_apc});
+ return error.KittyPdfWidthCropWrong;
+ }
+ const source_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropMissing;
+ const pixel_y = kittyParam(width_apc, 'Y') orelse return error.KittyPdfWidthLetterboxMissing;
+ if (source_h != 96 or pixel_y == 0 or kittyParam(width_apc, 'c') == null) {
+ std.debug.print("unexpected Kitty fit-width letterbox: {s}\n", .{width_apc});
+ return error.KittyPdfWidthLetterboxWrong;
+ }
+
+ // Placement-only state reuses the terminal image. Middle-click the
+ // visible PdfFit word in the PDF tag, wait for a fit-height placement
+ // with explicit rows and a full source image, and prove that no second
+ // transmit happened.
+ const transmit_before_fit = std.mem.count(u8, h.raw.items, transmit);
+ const fit_raw_start = h.raw.items.len;
+ const tag = try h.screenText();
+ defer gpa.free(tag);
+ const fit_at = std.mem.indexOf(u8, tag, "PdfFit") orelse
+ return error.PdfFitMissing;
+ const line_at = std.mem.lastIndexOfScalar(u8, tag[0..fit_at], '\n');
+ const mouse_col = fit_at - if (line_at) |at| at + 1 else 0;
+ const mouse_row = std.mem.count(u8, tag[0..fit_at], "\n");
+ const click = try std.fmt.allocPrint(
+ arena,
+ "\x1b[<1;{d};{d}M\x1b[<1;{d};{d}m",
+ .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 },
+ );
+ try h.send(click);
+ const fit_deadline = eh.nowMs() + 3000;
+ while (eh.nowMs() < fit_deadline) {
+ _ = try h.pumpOnce(50);
+ if (kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') != null) break;
+ }
+ try h.expectContains("pdf 1/2 height PdfFit", "PdfFit did not expose the active height fit");
+ const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse
+ return error.KittyPdfHeightPlacementMissing;
+ const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong;
+ if (kittyParam(height_apc, 'x') != 0 or kittyParam(height_apc, 'y') != 0 or
+ height_crop_w == 0 or height_crop_w >= 144 or kittyParam(height_apc, 'h') != 96 or
+ kittyParam(height_apc, 'r') == null)
+ return error.KittyPdfHeightPlacementWrong;
+ if (std.mem.count(u8, h.raw.items, transmit) != transmit_before_fit)
+ return error.KittyPdfFitRetransmitted;
+
+ // Same pane, different pixels: page navigation must delete the cached
+ // Kitty image and transmit the new revision, not keep showing page one.
+ try h.send("j");
+ const second_deadline = eh.nowMs() + 5000;
+ while (eh.nowMs() < second_deadline) {
+ _ = try h.pumpOnce(50);
+ const deleted = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") != null;
+ if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/2 height PdfFit")) break;
+ }
+ try h.expectRawContains("\x1b_Ga=d,d=I,i=", "page navigation did not release the old Kitty PDF image");
+ if (std.mem.count(u8, h.raw.items, transmit) < 2) return error.KittyPdfRetransmitMissing;
+ try h.expectContains("pdf 2/2 height PdfFit", "j did not retain height fit on PDF page two");
+
+ // Exercise the semantic seam too: return to page one, search an exact
+ // occurrence across the document, then let n Look the page-two result.
+ // That Look must change the page and therefore produce another pixel
+ // generation.
+ try h.send("k");
+ try h.expectWaitFor("pdf 1/2 height PdfFit", 5000, "k did not return to PDF page one at height fit");
+ try h.send("/SECOND\r");
+ try h.expectWaitFor("pages.pdf:2:1 SECOND", 5000, "PDF search did not preserve the exact page-two query occurrence");
+ const before_look = std.mem.count(u8, h.raw.items, transmit);
+ try h.send("n");
+ const look_deadline = eh.nowMs() + 5000;
+ while (eh.nowMs() < look_deadline) {
+ _ = try h.pumpOnce(50);
+ if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/2 height PdfFit")) break;
+ }
+ try h.expectContains("pdf 2/2 height PdfFit", "n did not Look the PDF search result at page two");
+ if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing;
+ std.debug.print("PDF harness ok: Kitty fit crop/cache + page revision + search Look\n", .{});
+}
+
+fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 {
+ if (start > raw.len) return null;
+ const relative = std.mem.indexOf(u8, raw[start..], "\x1b_Ga=p,i=") orelse return null;
+ const begin = start + relative;
+ const end = std.mem.indexOf(u8, raw[begin..], "\x1b\\") orelse return null;
+ return raw[begin .. begin + end + 2];
+}
+
+fn kittyPlacementApcWithParam(raw: []const u8, start: usize, key: u8) ?[]const u8 {
+ var at = start;
+ while (kittyPlacementApc(raw, at)) |apc| {
+ if (kittyParam(apc, key) != null) return apc;
+ const next = @intFromPtr(apc.ptr) - @intFromPtr(raw.ptr) + apc.len;
+ if (next <= at or next > raw.len) return null;
+ at = next;
+ }
+ return null;
+}
+
+fn kittyParam(apc: []const u8, key: u8) ?u32 {
+ var i: usize = 0;
+ while (i + 3 <= apc.len) : (i += 1) {
+ if (apc[i] != ',' or apc[i + 1] != key or apc[i + 2] != '=') continue;
+ const begin = i + 3;
+ var end = begin;
+ while (end < apc.len and std.ascii.isDigit(apc[end])) : (end += 1) {}
+ if (end == begin) return null;
+ return std.fmt.parseUnsigned(u32, apc[begin..end], 10) catch null;
+ }
+ return null;
+}
+
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);
@@ -139,6 +352,120 @@ fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void
std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{exact});
}
+fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
+ const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0);
+ const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0);
+ try writePdf(arena, pdf_path);
+ _ = 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, pdf_path);
+ defer h.deinit();
+ const fit_width = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000);
+
+ // The narrow PDF pane makes fit-width vertically letterboxed. Fit-height
+ // keeps roughly the same colored width but fills/crops the full body
+ // height. These are GPU readback extents, so this exercises SDL's UV crop
+ // and destination rectangle rather than merely observing the page color.
+ _ = libc.unlink(capture_path);
+ try h.send(" tz");
+ const fit_height = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000);
+ const width_delta = if (fit_width.width() > fit_height.width())
+ fit_width.width() - fit_height.width()
+ else
+ fit_height.width() - fit_width.width();
+ if (fit_height.height() < fit_width.height() * 2 or width_delta > 8 or
+ fit_height.min_y >= fit_width.min_y)
+ {
+ std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height });
+ return error.SdlPdfFitGeometryWrong;
+ }
+
+ // Remove the old capture before the key so merely rereading page one's
+ // file cannot satisfy page two. The app replaces this path atomically.
+ _ = libc.unlink(capture_path);
+ try h.send("j");
+ const green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000);
+ std.debug.print("PDF harness ok: SDL fit geometry + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count });
+}
+
+const ColorBounds = struct {
+ count: usize,
+ min_x: usize,
+ min_y: usize,
+ max_x: usize,
+ max_y: usize,
+
+ fn width(bounds: ColorBounds) usize {
+ return bounds.max_x - bounds.min_x + 1;
+ }
+
+ fn height(bounds: ColorBounds) usize {
+ return bounds.max_y - bounds.min_y + 1;
+ }
+};
+
+fn waitForCaptureBounds(
+ h: *eh.Harness,
+ path: [:0]const u8,
+ color: [3]u8,
+ minimum: usize,
+ timeout_ms: i64,
+) !ColorBounds {
+ const deadline = eh.nowMs() + timeout_ms;
+ var best: usize = 0;
+ while (eh.nowMs() < deadline) {
+ if (readFile(gpa, path)) |capture| {
+ defer gpa.free(capture);
+ if (ppmView(capture)) |ppm| {
+ if (colorBounds(ppm, color)) |bounds| {
+ best = @max(best, bounds.count);
+ if (bounds.count >= minimum) return bounds;
+ }
+ }
+ } else |_| {}
+ _ = try h.pumpOnce(50);
+ }
+ std.debug.print("SDL PDF harness found only {d} pixels of color {any}\n", .{ best, color });
+ return error.PdfPageColorMissing;
+}
+
+const PpmView = struct { width: usize, height: usize, pixels: []const u8 };
+
+fn colorBounds(ppm: PpmView, color: [3]u8) ?ColorBounds {
+ var bounds = ColorBounds{
+ .count = 0,
+ .min_x = std.math.maxInt(usize),
+ .min_y = std.math.maxInt(usize),
+ .max_x = 0,
+ .max_y = 0,
+ };
+ var pixel: usize = 0;
+ while (pixel < ppm.width * ppm.height) : (pixel += 1) {
+ const i = pixel * 3;
+ if (!std.mem.eql(u8, ppm.pixels[i .. i + 3], &color)) continue;
+ const x = pixel % ppm.width;
+ const y = pixel / ppm.width;
+ bounds.count += 1;
+ bounds.min_x = @min(bounds.min_x, x);
+ bounds.min_y = @min(bounds.min_y, y);
+ bounds.max_x = @max(bounds.max_x, x);
+ bounds.max_y = @max(bounds.max_y, y);
+ }
+ return if (bounds.count == 0) null else bounds;
+}
+
+fn countColor(pixels: []const u8, color: [3]u8) usize {
+ var count: usize = 0;
+ var i: usize = 0;
+ while (i + 2 < pixels.len) : (i += 3)
+ count += @intFromBool(std.mem.eql(u8, pixels[i .. i + 3], &color));
+ return count;
+}
+
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;
@@ -156,11 +483,20 @@ fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 {
}
fn ppmPixels(bytes: []const u8) ?[]const u8 {
+ return (ppmView(bytes) orelse return null).pixels;
+}
+
+fn ppmView(bytes: []const u8) ?PpmView {
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;
+ const dimensions_end = std.mem.indexOfScalarPos(u8, bytes, 3, '\n') orelse return null;
+ var dimensions = std.mem.splitScalar(u8, bytes[3..dimensions_end], ' ');
+ const width = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null;
+ const height = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null;
+ if (dimensions.next() != null) return null;
+ const max_end = std.mem.indexOfScalarPos(u8, bytes, dimensions_end + 1, '\n') orelse return null;
+ if (!std.mem.eql(u8, bytes[dimensions_end + 1 .. max_end], "255")) return null;
+ const expected = std.math.mul(usize, width, height) catch return null;
+ const byte_len = std.math.mul(usize, expected, 3) catch return null;
+ if (bytes.len -| (max_end + 1) < byte_len) return null;
+ return .{ .width = width, .height = height, .pixels = bytes[max_end + 1 ..][0..byte_len] };
}