160 lines
4.6 KiB
Python
160 lines
4.6 KiB
Python
|
|
"""Shared fixtures for backend tests.
|
||
|
|
|
||
|
|
Heavy third-party modules (torch, sounddevice, numpy, RealtimeSTT, etc.) are
|
||
|
|
stubbed at the *sys.modules* level before any backend code is imported. This
|
||
|
|
lets the test suite run on a plain Python install without GPU drivers, audio
|
||
|
|
hardware, or heavyweight ML libraries.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
import types
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
# ── Project root on sys.path ────────────────────────────────────────
|
||
|
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||
|
|
if str(project_root) not in sys.path:
|
||
|
|
sys.path.insert(0, str(project_root))
|
||
|
|
|
||
|
|
|
||
|
|
# ── Stub heavy modules before anything imports them ─────────────────
|
||
|
|
|
||
|
|
def _stub(name: str) -> types.ModuleType:
|
||
|
|
"""Create a stub module and register it in sys.modules if not already present."""
|
||
|
|
if name in sys.modules:
|
||
|
|
return sys.modules[name]
|
||
|
|
mod = types.ModuleType(name)
|
||
|
|
sys.modules[name] = mod
|
||
|
|
return mod
|
||
|
|
|
||
|
|
|
||
|
|
# numpy -- must behave like a real module for `import numpy as np`
|
||
|
|
_np = _stub("numpy")
|
||
|
|
_np.float32 = float
|
||
|
|
_np.float64 = float
|
||
|
|
_np.int16 = int
|
||
|
|
_np.ndarray = MagicMock
|
||
|
|
_np.array = MagicMock(return_value=MagicMock())
|
||
|
|
_np.zeros = MagicMock(return_value=MagicMock())
|
||
|
|
_np.frombuffer = MagicMock(return_value=MagicMock())
|
||
|
|
|
||
|
|
# torch + sub-modules
|
||
|
|
_torch = _stub("torch")
|
||
|
|
_torch.cuda = MagicMock()
|
||
|
|
_torch.cuda.is_available = MagicMock(return_value=False)
|
||
|
|
_torch.backends = MagicMock()
|
||
|
|
_torch.backends.mps = MagicMock()
|
||
|
|
_torch.backends.mps.is_available = MagicMock(return_value=False)
|
||
|
|
_stub("torch.cuda")
|
||
|
|
_stub("torch.backends")
|
||
|
|
_stub("torch.backends.mps")
|
||
|
|
_stub("torchaudio")
|
||
|
|
|
||
|
|
# sounddevice
|
||
|
|
_sd = _stub("sounddevice")
|
||
|
|
_sd.query_devices = MagicMock(return_value=[])
|
||
|
|
|
||
|
|
# RealtimeSTT (imported by transcription_engine_realtime)
|
||
|
|
_rtstt = _stub("RealtimeSTT")
|
||
|
|
_rtstt.AudioToTextRecorder = MagicMock
|
||
|
|
|
||
|
|
# faster_whisper (sometimes imported transitively)
|
||
|
|
_stub("faster_whisper")
|
||
|
|
|
||
|
|
# noisereduce
|
||
|
|
_stub("noisereduce")
|
||
|
|
|
||
|
|
# scipy
|
||
|
|
_scipy = _stub("scipy")
|
||
|
|
_stub("scipy.signal")
|
||
|
|
_stub("scipy.io")
|
||
|
|
_stub("scipy.io.wavfile")
|
||
|
|
|
||
|
|
# webrtcvad
|
||
|
|
_stub("webrtcvad")
|
||
|
|
|
||
|
|
# openwakeword
|
||
|
|
_stub("openwakeword")
|
||
|
|
|
||
|
|
# pvporcupine
|
||
|
|
_stub("pvporcupine")
|
||
|
|
|
||
|
|
# PySide6 (should not be needed, but just in case)
|
||
|
|
_stub("PySide6")
|
||
|
|
_stub("PySide6.QtWidgets")
|
||
|
|
_stub("PySide6.QtCore")
|
||
|
|
_stub("PySide6.QtGui")
|
||
|
|
|
||
|
|
# websockets
|
||
|
|
_ws = _stub("websockets")
|
||
|
|
_ws.connect = MagicMock
|
||
|
|
|
||
|
|
# deepgram (cloud transcription)
|
||
|
|
_stub("deepgram")
|
||
|
|
|
||
|
|
|
||
|
|
# ── Fixtures ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_config(tmp_path):
|
||
|
|
"""Return a Config object backed by a temporary file.
|
||
|
|
|
||
|
|
This avoids touching the real user config at ~/.local-transcription/.
|
||
|
|
"""
|
||
|
|
config_file = tmp_path / "test_config.yaml"
|
||
|
|
from client.config import Config
|
||
|
|
|
||
|
|
config = Config(config_path=str(config_file))
|
||
|
|
return config
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def controller(mock_config):
|
||
|
|
"""Return an AppController wired to *mock_config* without starting heavy
|
||
|
|
subsystems (engine, web server, device manager).
|
||
|
|
|
||
|
|
The transcription engine, web server thread, and DeviceManager are all
|
||
|
|
replaced with lightweight mocks so the test suite can run without a GPU,
|
||
|
|
audio hardware, or a free network port.
|
||
|
|
"""
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
with patch("backend.app_controller.DeviceManager") as MockDM, \
|
||
|
|
patch("backend.app_controller.RealtimeTranscriptionEngine"), \
|
||
|
|
patch("backend.app_controller.DeepgramTranscriptionEngine"), \
|
||
|
|
patch("backend.app_controller.TranscriptionWebServer"), \
|
||
|
|
patch("backend.app_controller.ServerSyncClient"):
|
||
|
|
|
||
|
|
# DeviceManager stub
|
||
|
|
dm_instance = MagicMock()
|
||
|
|
dm_instance.get_device_info.return_value = [("cpu", "CPU")]
|
||
|
|
dm_instance.get_device_for_whisper.return_value = "cpu"
|
||
|
|
MockDM.return_value = dm_instance
|
||
|
|
|
||
|
|
from backend.app_controller import AppController
|
||
|
|
|
||
|
|
ctrl = AppController(config=mock_config)
|
||
|
|
yield ctrl
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def api_client(controller):
|
||
|
|
"""Return an httpx.AsyncClient speaking ASGI to the APIServer's FastAPI app.
|
||
|
|
|
||
|
|
Usage in tests::
|
||
|
|
|
||
|
|
async def test_something(api_client):
|
||
|
|
resp = await api_client.get("/api/status")
|
||
|
|
assert resp.status_code == 200
|
||
|
|
"""
|
||
|
|
from backend.api_server import APIServer
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
api = APIServer(controller)
|
||
|
|
|
||
|
|
transport = httpx.ASGITransport(app=api.app)
|
||
|
|
client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
|
||
|
|
return client
|