summaryrefslogtreecommitdiff
path: root/test/hxdiff.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-26 18:36:08 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commite381c96a08f08e03a340a24acb3fe5fe1fca51be (patch)
treedb0ef0cc2652d2797b9e2f73709db601816436d1 /test/hxdiff.zig
parenta4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60 (diff)
downloadpardes-e381c96a08f08e03a340a24acb3fe5fe1fca51be.tar.gz
pardes-e381c96a08f08e03a340a24acb3fe5fe1fca51be.zip
adding better search to tty mode
Diffstat (limited to 'test/hxdiff.zig')
-rw-r--r--test/hxdiff.zig136
1 files changed, 90 insertions, 46 deletions
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
index 2170961e..dc901263 100644
--- a/test/hxdiff.zig
+++ b/test/hxdiff.zig
@@ -10,13 +10,27 @@
//
// zig build hxdiff (diff the checked-in corpus)
// zig build hxdiff -- <cases.jsonl> (results to stdout, no diff)
+// zig build hxparity (file-vs-pty parity, both corpora)
// ./zig-out/bin/pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]
+// ./zig-out/bin/pardes-hxdiff --parity [--waivers w.jsonl] <cases.jsonl>...
//
// DIFF MODE (goldens given): every result is compared field-by-field
// (text/mode/cursor/anchor) against the same-named golden line. Waivers
// ({"name":...,"reason":...}) exempt named cases — each must carry a reason.
// Any unwaivered mismatch prints a per-case report and exits 1.
//
+// PARITY MODE (--parity): no goldens at all. Each case is run TWICE, once in
+// a file pane and once in a pty pane, over the same starting text and the
+// same keys, and the two result lines must be identical. That is the whole
+// contract of "editing a shell pane behaves like editing a text file": the
+// oracle is the file pane itself, so it cannot drift. The case's own "pane"
+// field is ignored, and SEVERAL case files may be given — the gate runs the
+// whole helix corpus, not just the editing cases, because a parity suite
+// that only covers what its author remembered cannot catch the next
+// regression. Divergences that are not editing (pardes key bindings, pty
+// viewport geometry, text a terminal cannot hold verbatim) are waived by
+// name in test/hxcases/parity-waivers.jsonl, each with its reason.
+//
// Viewport is fixed at 80x24, matching the helix harness: one pane = 22 body
// rows (topbar + tag take two; helix: statusline + commandline take two).
// pane "file" swaps the boot pane for a file pane holding the case text
@@ -43,21 +57,31 @@ pub fn main(init: std.process.Init) !void {
const arena = arena_state.allocator();
const args = try init.minimal.args.toSlice(init.arena.allocator());
- var cases_path: ?[]const u8 = null;
+ var parity = false;
var goldens_path: ?[]const u8 = null;
var waivers_path: ?[]const u8 = null;
+ var case_paths: std.ArrayList([]const u8) = .empty;
+ var want_waivers = false;
for (args[1..]) |a| {
- if (cases_path == null) {
- cases_path = a;
+ if (want_waivers) {
+ waivers_path = a;
+ want_waivers = false;
+ } else if (std.mem.eql(u8, a, "--parity")) {
+ parity = true;
+ } else if (std.mem.eql(u8, a, "--waivers")) {
+ want_waivers = true;
+ } else if (parity or case_paths.items.len == 0) {
+ // diff mode is positional (cases, goldens, waivers); parity mode
+ // takes any number of case files and nothing else
+ case_paths.append(arena, a) catch fatal("oom", .{});
} else if (goldens_path == null) {
goldens_path = a;
} else if (waivers_path == null) {
waivers_path = a;
} else fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
}
- const path = cases_path orelse fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
-
- const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) });
+ if (case_paths.items.len == 0)
+ fatal("usage: pardes-hxdiff [--parity] <cases.jsonl>... [goldens.jsonl [waivers.jsonl]]", .{});
// goldens + waivers, keyed by name (diff mode)
var goldens: std.StringHashMap(Result) = .init(arena);
@@ -94,38 +118,56 @@ pub fn main(init: std.process.Init) !void {
var n_cases: usize = 0;
var n_bad: usize = 0;
var n_waived: usize = 0;
- var lines = std.mem.splitScalar(u8, src, '\n');
- var lineno: usize = 0;
- while (lines.next()) |raw| {
- lineno += 1;
- const line = std.mem.trim(u8, raw, " \t\r");
- if (line.len == 0) continue;
- const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
- fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) });
- n_cases += 1;
- const res = runCase(arena, case) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) });
- if (goldens_path == null) {
- const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{});
- out.appendSlice(arena, jl) catch fatal("oom", .{});
- out.append(arena, '\n') catch fatal("oom", .{});
- continue;
- }
- const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name});
- const diff = diffResult(arena, g, res);
- if (diff.len == 0) continue;
- if (waivers.get(case.name)) |reason| {
- n_waived += 1;
- std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason });
- continue;
+ for (case_paths.items) |path| {
+ const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) });
+ var lines = std.mem.splitScalar(u8, src, '\n');
+ var lineno: usize = 0;
+ while (lines.next()) |raw| {
+ lineno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) });
+ n_cases += 1;
+ if (parity) {
+ // the file pane IS the oracle: same text, same keys, both kinds
+ const want = runCase(arena, case, "file") catch |e| fatal("case {s} (file): {s}", .{ case.name, @errorName(e) });
+ const got = runCase(arena, case, "tty") catch |e| fatal("case {s} (tty): {s}", .{ case.name, @errorName(e) });
+ const diff = diffResult(arena, want, got, "file", "pty");
+ if (diff.len == 0) continue;
+ if (waivers.get(case.name)) |reason| {
+ n_waived += 1;
+ std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason });
+ continue;
+ }
+ n_bad += 1;
+ std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
+ continue;
+ }
+ const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) });
+ if (goldens_path == null) {
+ const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{});
+ out.appendSlice(arena, jl) catch fatal("oom", .{});
+ out.append(arena, '\n') catch fatal("oom", .{});
+ continue;
+ }
+ const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name});
+ const diff = diffResult(arena, g, res, "helix", "pardes");
+ if (diff.len == 0) continue;
+ if (waivers.get(case.name)) |reason| {
+ n_waived += 1;
+ std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason });
+ continue;
+ }
+ n_bad += 1;
+ std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
}
- n_bad += 1;
- std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
}
- if (goldens_path == null) {
+ if (!parity and goldens_path == null) {
try std.Io.File.stdout().writeStreamingAll(init.io, out.items);
return;
}
- std.debug.print("hxdiff: {d} cases, {d} mismatches, {d} waived\n", .{ n_cases, n_bad, n_waived });
+ std.debug.print("{s}: {d} cases, {d} mismatches, {d} waived\n", .{ if (parity) "hxparity" else "hxdiff", n_cases, n_bad, n_waived });
if (n_bad > 0) std.process.exit(1);
}
@@ -152,20 +194,21 @@ const Waiver = struct {
reason: []const u8,
};
-/// field-by-field compare; returns a human-readable report ("" = match)
-fn diffResult(arena: std.mem.Allocator, want: Result, got: Result) []const u8 {
+/// field-by-field compare; returns a human-readable report ("" = match).
+/// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty).
+fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 {
var buf: std.ArrayList(u8) = .empty;
if (!std.mem.eql(u8, want.text, got.text)) {
- appendf(arena, &buf, " text: helix {f} != pardes {f}\n", .{ std.json.fmt(want.text, .{}), std.json.fmt(got.text, .{}) });
+ appendf(arena, &buf, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) });
}
if (!std.mem.eql(u8, want.mode, got.mode)) {
- appendf(arena, &buf, " mode: helix {s} != pardes {s}\n", .{ want.mode, got.mode });
+ appendf(arena, &buf, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode });
}
if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) {
- appendf(arena, &buf, " cursor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.cursor.row), @as(i64, want.cursor.col), @as(i64, got.cursor.row), @as(i64, got.cursor.col) });
+ appendf(arena, &buf, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) });
}
if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) {
- appendf(arena, &buf, " anchor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.anchor.row), @as(i64, want.anchor.col), @as(i64, got.anchor.row), @as(i64, got.anchor.col) });
+ appendf(arena, &buf, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) });
}
return buf.items;
}
@@ -175,7 +218,7 @@ fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []co
buf.appendSlice(arena, s) catch {};
}
-fn runCase(arena: std.mem.Allocator, case: Case) !Result {
+fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result {
// fresh core per case: the tty_only boot — one shell pane, tty mode, no
// greeting. Live-session contract: sizes arrive as the first resize event.
const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
@@ -183,9 +226,9 @@ fn runCase(arena: std.mem.Allocator, case: Case) !Result {
core.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
pump(core);
- if (std.mem.eql(u8, case.pane, "file")) {
+ if (std.mem.eql(u8, pane_kind, "file")) {
_ = try core.hxOpenFileContent(case.text);
- } else if (std.mem.eql(u8, case.pane, "tty")) {
+ } else if (std.mem.eql(u8, pane_kind, "tty")) {
// the harness is the pty here: cook \n to \r\n the way ONLCR would
var cooked: std.ArrayList(u8) = .empty;
for (case.text) |ch| {
@@ -205,7 +248,7 @@ fn runCase(arena: std.mem.Allocator, case: Case) !Result {
pane.cur_col = 0;
pane.cur_pinned = true;
}
- } else fatal("case {s}: unknown pane {s}", .{ case.name, case.pane });
+ } else fatal("case {s}: unknown pane {s}", .{ case.name, pane_kind });
// keys: helix notation — printables verbatim, <...> specials
var ki: usize = 0;
@@ -280,15 +323,16 @@ fn runCase(arena: std.mem.Allocator, case: Case) !Result {
text = f.content;
} else {
// the tty motion surface, dumped honestly: whole scrollback+grid,
- // prompt rows blanked, typed runs spliced — plus the trailing '\n'
- // ghostty's dump trims off the cursor's empty final row
+ // prompt rows blanked, the edit buffer standing in. The surface keeps
+ // the cursor's empty row below the output (see Pardes.shellRows), so
+ // its line list is exactly a file content's — join it and you have the
+ // same bytes, trailing newline included, with nothing to patch up.
const pl = try core.paneCursorLines(pane);
var buf: std.ArrayList(u8) = .empty;
for (pl.lines, 0..) |ln, i| {
if (i > 0) try buf.append(arena, '\n');
try buf.appendSlice(arena, ln);
}
- if (buf.items.len > 0 and buf.items[buf.items.len - 1] != '\n') try buf.append(arena, '\n');
text = buf.items;
}
var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col };