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:
2026-05-12 00:30:18 +08:00
commit 65ef838bd7
17 changed files with 1680 additions and 0 deletions

154
tests/test_integration.py Normal file
View 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