From 7969c7b48e12268d6e0ebc64ba77efcdf426eecc Mon Sep 17 00:00:00 2001 From: Steven Gates Date: Tue, 1 Sep 2026 10:03:54 -0500 Subject: [PATCH 1/2] test(verify): autonomous capture-mode toggle verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds photo_mode_loop.py, the verifier for the `photo-mode` sub-feature of features/photo-capture.md: the top-right CAMERA | VIDEO selector and what a tap on it actually does. It drives Video -> Camera -> Video on the installed debug APK and asserts each state out of a SINGLE uiautomator dump, two ways at once: * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being switched, and * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is why the single toggling icon became a segmented control (issue #126). Half a flip fails: a highlight that moves while the shutter still records, or a shutter that flips while the pill stays put. Re-dumping between the two reads would be a race, so both come off one dump. Nothing is captured — no still, no clip, no gallery write. verify_common gains `checkable`/`checked` on UiNode (both parsers). Compose puts `selectable` on a full-height wrapper around the visible pill, and only that wrapper reports `checked` — the label leaf and the RadioButton-class leaf both read checked="false" whichever segment is selected — so the loop finds the wrapper by bounds containment. No registry change needed: run-verification-loops.py globs `*_loop.py`, so sweep gate 5b picks this up automatically. Verified on emulator-5584 (Pixel_8, API 34): PASS in 26s from .claude and 21s from the synced .cursor copy, with video-baseline / camera-mode / video-restored XML + PNG evidence. Re-parsing that evidence confirms each state matches its own mode and fails the other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01249no1TrwKLR1aorGqaf7C --- .../verify-openloop/features/photo-capture.md | 21 ++ .../helpers/photo_mode_loop.py | 249 ++++++++++++++++++ .../verify-openloop/helpers/verify_common.py | 12 + .../verify-openloop/features/photo-capture.md | 21 ++ .../helpers/photo_mode_loop.py | 249 ++++++++++++++++++ .../verify-openloop/helpers/verify_common.py | 12 + .../verify-openloop/features/photo-capture.md | 21 ++ .../helpers/photo_mode_loop.py | 249 ++++++++++++++++++ .../verify-openloop/helpers/verify_common.py | 12 + 9 files changed, 846 insertions(+) create mode 100644 .claude/skills/verify-openloop/helpers/photo_mode_loop.py create mode 100644 .codex/skills/verify-openloop/helpers/photo_mode_loop.py create mode 100644 .cursor/skills/verify-openloop/helpers/photo_mode_loop.py diff --git a/.claude/skills/verify-openloop/features/photo-capture.md b/.claude/skills/verify-openloop/features/photo-capture.md index e98b047..4ad208c 100644 --- a/.claude/skills/verify-openloop/features/photo-capture.md +++ b/.claude/skills/verify-openloop/features/photo-capture.md @@ -15,6 +15,27 @@ Camera mode takes a single still from the live preview (including any active len - Tap the shutter (`Take photo`). - Share or dismiss; open Gallery to see the still. +## Autonomous check + +Run it from the repository root: + +```powershell +python .claude/skills/verify-openloop/helpers/photo_mode_loop.py +``` + +`python scripts/run-verification-loops.py --changed` runs it alongside every other loop. Both take +`VERIFY_SERIAL` when more than one emulator is online and `VERIFY_EVIDENCE_DIR` for the artifacts. + +It covers the `photo-mode` sub-feature only — the CAMERA | VIDEO selector and what tapping it does. +Nothing is captured: no still, no clip, no gallery write. Three states (Video baseline → Camera → +Video) are each asserted out of a **single** dump, two ways at once: the shutter's description +(`Start recording` ⇄ `Take photo`, the ability being switched) and the tapped segment's `checked` +flag, which is where the lime highlight surfaces in the hierarchy. Half a flip — the highlight moves +but the shutter still records, or the reverse — fails. Roughly 25 s on a healthy AVD; evidence is +`video-baseline`, `camera-mode` and `video-restored` as XML + PNG. + +Taking the actual still is not automated — that is the `control.ps1` recipe below. + ## Driving it with control.ps1 Preconditions: diff --git a/.claude/skills/verify-openloop/helpers/photo_mode_loop.py b/.claude/skills/verify-openloop/helpers/photo_mode_loop.py new file mode 100644 index 0000000..7d2cf03 --- /dev/null +++ b/.claude/skills/verify-openloop/helpers/photo_mode_loop.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Autonomous verifier for the `photo-mode` sub-feature of `features/photo-capture.md`. + +Proves the viewfinder carries the top-right CAMERA | VIDEO selector and that tapping a segment +actually swaps what the shutter does — video -> stills -> video. Every state is asserted two ways +out of the *same* dump: + + * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being + switched, i.e. whether the user can record or take a picture right now, and + * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is the + whole reason the single toggling icon became a segmented control (issue #126). + +Neither alone is the claim: a highlight that moves while the shutter still records, or a shutter +that flips while the pill stays put, is a bug and this catches both. The two reads must land in +one dump, because a dump costs seconds and re-dumping between them is a race. + +Nothing is captured — no still, no clip, no gallery write. The scope is the toggle. + + python .claude/skills/verify-openloop/helpers/photo_mode_loop.py + + VERIFY_SERIAL=emulator-5556 pick a device when more than one is online + VERIFY_EVIDENCE_DIR= where the XML/PNG evidence lands + +Roughly 30 s on a healthy AVD; the cost is three uiautomator dumps plus the two taps. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_common import ( # noqa: E402 + UiNode, + app_nodes, + dump_strings, + dump_ui, + ensure_installed, + ensure_serial_allowed, + evidence_dir, + fail, + find_exact, + force_stop, + grant_camera, + require_online, + resolve_serial, + save_screencap, + start_activity, + tap_node, + wait_until, +) + +# Labels from strings.xml: camera_mode_camera / camera_mode_video, and the two idle shutter +# content descriptions camera_take_photo / camera_start_recording. +CAMERA_LABEL = "Camera" +VIDEO_LABEL = "Video" +PHOTO_SHUTTER = "Take photo" +VIDEO_SHUTTER = "Start recording" + + +def snapshot(serial: str, evidence: Path, name: str, xml: str) -> Path: + path = evidence / f"{name}.xml" + path.write_text(xml, encoding="utf-8") + save_screencap(serial, evidence / f"{name}.png") + return path + + +def segment_checked(nodes: list[UiNode], label: str) -> bool | None: + """Whether the selector segment carrying `label` is the selected one; None if there is none. + + uiautomator hands back a flat node list, so the segment is found by geometry: the smallest + checkable node whose bounds contain the label's. Compose puts the `selectable` on a + full-height wrapper *around* the visible pill, and only that wrapper reports `checked` — the + label leaf and the RadioButton-class leaf inside it both read checked="false" in either mode. + """ + label_node = find_exact(app_nodes(nodes), label) + if not label_node or not label_node.bounds: + return None + lx1, ly1, lx2, ly2 = label_node.bounds + best: tuple[int, bool] | None = None + for node in app_nodes(nodes): + if not node.checkable or not node.bounds: + continue + x1, y1, x2, y2 = node.bounds + if x1 <= lx1 and y1 <= ly1 and x2 >= lx2 and y2 >= ly2: + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, node.checked) + return None if best is None else best[1] + + +def mode_state(nodes: list[UiNode]) -> tuple[str, bool | None, bool | None]: + """One dump's answer to "which mode is the viewfinder in": shutter desc + both segment flags.""" + strings = dump_strings(app_nodes(nodes)) + shutter = "" + if PHOTO_SHUTTER in strings: + shutter = PHOTO_SHUTTER + if VIDEO_SHUTTER in strings: + # Both at once is a product bug, not a read error — report it as what it is. + shutter = "both" if shutter else VIDEO_SHUTTER + return shutter, segment_checked(nodes, CAMERA_LABEL), segment_checked(nodes, VIDEO_LABEL) + + +def matches(nodes: list[UiNode], want_photo: bool) -> bool: + shutter, camera_on, video_on = mode_state(nodes) + return ( + shutter == (PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER) + and camera_on is want_photo + and video_on is not want_photo + ) + + +def describe(nodes: list[UiNode]) -> str: + shutter, camera_on, video_on = mode_state(nodes) + return f"shutter={shutter or ''!r} Camera.checked={camera_on} Video.checked={video_on}" + + +def expected(want_photo: bool) -> str: + return ( + f"shutter={(PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER)!r} " + f"Camera.checked={want_photo} Video.checked={not want_photo}" + ) + + +def wait_for_viewfinder(serial: str, evidence: Path, timeout_s: float = 90.0) -> tuple[str, list[UiNode]]: + """Poll until the idle viewfinder is up in Video mode, walking onboarding like a user. + + Deliberately resets no stored state: this feature owns neither onboarding nor anyone else's + in-progress capture. Capture mode itself is ViewModel state, so the force-stop in `main` + already puts a fresh process back in VIDEO — the stills-mode tap here only covers a device + someone else left in Camera mode without restarting the app. + """ + seen: dict = {"xml": "", "nodes": []} + + def ready() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + strings = dump_strings(app_nodes(nodes)) + if VIDEO_SHUTTER in strings: + return True + cta = find_exact(nodes, "LET'S GO!") + if cta: # first run on a fresh install — walk through it like a user + tap_node(serial, cta) + return False + if PHOTO_SHUTTER in strings: # stills mode left over from another recipe + video = find_exact(app_nodes(nodes), VIDEO_LABEL) + if video: + tap_node(serial, video) + return False + + if not wait_until(ready, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, "viewfinder-not-idle", seen["xml"]) + fail(f"viewfinder: never reached an idle camera in Video mode; evidence={path}") + return seen["xml"], seen["nodes"] + + +def select_mode(serial: str, xml: str, nodes: list[UiNode], label: str, evidence: Path, context: str) -> None: + segment = find_exact(app_nodes(nodes), label) + if not segment: + path = snapshot(serial, evidence, f"{context}-no-segment", xml) + fail(f"{context}: the capture-mode selector has no {label!r} segment to tap; evidence={path}") + tap_node(serial, segment) + + +def wait_for_mode( + serial: str, want_photo: bool, context: str, evidence: Path, timeout_s: float = 30.0 +) -> tuple[str, list[UiNode]]: + """Poll until ONE dump shows the whole target state, then save it as this step's evidence.""" + seen: dict = {"xml": "", "nodes": []} + + def arrived() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + return matches(nodes, want_photo) + + if not wait_until(arrived, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, f"{context}-mismatch", seen["xml"]) + fail( + f"{context}: expected {expected(want_photo)}; last dump had {describe(seen['nodes'])}; " + f"evidence={path}" + ) + snapshot(serial, evidence, context, seen["xml"]) + return seen["xml"], seen["nodes"] + + +def main() -> int: + serial = resolve_serial() + ensure_serial_allowed(serial) + require_online(serial) + + evidence = evidence_dir("photo-mode") + ensure_installed(serial) + grant_camera(serial) + force_stop(serial) + start_activity(serial) + + started = time.monotonic() + xml, nodes = wait_for_viewfinder(serial, evidence) + + # The control exists at all. Checked separately from the state below so a selector that is + # simply missing fails saying so, instead of timing out on a mode that can never arrive. + for label in (CAMERA_LABEL, VIDEO_LABEL): + if find_exact(app_nodes(nodes), label) is None: + path = snapshot(serial, evidence, "no-selector", xml) + fail(f"selector: no {label!r} segment on the viewfinder; evidence={path}") + if segment_checked(nodes, label) is None: + path = snapshot(serial, evidence, "not-selectable", xml) + fail( + f"selector: {label!r} is on screen but sits in no checkable segment, so nothing " + f"tells the user which mode is armed; evidence={path}" + ) + + # Baseline asserted on the dump the idle wait already paid for. + if not matches(nodes, want_photo=False): + path = snapshot(serial, evidence, "video-baseline-mismatch", xml) + fail( + f"video-baseline: expected {expected(False)}; got {describe(nodes)}; evidence={path}" + ) + snapshot(serial, evidence, "video-baseline", xml) + + # Video -> stills, then back. Each tap targets the segment as it was seen in the dump that + # proved the *previous* state, so no extra dump is paid for between the two directions. + select_mode(serial, xml, nodes, CAMERA_LABEL, evidence, "camera-mode") + camera_xml, camera_nodes = wait_for_mode(serial, True, "camera-mode", evidence) + + select_mode(serial, camera_xml, camera_nodes, VIDEO_LABEL, evidence, "video-restored") + wait_for_mode(serial, False, "video-restored", evidence) + + force_stop(serial) + print( + f"PASS serial={serial} selector=Camera|Video video->photo->video " + f"took={int(time.monotonic() - started)}s evidence={evidence}" + ) + return 0 + + +if __name__ == "__main__": + import subprocess + + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as exc: + cmd = " ".join(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]) + fail(f"adb command failed ({cmd}): {(exc.stderr or exc.stdout or '').strip()}") diff --git a/.claude/skills/verify-openloop/helpers/verify_common.py b/.claude/skills/verify-openloop/helpers/verify_common.py index 3bd9ce8..df50712 100644 --- a/.claude/skills/verify-openloop/helpers/verify_common.py +++ b/.claude/skills/verify-openloop/helpers/verify_common.py @@ -36,6 +36,12 @@ class UiNode: # and the navigation bar come back alongside the app — filter on this before asserting that # some text is or is not on screen "in the app". pkg: str = "" + # Selection/toggle state. A Compose `selectable`/`toggleable` reaches uiautomator as + # checkable="true" on the wrapper node, with `checked` carrying whether it is the selected + # one — the label leaf and the RadioButton-class leaf both report checked="false" whatever + # is selected, so this pair is the only place a segmented control's state is readable. + checkable: bool = False + checked: bool = False def fail(message: str) -> None: @@ -150,6 +156,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc_m = re.search(r'content-desc="([^"]*)"', fragment) bounds_m = re.search(r'bounds="(\[[^\]]+\]\[[^\]]+\])"', fragment) pkg_m = re.search(r'package="([^"]*)"', fragment) + checkable_m = re.search(r'checkable="([^"]*)"', fragment) + checked_m = re.search(r'checked="([^"]*)"', fragment) bounds = parse_bounds(bounds_m.group(1)) if bounds_m else None nodes.append( UiNode( @@ -157,6 +165,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc=decode_entities(desc_m.group(1) if desc_m else ""), bounds=bounds, pkg=pkg_m.group(1) if pkg_m else "", + checkable=bool(checkable_m) and checkable_m.group(1) == "true", + checked=bool(checked_m) and checked_m.group(1) == "true", ) ) return nodes @@ -173,6 +183,8 @@ def parse_nodes_etree(xml_text: str) -> list[UiNode]: desc=decode_entities(elem.attrib.get("content-desc", "")), bounds=bounds, pkg=elem.attrib.get("package", ""), + checkable=elem.attrib.get("checkable") == "true", + checked=elem.attrib.get("checked") == "true", ) ) return nodes diff --git a/.codex/skills/verify-openloop/features/photo-capture.md b/.codex/skills/verify-openloop/features/photo-capture.md index e98b047..b94f764 100644 --- a/.codex/skills/verify-openloop/features/photo-capture.md +++ b/.codex/skills/verify-openloop/features/photo-capture.md @@ -15,6 +15,27 @@ Camera mode takes a single still from the live preview (including any active len - Tap the shutter (`Take photo`). - Share or dismiss; open Gallery to see the still. +## Autonomous check + +Run it from the repository root: + +```powershell +python .codex/skills/verify-openloop/helpers/photo_mode_loop.py +``` + +`python scripts/run-verification-loops.py --changed` runs it alongside every other loop. Both take +`VERIFY_SERIAL` when more than one emulator is online and `VERIFY_EVIDENCE_DIR` for the artifacts. + +It covers the `photo-mode` sub-feature only — the CAMERA | VIDEO selector and what tapping it does. +Nothing is captured: no still, no clip, no gallery write. Three states (Video baseline → Camera → +Video) are each asserted out of a **single** dump, two ways at once: the shutter's description +(`Start recording` ⇄ `Take photo`, the ability being switched) and the tapped segment's `checked` +flag, which is where the lime highlight surfaces in the hierarchy. Half a flip — the highlight moves +but the shutter still records, or the reverse — fails. Roughly 25 s on a healthy AVD; evidence is +`video-baseline`, `camera-mode` and `video-restored` as XML + PNG. + +Taking the actual still is not automated — that is the `control.ps1` recipe below. + ## Driving it with control.ps1 Preconditions: diff --git a/.codex/skills/verify-openloop/helpers/photo_mode_loop.py b/.codex/skills/verify-openloop/helpers/photo_mode_loop.py new file mode 100644 index 0000000..02bb2e2 --- /dev/null +++ b/.codex/skills/verify-openloop/helpers/photo_mode_loop.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Autonomous verifier for the `photo-mode` sub-feature of `features/photo-capture.md`. + +Proves the viewfinder carries the top-right CAMERA | VIDEO selector and that tapping a segment +actually swaps what the shutter does — video -> stills -> video. Every state is asserted two ways +out of the *same* dump: + + * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being + switched, i.e. whether the user can record or take a picture right now, and + * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is the + whole reason the single toggling icon became a segmented control (issue #126). + +Neither alone is the claim: a highlight that moves while the shutter still records, or a shutter +that flips while the pill stays put, is a bug and this catches both. The two reads must land in +one dump, because a dump costs seconds and re-dumping between them is a race. + +Nothing is captured — no still, no clip, no gallery write. The scope is the toggle. + + python .codex/skills/verify-openloop/helpers/photo_mode_loop.py + + VERIFY_SERIAL=emulator-5556 pick a device when more than one is online + VERIFY_EVIDENCE_DIR= where the XML/PNG evidence lands + +Roughly 30 s on a healthy AVD; the cost is three uiautomator dumps plus the two taps. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_common import ( # noqa: E402 + UiNode, + app_nodes, + dump_strings, + dump_ui, + ensure_installed, + ensure_serial_allowed, + evidence_dir, + fail, + find_exact, + force_stop, + grant_camera, + require_online, + resolve_serial, + save_screencap, + start_activity, + tap_node, + wait_until, +) + +# Labels from strings.xml: camera_mode_camera / camera_mode_video, and the two idle shutter +# content descriptions camera_take_photo / camera_start_recording. +CAMERA_LABEL = "Camera" +VIDEO_LABEL = "Video" +PHOTO_SHUTTER = "Take photo" +VIDEO_SHUTTER = "Start recording" + + +def snapshot(serial: str, evidence: Path, name: str, xml: str) -> Path: + path = evidence / f"{name}.xml" + path.write_text(xml, encoding="utf-8") + save_screencap(serial, evidence / f"{name}.png") + return path + + +def segment_checked(nodes: list[UiNode], label: str) -> bool | None: + """Whether the selector segment carrying `label` is the selected one; None if there is none. + + uiautomator hands back a flat node list, so the segment is found by geometry: the smallest + checkable node whose bounds contain the label's. Compose puts the `selectable` on a + full-height wrapper *around* the visible pill, and only that wrapper reports `checked` — the + label leaf and the RadioButton-class leaf inside it both read checked="false" in either mode. + """ + label_node = find_exact(app_nodes(nodes), label) + if not label_node or not label_node.bounds: + return None + lx1, ly1, lx2, ly2 = label_node.bounds + best: tuple[int, bool] | None = None + for node in app_nodes(nodes): + if not node.checkable or not node.bounds: + continue + x1, y1, x2, y2 = node.bounds + if x1 <= lx1 and y1 <= ly1 and x2 >= lx2 and y2 >= ly2: + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, node.checked) + return None if best is None else best[1] + + +def mode_state(nodes: list[UiNode]) -> tuple[str, bool | None, bool | None]: + """One dump's answer to "which mode is the viewfinder in": shutter desc + both segment flags.""" + strings = dump_strings(app_nodes(nodes)) + shutter = "" + if PHOTO_SHUTTER in strings: + shutter = PHOTO_SHUTTER + if VIDEO_SHUTTER in strings: + # Both at once is a product bug, not a read error — report it as what it is. + shutter = "both" if shutter else VIDEO_SHUTTER + return shutter, segment_checked(nodes, CAMERA_LABEL), segment_checked(nodes, VIDEO_LABEL) + + +def matches(nodes: list[UiNode], want_photo: bool) -> bool: + shutter, camera_on, video_on = mode_state(nodes) + return ( + shutter == (PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER) + and camera_on is want_photo + and video_on is not want_photo + ) + + +def describe(nodes: list[UiNode]) -> str: + shutter, camera_on, video_on = mode_state(nodes) + return f"shutter={shutter or ''!r} Camera.checked={camera_on} Video.checked={video_on}" + + +def expected(want_photo: bool) -> str: + return ( + f"shutter={(PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER)!r} " + f"Camera.checked={want_photo} Video.checked={not want_photo}" + ) + + +def wait_for_viewfinder(serial: str, evidence: Path, timeout_s: float = 90.0) -> tuple[str, list[UiNode]]: + """Poll until the idle viewfinder is up in Video mode, walking onboarding like a user. + + Deliberately resets no stored state: this feature owns neither onboarding nor anyone else's + in-progress capture. Capture mode itself is ViewModel state, so the force-stop in `main` + already puts a fresh process back in VIDEO — the stills-mode tap here only covers a device + someone else left in Camera mode without restarting the app. + """ + seen: dict = {"xml": "", "nodes": []} + + def ready() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + strings = dump_strings(app_nodes(nodes)) + if VIDEO_SHUTTER in strings: + return True + cta = find_exact(nodes, "LET'S GO!") + if cta: # first run on a fresh install — walk through it like a user + tap_node(serial, cta) + return False + if PHOTO_SHUTTER in strings: # stills mode left over from another recipe + video = find_exact(app_nodes(nodes), VIDEO_LABEL) + if video: + tap_node(serial, video) + return False + + if not wait_until(ready, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, "viewfinder-not-idle", seen["xml"]) + fail(f"viewfinder: never reached an idle camera in Video mode; evidence={path}") + return seen["xml"], seen["nodes"] + + +def select_mode(serial: str, xml: str, nodes: list[UiNode], label: str, evidence: Path, context: str) -> None: + segment = find_exact(app_nodes(nodes), label) + if not segment: + path = snapshot(serial, evidence, f"{context}-no-segment", xml) + fail(f"{context}: the capture-mode selector has no {label!r} segment to tap; evidence={path}") + tap_node(serial, segment) + + +def wait_for_mode( + serial: str, want_photo: bool, context: str, evidence: Path, timeout_s: float = 30.0 +) -> tuple[str, list[UiNode]]: + """Poll until ONE dump shows the whole target state, then save it as this step's evidence.""" + seen: dict = {"xml": "", "nodes": []} + + def arrived() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + return matches(nodes, want_photo) + + if not wait_until(arrived, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, f"{context}-mismatch", seen["xml"]) + fail( + f"{context}: expected {expected(want_photo)}; last dump had {describe(seen['nodes'])}; " + f"evidence={path}" + ) + snapshot(serial, evidence, context, seen["xml"]) + return seen["xml"], seen["nodes"] + + +def main() -> int: + serial = resolve_serial() + ensure_serial_allowed(serial) + require_online(serial) + + evidence = evidence_dir("photo-mode") + ensure_installed(serial) + grant_camera(serial) + force_stop(serial) + start_activity(serial) + + started = time.monotonic() + xml, nodes = wait_for_viewfinder(serial, evidence) + + # The control exists at all. Checked separately from the state below so a selector that is + # simply missing fails saying so, instead of timing out on a mode that can never arrive. + for label in (CAMERA_LABEL, VIDEO_LABEL): + if find_exact(app_nodes(nodes), label) is None: + path = snapshot(serial, evidence, "no-selector", xml) + fail(f"selector: no {label!r} segment on the viewfinder; evidence={path}") + if segment_checked(nodes, label) is None: + path = snapshot(serial, evidence, "not-selectable", xml) + fail( + f"selector: {label!r} is on screen but sits in no checkable segment, so nothing " + f"tells the user which mode is armed; evidence={path}" + ) + + # Baseline asserted on the dump the idle wait already paid for. + if not matches(nodes, want_photo=False): + path = snapshot(serial, evidence, "video-baseline-mismatch", xml) + fail( + f"video-baseline: expected {expected(False)}; got {describe(nodes)}; evidence={path}" + ) + snapshot(serial, evidence, "video-baseline", xml) + + # Video -> stills, then back. Each tap targets the segment as it was seen in the dump that + # proved the *previous* state, so no extra dump is paid for between the two directions. + select_mode(serial, xml, nodes, CAMERA_LABEL, evidence, "camera-mode") + camera_xml, camera_nodes = wait_for_mode(serial, True, "camera-mode", evidence) + + select_mode(serial, camera_xml, camera_nodes, VIDEO_LABEL, evidence, "video-restored") + wait_for_mode(serial, False, "video-restored", evidence) + + force_stop(serial) + print( + f"PASS serial={serial} selector=Camera|Video video->photo->video " + f"took={int(time.monotonic() - started)}s evidence={evidence}" + ) + return 0 + + +if __name__ == "__main__": + import subprocess + + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as exc: + cmd = " ".join(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]) + fail(f"adb command failed ({cmd}): {(exc.stderr or exc.stdout or '').strip()}") diff --git a/.codex/skills/verify-openloop/helpers/verify_common.py b/.codex/skills/verify-openloop/helpers/verify_common.py index 3bd9ce8..df50712 100644 --- a/.codex/skills/verify-openloop/helpers/verify_common.py +++ b/.codex/skills/verify-openloop/helpers/verify_common.py @@ -36,6 +36,12 @@ class UiNode: # and the navigation bar come back alongside the app — filter on this before asserting that # some text is or is not on screen "in the app". pkg: str = "" + # Selection/toggle state. A Compose `selectable`/`toggleable` reaches uiautomator as + # checkable="true" on the wrapper node, with `checked` carrying whether it is the selected + # one — the label leaf and the RadioButton-class leaf both report checked="false" whatever + # is selected, so this pair is the only place a segmented control's state is readable. + checkable: bool = False + checked: bool = False def fail(message: str) -> None: @@ -150,6 +156,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc_m = re.search(r'content-desc="([^"]*)"', fragment) bounds_m = re.search(r'bounds="(\[[^\]]+\]\[[^\]]+\])"', fragment) pkg_m = re.search(r'package="([^"]*)"', fragment) + checkable_m = re.search(r'checkable="([^"]*)"', fragment) + checked_m = re.search(r'checked="([^"]*)"', fragment) bounds = parse_bounds(bounds_m.group(1)) if bounds_m else None nodes.append( UiNode( @@ -157,6 +165,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc=decode_entities(desc_m.group(1) if desc_m else ""), bounds=bounds, pkg=pkg_m.group(1) if pkg_m else "", + checkable=bool(checkable_m) and checkable_m.group(1) == "true", + checked=bool(checked_m) and checked_m.group(1) == "true", ) ) return nodes @@ -173,6 +183,8 @@ def parse_nodes_etree(xml_text: str) -> list[UiNode]: desc=decode_entities(elem.attrib.get("content-desc", "")), bounds=bounds, pkg=elem.attrib.get("package", ""), + checkable=elem.attrib.get("checkable") == "true", + checked=elem.attrib.get("checked") == "true", ) ) return nodes diff --git a/.cursor/skills/verify-openloop/features/photo-capture.md b/.cursor/skills/verify-openloop/features/photo-capture.md index e98b047..e96be30 100644 --- a/.cursor/skills/verify-openloop/features/photo-capture.md +++ b/.cursor/skills/verify-openloop/features/photo-capture.md @@ -15,6 +15,27 @@ Camera mode takes a single still from the live preview (including any active len - Tap the shutter (`Take photo`). - Share or dismiss; open Gallery to see the still. +## Autonomous check + +Run it from the repository root: + +```powershell +python .cursor/skills/verify-openloop/helpers/photo_mode_loop.py +``` + +`python scripts/run-verification-loops.py --changed` runs it alongside every other loop. Both take +`VERIFY_SERIAL` when more than one emulator is online and `VERIFY_EVIDENCE_DIR` for the artifacts. + +It covers the `photo-mode` sub-feature only — the CAMERA | VIDEO selector and what tapping it does. +Nothing is captured: no still, no clip, no gallery write. Three states (Video baseline → Camera → +Video) are each asserted out of a **single** dump, two ways at once: the shutter's description +(`Start recording` ⇄ `Take photo`, the ability being switched) and the tapped segment's `checked` +flag, which is where the lime highlight surfaces in the hierarchy. Half a flip — the highlight moves +but the shutter still records, or the reverse — fails. Roughly 25 s on a healthy AVD; evidence is +`video-baseline`, `camera-mode` and `video-restored` as XML + PNG. + +Taking the actual still is not automated — that is the `control.ps1` recipe below. + ## Driving it with control.ps1 Preconditions: diff --git a/.cursor/skills/verify-openloop/helpers/photo_mode_loop.py b/.cursor/skills/verify-openloop/helpers/photo_mode_loop.py new file mode 100644 index 0000000..8c5a69b --- /dev/null +++ b/.cursor/skills/verify-openloop/helpers/photo_mode_loop.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Autonomous verifier for the `photo-mode` sub-feature of `features/photo-capture.md`. + +Proves the viewfinder carries the top-right CAMERA | VIDEO selector and that tapping a segment +actually swaps what the shutter does — video -> stills -> video. Every state is asserted two ways +out of the *same* dump: + + * the shutter's contentDescription (`Start recording` <-> `Take photo`) — the ability being + switched, i.e. whether the user can record or take a picture right now, and + * the tapped segment's `checked` flag — the only indicator of which mode is armed, which is the + whole reason the single toggling icon became a segmented control (issue #126). + +Neither alone is the claim: a highlight that moves while the shutter still records, or a shutter +that flips while the pill stays put, is a bug and this catches both. The two reads must land in +one dump, because a dump costs seconds and re-dumping between them is a race. + +Nothing is captured — no still, no clip, no gallery write. The scope is the toggle. + + python .cursor/skills/verify-openloop/helpers/photo_mode_loop.py + + VERIFY_SERIAL=emulator-5556 pick a device when more than one is online + VERIFY_EVIDENCE_DIR= where the XML/PNG evidence lands + +Roughly 30 s on a healthy AVD; the cost is three uiautomator dumps plus the two taps. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_common import ( # noqa: E402 + UiNode, + app_nodes, + dump_strings, + dump_ui, + ensure_installed, + ensure_serial_allowed, + evidence_dir, + fail, + find_exact, + force_stop, + grant_camera, + require_online, + resolve_serial, + save_screencap, + start_activity, + tap_node, + wait_until, +) + +# Labels from strings.xml: camera_mode_camera / camera_mode_video, and the two idle shutter +# content descriptions camera_take_photo / camera_start_recording. +CAMERA_LABEL = "Camera" +VIDEO_LABEL = "Video" +PHOTO_SHUTTER = "Take photo" +VIDEO_SHUTTER = "Start recording" + + +def snapshot(serial: str, evidence: Path, name: str, xml: str) -> Path: + path = evidence / f"{name}.xml" + path.write_text(xml, encoding="utf-8") + save_screencap(serial, evidence / f"{name}.png") + return path + + +def segment_checked(nodes: list[UiNode], label: str) -> bool | None: + """Whether the selector segment carrying `label` is the selected one; None if there is none. + + uiautomator hands back a flat node list, so the segment is found by geometry: the smallest + checkable node whose bounds contain the label's. Compose puts the `selectable` on a + full-height wrapper *around* the visible pill, and only that wrapper reports `checked` — the + label leaf and the RadioButton-class leaf inside it both read checked="false" in either mode. + """ + label_node = find_exact(app_nodes(nodes), label) + if not label_node or not label_node.bounds: + return None + lx1, ly1, lx2, ly2 = label_node.bounds + best: tuple[int, bool] | None = None + for node in app_nodes(nodes): + if not node.checkable or not node.bounds: + continue + x1, y1, x2, y2 = node.bounds + if x1 <= lx1 and y1 <= ly1 and x2 >= lx2 and y2 >= ly2: + area = (x2 - x1) * (y2 - y1) + if best is None or area < best[0]: + best = (area, node.checked) + return None if best is None else best[1] + + +def mode_state(nodes: list[UiNode]) -> tuple[str, bool | None, bool | None]: + """One dump's answer to "which mode is the viewfinder in": shutter desc + both segment flags.""" + strings = dump_strings(app_nodes(nodes)) + shutter = "" + if PHOTO_SHUTTER in strings: + shutter = PHOTO_SHUTTER + if VIDEO_SHUTTER in strings: + # Both at once is a product bug, not a read error — report it as what it is. + shutter = "both" if shutter else VIDEO_SHUTTER + return shutter, segment_checked(nodes, CAMERA_LABEL), segment_checked(nodes, VIDEO_LABEL) + + +def matches(nodes: list[UiNode], want_photo: bool) -> bool: + shutter, camera_on, video_on = mode_state(nodes) + return ( + shutter == (PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER) + and camera_on is want_photo + and video_on is not want_photo + ) + + +def describe(nodes: list[UiNode]) -> str: + shutter, camera_on, video_on = mode_state(nodes) + return f"shutter={shutter or ''!r} Camera.checked={camera_on} Video.checked={video_on}" + + +def expected(want_photo: bool) -> str: + return ( + f"shutter={(PHOTO_SHUTTER if want_photo else VIDEO_SHUTTER)!r} " + f"Camera.checked={want_photo} Video.checked={not want_photo}" + ) + + +def wait_for_viewfinder(serial: str, evidence: Path, timeout_s: float = 90.0) -> tuple[str, list[UiNode]]: + """Poll until the idle viewfinder is up in Video mode, walking onboarding like a user. + + Deliberately resets no stored state: this feature owns neither onboarding nor anyone else's + in-progress capture. Capture mode itself is ViewModel state, so the force-stop in `main` + already puts a fresh process back in VIDEO — the stills-mode tap here only covers a device + someone else left in Camera mode without restarting the app. + """ + seen: dict = {"xml": "", "nodes": []} + + def ready() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + strings = dump_strings(app_nodes(nodes)) + if VIDEO_SHUTTER in strings: + return True + cta = find_exact(nodes, "LET'S GO!") + if cta: # first run on a fresh install — walk through it like a user + tap_node(serial, cta) + return False + if PHOTO_SHUTTER in strings: # stills mode left over from another recipe + video = find_exact(app_nodes(nodes), VIDEO_LABEL) + if video: + tap_node(serial, video) + return False + + if not wait_until(ready, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, "viewfinder-not-idle", seen["xml"]) + fail(f"viewfinder: never reached an idle camera in Video mode; evidence={path}") + return seen["xml"], seen["nodes"] + + +def select_mode(serial: str, xml: str, nodes: list[UiNode], label: str, evidence: Path, context: str) -> None: + segment = find_exact(app_nodes(nodes), label) + if not segment: + path = snapshot(serial, evidence, f"{context}-no-segment", xml) + fail(f"{context}: the capture-mode selector has no {label!r} segment to tap; evidence={path}") + tap_node(serial, segment) + + +def wait_for_mode( + serial: str, want_photo: bool, context: str, evidence: Path, timeout_s: float = 30.0 +) -> tuple[str, list[UiNode]]: + """Poll until ONE dump shows the whole target state, then save it as this step's evidence.""" + seen: dict = {"xml": "", "nodes": []} + + def arrived() -> bool: + xml, nodes = dump_ui(serial) + if not nodes: + return False + seen["xml"], seen["nodes"] = xml, nodes + return matches(nodes, want_photo) + + if not wait_until(arrived, timeout_s=timeout_s, interval_s=1.0): + path = snapshot(serial, evidence, f"{context}-mismatch", seen["xml"]) + fail( + f"{context}: expected {expected(want_photo)}; last dump had {describe(seen['nodes'])}; " + f"evidence={path}" + ) + snapshot(serial, evidence, context, seen["xml"]) + return seen["xml"], seen["nodes"] + + +def main() -> int: + serial = resolve_serial() + ensure_serial_allowed(serial) + require_online(serial) + + evidence = evidence_dir("photo-mode") + ensure_installed(serial) + grant_camera(serial) + force_stop(serial) + start_activity(serial) + + started = time.monotonic() + xml, nodes = wait_for_viewfinder(serial, evidence) + + # The control exists at all. Checked separately from the state below so a selector that is + # simply missing fails saying so, instead of timing out on a mode that can never arrive. + for label in (CAMERA_LABEL, VIDEO_LABEL): + if find_exact(app_nodes(nodes), label) is None: + path = snapshot(serial, evidence, "no-selector", xml) + fail(f"selector: no {label!r} segment on the viewfinder; evidence={path}") + if segment_checked(nodes, label) is None: + path = snapshot(serial, evidence, "not-selectable", xml) + fail( + f"selector: {label!r} is on screen but sits in no checkable segment, so nothing " + f"tells the user which mode is armed; evidence={path}" + ) + + # Baseline asserted on the dump the idle wait already paid for. + if not matches(nodes, want_photo=False): + path = snapshot(serial, evidence, "video-baseline-mismatch", xml) + fail( + f"video-baseline: expected {expected(False)}; got {describe(nodes)}; evidence={path}" + ) + snapshot(serial, evidence, "video-baseline", xml) + + # Video -> stills, then back. Each tap targets the segment as it was seen in the dump that + # proved the *previous* state, so no extra dump is paid for between the two directions. + select_mode(serial, xml, nodes, CAMERA_LABEL, evidence, "camera-mode") + camera_xml, camera_nodes = wait_for_mode(serial, True, "camera-mode", evidence) + + select_mode(serial, camera_xml, camera_nodes, VIDEO_LABEL, evidence, "video-restored") + wait_for_mode(serial, False, "video-restored", evidence) + + force_stop(serial) + print( + f"PASS serial={serial} selector=Camera|Video video->photo->video " + f"took={int(time.monotonic() - started)}s evidence={evidence}" + ) + return 0 + + +if __name__ == "__main__": + import subprocess + + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as exc: + cmd = " ".join(exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)]) + fail(f"adb command failed ({cmd}): {(exc.stderr or exc.stdout or '').strip()}") diff --git a/.cursor/skills/verify-openloop/helpers/verify_common.py b/.cursor/skills/verify-openloop/helpers/verify_common.py index 3bd9ce8..df50712 100644 --- a/.cursor/skills/verify-openloop/helpers/verify_common.py +++ b/.cursor/skills/verify-openloop/helpers/verify_common.py @@ -36,6 +36,12 @@ class UiNode: # and the navigation bar come back alongside the app — filter on this before asserting that # some text is or is not on screen "in the app". pkg: str = "" + # Selection/toggle state. A Compose `selectable`/`toggleable` reaches uiautomator as + # checkable="true" on the wrapper node, with `checked` carrying whether it is the selected + # one — the label leaf and the RadioButton-class leaf both report checked="false" whatever + # is selected, so this pair is the only place a segmented control's state is readable. + checkable: bool = False + checked: bool = False def fail(message: str) -> None: @@ -150,6 +156,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc_m = re.search(r'content-desc="([^"]*)"', fragment) bounds_m = re.search(r'bounds="(\[[^\]]+\]\[[^\]]+\])"', fragment) pkg_m = re.search(r'package="([^"]*)"', fragment) + checkable_m = re.search(r'checkable="([^"]*)"', fragment) + checked_m = re.search(r'checked="([^"]*)"', fragment) bounds = parse_bounds(bounds_m.group(1)) if bounds_m else None nodes.append( UiNode( @@ -157,6 +165,8 @@ def parse_nodes_regex(xml_text: str) -> list[UiNode]: desc=decode_entities(desc_m.group(1) if desc_m else ""), bounds=bounds, pkg=pkg_m.group(1) if pkg_m else "", + checkable=bool(checkable_m) and checkable_m.group(1) == "true", + checked=bool(checked_m) and checked_m.group(1) == "true", ) ) return nodes @@ -173,6 +183,8 @@ def parse_nodes_etree(xml_text: str) -> list[UiNode]: desc=decode_entities(elem.attrib.get("content-desc", "")), bounds=bounds, pkg=elem.attrib.get("package", ""), + checkable=elem.attrib.get("checkable") == "true", + checked=elem.attrib.get("checked") == "true", ) ) return nodes From e551a20b14005aae15f241dd66564a5d62ccb468 Mon Sep 17 00:00:00 2001 From: Steven Gates Date: Tue, 1 Sep 2026 10:05:59 -0500 Subject: [PATCH 2/2] docs(verify): record the capture-mode toggle as automated, and make that a step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INVENTORY gains a row for the capture-mode toggle (`automated 2026-09-01`) — the surface photo_mode_loop.py actually drives. "Photo stills mode" deliberately stays `mapped`: taking the still is still a manual control.ps1 recipe, and the table's own legend makes `automated` a claim about what a loop drives. Marking that row was ad-hoc; the create-verifier skill now names it as step 6, after the loop has been seen passing, with the harness sync moved to the end so it picks up the INVENTORY edit too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01249no1TrwKLR1aorGqaf7C --- .claude/skills/create-verifier/SKILL.md | 3 ++- .claude/skills/verify-openloop/features/INVENTORY.md | 1 + .codex/skills/create-verifier/SKILL.md | 3 ++- .codex/skills/verify-openloop/features/INVENTORY.md | 1 + .cursor/skills/create-verifier/SKILL.md | 3 ++- .cursor/skills/verify-openloop/features/INVENTORY.md | 1 + 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.claude/skills/create-verifier/SKILL.md b/.claude/skills/create-verifier/SKILL.md index cffdc48..55f5d80 100644 --- a/.claude/skills/create-verifier/SKILL.md +++ b/.claude/skills/create-verifier/SKILL.md @@ -16,8 +16,9 @@ The verifier drives `io.github.stozo04.openloop/.MainActivity` on an emulator li 1. Read `docs/OPERATING_INSTRUCTIONS.md`, `docs/OPENLOOP_INSTRUCTIONS.md`, the matching `.claude/skills/verify-openloop/features/.md`, and the relevant product code/strings. Use `.claude/skills/verify-openloop/helpers/onboarding_loop.py` as the proven reference. 2. Derive observable acceptance criteria from the shipped product: precondition, entry point, exact must-have/must-not-have UI, user actions, persisted/resulting state, and any required non-UI proof such as logcat or a file. 3. Create `.claude/skills/verify-openloop/helpers/_loop.py` with Python's standard library. Reuse an existing helper when one fits; extract shared code only after two real loops demonstrate material duplication. -4. Update the matching feature recipe with the direct run command. Run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), then `--check`. +4. Update the matching feature recipe with the direct run command. 5. Build the current debug APK if needed and run the new loop on a booted emulator. Do not report completion from syntax checks or exit code alone; independently confirm its final PASS marker and evidence artifacts. +6. Only once it has been seen passing, mark the surface `automated ` in `features/INVENTORY.md` — add a row when the loop covers a surface the table does not list yet, and leave neighbouring rows `mapped` rather than overstating what the loop drives. Then run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), followed by `--check`; the sync must come last so every edit above reaches all three harnesses. `scripts/run-verification-loops.py --changed` discovers shipped `*_loop.py` files automatically, so there is no registry or roadmap to update. diff --git a/.claude/skills/verify-openloop/features/INVENTORY.md b/.claude/skills/verify-openloop/features/INVENTORY.md index 41b38a9..71a3b9e 100644 --- a/.claude/skills/verify-openloop/features/INVENTORY.md +++ b/.claude/skills/verify-openloop/features/INVENTORY.md @@ -11,6 +11,7 @@ Does **not** need to stay perfectly current between audits — the [README compl | Record / stop video | `Start recording` | automated 2026-08-31 | [record-clip.md](./record-clip.md) | | Lenses drawer + catalogue | `Lenses and Photo Booth` | mapped | [lenses.md](./lenses.md) | | Multi-face (1–2) lens | FaceRoster / live preview | folded into lenses | [lenses.md](./lenses.md) | +| Capture-mode toggle (photo/video) | `Camera` / `Video` selector | automated 2026-09-01 | [photo-capture.md](./photo-capture.md) | | Photo stills mode | `Camera` / `Take photo` | mapped | [photo-capture.md](./photo-capture.md) | | Photo booth | booth tab / countdown | mapped | [photo-booth.md](./photo-booth.md) | | Pinch zoom | zoom chip | mapped | [pinch-zoom.md](./pinch-zoom.md) | diff --git a/.codex/skills/create-verifier/SKILL.md b/.codex/skills/create-verifier/SKILL.md index 4251a32..bd14c6c 100644 --- a/.codex/skills/create-verifier/SKILL.md +++ b/.codex/skills/create-verifier/SKILL.md @@ -16,8 +16,9 @@ The verifier drives `io.github.stozo04.openloop/.MainActivity` on an emulator li 1. Read `docs/OPERATING_INSTRUCTIONS.md`, `docs/OPENLOOP_INSTRUCTIONS.md`, the matching `.codex/skills/verify-openloop/features/.md`, and the relevant product code/strings. Use `.codex/skills/verify-openloop/helpers/onboarding_loop.py` as the proven reference. 2. Derive observable acceptance criteria from the shipped product: precondition, entry point, exact must-have/must-not-have UI, user actions, persisted/resulting state, and any required non-UI proof such as logcat or a file. 3. Create `.codex/skills/verify-openloop/helpers/_loop.py` with Python's standard library. Reuse an existing helper when one fits; extract shared code only after two real loops demonstrate material duplication. -4. Update the matching feature recipe with the direct run command. Run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), then `--check`. +4. Update the matching feature recipe with the direct run command. 5. Build the current debug APK if needed and run the new loop on a booted emulator. Do not report completion from syntax checks or exit code alone; independently confirm its final PASS marker and evidence artifacts. +6. Only once it has been seen passing, mark the surface `automated ` in `features/INVENTORY.md` — add a row when the loop covers a surface the table does not list yet, and leave neighbouring rows `mapped` rather than overstating what the loop drives. Then run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), followed by `--check`; the sync must come last so every edit above reaches all three harnesses. `scripts/run-verification-loops.py --changed` discovers shipped `*_loop.py` files automatically, so there is no registry or roadmap to update. diff --git a/.codex/skills/verify-openloop/features/INVENTORY.md b/.codex/skills/verify-openloop/features/INVENTORY.md index 41b38a9..71a3b9e 100644 --- a/.codex/skills/verify-openloop/features/INVENTORY.md +++ b/.codex/skills/verify-openloop/features/INVENTORY.md @@ -11,6 +11,7 @@ Does **not** need to stay perfectly current between audits — the [README compl | Record / stop video | `Start recording` | automated 2026-08-31 | [record-clip.md](./record-clip.md) | | Lenses drawer + catalogue | `Lenses and Photo Booth` | mapped | [lenses.md](./lenses.md) | | Multi-face (1–2) lens | FaceRoster / live preview | folded into lenses | [lenses.md](./lenses.md) | +| Capture-mode toggle (photo/video) | `Camera` / `Video` selector | automated 2026-09-01 | [photo-capture.md](./photo-capture.md) | | Photo stills mode | `Camera` / `Take photo` | mapped | [photo-capture.md](./photo-capture.md) | | Photo booth | booth tab / countdown | mapped | [photo-booth.md](./photo-booth.md) | | Pinch zoom | zoom chip | mapped | [pinch-zoom.md](./pinch-zoom.md) | diff --git a/.cursor/skills/create-verifier/SKILL.md b/.cursor/skills/create-verifier/SKILL.md index d85a7f5..0f08d27 100644 --- a/.cursor/skills/create-verifier/SKILL.md +++ b/.cursor/skills/create-verifier/SKILL.md @@ -16,8 +16,9 @@ The verifier drives `io.github.stozo04.openloop/.MainActivity` on an emulator li 1. Read `docs/OPERATING_INSTRUCTIONS.md`, `docs/OPENLOOP_INSTRUCTIONS.md`, the matching `.cursor/skills/verify-openloop/features/.md`, and the relevant product code/strings. Use `.cursor/skills/verify-openloop/helpers/onboarding_loop.py` as the proven reference. 2. Derive observable acceptance criteria from the shipped product: precondition, entry point, exact must-have/must-not-have UI, user actions, persisted/resulting state, and any required non-UI proof such as logcat or a file. 3. Create `.cursor/skills/verify-openloop/helpers/_loop.py` with Python's standard library. Reuse an existing helper when one fits; extract shared code only after two real loops demonstrate material duplication. -4. Update the matching feature recipe with the direct run command. Run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), then `--check`. +4. Update the matching feature recipe with the direct run command. 5. Build the current debug APK if needed and run the new loop on a booted emulator. Do not report completion from syntax checks or exit code alone; independently confirm its final PASS marker and evidence artifacts. +6. Only once it has been seen passing, mark the surface `automated ` in `features/INVENTORY.md` — add a row when the loop covers a surface the table does not list yet, and leave neighbouring rows `mapped` rather than overstating what the loop drives. Then run `python scripts/sync-harness-skills.py --fix` (it takes the direction from git and retargets each copy's own paths), followed by `--check`; the sync must come last so every edit above reaches all three harnesses. `scripts/run-verification-loops.py --changed` discovers shipped `*_loop.py` files automatically, so there is no registry or roadmap to update. diff --git a/.cursor/skills/verify-openloop/features/INVENTORY.md b/.cursor/skills/verify-openloop/features/INVENTORY.md index 41b38a9..71a3b9e 100644 --- a/.cursor/skills/verify-openloop/features/INVENTORY.md +++ b/.cursor/skills/verify-openloop/features/INVENTORY.md @@ -11,6 +11,7 @@ Does **not** need to stay perfectly current between audits — the [README compl | Record / stop video | `Start recording` | automated 2026-08-31 | [record-clip.md](./record-clip.md) | | Lenses drawer + catalogue | `Lenses and Photo Booth` | mapped | [lenses.md](./lenses.md) | | Multi-face (1–2) lens | FaceRoster / live preview | folded into lenses | [lenses.md](./lenses.md) | +| Capture-mode toggle (photo/video) | `Camera` / `Video` selector | automated 2026-09-01 | [photo-capture.md](./photo-capture.md) | | Photo stills mode | `Camera` / `Take photo` | mapped | [photo-capture.md](./photo-capture.md) | | Photo booth | booth tab / countdown | mapped | [photo-booth.md](./photo-booth.md) | | Pinch zoom | zoom chip | mapped | [pinch-zoom.md](./pinch-zoom.md) |