feat(driver): add W3C actions helper and Driver.swipe_path
This commit is contained in:
@@ -60,6 +60,11 @@ class FakeDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def swipe_path(
|
||||||
|
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def input(self, text: str) -> None:
|
def input(self, text: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class FakeDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def swipe_path(
|
||||||
|
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def input(self, text: str) -> None:
|
def input(self, text: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -129,6 +129,17 @@ class AndroidDriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("text input failed") from 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:
|
def launch(self, app_id: str) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class Driver(ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Swipe between two screen coordinates."""
|
"""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
|
@abstractmethod
|
||||||
def input(self, text: str) -> None:
|
def input(self, text: str) -> None:
|
||||||
"""Input text into the current focused field."""
|
"""Input text into the current focused field."""
|
||||||
|
|||||||
@@ -120,6 +120,17 @@ class WDADriver(Driver):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise DriverError("text input failed") from 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:
|
def launch(self, app_id: str) -> None:
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class FakeDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.calls.append(("swipe", (start_x, start_y, end_x, end_y, duration_ms)))
|
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:
|
def input(self, text: str) -> None:
|
||||||
self.calls.append(("input", (text,)))
|
self.calls.append(("input", (text,)))
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ def test_connect_failure_raises_device_offline_and_clears_client() -> None:
|
|||||||
("unlock", {}),
|
("unlock", {}),
|
||||||
("disconnect", {}),
|
("disconnect", {}),
|
||||||
("long_press", {"x": 1, "y": 2}),
|
("long_press", {"x": 1, "y": 2}),
|
||||||
|
("swipe_path", {"waypoints": [(0, 0), (1, 1)], "duration_ms": 100}),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_operation_before_connect_raises_device_offline(
|
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:
|
def test_swipe_uses_drag_gesture_with_converted_speed() -> None:
|
||||||
driver = _connected_driver()
|
driver = _connected_driver()
|
||||||
# 100px horizontal drag over 100ms => 100 / 0.1 = 1000 px/s
|
# 100px horizontal drag over 100ms => 100 / 0.1 = 1000 px/s
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class TreeFailingDriver(Driver):
|
|||||||
) -> None:
|
) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def swipe_path(
|
||||||
|
self, waypoints: list[tuple[float, float]], duration_ms: int
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
def input(self, text: str) -> None:
|
def input(self, text: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user