#!/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) # No linger: a dismissed message is gone, not caught part way, when a # scene settles. (config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\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 # The virtual clock: a transition's frames are the same frames on any # machine, so one partway through can be hashed too. inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '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, frame='latest.ppm'): settled(root) results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest())) if args.keep: args.keep.mkdir(parents=True, exist_ok=True) shutil.copyfile(root / frame, 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(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') client.write('/ctl', b'Debug\n') # A terminal pane: raw rows, ANSI colours, a hidden prompt. before = set(panes(client)) client.write(f'/pane/{first}/exec', b'Tty\n') terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Tty missing') client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r") require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') scene('17-terminal') # Partway through a transition: the panes, the picture among them, # sliding over for a new column, frame 6 of PanelSlide's 12. Its # chrome slides with each pane. client.write('/ctl', b'PanelSlide\n') client.write(f'/pane/{first}/exec', b'Newcol\n') require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') scene('18-mid-transition', 'transition-6.ppm') 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())