fix(settings): speech model follow-ups from QA (#836)

* fix(settings): speech model follow-ups from #834

Keep the Unused downloads expander in sync with search, recommend Faster
Whisper .en weights for English, parse nvidia-smi MiB VRAM, stop simple
mode from clobbering Faster Whisper/Parakeet, and ceil RAM so ~7.4 GiB
counts as 8.

* fix(settings): keep FW .en picker ids and test unused search

Represent Faster Whisper English catalog variants in the Settings size
combo so tray-saved small.en is not rejected and overwritten. Add
behavioral unused-downloads search coverage for match/nonmatch/clear.
This commit is contained in:
Jatin K Malik
2026-09-29 01:06:08 -07:00
committed by GitHub
parent 06f049ac76
commit 9646011e77
10 changed files with 744 additions and 25 deletions
+12
View File
@@ -200,6 +200,18 @@ class TestFasterWhisperModelInfo:
model, _reason = get_recommended_model()
assert model == "base"
def test_get_recommended_model_cuda_ceils_fractional_ram(self):
"""~7.4 GiB must count as 8 GiB so CUDA recommends small, not base."""
with patch(
"vocalinux.utils.faster_whisper_model_info._has_torch_cuda",
return_value=True,
):
with patch("psutil.virtual_memory") as mock_mem:
mock_mem.return_value.total = int(7.4 * 1024**3)
model, reason = get_recommended_model()
assert model == "small"
assert "8GB" in reason
def test_has_torch_cuda_available(self):
"""Test that torch detection reports CUDA availability."""
from vocalinux.utils.faster_whisper_model_info import _has_torch_cuda
+32
View File
@@ -105,6 +105,38 @@ class TestRecommendedModelForEngine:
def test_remote_api_has_nothing_to_download(self, settings_dialog):
assert settings_dialog.recommended_model_for_engine("remote_api", "auto") is None
def test_faster_whisper_english_gets_the_english_only_variant(self, settings_dialog):
with patch.object(
settings_dialog,
"get_recommended_faster_whisper_model",
return_value=("small", "CUDA GPU"),
):
result = settings_dialog.recommended_model_for_engine("faster_whisper", "en-us")
assert result.model_id == "small.en"
assert "CUDA GPU" in result.reason
assert result.model_id in settings_dialog.ENGINE_MODELS["faster_whisper"]
def test_faster_whisper_non_english_keeps_the_multilingual_model(self, settings_dialog):
with patch.object(
settings_dialog,
"get_recommended_faster_whisper_model",
return_value=("tiny", "4GB RAM"),
):
result = settings_dialog.recommended_model_for_engine("faster_whisper", "de")
assert result.model_id == "tiny"
def test_faster_whisper_skips_english_variant_when_catalog_has_none(self, settings_dialog):
with patch.object(
settings_dialog,
"get_recommended_faster_whisper_model",
return_value=("large-v3", "lots of RAM"),
):
result = settings_dialog.recommended_model_for_engine("faster_whisper", "en-us")
assert result.model_id == "large-v3"
class TestOffer:
"""What the notification carries."""
+70
View File
@@ -604,6 +604,19 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase):
self.assertTrue(callable(_get_recommended_whisper_model))
def test_get_recommended_whisper_model_ceils_fractional_ram(self):
"""~7.4 GiB must count as 8 GiB so the CPU path recommends small."""
from vocalinux.ui.settings_dialog import _get_recommended_whisper_model
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3)))
torch_mock = MagicMock()
torch_mock.cuda.is_available.return_value = False
with patch.dict("sys.modules", {"psutil": mock_psutil, "torch": torch_mock}):
model, reason = _get_recommended_whisper_model()
self.assertEqual(model, "small")
self.assertIn("8GB", reason)
def test_get_recommended_vosk_model_function_exists(self):
"""Test that _get_recommended_vosk_model function exists."""
from vocalinux.ui.settings_dialog import _get_recommended_vosk_model
@@ -622,6 +635,20 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase):
self.assertIn("large-v3-turbo-q5_0", WHISPERCPP_MODEL_INFO)
self.assertNotIn("small.en-tdrz", WHISPERCPP_MODEL_INFO)
def test_faster_whisper_picker_lists_english_only_catalog_ids(self):
"""Settings must list the same .en ids the tray can persist."""
from vocalinux.ui.settings_dialog import ENGINE_MODELS, FASTER_WHISPER_MODEL_INFO
fw = ENGINE_MODELS["faster_whisper"]
for model_id in ("tiny.en", "base.en", "small.en", "medium.en"):
self.assertIn(model_id, fw)
self.assertIn(model_id, FASTER_WHISPER_MODEL_INFO)
self.assertIn("large-v3", fw)
self.assertNotIn("large-v3.en", fw)
self.assertNotIn("large-v3.en", FASTER_WHISPER_MODEL_INFO)
for model_id in fw:
self.assertIn(model_id, FASTER_WHISPER_MODEL_INFO)
def test_model_display_name_large_v3_turbo(self):
"""Test display labels for whisper.cpp model variants."""
from vocalinux.ui.settings_dialog import _model_display_name
@@ -720,6 +747,37 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase):
)
self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium")
def test_faster_whisper_recommendation_uses_language_for_english_variant(self):
"""English language nudges Faster Whisper recommendations to .en variants."""
from vocalinux.ui.settings_dialog import (
_recommended_faster_whisper_variant_for_language,
)
self.assertEqual(
_recommended_faster_whisper_variant_for_language(
"small",
"mock hardware reason",
"en-us",
),
("small.en", "mock hardware reason"),
)
self.assertEqual(
_recommended_faster_whisper_variant_for_language(
"tiny",
"mock hardware reason",
"de",
),
("tiny", "mock hardware reason"),
)
self.assertEqual(
_recommended_faster_whisper_variant_for_language(
"large-v3",
"mock hardware reason",
"en-us",
),
("large-v3", "mock hardware reason"),
)
def test_whispercpp_variant_for_language_retargets_ambiguous_size_ids(self):
"""Bare size ids must follow language, not stick on multilingual."""
from vocalinux.ui.settings_dialog import _whispercpp_variant_for_language
@@ -941,6 +999,18 @@ class TestSettingsSearch(unittest.TestCase):
self.assertIn("Gtk.Container", source)
self.assertIn("widget.get_children()", source)
def test_unused_island_follows_nested_group_during_search(self):
"""The Unused downloads expander header must not leak next to unrelated hits."""
source = self._settings_source()
search_body = source.split("def _on_search_changed(self, entry)")[1].split("def ")[0]
self.assertIn("self.unused_island.set_visible", search_body)
self.assertIn("self.unused_models_group.get_visible()", search_body)
snapshot_body = source.split("def _snapshot_search_baseline")[1].split("def ")[0]
self.assertIn("self.unused_island", snapshot_body)
restore_body = source.split("def _restore_search_baseline")[1].split("def ")[0]
self.assertIn("widget.set_visible(visible)", restore_body)
self.assertIn('self.unused_models_group.title = "Unused downloads"', source)
def test_search_respects_closed_revealers(self):
"""A locked Advanced section must not leak controls into search."""
source = self._settings_source()
+157
View File
@@ -788,6 +788,54 @@ def _dialog_for_engine_ui(engine_text: str):
return dialog
class _FakeCombo:
"""ComboBoxText stand-in that records ids the way `_populate_model_options` writes them."""
def __init__(self) -> None:
self._items: list[tuple[str, str]] = []
self._active_id: str | None = None
def remove_all(self) -> None:
self._items.clear()
self._active_id = None
def append(self, item_id: str, text: str) -> None:
self._items.append((item_id, text))
def set_active_id(self, item_id: str) -> bool:
if any(stored_id == item_id for stored_id, _text in self._items):
self._active_id = item_id
return True
return False
def set_active(self, index: int) -> None:
self._active_id = self._items[index][0]
def get_active_id(self) -> str | None:
return self._active_id
def get_active_text(self) -> str | None:
for stored_id, text in self._items:
if stored_id == self._active_id:
return text
return None
def get_model(self) -> list[tuple[str, str]]:
# Gtk.ComboBoxText stores (display text, id); the populate fallback
# compares row[0] against the id it is trying to restore.
return [(text, stored_id) for stored_id, text in self._items]
@property
def ids(self) -> list[str]:
return [item_id for item_id, _text in self._items]
def text_for(self, item_id: str) -> str:
for stored_id, text in self._items:
if stored_id == item_id:
return text
raise KeyError(item_id)
def _dialog_for_selected_settings(engine_text: str, language_id: str, model_id: str = "small"):
"""Stub combos and spins so `get_selected_settings` can run unbound."""
dialog = _dialog_stub()
@@ -811,6 +859,115 @@ def _dialog_for_selected_settings(engine_text: str, language_id: str, model_id:
return dialog
def _faster_whisper_picker_dialog(
dialog_class: type[Any], *, saved_model: str, language: str = "en-us"
) -> Mock:
"""Stub enough widgets to run real `_populate_model_options` for Faster Whisper."""
dialog = _dialog_for_selected_settings("Faster Whisper", language, model_id=saved_model)
dialog.language = language
dialog.config_manager.get_model_size_for_engine.return_value = saved_model
dialog.model_combo = _FakeCombo()
dialog.model_variant_combo = _FakeCombo()
_bind_real(dialog, dialog_class, "_populate_model_options", "get_selected_settings")
return dialog
def _populate_faster_whisper_picker(
settings_dialog: Any,
dialog_class: type[Any],
*,
saved_model: str,
language: str = "en-us",
recommended: str = "small",
) -> Mock:
dialog = _faster_whisper_picker_dialog(dialog_class, saved_model=saved_model, language=language)
with (
patch.object(
settings_dialog,
"get_recommended_faster_whisper_model",
return_value=(recommended, "CUDA GPU"),
),
patch.object(settings_dialog, "is_faster_whisper_model_downloaded", return_value=False),
):
dialog._populate_model_options()
return dialog
def test_faster_whisper_picker_keeps_saved_english_model(settings_dialog, dialog_class):
"""A tray-persisted ``small.en`` must stay selected instead of falling back."""
dialog = _populate_faster_whisper_picker(
settings_dialog, dialog_class, saved_model="small.en", language="en-us"
)
assert "small.en" in dialog.model_combo.ids
assert "Small.en" not in dialog.model_combo.ids
assert dialog.model_combo.get_active_id() == "small.en"
assert dialog.get_selected_settings()["model_size"] == "small.en"
def test_faster_whisper_english_recommendation_stars_en_variant(settings_dialog, dialog_class):
"""English language must star ``small.en``, not the bare multilingual row."""
dialog = _populate_faster_whisper_picker(
settings_dialog,
dialog_class,
saved_model="tiny",
language="en-us",
recommended="small",
)
assert " ★" in dialog.model_combo.text_for("small.en")
assert " ★" not in dialog.model_combo.text_for("small")
def test_faster_whisper_non_english_stars_multilingual_and_keeps_saved_en(
settings_dialog, dialog_class
):
"""German must star ``small``, not ``small.en``, and must not rewrite a saved .en id."""
dialog = _populate_faster_whisper_picker(
settings_dialog,
dialog_class,
saved_model="small.en",
language="de",
recommended="small",
)
assert dialog.model_combo.get_active_id() == "small.en"
assert " ★" in dialog.model_combo.text_for("small")
assert " ★" not in dialog.model_combo.text_for("small.en")
assert dialog.get_selected_settings()["model_size"] == "small.en"
def test_faster_whisper_selected_settings_keep_dotted_id(dialog_class):
"""Raw combo ids such as ``small.en`` must persist through instant-apply."""
dialog = _dialog_for_selected_settings("Faster Whisper", "en-us", model_id="small.en")
settings = dialog_class.get_selected_settings(dialog)
assert settings["engine"] == "faster_whisper"
assert settings["model_size"] == "small.en"
def test_whisper_picker_still_uses_capitalized_combo_ids(settings_dialog, dialog_class):
"""Raw Faster Whisper ids must not leak into engines whose combo ids are capitalized."""
dialog = _dialog_for_selected_settings("Whisper", "en-us", model_id="small")
dialog.language = "en-us"
dialog.config_manager.get_model_size_for_engine.return_value = "small"
dialog.model_combo = _FakeCombo()
dialog.model_variant_combo = _FakeCombo()
_bind_real(dialog, dialog_class, "_populate_model_options")
with (
patch.object(
settings_dialog, "_get_recommended_whisper_model", return_value=("small", "reason")
),
patch.object(settings_dialog, "_is_whisper_model_downloaded", return_value=False),
):
dialog._populate_model_options()
assert "Small" in dialog.model_combo.ids
assert "small" not in dialog.model_combo.ids
assert dialog.model_combo.get_active_id() == "Small"
def test_parakeet_hides_the_language_picker(dialog_class):
"""Parakeet language coverage is the model, so the picker must not stay shown."""
dialog = _dialog_for_engine_ui("Parakeet")
+33
View File
@@ -60,6 +60,7 @@ def _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="sma
dialog.simple_second_language_combo.get_active_id.return_value = second
dialog.simple_priority_combo.get_active_id.return_value = priority
dialog._get_recommended_whispercpp_model_for_language.return_value = (recommended, "reason")
dialog._get_selected_engine.return_value = "whisper_cpp"
# The language resolution is the code under test's own helper, not a mock.
dialog._simple_decoding_language.side_effect = (
lambda: _sd().SettingsDialog._simple_decoding_language(dialog)
@@ -688,6 +689,38 @@ def test_a_plain_weight_is_preferred_over_a_quantized_one(settings_dialog, dialo
assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base"
@pytest.mark.parametrize("engine", ["faster_whisper", "parakeet"])
def test_simple_choice_leaves_faster_whisper_and_parakeet_alone(
settings_dialog, dialog_class, engine
):
"""Editing a simple question must not yank Advanced off Faster Whisper or Parakeet."""
dialog = _dialog_stub(language="en-us", multi=False, priority=BALANCED, recommended="small")
dialog._get_selected_engine.return_value = engine
dialog_class._apply_simple_choice(dialog)
dialog.engine_combo.set_active_id.assert_not_called()
dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "en-us")
assert dialog.language == "en-us"
dialog.model_combo.set_active_id.assert_not_called()
dialog.model_variant_combo.set_active_id.assert_not_called()
@pytest.mark.parametrize("engine", ["vosk", "remote_api", "whisper", "unknown"])
def test_simple_choice_steers_incompatible_engines_to_whisper_cpp(
settings_dialog, dialog_class, engine
):
dialog = _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="small")
dialog._get_selected_engine.return_value = engine
dialog_class._apply_simple_choice(dialog)
dialog.engine_combo.set_active_id.assert_called_once_with("whisper_cpp")
dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "pl")
dialog.model_combo.set_active_id.assert_called_once_with("small")
dialog.model_variant_combo.set_active_id.assert_called_once_with("small")
def test_simple_mode_consults_the_disk_before_settling_on_a_variant(settings_dialog, dialog_class):
dialog = _dialog_stub(language="en-us", multi=False, priority=BALANCED, recommended="small")
dialog._on_disk_stand_in.side_effect = lambda variant, size, language: "small"
+235
View File
@@ -6,9 +6,18 @@ helper that is unit-tested directly, and the wiring around it is asserted
against the source of the two methods.
"""
from __future__ import annotations
import importlib
import inspect
import re
import sys
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
import vocalinux.ui
from vocalinux.ui import settings_dialog
from vocalinux.ui.settings_dialog import (
_UNUSED_DOWNLOADS_MAX_HEIGHT,
@@ -89,3 +98,229 @@ def test_unused_downloads_are_a_sibling_expander_not_nested_in_advanced():
assert "self.advanced_box.pack_start(self.unused_models_group" not in src
assert "self.unused_expander" in src
assert "_make_expander_card" in src
def test_unused_island_visibility_tracks_nested_group_during_search():
"""Search hides leftover-model rows; the expander header must follow them."""
src = inspect.getsource(settings_dialog)
search_body = src.split("def _on_search_changed(self, entry)")[1].split("def ")[0]
snapshot_body = src.split("def _snapshot_search_baseline")[1].split("def ")[0]
assert "self.unused_island.set_visible" in search_body
assert "self.unused_models_group.get_visible()" in search_body
assert "unused_island" in snapshot_body
@pytest.fixture(scope="module")
def settings_dialog_module():
"""Reimport with real Gtk bases so SettingsDialog search methods stay callable."""
repository = sys.modules["gi.repository"]
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None)
saved_attribute = getattr(vocalinux.ui, "settings_dialog", None)
try:
with patch.object(repository, "Gtk", MagicMock(**bases)):
module = importlib.import_module("vocalinux.ui.settings_dialog")
yield module
finally:
sys.modules.pop("vocalinux.ui.settings_dialog", None)
if saved_module is not None:
sys.modules["vocalinux.ui.settings_dialog"] = saved_module
if saved_attribute is not None:
vocalinux.ui.settings_dialog = saved_attribute
elif hasattr(vocalinux.ui, "settings_dialog"):
del vocalinux.ui.settings_dialog
class _Visible:
"""Widget stand-in with the visibility API search actually calls."""
def __init__(self, visible: bool = True) -> None:
self._visible = visible
self._parent = None
def get_visible(self) -> bool:
return self._visible
def set_visible(self, visible: bool) -> None:
self._visible = bool(visible)
def hide(self) -> None:
self._visible = False
def show(self) -> None:
self._visible = True
def get_parent(self) -> Any:
return self._parent
class _Label(_Visible):
def __init__(self) -> None:
super().__init__(visible=False)
self.text = ""
def set_text(self, text: str) -> None:
self.text = text
class _SidebarRow(_Visible):
def __init__(self) -> None:
super().__init__(visible=True)
self.sensitive = True
def set_sensitive(self, sensitive: bool) -> None:
self.sensitive = bool(sensitive)
class _Group(_Visible):
def __init__(
self,
title: str = "",
description: str = "",
keywords: tuple[str, ...] = (),
rows: list[Any] | None = None,
visible: bool = True,
) -> None:
super().__init__(visible)
self.title = title
self.description = description
self.keywords = keywords
self.rows = list(rows or [])
class _Page:
def __init__(self, name: str, groups: list[_Group]) -> None:
self.name = name
self.groups = groups
self.extras: list[Any] = []
self.box = _Visible()
self.sidebar_row = _SidebarRow()
self.match_count_label = _Label()
self.update_badge_label = None
class _Stack:
def __init__(self, name: str) -> None:
self._name = name
def get_visible_child_name(self) -> str:
return self._name
def set_visible_child_name(self, name: str) -> None:
self._name = name
class _Listbox:
def __init__(self) -> None:
self.selected = None
def select_row(self, row: Any) -> None:
self.selected = row
def unselect_all(self) -> None:
self.selected = None
class _Entry:
def __init__(self, text: str) -> None:
self._text = text
def get_text(self) -> str:
return self._text
def _search_row(settings_dialog_module: Any, title: str, **kwargs: Any) -> Any:
"""PreferenceRow subclass that skips GTK construction.
``matches_query`` is left on PreferenceRow so a stub cannot make row-only
searches look like they work.
"""
class _Row(settings_dialog_module.PreferenceRow, _Visible):
def __init__(
self,
title: str,
subtitle: str = "",
keywords: tuple[str, ...] = (),
visible: bool = True,
) -> None:
_Visible.__init__(self, visible)
self.title = title
self.subtitle = subtitle
self.keywords = keywords
return _Row(title, **kwargs)
def _wire_unused_search_dialog(settings_dialog_module: Any) -> Mock:
"""Minimal dialog for `_on_search_changed` unused-island visibility."""
unused_row = _search_row(
settings_dialog_module,
"Faster Whisper small",
subtitle="244 MB",
keywords=("unused", "download"),
)
unused_group = _Group(
title="Unused downloads",
description="Downloaded, but not the one in use",
keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"),
rows=[unused_row],
)
other_group = _Group(
title="Engine",
rows=[_search_row(settings_dialog_module, "Speech engine")],
)
page = _Page("speech", groups=[other_group, unused_group])
unused_island = _Visible(visible=True)
dialog = Mock()
dialog._pages = [page]
dialog.unused_island = unused_island
dialog.unused_models_group = unused_group
dialog.settings_stack = _Stack("speech")
dialog.sidebar_listbox = _Listbox()
dialog._search_baseline = None
dialog._search_previous_page = None
dialog._pending_update = None
dialog.search_empty_label = _Label()
dialog._update_engine_specific_ui = lambda: None
dialog._set_about_update_badge = lambda *_args, **_kwargs: None
dialog_class = settings_dialog_module.SettingsDialog
for name in (
"_snapshot_search_baseline",
"_restore_search_baseline",
"_on_search_changed",
):
setattr(dialog, name, getattr(dialog_class, name).__get__(dialog))
return dialog
def test_unused_island_follows_search_hits_and_restores_on_clear(settings_dialog_module):
"""Matching, non-matching, and cleared queries must drive expander visibility."""
dialog = _wire_unused_search_dialog(settings_dialog_module)
island = dialog.unused_island
group = dialog.unused_models_group
unused_row = group.rows[0]
engine_row = dialog._pages[0].groups[0].rows[0]
# Subtitle-only needle: group title/keywords do not contain "244", so the
# island can show only if PreferenceRow.matches_query actually runs.
dialog._on_search_changed(_Entry("244"))
assert unused_row.get_visible() is True
assert engine_row.get_visible() is False
assert island.get_visible() is True
assert group.get_visible() is True
dialog._on_search_changed(_Entry("unused"))
assert island.get_visible() is True
assert group.get_visible() is True
dialog._on_search_changed(_Entry("engine"))
assert unused_row.get_visible() is False
assert island.get_visible() is False
assert group.get_visible() is False
dialog._on_search_changed(_Entry(""))
assert island.get_visible() is True
assert group.get_visible() is True
+100
View File
@@ -948,6 +948,106 @@ class TestGetRecommendedModel(unittest.TestCase):
self.assertIsInstance(reason, str)
self.assertGreater(len(reason), 0)
def test_parse_cuda_vram_mib_and_gb(self):
"""nvidia-smi reports MiB; older mocks used GB. Both must parse."""
from vocalinux.utils.whispercpp_model_info import _parse_cuda_vram_gib
self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3060 (6144 MiB)"), 6.0)
self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3070 (8192 MiB)"), 8.0)
self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 4090 (24GB)"), 24.0)
self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3080 (10 GB)"), 10.0)
self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3060 (8 GiB)"), 8.0)
self.assertEqual(_parse_cuda_vram_gib("NVIDIA GTX 1650 (4096 MB)"), 4.0)
self.assertIsNone(_parse_cuda_vram_gib("NVIDIA GPU"))
self.assertIsNone(_parse_cuda_vram_gib(""))
def test_get_recommended_model_with_cuda_mib_medium(self):
"""8 GiB of CUDA VRAM reported as MiB must recommend medium."""
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend"
) as mock_backend:
mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3070 (8192 MiB)")
from vocalinux.utils.whispercpp_model_info import get_recommended_model
model, reason = get_recommended_model()
self.assertEqual(model, "medium")
self.assertIn("VRAM", reason)
def test_get_recommended_model_with_cuda_mib_small(self):
"""6 GiB of CUDA VRAM reported as MiB must recommend small."""
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend"
) as mock_backend:
mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3060 (6144 MiB)")
from vocalinux.utils.whispercpp_model_info import get_recommended_model
model, reason = get_recommended_model()
self.assertEqual(model, "small")
def test_get_recommended_model_with_cuda_mib_base(self):
"""Low CUDA VRAM reported as MiB must recommend base."""
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend"
) as mock_backend:
mock_backend.return_value = ("cuda", "NVIDIA GeForce GTX 1050 (2048 MiB)")
from vocalinux.utils.whispercpp_model_info import get_recommended_model
model, reason = get_recommended_model()
self.assertEqual(model, "base")
def test_get_recommended_model_with_cuda_unparseable_vram_falls_back(self):
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend"
) as mock_backend:
mock_backend.return_value = ("cuda", "NVIDIA GPU")
from vocalinux.utils.whispercpp_model_info import get_recommended_model
model, reason = get_recommended_model()
self.assertEqual(model, "small")
self.assertIn("CUDA GPU detected", reason)
def test_get_recommended_model_vulkan_ceils_fractional_ram(self):
"""~7.4 GiB must count as 8 GiB so Vulkan recommends small, not base."""
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3)))
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch(
"vocalinux.utils.whispercpp_model_info.detect_compute_backend"
) as mock_backend:
mock_backend.return_value = ("vulkan", "Intel Arc A770")
from vocalinux.utils.whispercpp_model_info import get_recommended_model
model, reason = get_recommended_model()
self.assertEqual(model, "small")
self.assertIn("8GB", reason)
class TestGetModelPath(unittest.TestCase):
"""Test cases for get_model_path function."""