"""Tests for :mod:`neuropose.cli`. Uses ``typer.testing.CliRunner`` to invoke the CLI in-process and assert on exit codes and captured output. The autouse ``_isolate_environment`` fixture in ``conftest.py`` ensures each invocation uses an isolated ``$HOME`` / ``$XDG_DATA_HOME`` so the daemon's lock file and data dirs cannot bleed between tests. The tests deliberately check ``result.output`` (combined stdout + stderr) rather than separate ``result.stderr``, because click's ``mix_stderr`` plumbing varies across 8.x releases and we want the tests to be version- robust. """ from __future__ import annotations import math from pathlib import Path import numpy as np import pytest import yaml from typer.testing import CliRunner from neuropose import __version__ from neuropose.cli import ( EXIT_INTERRUPTED, EXIT_OK, EXIT_PENDING, EXIT_USAGE, app, ) from neuropose.io import ( JobResults, VideoPredictions, load_benchmark_result, load_job_results, load_video_predictions, save_job_results, save_video_predictions, ) @pytest.fixture def runner() -> CliRunner: """Return a default CliRunner. We do NOT set ``mix_stderr=False`` because that parameter's semantics changed between click 8.1 and 8.2. The default behaviour — combined output on ``result.output`` — is stable and good enough for the assertions this test suite makes. """ return CliRunner() @pytest.fixture def stub_metrabs_loader(monkeypatch: pytest.MonkeyPatch) -> None: """Patch the MeTRAbs loader to raise a recognisable stub error. The real loader downloads a ~675 MB tarball and loads it through TensorFlow — neither suitable for unit tests. This fixture replaces it with a function that raises ``NotImplementedError`` tagged with a stable "pending commit 11" marker so the CLI's ``NotImplementedError`` handler still has a testable failure mode. The handler itself is defensive code for any future stub; this fixture lets us keep it honest without reintroducing a real stub in production code. """ def fake_loader(cache_dir: Path | None = None) -> object: del cache_dir raise NotImplementedError("pending commit 11: MeTRAbs loader stubbed for unit testing") monkeypatch.setattr("neuropose.estimator.load_metrabs_model", fake_loader) # --------------------------------------------------------------------------- # Top-level options # --------------------------------------------------------------------------- class TestTopLevelOptions: def test_version_flag(self, runner: CliRunner) -> None: result = runner.invoke(app, ["--version"]) assert result.exit_code == EXIT_OK assert __version__ in result.output def test_no_args_shows_help(self, runner: CliRunner) -> None: result = runner.invoke(app, []) # Typer's ``no_args_is_help`` exits with a help message. The exit # code convention varies between click versions; accept either # success or a usage-style code. assert result.exit_code in (EXIT_OK, EXIT_USAGE) assert "watch" in result.output assert "process" in result.output assert "segment" in result.output assert "benchmark" in result.output assert "analyze" in result.output def test_help_flag(self, runner: CliRunner) -> None: result = runner.invoke(app, ["--help"]) assert result.exit_code == EXIT_OK assert "NeuroPose" in result.output def test_subcommand_help(self, runner: CliRunner) -> None: for subcommand in ("watch", "process", "segment", "benchmark", "analyze"): result = runner.invoke(app, [subcommand, "--help"]) assert result.exit_code == EXIT_OK, f"{subcommand} --help failed" def test_verbose_and_quiet_are_mutually_exclusive(self, runner: CliRunner) -> None: result = runner.invoke(app, ["--verbose", "--quiet", "watch"]) assert result.exit_code != EXIT_OK # --------------------------------------------------------------------------- # --config handling # --------------------------------------------------------------------------- class TestConfigOption: def test_missing_config_file(self, runner: CliRunner, tmp_path: Path) -> None: result = runner.invoke(app, ["--config", str(tmp_path / "nope.yaml"), "watch"]) assert result.exit_code == EXIT_USAGE # typer validates file existence via the Option's exists/readable # machinery, so the error message comes from click, not our handler. # We just verify it mentions the missing file's name. assert "nope.yaml" in result.output def test_invalid_config_yaml_structure(self, runner: CliRunner, tmp_path: Path) -> None: path = tmp_path / "bad.yaml" path.write_text("- not a mapping\n- another item\n") result = runner.invoke(app, ["--config", str(path), "watch"]) assert result.exit_code == EXIT_USAGE lowered = result.output.lower() assert "invalid config" in lowered or "mapping" in lowered def test_invalid_config_field(self, runner: CliRunner, tmp_path: Path) -> None: path = tmp_path / "bad.yaml" path.write_text(yaml.safe_dump({"device": "cpu"})) result = runner.invoke(app, ["--config", str(path), "watch"]) assert result.exit_code == EXIT_USAGE assert "invalid config" in result.output.lower() def test_valid_config_reaches_subcommand( self, runner: CliRunner, tmp_path: Path, stub_metrabs_loader: None, ) -> None: # A valid config should flow through the callback and into the # subcommand. ``watch`` will then reach the model-loading step, at # which point the stubbed loader raises ``NotImplementedError`` and # the CLI exits ``EXIT_PENDING``. Observing that exit code is how # we confirm the config made it all the way to the subcommand. del stub_metrabs_loader data_dir = tmp_path / "data" path = tmp_path / "good.yaml" path.write_text( yaml.safe_dump( { "device": "/CPU:0", "data_dir": str(data_dir), "model_cache_dir": str(tmp_path / "models"), } ) ) result = runner.invoke(app, ["--config", str(path), "watch"]) assert result.exit_code == EXIT_PENDING assert "commit 11" in result.output # --------------------------------------------------------------------------- # watch # --------------------------------------------------------------------------- class TestWatch: def test_without_model_exits_pending( self, runner: CliRunner, stub_metrabs_loader: None, ) -> None: """The stubbed loader raises ``NotImplementedError`` on model load. The CLI should catch it and exit with ``EXIT_PENDING`` and a message pointing at the pending commit. The real loader is patched out by ``stub_metrabs_loader`` so this test does not download the model. """ del stub_metrabs_loader result = runner.invoke(app, ["watch"]) assert result.exit_code == EXIT_PENDING assert "commit 11" in result.output # --------------------------------------------------------------------------- # process # --------------------------------------------------------------------------- class TestProcess: def test_missing_video_exits_usage(self, runner: CliRunner, tmp_path: Path) -> None: result = runner.invoke(app, ["process", str(tmp_path / "nope.mp4")]) # click's path existence check fires before our callback, so the # exit is a usage error. assert result.exit_code == EXIT_USAGE def test_existing_video_without_model_exits_pending( self, runner: CliRunner, synthetic_video: Path, stub_metrabs_loader: None, ) -> None: del stub_metrabs_loader result = runner.invoke(app, ["process", str(synthetic_video)]) assert result.exit_code == EXIT_PENDING assert "commit 11" in result.output # --------------------------------------------------------------------------- # segment # --------------------------------------------------------------------------- _NUM_JOINTS = 43 def _triple_hump_predictions(joint_name: str = "lwri") -> VideoPredictions: """Build a 300-frame synthetic VideoPredictions with three clear humps.""" from neuropose.analyzer.segment import JOINT_INDEX joint = JOINT_INDEX[joint_name] t = np.linspace(0.0, 6.0 * math.pi, 300) signal = np.maximum(0.0, np.sin(t)) ** 2 * 1000.0 frames = {} for i, value in enumerate(signal): poses = [[[0.0, 0.0, 0.0] for _ in range(_NUM_JOINTS)]] poses[0][joint][1] = float(value) frames[f"frame_{i:06d}"] = { "boxes": [[0.0, 0.0, 1.0, 1.0, 0.9]], "poses3d": poses, "poses2d": [[[0.0, 0.0]] * _NUM_JOINTS], } return VideoPredictions.model_validate( { "metadata": { "frame_count": 300, "fps": 30.0, "width": 640, "height": 480, }, "frames": frames, } ) class TestSegmentSubcommand: def test_segment_job_results_in_place(self, runner: CliRunner, tmp_path: Path) -> None: path = tmp_path / "results.json" save_job_results( path, JobResults(root={"trial_01.mp4": _triple_hump_predictions()}), ) result = runner.invoke( app, [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "lwri", "--axis", "1", "--min-prominence", "50", ], ) assert result.exit_code == EXIT_OK, result.output loaded = load_job_results(path) vp = loaded["trial_01.mp4"] assert "cup_lift" in vp.segmentations assert len(vp.segmentations["cup_lift"].segments) == 3 def test_segment_single_predictions_file(self, runner: CliRunner, tmp_path: Path) -> None: path = tmp_path / "video_predictions.json" save_video_predictions(path, _triple_hump_predictions()) result = runner.invoke( app, [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_pair_distance", "--joints", "lwri,rwri", "--min-prominence", "50", ], ) # With both wrists at origin except for lwri's y-coordinate, the # pair distance is exactly the lwri.y signal, so the three humps # are detected the same way as the joint_axis case. assert result.exit_code == EXIT_OK, result.output loaded = load_video_predictions(path) assert "cup_lift" in loaded.segmentations assert len(loaded.segmentations["cup_lift"].segments) == 3 def test_segment_writes_to_output_option(self, runner: CliRunner, tmp_path: Path) -> None: src = tmp_path / "src.json" dst = tmp_path / "dst.json" save_job_results(src, JobResults(root={"trial_01.mp4": _triple_hump_predictions()})) result = runner.invoke( app, [ "segment", str(src), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "15", # integer form "--axis", "1", "--min-prominence", "50", "--output", str(dst), ], ) assert result.exit_code == EXIT_OK, result.output assert dst.exists() # Source must be left untouched when --output is given. src_loaded = load_job_results(src) assert src_loaded["trial_01.mp4"].segmentations == {} dst_loaded = load_job_results(dst) assert "cup_lift" in dst_loaded["trial_01.mp4"].segmentations def test_segment_rejects_collision_without_force( self, runner: CliRunner, tmp_path: Path ) -> None: path = tmp_path / "results.json" save_job_results(path, JobResults(root={"trial_01.mp4": _triple_hump_predictions()})) base_args = [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "lwri", "--axis", "1", "--min-prominence", "50", ] first = runner.invoke(app, base_args) assert first.exit_code == EXIT_OK, first.output collision = runner.invoke(app, base_args) assert collision.exit_code == EXIT_USAGE assert "force" in collision.output.lower() def test_segment_force_overwrites(self, runner: CliRunner, tmp_path: Path) -> None: path = tmp_path / "results.json" save_job_results(path, JobResults(root={"trial_01.mp4": _triple_hump_predictions()})) args = [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "lwri", "--axis", "1", "--min-prominence", "50", ] runner.invoke(app, args) result = runner.invoke(app, [*args, "--force"]) assert result.exit_code == EXIT_OK, result.output def test_segment_unknown_joint_name_is_usage_error( self, runner: CliRunner, tmp_path: Path ) -> None: path = tmp_path / "results.json" save_job_results(path, JobResults(root={"trial_01.mp4": _triple_hump_predictions()})) result = runner.invoke( app, [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "elbow", # deliberately not a valid berkeley_mhad_43 name "--axis", "1", ], ) assert result.exit_code == EXIT_USAGE assert "unknown joint" in result.output.lower() def test_segment_missing_required_flag_is_usage_error( self, runner: CliRunner, tmp_path: Path ) -> None: path = tmp_path / "results.json" save_job_results(path, JobResults(root={"trial_01.mp4": _triple_hump_predictions()})) # joint_axis without --joint result = runner.invoke( app, [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--axis", "1", ], ) assert result.exit_code == EXIT_USAGE def test_segment_unreadable_file_is_usage_error( self, runner: CliRunner, tmp_path: Path ) -> None: path = tmp_path / "garbage.json" path.write_text("{ this is not valid json") result = runner.invoke( app, [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "lwri", "--axis", "1", ], ) assert result.exit_code == EXIT_USAGE def test_segment_preserves_pose_values(self, runner: CliRunner, tmp_path: Path) -> None: """Segmentation only touches ``segmentations``; pose data is untouched.""" path = tmp_path / "results.json" original = _triple_hump_predictions() save_job_results(path, JobResults(root={"trial_01.mp4": original})) result = runner.invoke( app, [ "segment", str(path), "--name", "cup_lift", "--extractor", "joint_axis", "--joint", "lwri", "--axis", "1", "--min-prominence", "50", ], ) assert result.exit_code == EXIT_OK, result.output loaded = load_job_results(path) lv = loaded["trial_01.mp4"] assert lv.frame_names() == original.frame_names() for name in original.frame_names(): assert lv[name].poses3d == original[name].poses3d # --------------------------------------------------------------------------- # benchmark # --------------------------------------------------------------------------- @pytest.fixture def stub_estimator_with_metrics(monkeypatch: pytest.MonkeyPatch): """Monkeypatch the benchmark's Estimator path to use a fake model. The CLI's ``benchmark`` subcommand instantiates an :class:`Estimator` and calls ``load_model``. We replace ``load_metrabs_model`` with a fake that returns a deterministic stand-in so the CLI's ``load_model`` succeeds without downloading or touching TF. The estimator's own metrics-collection path still runs, so the CLI exercise is end-to-end except for the real model. """ import numpy as np class RecordingFake: def detect_poses(self, image, **kwargs): del image, kwargs return { "boxes": np.array([[0.0, 0.0, 1.0, 1.0, 0.9]]), "poses3d": np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]]), "poses2d": np.array([[[0.0, 0.0], [1.0, 1.0]]]), } def fake_loader(cache_dir: Path | None = None) -> object: del cache_dir return RecordingFake() monkeypatch.setattr("neuropose.estimator.load_metrabs_model", fake_loader) class TestBenchmarkSubcommand: def test_benchmark_smoke( self, runner: CliRunner, synthetic_video: Path, tmp_path: Path, stub_estimator_with_metrics, ) -> None: del stub_estimator_with_metrics output = tmp_path / "bench.json" result = runner.invoke( app, [ "benchmark", str(synthetic_video), "--repeats", "3", "--warmup-frames", "0", "--output", str(output), ], ) assert result.exit_code == EXIT_OK, result.output # Human-readable report must hit stdout. assert "Benchmark:" in result.output assert "Throughput:" in result.output # JSON output file must exist and validate against the schema. assert output.exists() loaded = load_benchmark_result(output) assert loaded.video_name == synthetic_video.name assert loaded.repeats == 3 assert len(loaded.measured_passes) == 2 assert loaded.cpu_comparison is None def test_benchmark_rejects_repeats_below_two( self, runner: CliRunner, synthetic_video: Path, stub_estimator_with_metrics, ) -> None: del stub_estimator_with_metrics result = runner.invoke(app, ["benchmark", str(synthetic_video), "--repeats", "1"]) # Typer's min=2 validation catches this before our code runs. assert result.exit_code != EXIT_OK def test_benchmark_missing_video_is_usage_error( self, runner: CliRunner, tmp_path: Path, ) -> None: result = runner.invoke(app, ["benchmark", str(tmp_path / "nope.mp4")]) assert result.exit_code == EXIT_USAGE def test_benchmark_force_cpu_and_compare_cpu_are_mutually_exclusive( self, runner: CliRunner, synthetic_video: Path, stub_estimator_with_metrics, ) -> None: del stub_estimator_with_metrics result = runner.invoke( app, [ "benchmark", str(synthetic_video), "--compare-cpu", "--force-cpu", ], ) assert result.exit_code == EXIT_USAGE assert "mutually exclusive" in result.output # --------------------------------------------------------------------------- # analyze # --------------------------------------------------------------------------- class TestAnalyze: def test_analyze_stub_exits_with_pending_message( self, runner: CliRunner, tmp_path: Path ) -> None: results_path = tmp_path / "results.json" results_path.write_text("{}") result = runner.invoke(app, ["analyze", str(results_path)]) assert result.exit_code == EXIT_PENDING assert "commit 10" in result.output def test_analyze_requires_an_argument(self, runner: CliRunner) -> None: result = runner.invoke(app, ["analyze"]) assert result.exit_code == EXIT_USAGE # --------------------------------------------------------------------------- # Exit-code module constants # --------------------------------------------------------------------------- class TestExitCodes: def test_exit_codes_are_distinct(self) -> None: codes = {EXIT_OK, EXIT_USAGE, EXIT_PENDING, EXIT_INTERRUPTED} assert len(codes) == 4