kick/tests/test_vt_plane.py
Andrew Hamilton 78285ab581 fix: order cursor above overlay above content on the plane path
Park the hardware cursor plane while the overlay is held and paint
the themed cursor image into the plane buffer after the menu, so the
stack is cursor, overlay, content by construction on every backend:
no cursor layer exists for the menu layer to trade planes with, and
the menu buffer carries its own cursor wherever it lands.  On the
composite path re-add software cursors after the menu pass (a no-op
while a hardware cursor plane is active).

Extend the VT test with kernel-side placement (debugfs crtc-pos and
normalized-zpos must match the logged box on top) and a single-snapshot
cursor-above-menu order check.
2026-09-23 12:24:19 +10:00

628 lines
25 KiB
Python

#!/usr/bin/env python3
"""VT overlay-plane test: the menu must survive a client format switch.
Spawns kick on the real DRM backend with a `foot` tab, holds Alt (the
overlay plane is probed), then Alt+Shift+T's an `alacritty` tab while
still holding Alt. foot uses shm buffers, alacritty GL/dmabuf: if the
overlay layer stops being accepted when alacritty's buffers join the
libliftoff allocation, the menu vanishes over alacritty but not foot.
Every plane frame logs `[render] overlay plane accepted=0/1`, so the log
is the witness (plane content never reaches frame dumps).
Skips unless this process can grab DRM master on a card with a connected
connector (i.e. a live virtual console). Taking master steals the live
display for the duration; master is dropped on teardown.
"""
import fcntl
import glob
import ctypes
import os
import re
import subprocess
import time
import pytest
from test_smoke import ( # noqa: F401
ALT,
BIN,
DOWN,
KEY_PRESSED,
KEY_RELEASED,
SHIFT,
UP,
Session,
_assert_alive,
_fresh_log,
_mk_dump_dir,
_pick_font,
_preexec_death,
)
_DRM_SET_MASTER = 0x641E
_DRM_DROP_MASTER = 0x641F
_VT_LOCK = __file__ + ".lock"
re_frames = re.compile(
r"\[diag\] frame (?:start|result) .*?vis=(\d) plane=(\d)")
re_accept = re.compile(r"\[render\] overlay plane accepted=([01])")
def _drm_card_with_connected():
for card in sorted(glob.glob("/dev/dri/card[0-9]*")):
sysf = card.replace("/dev/dri/", "/sys/class/drm/")
for status in glob.glob(sysf + "/*/status"):
try:
if open(status).read().strip() == "connected":
return card
except OSError:
continue
return None
def _vt_drm_available():
card = _drm_card_with_connected()
if not card:
return False
try:
fd = os.open(card, os.O_RDWR)
except OSError:
return False
try:
fcntl.ioctl(fd, _DRM_SET_MASTER, 0)
except OSError:
return False
finally:
try:
fcntl.ioctl(fd, _DRM_DROP_MASTER, 0)
except OSError:
pass
os.close(fd)
return True
def _liftoff_excerpt(log, n=3000):
lines = [l for l in log.splitlines()
if "libliftoff" in l or "overlay plane" in l]
return "\n".join(lines[-80:]) + "\n...[last %d chars]\n%s" % (n, log[-n:])
def _drm_planes(card="/dev/dri/card1"):
"""Kernel-side plane state via libdrm (no master needed, read-only).
Returns [(id, type, crtc_id, fb_id, fb_w, fb_h, zpos)]. KMS has no
per-layer name, so the overlay menu is identified by what only it can
be while we hold Alt: an active overlay plane whose FB is exactly the
box size kick logged (`overlay plane box=WxH+X+Y`), timed to appear on
hold and vanish on release. Client buffers always go to the primary;
the hardware cursor lives on its own non-overlay plane.
"""
lib = ctypes.CDLL("libdrm.so.2")
class PlaneRes(ctypes.Structure):
_fields_ = [("count_planes", ctypes.c_uint32),
("planes", ctypes.POINTER(ctypes.c_uint32))]
class Plane(ctypes.Structure):
_fields_ = [("count_formats", ctypes.c_uint32),
("formats", ctypes.POINTER(ctypes.c_uint32)),
("plane_id", ctypes.c_uint32),
("crtc_id", ctypes.c_uint32),
("fb_id", ctypes.c_uint32),
("crtc_x", ctypes.c_int32), ("crtc_y", ctypes.c_int32),
("x", ctypes.c_uint32), ("y", ctypes.c_uint32),
("possible_crtcs", ctypes.c_uint32),
("gamma_size", ctypes.c_uint32)]
class PropPtrs(ctypes.Structure):
_fields_ = [("count_props", ctypes.c_uint32),
("props", ctypes.POINTER(ctypes.c_uint32)),
("prop_values", ctypes.POINTER(ctypes.c_uint64))]
class Prop(ctypes.Structure):
_fields_ = [("prop_id", ctypes.c_uint32), ("flags", ctypes.c_uint32),
("name", ctypes.c_char * 32),
("count_values", ctypes.c_uint32),
("values", ctypes.POINTER(ctypes.c_uint64))]
class FB(ctypes.Structure):
_fields_ = [("fb_id", ctypes.c_uint32),
("width", ctypes.c_uint32), ("height", ctypes.c_uint32),
("pitch", ctypes.c_uint32), ("bpp", ctypes.c_uint32),
("depth", ctypes.c_uint32), ("handle", ctypes.c_uint32)]
lib.drmModeGetPlaneResources.restype = ctypes.POINTER(PlaneRes)
lib.drmModeGetPlaneResources.argtypes = [ctypes.c_int]
lib.drmModeGetPlane.restype = ctypes.POINTER(Plane)
lib.drmModeGetPlane.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeObjectGetProperties.restype = ctypes.POINTER(PropPtrs)
lib.drmModeObjectGetProperties.argtypes = [ctypes.c_int, ctypes.c_uint32,
ctypes.c_uint32]
lib.drmModeGetProperty.restype = ctypes.POINTER(Prop)
lib.drmModeGetProperty.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeGetFB.restype = ctypes.POINTER(FB)
lib.drmModeGetFB.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeFreePlaneResources.argtypes = [ctypes.c_void_p]
lib.drmModeFreePlane.argtypes = [ctypes.c_void_p]
lib.drmModeFreeObjectProperties.argtypes = [ctypes.c_void_p]
lib.drmModeFreeProperty.argtypes = [ctypes.c_void_p]
lib.drmModeFreeFB.argtypes = [ctypes.c_void_p]
out = []
fd = os.open(card, os.O_RDWR)
try:
pres = lib.drmModeGetPlaneResources(fd)
ids = [pres.contents.planes[i]
for i in range(pres.contents.count_planes)]
lib.drmModeFreePlaneResources(pres)
for pid in ids:
pl = lib.drmModeGetPlane(fd, pid)
c = pl.contents
zpos = None
ptype = None
pp = lib.drmModeObjectGetProperties(fd, pid, 0xeeeeeeee)
if pp:
for j in range(pp.contents.count_props):
prid = pp.contents.props[j]
pr = lib.drmModeGetProperty(fd, prid)
name = pr.contents.name.decode()
if name == "zpos":
zpos = pp.contents.prop_values[j]
elif name == "type":
ptype = pp.contents.prop_values[j]
lib.drmModeFreeProperty(pr)
lib.drmModeFreeObjectProperties(pp)
fw = fh = None
if c.fb_id:
fb = lib.drmModeGetFB(fd, c.fb_id)
if fb:
fw, fh = fb.contents.width, fb.contents.height
lib.drmModeFreeFB(fb)
out.append((c.plane_id, ptype, c.crtc_id, c.fb_id, fw, fh,
zpos))
lib.drmModeFreePlane(pl)
finally:
os.close(fd)
return out
def _logged_box(s):
"""Latest `box=WxH+X+Y` from the overlay plane witness lines, or None."""
ms = re.findall(r"overlay plane accepted=[01] box=(\d+)x(\d+)\+(\d+)\+(\d+)",
s.read_log())
return tuple(map(int, ms[-1])) if ms else None
def _debugfs_planes(path="/sys/kernel/debug/dri/1/state"):
"""Kernel atomic state: {plane_id: {crtc, fb, cw, ch, cx, cy, zpos,
fb_w, fb_h}}. Unlike the ioctl queries this shows the PROGRAMMED
destination rectangle and z-order -- the ground truth for whether our
menu actually lands centered and on top."""
planes = {}
try:
text = open(path).read()
except OSError:
return planes
cur = None
import re as _re
def _ints(line):
return list(map(int, _re.findall(r"\d+", line)))
for line in text.splitlines():
m = _re.match(r"plane\[(\d+)\]:", line)
if m:
cur = int(m.group(1))
planes[cur] = {}
continue
if cur is None:
continue
line = line.strip()
if line.startswith("crtc="):
v = line[5:]
planes[cur]["crtc"] = None if v == "(null)" else v
elif line.startswith("fb="):
nums = _ints(line[3:])
planes[cur]["fb"] = nums[0] if nums else 0
elif line.startswith("crtc-pos="):
nums = _ints(line)
if len(nums) >= 4:
planes[cur]["cw"], planes[cur]["ch"], planes[cur]["cx"], \
planes[cur]["cy"] = nums[:4]
elif line.startswith("normalized-zpos="):
nums = _ints(line)
if nums:
planes[cur]["zpos"] = nums[0]
elif line.startswith("size=") and "fb_w" not in planes[cur]:
nums = _ints(line)
if len(nums) >= 2:
planes[cur]["fb_w"], planes[cur]["fb_h"] = nums[:2]
return planes
def _assert_menu_placed(card, box, ctx, samples=3):
"""The menu FB must be programmed at the logged box geometry on top:
same CRTC as the fullscreen primary, crtc-pos == box, zpos above the
primary's. Accepted/size checks can't see misplacement -- this can."""
if box is None:
raise AssertionError("no logged overlay box (%s)" % ctx)
bw, bh, bx, by_ = box
for _ in range(samples):
planes = _debugfs_planes()
prims = [p for p in planes.values()
if p.get("crtc") and p.get("fb")
and p.get("fb_w", 0) >= 1920 and p.get("zpos") == 0]
if not prims:
time.sleep(0.2)
continue
crtc = prims[0]["crtc"]
menu = [pid for pid, p in planes.items()
if p.get("crtc") == crtc and p.get("fb")
and p.get("fb_w") == bw and p.get("fb_h") == bh
and p.get("cx") == bx and p.get("cy") == by_
and p.get("zpos", 0) > 0]
if len(menu) == 1:
print("menu placed: plane %s at %dx%d+%d+%d zpos=%s (%s)"
% (menu[0], bw, bh, bx, by_,
planes[menu[0]].get("zpos"), ctx))
return menu[0]
time.sleep(0.2)
raise AssertionError(
"menu not placed at %dx%d+%d+%d (%s); planes:\n%s" % (
bw, bh, bx, by_, ctx,
"\n".join("%s: %s" % (pid, p)
for pid, p in sorted(planes.items())
if p.get("crtc"))))
def _assert_cursor_above_menu(card, box, ctx, samples=5):
"""Stacking order top-to-bottom must be cursor, menu, content, read
from a SINGLE debugfs snapshot per sample (plane reads across two
ioctls can straddle a commit). The menu matches by FB size and
programmed box; the primary is the fullscreen plane; any other small
active plane is the cursor and must sit above the menu. Votes."""
if box is None:
raise AssertionError("no logged overlay box (%s)" % ctx)
bw, bh, bx, by_ = box
goods = []
for _ in range(samples):
planes = _debugfs_planes()
prims = [p for p in planes.values()
if p.get("crtc") and p.get("fb")
and p.get("fb_w", 0) >= 1920 and p.get("zpos") == 0]
menu = [pid for pid, p in planes.items()
if p.get("crtc") and p.get("fb")
and p.get("fb_w") == bw and p.get("fb_h") == bh
and p.get("cx") == bx and p.get("cy") == by_]
ok = bool(prims) and len(menu) == 1 and \
planes[menu[0]].get("zpos", 0) > 0
if ok:
mz = planes[menu[0]]["zpos"]
small = [(pid, p) for pid, p in planes.items()
if pid != menu[0] and p.get("crtc") and p.get("fb")
and p.get("fb_w") is not None and p.get("fb_h") is not None
and p.get("fb_w") <= 256 and p.get("fb_h") <= 256]
ok = all(p.get("zpos", 0) > mz for _, p in small)
goods.append(ok)
print("stack vote (%s): menu=%s cursor-above=%s" % (
ctx, menu[0] if ok and menu else None, ok))
time.sleep(0.15)
assert sum(goods) >= (samples // 2 + 1), \
"bad stacking (%s): %d/%d" % (ctx, sum(goods), samples)
def _menu_plane_sample(card, box):
"""One kernel snapshot: the plane id carrying the menu-size FB, or
None. libliftoff may place our layer on any compatible plane
(overlay 107, but also 83/657 in a pinch), so every candidate is
scanned: active CRTC, live FB exactly the logged box size, zpos >= 1.
Commits pipeline, so a single snapshot may catch an intermediate
state -- callers vote over several."""
bw, bh, _, _ = box
cands = []
for (pid, crtc, fb, fw, fh, zpos) in _all_planes(card).values():
if crtc and fb and fw == bw and fh == bh and zpos is not None \
and zpos >= 1:
cands.append(pid)
for (pid, typ, crtc, fb, fw, fh, zpos) in _drm_planes(card):
if typ == 0 and crtc and fb and fw == bw and fh == bh \
and zpos is not None and zpos >= 1:
cands.append(pid)
cands = sorted(set(cands))
return cands[0] if len(cands) == 1 else None
def _all_planes(card="/dev/dri/card1"):
"""Overlay planes plus the primary (83) and cursor (657) planes:
(id, crtc_id, fb_id, fb_w, fb_h, zpos). libliftoff may place any
layer on any compatible plane, so the menu FB can legally land
outside the overlay list -- what matters is that exactly one
topmost (zpos >= 1) plane carries it."""
import ctypes
lib = ctypes.CDLL("libdrm.so.2")
class Plane(ctypes.Structure):
_fields_ = [("count_formats", ctypes.c_uint32),
("formats", ctypes.POINTER(ctypes.c_uint32)),
("plane_id", ctypes.c_uint32),
("crtc_id", ctypes.c_uint32),
("fb_id", ctypes.c_uint32),
("crtc_x", ctypes.c_int32), ("crtc_y", ctypes.c_int32),
("x", ctypes.c_uint32), ("y", ctypes.c_uint32),
("possible_crtcs", ctypes.c_uint32),
("gamma_size", ctypes.c_uint32)]
class PropPtrs(ctypes.Structure):
_fields_ = [("count_props", ctypes.c_uint32),
("props", ctypes.POINTER(ctypes.c_uint32)),
("prop_values", ctypes.POINTER(ctypes.c_uint64))]
class Prop(ctypes.Structure):
_fields_ = [("prop_id", ctypes.c_uint32), ("flags", ctypes.c_uint32),
("name", ctypes.c_char * 32),
("count_values", ctypes.c_uint32),
("values", ctypes.POINTER(ctypes.c_uint64))]
class FB(ctypes.Structure):
_fields_ = [("fb_id", ctypes.c_uint32),
("width", ctypes.c_uint32), ("height", ctypes.c_uint32),
("pitch", ctypes.c_uint32), ("bpp", ctypes.c_uint32),
("depth", ctypes.c_uint32), ("handle", ctypes.c_uint32)]
lib.drmModeGetPlaneResources.restype = ctypes.c_void_p
lib.drmModeGetPlane.restype = ctypes.POINTER(Plane)
lib.drmModeGetPlane.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeObjectGetProperties.restype = ctypes.POINTER(PropPtrs)
lib.drmModeObjectGetProperties.argtypes = [ctypes.c_int, ctypes.c_uint32,
ctypes.c_uint32]
lib.drmModeGetProperty.restype = ctypes.POINTER(Prop)
lib.drmModeGetProperty.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeGetFB.restype = ctypes.POINTER(FB)
lib.drmModeGetFB.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeFreePlane.argtypes = [ctypes.c_void_p]
lib.drmModeFreeObjectProperties.argtypes = [ctypes.c_void_p]
lib.drmModeFreeProperty.argtypes = [ctypes.c_void_p]
lib.drmModeFreeFB.argtypes = [ctypes.c_void_p]
def one(fd, pid):
pl = lib.drmModeGetPlane(fd, pid)
if not pl:
return None
c = pl.contents
zpos = None
pp = lib.drmModeObjectGetProperties(fd, pid, 0xeeeeeeee)
if pp:
for j in range(pp.contents.count_props):
pr = lib.drmModeGetProperty(fd, pp.contents.props[j])
if pr.contents.name.decode() == "zpos":
zpos = pp.contents.prop_values[j]
lib.drmModeFreeProperty(pr)
lib.drmModeFreeObjectProperties(pp)
fw = fh = None
if c.fb_id:
fb = lib.drmModeGetFB(fd, c.fb_id)
if fb:
fw, fh = fb.contents.width, fb.contents.height
lib.drmModeFreeFB(fb)
lib.drmModeFreePlane(pl)
return (pid, c.crtc_id, c.fb_id, fw, fh, zpos)
out = {}
fd = os.open(card, os.O_RDWR)
try:
for pid in (83, 107, 657):
out[pid] = one(fd, pid)
finally:
os.close(fd)
return out
def _assert_menu_plane_on_top(card, box, ctx, samples=5):
"""The overlay menu must be scanned out on exactly one overlay plane:
active CRTC, live FB sized exactly like the logged box, zpos >= 1
(above the zpos-0 primary). Votes over several snapshots so one
pipelined-commit race cannot fail the run; reports every sample."""
if box is None:
raise AssertionError("no logged overlay box (%s)" % ctx)
seen = []
for _ in range(samples):
seen.append(_menu_plane_sample(card, box))
time.sleep(0.15)
good = sum(1 for m in seen if m is not None)
print("menu plane votes (%s): %s" % (ctx, seen))
assert good >= (samples // 2 + 1), \
"menu plane missing/not on top (%s): %d/%d samples, last planes:\n%s" % (
ctx, good, samples, _drm_planes(card))
return seen[-1] if seen[-1] is not None else seen[0]
def _assert_no_menu_plane(card, box, ctx):
"""After release no overlay plane may still carry the menu FB size."""
if box is None:
return
bw, bh, _, _ = box
stale = [p for p in _drm_planes(card)
if p[1] == 0 and p[2] and p[3] and p[4] == bw and p[5] == bh]
assert not stale, "menu plane stuck on screen (%s): %r" % (ctx, stale)
@pytest.fixture(scope="function")
def vt_foot_session():
if not _vt_drm_available():
pytest.skip("not on a DRM-capable VT (no card grants master)")
# Serialize against other workers: two kicks would fight for master.
with open(_VT_LOCK, "w") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
if not _vt_drm_available():
pytest.skip("DRM master went away before spawn")
for path in (BIN,):
if not os.access(path, os.X_OK):
pytest.skip("build first (DRM backend): %s" % path)
log = _fresh_log()
dump_dir = _mk_dump_dir()
env = dict(os.environ)
# Screen mode for the DRM output (KICK_TEST_SCREENMODE, default
# 1920x1080): also try the connector preferred mode (4K here) for
# HVS bandwidth pressure via KICK_TEST_SCREENMODE=3840x2160.
screen_mode = env.get("KICK_TEST_SCREENMODE", "1920x1080")
out_w, out_h = (int(x) for x in screen_mode.split("x"))
cmd = [
"env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"KICK_LOG=2",
"KICK_MODE=standalone",
"WLR_BACKENDS=drm",
"KICK_FONT=" + (env.get("KICK_FONT") or _pick_font() or ""),
"KICK_DUMP_DIR=" + dump_dir,
"KICK_TEST_SCREENMODE=" + screen_mode,
"stdbuf", "-oL",
BIN, "--screen-mode", screen_mode, "foot",
]
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()))
s = Session(proc, log, dump_dir)
try:
yield s
finally:
s.close()
def test_vt_overlay_survives_alacritty_format_switch(vt_foot_session):
"""foot (shm) + Alt-held plane probe, then Alt+Shift+T alacritty
(dmabuf) while held: the layer must stay accepted across the switch."""
s = vt_foot_session
_assert_alive(s, "kick exited early on the VT")
assert s.wait_any_active() == 0
s.wait_active_frame()
_assert_alive(s, "kick died after first frame")
pos = len(s.read_log())
s.mods(ALT)
s.wait_for_new_after(pos, r"\[render\] overlay show")
try:
s.wait_for_new_after(pos, r"\[render\] overlay plane available",
timeout=30.0)
except Exception:
pytest.fail("plane never became available (rejected at probe?):\n"
+ _liftoff_excerpt(s.read_log()))
s.wait_for_new_after(pos, r"\[diag\] frame (?:start|result) .*vis=1 plane=1")
_assert_alive(s, "kick died while Alt held over foot")
# Alt+Shift+T (T is keycode 20), keep Alt held throughout.
mark = len(s.read_log())
s.mods(ALT | SHIFT)
s.key(20, KEY_PRESSED)
s.key(20, KEY_RELEASED)
s.mods(ALT)
s.wait_for_new_after(mark, r"\[launch\] app=\d+ pid=\d+ cmd=alacritty",
timeout=30.0)
# The fresh terminal becomes active once it maps.
deadline = time.monotonic() + 60.0
while time.monotonic() < deadline:
_assert_alive(s, "kick died waiting for alacritty")
try:
if s.active() == 1:
break
except Exception:
pass
time.sleep(0.2)
assert s.active() == 1, \
"alacritty never became active:\n%s" % s.read_log()[-1500:]
# Let several plane frames land over alacritty, then require every
# accepted sample since the switch chord to be 1. The offset predates
# the chord: the switch's own frames qualify, so there is no race with
# observing the effect first.
s.wait_for_new_after(
mark, r"\[render\] overlay plane accepted=1", timeout=30.0)
time.sleep(2.0)
tail = s.read_log()[mark:]
verdicts = re_accept.findall(tail)
assert verdicts, "no accepted samples over alacritty:\n%s" % tail[-1500:]
assert set(verdicts) == {"1"}, \
"overlay layer rejected over alacritty %r:\n%s" % (
verdicts, _liftoff_excerpt(s.read_log()))
# Cycle back and confirm the plane is still accepted. Offset again
# predates the key press for the same reason.
before = s.active()
cyc = len(s.read_log())
s.key(DOWN, KEY_PRESSED)
s.key(DOWN, KEY_RELEASED)
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
_assert_alive(s, "kick died cycling")
try:
if s.active() != before:
break
except Exception:
pass
time.sleep(0.2)
assert s.active() != before, \
"never cycled away from %d:\n%s" % (before, s.read_log()[-1500:])
s.wait_for_new_after(
cyc, r"\[render\] overlay plane accepted=1", timeout=30.0)
# Kernel-side stacking check while held: the menu must be on a small
# overlay plane above the primary over BOTH tabs, with cursor motion
# and held-key repeats churning the libliftoff allocation in between.
# accepted=1 only means "placed somewhere" -- matching the logged box
# size proves it is ours, zpos >= 1 proves "on top".
card = _drm_card_with_connected()
assert card, "DRM card went away mid-test"
box = _logged_box(s)
_assert_menu_plane_on_top(card, box, "over foot")
_assert_menu_placed(card, box, "over foot")
for x, y in ((960, 540), (200, 200), (1700, 900), (960, 540)):
s.warp(x, y)
time.sleep(0.3)
time.sleep(3.0)
_assert_menu_plane_on_top(card, _logged_box(s),
"over foot after warps")
# Held Down at the production repeat rate walks foot<->alacritty many
# times; the stack must survive all of it.
s.key(DOWN, KEY_PRESSED)
time.sleep(2.0)
s.key(DOWN, KEY_RELEASED)
_assert_menu_plane_on_top(card, _logged_box(s),
"after held-Down repeats")
s.key(UP, KEY_PRESSED)
time.sleep(2.0)
s.key(UP, KEY_RELEASED)
held_at = len(s.read_log())
time.sleep(2.0)
_assert_menu_plane_on_top(card, _logged_box(s),
"after held-Up repeats")
_assert_menu_placed(card, _logged_box(s), "after held-Up repeats")
_assert_cursor_above_menu(card, _logged_box(s),
"after held-Up repeats")
tail = s.read_log()[held_at:]
assert "overlay hide" not in tail, \
"overlay hid mid-hold:\n%s" % tail[-1500:]
bad = [v for v in re_accept.findall(tail) if v != "1"]
assert not bad, \
"overlay layer rejected mid-hold %r:\n%s" % (
bad, _liftoff_excerpt(s.read_log()))
pos = len(s.read_log())
s.mods(0)
s.wait_for_new_after(pos, r"\[render\] overlay hide\b")
s.wait_for_new_after(pos, r"\[diag\] frame (?:start|result) .*vis=0 plane=0")
box = _logged_box(s)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
_assert_no_menu_plane(card, box, "after release")
break
except AssertionError:
time.sleep(0.5)
_assert_no_menu_plane(card, box, "after release")
_assert_alive(s)