diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py new file mode 100644 index 0000000..5435a65 --- /dev/null +++ b/tests/test_main_cli.py @@ -0,0 +1,620 @@ +"""Focused pytest coverage for registral_dispersion CLI (__main__).""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from registral_dispersion.__main__ import ( + _cli_analyze, + _cli_summarize, + _run_params_from_args, + main, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +SINGLE_NOTE_XML = REPO_ROOT / "tests" / "fixtures" / "single_note.xml" + + +def _argv(*parts: str) -> list[str]: + return ["registral_dispersion", *parts] + + +def _run_main(monkeypatch: pytest.MonkeyPatch, argv: list[str]) -> None: + monkeypatch.setattr(sys, "argv", argv) + main() + + +# --------------------------------------------------------------------------- +# Help / usage +# --------------------------------------------------------------------------- + + +def test_analyze_help_exits_cleanly(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + _run_main(monkeypatch, _argv("analyze", "--help")) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "--score" in out + assert "analyze" in out.lower() + + +def test_summarize_help_exits_cleanly(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + _run_main(monkeypatch, _argv("summarize", "--help")) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "--score" in out + assert "summarize" in out.lower() + + +def test_bare_help_routes_to_ui_subcommand_help( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc_info: + _run_main(monkeypatch, _argv("--help")) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "ui" in out.lower() or "Gradio" in out + + +# --------------------------------------------------------------------------- +# Missing required arguments +# --------------------------------------------------------------------------- + + +def test_analyze_missing_required_score(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + _run_main(monkeypatch, _argv("analyze")) + assert exc_info.value.code == 2 + err = capsys.readouterr().err + assert "score" in err.lower() or "required" in err.lower() + + +def test_concentration_map_missing_required_out( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv("concentration-map", "--score", "score.xml"), + ) + assert exc_info.value.code == 2 + err = capsys.readouterr().err + assert "out" in err.lower() or "required" in err.lower() + + +# --------------------------------------------------------------------------- +# Invalid input file / validation errors +# --------------------------------------------------------------------------- + + +def test_analyze_missing_score_path_exits_with_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + missing = tmp_path / "missing.xml" + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv( + "analyze", + "--score", + str(missing), + "--out-dir", + str(tmp_path / "out"), + ), + ) + assert exc_info.value.code == 1 + assert "not found" in capsys.readouterr().err.lower() + + +def test_analyze_unsupported_extension_exits_with_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + bad = tmp_path / "notes.txt" + bad.write_text("not a score", encoding="utf-8") + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv("analyze", "--score", str(bad), "--out-dir", str(tmp_path)), + ) + assert exc_info.value.code == 1 + assert "unsupported extension" in capsys.readouterr().err.lower() + + +def test_analyze_empty_file_exits_with_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + empty = tmp_path / "empty.xml" + empty.write_bytes(b"") + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv("analyze", "--score", str(empty), "--out-dir", str(tmp_path)), + ) + assert exc_info.value.code == 1 + assert "empty" in capsys.readouterr().err.lower() + + +def test_summarize_missing_score_path_exits_with_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + missing = tmp_path / "ghost.xml" + with pytest.raises(SystemExit) as exc_info: + _run_main(monkeypatch, _argv("summarize", "--score", str(missing))) + assert exc_info.value.code == 1 + assert "not found" in capsys.readouterr().err.lower() + + +# --------------------------------------------------------------------------- +# argparse invalid choices +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("flag", "value"), + [ + ("--tie-policy", "invalid_policy"), + ("--analysis-profile", "invalid_profile"), + ("--pitch-sampling", "invalid_sampling"), + ("--observation-mode", "invalid_mode"), + ], +) +def test_analyze_rejects_invalid_choice( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + flag: str, + value: str, +) -> None: + score = tmp_path / "score.xml" + score.write_text("", encoding="utf-8") + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv("analyze", "--score", str(score), flag, value), + ) + assert exc_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# _run_params_from_args +# --------------------------------------------------------------------------- + + +def test_run_params_from_args_applies_defaults_and_optional_pitch_sampling() -> None: + args = argparse.Namespace( + time_step=0.5, + window_size=2.0, + register_low="A1", + register_high="E7", + analysis_profile="component_weighted", + observation_mode=None, + tie_policy="merge_ties", + pitch_sampling_mode="unique_pitch_heights", + ) + params = _run_params_from_args(args, default_observation_mode="fixed_window") + assert params["observation_mode"] == "fixed_window" + assert params["tie_policy"] == "merge_ties" + assert params["pitch_sampling_mode"] == "unique_pitch_heights" + assert params["analysis_profile"] == "component_weighted" + + +def test_run_params_from_args_omits_pitch_sampling_when_none() -> None: + args = argparse.Namespace( + time_step=0.25, + window_size=4.0, + register_low="A0", + register_high="C8", + analysis_profile="occupied_space", + observation_mode="event_boundaries", + tie_policy="as_imported", + pitch_sampling_mode=None, + ) + params = _run_params_from_args(args, default_observation_mode="fixed_window") + assert "pitch_sampling_mode" not in params + assert params["observation_mode"] == "event_boundaries" + + +# --------------------------------------------------------------------------- +# analyze — mocked fast path +# --------------------------------------------------------------------------- + + +def _fake_analyze_out() -> dict: + analyzer = MagicMock() + analyzer.register_low = 48.0 + analyzer.register_high = 72.0 + analyzer.register_width_semitones = 24.0 + return { + "error": None, + "params": { + "pitch_sampling_mode": "event_instances", + "analysis_profile": "component_weighted", + "pitch_sampling_source": "explicit", + "observation_mode": "fixed_window", + "tie_policy": "merge_ties", + }, + "analyzer": analyzer, + "results": { + "t": [0.0], + "interval_start": [0.0], + "interval_end": [1.0], + "interval_duration": [1.0], + "window_start": [0.0], + "window_end": [4.0], + "active_note_count": [1.0], + "min_pitch": [60.0], + "max_pitch": [60.0], + "dispersion_degree": [0.0], + "normalized_dispersion_degree": [0.0], + "registral_span": [0.0], + "mean_pairwise_registral_distance": [0.0], + "registral_centroid": [60.0], + "registral_std": [0.0], + "normalized_registral_span": [0.0], + "normalized_mean_pairwise_registral_distance": [0.0], + "normalized_registral_centroid": [0.5], + "normalized_registral_std": [0.0], + "occupancy_entropy": [0.0], + }, + "global_summary": {"aggregation_method": "duration_weighted", "mean": 0.1}, + "summary": "ok", + } + + +def test_cli_analyze_writes_outputs_with_explicit_dir_and_prefix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + score = tmp_path / "score.xml" + score.write_text("", encoding="utf-8") + out_dir = tmp_path / "exports" + monkeypatch.setattr( + "registral_dispersion.__main__.run_registral_dispersion_analysis", + lambda _score, _params: _fake_analyze_out(), + ) + + args = argparse.Namespace( + score=str(score), + out_dir=str(out_dir), + prefix="cli_run", + register_low="C3", + register_high="C5", + window_size=4.0, + plot_span=False, + plot_pairwise=True, + plot_entropy=True, + plot_normalized=True, + time_step=0.25, + analysis_profile="component_weighted", + observation_mode="fixed_window", + tie_policy="merge_ties", + pitch_sampling_mode="event_instances", + ) + assert _cli_analyze(args) == 0 + + assert (out_dir / "cli_run.csv").is_file() + assert (out_dir / "cli_run.json").is_file() + assert (out_dir / "cli_run.png").is_file() + assert (out_dir / "cli_run_global_summary.csv").is_file() + out = capsys.readouterr().out + assert "Wrote" in out + assert str(out_dir / "cli_run.csv") in out + + +def test_cli_analyze_returns_error_code_when_analysis_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + score = tmp_path / "score.xml" + score.write_text("", encoding="utf-8") + monkeypatch.setattr( + "registral_dispersion.__main__.run_registral_dispersion_analysis", + lambda _score, _params: {"error": "analysis failed", "params": {}}, + ) + args = argparse.Namespace( + score=str(score), + out_dir=str(tmp_path), + prefix="x", + register_low="C3", + register_high="C5", + window_size=4.0, + plot_span=False, + plot_pairwise=False, + plot_entropy=False, + plot_normalized=False, + time_step=0.25, + analysis_profile="occupied_space", + observation_mode="fixed_window", + tie_policy="as_imported", + pitch_sampling_mode=None, + ) + assert _cli_analyze(args) == 1 + assert "analysis failed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# summarize — mocked and direct paths +# --------------------------------------------------------------------------- + + +def test_cli_summarize_writes_optional_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + score = tmp_path / "score.xml" + score.write_text("", encoding="utf-8") + out_json = tmp_path / "summary.json" + out_csv = tmp_path / "summary.csv" + monkeypatch.setattr( + "registral_dispersion.__main__.summarize_registral_dispersion", + lambda _score, _params: { + "error": None, + "primary_metric": "dispersion_degree", + "primary_value": 0.42, + "secondary_metric": "occupancy_entropy", + "secondary_value": 0.1, + "global_summary": {"aggregation_method": "duration_weighted", "mean": 0.42}, + "params": { + "analysis_profile": "occupied_space", + "pitch_sampling_mode": "unique_pitch_heights", + "observation_mode": "event_boundaries", + "register_low": "A0", + "register_high": "C8", + }, + "warnings": ["caution"], + }, + ) + args = argparse.Namespace( + score=str(score), + out_json=str(out_json), + out_csv=str(out_csv), + time_step=0.25, + window_size=4.0, + register_low="A0", + register_high="C8", + analysis_profile="occupied_space", + observation_mode="event_boundaries", + tie_policy="as_imported", + pitch_sampling_mode=None, + ) + assert _cli_summarize(args) == 0 + assert out_json.is_file() + assert out_csv.is_file() + doc = json.loads(out_json.read_text(encoding="utf-8")) + assert doc["primary_value"] == 0.42 + out = capsys.readouterr().out + assert "primary_metric:" in out + assert "caution" in out + + +def test_cli_summarize_returns_error_code( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + score = tmp_path / "score.xml" + score.write_text("", encoding="utf-8") + monkeypatch.setattr( + "registral_dispersion.__main__.summarize_registral_dispersion", + lambda _score, _params: {"error": "summarize failed", "params": {}}, + ) + args = argparse.Namespace( + score=str(score), + out_json=None, + out_csv=None, + time_step=0.25, + window_size=4.0, + register_low="A0", + register_high="C8", + analysis_profile="occupied_space", + observation_mode="event_boundaries", + tie_policy="as_imported", + pitch_sampling_mode=None, + ) + assert _cli_summarize(args) == 1 + assert "summarize failed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# UI launch paths +# --------------------------------------------------------------------------- + + +def test_default_no_argv_launches_ui(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, object] = {} + + def _launch() -> None: + called["ui"] = True + + monkeypatch.setattr("registral_dispersion.app.launch", _launch) + monkeypatch.setattr(sys, "argv", ["registral_dispersion"]) + main() + assert called.get("ui") is True + + +def test_ui_subcommand_forwards_host_and_port(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, object] = {} + + def _launch(*, host=None, port=None, share=False) -> None: + called["host"] = host + called["port"] = port + called["share"] = share + + monkeypatch.setattr("registral_dispersion.app.launch", _launch) + monkeypatch.setattr( + sys, + "argv", + _argv("ui", "--host", "127.0.0.1", "--port", "7860", "--share"), + ) + main() + assert called["host"] == "127.0.0.1" + assert called["port"] == 7860 + assert called["share"] is True + + +def test_unknown_first_token_routes_to_ui_subcommand(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, object] = {} + + def _launch(*, host=None, port=None, share=False) -> None: + called["host"] = host + + monkeypatch.setattr("registral_dispersion.app.launch", _launch) + monkeypatch.setattr(sys, "argv", _argv("--host", "0.0.0.0")) + main() + assert called["host"] == "0.0.0.0" + + +# --------------------------------------------------------------------------- +# concentration-map CLI +# --------------------------------------------------------------------------- + + +def test_cli_analyze_skips_global_summary_when_absent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + score = tmp_path / "score.xml" + score.write_text("", encoding="utf-8") + out = _fake_analyze_out() + out.pop("global_summary") + monkeypatch.setattr( + "registral_dispersion.__main__.run_registral_dispersion_analysis", + lambda _score, _params: out, + ) + args = argparse.Namespace( + score=str(score), + out_dir=str(tmp_path), + prefix="no_summary", + register_low="C3", + register_high="C5", + window_size=4.0, + plot_span=False, + plot_pairwise=False, + plot_entropy=False, + plot_normalized=False, + time_step=0.25, + analysis_profile="occupied_space", + observation_mode="fixed_window", + tie_policy="as_imported", + pitch_sampling_mode=None, + ) + assert _cli_analyze(args) == 0 + assert not (tmp_path / "no_summary_global_summary.csv").exists() + printed = capsys.readouterr().out + assert "global_summary.csv" not in printed + + +def test_concentration_map_success_exits_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + out = tmp_path / "map.png" + monkeypatch.setattr( + "registral_dispersion.__main__.run_concentration_map_to_files", + lambda *_args, **_kwargs: {"outputs": [str(out)]}, + ) + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv( + "concentration-map", + "--score", + str(tmp_path / "score.xml"), + "--out", + str(out), + ), + ) + assert exc_info.value.code == 0 + assert "Wrote" in capsys.readouterr().out + + +def test_concentration_map_failure_exits_nonzero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + out = tmp_path / "map.png" + + def _boom(*_args, **_kwargs): + raise RuntimeError("map failed") + + monkeypatch.setattr("registral_dispersion.__main__.run_concentration_map_to_files", _boom) + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv( + "concentration-map", + "--score", + str(tmp_path / "score.xml"), + "--out", + str(out), + ), + ) + assert exc_info.value.code == 1 + assert "map failed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Integration — real fixture through main() +# --------------------------------------------------------------------------- + + +def test_analyze_integration_with_fixture( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + if not SINGLE_NOTE_XML.is_file(): + pytest.skip("Fixture not found") + out_dir = tmp_path / "cli_out" + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv( + "analyze", + "--score", + str(SINGLE_NOTE_XML), + "--out-dir", + str(out_dir), + "--prefix", + "fixture_run", + "--register-low", + "C3", + "--register-high", + "C5", + "--window-size", + "4", + "--time-step", + "1", + "--tie-policy", + "as_imported", + "--analysis-profile", + "occupied_space", + ), + ) + assert exc_info.value.code == 0 + assert (out_dir / "fixture_run.csv").is_file() + assert (out_dir / "fixture_run.json").is_file() + assert (out_dir / "fixture_run.png").is_file() + assert "Wrote" in capsys.readouterr().out + + +def test_summarize_integration_with_fixture( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + if not SINGLE_NOTE_XML.is_file(): + pytest.skip("Fixture not found") + out_json = tmp_path / "sum.json" + with pytest.raises(SystemExit) as exc_info: + _run_main( + monkeypatch, + _argv( + "summarize", + "--score", + str(SINGLE_NOTE_XML), + "--out-json", + str(out_json), + "--tie-policy", + "merge_ties", + ), + ) + assert exc_info.value.code == 0 + doc = json.loads(out_json.read_text(encoding="utf-8")) + assert "primary_value" in doc + assert "global_summary" in doc + assert "primary_metric:" in capsys.readouterr().out