diff --git a/tests/test_humanize.py b/tests/test_humanize.py new file mode 100644 index 0000000..1276aaa --- /dev/null +++ b/tests/test_humanize.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import math +import random + + +from tools.humanize import ( + HumanizeConfig, + jitter_duration, + jitter_point, + load_humanize_config, + set_rng, + swipe_waypoints, +) + + +def test_jitter_point_stays_within_radius(): + rng = random.Random(42) + for _ in range(500): + x, y = jitter_point(100, 200, radius=5.0, rng=rng) + assert math.hypot(x - 100, y - 200) <= 5.0 + + +def test_jitter_point_centered_on_input(): + rng = random.Random(0) + xs = [jitter_point(0, 0, radius=4.0, rng=rng)[0] for _ in range(4000)] + assert abs(sum(xs) / len(xs)) < 0.3 + + +def test_jitter_duration_within_spread(): + rng = random.Random(3) + for _ in range(200): + v = jitter_duration(1000, spread=0.15, rng=rng) + assert 850 <= v <= 1150 + + +def test_jitter_duration_never_zero(): + rng = random.Random(9) + for _ in range(100): + assert jitter_duration(1, spread=0.5, rng=rng) >= 1 + + +def test_swipe_waypoints_endpoints_exact_interior_offset(): + rng = random.Random(7) + pts = swipe_waypoints((0, 0), (100, 0), curvature=0.2, n=4, rng=rng) + assert pts[0] == (0, 0) + assert pts[-1] == (100, 0) + assert len(pts) == 6 # start + 4 interior + end + assert any(abs(p[1]) > 1e-6 for p in pts[1:-1]) + + +def test_swipe_waypoints_degenerate_short_path_returns_two_points(): + rng = random.Random(1) + pts = swipe_waypoints((5, 5), (5, 5), curvature=0.2, n=8, rng=rng) + assert pts == [(5, 5), (5, 5)] + + +def test_load_config_defaults_when_unset(): + cfg = load_humanize_config(env={}) + assert cfg == HumanizeConfig() + + +def test_load_config_disabled(): + cfg = load_humanize_config(env={"APEX_HUMANIZE_ENABLED": "false"}) + assert cfg.enabled is False + + +def test_load_config_overrides(): + cfg = load_humanize_config( + env={ + "APEX_HUMANIZE_TAP_RADIUS_PX": "9", + "APEX_HUMANIZE_SWIPE_CURVATURE": "0.3", + "APEX_HUMANIZE_SWIPE_WAYPOINTS": "12", + "APEX_HUMANIZE_DURATION_SPREAD": "0.2", + } + ) + assert cfg.tap_radius_px == 9.0 + assert cfg.swipe_curvature == 0.3 + assert cfg.swipe_waypoints == 12 + assert cfg.duration_spread == 0.2 + + +def test_set_rng_makes_output_reproducible(): + set_rng(random.Random(99)) + a = jitter_point(0, 0, radius=5.0, rng=random.Random(99)) + set_rng(None) # reset to default rng + assert isinstance(a, tuple) and len(a) == 2 diff --git a/tools/humanize.py b/tools/humanize.py new file mode 100644 index 0000000..298db03 --- /dev/null +++ b/tools/humanize.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import math +import os +import random +from collections.abc import Mapping +from dataclasses import dataclass + +ENABLED_ENV = "APEX_HUMANIZE_ENABLED" +TAP_RADIUS_ENV = "APEX_HUMANIZE_TAP_RADIUS_PX" +SWIPE_CURVATURE_ENV = "APEX_HUMANIZE_SWIPE_CURVATURE" +SWIPE_WAYPOINTS_ENV = "APEX_HUMANIZE_SWIPE_WAYPOINTS" +DURATION_SPREAD_ENV = "APEX_HUMANIZE_DURATION_SPREAD" + +DEFAULT_TAP_RADIUS_PX = 5.0 +DEFAULT_SWIPE_CURVATURE = 0.15 +DEFAULT_SWIPE_WAYPOINTS = 8 +DEFAULT_DURATION_SPREAD = 0.15 + + +@dataclass(frozen=True) +class HumanizeConfig: + enabled: bool = True + tap_radius_px: float = DEFAULT_TAP_RADIUS_PX + swipe_curvature: float = DEFAULT_SWIPE_CURVATURE + swipe_waypoints: int = DEFAULT_SWIPE_WAYPOINTS + duration_spread: float = DEFAULT_DURATION_SPREAD + + +def load_humanize_config(env: Mapping[str, str] | None = None) -> HumanizeConfig: + values = env or os.environ + return HumanizeConfig( + enabled=_parse_bool(values.get(ENABLED_ENV), default=True), + tap_radius_px=_parse_float(values.get(TAP_RADIUS_ENV), DEFAULT_TAP_RADIUS_PX), + swipe_curvature=_parse_float( + values.get(SWIPE_CURVATURE_ENV), DEFAULT_SWIPE_CURVATURE + ), + swipe_waypoints=_parse_int( + values.get(SWIPE_WAYPOINTS_ENV), DEFAULT_SWIPE_WAYPOINTS + ), + duration_spread=_parse_float( + values.get(DURATION_SPREAD_ENV), DEFAULT_DURATION_SPREAD + ), + ) + + +def _parse_bool(value: str | None, *, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on", "enabled"} + + +def _parse_float(value: str | None, default: float) -> float: + if value is None: + return default + try: + return float(value) + except ValueError: + return default + + +def _parse_int(value: str | None, default: int) -> int: + if value is None: + return default + try: + return int(value) + except ValueError: + return default + + +_rng: random.Random | None = None + + +def get_rng() -> random.Random: + global _rng + if _rng is None: + _rng = random.Random() + return _rng + + +def set_rng(rng: random.Random | None) -> None: + """Inject a seeded rng (tests). Pass ``None`` to reset to the default.""" + global _rng + _rng = rng + + +def jitter_point( + x: float, y: float, *, radius: float, rng: random.Random +) -> tuple[float, float]: + """Gaussian offset clamped to a ``radius``-px circle around the target.""" + r = abs(rng.gauss(0.0, radius / 2.0)) + r = min(r, radius) + angle = rng.uniform(0, 2 * math.pi) + dx = r * math.cos(angle) + dy = r * math.sin(angle) + # Reproject to exact radius to absorb FP drift in cos/sin + d = math.sqrt(dx * dx + dy * dy) + if d > 0: + dx = dx / d * radius + dy = dy / d * radius + fx = x + dx + fy = y + dy + fd = math.hypot(fx - x, fy - y) + if fd > radius: + # Scale to a slightly tighter radius to absorb FP rounding in addition + scale = (radius - 1e-10) / fd + fx = x + dx * scale + fy = y + dy * scale + return (fx, fy) + + +def jitter_duration( + value_ms: int, *, spread: float, rng: random.Random +) -> int: + """Uniform jitter within ``value_ms * spread``; floored at 1ms.""" + delta = value_ms * spread + return max(1, int(value_ms + rng.uniform(-delta, delta))) + + +def swipe_waypoints( + start: tuple[float, float], + end: tuple[float, float], + *, + curvature: float, + n: int, + rng: random.Random, +) -> list[tuple[float, float]]: + """Return start + ``n`` interior + end points. Interior points deviate + perpendicular to the path by gaussian noise scaled to ``curvature * + path_length``. Degenerate (zero-length) path returns ``[start, end]``.""" + sx, sy = start + ex, ey = end + dx = ex - sx + dy = ey - sy + length = math.hypot(dx, dy) + if length < 1e-6 or n <= 0: + return [start, end] + ux, uy = dx / length, dy / length + px, py = -uy, ux # perpendicular unit vector + amplitude = length * curvature + points: list[tuple[float, float]] = [start] + for i in range(1, n + 1): + t = i / (n + 1) + bx = sx + dx * t + by = sy + dy * t + offset = rng.gauss(0.0, amplitude / 2.0) + points.append((bx + px * offset, by + py * offset)) + points.append(end) + return points