405 lines
15 KiB
Python
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})
|