Files
Artemis-Eapil/tests/unit/test_hierarchy_fidelity.py
T
chenmingxuan fac4ccb760 feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
2026-10-26 00:44:13 +08:00

160 lines
6.2 KiB
Python

"""What the agent sees must be what is on screen, whichever backend dumped it.
Covers the host-side half of the UIAutomator parity work: negative bounds no
longer lose an element's coordinates, hints and errors reach the element list
the model reads, an empty helper answer degrades to UIAutomator2 in ``auto``
mode, and the parity comparison used by ``artemis helper parity`` flags the
regressions it exists for.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from artemis.clients.accessibility_client import AccessibilityClient, HelperEmptyHierarchy
from artemis.clients.screen_client_factory import FallbackScreenClient
from artemis.clients.ui_automator_client import UIAutomatorClient, _parse_hierarchy_xml_to_elements
from artemis.core.diagnostics.hierarchy_parity import compare_dumps
from artemis.utils import ui_filter
from artemis.utils.visualization import format_minimal_list_with_elements
def _node(**attrs: str) -> str:
base = {
"index": "0",
"text": "",
"resource-id": "",
"class": "android.widget.TextView",
"package": "app",
"content-desc": "",
"clickable": "false",
"enabled": "true",
"bounds": "[0,0][100,100]",
}
base.update(attrs)
return "<node " + " ".join(f'{k}="{v}"' for k, v in base.items()) + " />"
def _xml(*nodes: str) -> str:
return '<?xml version="1.0"?><hierarchy rotation="0">' + "".join(nodes) + "</hierarchy>"
# --------------------------------------------------------------------------- #
# Negative bounds
# --------------------------------------------------------------------------- #
def test_negative_bounds_keep_their_coordinates_and_get_clipped():
elements = _parse_hierarchy_xml_to_elements(_xml(_node(text="Row", bounds="[-20,-40][300,60]")))
row = elements[1]
assert row["parsed_bounds"] == {"left": -20, "top": -40, "right": 300, "bottom": 60}
assert ui_filter._parse_bounds("[-20,-40][300,60]") == {
"left": -20,
"top": -40,
"right": 300,
"bottom": 60,
}
filtered = ui_filter.filter_ui_hierarchy([row], screen_width=1080, screen_height=2400)
assert len(filtered) == 1
assert filtered[0]["parsed_bounds"] == {"left": 0, "top": 0, "right": 300, "bottom": 60}
# --------------------------------------------------------------------------- #
# Hints and errors reach the model
# --------------------------------------------------------------------------- #
def test_minimal_list_shows_hint_for_empty_inputs_and_errors():
elements = _parse_hierarchy_xml_to_elements(
_xml(
_node(text="", hint="Search settings", **{"class": "android.widget.EditText"}),
_node(text="abc", error="Password too short", bounds="[0,200][100,300]"),
_node(text="", bounds="[0,400][100,500]"), # nothing to say: stays out
)
)
text, items, labels = format_minimal_list_with_elements(elements[1:], 1000, 1000)
lines = text.splitlines()
assert (
lines[0]
== "[1] Hint: 'Search settings' | Bounds: [0,0][100,100] | Editable: true | Value: empty"
)
assert lines[1] == "[2] Text: 'abc' | Bounds: [0,200][100,300] | Error: 'Password too short'"
assert len(lines) == 2 and labels == ["1", "2"]
assert items[0]["is_hint"] is True and items[0]["text"] == "Search settings"
assert items[1]["error"] == "Password too short"
# --------------------------------------------------------------------------- #
# Empty helper answer degrades instead of showing a blank screen
# --------------------------------------------------------------------------- #
def test_empty_helper_hierarchy_falls_back_to_uiautomator():
helper = MagicMock(spec=AccessibilityClient)
u2 = MagicMock(spec=UIAutomatorClient)
helper.get_screen_data.side_effect = HelperEmptyHierarchy("no window")
u2.get_screen_data.return_value = "from-u2"
client = FallbackScreenClient(
"dev", helper=helper, uiautomator_factory=lambda: u2, state_probe=lambda _s: "device"
)
assert client.get_screen_data() == "from-u2"
assert client.active_backend == "uiautomator"
assert "HelperEmptyHierarchy" in client.backend_history[-1]["reason"]
# --------------------------------------------------------------------------- #
# Parity comparison
# --------------------------------------------------------------------------- #
def test_parity_passes_when_both_backends_describe_the_same_screen():
u2 = _xml(
_node(text="Wi-Fi", bounds="[0,100][1080,200]"),
_node(**{"content-desc": "Back", "bounds": "[0,0][120,120]"}),
)
helper = _xml(
_node(text="Wi-Fi", bounds="[2,101][1078,199]"),
_node(**{"content-desc": "Back", "bounds": "[0,0][120,120]"}),
)
report = compare_dumps(helper, u2, 1080, 2400)
assert report["ok"], report["problems"]
assert report["match"]["recall"] == 1.0 and report["match"]["precision"] == 1.0
@pytest.mark.parametrize(
"helper_extra, expected",
[
(_node(text="Hidden drawer item", bounds="[-1080,300][0,400]"), "negative bounds"),
(_node(text="Next page", bounds="[1080,300][2160,400]"), "outside the screen"),
(
"".join(
_node(text=f"Ghost {i}", bounds=f"[0,{i * 50}][100,{i * 50 + 40}]")
for i in range(8)
),
"not on screen",
),
],
)
def test_parity_flags_elements_uiautomator_would_not_show(helper_extra, expected):
u2 = _xml(_node(text="Wi-Fi", bounds="[0,100][1080,200]"))
helper = _xml(_node(text="Wi-Fi", bounds="[0,100][1080,200]"), helper_extra)
report = compare_dumps(helper, u2, 1080, 2400)
assert not report["ok"]
assert any(expected in problem for problem in report["problems"]), report["problems"]
def test_parity_flags_missing_elements():
u2 = _xml(
_node(text="Wi-Fi", bounds="[0,100][1080,200]"),
_node(text="Bluetooth", bounds="[0,200][1080,300]"),
)
helper = _xml(_node(text="Wi-Fi", bounds="[0,100][1080,200]"))
report = compare_dumps(helper, u2, 1080, 2400)
assert not report["ok"]
assert report["match"]["missing_in_helper"] == [
"'Bluetooth' at {'left': 0, 'top': 200, 'right': 1080, 'bottom': 300}"
]