Files
OBLITERATUS/tests/test_cli.py
T
Joseph Magly ff0a298c6d Test Windows console and path portability contracts
Cover legacy console fallbacks, platform temp paths, local UI disk probing, and lazy analysis exports. Bind the new local UI test to the executable source-to-test risk map.
2026-08-15 03:13:27 -04:00

200 lines
8.0 KiB
Python

"""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 all 9 pipeline methods."""
valid_methods = [
"basic", "advanced", "aggressive", "spectral_cascade",
"informed", "surgical", "optimized", "inverted", "nuclear",
]
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. invalid methods are rejected
def test_obliterate_rejects_invalid_method(self):
"""The CLI --method flag rejects unknown method names."""
stderr_text = _capture_exit(
["obliterate", "fake/model", "--method", "nonexistent"],
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
def test_aggregate_accepts_format_metric_min_runs(self):
"""aggregate accepts --format, --metric and --min-runs flags."""
with patch("obliteratus.cli._cmd_aggregate") as mock_cmd:
main([
"aggregate",
"--format", "latex",
"--metric", "refusal_rate",
"--min-runs", "3",
])
mock_cmd.assert_called_once()
args_passed = mock_cmd.call_args[0][0]
assert args_passed.format == "latex"
assert args_passed.metric == "refusal_rate"
assert args_passed.min_runs == 3
def test_aggregate_rejects_invalid_format(self):
"""aggregate rejects unknown --format choices."""
stderr_text = _capture_exit(
["aggregate", "--format", "invalid"],
expect_code=2,
)
assert "invalid choice" in stderr_text.lower()
# 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"
class _EncodingOnlyStdout:
"""Minimal stream stand-in for encoding-selection tests."""
def __init__(self, encoding: str | None) -> None:
self.encoding = encoding
class TestConsoleEncoding:
"""The CLI must remain renderable on legacy Windows code pages."""
@pytest.mark.parametrize("encoding", ["cp1252", "ascii", "not-a-codec"])
def test_console_text_falls_back_when_text_is_not_encodable(self, encoding):
from obliteratus.cli import _console_text
with patch("sys.stdout", _EncodingOnlyStdout(encoding)):
assert _console_text("█→", "FALLBACK") == "FALLBACK"
@pytest.mark.parametrize("encoding", ["utf-8", None])
def test_console_text_keeps_unicode_on_utf8_compatible_streams(self, encoding):
from obliteratus.cli import _console_text
with patch("sys.stdout", _EncodingOnlyStdout(encoding)):
assert _console_text("█→", "FALLBACK") == "█→"
def test_banner_degrades_to_ascii_on_cp1252(self):
from obliteratus.cli import _banner_for_console
with patch("sys.stdout", _EncodingOnlyStdout("cp1252")):
banner = _banner_for_console()
assert "OBLITERATUS" in banner
assert banner.isascii()
def test_main_renders_selected_banner(self):
with (
patch("sys.stdout", _EncodingOnlyStdout("cp1252")),
patch("obliteratus.cli.console") as mock_console,
):
main(["models"])
assert mock_console.print.call_args_list[0].args[0].isascii()