feat: initial release of human_mouse v0.1.0
A small Python library for replaying real human mouse trajectories from the SapiMouse dataset onto a Playwright page. Designed for ML-based bot-detection research, behavioral biometrics prototyping, and replay-based test fixtures. Public API: load_all_segments, pick_segments, affine_warp, upsample, replay, replay_random, download_sapimouse. - src/ layout with hatchling build backend - 23 pytest tests (10 transform unit + 13 integration) - MIT license, PEP 561 py.typed marker - python -m human_mouse download for one-shot dataset fetch - examples/cloakbrowser_demo.py demonstrates end-to-end use with CloakBrowser Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
154
tests/test_integration.py
Normal file
154
tests/test_integration.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Integration tests: real SapiMouse loading, filtering, caching, error paths.
|
||||
|
||||
These tests assume the SapiMouse dataset is present at the standard project
|
||||
location (sapimouse_data/sapimouse/). They will skip otherwise — so the
|
||||
core unit tests still pass on a fresh clone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import human_mouse as hm
|
||||
from human_mouse import _replay as replay_mod
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA_ROOT = REPO_ROOT / "sapimouse_data" / "sapimouse"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not DATA_ROOT.exists(),
|
||||
reason="SapiMouse dataset not available at the default location",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------- happy-path loading
|
||||
|
||||
|
||||
def test_load_all_segments_finds_segments():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
assert len(segs) > 1000, f"expected thousands, got {len(segs)}"
|
||||
assert all(len(s.points) >= 50 for s in segs)
|
||||
|
||||
|
||||
def test_segment_properties_consistent():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
s = segs[0]
|
||||
sx, sy = s.start
|
||||
ex, ey = s.end
|
||||
assert s.straight_distance == pytest.approx(math.hypot(ex - sx, ey - sy))
|
||||
assert s.path_length >= s.straight_distance - 1e-6
|
||||
assert s.path_ratio >= 1.0 - 1e-6
|
||||
assert s.duration_ms >= 0
|
||||
|
||||
|
||||
# -------------------------------------------------------------- pick_segments
|
||||
|
||||
|
||||
def test_pick_segments_basic():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
picked = hm.pick_segments(segs, n=3, target_distance=500, seed=42)
|
||||
assert len(picked) == 3
|
||||
assert all(0.25 * 500 < p.straight_distance < 1.75 * 500 for p in picked)
|
||||
|
||||
|
||||
def test_pick_segments_distinct_sessions():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
picked = hm.pick_segments(segs, n=5, target_distance=800,
|
||||
distinct_sessions=True, seed=1)
|
||||
keys = {(p.user, p.session) for p in picked}
|
||||
assert len(keys) == 5
|
||||
|
||||
|
||||
def test_pick_segments_seed_is_deterministic():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
a = hm.pick_segments(segs, n=3, target_distance=800, seed=7)
|
||||
b = hm.pick_segments(segs, n=3, target_distance=800, seed=7)
|
||||
assert [(p.user, p.session) for p in a] == [(p.user, p.session) for p in b]
|
||||
|
||||
|
||||
def test_pick_segments_path_ratio_filter():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
picked = hm.pick_segments(segs, n=10, target_distance=800,
|
||||
max_path_ratio=1.3, seed=1)
|
||||
assert all(p.path_ratio <= 1.3 for p in picked)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ errors
|
||||
|
||||
|
||||
def test_load_missing_directory_raises():
|
||||
with pytest.raises(FileNotFoundError, match="SapiMouse"):
|
||||
hm.load_all_segments("/nonexistent/path")
|
||||
|
||||
|
||||
def test_pick_segments_no_match_raises():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
with pytest.raises(ValueError, match="No segments matched"):
|
||||
hm.pick_segments(segs, n=1, target_distance=999_999,
|
||||
distance_tolerance=0.01)
|
||||
|
||||
|
||||
def test_pick_segments_not_enough_distinct_raises():
|
||||
segs = hm.load_all_segments(DATA_ROOT)
|
||||
with pytest.raises(ValueError, match="distinct"):
|
||||
hm.pick_segments(segs, n=10_000, target_distance=800)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- cache
|
||||
|
||||
|
||||
def test_replay_cache_reuses_same_data_root(monkeypatch):
|
||||
replay_mod._SEGMENT_CACHE.clear()
|
||||
calls = {"n": 0}
|
||||
original = hm.load_all_segments
|
||||
|
||||
def counting(*a, **kw):
|
||||
calls["n"] += 1
|
||||
return original(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(replay_mod, "load_all_segments", counting)
|
||||
_ = replay_mod._cached_segments(DATA_ROOT)
|
||||
_ = replay_mod._cached_segments(DATA_ROOT)
|
||||
_ = replay_mod._cached_segments(DATA_ROOT)
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------ end-to-end replay_random
|
||||
|
||||
|
||||
class _FakeMouse:
|
||||
def __init__(self) -> None:
|
||||
self.moves: list[tuple[int, int]] = []
|
||||
|
||||
def move(self, x: int, y: int) -> None:
|
||||
self.moves.append((x, y))
|
||||
|
||||
|
||||
class _FakePage:
|
||||
"""A Page-like duck that satisfies the _PageLike Protocol."""
|
||||
def __init__(self) -> None:
|
||||
self.mouse = _FakeMouse()
|
||||
|
||||
|
||||
def test_replay_random_drives_page_endpoints():
|
||||
page = _FakePage()
|
||||
seg = hm.replay_random(
|
||||
page, start=(100, 100), end=(900, 500),
|
||||
data_root=DATA_ROOT, density=2, speed=10_000.0, seed=1,
|
||||
)
|
||||
assert isinstance(seg, hm.Segment)
|
||||
assert len(page.mouse.moves) >= 50
|
||||
assert page.mouse.moves[0] == (100, 100)
|
||||
assert page.mouse.moves[-1] == (900, 500)
|
||||
|
||||
|
||||
def test_replay_random_returns_filtered_segment():
|
||||
page = _FakePage()
|
||||
seg = hm.replay_random(
|
||||
page, start=(0, 0), end=(800, 0),
|
||||
data_root=DATA_ROOT, density=1, speed=10_000.0, seed=2,
|
||||
max_path_ratio=1.5,
|
||||
)
|
||||
assert seg.path_ratio <= 1.5
|
||||
93
tests/test_transform.py
Normal file
93
tests/test_transform.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Pure-algorithm tests for human_mouse.transform — no IO, no playwright."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from human_mouse import Segment, affine_warp, upsample
|
||||
|
||||
|
||||
def _seg(points: list[tuple[float, float, float]]) -> Segment:
|
||||
return Segment(user="t", session="s", points=points)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- affine_warp
|
||||
|
||||
|
||||
def test_affine_warp_pins_endpoints():
|
||||
seg = _seg([(0, 0, 0), (50, 50, 10), (100, 100, 20)])
|
||||
out = affine_warp(seg, start=(500, 300), end=(700, 400))
|
||||
assert math.isclose(out[0][0], 500) and math.isclose(out[0][1], 300)
|
||||
assert math.isclose(out[-1][0], 700) and math.isclose(out[-1][1], 400)
|
||||
|
||||
|
||||
def test_affine_warp_uniform_along_axis():
|
||||
"""A straight segment along +x maps to a straight segment between targets."""
|
||||
seg = _seg([(0, 0, 0), (50, 0, 10), (100, 0, 20)])
|
||||
out = affine_warp(seg, start=(0, 0), end=(200, 0))
|
||||
assert math.isclose(out[1][0], 100, abs_tol=1e-6)
|
||||
assert math.isclose(out[1][1], 0, abs_tol=1e-6)
|
||||
|
||||
|
||||
def test_affine_warp_preserves_time():
|
||||
seg = _seg([(0, 0, 100), (10, 5, 130), (20, 0, 160)])
|
||||
out = affine_warp(seg, start=(0, 0), end=(40, 0))
|
||||
assert out[0][2] == 0 # rebased
|
||||
assert out[1][2] == 30
|
||||
assert out[2][2] == 60
|
||||
|
||||
|
||||
def test_affine_warp_rotates_perpendicular_offset():
|
||||
"""A 90-degree rotation: source goes east, target goes north."""
|
||||
seg = _seg([(0, 0, 0), (50, 10, 10), (100, 0, 20)])
|
||||
out = affine_warp(seg, start=(0, 0), end=(0, 100))
|
||||
# The middle point sat 10 px to the left of the source axis (east-pointing
|
||||
# axis -> left is +y); after rotating the axis to north, "left of axis"
|
||||
# means -x. So midpoint x should be approximately -10.
|
||||
assert math.isclose(out[1][0], -10, abs_tol=1e-6)
|
||||
assert math.isclose(out[1][1], 50, abs_tol=1e-6)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- upsample
|
||||
|
||||
|
||||
def test_upsample_factor_1_is_identity():
|
||||
pts = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (2.0, 2.0, 2.0)]
|
||||
assert upsample(pts, 1) == pts
|
||||
|
||||
|
||||
def test_upsample_factor_4_count_formula():
|
||||
# For N input points and factor k, expect k*(N-1) + 1 output points.
|
||||
pts = [(0.0, 0.0, 0.0), (4.0, 0.0, 4.0), (8.0, 0.0, 8.0)]
|
||||
out = upsample(pts, 4)
|
||||
assert len(out) == 4 * (3 - 1) + 1
|
||||
|
||||
|
||||
def test_upsample_endpoints_unchanged():
|
||||
pts = [(0.0, 0.0, 0.0), (10.0, 10.0, 5.0)]
|
||||
out = upsample(pts, 5)
|
||||
assert out[0] == pts[0]
|
||||
assert out[-1] == pts[-1]
|
||||
|
||||
|
||||
def test_upsample_midpoint_correct_for_factor_2():
|
||||
pts = [(0.0, 0.0, 0.0), (10.0, 20.0, 100.0)]
|
||||
out = upsample(pts, 2)
|
||||
# Should be: P0, P0+0.5*delta, P1
|
||||
assert out[1] == (5.0, 10.0, 50.0)
|
||||
|
||||
|
||||
def test_upsample_time_monotonic_with_monotonic_input():
|
||||
pts = [(0.0, 0.0, 0.0), (1.0, 0.0, 5.0), (2.0, 0.0, 12.0), (3.0, 0.0, 20.0)]
|
||||
out = upsample(pts, 4)
|
||||
times = [p[2] for p in out]
|
||||
assert all(t1 >= t0 for t0, t1 in zip(times, times[1:]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("factor", [0, -1])
|
||||
def test_upsample_non_positive_factor_returns_copy(factor: int):
|
||||
pts = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]
|
||||
out = upsample(pts, factor)
|
||||
assert out == pts
|
||||
assert out is not pts # returns a copy, not the same list
|
||||
Reference in New Issue
Block a user