153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
"""Model-visible selectors must round-trip to exact native matching safely."""
|
|
|
|
from copy import deepcopy
|
|
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from artemis.mcp.native_actions import ControlSelector
|
|
from artemis.mcp.native_executor import execute_native
|
|
from artemis.utils.ui_filter import filter_ui_hierarchy
|
|
from artemis.utils.visualization import format_minimal_list_with_elements
|
|
from tests.unit.utils.test_native_control_state import encode_node, hierarchy_node
|
|
|
|
|
|
def native_node(raw):
|
|
return {
|
|
"resource_id": raw.get("resource-id", ""),
|
|
"class_name": raw["class"],
|
|
"package_name": raw["package"],
|
|
"bounds": [30, 549, 110, 629],
|
|
"text": "17:42:20",
|
|
"content_desc": "current 17:42:20",
|
|
"visible": True,
|
|
"actions": ["set_selection"],
|
|
}
|
|
|
|
|
|
def exact_native(nodes):
|
|
async def inspect(command, args, *, timeout=None):
|
|
assert command == "inspect_control", "A failed read must never dispatch input"
|
|
matches = [
|
|
node
|
|
for node in nodes
|
|
if all(node.get(key) == value for key, value in args["selector"].items())
|
|
]
|
|
if len(matches) == 1:
|
|
return {"success": True, "execution": "observed", "code": "OK", "node": matches[0]}
|
|
return {
|
|
"success": False,
|
|
"execution": "not_started",
|
|
"code": "NOT_FOUND" if not matches else "AMBIGUOUS",
|
|
"error": "Exact native selector did not resolve uniquely",
|
|
}
|
|
|
|
return AsyncMock(side_effect=inspect)
|
|
|
|
|
|
@pytest.mark.parametrize("encoding", ["xml", "json"])
|
|
@pytest.mark.parametrize("screen", [(720, 1600), (1080, 2400), (1600, 720)])
|
|
@pytest.mark.asyncio
|
|
async def test_copy_visible_selector_reads_a_changing_value_without_lossy_ui_bounds(
|
|
encoding, screen
|
|
):
|
|
raw = hierarchy_node(
|
|
text="17:42:17",
|
|
**{
|
|
"class": "android.widget.TextView",
|
|
"content-desc": "current 17:42:17",
|
|
"bounds": "[30,549][110,629]",
|
|
"range": None,
|
|
"actions": ["set_selection"],
|
|
},
|
|
)
|
|
source = encode_node(raw, encoding)
|
|
original = deepcopy(source)
|
|
text, elements, _ = format_minimal_list_with_elements([source], *screen)
|
|
selector = json.loads(text.split(" | native_selector: ", 1)[1])
|
|
assert (
|
|
selector
|
|
== elements[0]["native_selector"]
|
|
== {
|
|
"resource_id": raw["resource-id"],
|
|
"class_name": raw["class"],
|
|
"package_name": raw["package"],
|
|
}
|
|
)
|
|
assert source == original
|
|
ControlSelector.model_validate(selector)
|
|
native = exact_native([native_node(raw)])
|
|
result = await execute_native("read_control", {"selector": selector}, *screen, native)
|
|
assert result["success"] and result["node"]["text"] == "17:42:20"
|
|
native.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.parametrize("resource_id", ["", "fixture:id/shared"])
|
|
@pytest.mark.asyncio
|
|
async def test_no_id_and_repeated_ids_use_original_pixels_to_resolve_each_node(resource_id):
|
|
rows = [
|
|
hierarchy_node(
|
|
text=f"row {i}",
|
|
**{
|
|
"resource-id": resource_id,
|
|
"bounds": f"[30,{549 + i * 100}][110,{629 + i * 100}]",
|
|
},
|
|
)
|
|
for i in range(2)
|
|
]
|
|
_, elements, _ = format_minimal_list_with_elements(rows, 720, 1600)
|
|
nodes = [
|
|
native_node(row) | {"bounds": [30, 549 + i * 100, 110, 629 + i * 100]}
|
|
for i, row in enumerate(rows)
|
|
]
|
|
native = exact_native(nodes)
|
|
for element, node in zip(elements, nodes, strict=True):
|
|
selector = element["native_selector"]
|
|
assert selector["bounds"] == node["bounds"]
|
|
result = await execute_native("read_control", {"selector": selector}, 720, 1600, native)
|
|
assert result["success"] and result["node"]["bounds"] == node["bounds"]
|
|
|
|
|
|
def test_clipped_hit_area_does_not_replace_the_original_native_identity_bounds():
|
|
raw = hierarchy_node(
|
|
text="partly visible",
|
|
**{
|
|
"resource-id": "",
|
|
"bounds": "[-20,-40][300,60]",
|
|
},
|
|
)
|
|
filtered = filter_ui_hierarchy([raw], screen_width=720, screen_height=1600)
|
|
_, elements, _ = format_minimal_list_with_elements(filtered, 720, 1600)
|
|
assert elements[0]["bounds"] == [0, 0, 300, 60]
|
|
assert elements[0]["native_selector"]["bounds"] == [-20, -40, 300, 60]
|
|
ControlSelector.model_validate(elements[0]["native_selector"])
|
|
|
|
|
|
def test_ocr_only_geometry_is_not_presented_as_a_native_selector():
|
|
text, elements, _ = format_minimal_list_with_elements(
|
|
[{"text": "17:42:17", "class": "[OCR]", "bounds": "[30,549][110,629]"}],
|
|
720,
|
|
1600,
|
|
)
|
|
assert "native_selector" not in text
|
|
assert "native_selector" not in elements[0]
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["read_control", "control_action"])
|
|
@pytest.mark.parametrize("extra", [{"text": "17:42:17"}, {"bounds": [41, 343, 152, 393]}])
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_stale_text_or_wrong_bounds_are_not_silently_dropped_or_retried(
|
|
action, extra
|
|
):
|
|
raw = hierarchy_node()
|
|
native = exact_native([native_node(raw)])
|
|
request = {"selector": {"resource_id": raw["resource-id"], **extra}}
|
|
if action == "control_action":
|
|
request["action"] = "click"
|
|
result = await execute_native(action, request, 720, 1600, native)
|
|
assert not result["success"] and result["code"] == "NOT_FOUND"
|
|
assert result["execution"] == "not_started"
|
|
assert native.await_args.args[1]["selector"] == request["selector"]
|
|
native.assert_awaited_once()
|