|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""GUI test (Linux) of core's view_example (C++): a sign-in form of native controls |
| 3 | +(Label, TextField, Button) laid out by a Column and a Row. The Linux twin of |
| 4 | +core_view_test.py (macOS) and core_view_test.ps1 (Windows): the layout the example |
| 5 | +prints, clicks on the real buttons, the focus event, and the re-flow after a resize. |
| 6 | +
|
| 7 | + tools/gui/core_view_test_linux.py [--wayland] [--keep-open] |
| 8 | +
|
| 9 | +Runs on the host's desktop session through the remote-hosts skill: |
| 10 | +
|
| 11 | + .agents/skills/remote-hosts/scripts/remote.sh linux desktop \\ |
| 12 | + tools/gui/core_view_test_linux.py 180 |
| 13 | +
|
| 14 | +The example is expected in $REMOTE_SCRATCH/core-build (build_core_example_linux.sh) or, |
| 15 | +run locally, in core/build. It takes over the mouse for ~15 s and sends no keys. |
| 16 | +""" |
| 17 | + |
| 18 | +import ctypes |
| 19 | +import os |
| 20 | +import re |
| 21 | +import sys |
| 22 | + |
| 23 | +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # the flat kit on a remote host |
| 24 | + |
| 25 | +from guiapp import Abort, Checks, GuiApp, assert_idle, pause # noqa: E402 |
| 26 | +from xinput import session_type # noqa: E402 |
| 27 | + |
| 28 | +NAME = 'view_example' |
| 29 | +WINDOW = 'Sign in' |
| 30 | +CONTENT = (360, 220) |
| 31 | +PADDING, SPACING = 16, 8 |
| 32 | + |
| 33 | + |
| 34 | +def executable(): |
| 35 | + scratch = os.environ.get('REMOTE_SCRATCH') |
| 36 | + if scratch and os.path.exists(os.path.join(scratch, 'core-build')): |
| 37 | + return os.path.join(scratch, 'core-build', 'examples', NAME, NAME) |
| 38 | + workspace = os.environ.get('REMOTE_WORKSPACE') or os.path.dirname( |
| 39 | + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 40 | + return os.path.join(workspace, 'core', 'build', 'examples', NAME, NAME) |
| 41 | + |
| 42 | + |
| 43 | +def layouts(app): |
| 44 | + """{label: (x, y, w, h)} from the LAST "[view] layout" block the example printed.""" |
| 45 | + out = {} |
| 46 | + for line in app.output().splitlines(): |
| 47 | + m = re.match(r'\[view\] layout (\w+) (\S+) (\S+) (\S+) (\S+)', line) |
| 48 | + if m: |
| 49 | + if m.group(1) == 'root': |
| 50 | + out = {} |
| 51 | + out[m.group(1)] = tuple(float(v) for v in m.group(2, 3, 4, 5)) |
| 52 | + return out |
| 53 | + |
| 54 | + |
| 55 | +def statuses(app): |
| 56 | + return [l.split(': ', 1)[1] for l in app.output().splitlines() |
| 57 | + if l.startswith('[view] status: ')] |
| 58 | + |
| 59 | + |
| 60 | +def near(checks, what, actual, expected, tolerance=2): |
| 61 | + checks.check(what, abs(actual - expected) <= tolerance, f'{actual} vs {expected}') |
| 62 | + |
| 63 | + |
| 64 | +def control_center(app, layout, name): |
| 65 | + """Screen point of a control's centre: its root-relative logical frame, scaled by the |
| 66 | + client area against the root (GNOME's scale is per monitor).""" |
| 67 | + x, y, w, _ = next(c for t, c in app.contents() if t == WINDOW) |
| 68 | + scale = w / layout['root'][2] |
| 69 | + cx, cy, cw, ch = layout[name] |
| 70 | + return x + (cx + cw / 2) * scale, y + (cy + ch / 2) * scale |
| 71 | + |
| 72 | + |
| 73 | +def click_control(app, layout, name): |
| 74 | + xid = next(w.xid for w in app._windows() if w.title == WINDOW) |
| 75 | + app.x.activate(xid) # raise it over whatever else is on the desktop |
| 76 | + pause(0.4) |
| 77 | + point = control_center(app, layout, name) |
| 78 | + print(f'click {name} at {point[0]:.0f},{point[1]:.0f}', flush=True) |
| 79 | + app.click(point) |
| 80 | + |
| 81 | + |
| 82 | +def resize_client(app, width, height): |
| 83 | + """XResizeWindow on the client window; the window manager keeps the frame around it.""" |
| 84 | + xid = next(w.xid for w in app._windows() if w.title == WINDOW) |
| 85 | + x11 = app.x.x11 |
| 86 | + x11.XResizeWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_uint, ctypes.c_uint] |
| 87 | + x11.XResizeWindow(app.x.dpy, xid, int(width), int(height)) |
| 88 | + app.x.flush() |
| 89 | + |
| 90 | + |
| 91 | +def main(): |
| 92 | + backend = 'wayland' if '--wayland' in sys.argv else 'x11' |
| 93 | + assert_idle() |
| 94 | + app = GuiApp(executable(), backend=backend) |
| 95 | + app.keep_open = '--keep-open' in sys.argv |
| 96 | + checks = Checks() |
| 97 | + print(f'session: {session_type()}, app backend: {backend}', flush=True) |
| 98 | + try: |
| 99 | + app.launch(min_windows=1, flutter=False) |
| 100 | + pause(0.5) |
| 101 | + layout = layouts(app) |
| 102 | + checks.check('layout printed for every control', |
| 103 | + {'root', 'heading', 'name', 'password', 'status', 'actions', 'clear', |
| 104 | + 'sign_in'} <= set(layout), sorted(layout)) |
| 105 | + near(checks, 'root is the content area (w)', layout['root'][2], CONTENT[0]) |
| 106 | + near(checks, 'root is the content area (h)', layout['root'][3], CONTENT[1]) |
| 107 | + content = next(c for t, c in app.contents() if t == WINDOW) |
| 108 | + near(checks, "client area has the root's aspect", content[3] / content[2], |
| 109 | + CONTENT[1] / CONTENT[0], 0.01) |
| 110 | + near(checks, 'heading starts at the padding', layout['heading'][1], PADDING) |
| 111 | + near(checks, 'heading spans the padded width', layout['heading'][2], |
| 112 | + CONTENT[0] - 2 * PADDING) |
| 113 | + h, n = layout['heading'], layout['name'] |
| 114 | + near(checks, 'name follows the heading', n[1], h[1] + h[3] + SPACING) |
| 115 | + near(checks, 'password follows name', layout['password'][1], n[1] + n[3] + SPACING) |
| 116 | + checks.check('a text field has a height', n[3] > 10, n[3]) |
| 117 | + near(checks, 'actions row is 32 high (preferred size)', layout['actions'][3], 32) |
| 118 | + s, c = layout['sign_in'], layout['clear'] |
| 119 | + near(checks, 'sign in hugs the trailing edge', s[0] + s[2], CONTENT[0] - PADDING) |
| 120 | + near(checks, 'clear sits before sign in, spaced', c[0] + c[2] + SPACING, s[0]) |
| 121 | + checks.check('buttons have an intrinsic width', 40 < s[2] < 140, s[2]) |
| 122 | + |
| 123 | + click_control(app, layout, 'sign_in') |
| 124 | + pause(1.0) |
| 125 | + checks.check('empty sign-in is refused', |
| 126 | + statuses(app)[-1:] == ['A username is required.'], statuses(app)) |
| 127 | + checks.check('the refused sign-in focused the username field', |
| 128 | + '[view] focused: name' in app.output()) |
| 129 | + |
| 130 | + click_control(app, layouts(app), 'clear') |
| 131 | + pause(1.0) |
| 132 | + checks.check('clear resets the status', |
| 133 | + statuses(app)[-1:] == ['Enter your credentials.'], statuses(app)) |
| 134 | + |
| 135 | + scale = content[2] / CONTENT[0] |
| 136 | + resize_client(app, (CONTENT[0] + 120) * scale, (CONTENT[1] + 60) * scale) |
| 137 | + pause(1.5) |
| 138 | + # WindowResizedEvent arrives before GTK lays the content out, so the layout the |
| 139 | + # example printed for it is stale. Click where "Sign in" must be now — it hugs |
| 140 | + # the trailing edge, and the Column keeps its row where it was — and read the |
| 141 | + # layout printed with the status line. |
| 142 | + moved = dict(layout) |
| 143 | + moved['root'] = (0, 0, CONTENT[0] + 120, CONTENT[1] + 60) |
| 144 | + moved['sign_in'] = (s[0] + 120, s[1], s[2], s[3]) |
| 145 | + click_control(app, moved, 'sign_in') |
| 146 | + pause(1.0) |
| 147 | + st = statuses(app) |
| 148 | + checks.check('sign in answers where the re-flow put it', |
| 149 | + len(st) >= 3 and st[-1] == 'A username is required.', st) |
| 150 | + after = layouts(app) |
| 151 | + near(checks, 'root grew with the window (w)', after['root'][2], CONTENT[0] + 120) |
| 152 | + near(checks, 'root grew with the window (h)', after['root'][3], CONTENT[1] + 60) |
| 153 | + near(checks, 'heading re-stretched', after['heading'][2], CONTENT[0] + 120 - 2 * PADDING) |
| 154 | + s2 = after['sign_in'] |
| 155 | + near(checks, 'sign in followed the trailing edge', s2[0] + s2[2], |
| 156 | + CONTENT[0] + 120 - PADDING) |
| 157 | + near(checks, 'button width unchanged by the resize', s2[2], s[2]) |
| 158 | + except Abort as e: |
| 159 | + checks.check('ran to the end', False, e) |
| 160 | + finally: |
| 161 | + app.quit() |
| 162 | + for line in app.output().splitlines(): |
| 163 | + if not line.startswith('[view] layout'): |
| 164 | + print(f'app: {line}') |
| 165 | + print(f'{checks.failures} failure(s); app log: {app.log_path}') |
| 166 | + return 1 if checks.failures else 0 |
| 167 | + |
| 168 | + |
| 169 | +if __name__ == '__main__': |
| 170 | + sys.exit(main()) |
0 commit comments