qemu: scroll and home verbs in the driver

scroll X Y DY and home are judged by the channel's own ack, like nav and
wake: the deterministic stand-in for a swipe that only brings a control
into view (flare-edge #174), and the fresh-boot state a live panel needs
before each flow (flare-edge #173).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
This commit is contained in:
Noah
2026-09-08 16:48:09 -06:00
co-authored by Claude Fable 5.1
parent bd5c5af9db
commit a512d56650
2 changed files with 25 additions and 2 deletions
+16
View File
@@ -26,6 +26,12 @@ 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: 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 nav MENU[/TAB] jump to a page; fails if the page is unknown
scroll X Y DY scroll the scrollable under pixel X,Y by DY
pixels (positive reveals content below), no
animation: the deterministic stand-in for a
swipe that only brings a control into view
home close every popup and go to Dashboard/Dashboard,
the state a fresh boot starts in
wake wake the UI as if touched, minus the swallow wake wake the UI as if touched, minus the swallow
(sleep.c dims and then blanks an idle panel and (sleep.c dims and then blanks an idle panel and
eats the touch that ends either); every compiled eats the touch that ends either); every compiled
@@ -538,6 +544,16 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
need_ctl(lineno, cmd) need_ctl(lineno, cmd)
reply = ctl.send("wake") reply = ctl.send("wake")
record(lineno, line, "ok" if reply == "wake: ok" else "fail", reply) record(lineno, line, "ok" if reply == "wake: ok" else "fail", reply)
elif cmd in ("scroll", "home"):
# scroll X Y DY: the scrollable under the pixel moves DY pixels
# with no animation (warden_debug.c), the deterministic stand-in
# for a swipe whose only job was to bring a control into view.
# home: close every popup and return to Dashboard/Dashboard, the
# state a fresh boot starts in, so a live panel can run one flow
# after another. Both are judged by the channel's own ack.
need_ctl(lineno, cmd)
reply = ctl.send(line)
record(lineno, line, "ok" if reply.startswith(f"{cmd}: ok") else "fail", reply)
elif cmd in ("page", "hit", "stats", "ctl"): elif cmd in ("page", "hit", "stats", "ctl"):
# Query verbs: print the reply, never judge it. `ctl` is a raw # Query verbs: print the reply, never judge it. `ctl` is a raw
# passthrough for anything the channel grows later. # passthrough for anything the channel grows later.
+9 -2
View File
@@ -42,6 +42,10 @@ class FakeCtl:
return "page: Demo/Rows\ncpu: 12%\nfps: 10\nrender: 3.20 ms/frame\nrga: 0%\nidle: 0" return "page: Demo/Rows\ncpu: 12%\nfps: 10\nrender: 3.20 ms/frame\nrga: 0%\nidle: 0"
if cmd == "wake": if cmd == "wake":
return "wake: ok" return "wake: ok"
if cmd == "home":
return "home: ok"
if cmd.startswith("scroll "):
return "scroll: ok y=300 of 900" if not cmd.endswith(" 0") else "scroll: expected X Y DY (DY non-zero pixels)"
if cmd.startswith("@cat "): if cmd.startswith("@cat "):
return json.dumps({"a": {"b": 1}, "list": [1, 2], "name": "warden"}) return json.dumps({"a": {"b": 1}, "list": [1, 2], "name": "warden"})
if cmd.startswith("hit "): if cmd.startswith("hit "):
@@ -129,9 +133,11 @@ class DriveVerbs(unittest.TestCase):
"capture_region r1 0 0 8 8 exact\n" "capture_region r1 0 0 8 8 exact\n"
"assert_region r1 exact\n" "assert_region r1 exact\n"
"assert_region r1\n" "assert_region r1\n"
"scroll 360 400 300\n"
"home\n"
) )
self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"]) self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"])
self.assertEqual(len(rows), 12) self.assertEqual(len(rows), 14)
self.assertTrue(all(r["status"] == "ok" for r in rows)) self.assertTrue(all(r["status"] == "ok" for r in rows))
def test_mismatches_are_fails_not_stops(self): def test_mismatches_are_fails_not_stops(self):
@@ -142,11 +148,12 @@ class DriveVerbs(unittest.TestCase):
"wait_json a.b eq 2 1\n" "wait_json a.b eq 2 1\n"
"assert_json a.zz eq 1\n" "assert_json a.zz eq 1\n"
"assert_stat fps lt 0\n" "assert_stat fps lt 0\n"
"scroll 360 400 0\n"
"assert_page Demo/Rows\n" "assert_page Demo/Rows\n"
) )
self.assertEqual(rc, 1) self.assertEqual(rc, 1)
self.assertEqual([r["status"] for r in rows], self.assertEqual([r["status"] for r in rows],
["fail", "fail", "fail", "fail", "fail", "ok"]) ["fail", "fail", "fail", "fail", "fail", "fail", "ok"])
self.assertIn("moved", by["assert_hit 47 676 obj box=0,0,1x1"]["detail"]) self.assertIn("moved", by["assert_hit 47 676 obj box=0,0,1x1"]["detail"])
self.assertGreaterEqual(time.monotonic() - t0, 1.0, "wait_json must honour its timeout") self.assertGreaterEqual(time.monotonic() - t0, 1.0, "wait_json must honour its timeout")