""" 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 json import os import sys import threading 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.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 from vocalinux.utils.parakeet_model_info import model_files as parakeet_model_files 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_cancelled_while_connecting(self, tmp_path) -> None: """Cancel must not wait out a blocked request open. requests offers no way to abort a request stuck resolving, connecting, or waiting on headers — exactly what an unreachable download server does. The flag is only read once chunks flow, so Cancel used to ride out the whole connect/read timeout (issue #679). """ manager = _make_manager(engine="whisper_cpp") dest = str(tmp_path / "never.bin") mock_requests = MagicMock() release_opener = threading.Event() def blocked_get(*args, **kwargs) -> MagicMock: release_opener.wait(60) # a server that never answers return MagicMock() mock_requests.get.side_effect = blocked_get def cancel_soon() -> None: time.sleep(0.3) manager._download_cancelled = True with patch.dict("sys.modules", {"requests": mock_requests}): threading.Thread(target=cancel_soon, daemon=True).start() started = time.monotonic() with pytest.raises(RuntimeError, match="cancelled"): manager._stream_model_download("https://example.com/model.bin", dest) assert time.monotonic() - started < 10 # The opener the cancel abandoned must be released and reaped, not left # sleeping inside later tests. release_opener.set() for opener in manager._download_openers: opener.join(timeout=5) assert not any(t.is_alive() for t in manager._download_openers) assert not os.path.exists(dest) def test_stream_model_download_closes_late_response(self, tmp_path) -> None: """A response landing after the cancel is closed, not left streaming.""" manager = _make_manager(engine="whisper_cpp") dest = str(tmp_path / "late.bin") mock_requests = MagicMock() mock_response = MagicMock() release_opener = threading.Event() def slow_get(*args, **kwargs) -> MagicMock: release_opener.wait(60) return mock_response mock_requests.get.side_effect = slow_get manager._download_cancelled = True with patch.dict("sys.modules", {"requests": mock_requests}): with pytest.raises(RuntimeError, match="cancelled"): manager._stream_model_download("https://example.com/model.bin", dest) release_opener.set() for opener in manager._download_openers: opener.join(timeout=5) mock_response.close.assert_called_once() assert not os.path.exists(dest) def test_stream_model_download_propagates_request_errors(self, tmp_path) -> None: """A failed open re-raises the original error for callers to classify.""" manager = _make_manager(engine="whisper_cpp") dest = str(tmp_path / "err.bin") mock_requests = MagicMock() mock_requests.get.side_effect = FakeRequestError("Connection refused") mock_requests.exceptions.RequestException = FakeRequestError with patch.dict("sys.modules", {"requests": mock_requests}): with pytest.raises(FakeRequestError, match="Connection refused"): manager._stream_model_download("https://example.com/model.bin", dest) assert not os.path.exists(dest) def test_stream_model_download_no_response(self, tmp_path) -> None: """A helper that produced neither a response nor an error still fails.""" manager = _make_manager(engine="whisper_cpp") dest = str(tmp_path / "none.bin") mock_requests = MagicMock() mock_requests.get.return_value = None with patch.dict("sys.modules", {"requests": mock_requests}): with pytest.raises(RuntimeError, match="no response"): 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 TestParakeetReleaseManifestRecovery: """Invalid publisher metadata must leave a retryable, unloaded model.""" @pytest.mark.parametrize("cached", [False, True]) @pytest.mark.parametrize("invalid_manifest", ['{"files": []}', '{"files": [{}]}']) def test_invalid_manifest_is_removed_and_retry_reuses_weights( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, skip_checksum: MagicMock, cached: bool, invalid_manifest: str, ) -> None: from vocalinux.utils import parakeet_model_info as parakeet model_name = "orukeet-v0.1.0" model_dir = tmp_path / model_name model_dir.mkdir() names = parakeet_model_files(model_name) manifest_path = model_dir / "manifest.json" records = [] for name in names[1:]: expected = expected_for(parakeet_manifest_key(model_name, name)) assert expected is not None records.append({"path": name, "sha256": expected.digest, "bytes": expected.size}) if cached: (model_dir / name).write_bytes(b"verified model file") if cached: manifest_path.write_text(invalid_manifest, encoding="utf-8") manager = _make_manager(engine="parakeet") manager.model_size = model_name manager._defer_download = cached progress = MagicMock() manager._download_progress_callback = progress streamed = [] content = invalid_manifest def stream(url: str, destination: str) -> None: streamed.append(os.path.basename(destination)) with open(destination, "wb") as target: target.write( content.encode() if destination.endswith("manifest.json.tmp") else b"verified model file" ) mock_sherpa = MagicMock() mock_requests = MagicMock() mock_requests.exceptions.RequestException = FakeRequestError monkeypatch.setitem(sys.modules, "sherpa_onnx", mock_sherpa) monkeypatch.setitem(sys.modules, "requests", mock_requests) monkeypatch.setattr(parakeet, "get_model_path", lambda _: str(model_dir)) monkeypatch.setattr(manager, "_stream_model_download", stream) if cached: manager._init_parakeet() else: with pytest.raises(ChecksumError): manager._init_parakeet() assert manager.state == RecognitionState.ERROR assert not manager._model_initialized mock_sherpa.OfflineRecognizer.from_transducer.assert_not_called() assert not manifest_path.exists() assert not parakeet.is_model_downloaded(model_name) assert manager._download_progress_callback is progress assert all(call.args[2] != "Complete!" for call in progress.call_args_list) assert all((model_dir / name).read_bytes() == b"verified model file" for name in names[1:]) content = json.dumps({"files": records}) manager._defer_download = False streamed.clear() manager._init_parakeet() assert manager._model_initialized assert parakeet.is_model_downloaded(model_name) assert streamed == ["manifest.json.tmp"] assert progress.call_args.args == (1.0, 0, "Complete!") 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( "vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded", return_value=True, ), 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( "vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded", return_value=True, ), 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( "vocalinux.speech_recognition.recognition_manager.faster_whisper.is_model_downloaded", return_value=True, ), 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.audio.capture._resolve_device_by_name", return_value=None, ) as mock_resolve_name, patch( "vocalinux.audio.capture._resolve_valid_input_device", return_value=1, ) as mock_resolve_default, patch( "vocalinux.audio.capture._get_supported_channels", return_value=1, ), patch( "vocalinux.audio.capture._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.audio.capture._resolve_device_by_name", return_value=None, ), patch( "vocalinux.audio.capture._resolve_valid_input_device", return_value=None, ), patch( "vocalinux.audio.capture._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.audio.capture._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