diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-26 18:36:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | e381c96a08f08e03a340a24acb3fe5fe1fca51be (patch) | |
| tree | db0ef0cc2652d2797b9e2f73709db601816436d1 /test/hxdiff.zig | |
| parent | a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60 (diff) | |
| download | pardes-e381c96a08f08e03a340a24acb3fe5fe1fca51be.tar.gz pardes-e381c96a08f08e03a340a24acb3fe5fe1fca51be.zip | |
adding better search to tty mode
Diffstat (limited to 'test/hxdiff.zig')
| -rw-r--r-- | test/hxdiff.zig | 136 |
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 }; |
