| """Unit tests for the LocalGPU4BitBackend and AutoBackend classes.""" |
|
|
| import json |
|
|
| import pytest |
| from pytest_mock import MockerFixture |
|
|
|
|
| class TestLocalGPU4BitBackendIsAvailable: |
| """Tests for the static ``is_available`` method.""" |
|
|
| def test_returns_false_when_torch_not_importable(self, mocker: MockerFixture) -> None: |
| """When torch cannot be imported, is_available returns False.""" |
| import builtins |
|
|
| original_import = builtins.__import__ |
|
|
| def mock_import(name, *args, **kwargs): |
| if name == "torch": |
| raise ImportError("No module named 'torch'") |
| return original_import(name, *args, **kwargs) |
|
|
| mocker.patch("builtins.__import__", side_effect=mock_import) |
|
|
| from importlib import reload |
|
|
| import alien_obfuscator.riddle_generator as mod |
|
|
| reload(mod) |
| result = mod.LocalGPU4BitBackend.is_available() |
| assert result is False |
|
|
| def test_returns_false_when_no_cuda(self, mocker: MockerFixture) -> None: |
| """When torch is available but CUDA is not, is_available returns False.""" |
| mock_torch = mocker.MagicMock() |
| mock_torch.cuda.is_available.return_value = False |
| mocker.patch.dict("sys.modules", {"torch": mock_torch}) |
|
|
| from alien_obfuscator.riddle_generator import LocalGPU4BitBackend |
|
|
| result = LocalGPU4BitBackend.is_available() |
| assert result is False |
|
|
| def test_returns_true_when_torch_and_cuda_available(self, mocker: MockerFixture) -> None: |
| """When torch and CUDA are both available, is_available returns True.""" |
| mock_torch = mocker.MagicMock() |
| mock_torch.cuda.is_available.return_value = True |
| mocker.patch.dict("sys.modules", {"torch": mock_torch}) |
|
|
| from alien_obfuscator.riddle_generator import LocalGPU4BitBackend |
|
|
| result = LocalGPU4BitBackend.is_available() |
| assert result is True |
|
|
|
|
| class TestLocalGPU4BitBackend: |
| """Tests for the local GPU backend.""" |
|
|
| def test_init_raises_without_cuda(self, mocker: MockerFixture) -> None: |
| """When CUDA is not available, __init__ raises RuntimeError.""" |
| mock_torch = mocker.MagicMock() |
| mock_torch.cuda.is_available.return_value = False |
| mocker.patch.dict("sys.modules", {"torch": mock_torch}) |
|
|
| from alien_obfuscator.riddle_generator import LocalGPU4BitBackend |
|
|
| with pytest.raises(RuntimeError, match="CUDA-capable GPU"): |
| LocalGPU4BitBackend("dummy-model") |
|
|
| def test_init_loads_model_on_startup(self, mocker: MockerFixture) -> None: |
| """When CUDA is available, the model and tokenizer are loaded on init.""" |
| mock_torch = mocker.MagicMock() |
| mock_torch.cuda.is_available.return_value = True |
| mock_torch.float16 = "float16" |
| mock_torch.no_grad.return_value.__enter__ = mocker.MagicMock() |
| mocker.patch.dict("sys.modules", {"torch": mock_torch}) |
|
|
| mock_tokenizer = mocker.MagicMock() |
| mock_tokenizer.pad_token = None |
| mock_tokenizer.eos_token = "[EOS]" |
| mock_model = mocker.MagicMock() |
|
|
| mock_auto_model = mocker.MagicMock() |
| mock_auto_model.from_pretrained.return_value = mock_model |
| mock_auto_tokenizer = mocker.MagicMock() |
| mock_auto_tokenizer.from_pretrained.return_value = mock_tokenizer |
| mock_bnb = mocker.MagicMock() |
|
|
| mock_transformers = mocker.MagicMock() |
| mock_transformers.AutoModelForCausalLM = mock_auto_model |
| mock_transformers.AutoTokenizer = mock_auto_tokenizer |
| mock_transformers.BitsAndBytesConfig = mock_bnb |
| mocker.patch.dict("sys.modules", {"transformers": mock_transformers}) |
|
|
| from alien_obfuscator.riddle_generator import LocalGPU4BitBackend |
|
|
| backend = LocalGPU4BitBackend("dummy-model", quantize="fp16") |
|
|
| assert backend._model is mock_model |
| assert backend._tokenizer is mock_tokenizer |
| mock_auto_tokenizer.from_pretrained.assert_called_once_with("dummy-model") |
| mock_auto_model.from_pretrained.assert_called_once() |
|
|
|
|
| class TestAutoBackend: |
| """Tests for the configurable auto-backend with fallback chain.""" |
|
|
| def test_default_order_modal_local_mock(self, mocker: MockerFixture) -> None: |
| """Default fallback chain is modal → local → mock when all available.""" |
| mocker.patch.dict("os.environ", {"MODAL_API_URL": "https://foo.modal.run"}, clear=True) |
|
|
| mock_torch = mocker.MagicMock() |
| mock_torch.cuda.is_available.return_value = True |
| mock_torch.float16 = "float16" |
| mocker.patch.dict("sys.modules", {"torch": mock_torch}) |
|
|
| mock_transformers = mocker.MagicMock() |
| mock_tokenizer_cls = mocker.MagicMock() |
| mock_tokenizer = mocker.MagicMock() |
| mock_tokenizer.pad_token = None |
| mock_tokenizer.eos_token = "[EOS]" |
| mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer |
| mock_model_cls = mocker.MagicMock() |
| mock_model_cls.from_pretrained.return_value = mocker.MagicMock() |
| mock_transformers.AutoTokenizer = mock_tokenizer_cls |
| mock_transformers.AutoModelForCausalLM = mock_model_cls |
| mock_transformers.BitsAndBytesConfig = mocker.MagicMock() |
| mocker.patch.dict("sys.modules", {"transformers": mock_transformers}) |
|
|
| mocker.patch("alien_obfuscator.riddle_generator.ModalBackend.__init__", return_value=None) |
|
|
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| backend = AutoBackend("test-model") |
| names = [type(b).__name__ for b in backend._backends] |
| assert names == ["ModalBackend", "LocalGPU4BitBackend", "MockBackend"] |
|
|
| def test_custom_fallback_order(self, mocker: MockerFixture) -> None: |
| """Custom fallback_order is respected.""" |
| mocker.patch.dict("os.environ", {}, clear=True) |
| mocker.patch( |
| "alien_obfuscator.riddle_generator.LocalGPU4BitBackend.is_available", |
| return_value=False, |
| ) |
|
|
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| backend = AutoBackend("test-model", fallback_order=["local", "mock"]) |
| names = [type(b).__name__ for b in backend._backends] |
| |
| assert names == ["MockBackend"] |
|
|
| def test_skips_unavailable_modal(self, mocker: MockerFixture) -> None: |
| """When MODAL_API_URL is not set, modal is skipped.""" |
| mocker.patch.dict("os.environ", {}, clear=True) |
| mocker.patch( |
| "alien_obfuscator.riddle_generator.LocalGPU4BitBackend.is_available", |
| return_value=False, |
| ) |
|
|
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| backend = AutoBackend("test-model") |
| names = [type(b).__name__ for b in backend._backends] |
| assert names == ["MockBackend"] |
|
|
| def test_skips_unavailable_local(self, mocker: MockerFixture) -> None: |
| """When local GPU is not available, it is skipped.""" |
| mocker.patch.dict("os.environ", {"MODAL_API_URL": "https://foo.modal.run"}, clear=True) |
| mocker.patch( |
| "alien_obfuscator.riddle_generator.LocalGPU4BitBackend.is_available", |
| return_value=False, |
| ) |
| mocker.patch( |
| "alien_obfuscator.riddle_generator.ModalBackend.__init__", |
| return_value=None, |
| ) |
|
|
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| backend = AutoBackend("test-model", fallback_order=["local", "modal", "mock"]) |
| names = [type(b).__name__ for b in backend._backends] |
| assert names == ["ModalBackend", "MockBackend"] |
|
|
| def test_tries_first_then_falls_back(self, mocker: MockerFixture) -> None: |
| """When first backend fails, the second is tried.""" |
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| mock_primary = mocker.MagicMock() |
| mock_primary.generate.side_effect = RuntimeError("boom") |
| mock_fallback = mocker.MagicMock() |
| mock_fallback.generate.return_value = ( |
| '{"riddle":"r","options":["a","b","c","d","e"],"correct_index":0,"theme":"t"}' |
| ) |
|
|
| backend = AutoBackend.__new__(AutoBackend) |
| backend._backends = [mock_primary, mock_fallback] |
|
|
| result = backend.generate("test prompt") |
| mock_primary.generate.assert_called_once() |
| mock_fallback.generate.assert_called_once() |
| data = json.loads(result) |
| assert data["riddle"] == "r" |
|
|
| def test_falls_through_all_to_mock(self, mocker: MockerFixture) -> None: |
| """When all backends fail, mock always succeeds.""" |
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| mock_one = mocker.MagicMock() |
| mock_one.generate.side_effect = RuntimeError("fail1") |
| mock_two = mocker.MagicMock() |
| mock_two.generate.side_effect = RuntimeError("fail2") |
| mock_mock = mocker.MagicMock() |
| mock_mock.generate.return_value = '{"riddle":"r","options":["a","b","c","d","e"],"correct_index":0,"theme":"t"}' |
|
|
| backend = AutoBackend.__new__(AutoBackend) |
| backend._backends = [mock_one, mock_two, mock_mock] |
|
|
| result = backend.generate("test prompt") |
| mock_one.generate.assert_called_once() |
| mock_two.generate.assert_called_once() |
| mock_mock.generate.assert_called_once() |
| data = json.loads(result) |
| assert data["riddle"] == "r" |
|
|
| def test_unknown_backend_name_skipped(self, mocker: MockerFixture) -> None: |
| """Unknown backend names in fallback_order are silently skipped.""" |
| mocker.patch.dict("os.environ", {}, clear=True) |
|
|
| from alien_obfuscator.riddle_generator import AutoBackend |
|
|
| backend = AutoBackend("test-model", fallback_order=["bogus", "mock"]) |
| names = [type(b).__name__ for b in backend._backends] |
| assert names == ["MockBackend"] |
|
|