"""Тесты доступности уровней AI (`services.ai_levels.detect_ai_levels`). Детект без GPU → `max` недоступен с причиной; `medium` доступен или недоступен по RAM/моделям; ровно три уровня в ответе. Реальное железо/файлы моделей недетерминированы на машине разработчика — `HW_*` подставляются через `monkeypatch.setenv` + `get_settings.cache_clear()` (см. `test_admin_api.py::test_upload_user_avatar_by_admin` за образцом), факт «модель скачана» — через monkeypatch `services.ai_levels._model_downloaded`. """ from collections.abc import Iterator import pytest from core.config import Settings, get_settings from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig from services import ai_levels from services.ai_levels import detect_ai_levels def _cfg() -> InstanceConfig: return InstanceConfig( transcriber=TranscriberConfig(), summarizer=SummarizerConfig(), chat=ChatConfig() ) @pytest.fixture(autouse=True) def _clear_hw_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Гарантировать «чистое» железо (без HW_*) на старте каждого теста и сброс кэша `get_settings` после — тесты этого модуля не должны зависеть друг от друга или от `.env` окружения разработчика.""" for var in ("HW_CPUS", "HW_RAM_MB", "HW_GPU_NAME", "HW_VRAM_MB"): monkeypatch.delenv(var, raising=False) get_settings.cache_clear() try: yield finally: get_settings.cache_clear() @pytest.fixture def _all_models_downloaded(monkeypatch: pytest.MonkeyPatch) -> None: """Считать все модели всех уровней скачанными (изолирует тест от реального диска).""" monkeypatch.setattr(ai_levels, "_model_downloaded", lambda _path: True) @pytest.fixture def _no_models_downloaded(monkeypatch: pytest.MonkeyPatch) -> None: """Считать, что ни одна модель не скачана.""" monkeypatch.setattr(ai_levels, "_model_downloaded", lambda _path: False) def test_settings_treats_empty_hw_env_strings_as_none(monkeypatch: pytest.MonkeyPatch) -> None: """`docker-compose`/`env_file` подставляют `KEY=` из `.env` как пустую строку, не как отсутствие переменной: `install.sh` пишет `HW_VRAM_MB=` пустым на любой машине без NVIDIA GPU (пресеты 1–4), `.env.example` — все четыре `HW_*` пустыми по умолчанию. Без нормализации пустой строки в `None` `Settings()` падает `ValidationError` уже на импорте (`main.py`, `workers/celery_app.py`) — регрессионный тест.""" monkeypatch.setenv("HW_CPUS", "") monkeypatch.setenv("HW_RAM_MB", "") monkeypatch.setenv("HW_GPU_NAME", "") monkeypatch.setenv("HW_VRAM_MB", "") settings = Settings() assert settings.hw_cpus is None assert settings.hw_ram_mb is None assert settings.hw_gpu_name is None assert settings.hw_vram_mb is None def test_detect_ai_levels_returns_exactly_three_levels() -> None: statuses = detect_ai_levels(_cfg()) assert {status.level for status in statuses} == {"min", "medium", "max"} def test_min_available_with_enough_ram_and_models( monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None ) -> None: monkeypatch.setenv("HW_RAM_MB", "16384") get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["min"].available is True assert statuses["min"].reason is None def test_min_unavailable_when_model_not_downloaded(_no_models_downloaded: None) -> None: statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["min"].available is False reason = statuses["min"].reason assert reason is not None assert "не скачана" in reason assert "пресетом 3" in reason def test_medium_unavailable_with_insufficient_ram( monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None ) -> None: monkeypatch.setenv("HW_RAM_MB", "16384") # хватает на min (16 ГБ), мало для medium (32 ГБ) get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["medium"].available is False reason = statuses["medium"].reason assert reason is not None assert "недостаточно RAM: нужно 32 ГБ" in reason def test_medium_available_with_enough_ram_and_models_without_gpu( monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None ) -> None: """`medium` не требует GPU (ADR-004: GPU опционален) — доступен на чистом CPU-железе.""" monkeypatch.setenv("HW_RAM_MB", "32768") get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["medium"].available is True assert statuses["medium"].reason is None def test_medium_unavailable_when_model_not_downloaded( monkeypatch: pytest.MonkeyPatch, _no_models_downloaded: None ) -> None: monkeypatch.setenv("HW_RAM_MB", "32768") get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["medium"].available is False reason = statuses["medium"].reason assert reason is not None assert "не скачана" in reason assert "пресетом 4" in reason def test_max_unavailable_without_gpu( monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None ) -> None: monkeypatch.setenv("HW_RAM_MB", "65536") get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["max"].available is False reason = statuses["max"].reason assert reason is not None assert "требуется GPU NVIDIA ≥16 ГБ VRAM, не обнаружен" in reason def test_max_unavailable_with_insufficient_vram( monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None ) -> None: monkeypatch.setenv("HW_RAM_MB", "65536") monkeypatch.setenv("HW_GPU_NAME", "NVIDIA RTX 3060") monkeypatch.setenv("HW_VRAM_MB", "12288") get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["max"].available is False reason = statuses["max"].reason assert reason is not None assert "обнаружено только 12 ГБ" in reason def test_max_available_with_gpu_ram_and_models( monkeypatch: pytest.MonkeyPatch, _all_models_downloaded: None ) -> None: monkeypatch.setenv("HW_RAM_MB", "65536") monkeypatch.setenv("HW_GPU_NAME", "NVIDIA RTX 4090") monkeypatch.setenv("HW_VRAM_MB", "24576") get_settings.cache_clear() statuses = {status.level: status for status in detect_ai_levels(_cfg())} assert statuses["max"].available is True assert statuses["max"].reason is None