Files
voculinux/tests/test_recognition_manager_downloads.py
T
Jatin K MalikandGitHub cdfe541c25 feat(speech_recognition): add faster-whisper engine backend (#543)
* feat(speech_recognition): add EngineType enum and Engine Protocol

Introduce a typed EngineType enum and an Engine Protocol in common_types.py,
plus an engines/ registry so new speech recognition backends can be plugged in
without scattering string comparisons across the manager.

* feat(speech_recognition): add faster-whisper engine backend

Add a faster-whisper engine implementation using CTranslate2/INT8 for fast
CPU inference. Includes model metadata utility, wiring through the recognition
manager, CLI, settings UI, config, optional dependency, and unit tests. Also
updates the suspend-handler test fixture for the new engine attribute.

* style(ui): format faster-whisper imports for isort 8

* test(speech_recognition): expand faster-whisper coverage for codecov

* test(speech_recognition): fix failing faster-whisper coverage tests

- Import missing get_compute_type helper

- Add tests for English-only detection, cache exceptions, and import-error registry path

- Clear _has_torch_cuda lru_cache around exception test to avoid cached True

- Replace broad builtins.__import__ patch with a fake engine module for registry test

- Restore registry module state after test

* test(speech_recognition): add coverage for faster-whisper edge cases

Cover previously missing lines in:

- faster_whisper_model_info.py: HF cache path/none, hf_hub lookup, fallback traversal, torch CUDA true, psutil exception

- faster_whisper_engine.py: model None guard after is_ready()

- engine registry import-error path

* feat(install): add faster_whisper engine option to install.sh

- Add faster_whisper to --engine help text and examples

- Add faster-whisper as interactive option 5 with description

- Install faster-whisper package and verify import at install time

- Add fallback to whisper.cpp if faster-whisper installation fails

- Add smoke test for faster-whisper import

- Update welcome message engine display name

- Note: faster-whisper models download automatically on first use

* refactor(faster_whisper): apply ponytail review cleanups

- Inline FASTER_WHISPER_MODEL_INFO dict literal; drop unused AVAILABLE_MODELS/MODEL_SIZES

- Reuse _has_torch_cuda() in FasterWhisperEngine.device property

- Remove redundant model None guard after is_ready() verification

- Shrink faster-whisper default config in install.sh to engine + model_size

- Update device tests to manage _has_torch_cuda lru_cache

- Remove test for deleted dead-code guard

* fix(faster_whisper): integrate optional engine with current main

Keep whisper_cpp as default and Parakeet as an extra. Put engine selection
in the Advanced island, install faster-whisper via the optional extra, and
refuse unpinned Hugging Face downloads until model_checksums.txt pins them.

* fix(faster_whisper): download verified models and refresh uv.lock

Settings already opened a download dialog for uncached faster-whisper, but
init only raised ChecksumError. Wire a Parakeet-style downloader that stores
bundles under models_dir, verifies each file against model_checksums.txt, and
loads WhisperModel from that path. Regenerate uv.lock for the optional
faster-whisper extra, pin digests for all eleven Systran models, and add the
missing -> None return annotations.

* fix(faster_whisper): clear engine on unload and fix auto-pause fixture

Auto-pause tests stub __init__, so they never set _faster_whisper_engine.
reinitialize_after_resume then AttributeErrors once Test Python runs.
Mirror the suspend-handler fixture, and release the faster-whisper engine
inside unload_model the same way resume already does.

* fix(faster_whisper): restore previous engine after a failed switch

reconfigure() selected the new engine and released the live model before
init. A failed faster-whisper download then left the manager in ERROR on
the new backend while Settings reverted the pickers to the saved engine,
so dictation stayed dead until restart. Reload the previous engine from
disk (no download) before re-raising so the UI and live manager agree.

* fix(speech_recognition): restore all live settings after failed switch

Failed reconfigure already put the previous engine back, but VAD, device,
remote API, and whisper.cpp knobs stayed on the attempted values while
config on disk did not. Snapshot every field reconfigure writes and restore
the lot before reloading the old backend. Also bind the real
_init_selected_engine onto the MagicMock in the force_reinit test.

* fix(speech_recognition): restore every live field after failed reconfigure

A failed engine switch already reloaded the previous backend, but Greptile
could still miss that VAD, audio device, remote API, and whisper.cpp knobs
were part of the snapshot. Keep one explicit attr list for snapshot/restore,
including the derived voice-commands flag, so live state matches persisted
config after a failed Settings apply.

* chore(speech_recognition): sign tip after rollback follow-up
2026-09-07 20:47:56 -07:00

1297 lines
54 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 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.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
from vocalinux.utils.faster_whisper_model_info import manifest_key as faster_whisper_manifest_key
from vocalinux.utils.faster_whisper_model_info import model_files as faster_whisper_model_files
from vocalinux.utils.model_checksums import VERIFICATION_STAMP_NAME, ChecksumError, expected_for
from vocalinux.utils.model_checksums import verify_model_file as verify_model_file_real
from vocalinux.utils.parakeet_model_info import MODEL_FILES as PARAKEET_MODEL_FILES
from vocalinux.utils.parakeet_model_info import manifest_key as parakeet_manifest_key
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"):
with patch.object(SpeechRecognitionManager, "_init_parakeet"):
with patch.object(SpeechRecognitionManager, "_init_faster_whisper"):
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 TestParakeetRejectsAnUnverifiedModelOnDisk:
"""Wiring test: the hash has to happen where the model is picked up.
_download_parakeet_model verifies before its rename, so the exposure is a
bundle that never came through it: files copied in, or left over from a
cancelled download. sherpa-onnx loads these through native code. Without
this the files go to OfflineRecognizer.from_transducer as-is.
"""
@staticmethod
def _write_bundle(tmp_path, payload=b"not the model that is pinned"):
model_dir = tmp_path / "v3-european"
model_dir.mkdir()
for name in PARAKEET_MODEL_FILES:
(model_dir / name).write_bytes(payload)
return model_dir
def test_a_model_that_fails_its_pin_is_removed_and_not_loaded(self, tmp_path):
model_dir = self._write_bundle(tmp_path)
manager = _make_manager(engine="parakeet")
manager.model_size = "v3-european"
manager._defer_download = True
mock_sherpa = MagicMock()
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
with patch(
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
return_value=str(model_dir),
):
manager._init_parakeet()
for name in PARAKEET_MODEL_FILES:
assert not (model_dir / name).exists(), "an unverifiable model must not stay on disk"
mock_sherpa.OfflineRecognizer.from_transducer.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_dir = self._write_bundle(tmp_path)
manager = _make_manager(engine="parakeet")
manager.model_size = "v3-european"
manager._defer_download = False
mock_sherpa = MagicMock()
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
with patch(
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
return_value=str(model_dir),
):
with patch(
"vocalinux.speech_recognition.recognition_manager.os.remove",
side_effect=OSError("read-only"),
):
with pytest.raises(RuntimeError, match="failed verification"):
manager._init_parakeet()
for name in PARAKEET_MODEL_FILES:
assert (model_dir / name).exists()
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
"""Parakeet pins are constructed keys; simulate a missing pin for one file."""
model_dir = self._write_bundle(tmp_path, payload=b"bytes")
unpinned_name = PARAKEET_MODEL_FILES[0]
unpinned_key = parakeet_manifest_key("v3-european", unpinned_name)
def fake_verify(path, filename=None):
key = filename or os.path.basename(path)
if key == unpinned_key:
raise ChecksumError(f"No checksum is pinned for {key}")
verify_model_file_real(path, filename)
def fake_expected(filename):
if os.path.basename(filename) == unpinned_key:
return None
return expected_for(filename)
manager = _make_manager(engine="parakeet")
manager.model_size = "v3-european"
manager._defer_download = True
mock_sherpa = MagicMock()
with patch.dict("sys.modules", {"sherpa_onnx": mock_sherpa}):
with patch(
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
return_value=str(model_dir),
):
with patch(
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
side_effect=fake_verify,
):
with patch(
"vocalinux.speech_recognition.recognition_manager.expected_for",
side_effect=fake_expected,
):
manager._init_parakeet()
assert (
model_dir / unpinned_name
).exists(), "a missing pin is not a reason to delete the file"
mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called()
assert manager._model_initialized is False
class TestParakeetDownloadVerifiesExistingFiles:
"""Existence is not a pin: a leftover dest must still match its digest.
A previous continue-on-exists skipped verification and left a copied-in or
truncated file for sherpa-onnx to load. OfflineRecognizer is not involved
here; this is the download loop only.
"""
def test_an_existing_file_that_fails_its_pin_is_redownloaded(self, tmp_path):
manager = _make_manager(engine="parakeet")
manager.model_size = "v3-european"
model_dir = tmp_path / "v3-european"
model_dir.mkdir()
encoder_name = PARAKEET_MODEL_FILES[0]
encoder = model_dir / encoder_name
encoder.write_bytes(b"not the encoder that is pinned")
encoder_key = parakeet_manifest_key("v3-european", encoder_name)
streamed = []
verify_calls = []
def fake_stream(url, dest_path):
streamed.append(dest_path)
with open(dest_path, "wb") as handle:
handle.write(b"good-enough")
def fake_verify(path, filename=None):
verify_calls.append((path, filename))
# Existing dests fail; freshly streamed temps pass.
if not str(path).endswith(".tmp"):
raise ChecksumError("digest mismatch")
mock_requests = MagicMock()
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch(
"vocalinux.speech_recognition.recognition_manager.parakeet.get_model_path",
return_value=str(model_dir),
):
with patch.object(manager, "_stream_model_download", side_effect=fake_stream):
with patch(
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
side_effect=fake_verify,
):
manager._download_parakeet_model()
assert any(
path == str(encoder) and filename == encoder_key for path, filename in verify_calls
), "an existing dest must be hashed, not skipped because it is already on disk"
assert any(
os.path.basename(path) == encoder_name + ".tmp" for path in streamed
), "an existing bad file must be removed and re-downloaded"
assert encoder.read_bytes() == b"good-enough"
class TestFasterWhisperRejectsAnUnverifiedModelOnDisk:
"""Wiring test: the hash has to happen where the model is picked up."""
@staticmethod
def _write_bundle(tmp_path, payload=b"not the model that is pinned"):
model_dir = tmp_path / "tiny"
model_dir.mkdir()
for name in faster_whisper_model_files("tiny"):
(model_dir / name).write_bytes(payload)
return model_dir
def test_a_model_that_fails_its_pin_is_removed_and_not_loaded(self, tmp_path):
model_dir = self._write_bundle(tmp_path)
manager = _make_manager(engine="faster_whisper")
manager.model_size = "tiny"
manager._defer_download = True
with patch(
"vocalinux.speech_recognition.recognition_manager.faster_whisper.get_model_path",
return_value=str(model_dir),
):
with patch(
"vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine"
) as engine_cls:
manager._init_faster_whisper()
for name in faster_whisper_model_files("tiny"):
assert not (model_dir / name).exists(), "an unverifiable model must not stay on disk"
engine_cls.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_dir = self._write_bundle(tmp_path)
manager = _make_manager(engine="faster_whisper")
manager.model_size = "tiny"
manager._defer_download = False
with patch(
"vocalinux.speech_recognition.recognition_manager.faster_whisper.get_model_path",
return_value=str(model_dir),
):
with patch(
"vocalinux.speech_recognition.recognition_manager.os.remove",
side_effect=OSError("read-only"),
):
with patch(
"vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine"
) as engine_cls:
with pytest.raises(RuntimeError, match="failed verification"):
manager._init_faster_whisper()
for name in faster_whisper_model_files("tiny"):
assert (model_dir / name).exists()
engine_cls.assert_not_called()
def test_an_unpinned_model_is_refused_not_deleted(self, tmp_path):
"""faster-whisper pins are constructed keys; simulate a missing pin for one file."""
model_dir = self._write_bundle(tmp_path, payload=b"bytes")
unpinned_name = faster_whisper_model_files("tiny")[0]
unpinned_key = faster_whisper_manifest_key("tiny", unpinned_name)
def fake_verify(path, filename=None):
key = filename or os.path.basename(path)
if key == unpinned_key:
raise ChecksumError(f"No checksum is pinned for {key}")
verify_model_file_real(path, filename)
def fake_expected(filename):
if os.path.basename(filename) == unpinned_key:
return None
return expected_for(filename)
manager = _make_manager(engine="faster_whisper")
manager.model_size = "tiny"
manager._defer_download = True
with patch(
"vocalinux.speech_recognition.recognition_manager.faster_whisper.get_model_path",
return_value=str(model_dir),
):
with patch(
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
side_effect=fake_verify,
):
with patch(
"vocalinux.speech_recognition.recognition_manager.expected_for",
side_effect=fake_expected,
):
with patch(
"vocalinux.speech_recognition.engines.faster_whisper_engine.FasterWhisperEngine"
) as engine_cls:
manager._init_faster_whisper()
assert (
model_dir / unpinned_name
).exists(), "a missing pin is not a reason to delete the file"
engine_cls.assert_not_called()
assert manager._model_initialized is False
class TestFasterWhisperDownloadVerifiesExistingFiles:
"""Existence is not a pin: a leftover dest must still match its digest."""
def test_an_existing_file_that_fails_its_pin_is_redownloaded(self, tmp_path):
manager = _make_manager(engine="faster_whisper")
manager.model_size = "tiny"
model_dir = tmp_path / "tiny"
model_dir.mkdir()
first_name = faster_whisper_model_files("tiny")[0]
first_file = model_dir / first_name
first_file.write_bytes(b"not the file that is pinned")
first_key = faster_whisper_manifest_key("tiny", first_name)
streamed = []
verify_calls = []
def fake_stream(url, dest_path):
streamed.append(dest_path)
with open(dest_path, "wb") as handle:
handle.write(b"good-enough")
def fake_verify(path, filename=None):
verify_calls.append((path, filename))
if not str(path).endswith(".tmp"):
raise ChecksumError("digest mismatch")
mock_requests = MagicMock()
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch(
"vocalinux.speech_recognition.recognition_manager.faster_whisper.get_model_path",
return_value=str(model_dir),
):
with patch.object(manager, "_stream_model_download", side_effect=fake_stream):
with patch(
"vocalinux.speech_recognition.recognition_manager.verify_model_file",
side_effect=fake_verify,
):
manager._download_faster_whisper_model()
assert any(
path == str(first_file) and filename == first_key for path, filename in verify_calls
), "an existing dest must be hashed, not skipped because it is already on disk"
assert any(
os.path.basename(path) == first_name + ".tmp" for path in streamed
), "an existing bad file must be removed and re-downloaded"
assert first_file.read_bytes() == b"good-enough"
class TestFailedReconfigureRestoresPreviousEngine:
"""A failed faster-whisper switch must not discard the live engine.
Settings reverts the pickers to the saved config on error. If reconfigure
keeps faster-whisper in ERROR after releasing whisper.cpp, dictation is
dead until restart even though the UI shows the old engine.
"""
def test_failed_faster_whisper_download_reloads_previous_engine(self):
manager = _make_manager(engine="whisper_cpp")
manager.engine = "whisper_cpp"
manager.model_size = "tiny"
manager._model_initialized = True
manager.state = RecognitionState.IDLE
with patch.object(
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
):
with patch.object(manager, "_init_whispercpp") as restore_init:
with pytest.raises(RuntimeError, match="download failed"):
manager.reconfigure(
engine="faster_whisper",
model_size="tiny",
force_download=True,
)
restore_init.assert_called_once()
assert manager.engine == "whisper_cpp"
assert manager.model_size == "tiny"
assert manager.state != RecognitionState.ERROR
def test_restore_failure_stays_in_error(self):
manager = _make_manager(engine="whisper_cpp")
manager.engine = "whisper_cpp"
manager.model_size = "tiny"
manager.state = RecognitionState.IDLE
with patch.object(
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
):
with patch.object(
manager, "_init_whispercpp", side_effect=RuntimeError("restore failed")
):
with pytest.raises(RuntimeError, match="download failed"):
manager.reconfigure(
engine="faster_whisper",
model_size="tiny",
force_download=True,
)
assert manager.engine == "whisper_cpp"
assert manager.state == RecognitionState.ERROR
def test_failed_switch_restores_live_settings_not_just_engine(self):
"""Unsaved VAD/device/API fields must roll back with the previous engine."""
manager = _make_manager(engine="whisper_cpp")
manager.engine = "whisper_cpp"
manager.model_size = "tiny"
manager.language = "en-us"
manager.vad_sensitivity = 2
manager.silence_timeout = 1.5
manager.audio_device_index = 1
manager.audio_device_name = "Built-in Mic"
manager._voice_commands_preference = False
manager.stop_sound_guard_ms = 200
manager.whispercpp_n_threads = 4
manager.whispercpp_gpu_device = 0
manager.whispercpp_no_timestamps = True
manager.remote_api_url = "http://old"
manager.remote_api_key = "old-key"
manager.remote_api_endpoint = "/inference"
manager.remote_api_model = "whisper-1"
manager._model_initialized = True
manager.state = RecognitionState.IDLE
with patch.object(
manager, "_init_faster_whisper", side_effect=RuntimeError("download failed")
):
with patch.object(manager, "_init_whispercpp") as restore_init:
with pytest.raises(RuntimeError, match="download failed"):
manager.reconfigure(
engine="faster_whisper",
model_size="tiny",
vad_sensitivity=5,
silence_timeout=4.0,
audio_device_index=9,
audio_device_name="USB Mic",
voice_commands_enabled=True,
stop_sound_guard_ms=50,
whispercpp_n_threads=1,
whispercpp_gpu_device=2,
whispercpp_no_timestamps=False,
remote_api_url="http://new",
remote_api_key="new-key",
remote_api_endpoint="/v1/audio",
remote_api_model="sensevoice",
force_download=True,
)
restore_init.assert_called_once()
assert manager.engine == "whisper_cpp"
assert manager.model_size == "tiny"
assert manager.language == "en-us"
assert manager.vad_sensitivity == 2
assert manager.silence_timeout == 1.5
assert manager.audio_device_index == 1
assert manager.audio_device_name == "Built-in Mic"
assert manager._voice_commands_preference is False
assert manager._voice_commands_enabled is False
assert manager.stop_sound_guard_ms == 200
assert manager.whispercpp_n_threads == 4
assert manager.whispercpp_gpu_device == 0
assert manager.whispercpp_no_timestamps is True
assert manager.remote_api_url == "http://old"
assert manager.remote_api_key == "old-key"
assert manager.remote_api_endpoint == "/inference"
assert manager.remote_api_model == "whisper-1"
assert manager.state != RecognitionState.ERROR
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