diff --git a/ci/test-risk-map.json b/ci/test-risk-map.json index ea2f35c..3c9e91d 100644 --- a/ci/test-risk-map.json +++ b/ci/test-risk-map.json @@ -110,6 +110,7 @@ "required_tests": [ "tests/test_cli.py", "tests/test_cli_boundaries.py", + "tests/test_local_ui_portability.py", "tests/test_remote_boundaries.py", "tests/test_remote_contracts.py", "tests/conditional/test_network_services.py", @@ -496,7 +497,11 @@ "path": "obliteratus/local_ui.py", "risk_class": "mixed-runtime", "risk": "Gradio construction, launch configuration, authentication, and signals", - "required_tests": ["tests/test_cli.py", "tests/conditional/test_operator_ui.py"], + "required_tests": [ + "tests/test_cli.py", + "tests/test_local_ui_portability.py", + "tests/conditional/test_operator_ui.py" + ], "conditional_gates": ["operator-ui"] }, { diff --git a/tests/test_cli.py b/tests/test_cli.py index 43fbeed..8926c9b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -154,3 +154,46 @@ class TestCLIDispatch: 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() diff --git a/tests/test_cli_boundaries.py b/tests/test_cli_boundaries.py index 4292658..310f513 100644 --- a/tests/test_cli_boundaries.py +++ b/tests/test_cli_boundaries.py @@ -58,6 +58,16 @@ def test_main_routes_remote_commands(monkeypatch, argv, target): command.assert_called_once() +def test_tourney_default_output_uses_platform_temp_directory(monkeypatch, tmp_path): + command = Mock() + monkeypatch.setattr(cli, "_cmd_tourney", command) + monkeypatch.setattr(cli.tempfile, "gettempdir", lambda: str(tmp_path)) + + cli.main(["tourney", "local/model"]) + + assert command.call_args.args[0].output_dir == str(tmp_path / "obliteratus_tourney") + + def test_refusal_max_tokens_cli_default_and_positive_override(monkeypatch): command = Mock() monkeypatch.setattr(cli, "_cmd_abliterate", command) diff --git a/tests/test_local_ui_portability.py b/tests/test_local_ui_portability.py new file mode 100644 index 0000000..c42ecad --- /dev/null +++ b/tests/test_local_ui_portability.py @@ -0,0 +1,35 @@ +"""Portable local-launcher rendering and filesystem contracts.""" + +from __future__ import annotations + +from unittest.mock import Mock + +from obliteratus import local_ui + + +class _EncodingOnlyStdout: + def __init__(self, encoding: str | None) -> None: + self.encoding = encoding + + +def test_local_ui_banner_degrades_to_ascii_on_cp1252(monkeypatch): + monkeypatch.setattr(local_ui.sys, "stdout", _EncodingOnlyStdout("cp1252")) + banner = local_ui._banner_for_console() + assert "OBLITERATUS" in banner + assert banner.isascii() + + +def test_local_ui_banner_keeps_block_art_on_utf8(monkeypatch): + monkeypatch.setattr(local_ui.sys, "stdout", _EncodingOnlyStdout("utf-8")) + assert "░" in local_ui._banner_for_console() + + +def test_system_info_queries_the_platform_temp_directory(monkeypatch, tmp_path): + disk_free = Mock(return_value=12.5) + monkeypatch.setattr(local_ui.tempfile, "gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr(local_ui, "_get_disk_free_gb", disk_free) + monkeypatch.setattr(local_ui, "console", Mock()) + + local_ui._print_system_info([]) + + disk_free.assert_called_once_with(str(tmp_path)) diff --git a/tests/test_package_export_contracts.py b/tests/test_package_export_contracts.py index 94b0150..6727c7e 100644 --- a/tests/test_package_export_contracts.py +++ b/tests/test_package_export_contracts.py @@ -2,9 +2,13 @@ from __future__ import annotations +import subprocess +import sys + import pytest import obliteratus +import obliteratus.analysis as analysis @pytest.mark.parametrize( @@ -33,3 +37,34 @@ def test_documented_lazy_export_resolves(name): def test_unknown_lazy_export_raises_attribute_error(): with pytest.raises(AttributeError, match="has no attribute 'not_an_export'"): getattr(obliteratus, "not_an_export") + + +def test_analysis_export_map_covers_the_documented_surface(): + assert set(analysis._LAZY_IMPORTS) == set(analysis.__all__) + assert analysis.CrossLayerAlignmentAnalyzer.__name__ == "CrossLayerAlignmentAnalyzer" + assert "CrossLayerAlignmentAnalyzer" in dir(analysis) + + +def test_unknown_analysis_export_raises_attribute_error(): + with pytest.raises(AttributeError, match="has no attribute 'not_an_export'"): + getattr(analysis, "not_an_export") + + +def test_importing_analysis_package_does_not_eagerly_import_torch(): + probe = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; import obliteratus.analysis as analysis; " + "assert 'torch' not in sys.modules; " + "assert not any(name.startswith('obliteratus.analysis.') " + "for name in sys.modules); " + "assert analysis.__all__" + ), + ], + check=False, + capture_output=True, + text=True, + ) + assert probe.returncode == 0, probe.stderr diff --git a/tests/test_telemetry_failure_contracts.py b/tests/test_telemetry_failure_contracts.py index 4dbd31b..fdfd1d9 100644 --- a/tests/test_telemetry_failure_contracts.py +++ b/tests/test_telemetry_failure_contracts.py @@ -59,7 +59,9 @@ def test_telemetry_directory_has_ephemeral_fallback(monkeypatch, tmp_path): monkeypatch.setattr(telemetry, "_ON_HF_SPACES", False) monkeypatch.setattr(telemetry, "_test_writable", lambda _path: False) monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home")) - assert telemetry._telemetry_dir() == Path("/tmp/obliteratus_telemetry") + platform_temp = tmp_path / "platform-temp" + monkeypatch.setattr(telemetry.tempfile, "gettempdir", lambda: str(platform_temp)) + assert telemetry._telemetry_dir() == platform_temp / "obliteratus_telemetry" class _HubApi: