135 lines
5.6 KiB
Python
135 lines
5.6 KiB
Python
"""What the phone is actually in, read the same way for both backends.
|
|||
|
|
|
||
|
|
The scored suite used to take its predicates over ``SimulatedDevice``'s Python
|
||
|
|
attributes, so every scored run was a simulator run by construction -- the
|
||
|
|
real-device path existed for *driving* a phone and not for judging one. That
|
||
|
|
made "the agent suite" a claim about fixtures.
|
||
|
|
|
||
|
|
Success is therefore read two ways, and the order matters:
|
||
|
|
|
||
|
|
1. **Out of band, from the setting itself** -- ``device.state`` in the
|
||
|
|
simulator, ``settings get global wifi_on`` over adb. This is the ground
|
||
|
|
truth, and it does not depend on where the agent happened to leave the
|
||
|
|
phone. Scoring from the final screen alone fails an agent that turns Wi-Fi
|
||
|
|
on correctly and then presses Home, which is not a failure.
|
||
|
|
2. **From the UI dump**, for anything with no setting behind it -- a saved
|
||
|
|
alarm appears as a row in the clock app and nowhere else. The simulator
|
||
|
|
emits real uiautomator XML, so this goes down the same parse, the same
|
||
|
|
switch lookup and the same text matching a phone would.
|
||
|
|
|
||
|
|
Only the facts a phone genuinely cannot report after the fact -- which
|
||
|
|
switches were flipped on the way, and what was typed into which field -- come
|
||
|
|
from the simulator object, and any task needing them declares it, so a
|
||
|
|
real-device run refuses to score rather than scoring wrong.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
|
||
|
|
from ui_tree import parse_ui_dump
|
||
|
|
|
||
|
|
|
||
|
|
def _norm(s: str) -> str:
|
||
|
|
"""Match labels across the punctuation Android is inconsistent about."""
|
||
|
|
return "".join(c for c in s.lower() if c.isalnum())
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class PhoneState:
|
||
|
|
source: str
|
||
|
|
"""``"sim"`` or ``"device"`` -- which facts below are trustworthy."""
|
||
|
|
settings: dict[str, bool] = field(default_factory=dict)
|
||
|
|
"""Authoritative setting values, read out of band. Preferred over
|
||
|
|
:attr:`switches`, which only shows what is currently on screen."""
|
||
|
|
screen: str | None = None
|
||
|
|
"""Simulator screen id. ``None`` on a real phone, where there is no such
|
||
|
|
thing; use :meth:`has_text` against something the screen actually shows."""
|
||
|
|
switches: dict[str, bool] = field(default_factory=dict)
|
||
|
|
texts: list[str] = field(default_factory=list)
|
||
|
|
typed: list[str] = field(default_factory=list)
|
||
|
|
fields: dict[str, str] = field(default_factory=dict)
|
||
|
|
records: dict[str, list[dict]] = field(default_factory=dict)
|
||
|
|
toggled: list[tuple[str, bool, bool]] = field(default_factory=list)
|
||
|
|
"""(key, before, after) for every switch the agent actually flipped."""
|
||
|
|
|
||
|
|
def switch(self, *names: str) -> bool | None:
|
||
|
|
"""State of the first control matching any of ``names``.
|
||
|
|
|
||
|
|
The setting wins over the screen: an agent that turned Wi-Fi on and
|
||
|
|
then navigated away has still turned Wi-Fi on. ``None`` means neither
|
||
|
|
source knows the control, which is not the same as off -- an agent that
|
||
|
|
never found the screen has not turned anything on, and predicates must
|
||
|
|
not read that as "off".
|
||
|
|
"""
|
||
|
|
wanted = {_norm(n) for n in names}
|
||
|
|
for key, value in self.settings.items():
|
||
|
|
if _norm(key) in wanted:
|
||
|
|
return bool(value)
|
||
|
|
for label, checked in self.switches.items():
|
||
|
|
if _norm(label) in wanted:
|
||
|
|
return checked
|
||
|
|
return None
|
||
|
|
|
||
|
|
def has_text(self, *needles: str) -> bool:
|
||
|
|
joined = " ".join(self.texts).lower()
|
||
|
|
return any(n.lower() in joined for n in needles)
|
||
|
|
|
||
|
|
def times_toggled(self, key: str) -> int:
|
||
|
|
return sum(1 for k, _, _ in self.toggled if k == key)
|
||
|
|
|
||
|
|
|
||
|
|
def read_state(device) -> PhoneState:
|
||
|
|
"""Read the phone through its screen, plus the simulator's own records."""
|
||
|
|
elements = parse_ui_dump(device.dump())
|
||
|
|
# `checkable` is deliberately not among the flags ui_tree parses, because
|
||
|
|
# that list is also what the model is shown; adding to it would change the
|
||
|
|
# agent's prompt. A switch is recognised by its class or by exposing a
|
||
|
|
# `checked` flag instead.
|
||
|
|
switches = {e.text: bool(e.flags.get("checked", False))
|
||
|
|
for e in elements
|
||
|
|
if "checked" in e.flags
|
||
|
|
or e.short_class in ("Switch", "CheckBox", "ToggleButton")}
|
||
|
|
texts = [e.text for e in elements if e.text]
|
||
|
|
|
||
|
|
if not hasattr(device, "screens"):
|
||
|
|
return PhoneState(source="device", switches=switches, texts=texts,
|
||
|
|
settings=adb_settings(device))
|
||
|
|
|
||
|
|
return PhoneState(
|
||
|
|
source="sim",
|
||
|
|
screen=device.current,
|
||
|
|
settings={k: bool(v) for k, v in device.state.items()},
|
||
|
|
switches=switches,
|
||
|
|
texts=texts,
|
||
|
|
typed=list(device.typed),
|
||
|
|
fields=dict(getattr(device, "fields", {})),
|
||
|
|
records={k: [dict(r) for r in v]
|
||
|
|
for k, v in getattr(device, "records", {}).items()},
|
||
|
|
toggled=list(getattr(device, "toggled", [])),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
ADB_SETTINGS = {
|
||
|
|
"wifi": ("global", "wifi_on"),
|
||
|
|
"airplane": ("global", "airplane_mode_on"),
|
||
|
|
"bluetooth": ("global", "bluetooth_on"),
|
||
|
|
}
|
||
|
|
"""Settings readable out of band, so scoring does not depend on which screen
|
||
|
|
the agent left the phone on."""
|
||
|
|
|
||
|
|
|
||
|
|
def adb_settings(device) -> dict[str, bool]:
|
||
|
|
"""Read the authoritative values from a real phone. Missing keys are
|
||
|
|
omitted rather than defaulted -- an unreadable setting must not be scored
|
||
|
|
as "off"."""
|
||
|
|
out: dict[str, bool] = {}
|
||
|
|
for key, (namespace, name) in ADB_SETTINGS.items():
|
||
|
|
try:
|
||
|
|
raw = device._run("shell", "settings", "get", namespace, name)
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
raw = raw.strip()
|
||
|
|
if raw in ("0", "1"):
|
||
|
|
out[key] = raw == "1"
|
||
|
|
return out
|