diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..de396f6 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..ab96bfe --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_api_server.py b/backend/tests/test_api_server.py new file mode 100644 index 0000000..f687576 --- /dev/null +++ b/backend/tests/test_api_server.py @@ -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 diff --git a/backend/tests/test_app_controller.py b/backend/tests/test_app_controller.py new file mode 100644 index 0000000..d1bd371 --- /dev/null +++ b/backend/tests/test_app_controller.py @@ -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 diff --git a/backend/tests/test_main_headless.py b/backend/tests/test_main_headless.py new file mode 100644 index 0000000..2531c5c --- /dev/null +++ b/backend/tests/test_main_headless.py @@ -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'" + ) diff --git a/client/tests/__init__.py b/client/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/tests/test_config.py b/client/tests/test_config.py new file mode 100644 index 0000000..54eef57 --- /dev/null +++ b/client/tests/test_config.py @@ -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" diff --git a/package-lock.json b/package-lock.json index e394983..8ea3558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "local-transcription", - "version": "1.4.0", + "version": "1.4.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "local-transcription", - "version": "1.4.0", + "version": "1.4.16", "dependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", @@ -16,11 +16,241 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2.0.0", + "@testing-library/svelte": "^5.3.1", "@tsconfig/svelte": "^5.0.0", + "jsdom": "^29.0.2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.3" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.6.tgz", + "integrity": "sha512-BXWCh8dHs9GOfpo/fWGDJtDmleta2VePN9rn6WQt3GjEbxzutVF4t0x2pmH+7dbMCLtuv3MlwqRsAuxlzFXqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.7.tgz", + "integrity": "sha512-d2BgqDUOS1Hfp4IzKUZqCNz+Kg3Y88AkaBvJK/ZVSQPU1f7OpPNi7nQTH6/oI47Dkdg+Z3e8Yp6ynOu4UMINAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -465,6 +695,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -865,6 +1113,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", @@ -1169,6 +1424,76 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -1176,6 +1501,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1204,6 +1554,119 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1217,6 +1680,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1227,6 +1713,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1237,6 +1733,26 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1263,6 +1779,41 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1281,6 +1832,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1291,6 +1849,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.6.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", @@ -1298,6 +1866,33 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1358,6 +1953,26 @@ "@typescript-eslint/types": "^8.2.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1391,6 +2006,26 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1401,6 +2036,54 @@ "@types/estree": "^1.0.6" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1418,6 +2101,26 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.2.tgz", + "integrity": "sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1428,6 +2131,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1464,6 +2174,37 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1513,6 +2254,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1527,6 +2300,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -1585,6 +2368,26 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1595,6 +2398,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.55.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", @@ -1647,6 +2464,30 @@ "typescript": ">=5.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1664,6 +2505,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -1678,6 +2575,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1773,6 +2680,178 @@ } } }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 86393bc..28181f5 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,19 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2.0.0", + "@testing-library/svelte": "^5.3.1", "@tsconfig/svelte": "^5.0.0", + "jsdom": "^29.0.2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.3" }, "dependencies": { "@tauri-apps/api": "^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" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 307ea11..8cbbb3c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-transcription" -version = "1.4.5" +version = "1.4.16" dependencies = [ "bytes", "chrono", @@ -1894,6 +1894,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-process", "tauri-plugin-shell", + "tempfile", "tokio", "zip", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d4c1a5d..04820e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,3 +25,6 @@ zip = { version = "2", default-features = false, features = ["deflate"] } bytes = "1" tokio = { version = "1", features = ["full"] } chrono = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/src/sidecar/mod.rs b/src-tauri/src/sidecar/mod.rs index dddbd2c..bc4efc6 100644 --- a/src-tauri/src/sidecar/mod.rs +++ b/src-tauri/src/sidecar/mod.rs @@ -578,3 +578,364 @@ pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), Strin mgr.stop(); 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::(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::(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 = 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\"")); + } +} diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 55a3b62..311ba9b 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -214,7 +214,7 @@ async function handleCheckUpdates() { try { - await backendStore.apiPost("/api/check-updates"); + await backendStore.apiGet("/api/check-update"); } catch (err) { console.error("Failed to check for updates:", err); } @@ -222,7 +222,7 @@ async function handleManagedLogin() { try { - await backendStore.apiPost("/api/remote/login", { + await backendStore.apiPost("/api/login", { email: managedEmail, password: managedPassword, }); @@ -233,7 +233,7 @@ async function handleManagedRegister() { try { - await backendStore.apiPost("/api/remote/register", { + await backendStore.apiPost("/api/register", { email: managedEmail, password: managedPassword, }); diff --git a/src/lib/stores/backend.test.ts b/src/lib/stores/backend.test.ts new file mode 100644 index 0000000..355f5f4 --- /dev/null +++ b/src/lib/stores/backend.test.ts @@ -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"); + }); +}); diff --git a/src/lib/stores/config.test.ts b/src/lib/stores/config.test.ts new file mode 100644 index 0000000..af7470b --- /dev/null +++ b/src/lib/stores/config.test.ts @@ -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(); + }); +}); diff --git a/src/lib/stores/file-extension.test.ts b/src/lib/stores/file-extension.test.ts new file mode 100644 index 0000000..a4d1483 --- /dev/null +++ b/src/lib/stores/file-extension.test.ts @@ -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` + ); + } + }); +}); diff --git a/src/lib/stores/transcriptions.test.ts b/src/lib/stores/transcriptions.test.ts new file mode 100644 index 0000000..a71766d --- /dev/null +++ b/src/lib/stores/transcriptions.test.ts @@ -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); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index ee480ec..4182d2e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,4 +19,8 @@ export default defineConfig({ ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"], }, }, + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, });