Add pytest suite with 40 unit and integration tests

Coverage:
- test_model: SimpleNet forward (parametrized over batch sizes and both
  unsqueezed and flat input shapes), layer dimensions, differentiability,
  and ONNX round-trip
- test_inference: load_model resolution order (bundled, cwd override,
  explicit path, missing path), and predict shape/dtype/determinism plus
  endpoint sanity across 8 cardinal/diagonal targets
- test_train: _load_csv parsing, TrajectoryDataset indexing, full train()
  pipeline producing a single-file ONNX, plus a smoke test against the
  real data shipped under data/
- test_cli: --help for the three console scripts and a real run of
  mouse-visualize via both the entry point and python -m

Wire up pytest via dependency-groups and tool.pytest.ini_options.
This commit is contained in:
2026-05-12 00:33:15 +08:00
parent de602365e9
commit c8fa5db7d3
8 changed files with 423 additions and 0 deletions

95
tests/test_inference.py Normal file
View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import shutil
from importlib.resources import as_file, files
from pathlib import Path
import numpy as np
import onnxruntime as ort
import pytest
from mouse_control import load_model, predict
def _bundled_path() -> Path:
"""Resolve the bundled mouse.onnx to a stable filesystem path for tests."""
with as_file(files("mouse_control.assets").joinpath("mouse.onnx")) as p:
return Path(p)
def test_load_model_returns_inference_session(bundled_session) -> None:
assert isinstance(bundled_session, ort.InferenceSession)
def test_bundled_input_output_names(bundled_session) -> None:
assert bundled_session.get_inputs()[0].name == "input"
assert bundled_session.get_outputs()[0].name == "output"
def test_load_model_explicit_path() -> None:
session = load_model(_bundled_path())
assert isinstance(session, ort.InferenceSession)
def test_load_model_missing_path_raises() -> None:
with pytest.raises(Exception):
load_model("/no/such/path.onnx")
def test_load_model_cwd_override(tmp_path, monkeypatch) -> None:
"""When ./mouse.onnx exists, it should take precedence over the bundle."""
monkeypatch.chdir(tmp_path)
shutil.copy(_bundled_path(), tmp_path / "mouse.onnx")
session = load_model() # implicit
assert isinstance(session, ort.InferenceSession)
# Must produce the same output as the bundle since the file is identical.
pkg_session = load_model(_bundled_path())
a = predict(session, 75, -40)
b = predict(pkg_session, 75, -40)
np.testing.assert_array_equal(a, b)
def test_load_model_falls_back_to_bundled(tmp_path, monkeypatch) -> None:
"""With no ./mouse.onnx, load_model() must succeed via the bundle."""
monkeypatch.chdir(tmp_path)
assert not (tmp_path / "mouse.onnx").exists()
session = load_model()
assert isinstance(session, ort.InferenceSession)
def test_predict_shape_and_dtype(bundled_session) -> None:
out = predict(bundled_session, 100.0, 200.0)
assert out.shape == (10, 2)
assert out.dtype == np.float32
def test_predict_is_deterministic(bundled_session) -> None:
a = predict(bundled_session, 100, 100)
b = predict(bundled_session, 100, 100)
np.testing.assert_array_equal(a, b)
def test_predict_int_and_float_args_equivalent(bundled_session) -> None:
a = predict(bundled_session, 50, 50)
b = predict(bundled_session, 50.0, 50.0)
np.testing.assert_array_equal(a, b)
@pytest.mark.parametrize(
"dx,dy",
[
(150, 0), (-150, 0), (0, 150), (0, -150),
(100, 100), (-100, 100), (100, -100), (-100, -100),
],
)
def test_predict_endpoint_near_target(bundled_session, dx: int, dy: int) -> None:
"""Trained model's endpoint should fall within ~50px of the target."""
end = predict(bundled_session, dx, dy)[-1]
err = float(np.linalg.norm(end - np.array([dx, dy])))
assert err < 50, f"target ({dx},{dy}) endpoint err={err:.1f}"
def test_predict_zero_displacement_stays_near_origin(bundled_session) -> None:
end = predict(bundled_session, 0, 0)[-1]
assert abs(end[0]) < 20
assert abs(end[1]) < 20