Files
OBLITERATUS/tests/test_cli.py
T

400 lines
14 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
import json
from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock
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"
@pytest.mark.parametrize("flag", ["--prompt-pairs-file", "--prompt-pair-file"])
@pytest.mark.parametrize("command", ["obliterate", "abliterate"])
def test_prompt_pairs_file_flag_is_available_on_obliterate_and_alias(
self,
command,
flag,
tmp_path,
):
"""Explicit prompt-pair files are parsed for both commands and flag spellings."""
path = tmp_path / "pairs.json"
path.write_text("{}", encoding="utf-8")
with patch("obliteratus.cli._cmd_abliterate") as mock_cmd:
main([command, "fake/model", flag, str(path)])
args_passed = mock_cmd.call_args[0][0]
assert args_passed.prompt_pairs_file == str(path)
@pytest.mark.parametrize("flag", ["--prompt-pairs-file", "--prompt-pair-file"])
@pytest.mark.parametrize("command", ["obliterate", "abliterate"])
def test_prompt_pairs_file_is_mutually_exclusive_with_residue_files(
self,
command,
flag,
tmp_path,
):
"""Explicit prompt-pair files and mined residue construction cannot be mixed."""
path = tmp_path / "pairs.json"
path.write_text("{}", encoding="utf-8")
stderr_text = _capture_exit(
[
command,
"fake/model",
flag,
str(path),
"--residue-file",
"audit.json",
],
expect_code=2,
)
assert "not allowed with argument" in stderr_text.lower()
@pytest.mark.parametrize(
("option", "value"),
[
("--dataset", "custom"),
("--residue-weight", "7"),
("--residue-max", "3"),
],
)
@pytest.mark.parametrize("flag", ["--prompt-pairs-file", "--prompt-pair-file"])
@pytest.mark.parametrize("command", ["obliterate", "abliterate"])
def test_prompt_pairs_file_rejects_residue_only_options(
self,
command,
flag,
option,
value,
tmp_path,
):
"""Explicit prompt-pair files cannot be mixed with residue-only options."""
path = tmp_path / "pairs.json"
path.write_text("{}", encoding="utf-8")
stderr_text = _capture_exit(
[command, "fake/model", flag, str(path), option, value],
expect_code=2,
)
assert option in stderr_text
assert "can only be used with --residue-file" in stderr_text
def test_cmd_abliterate_wires_prompt_pairs_file_into_pipeline(tmp_path):
"""Loaded prompt-pair files are passed directly to AbliterationPipeline."""
path = tmp_path / "pairs.json"
path.write_text(
json.dumps(
{
"harmful": [f"harm {index}" for index in range(5)],
"harmless": [f"safe {index}" for index in range(5)],
}
),
encoding="utf-8",
)
result_path = tmp_path / "result"
result_path.mkdir()
pipeline = MagicMock()
pipeline.run.return_value = str(result_path)
class FakeLive:
def __init__(self, *_args, **_kwargs):
self.update = MagicMock()
def __enter__(self):
return self
def __exit__(self, *_args):
return False
args = SimpleNamespace(
model="org/model",
output_dir=str(tmp_path / "out"),
device="cpu",
dtype="float32",
method="basic",
n_directions=1,
direction_method=None,
regularization=None,
refinement_passes=1,
min_layer_fraction=None,
max_layer_fraction=None,
harmless_pc_count=None,
shield_concept_count=None,
shield_ridge=None,
shield_residualize=None,
shield_layer_penalty=None,
projection_target=None,
projection_row_fraction=None,
quantization=None,
gpu_memory_utilization=None,
large_model=False,
verify_sample_size=1,
refusal_max_tokens=1,
residue_file=[],
dataset="builtin",
residue_weight=5,
residue_max=None,
prompt_pairs_file=str(path),
contribute=False,
contribute_notes="",
)
with (
patch("rich.live.Live", FakeLive),
patch("obliteratus.abliterate.AbliterationPipeline", return_value=pipeline) as factory,
patch("obliteratus.telemetry.maybe_send_pipeline_report"),
):
from obliteratus import cli
cli._cmd_abliterate(args)
assert factory.call_args.kwargs["harmful_prompts"] == [f"harm {index}" for index in range(5)]
assert factory.call_args.kwargs["harmless_prompts"] == [f"safe {index}" for index in range(5)]
args.prompt_pairs_file = None
pipeline.reset_mock()
pipeline.run.return_value = str(result_path)
with (
patch("rich.live.Live", FakeLive),
patch("obliteratus.abliterate.AbliterationPipeline", return_value=pipeline) as factory,
patch("obliteratus.telemetry.maybe_send_pipeline_report"),
):
from obliteratus import cli
cli._cmd_abliterate(args)
assert "harmful_prompts" not in factory.call_args.kwargs
assert "harmless_prompts" not in factory.call_args.kwargs
def test_cmd_abliterate_reports_invalid_prompt_pairs_file(tmp_path):
from obliteratus import cli
args = SimpleNamespace(
model="org/model",
output_dir=str(tmp_path / "out"),
device="cpu",
dtype="float32",
method="basic",
n_directions=1,
direction_method=None,
regularization=None,
refinement_passes=1,
min_layer_fraction=None,
max_layer_fraction=None,
harmless_pc_count=None,
shield_concept_count=None,
shield_ridge=None,
shield_residualize=None,
shield_layer_penalty=None,
projection_target=None,
projection_row_fraction=None,
quantization=None,
gpu_memory_utilization=None,
large_model=False,
verify_sample_size=1,
refusal_max_tokens=1,
residue_file=[],
dataset="builtin",
residue_weight=5,
residue_max=None,
prompt_pairs_file=str(tmp_path / "missing.json"),
contribute=False,
contribute_notes="",
)
with pytest.raises(SystemExit) as exc:
cli._cmd_abliterate(args)
assert exc.value.code == 2
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()