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_golden.py | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 test/gui_golden.py (limited to 'test/gui_golden.py') 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