208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""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
|
|
|
|
from pathlib import Path
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 "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", "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
|
|
) -> None:
|
|
# A valid config should flow through the callback and into the
|
|
# subcommand. For ``watch``, the subcommand will then fail on the
|
|
# model load (NotImplementedError from the commit-11 stub), which
|
|
# is the behaviour we want to observe here — exit code
|
|
# ``EXIT_PENDING``.
|
|
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) -> None:
|
|
"""The commit-11 stub raises NotImplementedError on model load.
|
|
|
|
The CLI should catch it and exit with ``EXIT_PENDING`` and a message
|
|
pointing at the pending commit.
|
|
"""
|
|
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,
|
|
) -> None:
|
|
result = runner.invoke(app, ["process", str(synthetic_video)])
|
|
assert result.exit_code == EXIT_PENDING
|
|
assert "commit 11" 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
|