feat(android): add native controls and continuous multi-touch

This commit is contained in:
chenmingxuan committed 2026-11-02 21:33:45 +08:00
1 parent 3dd96078db
commit 4b12ae7728
70 files changed
+13105 -179

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
"""Mixed native sequences exercise production preflight and dispatch over mocks."""
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from artemis.mcp.action_manifest import DEVICE_ACTIONS
from artemis.mcp.actuators import MockActuator
SELECTOR = {"resource_id": "fixture:id/editor"}
TOKEN = "a" * 64
def wait_step():
return {"action": "wait_until", "selector": SELECTOR, "state": "visible", "timeout_ms": 100}
def control_step():
return {
"action": "control_action",
"request": {"selector": SELECTOR, "action": "set_text", "text": " exact 😀 \n"},
}
def touch_step():
return {
"action": "touch_gesture",
"frames": [
{"at_ms": 0, "points": [[250, 500]]},
{"at_ms": 150, "points": [[250, 500]]},
{"at_ms": 400, "points": [[500, 750]]},
],
"coordinate_space": "normalized",
"target_description": "observed canvas",
}
def click_step():
return {"action": "click", "target": [50, 100], "target_description": "observed button"}
def observed():
return {
"success": True,
"execution": "observed",
"code": "OK",
"node": {
"token": TOKEN,
"content_signature": "b" * 64,
"visible": True,
"enabled": True,
"editable": True,
"password": False,
"text": " A😀B ",
"selection_start": 1,
"selection_end": 3,
"actions": ["set_text", "set_selection"],
},
}
@pytest.fixture(autouse=True)
def sequence_limit(monkeypatch):
monkeypatch.setattr(
"artemis.config.agent.load_agent_config",
lambda: SimpleNamespace(
pro=SimpleNamespace(execution=SimpleNamespace(max_burst_actions=10))
),
)
def actuator_with_native(capabilities=DEVICE_ACTIONS):
actuator = MockActuator(width=720, height=1600, capabilities=capabilities)
async def native(command, args, *, timeout=None):
if command == "inspect_control":
return observed()
return {
"success": True,
"execution": "completed",
"code": "OK",
"native_marker": command,
}
actuator.controller.native_request = AsyncMock(side_effect=native)
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
actuator.controller.swipe_coords = AsyncMock()
return actuator
@pytest.mark.asyncio
@pytest.mark.parametrize(
"bad_member",
[
{**wait_step(), "timeout_ms": 0},
{**wait_step(), "state": "text"},
{**wait_step(), "timeout_ms": 3001},
{"action": "control_action", "request": {"selector": SELECTOR, "action": "set_progress"}},
{
"action": "control_action",
"request": {"selector": SELECTOR, "action": "set_text", "text": "x" * 1025},
},
{
"action": "control_action",
"request": {"selector": SELECTOR, "action": "click", "text": "unexpected"},
},
{
**touch_step(),
"frames": [
{"at_ms": 0, "points": [[100, 100]]},
{"at_ms": 400, "points": [[1001, 100]]},
],
},
{
**touch_step(),
"frames": [
{"at_ms": 0, "points": [[100, 100], [300, 100]]},
{"at_ms": 400, "points": [[300, 100], [100, 100]]},
],
},
{
**touch_step(),
"frames": [
{"at_ms": 0, "points": [[100, 100]]},
{"at_ms": 400, "points": [[200, 200], [300, 300]]},
],
},
],
)
async def test_all_native_parameters_and_paths_are_preflighted_before_even_the_first_click(
bad_member,
):
actuator = actuator_with_native()
result = await actuator.action_sequence(
[click_step(), wait_step(), control_step(), touch_step(), bad_member]
)
assert not result.ok and result.code.value == "INVALID_ARGS"
actuator.controller.native_request.assert_not_awaited()
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
assert actuator.action_history == []
@pytest.mark.asyncio
@pytest.mark.parametrize("missing_capability", ["wait_until", "control_action", "touch_gesture"])
async def test_every_native_member_capability_is_checked_before_any_input(missing_capability):
actuator = actuator_with_native(DEVICE_ACTIONS - {missing_capability})
result = await actuator.action_sequence(
[click_step(), wait_step(), control_step(), touch_step()]
)
assert not result.ok and result.code.value == "UNSUPPORTED"
assert result.metadata == {"execution": "not_started", "side_effect": "none"}
actuator.controller.native_request.assert_not_awaited()
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
async def test_mixed_wait_control_and_touch_keep_native_receipts_and_exact_text_in_order():
actuator = actuator_with_native()
steps = [wait_step(), control_step(), touch_step()]
original = deepcopy(steps)
result = await actuator.action_sequence(steps)
assert result.ok and result.metadata["side_effect"] == "possible"
assert steps == original
calls = actuator.controller.native_request.await_args_list
assert [call.args[0] for call in calls] == [
"inspect_control",
"inspect_control",
"control_action",
"touch_gesture",
]
assert calls[2].args[1]["text"] == " exact 😀 \n"
assert calls[2].args[1]["expected_token"] == TOKEN
receipts = result.metadata["sequence_results"]
assert [item["action"] for item in receipts] == [
"wait_until",
"control_action",
"touch_gesture",
]
assert receipts[0]["read_only"] and receipts[0]["side_effect"] == "none"
assert receipts[0]["native_execution"] == "observed"
assert receipts[0]["node"]["selection_end"] == 3
assert receipts[1]["native_receipt"]["native_marker"] == "control_action"
assert receipts[2]["native_receipt"]["native_marker"] == "touch_gesture"
assert receipts[0]["ended_ms"] <= receipts[1]["started_ms"] <= receipts[2]["started_ms"]
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [False, True])
async def test_readonly_native_wait_never_counts_as_an_input_side_effect(failure):
actuator = actuator_with_native()
if failure:
actuator.controller.native_request.return_value = None
actuator.controller.native_request.side_effect = lambda *args, **kwargs: {
"success": False,
"execution": "not_started",
"code": "UNAVAILABLE",
"error": "No active native tree",
}
result = await actuator.action_sequence([wait_step()])
assert result.ok is not failure
assert result.metadata["side_effect"] == "none"
receipt = result.metadata["sequence_results"][0]
assert receipt["read_only"] and receipt["side_effect"] == "none"
assert receipt["native_execution"] == ("not_started" if failure else "observed")
assert [call.args[0] for call in actuator.controller.native_request.await_args_list] == [
"inspect_control"
]
@pytest.mark.asyncio
@pytest.mark.parametrize("unknown_member", ["control_action", "touch_gesture"])
async def test_unknown_native_input_stops_every_remaining_member_and_preserves_original_receipt(
unknown_member,
):
actuator = actuator_with_native()
uncertain = {
"success": False,
"execution": "unknown",
"code": "TRANSPORT_ERROR",
"error": "Reply lost; do not replay input",
"native_marker": "unacknowledged input",
}
async def native(command, args, *, timeout=None):
return observed() if command == "inspect_control" else uncertain
actuator.controller.native_request.side_effect = native
member = control_step() if unknown_member == "control_action" else touch_step()
result = await actuator.action_sequence([wait_step(), member, click_step(), touch_step()])
assert not result.ok and result.metadata["side_effect"] == "possible"
receipts = result.metadata["sequence_results"]
assert receipts[1]["execution"] == "unknown"
assert receipts[1]["native_execution"] == "unknown"
assert receipts[1]["native_receipt"] == uncertain
assert [item["execution"] for item in receipts[2:]] == ["not_started", "not_started"]
writes = [
call.args[0]
for call in actuator.controller.native_request.await_args_list
if call.args[0] != "inspect_control"
]
assert writes == [unknown_member]
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
async def test_normalized_touch_in_sequence_is_mapped_to_physical_pixels_exactly_once():
actuator = actuator_with_native()
result = await actuator.action_sequence([touch_step()])
assert result.ok
command, sent = actuator.controller.native_request.await_args.args
assert command == "touch_gesture" and sent["screen_size"] == [720, 1600]
expected = [
{"at_ms": 0, "points": [[180, 800]]},
{"at_ms": 150, "points": [[180, 800]]},
{"at_ms": 400, "points": [[360, 1200]]},
]
assert sent["frames"] == expected
receipt = result.metadata["sequence_results"][0]
assert receipt["plan"]["frames"] == expected
assert receipt["plan"]["coordinate_space"] == "pixel"
assert receipt["args"]["coordinate_space"] == "normalized"
assert receipt["args"]["frames"] == touch_step()["frames"]