From 8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 12:52:47 -0300 Subject: 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. --- test/gui-goldens.txt | 16 ++++ test/gui_golden.py | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 test/gui-goldens.txt create mode 100644 test/gui_golden.py diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt new file mode 100644 index 00000000..5531b578 --- /dev/null +++ b/test/gui-goldens.txt @@ -0,0 +1,16 @@ +01-boot d4e3eb9dba35133df10d46b1d27ab7a90fa9bdefb64bcc304add221326ffdfc9 +02-second-document 235cdf75f3ce1d5800dbbdd7dcfde066af50cde01231a35d00c66c4310213a05 +03-notice 4d59205fe6b89a4589e94c68521257f21b40917c0198ea20248925687663d9f5 +04-context-rows-mid-thumb 7129ee318975f858c430b1e610b26ca756d5bb37553338c0dc6caab2290de2a4 +05-body-selection 39103f3747aae07a12b5151e7e377430b88b96e504670ac2f2d0272197eb827b +06-tag-focus-selection 2bedec24842109aa2cc302226d53a994ff547791ad54d5781fe48d8eaac179ac +07-prompt-notice 6d6eda3f3a74d3ff048cb0e433e3794ca061e356184ff62a4878395c58482727 +08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 +09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 +10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 +11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 +12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 +13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea +14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 +15-theme-acme-light 39f9256b7c8ed5187cd8d2cc3840db274efcecad263e519c4f9c79eb9efea19c +16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 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 compare +python3 -B test/gui_golden.py --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 .ppm') + run(parser.parse_args()) -- cgit v1.3