* fix: leftover engine repair, just --no-sync, and ggml verify from #713 #713 only rewrote a broken config.json toward whisper_cpp, so a reinstall that kept the old engine still died when vosk was the extra that installed. verify_configured_engine now prefers SELECTED_ENGINE, then any working extra, and the vosk fallback rewrites an existing file. just recipes use uv run --no-sync so they do not prune extras from just deps-all. CONTRIBUTING lists libgirepository-2.0-dev for Ubuntu 24.04. An unverified ggml file is not loaded if unlink fails, and an unpinned name is refused rather than deleted. * fix(appimage): retry tooling downloads on curl reset * fix(build): bootstrap .venv for --no-sync just recipes and cover the contributor path in CI --------- Co-authored-by: Sergey Silaev <hello@8am.dev>
851 lines
35 KiB
Python
851 lines
35 KiB
Python
"""
|
|
Coverage boost tests targeting major gaps in recognition_manager and ibus_engine.
|
|
|
|
Key focus areas:
|
|
- Model download methods with progress tracking
|
|
- Audio reconnection logic
|
|
- IBus engine utility functions
|
|
"""
|
|
|
|
import base64
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Mock GI imports before importing any vocalinux modules that use gi.
|
|
# On CI, real gi/IBus packages are installed; without mocks, importing
|
|
# ibus_engine would connect to a real IBus daemon and hang.
|
|
if "gi" not in sys.modules:
|
|
sys.modules["gi"] = MagicMock()
|
|
if "gi.repository" not in sys.modules:
|
|
sys.modules["gi.repository"] = MagicMock()
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
from vocalinux.utils.model_checksums import VERIFICATION_STAMP_NAME, expected_for
|
|
|
|
|
|
def _make_manager(engine="whisper_cpp", **kw):
|
|
"""Create a SpeechRecognitionManager with mocked initialization."""
|
|
with patch.object(SpeechRecognitionManager, "_init_vosk"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whisper"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
mgr = SpeechRecognitionManager(
|
|
engine=engine, model_size="small", language="en-us", defer_download=True, **kw
|
|
)
|
|
# Ensure vosk_model_map is set (normally done in _init_vosk)
|
|
if not hasattr(mgr, "vosk_model_map"):
|
|
# The names _init_vosk() would pick for en-us. They have to be
|
|
# real: the download path looks each one up in the checksum
|
|
# manifest to stamp the tree it extracts.
|
|
mgr.vosk_model_map = {
|
|
"small": "vosk-model-small-en-us-0.15",
|
|
"medium": "vosk-model-en-us-0.22",
|
|
"large": "vosk-model-en-us-0.22",
|
|
}
|
|
return mgr
|
|
|
|
|
|
# A real zip, embedded rather than built here: by the time this module is
|
|
# imported, other test modules have already replaced zipfile/BytesIO in
|
|
# sys.modules with mocks, so building one here yields empty bytes. Its single
|
|
# entry sits under the directory VOSK's small en-us archive unpacks to, because
|
|
# that is the tree the downloader stamps after extracting.
|
|
VOSK_ZIP_BYTES = base64.b64decode(
|
|
"UEsDBBQAAAAIAHV2F12oDuXYFgAAAIgTAAAoAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2Zp"
|
|
"bmFsLm1kbO3BMQEAAADCoPVPbQo/oAAAAACAtwFQSwECFAMUAAAACAB1dhddqA7l2BYAAACIEwAAKAAAAAAA"
|
|
"AAAAAAAAgAEAAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2ZpbmFsLm1kbFBLBQYAAAAAAQAB"
|
|
"AFYAAABcAAAAAAA="
|
|
)
|
|
|
|
|
|
MODEL_NAME = "vosk-model-small-en-us-0.15"
|
|
|
|
# Same shape, but its top-level directory is not the model name: the downloader
|
|
# has to notice rather than report a success that installed nothing.
|
|
WRONG_LAYOUT_ZIP_BYTES = base64.b64decode(
|
|
"UEsDBBQAAAAIAHeGF11E9MkCEQAAANAHAAAcAAAAc29tZS1vdGhlci1uYW1lL2FtL2ZpbmFsLm1kbGNgGAWj"
|
|
"YBSMglEwCkbBUAcAUEsBAhQDFAAAAAgAd4YXXUT0yQIRAAAA0AcAABwAAAAAAAAAAAAAAIABAAAAAHNvbWUt"
|
|
"b3RoZXItbmFtZS9hbS9maW5hbC5tZGxQSwUGAAAAAAEAAQBKAAAASwAAAAAA"
|
|
)
|
|
|
|
|
|
class FakeRequestError(Exception):
|
|
"""Stands in for requests.exceptions.RequestException.
|
|
|
|
A leaked mock makes ``except requests.exceptions.RequestException`` a
|
|
TypeError, so the mocked module needs a real class here. It must not be
|
|
``Exception`` itself, or that clause swallows every other failure the
|
|
downloader is supposed to surface unwrapped.
|
|
"""
|
|
|
|
|
|
def _fake_clock(step=0.2):
|
|
"""Monotonic stand-in for time.time().
|
|
|
|
A fixed side_effect list runs out unpredictably: time.time is patched
|
|
globally, so logging consumes ticks too.
|
|
"""
|
|
from itertools import count
|
|
|
|
counter = count()
|
|
return lambda: next(counter) * step
|
|
|
|
|
|
@pytest.fixture
|
|
def skip_checksum():
|
|
"""Accept the synthetic payloads these tests stream.
|
|
|
|
Downloads are verified against the digests pinned in model_checksums.txt, so
|
|
a few hundred bytes of ``b"x"`` are correctly rejected. These tests cover
|
|
download *mechanics* (progress, content-length, URL shaping); integrity
|
|
itself is covered by tests/test_model_checksums.py. Yielding the mock lets
|
|
each test still assert that verification was reached.
|
|
"""
|
|
with patch("vocalinux.speech_recognition.recognition_manager.verify_model_file") as mock_verify:
|
|
yield mock_verify
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_sys_modules():
|
|
"""Cleanup sys.modules after each test - full snapshot/restore."""
|
|
# Take a complete snapshot of sys.modules before the test
|
|
saved_modules = dict(sys.modules)
|
|
|
|
yield
|
|
|
|
# Restore sys.modules to exact pre-test state
|
|
added_keys = set(sys.modules.keys()) - set(saved_modules.keys())
|
|
for key in added_keys:
|
|
del sys.modules[key]
|
|
for key, value in saved_modules.items():
|
|
if key not in sys.modules or sys.modules[key] is not value:
|
|
sys.modules[key] = value
|
|
|
|
|
|
class TestDownloadWhispercppModel:
|
|
"""Test _download_whispercpp_model() with runtime import mocking."""
|
|
|
|
def test_download_whispercpp_success_basic(self, tmp_path, skip_checksum):
|
|
"""Test successful whisper.cpp model download."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "1000"}
|
|
mock_response.iter_content.return_value = [b"x" * 500, b"y" * 500]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
manager._download_whispercpp_model()
|
|
|
|
assert os.path.exists(model_file)
|
|
assert os.path.getsize(model_file) == 1000
|
|
# The model is only installed after it is verified.
|
|
skip_checksum.assert_called_once()
|
|
|
|
def test_download_whispercpp_progress_callback(self, tmp_path, skip_checksum):
|
|
"""Test progress callback is invoked during download."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
progress_calls = []
|
|
|
|
def track_progress(progress, speed, status):
|
|
progress_calls.append((progress, speed, status))
|
|
|
|
manager._download_progress_callback = track_progress
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "1000"}
|
|
mock_response.iter_content.return_value = [b"x" * 500, b"y" * 500]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
with patch("time.time", side_effect=_fake_clock()):
|
|
manager._download_whispercpp_model()
|
|
|
|
mock_requests.get.assert_called_once()
|
|
call_args = mock_requests.get.call_args
|
|
assert call_args is not None
|
|
assert len(call_args[0]) > 0 or "url" in call_args[1]
|
|
assert len(progress_calls) >= 1
|
|
|
|
def test_download_whispercpp_no_content_length(self, tmp_path, skip_checksum):
|
|
"""Test download when content-length header is missing."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {} # No content-length
|
|
mock_response.iter_content.return_value = [b"x" * 500, b"y" * 500]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
manager._download_whispercpp_model()
|
|
|
|
assert os.path.exists(model_file)
|
|
|
|
def test_download_whispercpp_request_error(self, tmp_path):
|
|
"""Test download request error handling."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_error = Exception("Network error")
|
|
mock_requests.get.side_effect = mock_error
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
with pytest.raises(RuntimeError, match="Failed to download"):
|
|
manager._download_whispercpp_model()
|
|
|
|
def test_download_whispercpp_appends_download_true(self, tmp_path, skip_checksum):
|
|
"""Hugging Face URLs get ?download=true for reliable binary responses."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "4", "content-type": "application/octet-stream"}
|
|
mock_response.iter_content.return_value = [b"data"]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
manager._download_whispercpp_model()
|
|
|
|
called_url = mock_requests.get.call_args[0][0]
|
|
assert "huggingface.co" in called_url
|
|
assert "download=true" in called_url
|
|
assert mock_requests.get.call_args[1].get("timeout") == manager._MODEL_DOWNLOAD_TIMEOUT
|
|
|
|
def test_download_whispercpp_timeout_message(self, tmp_path):
|
|
"""Timeout-like errors surface a dedicated user-facing message."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
model_file = str(tmp_path / "ggml-small.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_requests.get.side_effect = Exception("Read timeout")
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=model_file,
|
|
):
|
|
with pytest.raises(RuntimeError, match="timed out"):
|
|
manager._download_whispercpp_model()
|
|
|
|
def test_stream_model_download_rejects_html(self, tmp_path):
|
|
"""HTML error pages must not be written as model binaries."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
dest = str(tmp_path / "bad.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-type": "text/html; charset=utf-8"}
|
|
mock_response.status_code = 200
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with pytest.raises(RuntimeError, match="HTML"):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert not os.path.exists(dest)
|
|
|
|
def test_stream_model_download_empty_body(self, tmp_path):
|
|
"""Zero-byte downloads are treated as failure and cleaned up."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
dest = str(tmp_path / "empty.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": "0", "content-type": "application/octet-stream"}
|
|
mock_response.iter_content.return_value = [b"", b""]
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with pytest.raises(RuntimeError, match="0 bytes"):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert not os.path.exists(dest)
|
|
|
|
def test_stream_model_download_cancelled(self, tmp_path):
|
|
"""User cancel mid-stream removes the partial file."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._download_cancelled = True
|
|
dest = str(tmp_path / "partial.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {
|
|
"content-length": "100",
|
|
"content-type": "application/octet-stream",
|
|
}
|
|
mock_response.iter_content.return_value = [b"chunk"]
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with pytest.raises(RuntimeError, match="cancelled"):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert not os.path.exists(dest)
|
|
|
|
def test_stream_model_download_eta_minutes_and_empty_chunks(self, tmp_path):
|
|
"""Progress path covers multi-minute ETA and skips empty chunks."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
progress_calls = []
|
|
manager._download_progress_callback = lambda progress, speed, status: progress_calls.append(
|
|
status
|
|
)
|
|
dest = str(tmp_path / "big.bin")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
# Large total so remaining/speed yields ETA >= 60s
|
|
mock_response.headers = {
|
|
"content-length": str(100 * 1024 * 1024),
|
|
"content-type": "application/octet-stream",
|
|
}
|
|
mock_response.iter_content.return_value = [b"", b"x" * 1024]
|
|
mock_requests.get.return_value = mock_response
|
|
|
|
# start=0, then 0.2 for progress update (elapsed > 0, small speed)
|
|
times = [0.0, 0.2, 0.2]
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch("time.time", side_effect=lambda: times.pop(0) if times else 1.0):
|
|
manager._stream_model_download("https://example.com/model.bin", dest)
|
|
|
|
assert os.path.exists(dest)
|
|
assert os.path.getsize(dest) == 1024
|
|
assert any("m " in s or "ETA" in s for s in progress_calls)
|
|
|
|
|
|
class TestDownloadWhisperModel:
|
|
"""OpenAI Whisper downloads run through the shared streaming helper.
|
|
|
|
Vocalinux fetches the checkpoint only to give the UI progress and a working
|
|
cancel; whisper.load_model() is what verifies it. Both were briefly lost when
|
|
the downloader was removed altogether, so they are pinned here.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _fake_stream(url, dest_path):
|
|
with open(dest_path, "wb") as handle:
|
|
handle.write(b"checkpoint")
|
|
|
|
def test_writes_the_name_whisper_expects(self, tmp_path):
|
|
manager = _make_manager(engine="whisper")
|
|
manager.model_size = "large"
|
|
|
|
with patch.object(manager, "_stream_model_download", side_effect=self._fake_stream):
|
|
manager._download_whisper_model(str(tmp_path))
|
|
|
|
# large is stored as large-v3.pt; "large.pt" here would mean load_model
|
|
# downloads the 2.9GB checkpoint a second time.
|
|
assert (tmp_path / "large-v3.pt").exists()
|
|
assert not (tmp_path / "large.pt").exists()
|
|
|
|
def test_reports_progress(self, tmp_path):
|
|
manager = _make_manager(engine="whisper")
|
|
progress_calls = []
|
|
manager._download_progress_callback = lambda f, s, st: progress_calls.append(st)
|
|
|
|
with patch.object(manager, "_stream_model_download", side_effect=self._fake_stream):
|
|
manager._download_whisper_model(str(tmp_path))
|
|
|
|
assert progress_calls, "the download dialog would sit at zero"
|
|
|
|
def test_cancel_propagates_and_cleans_up(self, tmp_path):
|
|
"""Cancel raised by the stream helper must reach the caller."""
|
|
manager = _make_manager(engine="whisper")
|
|
|
|
def cancel(url, dest_path):
|
|
with open(dest_path, "wb") as handle:
|
|
handle.write(b"partial")
|
|
raise RuntimeError("Download cancelled")
|
|
|
|
# A leaked requests mock from another module makes
|
|
# `except requests.exceptions.RequestException` a TypeError, so pin a
|
|
# real exception class here as the other download tests do.
|
|
mock_requests = MagicMock()
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch.object(manager, "_stream_model_download", side_effect=cancel):
|
|
with pytest.raises(RuntimeError, match="cancelled"):
|
|
manager._download_whisper_model(str(tmp_path))
|
|
|
|
assert not list(tmp_path.glob("*"))
|
|
|
|
|
|
class TestDownloadVoskModel:
|
|
"""Test _download_vosk_model() with runtime import mocking."""
|
|
|
|
@staticmethod
|
|
def _serve_zip(manager, tmp_path, payload=None):
|
|
"""Run a full download+extract of an archive into tmp_path."""
|
|
payload = VOSK_ZIP_BYTES if payload is None else payload
|
|
mock_requests = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.headers = {"content-length": str(len(payload))}
|
|
mock_response.iter_content.return_value = [payload]
|
|
mock_requests.get.return_value = mock_response
|
|
mock_requests.exceptions.RequestException = FakeRequestError
|
|
|
|
# Extraction has to really happen here — the stamp lands in the tree it
|
|
# creates — and other modules leave a MagicMock in sys.modules["zipfile"].
|
|
real_zipfile = getattr(sys, "_vocalinux_real_zipfile", None) or __import__("zipfile")
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests, "zipfile": real_zipfile}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.MODELS_DIR", str(tmp_path)
|
|
):
|
|
with patch("time.time", side_effect=_fake_clock()):
|
|
manager._download_vosk_model()
|
|
return mock_requests
|
|
|
|
def test_download_vosk_progress_callback(self, tmp_path, skip_checksum):
|
|
"""Test progress callback during Vosk download."""
|
|
manager = _make_manager(engine="vosk")
|
|
progress_calls = []
|
|
|
|
def track_progress(progress, speed, status):
|
|
progress_calls.append((progress, speed, status))
|
|
|
|
manager._download_progress_callback = track_progress
|
|
|
|
mock_requests = self._serve_zip(manager, tmp_path)
|
|
|
|
mock_requests.get.assert_called_once()
|
|
call_args = mock_requests.get.call_args
|
|
assert call_args is not None
|
|
assert len(call_args[0]) > 0 or "url" in call_args[1]
|
|
assert len(progress_calls) >= 1
|
|
# Otherwise the digest check could be deleted and this stay green.
|
|
skip_checksum.assert_called_once()
|
|
|
|
def test_download_vosk_stamps_the_extracted_tree(self, tmp_path, skip_checksum):
|
|
"""A directory has no digest; install.sh reads this stamp instead.
|
|
|
|
Leave it out and every model fetched at first run or from Settings looks
|
|
unverified to the next ./install.sh, which downloads it again.
|
|
"""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
stamp = tmp_path / "vosk-model-small-en-us-0.15" / VERIFICATION_STAMP_NAME
|
|
pinned = expected_for("vosk-model-small-en-us-0.15.zip")
|
|
assert pinned is not None, "the fixture model must be in the manifest"
|
|
assert stamp.read_text().strip() == pinned.digest
|
|
|
|
@staticmethod
|
|
def _leftovers(tmp_path):
|
|
"""Everything in MODELS_DIR that is not the finished model tree."""
|
|
return sorted(p.name for p in tmp_path.iterdir() if p.name != MODEL_NAME)
|
|
|
|
def test_download_vosk_fails_when_the_stamp_cannot_be_written(self, tmp_path, skip_checksum):
|
|
"""An unstampable tree is one we would silently refetch; say so instead."""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.write_verification_stamp",
|
|
side_effect=OSError("read-only models directory"),
|
|
):
|
|
with pytest.raises(OSError):
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
# And it must not leave the tree behind: _init_vosk would call that
|
|
# installed while install.sh, finding no stamp, refetched it every run.
|
|
assert not (tmp_path / MODEL_NAME).exists()
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_a_failed_unpack_keeps_the_model_that_was_already_there(self, tmp_path, skip_checksum):
|
|
"""Replacing is a swap: a broken download must not cost the old model."""
|
|
old = tmp_path / MODEL_NAME / "am"
|
|
old.mkdir(parents=True)
|
|
(old / "final.mdl").write_bytes(b"the model that was already installed")
|
|
|
|
manager = _make_manager(engine="vosk")
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.write_verification_stamp",
|
|
side_effect=OSError("read-only models directory"),
|
|
):
|
|
with pytest.raises(OSError):
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
assert (old / "final.mdl").read_bytes() == b"the model that was already installed"
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_an_archive_with_the_wrong_layout_is_refused(self, tmp_path, skip_checksum):
|
|
"""A zip that unpacks somewhere else must not look like a success."""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
with pytest.raises(RuntimeError, match="does not contain"):
|
|
self._serve_zip(manager, tmp_path, payload=WRONG_LAYOUT_ZIP_BYTES)
|
|
|
|
assert not (tmp_path / MODEL_NAME).exists()
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_a_verified_download_replaces_an_older_tree(self, tmp_path, skip_checksum):
|
|
old = tmp_path / MODEL_NAME
|
|
(old / "am").mkdir(parents=True)
|
|
(old / "am" / "final.mdl").write_bytes(b"stale")
|
|
(old / "stale-file").write_text("must not survive the swap")
|
|
|
|
manager = _make_manager(engine="vosk")
|
|
self._serve_zip(manager, tmp_path)
|
|
|
|
assert (old / "am" / "final.mdl").read_bytes() != b"stale"
|
|
assert not (old / "stale-file").exists(), "the swap must replace, not merge"
|
|
assert (old / VERIFICATION_STAMP_NAME).exists()
|
|
assert self._leftovers(tmp_path) == []
|
|
|
|
def test_download_vosk_request_error(self, tmp_path):
|
|
"""Test Vosk download request error handling."""
|
|
manager = _make_manager(engine="vosk")
|
|
|
|
mock_requests = MagicMock()
|
|
mock_error = Exception("Network error")
|
|
mock_requests.get.side_effect = mock_error
|
|
mock_requests.exceptions.RequestException = Exception
|
|
|
|
with patch.dict("sys.modules", {"requests": mock_requests}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.MODELS_DIR", str(tmp_path)
|
|
):
|
|
with pytest.raises(RuntimeError, match="Failed to download"):
|
|
manager._download_vosk_model()
|
|
|
|
|
|
class TestWhispercppRejectsAnUnverifiedModelOnDisk:
|
|
"""Wiring test: the hash has to happen where the model is picked up.
|
|
|
|
_download_whispercpp_model verifies before its rename, so the exposure is a
|
|
file that never came through it: releases before verification fetched ggml
|
|
models from `resolve/main` unchecked, and install.sh re-hashes only
|
|
ggml-tiny.bin. Without this the file goes to pywhispercpp/ctypes as-is.
|
|
"""
|
|
|
|
def test_a_model_that_fails_its_pin_is_removed_and_not_loaded(self, tmp_path):
|
|
model = tmp_path / "ggml-tiny.bin"
|
|
model.write_bytes(b"not the model that is pinned")
|
|
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = True
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=str(model),
|
|
):
|
|
with patch.object(manager, "_load_whispercpp_model") as load:
|
|
manager._init_whispercpp()
|
|
|
|
assert not model.exists(), "an unverifiable model must not stay on disk"
|
|
load.assert_not_called()
|
|
assert manager._model_initialized is False
|
|
|
|
def test_a_model_that_cannot_be_deleted_is_still_not_loaded(self, tmp_path):
|
|
model = tmp_path / "ggml-tiny.bin"
|
|
model.write_bytes(b"not the model that is pinned")
|
|
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = False
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=str(model),
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.os.remove",
|
|
side_effect=OSError("read-only"),
|
|
):
|
|
with patch.object(manager, "_load_whispercpp_model") as load:
|
|
with pytest.raises(RuntimeError, match="failed verification"):
|
|
manager._init_whispercpp()
|
|
|
|
assert model.exists()
|
|
load.assert_not_called()
|
|
|
|
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
|
|
model = tmp_path / "ggml-not-in-manifest.bin"
|
|
model.write_bytes(b"bytes")
|
|
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = True
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pywhispercpp": mock_pywhispercpp, "pywhispercpp.model": mock_pywhispercpp},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value=str(model),
|
|
):
|
|
with patch.object(manager, "_load_whispercpp_model") as load:
|
|
manager._init_whispercpp()
|
|
|
|
assert model.exists(), "a missing pin is not a reason to delete the file"
|
|
load.assert_not_called()
|
|
assert manager._model_initialized is False
|
|
|
|
|
|
class TestAudioReconnection:
|
|
"""Test audio reconnection logic."""
|
|
|
|
def test_attempt_audio_reconnection_success(self):
|
|
"""Test successful audio reconnection."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep"):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
assert manager._audio_stream == mock_stream
|
|
|
|
def test_attempt_audio_reconnection_falls_back_to_default_resolver(self):
|
|
"""Test reconnection falls back when saved device name/index cannot resolve."""
|
|
manager = _make_manager(engine="whisper_cpp", audio_device_name="Missing Mic")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
|
patch("time.sleep"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
|
return_value=None,
|
|
) as mock_resolve_name,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device",
|
|
return_value=1,
|
|
) as mock_resolve_default,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._get_supported_channels",
|
|
return_value=1,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._get_supported_sample_rate",
|
|
return_value=16000,
|
|
),
|
|
):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
assert manager._audio_stream == mock_stream
|
|
mock_resolve_name.assert_called_once_with(mock_audio_instance, "Missing Mic", None)
|
|
mock_resolve_default.assert_called_once_with(mock_audio_instance, None)
|
|
|
|
def test_attempt_audio_reconnection_no_resolved_device(self):
|
|
"""When no safe device is enumerated, reconnect via system default."""
|
|
manager = _make_manager(engine="whisper_cpp", audio_device_name="Missing Mic")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_audio_instance = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
|
patch("time.sleep"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, mock_stream),
|
|
) as mock_open,
|
|
):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
mock_open.assert_called_once_with(mock_audio_instance, None)
|
|
|
|
def test_attempt_audio_reconnection_max_attempts(self):
|
|
"""Test reconnection stops after max attempts."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._reconnection_attempts = manager._max_reconnection_attempts
|
|
|
|
mock_audio_instance = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": MagicMock()}):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is False
|
|
|
|
def test_attempt_audio_reconnection_open_failure(self):
|
|
"""Test reconnection when stream open fails."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.side_effect = IOError("Cannot open stream")
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep"):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is False
|
|
|
|
def test_attempt_audio_reconnection_exponential_backoff(self):
|
|
"""Test exponential backoff in reconnection attempts."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._reconnection_delay = 0.1
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
sleep_durations = []
|
|
|
|
def track_sleep(duration):
|
|
sleep_durations.append(duration)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep", side_effect=track_sleep):
|
|
manager._reconnection_attempts = 0
|
|
manager._attempt_audio_reconnection(mock_audio_instance)
|
|
first_delay = sleep_durations[-1]
|
|
|
|
manager._reconnection_attempts = 1
|
|
manager._attempt_audio_reconnection(mock_audio_instance)
|
|
second_delay = sleep_durations[-1]
|
|
|
|
assert second_delay > first_delay
|
|
assert second_delay == first_delay * 2
|
|
|
|
def test_attempt_audio_reconnection_negotiation_fallback(self):
|
|
"""When negotiation returns no stream, reconnect falls back to plain open."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00" * 1024
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
|
patch("time.sleep"),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
),
|
|
):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is True
|
|
assert manager._audio_stream == mock_stream
|
|
mock_audio_instance.open.assert_called_once()
|
|
|
|
def test_attempt_audio_reconnection_empty_read_closes_stream(self):
|
|
"""A reconnected stream that returns no data must be closed safely."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
mock_pyaudio_mod = MagicMock()
|
|
mock_pyaudio_mod.paInt16 = 8
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b""
|
|
mock_audio_instance = MagicMock()
|
|
mock_audio_instance.open.return_value = mock_stream
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
|
|
with patch("time.sleep"):
|
|
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
|
|
|
assert result is False
|
|
mock_stream.stop_stream.assert_called_once()
|
|
mock_stream.close.assert_called_once()
|
|
|
|
|
|
class TestIBusEngineUtilities:
|
|
"""Test ibus_engine utility functions."""
|
|
|
|
def test_is_ibus_available(self):
|
|
"""Test is_ibus_available() function."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_available
|
|
|
|
result = is_ibus_available()
|
|
assert isinstance(result, bool)
|
|
|
|
def test_is_ibus_daemon_running(self):
|
|
"""Test daemon detection when not running."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value.returncode = 1
|
|
result = is_ibus_daemon_running()
|
|
assert result is False
|
|
|
|
def test_is_ibus_daemon_running_success(self):
|
|
"""Test daemon detection when running."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value.returncode = 0
|