"""Тесты для загрузчика конфига плагинов и factory.""" import sys import types from dataclasses import dataclass from pathlib import Path from typing import Any import pytest from core.plugins.config import PluginsConfig, TranscriberConfig, load_plugins_config from core.plugins.factory import UnknownProviderError, create_summarizer, create_transcriber from core.plugins.faster_whisper import FasterWhisperCPU, FasterWhisperGPU from core.plugins.null import NullSummarizer PLUGINS_YAML = Path(__file__).parent.parent.parent / "config" / "plugins.yaml" def test_load_plugins_config_from_yaml() -> None: config = load_plugins_config(PLUGINS_YAML) assert isinstance(config, PluginsConfig) assert config.transcriber.enabled is True assert config.transcriber.provider == "faster_whisper_cpu" assert config.transcriber.model == "small" assert config.transcriber.language == "ru" assert config.summarizer.provider == "null" assert config.summarizer.chunk_minutes == 20 assert config.chat.enabled is True def test_factory_instantiates_faster_whisper_transcriber_from_config() -> None: config = load_plugins_config(PLUGINS_YAML) transcriber = create_transcriber(config.transcriber) assert isinstance(transcriber, FasterWhisperCPU) assert transcriber.model_name == "small" assert transcriber.language == "ru" # Путь — КОНТРАКТ с backend/services/ai_tiers.py (WHISPER_MODELS_ROOT, # ADR-004): модель уровня хранится в подкаталоге по имени модели, # не в плоском корне тома (см. комментарий в config/plugins.yaml). assert transcriber.download_root == "/models/whisper/small" def test_factory_instantiates_null_summarizer() -> None: config = load_plugins_config(PLUGINS_YAML) summarizer = create_summarizer(config.summarizer) assert isinstance(summarizer, NullSummarizer) assert summarizer.summarize("some transcript") == "" def test_factory_unknown_transcriber_provider_raises() -> None: config = load_plugins_config(PLUGINS_YAML) config.transcriber.provider = "does-not-exist" with pytest.raises(UnknownProviderError): create_transcriber(config.transcriber) def test_factory_unknown_summarizer_provider_raises() -> None: config = load_plugins_config(PLUGINS_YAML) config.summarizer.provider = "does-not-exist" with pytest.raises(UnknownProviderError): create_summarizer(config.summarizer) def test_factory_creates_faster_whisper_without_importing_it( monkeypatch: pytest.MonkeyPatch, ) -> None: """`create_transcriber` для `faster_whisper_cpu` не импортирует пакет `faster_whisper`. Ленивость: тяжёлая зависимость грузится только внутри `transcribe()`, а не при инстанцировании плагина фабрикой (важно для API-процесса). """ monkeypatch.delitem(sys.modules, "faster_whisper", raising=False) config = load_plugins_config(PLUGINS_YAML) transcriber = create_transcriber(config.transcriber) assert isinstance(transcriber, FasterWhisperCPU) assert "faster_whisper" not in sys.modules @dataclass class _FakeRawSegment: """Сегмент, возвращаемый заглушкой `WhisperModel.transcribe`.""" start: float end: float text: str class _FakeWhisperModel: """Заглушка `faster_whisper.WhisperModel`, фиксирующая переданные аргументы.""" last_init_kwargs: dict[str, Any] | None = None last_transcribe_kwargs: dict[str, Any] | None = None def __init__(self, model_size_or_path: str, **kwargs: Any) -> None: self.model_size_or_path = model_size_or_path _FakeWhisperModel.last_init_kwargs = kwargs def transcribe(self, audio_path: str, **kwargs: Any) -> tuple[list[_FakeRawSegment], object]: _FakeWhisperModel.last_transcribe_kwargs = kwargs segments = [ _FakeRawSegment(0.0, 2.0, "длинный сегмент"), _FakeRawSegment(2.0, 2.2, "коротыш"), # 0.2с < 0.3с — отбрасывается _FakeRawSegment(2.2, 2.6, "ровно на границе"), # 0.4с — остаётся ] return segments, object() def test_faster_whisper_transcribe_forwards_params_and_filters_short_segments( monkeypatch: pytest.MonkeyPatch, ) -> None: """Юнит `FasterWhisperCPU` с замоканным `WhisperModel`: прокидывание language/vad_filter/vad_parameters и отбрасывание сегментов короче 0.3с. """ fake_module = types.ModuleType("faster_whisper") fake_module.WhisperModel = _FakeWhisperModel # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "faster_whisper", fake_module) monkeypatch.setattr(FasterWhisperCPU, "_model", None) transcriber = FasterWhisperCPU(model="small", language="ru", download_root="/models/whisper") result = transcriber.transcribe("audio.ogg", language="ru") assert [segment.text for segment in result] == ["длинный сегмент", "ровно на границе"] assert _FakeWhisperModel.last_init_kwargs == { "device": "cpu", "compute_type": "int8", "download_root": "/models/whisper", } assert _FakeWhisperModel.last_transcribe_kwargs == { "language": "ru", "vad_filter": True, "vad_parameters": {"min_silence_duration_ms": 500}, } def test_factory_instantiates_faster_whisper_gpu_transcriber() -> None: """`create_transcriber` для `faster_whisper_gpu` (уровни `medium`/`max`, ADR-004).""" config = TranscriberConfig( provider="faster_whisper_gpu", model="large-v3", options={"download_root": "/models/whisper/large-v3", "compute_type": "float16"}, ) transcriber = create_transcriber(config) assert isinstance(transcriber, FasterWhisperGPU) assert transcriber.model_name == "large-v3" assert transcriber.download_root == "/models/whisper/large-v3" assert transcriber.compute_type == "float16" assert transcriber.device == "cuda" def test_faster_whisper_gpu_transcribe_forwards_cuda_device_and_compute_type( monkeypatch: pytest.MonkeyPatch, ) -> None: """GPU-плагин прокидывает `device="cuda"`/`compute_type` в `WhisperModel`.""" fake_module = types.ModuleType("faster_whisper") fake_module.WhisperModel = _FakeWhisperModel # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "faster_whisper", fake_module) monkeypatch.setattr(FasterWhisperGPU, "_model", None) transcriber = FasterWhisperGPU(model="large-v3", download_root="/models/whisper/large-v3") result = transcriber.transcribe("audio.ogg", language="ru") assert [segment.text for segment in result] == ["длинный сегмент", "ровно на границе"] assert _FakeWhisperModel.last_init_kwargs == { "device": "cuda", "compute_type": "float16", "download_root": "/models/whisper/large-v3", } def test_cpu_and_gpu_transcribers_have_independent_model_singletons( monkeypatch: pytest.MonkeyPatch, ) -> None: """CPU- и GPU-плагины не делят синглтон `_model` — у каждого подкласса свой атрибут.""" monkeypatch.setattr(FasterWhisperCPU, "_model", "cpu-sentinel") monkeypatch.setattr(FasterWhisperGPU, "_model", None) assert FasterWhisperCPU._model == "cpu-sentinel" assert FasterWhisperGPU._model is None