Files
Artemis-Eapil/tests/unit/mcp/test_native_actions.py
T

405 lines
15 KiB
Python

"""Native action input contracts and geometry, without dispatching device input."""
from copy import deepcopy
import math
import pytest
from pydantic import ValidationError
from artemis.mcp.native_actions import (
NATIVE_REQUESTS,
ControlActionRequest,
ControlSelector,
PinchRequest,
TouchGestureRequest,
WaitUntilRequest,
plan_pinch,
plan_touch,
validate_native_action,
)
def touch(**overrides):
return {
"frames": [
{"at_ms": 0, "points": [[100, 200]]},
{"at_ms": 400, "points": [[300, 600]]},
],
"coordinate_space": "pixel",
"target_description": "observed canvas",
**overrides,
}
def pinch(**overrides):
return {
"center": [200, 400],
"start_span": 100,
"end_span": 200,
"coordinate_space": "pixel",
"target_description": "observed canvas",
**overrides,
}
VALID_REQUESTS = {
"pinch": pinch(),
"touch_gesture": touch(),
"read_control": {"selector": {"resource_id": "fixture:id/range"}},
"control_action": {
"selector": {"resource_id": "fixture:id/range"},
"action": "set_progress",
"value": 37.5,
},
"wait_until": {"selector": {"text": "Ready"}, "state": "visible"},
"scroll_to": {
"container": {"resource_id": "fixture:id/list"},
"target": {"text": "Destination"},
},
}
@pytest.mark.parametrize("name", VALID_REQUESTS)
def test_every_native_request_validates_before_dispatch_and_forbids_extra_fields(name):
args = deepcopy(VALID_REQUESTS[name])
assert validate_native_action(name, args, 400, 800).model_dump(exclude_none=True)
with pytest.raises(ValidationError):
validate_native_action(name, {**args, "coordinate_spce": "pixel"}, 400, 800)
@pytest.mark.parametrize(
"selector",
[
{},
{"bounds": [0, 0, 10, 10]},
{"text": ""},
{"text": 123},
{"resource_id": "item", "resourceId": "typo"},
{"resource_id": "item", "bounds": [0, 0, 10]},
{"resource_id": "item", "bounds": [0, 0, 10, 10, 20]},
{"resource_id": "item", "bounds": [10, 0, 10, 20]},
{"resource_id": "item", "bounds": [20, 0, 10, 20]},
{"resource_id": "item", "bounds": [0, 0, 10, 0]},
{"resource_id": "item", "bounds": [False, 0, 10, 10]},
{"resource_id": "item", "bounds": [0, 0, 10.0, 10]},
{"resource_id": "item", "bounds": [0, 0, "10", 10]},
],
)
def test_selectors_require_observed_semantics_and_strict_nonempty_bounds(selector):
with pytest.raises(ValidationError):
ControlSelector.model_validate(selector)
def test_exact_selector_strings_keep_whitespace_and_do_not_choose_one_attribute():
observed = {
"resource_id": "fixture:id/editor",
"text": " A😀B \n",
"content_desc": " description \t",
"class_name": "android.widget.EditText",
"package_name": "fixture",
"bounds": [-5, 20, 100, 60],
}
assert ControlSelector.model_validate(observed).model_dump(exclude_none=True) == observed
@pytest.mark.parametrize(
"frames",
[
[{"at_ms": 0, "points": [[1, 2]]}],
[{"at_ms": i, "points": [[1, 2]]} for i in range(33)],
[{"at_ms": 1, "points": [[1, 2]]}, {"at_ms": 2, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 0, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": -1, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 10001, "points": [[2, 3]]}],
[{"at_ms": 0, "points": []}, {"at_ms": 10, "points": []}],
[{"at_ms": 0, "points": [[1, 2]] * 6}, {"at_ms": 10, "points": [[2, 3]] * 6}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 10, "points": [[2, 3], [4, 5]]}],
[{"at_ms": 0, "points": [[1]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2, 3]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[True, 2]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1.0, 2]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [["1", 2]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": "10", "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 10.0, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": True, "points": [[2, 3]]}],
[
{"at_ms": 0, "points": [[1, 2]], "pressure": 0.5},
{"at_ms": 10, "points": [[2, 3]]},
],
],
)
def test_touch_timelines_and_pointer_shapes_are_strict(frames):
with pytest.raises(ValidationError):
TouchGestureRequest.model_validate(touch(frames=frames))
def test_pixel_keyframes_preserve_holds_and_every_intermediate_segment():
frames = [
{"at_ms": 0, "points": [[191, 501], [211, 502]]},
{"at_ms": 150, "points": [[191, 501], [211, 502]]},
{"at_ms": 900, "points": [[192, 532], [212, 533]]},
]
planned = plan_touch(TouchGestureRequest.model_validate(touch(frames=frames)), 400, 800)
assert planned["frames"] == frames
assert planned["coordinate_space"] == "pixel"
assert planned["screen_size"] == [400, 800]
def test_full_supported_pointer_count_frame_count_and_duration_remain_valid():
frames = [
{
"at_ms": round(frame * 10000 / 31),
"points": [[50 + finger * 30, 100 + frame] for finger in range(5)],
}
for frame in range(32)
]
request = validate_native_action("touch_gesture", touch(frames=frames), 400, 800)
assert plan_touch(request, 400, 800)["frames"] == frames
def test_normalized_points_use_each_display_axis_and_accept_the_nominal_edges():
request = TouchGestureRequest.model_validate(
touch(
coordinate_space="normalized",
frames=[
{"at_ms": 0, "points": [[0, 0]]},
{"at_ms": 100, "points": [[500, 500]]},
{"at_ms": 200, "points": [[1000, 1000]]},
],
)
)
assert [f["points"] for f in plan_touch(request, 720, 1600)["frames"]] == [
[[0, 0]],
[[360, 800]],
[[719, 1599]],
]
@pytest.mark.parametrize(
"space,point",
[
("pixel", [-1, 0]),
("pixel", [400, 0]),
("pixel", [0, 800]),
("normalized", [-1, 500]),
("normalized", [1001, 500]),
("normalized", [500, 1001]),
],
)
def test_offscreen_paths_fail_before_any_coordinate_can_be_clamped(space, point):
args = touch(
coordinate_space=space,
frames=[{"at_ms": 0, "points": [[100, 100]]}, {"at_ms": 100, "points": [point]}],
)
with pytest.raises(ValueError, match="outside"):
validate_native_action("touch_gesture", args, 400, 800)
@pytest.mark.parametrize("dimensions", [(0, 800), (400, 0), (-1, 800)])
def test_invalid_display_dimensions_cannot_produce_a_plan(dimensions):
with pytest.raises(ValueError, match="positive"):
validate_native_action("touch_gesture", touch(), *dimensions)
@pytest.mark.parametrize(
"before,after",
[
([[100, 100], [300, 100]], [[300, 100], [100, 100]]),
([[100, 100], [300, 100]], [[200, 200], [200, 200]]),
([[100, 100], [100, 100]], [[100, 200], [300, 200]]),
],
)
def test_fingers_may_not_merge_at_frames_or_between_frames(before, after):
with pytest.raises(ValueError, match="remain distinct"):
validate_native_action(
"touch_gesture",
touch(frames=[{"at_ms": 0, "points": before}, {"at_ms": 100, "points": after}]),
400,
800,
)
def test_paths_can_cross_geometrically_when_fingers_reach_the_crossing_at_different_times():
args = touch(
frames=[
{"at_ms": 0, "points": [[0, 100], [100, 0]]},
{"at_ms": 400, "points": [[200, 100], [100, 400]]},
]
)
assert (
plan_touch(TouchGestureRequest.model_validate(args), 400, 800)["frames"] == args["frames"]
)
@pytest.mark.parametrize("dimensions", [(720, 1600), (1600, 720)])
def test_normalized_pinch_spans_use_short_edge_without_stretching_diagonal_angles(dimensions):
width, height = dimensions
request = PinchRequest.model_validate(
pinch(
center=[500, 500],
start_span=200,
end_span=400,
angle_degrees=45,
coordinate_space="normalized",
)
)
planned = plan_pinch(request, width, height)
for frame, expected_span in zip(planned["frames"], [144, 288], strict=True):
first, second = frame["points"]
assert second[0] - first[0] == second[1] - first[1]
assert math.dist(first, second) == pytest.approx(expected_span, abs=1.5)
assert [(first[i] + second[i]) / 2 for i in (0, 1)] == [width / 2, height / 2]
@pytest.mark.parametrize("rotation", [-360, -180, -90, 90, 180, 360])
def test_rotation_uses_curved_synchronized_keyframes_and_keeps_both_fingers_down(rotation):
planned = plan_pinch(
PinchRequest.model_validate(
pinch(center=[500, 500], start_span=200, end_span=200, rotation_degrees=rotation)
),
1000,
1000,
)
frames = planned["frames"]
assert len(frames) == abs(rotation) // 15 + 1
assert frames[0]["points"] == [[400, 500], [600, 500]]
assert frames[0]["at_ms"] == 0 and frames[-1]["at_ms"] == 800
assert all(a["at_ms"] < b["at_ms"] for a, b in zip(frames, frames[1:]))
assert all(len(frame["points"]) == 2 for frame in frames)
quarter_turn = frames[6]["points"][1]
assert quarter_turn == [500, 600 if rotation > 0 else 400]
for frame in frames:
assert math.dist(*frame["points"]) == pytest.approx(200, abs=1.5)
@pytest.mark.parametrize(
"overrides",
[
{"start_span": 0},
{"end_span": -1},
{"start_span": True},
{"end_span": "200"},
{"end_span": 100},
{"rotation_degrees": 361},
{"angle_degrees": float("nan")},
{"angle_degrees": float("inf")},
{"duration_ms": 0},
{"duration_ms": 10001},
{"duration_ms": 1.0},
{"duration_ms": 20, "rotation_degrees": 360},
{"center": [0, 0]},
{"coordinate_space": "screen"},
],
)
def test_invalid_pinch_geometry_and_units_fail_during_planning(overrides):
with pytest.raises(ValueError):
validate_native_action("pinch", pinch(**overrides), 400, 800)
@pytest.mark.parametrize("action", ["set_text", "replace_selection"])
@pytest.mark.parametrize("text", ["", " A😀B \n\t"])
def test_precise_text_edits_preserve_empty_input_unicode_and_whitespace(action, text):
request = ControlActionRequest.model_validate(
{"selector": {"resource_id": "editor"}, "action": action, "text": text}
)
assert request.model_dump(mode="json", exclude_none=True)["text"] == text
def test_selection_offsets_are_preserved_as_utf16_units_without_python_character_conversion():
text = "A😀B"
end_of_emoji = len(text[:2].encode("utf-16-le")) // 2
request = ControlActionRequest.model_validate(
{
"selector": {"text": text},
"action": "set_selection",
"selection_start": 1,
"selection_end": end_of_emoji,
}
)
wire = request.model_dump(mode="json", exclude_none=True)
assert (wire["selection_start"], wire["selection_end"]) == (1, 3)
# Bounds and surrogate-pair boundaries require the fresh native text, checked by Java.
assert end_of_emoji != len(text[:2])
@pytest.mark.parametrize(
"args",
[
{"action": "click", "value": 1},
{"action": "click", "text": "extra"},
{"action": "click", "selection_start": 0, "selection_end": 1},
{"action": "set_text"},
{"action": "set_text", "text": "x" * 65537},
{"action": "replace_selection"},
{"action": "set_progress"},
{"action": "set_progress", "value": True},
{"action": "set_progress", "value": "3"},
{"action": "set_progress", "value": float("nan")},
{"action": "set_progress", "value": float("inf")},
{"action": "set_selection", "selection_start": 0},
{"action": "set_selection", "selection_start": -1, "selection_end": 1},
{"action": "set_selection", "selection_start": 2, "selection_end": 1},
{"action": "set_selection", "selection_start": False, "selection_end": 1},
{"action": "set_selection", "selection_start": 0, "selection_end": 1.0},
{"action": "set_selection", "selection_start": 0, "selection_end": "1"},
{"action": "click", "expected_token": "a" * 63},
{"action": "click", "expected_token": "G" * 64},
{"action": "toggle"},
],
)
def test_control_actions_reject_coercion_wrong_arguments_and_invalid_tokens(args):
with pytest.raises(ValidationError):
ControlActionRequest.model_validate({"selector": {"resource_id": "control"}, **args})
@pytest.mark.parametrize(
"state,extra",
[
("visible", {}),
("absent", {}),
("enabled", {}),
("disabled", {}),
("checked", {}),
("unchecked", {}),
("selected", {}),
("unselected", {}),
("text", {"expected_text": " exact 😀 \n"}),
("value", {"expected_value": 0}),
],
)
def test_nonstable_waits_allow_short_deadlines_without_overriding_stability_default(state, extra):
request = WaitUntilRequest.model_validate(
{"selector": {"resource_id": "control"}, "state": state, "timeout_ms": 100, **extra}
)
assert request.timeout_ms == 100 and request.stable_ms == 300
if state == "text":
assert request.expected_text == extra["expected_text"]
def test_stability_window_must_fit_the_deadline_only_for_stable_waits():
args = {"selector": {"resource_id": "control"}, "state": "stable", "timeout_ms": 100}
with pytest.raises(ValidationError, match="stable_ms must fit"):
WaitUntilRequest.model_validate(args)
assert WaitUntilRequest.model_validate({**args, "stable_ms": 100}).stable_ms == 100
@pytest.mark.parametrize(
"name,args",
[
("wait_until", {"state": "text"}),
("wait_until", {"state": "value"}),
("wait_until", {"state": "visible", "expected_text": "wrong predicate"}),
("wait_until", {"state": "visible", "tolerance": 1}),
("wait_until", {"state": "value", "expected_value": float("inf")}),
("wait_until", {"state": "visible", "timeout_ms": 0}),
("wait_until", {"state": "visible", "poll_interval_ms": 49}),
("scroll_to", {"max_scrolls": 0}),
("scroll_to", {"max_scrolls": 21}),
("scroll_to", {"timeout_ms": 100, "settle_ms": 200}),
("scroll_to", {"direction": "diagonal"}),
],
)
def test_condition_and_scroll_requests_reject_incompatible_or_unbounded_arguments(name, args):
with pytest.raises(ValidationError):
NATIVE_REQUESTS[name].model_validate({**VALID_REQUESTS[name], **args})