summaryrefslogtreecommitdiff
path: root/test/gui_golden.py
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 12:52:47 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724 (patch)
tree5a92fe0e6ccdf5d8df0b1f2df02b424bba0cbff4 /test/gui_golden.py
parentc8a3ecc3bcb36f0259f4d8ad2e0cdfcdc3183c9f (diff)
downloadpardes-8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724.tar.gz
pardes-8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724.zip
Hash settled GUI frames of sixteen scenes, a local gate for render refactors
test/gui_golden.py drives a hidden pardes-gui through 9P and window-pixel mouse events on its test stdin into sixteen scenes: documents, a notice, sticky context rows with the scroll thumb mid-rail, a body selection, a tag holding the keyboard with a selection and caret, a prompt notice, wide glyphs across a tag's and a body row's right edge, a collapsed pane with focus tint, Tagbottom, column tags, a multi-line tag, an image, a dark and a light theme, and Debug. Each scene waits until two captures agree and is hashed. The goldens are machine- and driver-specific: a local gate, never a CI one.
Diffstat (limited to 'test/gui_golden.py')
-rw-r--r--test/gui_golden.py205
1 files changed, 205 insertions, 0 deletions
diff --git a/test/gui_golden.py b/test/gui_golden.py
new file mode 100644
index 00000000..6586af3a
--- /dev/null
+++ b/test/gui_golden.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""Hash settled SDL frames of a fixed set of scenes, to prove a GUI refactor
+left every pixel where it was.
+
+python3 -B test/gui_golden.py <pardes-gui binary> compare
+python3 -B test/gui_golden.py <pardes-gui binary> --update rewrite goldens
+
+The window is hidden (PARDES_TEST renders into an offscreen capture texture),
+and the capture carries PARDES_TEST_PAD leftover pixels so edge bugs show.
+GPU rasterisation differs between machines and drivers: the goldens in
+test/gui-goldens.txt are a LOCAL gate for one machine, never a CI one. Each
+scene waits until two captures 400 ms apart agree before it is hashed.
+"""
+import argparse
+import hashlib
+import os
+from pathlib import Path
+import tempfile
+import shutil
+import time
+
+from fs import session
+from ui_review import require, wait_for
+
+GOLDENS = Path(__file__).resolve().parent / 'gui-goldens.txt'
+
+ZIG = b'''const std = @import("std");
+
+pub const Shape = union(enum) {
+ circle: f32,
+ square: f32,
+
+ pub fn area(shape: Shape) f32 {
+ return switch (shape) {
+ .circle => |r| std.math.pi * r * r,
+ .square => |s| s * s,
+ };
+ }
+};
+
+pub fn main() void {
+ var total: f32 = 0;
+ const shapes = [_]Shape{ .{ .circle = 1 }, .{ .square = 2 } };
+ for (shapes) |shape| {
+''' + b''.join(b' total += shape.area() * %d; // line %d\n' % (n, n) for n in range(60)) + b''' }
+ std.debug.print("{d}\\n", .{total});
+}
+'''
+
+
+def ppm(width, height):
+ rows = []
+ for y in range(height):
+ rows.append(bytes(v for x in range(width) for v in ((x * 255) // width, (y * 255) // height, 128)))
+ return b'P6\n%d %d\n255\n' % (width, height) + b''.join(rows)
+
+
+def panes(client):
+ serials = [int(row.split()[0]) for row in client.read('/index').decode().splitlines() if row.strip()]
+ return {serial: client.read(f'/pane/{serial}/tag').decode(errors='replace') for serial in serials}
+
+
+def settled(root):
+ """The capture once it stops changing: two reads 400 ms apart agree."""
+ latest = root / 'latest.ppm'
+ previous = None
+ deadline = time.monotonic() + 20
+ while time.monotonic() < deadline:
+ time.sleep(.4)
+ try:
+ data = latest.read_bytes()
+ except FileNotFoundError:
+ continue
+ if not data.startswith(b'P6'):
+ continue
+ digest = hashlib.sha256(data).hexdigest()
+ if digest == previous:
+ return digest
+ previous = digest
+ raise RuntimeError('capture never settled')
+
+
+def run(args):
+ results = []
+ # A fixed directory: the paths it holds are drawn in the tags.
+ root = Path(tempfile.gettempdir()) / 'pardes-gui-golden'
+ shutil.rmtree(root, ignore_errors=True)
+ root.mkdir(mode=0o700)
+ name = 'gui-golden'
+ config = root / 'config/pardes'
+ config.mkdir(parents=True)
+ (config / 'init').write_text('Shell /bin/sh\nTheme forge\n')
+ source = root / 'shapes.zig'
+ source.write_bytes(ZIG)
+ notes = root / 'notes.txt'
+ notes.write_bytes(b'Pardes GUI golden\n\nA second document, opened by Look.\n' * 4)
+ wide = root / 'wide.txt'
+ # One of the two alignments puts a wide glyph across the body's right edge.
+ wide.write_bytes(('x' + '\u754c' * 80 + '\n' + '\u754c' * 80 + '\n').encode())
+ image = root / 'ramp.ppm'
+ image.write_bytes(ppm(96, 64))
+ # The session points XDG_RUNTIME_DIR at its own root, so the caller's
+ # Wayland socket is passed as an absolute path.
+ display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0'))
+ if not display.is_absolute():
+ display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display
+ inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40',
+ 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root),
+ 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)}
+ launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()),
+ '--9p=' + name, str(source)]
+ handles = {}
+ with session('/bin/sh', root, name, socket_name=name, tty=True,
+ launch=launch, inherited=inherited, terminal=handles) as (client, _):
+ first = require(wait_for(lambda: next((s for s, tag in panes(client).items()
+ if 'shapes.zig' in tag), None)), 'source pane missing')
+
+ def scene(label):
+ results.append((label, settled(root)))
+ if args.keep:
+ args.keep.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(root / 'latest.ppm', args.keep / (label + '.ppm'))
+ print(label, results[-1][1][:16], flush=True)
+
+ def mouse(kind, button, x, y):
+ # The test shell's stdin takes window-pixel mouse events.
+ os.write(handles['input_fd'], f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode())
+ time.sleep(.1)
+
+ def serial_of(word):
+ return require(wait_for(lambda: next((s for s, tag in panes(client).items() if word in tag), None)),
+ word + ' missing')
+
+ scene('01-boot')
+ client.write(f'/pane/{first}/look', f'{notes}\n'.encode())
+ notes_pane = serial_of('notes.txt')
+ scene('02-second-document')
+ client.write(f'/pane/{first}/ctl', b'Msg a golden notice\n')
+ scene('03-notice')
+ client.write(f'/pane/{first}/ctl', b'TreeContext\n')
+ client.write(f'/pane/{first}/dot', b'45\n')
+ scene('04-context-rows-mid-thumb')
+ client.write(f'/pane/{first}/dot', b'40,43\n')
+ scene('05-body-selection')
+ # A press and a drag along the source's tag: the tag takes the
+ # keyboard with a selection and a caret. The pointer then rests on
+ # empty body space, far right.
+ mouse('down', 1, 500, 87)
+ mouse('motion', 1, 620, 87)
+ mouse('up', 1, 620, 87)
+ mouse('motion', 0, 1880, 1300)
+ scene('06-tag-focus-selection')
+ os.write(handles['input_fd'], b'\x1b')
+ time.sleep(.2)
+ scratch = int(client.read('/pane/new'))
+ client.write(f'/pane/{scratch}/exec', b'Save\n')
+ scene('07-prompt-notice')
+ os.write(handles['input_fd'], b'\x1b')
+ time.sleep(.2)
+ client.write(f'/pane/{scratch}/exec', b'Del\n')
+ client.write(f'/pane/{first}/look', f'{wide}\n'.encode())
+ wide_pane = serial_of('wide.txt')
+ client.write(f'/pane/{wide_pane}/name', ('x' + '\u754c' * 70 + '\n').encode())
+ client.write('/ctl', b'Wrap off\n')
+ scene('08-wide-glyph-edges')
+ client.write('/ctl', b'Wrap on\n')
+ client.write(f'/pane/{wide_pane}/exec', b'Del\n')
+ client.write('/ctl', b'FocusTint on\n')
+ client.write(f'/pane/{notes_pane}/ctl', b'Collapse\n')
+ scene('09-collapsed-focus-tint')
+ client.write(f'/pane/{notes_pane}/ctl', b'Collapse\n')
+ client.write('/ctl', b'Tagbottom\n')
+ scene('10-tagbottom')
+ client.write('/ctl', b'Tagbottom\n')
+ client.write('/ctl', b'ColumnTags\n')
+ scene('11-column-tags')
+ client.write('/ctl', b'ColumnTags\n')
+ client.write(f'/pane/{first}/tag', b' Second\nthird line of the tag')
+ scene('12-multiline-tag')
+ client.write(f'/pane/{first}/look', f'{image}\n'.encode())
+ serial_of('ramp.ppm')
+ scene('13-image')
+ client.write('/ctl', b'Theme ink\n')
+ scene('14-theme-ink')
+ client.write('/ctl', b'Theme acme\n')
+ scene('15-theme-acme-light')
+ client.write('/ctl', b'Debug\n')
+ scene('16-debug')
+ lines = ''.join(f'{label} {digest}\n' for label, digest in results)
+ if args.update:
+ GOLDENS.write_text(lines)
+ print('updated', GOLDENS)
+ return
+ expected = dict(line.split() for line in GOLDENS.read_text().splitlines() if line.strip())
+ bad = [label for label, digest in results if expected.get(label) != digest]
+ require(not bad, 'frames changed: ' + ', '.join(bad))
+ print(f'{len(results)} GUI frames byte-identical')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('binary', type=Path)
+ parser.add_argument('--update', action='store_true')
+ parser.add_argument('--keep', type=Path, help='copy each hashed frame here as <scene>.ppm')
+ run(parser.parse_args())