makepad/apps/wm/tools/usability/suite.py
Admin d3ca8bb506 wm and hosted apps: the desktop portal, clipboard, file picker and paced children
A hosted child is ticked at its owner's frames, one child frame per frame
shown; hosted apps launch with `cargo run` from a checkout, open at the size
they ask for, open more windows of their own, resize live and die with the
WM. The WM is the desktop's portal backend (file chooser, light/dark), with
a makepad-drawn file and folder picker; the Wayland clipboard and primary
selection meet the makepad clipboard. The AI hub is the WM's own child and
its panel shows running models. A covered window gets no frames; tiling and
the warp shelves exclude each other; phone WM Settings fixes; dock order
places a foreign app next to ours.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 07:24:22 +02:00

837 lines
30 KiB
Python

#!/usr/bin/env python3
"""WM usability gate: plain user tasks, scripted with human-like pointer
timing against the real WM binary and real apps (hidden, node 165).
suite.py <checkout-with-release-build> [--only name,name] [--out DIR]
Each task acts like a person (pointer moves ~every 16 ms, presses, dwells),
then checks the end state from the WM's motion recorder (window rects, mode,
shelf membership, focus, desktop) and the motion in between with
../frametrace/analyze.py (no size or position jumps, no shelf flash, no
blank captures, no hidden reconfigure). A screenshot ends every task.
Prints one PASS/FAIL table; exit 1 on any FAIL.
"""
import argparse, json, os, sys, time, traceback
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "frametrace"))
from ftrig import Rig # noqa: E402
import analyze # noqa: E402
# Motion findings that fail a task. STRETCH (a stale frame shown at a new
# size for a moment) and pacing are reported, not failed.
BAD = {"SIZE-NO-TWEEN", "SIZE-JUMP", "POS-JUMP", "HANDOFF", "ORIGIN", "SHELF-SHOWN", "BLANK-SHOT",
"DEGENERATE", "CFG-HIDDEN", "NO-FRAME"}
STEP = 0.016 # s between pointer moves
class Fail(Exception):
pass
class T:
"""The session: a rig plus person-like actions and state reads."""
def __init__(self, rig, out):
self.rig, self.out = rig, out
self.fixups = []
# ---- state -------------------------------------------------------------
def frames(self):
return analyze.load(self.rig.trace)
def last(self):
return self.rig.last()
def n(self):
f = self.last()
return f["n"] if f else 0
def win(self, app, fr=None):
fr = fr or self.last()
for w in fr["w"]:
if w["cap"] is None and w.get("app") == app:
return w
return None
def cid(self, app):
w = self.win(app)
if not w:
raise Fail(f"no {app} window on show")
return w["c"]
def hit(self, app, kind):
fr = self.last()
c = self.cid(app)
for h in fr.get("hits", []):
if h["c"] == c and h["k"] == kind:
return h["r"]
raise Fail(f"{app} has no '{kind}' hit rect drawn")
def screen(self):
return self.last()["disp"][0]["r"]
def still(self, timeout=4.0):
"""Wait until nothing animates (no frame for a while or anim false)."""
end = time.time() + timeout
time.sleep(0.15)
while time.time() < end:
a = self.last()
time.sleep(0.25)
b = self.last()
if a["n"] == b["n"] or not b.get("anim"):
if not b.get("anim"):
time.sleep(0.1)
return
time.sleep(0.05)
# ---- person ------------------------------------------------------------
def move(self, x, y):
self.rig.mouse("move", x, y)
time.sleep(STEP)
def glide(self, a, b, steps=None, speed=900.0):
"""Pointer from a to b at about `speed` pt/s, a sample every STEP."""
dist = ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5
steps = steps or max(2, int(dist / (speed * STEP)))
for i in range(1, steps + 1):
t = i / steps
e = t * t * (3 - 2 * t) # a hand eases in and out
self.move(a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e)
def drag(self, a, b, dwell=0.0, speed=900.0, release=True):
self.move(*a)
time.sleep(0.12)
self.rig.mouse("down", *a)
time.sleep(0.08)
self.glide(a, b, speed=speed)
if dwell:
time.sleep(dwell)
if release:
self.rig.mouse("up", *b)
time.sleep(0.05)
def click(self, x, y):
self.move(x, y)
time.sleep(0.08)
self.rig.mouse("down", x, y)
time.sleep(0.07)
self.rig.mouse("up", x, y)
time.sleep(0.05)
def dclick(self, x, y):
self.click(x, y)
time.sleep(0.09)
self.rig.mouse("down", x, y)
time.sleep(0.06)
self.rig.mouse("up", x, y)
def key(self, code, **mods):
self.rig.key(code, **mods)
time.sleep(0.05)
def center(self, r):
return (r[0] + r[2] / 2, r[1] + r[3] / 2)
def caption(self, app):
return self.center(self.hit(app, "title"))
def uncovered_point(self, app):
"""A point of the window no window above it covers (None: covered)."""
fr = self.last()
w = self.win(app, fr)
order = fr["zorder"]
if w["c"] not in order:
return None
above = [x["s"] for x in fr["w"] if x["cap"] is None and x["c"] in order and order.index(x["c"]) > order.index(w["c"])]
s = w["s"]
for fy in (0.03, 0.5, 0.9, 0.3, 0.7):
for fx in (0.5, 0.2, 0.8, 0.97, 0.03):
if fx < 0.1 and fy < 0.2:
continue # the traffic lights
p = (s[0] + s[2] * fx, s[1] + max(6, s[3] * fy))
if not any(a[0] <= p[0] <= a[0] + a[2] and a[1] <= p[1] <= a[1] + a[3] for a in above):
return p
return None
def raise_(self, app):
fr = self.last()
c = self.cid(app)
if fr["zorder"] and fr["zorder"][-1] == c:
return
p = self.uncovered_point(app)
if p is None:
raise Fail(f"{app} is fully covered")
self.click(*p)
self.still()
def ensure_normal(self, app):
"""Precondition: a normal, unshelved window on top (fixes it up if not)."""
w = self.win(app)
if w is None:
raise Fail(f"no {app} window")
if w.get("parked") or (w.get("pose") or {}).get("side"):
self.click(*self.center(w["s"]))
self.still()
self.key("ArrowDown", logo=1)
self.still()
w = self.win(app)
if w.get("parked") or (w.get("pose") or {}).get("side"):
# still on (or warped toward) a shelf: drag it to the middle
scr = self.screen()
self.drag(self.center(w["s"]), (scr[0] + scr[2] * 0.5, scr[1] + scr[3] * 0.4), speed=800)
self.still()
self.fixups.append(f"{app} had to be dragged off a shelf")
w = self.win(app)
if w and w.get("mode") == 1:
self.dclick(*self.caption(app))
self.still()
w = self.win(app)
if not w or w.get("parked") or w.get("mode") != 0:
raise Fail(f"precondition: {app} is not a normal window ({w and (w.get('mode'), w.get('parked'))})")
self.raise_(app)
return self.win(app)
def close(a, b, tol=6.0):
return all(abs(x - y) <= tol for x, y in zip(a, b))
def size_of(w):
return w["s"][2], w["s"][3]
# ---- the tasks -----------------------------------------------------------------
# Each returns a note; raise Fail for a wrong end state.
def t_move(t):
w = t.ensure_normal("notes")
a = t.caption("notes")
# Within the centre band (a drop that overlaps a shelf zone is eased back into it).
t.drag(a, (a[0] + 90, a[1] + 60))
t.still()
v = t.win("notes")
dx, dy = v["s"][0] - w["s"][0], v["s"][1] - w["s"][1]
if not (close((dx, dy), (90, 60), 8) and close(size_of(v), size_of(w), 2)):
raise Fail(f"moved by ({dx:.0f},{dy:.0f}) size {size_of(w)} -> {size_of(v)}; want (90,60), same size")
return f"moved ({dx:.0f},{dy:.0f})"
def t_move_near_edge(t):
w = t.ensure_normal("notes")
a = t.caption("notes")
scr = t.screen()
# The window's left edge to 24 pt from the screen edge; the pointer stays far from it.
dx = (scr[0] + 24) - w["s"][0]
t.drag(a, (a[0] + dx, a[1]), dwell=0.4)
t.still()
v = t.win("notes")
if v.get("parked") or (v.get("pose") or {}).get("side") or not close(size_of(v), size_of(w), 2):
raise Fail(f"near the edge it changed: parked={v.get('parked')} side={v['pose']['side']} size {size_of(w)} -> {size_of(v)}")
t.drag(t.caption("notes"), (scr[0] + scr[2] / 2, scr[1] + 120))
t.still()
return f"left edge at {v['s'][0]:.0f}"
def resize_task(edge, d):
def run(t):
w = t.ensure_normal("clock")
r = t.hit("clock", edge)
a = t.center(r)
sx = {"-": -1, "0": 0, "+": 1}[edge[1]]
sy = {"-": -1, "0": 0, "+": 1}[edge[2]]
t.drag(a, (a[0] + sx * d, a[1] + sy * d), speed=500)
t.still()
v = t.win("clock")
dw, dh = v["s"][2] - w["s"][2], v["s"][3] - w["s"][3]
want = (abs(sx) * d, abs(sy) * d)
if v.get("parked") or not close((dw, dh), want, 10):
raise Fail(f"resize {edge} by {d}: size change ({dw:.0f},{dh:.0f}) want {want}, parked={v.get('parked')}")
# and back
r = t.hit("clock", edge)
a = t.center(r)
t.drag(a, (a[0] - sx * d, a[1] - sy * d), speed=500)
t.still()
return f"({dw:.0f},{dh:.0f})"
return run
def t_resize_to_screen_edge(t):
w = t.ensure_normal("clock")
scr = t.screen()
a = t.center(t.hit("clock", "r-0"))
t.drag(a, (scr[0], a[1]), dwell=0.6, speed=700)
t.still()
v = t.win("clock")
if v.get("parked") or (v.get("pose") or {}).get("side"):
raise Fail(f"resizing to the screen edge shelved it (parked={v.get('parked')} side={v['pose']['side']})")
if v["s"][2] <= w["s"][2] + 20:
raise Fail(f"did not grow: {w['s'][2]:.0f} -> {v['s'][2]:.0f}")
# corner to the screen corner
a = t.center(t.hit("clock", "r--"))
t.drag(a, (scr[0], scr[1]), dwell=0.6, speed=700)
t.still()
v = t.win("clock")
if v.get("parked") or (v.get("pose") or {}).get("side"):
raise Fail("resizing to the screen corner shelved it")
# back to a sane size
a = t.center(t.hit("clock", "r--"))
t.drag(a, (a[0] + 260, a[1] + 120), speed=700)
t.still()
return f"width {w['s'][2]:.0f} -> {v['s'][2]:.0f}, never shelved"
def t_maximise_restore(t):
w = t.ensure_normal("clock")
t.dclick(*t.caption("clock"))
t.still()
v = t.win("clock")
if v.get("mode") != 1:
raise Fail(f"double click: mode {v.get('mode')} (want maximised)")
t.dclick(*t.caption("clock"))
t.still()
u = t.win("clock")
if u.get("mode") != 0 or not close(u["s"], w["s"], 4):
raise Fail(f"restore: mode {u.get('mode')} rect {u['s']} want {w['s']}")
return f"max {v['s'][2]:.0f}x{v['s'][3]:.0f}, restored"
def t_fullscreen_restore(t):
w = t.ensure_normal("clock")
t.click(*t.center(t.hit("clock", "max")))
t.still(6)
v = t.win("clock")
scr = t.screen()
fr = t.last()
if not v or v.get("mode") != 2:
raise Fail(f"green button: mode {v and v.get('mode')} (want fullscreen)")
# top-edge caption reveal
t.glide((scr[0] + scr[2] / 2, scr[1] + 200), (scr[0] + scr[2] / 2, scr[1] + 1))
time.sleep(0.8)
try:
cap = t.hit("clock", "max")
except Fail:
raise Fail("fullscreen: no caption at the top edge")
t.click(*t.center(cap))
t.still(6)
u = t.win("clock")
if not u or u.get("mode") != 0 or not close(u["s"], w["s"], 6):
raise Fail(f"restore: mode {u and u.get('mode')} rect {u and u['s']} want {w['s']}")
return f"fullscreen {v['s'][2]:.0f}x{v['s'][3]:.0f} on desktop {fr['disp'][0]['cur']}, caption revealed, restored"
def shelve(t, app):
scr = t.screen()
a = t.caption(app)
t.drag(a, (scr[0] + 1, a[1]), dwell=0.45, speed=900)
t.still()
v = t.win(app)
if not v or not v.get("parked"):
raise Fail(f"edge drag + dwell did not shelve {app}")
return v
def t_shelve_edge(t):
w = t.ensure_normal("notes")
v = shelve(t, "notes")
return f"tile {v['s'][2]:.0f}x{v['s'][3]:.0f}"
def t_tile_half_fold(t):
v = t.win("notes")
if not v or not v.get("parked"):
shelve(t, "notes")
v = t.win("notes")
t.click(*t.center(v["s"]))
t.still()
h = t.win("notes")
if not h.get("half"):
raise Fail("click on the tile: no half-size unfold")
scr = t.screen()
t.click(scr[0] + scr[2] * 0.6, scr[1] + scr[3] * 0.5)
t.still()
f = t.win("notes")
if f.get("half") or not f.get("parked"):
raise Fail(f"click elsewhere: half={f.get('half')} parked={f.get('parked')}")
return f"half {h['s'][2]:.0f}x{h['s'][3]:.0f}, folded"
def t_pull_out_tile(t, full=None):
v = t.win("notes")
if not v or not v.get("parked"):
shelve(t, "notes")
v = t.win("notes")
scr = t.screen()
a = t.center(v["s"])
t.drag(a, (scr[0] + scr[2] * 0.5, scr[1] + scr[3] * 0.45), speed=800)
t.still()
u = t.win("notes")
if u.get("parked") or (u.get("pose") or {}).get("side") or u.get("mode") != 0:
raise Fail(f"pull-out: still parked={u.get('parked')} side={u['pose']['side']}")
return f"out at {u['s'][2]:.0f}x{u['s'][3]:.0f}"
def t_pull_out_half(t):
shelve(t, "notes")
v = t.win("notes")
t.click(*t.center(v["s"]))
t.still()
h = t.win("notes")
if not h.get("half"):
raise Fail("no half unfold")
scr = t.screen()
a = t.caption("notes")
t.drag(a, (scr[0] + scr[2] * 0.55, scr[1] + scr[3] * 0.4), speed=800)
t.still()
u = t.win("notes")
if u.get("parked") or u.get("half"):
raise Fail(f"pull-out from half: parked={u.get('parked')} half={u.get('half')}")
return f"out at {u['s'][2]:.0f}x{u['s'][3]:.0f}"
def t_esc_fold(t):
shelve(t, "notes")
v = t.win("notes")
t.click(*t.center(v["s"]))
t.still()
if not t.win("notes").get("half"):
raise Fail("no half unfold")
t.key("Escape")
t.still()
f = t.win("notes")
if f.get("half") or not f.get("parked"):
raise Fail(f"Esc: half={f.get('half')} parked={f.get('parked')}")
return "folded by Esc"
def t_super_down(t):
v = t.win("notes")
if not v.get("parked"):
shelve(t, "notes")
v = t.win("notes")
t.click(*t.center(v["s"])) # focus it (unfolds)
t.still()
t.key("ArrowDown", logo=1)
t.still()
u = t.win("notes")
if u.get("parked") or u.get("half"):
raise Fail(f"SUPER+Down: parked={u.get('parked')} half={u.get('half')}")
return "un-shelved"
def t_desktops(t):
t.ensure_normal("notes")
shelve(t, "notes")
fr0 = t.last()
start = fr0["disp"][0]["cur"]
t.key("Key0", logo=1) # a new desktop, shown
t.still()
for k in ["ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "ArrowLeft"]:
t.key(k, logo=1)
t.still()
fr = t.last()
if fr["disp"][0]["cur"] != start:
raise Fail(f"back on desktop {fr['disp'][0]['cur']}, want {start}")
# fast
for k in ["ArrowRight", "ArrowLeft", "ArrowRight", "ArrowLeft"]:
t.key(k, logo=1)
time.sleep(0.08)
t.still()
v = t.win("notes")
if not v or not v.get("parked"):
raise Fail("the shelved window is not on its shelf after the switches")
return f"{len(fr['disp'][0]['slots'])} desktops"
def dock_icon(t, app):
for d in t.last().get("dock", []):
if d["app"] == app:
return d["r"]
raise Fail(f"no {app} icon in the dock")
def shown(w):
"""The app has presented its first frame into the window (what a person waits for before
reaching for its buttons: until then the window may still take the size the app asks)."""
return bool(w and w.get("host") and w["host"].get("has"))
def t_dock_open_close_reopen(t):
app = "calculator"
r = dock_icon(t, app)
t.click(*t.center(r))
deadline = time.time() + 60
while time.time() < deadline and not shown(t.win(app)):
time.sleep(0.5)
t.still()
w = t.win(app)
if not w:
raise Fail("dock click: no window")
if t.last().get("focus") != w["c"]:
raise Fail("dock click: the new window has no focus")
t.click(*t.center(t.hit(app, "close")))
deadline = time.time() + 10
while time.time() < deadline and t.win(app):
time.sleep(0.3)
if t.win(app):
raise Fail("close button: still there")
t.click(*t.center(dock_icon(t, app)))
deadline = time.time() + 60
while time.time() < deadline and not shown(t.win(app)):
time.sleep(0.5)
t.still()
if not t.win(app):
raise Fail("reopen from the dock: no window")
return "opened, closed, reopened"
def t_focus_click(t):
t.ensure_normal("clock")
t.ensure_normal("notes")
for app in ("clock", "notes"):
w = t.win(app)
# a point of this window not covered by any later one
fr = t.last()
order = fr["zorder"]
above = [x["s"] for x in fr["w"] if x["cap"] is None and x["c"] in order and order.index(x["c"]) > order.index(w["c"])]
pt = None
s = w["s"]
for fx in (0.15, 0.5, 0.85, 0.97, 0.03):
for fy in (0.3, 0.6, 0.9, 0.97):
p = (s[0] + s[2] * fx, s[1] + s[3] * fy)
if not any(a[0] <= p[0] <= a[0] + a[2] and a[1] <= p[1] <= a[1] + a[3] for a in above):
pt = p
break
if pt:
break
if not pt:
raise Fail(f"{app} is fully covered")
t.click(*pt)
t.still()
if t.last().get("focus") != w["c"]:
raise Fail(f"click on {app}: focus is {t.last().get('focus')}")
return "focus followed both clicks"
def t_minimise_restore(t):
w = t.ensure_normal("clock")
t.click(*t.center(t.hit("clock", "min")))
t.still()
v = t.win("clock")
if v and not v.get("min"):
raise Fail("minimise: still on show")
t.click(*t.center(dock_icon(t, "clock")))
t.still()
u = t.win("clock")
if not u or u.get("min") or not close(u["s"], w["s"], 6):
raise Fail(f"restore from the dock: {u and u['s']} want {w['s']}")
return "minimised, restored from the dock"
def t_overlap_raise(t):
a = t.ensure_normal("notes")
b = t.ensure_normal("clock")
# clock over notes, offset
ca = t.caption("clock")
t.drag(ca, (ca[0] + (a["s"][0] + 120 - b["s"][0]), ca[1] + (a["s"][1] + 60 - b["s"][1])))
t.still()
t.click(*t.caption("clock"))
t.still()
fr = t.last()
if fr["zorder"][-1] != t.cid("clock"):
raise Fail("clock not on top after its click")
n = t.win("notes")
t.click(n["s"][0] + 30, n["s"][1] + n["s"][3] - 30)
t.still()
fr = t.last()
if fr["zorder"][-1] != t.cid("notes") or fr.get("focus") != t.cid("notes"):
raise Fail(f"click on the covered notes: top {fr['zorder'][-1]}, focus {fr.get('focus')}")
return "raised by click"
# The size setup() gives its two windows (the layout the tasks after it start from): apps
# open at the size they ask for (955d138a8b), so setup sizes them to this rather than by a
# fixed shrink of whatever they opened at.
SETUP_SIZE = (680, 290)
def setup(t):
"""Two smaller windows side by side in the centre band, clear of the dock."""
for app, (x, y) in (("notes", (200, 50)), ("clock", (600, 240))):
t.raise_(app)
w = t.win(app)
a = t.center(t.hit(app, "r++"))
t.drag(a, (a[0] - (w["s"][2] - SETUP_SIZE[0]), a[1] - (w["s"][3] - SETUP_SIZE[1])), speed=1200)
t.still()
w = t.win(app)
c = t.caption(app)
t.drag(c, (c[0] + x - w["s"][0], c[1] + y - w["s"][1]), speed=1500)
t.still()
TASKS = [
("move a window", t_move),
("move near an edge (no shelve)", t_move_near_edge),
] + [(f"resize from {e}", resize_task(e, 60)) for e in ("r+0", "r-0", "r0+", "r0-", "r++", "r--", "r+-", "r-+")] + [
("resize to the screen edge (never shelves)", t_resize_to_screen_edge),
("maximise by double click, restore", t_maximise_restore),
("fullscreen (green), top caption, restore", t_fullscreen_restore),
("shelve via the edge", t_shelve_edge),
("tile click -> half, click elsewhere -> fold", t_tile_half_fold),
("pull out from the tile", t_pull_out_tile),
("pull out from the half window", t_pull_out_half),
("Esc folds the half window", t_esc_fold),
("SUPER+Down un-shelves", t_super_down),
("switch desktops back and forth", t_desktops),
("dock: open, close, reopen", t_dock_open_close_reopen),
("focus follows click", t_focus_click),
("minimise and restore", t_minimise_restore),
("overlap and raise", t_overlap_raise),
]
# ---- screenshots ---------------------------------------------------------------
# The WM's HOME is a folder of its own, so ~/Screenshots is ours.
def shots(t):
d = os.path.join(t.rig.env["HOME"], "Screenshots")
return sorted(f for f in (os.listdir(d) if os.path.isdir(d) else []) if f.endswith(".png"))
def png_size(path):
from PIL import Image
with Image.open(path) as im:
return im.size
def wait_new_shot(t, before, secs=8.0):
end = time.time() + secs
while time.time() < end:
now = shots(t)
if len(now) > len(before):
return [f for f in now if f not in before][0]
time.sleep(0.2)
raise Fail("no screenshot file was saved")
def shot_path(t, name):
return os.path.join(t.rig.env["HOME"], "Screenshots", name)
def region_drag(t, a, b):
t.key("Key4", logo=1, shift=1)
time.sleep(1.2) # the frame is taken, then the overlay comes up
t.drag(a, b, speed=700)
def t_shot_region(t):
scr = t.screen()
before = shots(t)
a = (scr[0] + 300, scr[1] + 200)
region_drag(t, a, (a[0] + 300, a[1] + 200))
name = wait_new_shot(t, before)
w, h = png_size(shot_path(t, name))
scale = 2.0
if abs(w - 300 * scale) > 3 or abs(h - 200 * scale) > 3:
raise Fail(f"{name} is {w}x{h}, want about {int(300 * scale)}x{int(200 * scale)}")
if not name.startswith("screenshot-") or not name.endswith(".png"):
raise Fail(f"unexpected name {name}")
return f"{name} {w}x{h}"
def t_shot_screen(t):
scr = t.screen()
before = shots(t)
t.key("Key3", logo=1, shift=1)
name = wait_new_shot(t, before)
w, h = png_size(shot_path(t, name))
if abs(w - scr[2] * 2) > 3 or abs(h - scr[3] * 2) > 3:
raise Fail(f"{name} is {w}x{h}, want the screen {int(scr[2] * 2)}x{int(scr[3] * 2)}")
return f"{name} {w}x{h}"
def t_shot_cancel(t):
before = shots(t)
t.key("Key4", logo=1, shift=1)
time.sleep(1.2)
t.key("Escape")
time.sleep(1.0)
if shots(t) != before:
raise Fail("Esc still saved a picture")
return "Esc cancels"
def t_shot_window(t):
before = shots(t)
# The window on top (apps open at their asked sizes, centred: Code's 1440x900 lies over
# the middle of Notes's 1240x800), picked at its centre.
fr = t.last()
order = fr["zorder"]
w = max((t.win(app, fr) for app in ("notes", "code")), key=lambda x: order.index(x["c"]) if x and x["c"] in order else -1)
s = w["s"]
t.key("Key4", logo=1, shift=1)
time.sleep(1.2)
t.key("Space")
time.sleep(0.2)
p = (s[0] + s[2] / 2, s[1] + s[3] / 2)
t.move(*p)
time.sleep(0.2)
t.click(*p)
name = wait_new_shot(t, before)
pw, ph = png_size(shot_path(t, name))
# The app's own image: the window's width, its height less the WM's caption strip.
if abs(pw - s[2] * 2) > 6 or not (s[3] * 2 * 0.85 <= ph <= s[3] * 2 + 6):
raise Fail(f"{name} is {pw}x{ph}, want the window's own image about {int(s[2] * 2)}x{int(s[3] * 2)}")
return f"{name} {pw}x{ph}"
def t_shot_thumbnail_into_code(t):
scr = t.screen()
before = shots(t)
a = (scr[0] + 120, scr[1] + 120)
region_drag(t, a, (a[0] + 300, a[1] + 200))
name = wait_new_shot(t, before)
time.sleep(0.8) # it eases in
# The thumbnail: 240x160 at the screen's bottom-right corner, 20 pt in.
tx, ty = scr[0] + scr[2] - 20 - 120, scr[1] + scr[3] - 20 - 80
code = t.win("code")
if not code:
raise Fail("no code window")
s = code["s"]
target = (s[0] + s[2] * 0.7, s[1] + s[3] * 0.55)
n0 = len(open(t.rig.log, errors="replace").read())
t.drag((tx, ty), target, speed=500)
end = time.time() + 6
log = ""
while time.time() < end:
log = open(t.rig.log, errors="replace").read()[n0:]
if "code: attached" in log and "dropped on client" in log:
break
time.sleep(0.3)
if "dropped on client" not in log:
raise Fail("the WM did not hand the file to a window")
if "code: attached" not in log or name not in log:
raise Fail(f"Code did not take {name} as an attachment")
return f"{name} attached in Code"
SHOT_TASKS = [
("screenshot: rectangle", t_shot_region),
("screenshot: Esc cancels", t_shot_cancel),
("screenshot: whole screen", t_shot_screen),
("screenshot: pick a window", t_shot_window),
("screenshot: thumbnail dropped into Code", t_shot_thumbnail_into_code),
]
AUTOHIDE_TASKS = [
("auto-hide: shelve via the edge", t_shelve_edge),
("auto-hide: tile click -> half -> fold", t_tile_half_fold),
("auto-hide: pull out from the tile", t_pull_out_tile),
("auto-hide: switch desktops", t_desktops),
]
def run_session(root, out, tasks, autohide, only, rows, screens, desk, shots_session=False):
files = {"wm/current-shelf-autohide": "left right\n"} if autohide else {}
env = {"MAKEPAD_WM_FAKE_SCREENS": screens} if screens else {}
tag = "usability-autohide" if autohide else "usability-shots" if shots_session else "usability"
apps = "notes,code" if shots_session else "notes,clock"
if shots_session:
# A home of its own: ~/Screenshots is the session's.
env["HOME"] = os.path.join(out, tag, "home")
os.makedirs(env["HOME"], exist_ok=True)
rig = Rig(root, tag, apps=apps, files=files, env=env, size=desk, virtual=False, out=os.path.join(out, tag)).up()
t = T(rig, out)
try:
deadline = time.time() + 120
while time.time() < deadline:
fr = t.last() if os.path.exists(rig.trace) else None
first, second = apps.split(",")
if fr and t.win(first, fr) and t.win(second, fr) and all(w["host"] and w["host"]["has"] for w in fr["w"] if w["cap"] is None and w.get("host")):
break
time.sleep(1)
t.still()
if not shots_session:
setup(t)
for name, fn in tasks:
if only and not any(o in name for o in only):
continue
n0 = t.n()
t0 = time.time()
t.fixups = []
status, note = "PASS", ""
try:
note = fn(t) or ""
except Fail as e:
status, note = "FAIL", str(e)
except Exception as e: # a broken step is a failure too
status, note = "FAIL", f"{type(e).__name__}: {e}"
traceback.print_exc()
time.sleep(0.2)
n1 = t.n()
frames = t.frames()
lo = next((i for i, f in enumerate(frames) if f["n"] > n0), len(frames))
hi = next((i for i, f in enumerate(frames) if f["n"] > n1), len(frames))
issues = analyze.analyze(frames, lo, hi, hz=60.0)
bad = [it for it in issues if it["kind"] in BAD]
warn = [it for it in issues if it["kind"] not in BAD and it["kind"] not in ("LONG-FRAME", "SLOW-DRAW")]
longs = [it for it in issues if it["kind"] == "LONG-FRAME"]
if bad and status == "PASS":
status = "FAIL"
shot = os.path.join(rig.out, f"{len(rows) + 1:02d}.png")
try:
rig.grab(shot)
except Exception:
shot = ""
if t.fixups:
note += " [precondition fix-up: " + "; ".join(t.fixups) + "]"
rows.append(dict(name=name, status=status, note=note, frames=(n0 + 1, n1), bad=bad, warn=warn,
longs=len(longs), secs=time.time() - t0, shot=shot, trace=rig.trace))
print(f"{status} {name}: {note} {'; '.join(b['kind'] + '#' + str(b['n']) for b in bad[:4])}", flush=True)
finally:
rig.down()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("root")
ap.add_argument("--only", default="")
ap.add_argument("--out", default=os.path.expanduser("~/lanes/wm-review-runs/usability"))
# A desk like the real screens (149, a laptop): apps open at the size they ask for
# (955d138a8b) -- Notes 1240x800, Code 1440x900 -- and fit on it beside each other.
ap.add_argument("--desk", default="1920x1200", help="the WM window's size in points (the Rig's weston output)")
ap.add_argument("--screens", default=None, help="fake per-screen desk like Linux direct ('' = nested single desk; default: one screen the size of --desk)")
ap.add_argument("--no-autohide", action="store_true")
a = ap.parse_args()
if a.screens is None:
a.screens = "eDP-1:0,0,{},{}".format(*a.desk.split("x"))
only = [o for o in a.only.split(",") if o]
rows = []
t0 = time.time()
def wanted(tasks):
return not only or any(o in name for name, _ in tasks for o in only)
if wanted(TASKS):
run_session(a.root, a.out, TASKS, False, only, rows, a.screens, a.desk)
if not a.no_autohide and wanted(AUTOHIDE_TASKS):
run_session(a.root, a.out, AUTOHIDE_TASKS, True, only, rows, a.screens, a.desk)
if wanted(SHOT_TASKS):
run_session(a.root, a.out, SHOT_TASKS, False, only, rows, a.screens, a.desk, shots_session=True)
print()
print(f"{'#':>2} {'task':<46} {'result':<6} {'frames':<13} detail")
for i, r in enumerate(rows, 1):
det = r["note"]
if r["bad"]:
det += " | " + ", ".join(f"{b['kind']} #{b['n']} {b['c']}" for b in r["bad"][:3]) + (f" (+{len(r['bad']) - 3})" if len(r["bad"]) > 3 else "")
if r["warn"]:
kinds = sorted(set(w["kind"] for w in r["warn"]))
det += " | warn " + ",".join(kinds)
print(f"{i:>2} {r['name']:<46} {r['status']:<6} {r['frames'][0]:>5}-{r['frames'][1]:<6} {det}")
fails = sum(r["status"] == "FAIL" for r in rows)
print(f"\n{len(rows) - fails}/{len(rows)} pass in {time.time() - t0:.0f} s; traces + screenshots in {a.out}")
with open(os.path.join(a.out, "table.json"), "w") as f:
json.dump(rows, f, indent=1, default=str)
sys.exit(1 if fails else 0)
if __name__ == "__main__":
main()