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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user