qemu: wake verb, idle stat, and no fbcon cursor in the framebuffer

- tests/qmp.py: `wake` is a judged verb (the reply must be the channel's
  own ack) and `assert_stat` knows `idle` (0 awake, 1 dimmed, 2 asleep).
  warden-ui's sleep.c swallows the touch that wakes a dimmed or blanked
  panel, so every compiled flow script now opens with wake (flare-edge
  #148); tests cover both.
- run.sh: vt.global_cursor_default=0. fbcon shares the virtio-gpu
  framebuffer with warden-ui and its cursor blinked an 8x2 block onto the
  top-left corner in some boots and not others, which made a structural
  pixel reference fail by one cell (#18). The panel has no fbcon on its
  display, so this only makes the rig match it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aHKWzT5EF86RFKRMtAv9n
This commit is contained in:
Noah
2026-09-07 23:39:07 -06:00
co-authored by Claude Fable 5.1
parent 86a9544dcc
commit eb16a301c0
4 changed files with 36 additions and 10 deletions
+19 -6
View File
@@ -26,6 +26,10 @@ warden_debug.c. The same channel is what tools/warden-ctl reaches over SSH on
a real panel, so these verbs mean the same thing on the rig and on hardware:
nav MENU[/TAB] jump to a page; fails if the page is unknown
wake wake the UI as if touched, minus the swallow
(sleep.c dims and then blanks an idle panel and
eats the touch that ends either); every compiled
flow script opens with it
page | stats | hit X Y print the reply, judge nothing
ctl WORDS... raw passthrough for anything the channel grows
assert_page MENU/TAB the active page is exactly this
@@ -55,9 +59,10 @@ math, this file only drives it.
document is a live snapshot and the key might
simply not be built yet.
assert_stat FIELD OP VALUE
FIELD is cpu, fps or render, read off a fresh
`stats` reply (warden_debug.c). Same OP vocabulary
as assert_json.
FIELD is cpu, fps, render or idle (0 awake,
1 dimmed, 2 asleep), read off a fresh `stats`
reply (warden_debug.c). Same OP vocabulary as
assert_json.
capture_region NAME X Y W H TOLERANCE
screendump now, crop to X,Y,WxH, and (over)write
NAME in the refs file with both a phash and a
@@ -382,12 +387,13 @@ STATS_FIELD_RE = {
"cpu": re.compile(r'^cpu:\s*(-?\d+(?:\.\d+)?)%?\s*$'),
"fps": re.compile(r'^fps:\s*(-?\d+(?:\.\d+)?)\s*$'),
"render": re.compile(r'^render:\s*(-?\d+(?:\.\d+)?)\s*ms/frame\s*$'),
"idle": re.compile(r'^idle:\s*(\d+)\s*$'),
}
def parse_stats(reply):
"""The `stats` reply (warden_debug.c: 'page: X\\ncpu: N%\\nfps: N\\n
render: A.BB ms/frame\\nrga: N%') -> {'cpu'|'fps'|'render': float} for
render: A.BB ms/frame\\nrga: N%\\nidle: N') -> {'cpu'|'fps'|'render'|'idle': float} for
whichever lines are present. A field the reply lacks is simply absent
from the result -- the caller reports that as 'no such field', the same
shape as resolve_path's 'no such path' for the json channel."""
@@ -515,6 +521,13 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
need_ctl(lineno, cmd)
reply = ctl.send("nav " + " ".join(args))
record(lineno, line, "ok" if reply.endswith(": ok") else "fail", reply)
elif cmd == "wake":
# Judged, unlike the query verbs: a UI that does not answer the
# wake is one whose next tap may be swallowed, and that must not
# read as a passing step.
need_ctl(lineno, cmd)
reply = ctl.send("wake")
record(lineno, line, "ok" if reply == "wake: ok" else "fail", reply)
elif cmd in ("page", "hit", "stats", "ctl"):
# Query verbs: print the reply, never judge it. `ctl` is a raw
# passthrough for anything the channel grows later.
@@ -587,9 +600,9 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
# assert_stat FIELD OP VALUE: FIELD off a fresh `stats` reply.
need_ctl(lineno, cmd)
field, op, value = args[0], args[1], args[2]
if field not in ("cpu", "fps", "render"):
if field not in ("cpu", "fps", "render", "idle"):
record(lineno, line, "fail", f"unknown stat field {field!r} "
f"(known: cpu, fps, render)")
f"(known: cpu, fps, render, idle)")
else:
stats = parse_stats(ctl.send("stats"))
if field not in stats:
+7 -3
View File
@@ -39,7 +39,9 @@ class FakeCtl:
if cmd == "page":
return "Demo/Rows"
if cmd == "stats":
return "page: Demo/Rows\ncpu: 12%\nfps: 10\nrender: 3.20 ms/frame\nrga: 0%"
return "page: Demo/Rows\ncpu: 12%\nfps: 10\nrender: 3.20 ms/frame\nrga: 0%\nidle: 0"
if cmd == "wake":
return "wake: ok"
if cmd.startswith("@cat "):
return json.dumps({"a": {"b": 1}, "list": [1, 2], "name": "warden"})
if cmd.startswith("hit "):
@@ -98,14 +100,16 @@ class PureHelpers(unittest.TestCase):
def test_parse_stats(self):
got = qmp.parse_stats(FakeCtl("x").send("stats"))
self.assertEqual(got, {"cpu": 12.0, "fps": 10.0, "render": 3.2})
self.assertEqual(got, {"cpu": 12.0, "fps": 10.0, "render": 3.2, "idle": 0.0})
class DriveVerbs(unittest.TestCase):
def test_every_channel_passes_on_a_healthy_ui(self):
rc, by, rows = run_script(
"wake\n"
"assert_page Demo/Rows\n"
"assert_hit 47 676 obj box=12,640,72x72\n"
"assert_stat idle eq 0\n"
"wait_json a.b eq 1 2\n"
"wait_json list len_ge 2 2\n"
"assert_json name eq warden\n"
@@ -116,7 +120,7 @@ class DriveVerbs(unittest.TestCase):
"assert_region r1\n"
)
self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"])
self.assertEqual(len(rows), 10)
self.assertEqual(len(rows), 12)
self.assertTrue(all(r["status"] == "ok" for r in rows))
def test_mismatches_are_fails_not_stops(self):