"""CLI dispatch tests for obliteratus.cli.main(). These tests verify argument parsing and subcommand routing without downloading real models or running any pipeline. They use ``unittest.mock.patch`` to capture stdout/stderr and ``pytest.raises(SystemExit)`` for argparse exits. """ from __future__ import annotations from io import StringIO from unittest.mock import patch import pytest from obliteratus.cli import main # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _capture_exit(argv: list[str] | None, *, expect_code: int | None = None): """Call main(argv), expecting SystemExit; return captured stderr text.""" buf = StringIO() with pytest.raises(SystemExit) as exc_info, patch("sys.stderr", buf): main(argv) if expect_code is not None: assert exc_info.value.code == expect_code return buf.getvalue() # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestCLIDispatch: """Test suite for CLI argument parsing and subcommand dispatch.""" # 1. No args -> prints help / exits with error def test_main_no_args_prints_help(self): """Calling main() with no args should exit (subcommand is required).""" stderr_text = _capture_exit([], expect_code=2) # argparse prints usage info to stderr on error assert "usage" in stderr_text.lower() or "required" in stderr_text.lower() # 2. models command lists models without error def test_models_command(self): """Calling main(['models']) should list models without raising.""" with patch("obliteratus.cli.console") as mock_console: main(["models"]) # console.print is called at least once to render the table assert mock_console.print.call_count >= 1 # 3. obliterate without model arg -> error def test_obliterate_requires_model(self): """Calling main(['obliterate']) without a model arg should error.""" stderr_text = _capture_exit(["obliterate"], expect_code=2) assert "model" in stderr_text.lower() or "required" in stderr_text.lower() # 4. obliterate --method accepts valid methods def test_obliterate_valid_methods(self): """Test that --method accepts basic, advanced, and aggressive.""" valid_methods = ["basic", "advanced", "aggressive"] for method in valid_methods: # Patch the actual pipeline execution so nothing runs with patch("obliteratus.cli._cmd_abliterate") as mock_cmd: main(["obliterate", "fake/model", "--method", method]) mock_cmd.assert_called_once() args_passed = mock_cmd.call_args[0][0] assert args_passed.method == method # 4b. informed is NOT a valid --method choice on the CLI def test_obliterate_rejects_informed_method(self): """The CLI --method flag does not accept 'informed' (separate pipeline).""" stderr_text = _capture_exit( ["obliterate", "fake/model", "--method", "informed"], expect_code=2, ) assert "invalid choice" in stderr_text.lower() # 5. run requires config path def test_run_requires_config(self): """Calling main(['run']) without a config path should error.""" stderr_text = _capture_exit(["run"], expect_code=2) assert "config" in stderr_text.lower() or "required" in stderr_text.lower() # 6. aggregate with nonexistent dir handles gracefully def test_aggregate_command_missing_dir(self): """Calling main(['aggregate']) with nonexistent dir should handle gracefully.""" with patch("obliteratus.cli.console") as mock_console: main(["aggregate", "--dir", "/nonexistent/path/to/nowhere"]) # The command prints a message about no contributions found and returns printed_text = " ".join( str(call) for call in mock_console.print.call_args_list ) assert "no contributions found" in printed_text.lower() or mock_console.print.called # 7. --help flag prints help def test_help_flag(self): """Calling main(['--help']) should print help and exit 0.""" buf = StringIO() with pytest.raises(SystemExit) as exc_info, patch("sys.stdout", buf): main(["--help"]) assert exc_info.value.code == 0 output = buf.getvalue() assert "obliteratus" in output.lower() or "usage" in output.lower() # 8. interactive subcommand is registered def test_interactive_command_exists(self): """Verify 'interactive' subcommand is registered and dispatches.""" with patch("obliteratus.cli._cmd_interactive") as mock_cmd: main(["interactive"]) mock_cmd.assert_called_once() # 9. --contribute and --contribute-notes are accepted on obliterate def test_contribute_flags_on_obliterate(self): """Verify --contribute and --contribute-notes are accepted args.""" with patch("obliteratus.cli._cmd_abliterate") as mock_cmd: main([ "obliterate", "fake/model", "--contribute", "--contribute-notes", "Testing contribution system", ]) mock_cmd.assert_called_once() args_passed = mock_cmd.call_args[0][0] assert args_passed.contribute is True assert args_passed.contribute_notes == "Testing contribution system"