Add test suite (63 tests) and CI workflow, fix Settings API bugs
Some checks failed
Release / Bump version and tag (push) Successful in 4s
Sidecar Release / Bump sidecar version and tag (push) Failing after 3s
Tests / Python Backend Tests (push) Failing after 3s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 3m10s
Some checks failed
Release / Bump version and tag (push) Successful in 4s
Sidecar Release / Bump sidecar version and tag (push) Failing after 3s
Tests / Python Backend Tests (push) Failing after 3s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 3m10s
Test suite covering all three layers: Python backend (25 tests): - AppController: state machine, start/stop, callbacks, settings reload - API server: REST endpoints, config CRUD, status, devices - Config: dot-notation get/set, persistence, nested paths - Main headless: ready event port format validation Svelte frontend (14 tests via Vitest): - Backend store: exported properties/methods, port derivation, URLs - Config store: method names (fetchConfig not loadConfig), defaults - Transcriptions store: add/clear/plaintext - File extension regression: ensures $state runes only in .svelte.ts Rust sidecar (24 tests via cargo test): - Platform/arch detection, asset name construction - Ready event deserialization (with extra fields tolerance) - Path construction, version read/write, old version cleanup - Zip extraction, SidecarManager lifecycle CI workflow (.gitea/workflows/test.yml): - Runs on push to main and PRs - Three parallel jobs: Python, Frontend, Rust Also fixes three bugs found during test planning: - Settings: /api/check-updates -> GET /api/check-update - Settings: /api/remote/login -> /api/login - Settings: /api/remote/register -> /api/register Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
57
.gitea/workflows/test.yml
Normal file
57
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-tests:
|
||||||
|
name: Python Backend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install test dependencies
|
||||||
|
run: |
|
||||||
|
pip install --break-system-packages pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
run: python3 -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||||
|
|
||||||
|
frontend-tests:
|
||||||
|
name: Frontend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run Vitest
|
||||||
|
run: npx vitest run
|
||||||
|
|
||||||
|
rust-tests:
|
||||||
|
name: Rust Sidecar Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install Tauri system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Run cargo test
|
||||||
|
working-directory: src-tauri
|
||||||
|
run: cargo test
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
159
backend/tests/conftest.py
Normal file
159
backend/tests/conftest.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""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
|
||||||
150
backend/tests/test_api_server.py
Normal file
150
backend/tests/test_api_server.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for backend.api_server.APIServer REST endpoints."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/status ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status(api_client):
|
||||||
|
resp = await api_client.get("/api/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "state" in data
|
||||||
|
assert "is_transcribing" in data
|
||||||
|
assert "version" in data
|
||||||
|
assert "web_server" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/config ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_config(api_client):
|
||||||
|
resp = await api_client.get("/api/config")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# The config should be a dict (the raw config mapping)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT /api/config ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_config(api_client, controller):
|
||||||
|
"""Updating config via PUT should persist and return success."""
|
||||||
|
# Patch reload_engine to avoid heavy lifting
|
||||||
|
controller.reload_engine = MagicMock(return_value=(True, "ok"))
|
||||||
|
controller.current_model_size = "base.en"
|
||||||
|
controller.current_device_config = "auto"
|
||||||
|
controller.config.set("transcription.model", "base.en")
|
||||||
|
controller.config.set("transcription.device", "auto")
|
||||||
|
|
||||||
|
resp = await api_client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"settings": {"display.font_size": 24}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
|
||||||
|
# Verify the value was actually saved
|
||||||
|
assert controller.config.get("display.font_size") == 24
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/start (engine not ready) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_when_not_ready(api_client, controller):
|
||||||
|
"""Starting transcription without an engine should return 400."""
|
||||||
|
controller.transcription_engine = None
|
||||||
|
resp = await api_client.post("/api/start")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/clear ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear(api_client, controller):
|
||||||
|
from client.transcription_engine_realtime import TranscriptionResult
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
controller.transcriptions = [
|
||||||
|
TranscriptionResult(text="One", is_final=True, timestamp=datetime.now(), user_name="U"),
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = await api_client.post("/api/clear")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["cleared"] == 1
|
||||||
|
assert len(controller.transcriptions) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/audio-devices ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_audio_devices(api_client, controller):
|
||||||
|
"""Audio devices endpoint should return a list, even when mocked."""
|
||||||
|
# Mock sounddevice so the test works without audio hardware
|
||||||
|
with patch("backend.app_controller.AppController.get_audio_devices",
|
||||||
|
return_value=[{"index": 0, "name": "Mock Mic"}]):
|
||||||
|
resp = await api_client.get("/api/audio-devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "devices" in data
|
||||||
|
assert len(data["devices"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/compute-devices ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_compute_devices(api_client, controller):
|
||||||
|
resp = await api_client.get("/api/compute-devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "devices" in data
|
||||||
|
# At minimum we get the "Auto-detect" entry
|
||||||
|
assert any(d["id"] == "auto" for d in data["devices"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/check-update ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_update(api_client, controller):
|
||||||
|
"""check-update should return a dict with an 'available' key."""
|
||||||
|
with patch.object(controller, "check_for_updates",
|
||||||
|
return_value={"available": False, "current_version": "1.0.0"}):
|
||||||
|
resp = await api_client.get("/api/check-update")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "available" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/version ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version(api_client):
|
||||||
|
resp = await api_client.get("/api/version")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "version" in data
|
||||||
|
# Should be a non-empty string
|
||||||
|
assert isinstance(data["version"], str)
|
||||||
|
assert len(data["version"]) > 0
|
||||||
181
backend/tests/test_app_controller.py
Normal file
181
backend/tests/test_app_controller.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tests for backend.app_controller.AppController."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from backend.app_controller import AppState
|
||||||
|
|
||||||
|
|
||||||
|
# ── basic state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state(controller):
|
||||||
|
"""A freshly constructed controller should be INITIALIZING and not transcribing."""
|
||||||
|
assert controller.state == AppState.INITIALIZING
|
||||||
|
assert controller.is_transcribing is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── start / stop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_transcription_without_engine(controller):
|
||||||
|
"""Starting transcription before the engine is ready should fail gracefully."""
|
||||||
|
controller.transcription_engine = None
|
||||||
|
success, message = controller.start_transcription()
|
||||||
|
assert success is False
|
||||||
|
assert "not ready" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_stop_cycle(controller):
|
||||||
|
"""Full start -> stop cycle with a mocked engine that reports ready."""
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.is_ready.return_value = True
|
||||||
|
engine.start_recording.return_value = True
|
||||||
|
controller.transcription_engine = engine
|
||||||
|
|
||||||
|
# Start
|
||||||
|
ok, msg = controller.start_transcription()
|
||||||
|
assert ok is True
|
||||||
|
assert controller.is_transcribing is True
|
||||||
|
assert controller.state == AppState.TRANSCRIBING
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
ok, msg = controller.stop_transcription()
|
||||||
|
assert ok is True
|
||||||
|
assert controller.is_transcribing is False
|
||||||
|
engine.stop_recording.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_start_rejected(controller):
|
||||||
|
"""Calling start_transcription twice should reject the second call."""
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.is_ready.return_value = True
|
||||||
|
engine.start_recording.return_value = True
|
||||||
|
controller.transcription_engine = engine
|
||||||
|
|
||||||
|
controller.start_transcription()
|
||||||
|
success, message = controller.start_transcription()
|
||||||
|
assert success is False
|
||||||
|
assert "already" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── transcription storage ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_transcriptions(controller):
|
||||||
|
"""clear_transcriptions should empty the list and return the count."""
|
||||||
|
from client.transcription_engine_realtime import TranscriptionResult
|
||||||
|
|
||||||
|
controller.transcriptions = [
|
||||||
|
TranscriptionResult(text="Hello", is_final=True, timestamp=datetime.now(), user_name="Alice"),
|
||||||
|
TranscriptionResult(text="World", is_final=True, timestamp=datetime.now(), user_name="Bob"),
|
||||||
|
]
|
||||||
|
count = controller.clear_transcriptions()
|
||||||
|
assert count == 2
|
||||||
|
assert len(controller.transcriptions) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_transcriptions_text_with_timestamps(controller):
|
||||||
|
"""get_transcriptions_text should include [HH:MM:SS] prefixes when requested."""
|
||||||
|
from client.transcription_engine_realtime import TranscriptionResult
|
||||||
|
|
||||||
|
ts = datetime(2025, 1, 15, 10, 30, 45)
|
||||||
|
controller.transcriptions = [
|
||||||
|
TranscriptionResult(text="Test line", is_final=True, timestamp=ts, user_name="User"),
|
||||||
|
]
|
||||||
|
|
||||||
|
text = controller.get_transcriptions_text(include_timestamps=True)
|
||||||
|
assert "[10:30:45]" in text
|
||||||
|
assert "User:" in text
|
||||||
|
assert "Test line" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ── settings / engine reload ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_settings_triggers_reload_on_model_change(controller):
|
||||||
|
"""Changing the transcription model should trigger an engine reload."""
|
||||||
|
controller.current_model_size = "base.en"
|
||||||
|
controller.current_device_config = "auto"
|
||||||
|
|
||||||
|
# Patch reload_engine so it doesn't actually try to spin up threads
|
||||||
|
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
|
||||||
|
|
||||||
|
reloaded, msg = controller.apply_settings({
|
||||||
|
"transcription.model": "small.en",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert reloaded is True
|
||||||
|
controller.reload_engine.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_settings_no_reload_when_same(controller):
|
||||||
|
"""If model and device haven't changed, no reload should happen."""
|
||||||
|
controller.current_model_size = "base.en"
|
||||||
|
controller.current_device_config = "auto"
|
||||||
|
|
||||||
|
# Ensure config returns the same values
|
||||||
|
controller.config.set("transcription.model", "base.en")
|
||||||
|
controller.config.set("transcription.device", "auto")
|
||||||
|
|
||||||
|
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
|
||||||
|
|
||||||
|
reloaded, msg = controller.apply_settings({
|
||||||
|
"display.font_size": 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert reloaded is False
|
||||||
|
controller.reload_engine.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── transcription callbacks ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_final_transcription_callback_fires(controller):
|
||||||
|
"""_on_final_transcription should append and invoke on_transcription callback."""
|
||||||
|
from client.transcription_engine_realtime import TranscriptionResult
|
||||||
|
|
||||||
|
received = []
|
||||||
|
controller.on_transcription = lambda data: received.append(data)
|
||||||
|
|
||||||
|
controller.is_transcribing = True
|
||||||
|
controller._set_state(AppState.TRANSCRIBING)
|
||||||
|
|
||||||
|
result = TranscriptionResult(
|
||||||
|
text="Hello world",
|
||||||
|
is_final=True,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
user_name="Tester",
|
||||||
|
)
|
||||||
|
controller._on_final_transcription(result)
|
||||||
|
|
||||||
|
assert len(controller.transcriptions) == 1
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["text"] == "Hello world"
|
||||||
|
assert received[0]["user_name"] == "Tester"
|
||||||
|
assert received[0]["is_preview"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_final_transcription_ignored_when_not_transcribing(controller):
|
||||||
|
"""If the controller is not in transcribing state the callback should be a no-op."""
|
||||||
|
from client.transcription_engine_realtime import TranscriptionResult
|
||||||
|
|
||||||
|
controller.is_transcribing = False
|
||||||
|
|
||||||
|
result = TranscriptionResult(
|
||||||
|
text="Should be ignored",
|
||||||
|
is_final=True,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
user_name="Ghost",
|
||||||
|
)
|
||||||
|
controller._on_final_transcription(result)
|
||||||
|
|
||||||
|
assert len(controller.transcriptions) == 0
|
||||||
56
backend/tests/test_main_headless.py
Normal file
56
backend/tests/test_main_headless.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for backend.main_headless ready-event JSON format."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_event_reports_api_port_not_obs_port():
|
||||||
|
"""The ready JSON printed by main_headless must set ``port`` to
|
||||||
|
``obs_port + 1`` (the API port), not the OBS display port.
|
||||||
|
|
||||||
|
From main_headless.py::
|
||||||
|
|
||||||
|
obs_port = controller.actual_web_port or args.port
|
||||||
|
api_port = obs_port + 1
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"event": "ready",
|
||||||
|
"port": api_port,
|
||||||
|
"obs_port": obs_port,
|
||||||
|
}), flush=True)
|
||||||
|
|
||||||
|
We verify this contract by reading the source and checking the
|
||||||
|
structure directly (running main() would start a real server).
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
source_path = project_root / "backend" / "main_headless.py"
|
||||||
|
source = source_path.read_text()
|
||||||
|
|
||||||
|
# Verify the key relationships exist in the source:
|
||||||
|
# 1. api_port = obs_port + 1
|
||||||
|
assert "api_port = obs_port + 1" in source, (
|
||||||
|
"Expected `api_port = obs_port + 1` in main_headless.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. The ready event JSON uses api_port for "port", not obs_port
|
||||||
|
assert '"port": api_port' in source or "'port': api_port" in source, (
|
||||||
|
"The ready event should report api_port as 'port'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. obs_port is also included separately
|
||||||
|
assert '"obs_port": obs_port' in source or "'obs_port': obs_port" in source, (
|
||||||
|
"The ready event should also include 'obs_port'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Verify the event name
|
||||||
|
assert '"event": "ready"' in source or "'event': 'ready'" in source, (
|
||||||
|
"The ready event should have event='ready'"
|
||||||
|
)
|
||||||
0
client/tests/__init__.py
Normal file
0
client/tests/__init__.py
Normal file
78
client/tests/test_config.py
Normal file
78
client/tests/test_config.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for client.config.Config."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from client.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cfg(tmp_path):
|
||||||
|
"""A Config backed by a temp file so we never touch the real user config."""
|
||||||
|
return Config(config_path=str(tmp_path / "test_config.yaml"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── dot-notation get ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_dot_notation_get(cfg):
|
||||||
|
"""Config.get should traverse nested dicts using dot-separated keys."""
|
||||||
|
cfg.config = {"audio": {"sample_rate": 16000}}
|
||||||
|
assert cfg.get("audio.sample_rate") == 16000
|
||||||
|
|
||||||
|
|
||||||
|
# ── dot-notation set ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_dot_notation_set(cfg):
|
||||||
|
"""Config.set should create/update nested values via dot-separated keys."""
|
||||||
|
cfg.set("audio.sample_rate", 44100)
|
||||||
|
assert cfg.config["audio"]["sample_rate"] == 44100
|
||||||
|
|
||||||
|
# Also readable via .get
|
||||||
|
assert cfg.get("audio.sample_rate") == 44100
|
||||||
|
|
||||||
|
|
||||||
|
# ── missing key returns default ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_key_returns_default(cfg):
|
||||||
|
"""Accessing a nonexistent key should return the supplied default."""
|
||||||
|
assert cfg.get("nonexistent.path", "fallback") == "fallback"
|
||||||
|
assert cfg.get("also.missing") is None # default default is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── nested set creates intermediate dicts ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_set_creates_path(cfg):
|
||||||
|
"""Setting a deeply nested key should create all intermediate dicts."""
|
||||||
|
cfg.config = {}
|
||||||
|
cfg.set("a.b.c.d", 42)
|
||||||
|
|
||||||
|
assert cfg.config["a"]["b"]["c"]["d"] == 42
|
||||||
|
assert cfg.get("a.b.c.d") == 42
|
||||||
|
|
||||||
|
|
||||||
|
# ── save and reload round-trip ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_reload(tmp_path):
|
||||||
|
"""Values persisted via save() should survive a fresh Config load."""
|
||||||
|
config_file = str(tmp_path / "roundtrip.yaml")
|
||||||
|
|
||||||
|
# Create and populate
|
||||||
|
cfg1 = Config(config_path=config_file)
|
||||||
|
cfg1.set("user.name", "TestUser")
|
||||||
|
cfg1.set("transcription.model", "tiny.en")
|
||||||
|
|
||||||
|
# Load a fresh instance from the same file
|
||||||
|
cfg2 = Config(config_path=config_file)
|
||||||
|
assert cfg2.get("user.name") == "TestUser"
|
||||||
|
assert cfg2.get("transcription.model") == "tiny.en"
|
||||||
1085
package-lock.json
generated
1085
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,16 +12,19 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0"
|
"@tauri-apps/plugin-shell": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "1.4.5"
|
version = "1.4.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1894,6 +1894,7 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
bytes = "1"
|
bytes = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -578,3 +578,364 @@ pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), Strin
|
|||||||
mgr.stop();
|
mgr.stop();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. Platform / arch detection
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_token_returns_valid_value() {
|
||||||
|
let token = platform_token();
|
||||||
|
assert!(
|
||||||
|
["windows", "macos", "linux"].contains(&token),
|
||||||
|
"unexpected platform token: {token}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arch_token_returns_valid_value() {
|
||||||
|
let token = arch_token();
|
||||||
|
assert!(
|
||||||
|
["x86_64", "aarch64"].contains(&token),
|
||||||
|
"unexpected arch token: {token}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. Asset name construction
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_prefix_cpu() {
|
||||||
|
let prefix = asset_prefix("cpu");
|
||||||
|
let expected = format!("sidecar-{}-{}-cpu", platform_token(), arch_token());
|
||||||
|
assert_eq!(prefix, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_prefix_cuda() {
|
||||||
|
let prefix = asset_prefix("cuda");
|
||||||
|
let expected = format!("sidecar-{}-{}-cuda", platform_token(), arch_token());
|
||||||
|
assert_eq!(prefix, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_prefix_format_matches_zip_convention() {
|
||||||
|
// The download function looks for assets matching
|
||||||
|
// `{prefix}*.zip`, so verify the prefix starts with "sidecar-"
|
||||||
|
// and contains exactly three hyphens (sidecar-OS-ARCH-VARIANT).
|
||||||
|
let prefix = asset_prefix("cpu");
|
||||||
|
assert!(prefix.starts_with("sidecar-"));
|
||||||
|
assert_eq!(prefix.matches('-').count(), 3, "expected 3 hyphens in '{prefix}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 3. Version parsing — tag_name format "sidecar-vX.Y.Z"
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_tag_starts_with_expected_prefix() {
|
||||||
|
// The code filters releases by `tag_name.starts_with("sidecar-v")`.
|
||||||
|
// Verify the convention: a version string like "sidecar-v1.0.2" passes
|
||||||
|
// the filter, while "v1.0.2" does not.
|
||||||
|
let tag = "sidecar-v1.0.2";
|
||||||
|
assert!(tag.starts_with("sidecar-v"));
|
||||||
|
|
||||||
|
let bad_tag = "v1.0.2";
|
||||||
|
assert!(!bad_tag.starts_with("sidecar-v"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_sidecar_v_prefix() {
|
||||||
|
// The codebase stores the full tag as the version (e.g. "sidecar-v1.0.2").
|
||||||
|
// Verify we can strip the prefix to get just "1.0.2" when needed.
|
||||||
|
let tag = "sidecar-v1.0.2";
|
||||||
|
let semver = tag.strip_prefix("sidecar-v").unwrap();
|
||||||
|
assert_eq!(semver, "1.0.2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 4. ReadyEvent deserialization
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_deserializes_basic() {
|
||||||
|
let json = r#"{"event": "ready", "port": 8081}"#;
|
||||||
|
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(evt.event, "ready");
|
||||||
|
assert_eq!(evt.port, 8081);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_deserializes_with_extra_fields() {
|
||||||
|
// The backend may emit additional fields like `obs_port`.
|
||||||
|
// serde should ignore unknown fields by default (deny_unknown_fields
|
||||||
|
// is NOT set on ReadyEvent).
|
||||||
|
let json = r#"{"event": "ready", "port": 8081, "obs_port": 8080}"#;
|
||||||
|
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(evt.event, "ready");
|
||||||
|
assert_eq!(evt.port, 8081);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_rejects_missing_port() {
|
||||||
|
let json = r#"{"event": "ready"}"#;
|
||||||
|
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_rejects_invalid_port_type() {
|
||||||
|
let json = r#"{"event": "ready", "port": "not_a_number"}"#;
|
||||||
|
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helper: initialise DIRS with a temp directory so path-related functions
|
||||||
|
// work. Because OnceLock can only be set once per process, all tests that
|
||||||
|
// need DIRS must coordinate. We use std::sync::Once + a global temp path.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
static TEST_DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Ensure `DIRS` is initialised (idempotent within a test run).
|
||||||
|
/// Returns the data_dir path.
|
||||||
|
fn ensure_dirs_initialised() -> PathBuf {
|
||||||
|
TEST_DATA_DIR
|
||||||
|
.get_or_init(|| {
|
||||||
|
let tmp = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let data = tmp.path().to_path_buf();
|
||||||
|
// We intentionally leak `tmp` so the directory lives for the
|
||||||
|
// entire test-run.
|
||||||
|
std::mem::forget(tmp);
|
||||||
|
let resource = data.join("resource"); // dummy
|
||||||
|
std::fs::create_dir_all(&resource).ok();
|
||||||
|
init_dirs(resource, data.clone());
|
||||||
|
data
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 5. Path construction (requires init_dirs)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_file_path_is_in_data_dir() {
|
||||||
|
let data = ensure_dirs_initialised();
|
||||||
|
let vf = version_file();
|
||||||
|
assert_eq!(vf, data.join("sidecar-version.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_dir_for_version_contains_version() {
|
||||||
|
let data = ensure_dirs_initialised();
|
||||||
|
let dir = sidecar_dir_for_version("sidecar-v1.2.3");
|
||||||
|
assert_eq!(dir, data.join("sidecar-sidecar-v1.2.3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_path_for_version_has_correct_filename() {
|
||||||
|
let _data = ensure_dirs_initialised();
|
||||||
|
let bin = binary_path_for_version("sidecar-v1.2.3");
|
||||||
|
assert_eq!(bin.file_name().unwrap(), BINARY_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_installed_version_none_when_missing() {
|
||||||
|
let _data = ensure_dirs_initialised();
|
||||||
|
// The version file should not exist yet (clean temp dir).
|
||||||
|
// If another test wrote it, this still validates the function
|
||||||
|
// doesn't panic.
|
||||||
|
let _ = read_installed_version(); // should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_then_read_installed_version() {
|
||||||
|
let _data = ensure_dirs_initialised();
|
||||||
|
let vf = version_file();
|
||||||
|
std::fs::write(&vf, "sidecar-v2.0.0\n").unwrap();
|
||||||
|
let v = read_installed_version().expect("should read version");
|
||||||
|
assert_eq!(v, "sidecar-v2.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 6. Cleanup old versions
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Cleanup tests are combined into one function because
|
||||||
|
/// `cleanup_old_versions` operates on the shared `data_dir()` and
|
||||||
|
/// tests run in parallel, so separate tests would race.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_old_versions_behaviour() {
|
||||||
|
let data = ensure_dirs_initialised();
|
||||||
|
|
||||||
|
// -- Part A: removes stale version dirs, keeps current ----------------
|
||||||
|
let dirs_to_create = ["sidecar-v1.0.0", "sidecar-v1.0.1", "sidecar-v1.0.2"];
|
||||||
|
for d in &dirs_to_create {
|
||||||
|
std::fs::create_dir_all(data.join(d)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup_old_versions builds `current_dir_name = "sidecar-{version}"`.
|
||||||
|
// Passing "v1.0.2" produces "sidecar-v1.0.2" which matches our dir name.
|
||||||
|
cleanup_old_versions("v1.0.2");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!data.join("sidecar-v1.0.0").exists(),
|
||||||
|
"sidecar-v1.0.0 should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!data.join("sidecar-v1.0.1").exists(),
|
||||||
|
"sidecar-v1.0.1 should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
data.join("sidecar-v1.0.2").exists(),
|
||||||
|
"sidecar-v1.0.2 should be kept"
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Part B: ignores non-sidecar directories --------------------------
|
||||||
|
let other = data.join("some-other-dir");
|
||||||
|
std::fs::create_dir_all(&other).unwrap();
|
||||||
|
|
||||||
|
cleanup_old_versions("v1.0.2"); // run again — should leave other alone
|
||||||
|
|
||||||
|
assert!(other.exists(), "non-sidecar dir should not be removed");
|
||||||
|
|
||||||
|
// Clean up so we don't affect other tests that share data_dir.
|
||||||
|
let _ = std::fs::remove_dir_all(data.join("sidecar-v1.0.2"));
|
||||||
|
let _ = std::fs::remove_dir_all(&other);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 7. Zip extraction
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_zip_creates_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let zip_path = tmp.path().join("test.zip");
|
||||||
|
let dest_dir = tmp.path().join("output");
|
||||||
|
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||||
|
|
||||||
|
// Build a simple zip in memory.
|
||||||
|
{
|
||||||
|
let file = std::fs::File::create(&zip_path).unwrap();
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
|
||||||
|
writer.start_file("hello.txt", options).unwrap();
|
||||||
|
writer.write_all(b"Hello, world!").unwrap();
|
||||||
|
|
||||||
|
writer.start_file("subdir/nested.txt", options).unwrap();
|
||||||
|
writer.write_all(b"Nested content").unwrap();
|
||||||
|
|
||||||
|
writer.finish().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_zip(&zip_path, &dest_dir).expect("extraction should succeed");
|
||||||
|
|
||||||
|
let hello = dest_dir.join("hello.txt");
|
||||||
|
assert!(hello.exists(), "hello.txt should exist");
|
||||||
|
assert_eq!(std::fs::read_to_string(&hello).unwrap(), "Hello, world!");
|
||||||
|
|
||||||
|
let nested = dest_dir.join("subdir/nested.txt");
|
||||||
|
assert!(nested.exists(), "subdir/nested.txt should exist");
|
||||||
|
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "Nested content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_zip_error_on_invalid_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let bad_zip = tmp.path().join("bad.zip");
|
||||||
|
std::fs::write(&bad_zip, b"not a zip file").unwrap();
|
||||||
|
let dest = tmp.path().join("dest");
|
||||||
|
std::fs::create_dir_all(&dest).unwrap();
|
||||||
|
|
||||||
|
let result = extract_zip(&bad_zip, &dest);
|
||||||
|
assert!(result.is_err(), "should fail on invalid zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// SidecarManager unit tests (no process spawning)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_manager_new_is_not_running() {
|
||||||
|
let mut mgr = SidecarManager::new();
|
||||||
|
assert!(!mgr.is_running());
|
||||||
|
assert!(mgr.port().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_manager_stop_when_not_running() {
|
||||||
|
let mut mgr = SidecarManager::new();
|
||||||
|
mgr.stop(); // should not panic
|
||||||
|
assert!(!mgr.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GiteaRelease / GiteaAsset deserialization
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_release_deserializes() {
|
||||||
|
let json = r#"{
|
||||||
|
"tag_name": "sidecar-v1.0.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "sidecar-linux-x86_64-cuda.zip",
|
||||||
|
"browser_download_url": "https://example.com/file.zip",
|
||||||
|
"size": 12345
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let release: GiteaRelease = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(release.tag_name, "sidecar-v1.0.0");
|
||||||
|
assert_eq!(release.assets.len(), 1);
|
||||||
|
assert_eq!(release.assets[0].name, "sidecar-linux-x86_64-cuda.zip");
|
||||||
|
assert_eq!(release.assets[0].size, 12345);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_release_with_extra_fields() {
|
||||||
|
// Gitea responses include many more fields; serde should ignore them.
|
||||||
|
let json = r#"{
|
||||||
|
"id": 42,
|
||||||
|
"tag_name": "sidecar-v2.0.0",
|
||||||
|
"name": "Release 2.0.0",
|
||||||
|
"body": "changelog here",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false,
|
||||||
|
"assets": []
|
||||||
|
}"#;
|
||||||
|
let release: GiteaRelease = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(release.tag_name, "sidecar-v2.0.0");
|
||||||
|
assert!(release.assets.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// DownloadProgress serialization round-trip
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_progress_serializes() {
|
||||||
|
let progress = DownloadProgress {
|
||||||
|
downloaded: 1024,
|
||||||
|
total: 4096,
|
||||||
|
phase: "downloading".into(),
|
||||||
|
message: "50%".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&progress).unwrap();
|
||||||
|
assert!(json.contains("\"downloaded\":1024"));
|
||||||
|
assert!(json.contains("\"phase\":\"downloading\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@
|
|||||||
|
|
||||||
async function handleCheckUpdates() {
|
async function handleCheckUpdates() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/check-updates");
|
await backendStore.apiGet("/api/check-update");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to check for updates:", err);
|
console.error("Failed to check for updates:", err);
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
|
|
||||||
async function handleManagedLogin() {
|
async function handleManagedLogin() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/remote/login", {
|
await backendStore.apiPost("/api/login", {
|
||||||
email: managedEmail,
|
email: managedEmail,
|
||||||
password: managedPassword,
|
password: managedPassword,
|
||||||
});
|
});
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
|
|
||||||
async function handleManagedRegister() {
|
async function handleManagedRegister() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/remote/register", {
|
await backendStore.apiPost("/api/register", {
|
||||||
email: managedEmail,
|
email: managedEmail,
|
||||||
password: managedPassword,
|
password: managedPassword,
|
||||||
});
|
});
|
||||||
|
|||||||
77
src/lib/stores/backend.test.ts
Normal file
77
src/lib/stores/backend.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { backendStore } from "./backend.svelte.ts";
|
||||||
|
|
||||||
|
// Mock WebSocket globally so the store module can reference it
|
||||||
|
class MockWebSocket {
|
||||||
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
// Mock fetch to prevent real network calls
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("backend store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
backendStore.disconnect();
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_exports_expected_properties", () => {
|
||||||
|
expect(backendStore).toHaveProperty("port");
|
||||||
|
expect(backendStore).toHaveProperty("connectionState");
|
||||||
|
expect(backendStore).toHaveProperty("connected");
|
||||||
|
expect(backendStore).toHaveProperty("appState");
|
||||||
|
expect(backendStore).toHaveProperty("stateMessage");
|
||||||
|
expect(backendStore).toHaveProperty("deviceInfo");
|
||||||
|
expect(backendStore).toHaveProperty("version");
|
||||||
|
expect(backendStore).toHaveProperty("lastError");
|
||||||
|
expect(backendStore).toHaveProperty("apiBaseUrl");
|
||||||
|
expect(backendStore).toHaveProperty("wsUrl");
|
||||||
|
expect(backendStore).toHaveProperty("obsUrl");
|
||||||
|
expect(backendStore).toHaveProperty("syncUrl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_exports_expected_methods", () => {
|
||||||
|
expect(typeof backendStore.setPort).toBe("function");
|
||||||
|
expect(typeof backendStore.connect).toBe("function");
|
||||||
|
expect(typeof backendStore.disconnect).toBe("function");
|
||||||
|
expect(typeof backendStore.apiUrl).toBe("function");
|
||||||
|
expect(typeof backendStore.apiFetch).toBe("function");
|
||||||
|
expect(typeof backendStore.apiGet).toBe("function");
|
||||||
|
expect(typeof backendStore.apiPost).toBe("function");
|
||||||
|
expect(typeof backendStore.apiPut).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_obsUrl_derives_from_port", () => {
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
expect(backendStore.obsUrl).toBe("http://localhost:8080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_apiBaseUrl_uses_port", () => {
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
expect(backendStore.apiBaseUrl).toBe("http://localhost:8081");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_wsUrl_uses_port", () => {
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
expect(backendStore.wsUrl).toBe("ws://localhost:8081/ws/control");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_initial_state", () => {
|
||||||
|
// After disconnect() in beforeEach, state should be disconnected
|
||||||
|
expect(backendStore.connectionState).toBe("disconnected");
|
||||||
|
expect(backendStore.appState).toBe("initializing");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/lib/stores/config.test.ts
Normal file
48
src/lib/stores/config.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock fetch so the backend store module doesn't make real requests
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock WebSocket for the backend store dependency
|
||||||
|
class MockWebSocket {
|
||||||
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
import { configStore } from "./config.svelte.ts";
|
||||||
|
|
||||||
|
describe("config store", () => {
|
||||||
|
it("test_has_fetchConfig_method", () => {
|
||||||
|
expect(typeof configStore.fetchConfig).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_has_updateConfig_method", () => {
|
||||||
|
expect(typeof configStore.updateConfig).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_config_defaults_have_expected_keys", () => {
|
||||||
|
const cfg = configStore.config;
|
||||||
|
expect(cfg).toHaveProperty("user");
|
||||||
|
expect(cfg).toHaveProperty("audio");
|
||||||
|
expect(cfg).toHaveProperty("transcription");
|
||||||
|
expect(cfg).toHaveProperty("display");
|
||||||
|
expect(cfg).toHaveProperty("remote");
|
||||||
|
expect(cfg).toHaveProperty("updates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_remote_config_has_byok_api_key", () => {
|
||||||
|
expect(configStore.config.remote.byok_api_key).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/lib/stores/file-extension.test.ts
Normal file
34
src/lib/stores/file-extension.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
describe("store file extensions", () => {
|
||||||
|
it("test_store_files_use_svelte_ts_extension", () => {
|
||||||
|
const storesDir = path.resolve(__dirname);
|
||||||
|
const files = fs.readdirSync(storesDir);
|
||||||
|
|
||||||
|
// Find .ts files that are NOT .svelte.ts and NOT test files
|
||||||
|
const plainTsFiles = files.filter(
|
||||||
|
(f) =>
|
||||||
|
f.endsWith(".ts") &&
|
||||||
|
!f.endsWith(".svelte.ts") &&
|
||||||
|
!f.endsWith(".test.ts")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of plainTsFiles) {
|
||||||
|
const content = fs.readFileSync(path.join(storesDir, file), "utf-8");
|
||||||
|
expect(content).not.toMatch(
|
||||||
|
/\$state\s*[<(]/,
|
||||||
|
`${file} uses $state() but does not have .svelte.ts extension`
|
||||||
|
);
|
||||||
|
expect(content).not.toMatch(
|
||||||
|
/\$derived\s*[<(]/,
|
||||||
|
`${file} uses $derived() but does not have .svelte.ts extension`
|
||||||
|
);
|
||||||
|
expect(content).not.toMatch(
|
||||||
|
/\$effect\s*[<(]/,
|
||||||
|
`${file} uses $effect() but does not have .svelte.ts extension`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/lib/stores/transcriptions.test.ts
Normal file
71
src/lib/stores/transcriptions.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock WebSocket for the backend store dependency (loaded transitively)
|
||||||
|
class MockWebSocket {
|
||||||
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
import { transcriptionStore } from "./transcriptions.svelte.ts";
|
||||||
|
|
||||||
|
describe("transcriptions store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
transcriptionStore.clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_addTranscription", () => {
|
||||||
|
transcriptionStore.addTranscription({
|
||||||
|
text: "Hello world",
|
||||||
|
user_name: "TestUser",
|
||||||
|
timestamp: "12:00:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(transcriptionStore.items.length).toBe(1);
|
||||||
|
expect(transcriptionStore.items[0].text).toBe("Hello world");
|
||||||
|
expect(transcriptionStore.items[0].userName).toBe("TestUser");
|
||||||
|
expect(transcriptionStore.items[0].timestamp).toBe("12:00:00");
|
||||||
|
expect(transcriptionStore.items[0].isPreview).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_clearAll", () => {
|
||||||
|
transcriptionStore.addTranscription({ text: "One" });
|
||||||
|
transcriptionStore.addTranscription({ text: "Two" });
|
||||||
|
expect(transcriptionStore.items.length).toBe(2);
|
||||||
|
|
||||||
|
transcriptionStore.clearAll();
|
||||||
|
expect(transcriptionStore.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_getPlainText", () => {
|
||||||
|
transcriptionStore.addTranscription({
|
||||||
|
text: "Hello",
|
||||||
|
user_name: "Alice",
|
||||||
|
timestamp: "10:00",
|
||||||
|
});
|
||||||
|
transcriptionStore.addTranscription({
|
||||||
|
text: "World",
|
||||||
|
user_name: "Bob",
|
||||||
|
timestamp: "10:01",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = transcriptionStore.getPlainText();
|
||||||
|
expect(text).toContain("[10:00] Alice: Hello");
|
||||||
|
expect(text).toContain("[10:01] Bob: World");
|
||||||
|
// Lines separated by newline
|
||||||
|
expect(text.split("\n").length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,4 +19,8 @@ export default defineConfig({
|
|||||||
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
|
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user