summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--test/gui-goldens.txt16
-rw-r--r--test/gui_golden.py205
2 files changed, 221 insertions, 0 deletions
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 <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())