"""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})