diff --git a/apps/device-host-agent/tests/test_e2e.py b/apps/device-host-agent/tests/test_e2e.py index a475366..068c986 100644 --- a/apps/device-host-agent/tests/test_e2e.py +++ b/apps/device-host-agent/tests/test_e2e.py @@ -60,6 +60,11 @@ class FakeDriver(Driver): ) -> None: return None + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + return None + def input(self, text: str) -> None: return None diff --git a/apps/device-host-agent/tests/test_execution.py b/apps/device-host-agent/tests/test_execution.py index 3278e01..fb902b1 100644 --- a/apps/device-host-agent/tests/test_execution.py +++ b/apps/device-host-agent/tests/test_execution.py @@ -46,6 +46,11 @@ class FakeDriver(Driver): ) -> None: return None + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + return None + def input(self, text: str) -> None: return None diff --git a/driver/_w3c_actions.py b/driver/_w3c_actions.py new file mode 100644 index 0000000..d4f7904 --- /dev/null +++ b/driver/_w3c_actions.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +POINTER_ID = "finger1" + + +def _pointer(*actions: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "type": "pointer", + "id": POINTER_ID, + "parameters": {"pointerType": "touch"}, + "actions": list(actions), + } + ] + + +def build_swipe_actions( + waypoints: list[tuple[float, float]], total_duration_ms: int +) -> list[dict[str, Any]]: + """W3C actions moving through ``waypoints`` in one continuous touch.""" + if len(waypoints) < 2: + raise ValueError("swipe path requires at least two waypoints") + segments = len(waypoints) - 1 + per_segment = max(1, total_duration_ms // segments) + sx, sy = waypoints[0] + actions: list[dict[str, Any]] = [ + {"type": "pointerMove", "duration": 0, "x": int(sx), "y": int(sy)}, + {"type": "pointerDown", "button": 0}, + ] + for point in waypoints[1:]: + actions.append( + { + "type": "pointerMove", + "duration": per_segment, + "x": int(point[0]), + "y": int(point[1]), + } + ) + ex, ey = waypoints[-1] + actions.append( + {"type": "pointerUp", "button": 0, "x": int(ex), "y": int(ey)} + ) + return _pointer(*actions) + + +def build_double_tap_actions( + x: float, y: float, interval_ms: int +) -> list[dict[str, Any]]: + actions = [ + {"type": "pointerMove", "duration": 0, "x": int(x), "y": int(y)}, + {"type": "pointerDown", "button": 0}, + {"type": "pointerUp", "button": 0}, + {"type": "pause", "duration": max(1, interval_ms)}, + {"type": "pointerDown", "button": 0}, + {"type": "pointerUp", "button": 0}, + ] + return _pointer(*actions) + + +def perform_actions(client: Any, actions: list[dict[str, Any]]) -> None: + """Send a W3C actions payload via the Appium/Selenium command seam. + + Import is lazy so the pure payload builders stay importable without + selenium on the path (used by unit tests). + """ + from selenium.webdriver.remote.command import Command + + client.execute(Command.W3C_ACTIONS, {"actions": actions}) \ No newline at end of file diff --git a/driver/android_driver.py b/driver/android_driver.py index 462403e..cbc52f7 100644 --- a/driver/android_driver.py +++ b/driver/android_driver.py @@ -129,6 +129,17 @@ class AndroidDriver(Driver): except Exception as exc: raise DriverError("text input failed") from exc + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + from driver._w3c_actions import build_swipe_actions, perform_actions + + client = self._require_client() + try: + perform_actions(client, build_swipe_actions(waypoints, duration_ms)) + except Exception as exc: + raise DriverError("swipe_path failed") from exc + def launch(self, app_id: str) -> None: client = self._require_client() try: diff --git a/driver/base.py b/driver/base.py index 07e59a1..d8bee23 100644 --- a/driver/base.py +++ b/driver/base.py @@ -45,6 +45,12 @@ class Driver(ABC): ) -> None: """Swipe between two screen coordinates.""" + @abstractmethod + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + """Swipe through a sequence of waypoints in one continuous touch.""" + @abstractmethod def input(self, text: str) -> None: """Input text into the current focused field.""" diff --git a/driver/wda_driver.py b/driver/wda_driver.py index 1db8f8f..70d06ea 100644 --- a/driver/wda_driver.py +++ b/driver/wda_driver.py @@ -120,6 +120,17 @@ class WDADriver(Driver): except Exception as exc: raise DriverError("text input failed") from exc + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + from driver._w3c_actions import build_swipe_actions, perform_actions + + client = self._require_client() + try: + perform_actions(client, build_swipe_actions(waypoints, duration_ms)) + except Exception as exc: + raise DriverError("swipe_path failed") from exc + def launch(self, app_id: str) -> None: client = self._require_client() try: diff --git a/tests/fakes.py b/tests/fakes.py index b3dab39..4533675 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -64,6 +64,11 @@ class FakeDriver(Driver): ) -> None: self.calls.append(("swipe", (start_x, start_y, end_x, end_y, duration_ms))) + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + self.calls.append(("swipe_path", (tuple(waypoints), duration_ms))) + def input(self, text: str) -> None: self.calls.append(("input", (text,))) diff --git a/tests/test_android_driver.py b/tests/test_android_driver.py index 4574c23..f53f1f1 100644 --- a/tests/test_android_driver.py +++ b/tests/test_android_driver.py @@ -104,6 +104,7 @@ def test_connect_failure_raises_device_offline_and_clears_client() -> None: ("unlock", {}), ("disconnect", {}), ("long_press", {"x": 1, "y": 2}), + ("swipe_path", {"waypoints": [(0, 0), (1, 1)], "duration_ms": 100}), ], ) def test_operation_before_connect_raises_device_offline( @@ -229,6 +230,23 @@ def test_home_uses_presskey_with_home_keycode() -> None: ) +def test_swipe_path_sends_w3c_actions() -> None: + from driver._w3c_actions import build_swipe_actions + + driver = _connected_driver() + waypoints = [(0, 0), (50, 5), (100, 0)] + driver.swipe_path(waypoints, 600) + args = driver._client.execute.call_args.args + assert args[1] == {"actions": build_swipe_actions(waypoints, 600)} + + +def test_swipe_path_wraps_exception_into_driver_error() -> None: + driver = _connected_driver() + driver._client.execute.side_effect = RuntimeError("boom") + with pytest.raises(DriverError): + driver.swipe_path([(0, 0), (1, 1)], 100) + + def test_swipe_uses_drag_gesture_with_converted_speed() -> None: driver = _connected_driver() # 100px horizontal drag over 100ms => 100 / 0.1 = 1000 px/s diff --git a/tests/test_describe_screen.py b/tests/test_describe_screen.py index 39e7b00..99aad5f 100644 --- a/tests/test_describe_screen.py +++ b/tests/test_describe_screen.py @@ -46,6 +46,11 @@ class TreeFailingDriver(Driver): ) -> None: return None + def swipe_path( + self, waypoints: list[tuple[float, float]], duration_ms: int + ) -> None: + return None + def input(self, text: str) -> None: return None diff --git a/tests/test_w3c_actions.py b/tests/test_w3c_actions.py new file mode 100644 index 0000000..4bfa206 --- /dev/null +++ b/tests/test_w3c_actions.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from driver._w3c_actions import ( + build_double_tap_actions, + build_swipe_actions, +) + + +def test_build_swipe_actions_shape_and_duration_split(): + actions = build_swipe_actions([(0, 0), (50, 10), (100, 0)], 600) + assert len(actions) == 1 + pointer = actions[0] + assert pointer["type"] == "pointer" + assert pointer["id"] == "finger1" + assert pointer["parameters"]["pointerType"] == "touch" + seq = pointer["actions"] + types = [a["type"] for a in seq] + assert types == [ + "pointerMove", + "pointerDown", + "pointerMove", + "pointerMove", + "pointerUp", + ] + # 600ms over 2 segments => 300ms per move after pointerDown + moves_after_down = [a for a in seq if a["type"] == "pointerMove"][1:] + assert all(m["duration"] == 300 for m in moves_after_down) + assert seq[0]["x"] == 0 and seq[0]["y"] == 0 + assert seq[-1]["x"] == 100 and seq[-1]["y"] == 0 + + +def test_build_swipe_actions_requires_two_waypoints(): + with pytest.raises(ValueError): + build_swipe_actions([(1, 1)], 100) + + +def test_build_double_tap_actions_shape(): + actions = build_double_tap_actions(10, 20, interval_ms=80) + seq = actions[0]["actions"] + types = [a["type"] for a in seq] + assert types == [ + "pointerMove", + "pointerDown", + "pointerUp", + "pause", + "pointerDown", + "pointerUp", + ] + pause = next(a for a in seq if a["type"] == "pause") + assert pause["duration"] == 80 + assert seq[0]["x"] == 10 and seq[0]["y"] == 20 \ No newline at end of file