refactor(tests): split into tests/unit and tests/tools
This commit is contained in:
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
1
tests/unit/conftest.py
Normal file
1
tests/unit/conftest.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Fixtures for library-only tests (no torch)."""
|
||||
113
tests/unit/test_coord.py
Normal file
113
tests/unit/test_coord.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Tests for rotated coordinate system transforms."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ai_mouse.coord import encode_trajectory, decode_trajectory
|
||||
|
||||
|
||||
class TestEncodeTrajectory:
|
||||
"""Test pixel → rotated normalised frame."""
|
||||
|
||||
def test_start_maps_to_origin(self):
|
||||
start = (100, 200)
|
||||
end = (400, 500)
|
||||
points = np.array([[100, 200]], dtype=float)
|
||||
result = encode_trajectory(points, start, end)
|
||||
np.testing.assert_allclose(result[0], [0.0, 0.0], atol=1e-10)
|
||||
|
||||
def test_end_maps_to_one_zero(self):
|
||||
start = (100, 200)
|
||||
end = (400, 500)
|
||||
points = np.array([[400, 500]], dtype=float)
|
||||
result = encode_trajectory(points, start, end)
|
||||
np.testing.assert_allclose(result[0], [1.0, 0.0], atol=1e-10)
|
||||
|
||||
def test_midpoint_maps_to_half_zero(self):
|
||||
start = (0, 0)
|
||||
end = (200, 0)
|
||||
points = np.array([[100, 0]], dtype=float)
|
||||
result = encode_trajectory(points, start, end)
|
||||
np.testing.assert_allclose(result[0], [0.5, 0.0], atol=1e-10)
|
||||
|
||||
def test_lateral_offset_positive(self):
|
||||
"""Point at (100, 50) with horizontal start→end has lateral = 50/200 = 0.25."""
|
||||
start = (0, 0)
|
||||
end = (200, 0)
|
||||
# For horizontal u=(1,0), v=(-0, 1)=(0,1).
|
||||
# Point (100, 50): forward = 100/200=0.5, lateral = 50/200=0.25
|
||||
points = np.array([[100, 50]], dtype=float)
|
||||
result = encode_trajectory(points, start, end)
|
||||
np.testing.assert_allclose(result[0], [0.5, 0.25], atol=1e-10)
|
||||
|
||||
def test_various_angles(self):
|
||||
"""Encode/decode round-trip works for various angles."""
|
||||
angles = [0, 45, 90, 135, 180, -45, -90, -135]
|
||||
for deg in angles:
|
||||
rad = math.radians(deg)
|
||||
start = (400, 300)
|
||||
dist = 200
|
||||
end = (int(400 + dist * math.cos(rad)), int(300 + dist * math.sin(rad)))
|
||||
# Create a curved path
|
||||
t = np.linspace(0, 1, 20)
|
||||
px = start[0] + t * (end[0] - start[0]) + 20 * np.sin(t * math.pi)
|
||||
py = start[1] + t * (end[1] - start[1]) + 20 * np.cos(t * math.pi)
|
||||
points = np.stack([px, py], axis=1)
|
||||
|
||||
encoded = encode_trajectory(points, start, end)
|
||||
assert encoded[0, 0] == pytest.approx(0.0, abs=0.2)
|
||||
assert encoded[-1, 0] == pytest.approx(1.0, abs=0.2)
|
||||
|
||||
|
||||
class TestDecodeTrajectory:
|
||||
"""Test rotated normalised frame → pixel."""
|
||||
|
||||
def test_origin_maps_to_start(self):
|
||||
start = (100, 200)
|
||||
end = (400, 500)
|
||||
normalised = np.array([[0.0, 0.0]], dtype=float)
|
||||
result = decode_trajectory(normalised, start, end)
|
||||
np.testing.assert_allclose(result[0], [100, 200], atol=1e-10)
|
||||
|
||||
def test_one_zero_maps_to_end(self):
|
||||
start = (100, 200)
|
||||
end = (400, 500)
|
||||
normalised = np.array([[1.0, 0.0]], dtype=float)
|
||||
result = decode_trajectory(normalised, start, end)
|
||||
np.testing.assert_allclose(result[0], [400, 500], atol=1e-10)
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""Encode then decode should return original points."""
|
||||
|
||||
def test_round_trip_horizontal(self):
|
||||
start = (50, 100)
|
||||
end = (350, 100)
|
||||
points = np.array([[50, 100], [150, 130], [250, 90], [350, 100]], dtype=float)
|
||||
encoded = encode_trajectory(points, start, end)
|
||||
decoded = decode_trajectory(encoded, start, end)
|
||||
np.testing.assert_allclose(decoded, points, atol=1e-8)
|
||||
|
||||
def test_round_trip_diagonal(self):
|
||||
start = (100, 100)
|
||||
end = (500, 400)
|
||||
rng = np.random.default_rng(42)
|
||||
points = np.column_stack([
|
||||
np.linspace(100, 500, 30) + rng.normal(0, 10, 30),
|
||||
np.linspace(100, 400, 30) + rng.normal(0, 10, 30),
|
||||
])
|
||||
encoded = encode_trajectory(points, start, end)
|
||||
decoded = decode_trajectory(encoded, start, end)
|
||||
np.testing.assert_allclose(decoded, points, atol=1e-8)
|
||||
|
||||
def test_round_trip_vertical(self):
|
||||
"""Vertical movement (angle=90°) doesn't collapse."""
|
||||
start = (300, 50)
|
||||
end = (300, 450)
|
||||
points = np.array([[300, 50], [310, 200], [295, 350], [300, 450]], dtype=float)
|
||||
encoded = encode_trajectory(points, start, end)
|
||||
decoded = decode_trajectory(encoded, start, end)
|
||||
np.testing.assert_allclose(decoded, points, atol=1e-8)
|
||||
158
tests/unit/test_generator.py
Normal file
158
tests/unit/test_generator.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Tests for Flow Matching trajectory generator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from ai_mouse.generator import generate
|
||||
from tools.models import TrajectoryFlowModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_dir(tmp_path):
|
||||
"""Create temp dir with Flow model artifacts."""
|
||||
model = TrajectoryFlowModel(seq_len=64)
|
||||
torch.save(model.state_dict(), tmp_path / "flow_model.pt")
|
||||
|
||||
click_dist = {"mu": 80.0, "sigma": 30.0, "low": 20.0, "high": 300.0}
|
||||
(tmp_path / "click_dist.json").write_text(json.dumps(click_dist))
|
||||
|
||||
duration_dist = {
|
||||
"bins": [0, 50, 100, 200, 400, 600, 800, 1200, float("inf")],
|
||||
"params": [
|
||||
{"mu_log": 5.5, "sigma_log": 0.3},
|
||||
{"mu_log": 5.8, "sigma_log": 0.3},
|
||||
{"mu_log": 6.0, "sigma_log": 0.3},
|
||||
{"mu_log": 6.2, "sigma_log": 0.3},
|
||||
{"mu_log": 6.5, "sigma_log": 0.3},
|
||||
{"mu_log": 6.7, "sigma_log": 0.3},
|
||||
{"mu_log": 6.9, "sigma_log": 0.3},
|
||||
{"mu_log": 7.0, "sigma_log": 0.3},
|
||||
],
|
||||
}
|
||||
(tmp_path / "duration_dist.json").write_text(json.dumps(duration_dist))
|
||||
|
||||
train_config = {
|
||||
"seq_len": 64,
|
||||
"d_model": 128,
|
||||
"nhead": 4,
|
||||
"num_layers": 4,
|
||||
"dim_feedforward": 256,
|
||||
"cond_dim": 3,
|
||||
}
|
||||
(tmp_path / "train_config.json").write_text(json.dumps(train_config))
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_returns_list_of_tuples(self, model_dir):
|
||||
result = generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(p, tuple) and len(p) == 3 for p in result)
|
||||
# All elements are ints
|
||||
for p in result:
|
||||
assert all(isinstance(v, int) for v in p)
|
||||
|
||||
def test_timestamps_monotonically_increasing(self, model_dir):
|
||||
result = generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||||
times = [p[2] for p in result]
|
||||
for i in range(1, len(times)):
|
||||
assert times[i] >= times[i - 1]
|
||||
|
||||
def test_starts_near_start(self, model_dir):
|
||||
start = (100, 200)
|
||||
result = generate(start=start, end=(500, 400), model_dir=str(model_dir))
|
||||
first = result[0]
|
||||
assert abs(first[0] - start[0]) < 30
|
||||
assert abs(first[1] - start[1]) < 30
|
||||
|
||||
def test_ends_near_end(self, model_dir):
|
||||
end = (500, 400)
|
||||
result = generate(start=(100, 200), end=end, model_dir=str(model_dir))
|
||||
# Last two are click events; the one before is last movement point
|
||||
last_move = result[-3]
|
||||
assert abs(last_move[0] - end[0]) < 30
|
||||
assert abs(last_move[1] - end[1]) < 30
|
||||
|
||||
def test_last_two_are_click_events(self, model_dir):
|
||||
result = generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||||
down = result[-2]
|
||||
up = result[-1]
|
||||
# Same x, y for click down and up
|
||||
assert down[0] == up[0]
|
||||
assert down[1] == up[1]
|
||||
# Up timestamp > down timestamp
|
||||
assert up[2] > down[2]
|
||||
# Click duration within bounds
|
||||
assert 20 <= up[2] - down[2] <= 300
|
||||
|
||||
def test_different_z_gives_different_paths(self, model_dir):
|
||||
r1 = generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||||
r2 = generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||||
points1 = [(p[0], p[1]) for p in r1[:-2]]
|
||||
points2 = [(p[0], p[1]) for p in r2[:-2]]
|
||||
assert points1 != points2
|
||||
|
||||
def test_n_points_parameter(self, model_dir):
|
||||
result = generate(
|
||||
start=(100, 200), end=(500, 400), n_points=32, model_dir=str(model_dir)
|
||||
)
|
||||
# 32 move points + 2 click events = 34
|
||||
assert len(result) == 34
|
||||
|
||||
|
||||
class TestPostProcessing:
|
||||
def test_dt_diversity_preserved(self, model_dir):
|
||||
"""After removing speed_profile + median clip, multiple generations
|
||||
should differ in their Δt sequences (not all identical)."""
|
||||
results = [generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||||
for _ in range(5)]
|
||||
# Extract Δt sequences (only move events, not click events)
|
||||
dts = []
|
||||
for r in results:
|
||||
moves = r[:-2]
|
||||
dt_seq = [moves[i+1][2] - moves[i][2] for i in range(len(moves)-1)]
|
||||
dts.append(dt_seq)
|
||||
# At least 2 of the 5 sequences should differ at any given index
|
||||
for i in range(min(len(d) for d in dts)):
|
||||
values = {tuple([d[i]]) for d in dts}
|
||||
if len(values) > 1:
|
||||
return # at least one position has variation — pass
|
||||
pytest.fail("All 5 Δt sequences are identical at every position — diversity collapsed")
|
||||
|
||||
|
||||
class TestGaussianSmooth:
|
||||
def test_endpoints_preserved(self):
|
||||
from ai_mouse.generator import _gaussian_smooth
|
||||
x = np.array([1.0, 5.0, 3.0, 7.0, 2.0], dtype=np.float64)
|
||||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||||
assert smoothed[0] == 1.0
|
||||
assert smoothed[-1] == 2.0
|
||||
|
||||
def test_smooths_high_frequency(self):
|
||||
"""A high-frequency square wave should have reduced amplitude after smoothing."""
|
||||
from ai_mouse.generator import _gaussian_smooth
|
||||
x = np.array([0, 1, 0, 1, 0, 1, 0, 1, 0, 1], dtype=np.float64)
|
||||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||||
# Interior amplitude should be reduced
|
||||
interior_orig = x[2:-2]
|
||||
interior_smooth = smoothed[2:-2]
|
||||
assert interior_smooth.std() < interior_orig.std()
|
||||
|
||||
def test_constant_signal_unchanged(self):
|
||||
from ai_mouse.generator import _gaussian_smooth
|
||||
x = np.full(20, 0.5, dtype=np.float64)
|
||||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||||
np.testing.assert_allclose(smoothed, x, rtol=1e-6)
|
||||
|
||||
def test_short_array_returns_unchanged(self):
|
||||
"""Arrays shorter than the kernel are returned unchanged."""
|
||||
from ai_mouse.generator import _gaussian_smooth
|
||||
x = np.array([1.0, 2.0, 3.0], dtype=np.float64)
|
||||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||||
np.testing.assert_allclose(smoothed, x, rtol=1e-6)
|
||||
50
tests/unit/test_scroll_generator.py
Normal file
50
tests/unit/test_scroll_generator.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for scroll generator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import pytest
|
||||
|
||||
from ai_mouse.scroll.generator import generate_scroll
|
||||
from tools.scroll.models import ScrollCVAE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scroll_model_dir(tmp_path):
|
||||
model = ScrollCVAE(seq_len=32)
|
||||
torch.save(model.state_dict(), tmp_path / "scroll_model.pt")
|
||||
config = {"seq_len": 32, "epochs": 100}
|
||||
(tmp_path / "scroll_config.json").write_text(json.dumps(config))
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestGenerateScroll:
|
||||
def test_returns_list_of_dicts(self, scroll_model_dir):
|
||||
result = generate_scroll(1000, 3000, mode="target", model_dir=str(scroll_model_dir))
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
assert all("deltaY" in e and "t" in e and "deltaMode" in e for e in result)
|
||||
|
||||
def test_timestamps_monotonic(self, scroll_model_dir):
|
||||
result = generate_scroll(1000, 3000, mode="target", model_dir=str(scroll_model_dir))
|
||||
times = [e["t"] for e in result]
|
||||
for i in range(1, len(times)):
|
||||
assert times[i] >= times[i - 1]
|
||||
|
||||
def test_total_scroll_approximately_matches_distance(self, scroll_model_dir):
|
||||
result = generate_scroll(1000, 3000, mode="target", model_dir=str(scroll_model_dir))
|
||||
total = sum(e["deltaY"] for e in result)
|
||||
# Should be within 30% of target distance (2000px)
|
||||
assert abs(total - 2000) < 2000 * 0.4
|
||||
|
||||
def test_deltaY_are_integers(self, scroll_model_dir):
|
||||
result = generate_scroll(1000, 3000, mode="target", model_dir=str(scroll_model_dir))
|
||||
assert all(isinstance(e["deltaY"], int) for e in result)
|
||||
|
||||
def test_direction_up(self, scroll_model_dir):
|
||||
result = generate_scroll(3000, 1000, mode="target", model_dir=str(scroll_model_dir))
|
||||
total = sum(e["deltaY"] for e in result)
|
||||
# Negative total for scrolling up
|
||||
assert total < 0
|
||||
Reference in New Issue
Block a user