feat(settings): delete unused downloaded speech models (#671)

* feat(settings): delete unused downloaded speech models

Add a Remove Model dropdown on the Speech Model page so whisper.cpp,
Whisper, and VOSK downloads can be deleted from Settings. The currently
selected model is marked in use and cannot be removed until you switch
away from it.

Fixes #650

* fix(settings): move unused model deletion below the picker

Unused downloads is its own group under the model info card. Only leftover
files appear, each with a Delete button. The group hides when empty.

* fix(settings): collapse unused downloads into an expander

Keep leftover models behind one row so a long list does not stretch the
Speech Model page. Expand to delete files one at a time; the list scrolls
once it grows past a few entries.

* fix(settings): keep leftover model list from clipping in the expander

Wrap the unused-downloads list so the expander can show a few rows and
scroll the rest, instead of painting only the first leftover.
This commit is contained in:
Jatin K Malik
2026-08-12 23:42:36 -07:00
committed by GitHub
parent cfe93f0397
commit ecd5839a13
9 changed files with 611 additions and 3 deletions
+3 -1
View File
@@ -342,7 +342,9 @@ Configuration is stored in `~/.config/vocalinux/config.json`:
For whisper.cpp, `model_size` may be a size such as `tiny` or an exact ggml model ID
such as `medium.en-q5_0` or `large-v3-turbo`. You can also configure this through
the graphical Settings dialog, where whisper.cpp models are split into **Model Size**
and **Specialization** controls.
and **Specialization** controls. Unused leftover downloads can be deleted from
**Unused downloads** on the Speech Model page (expand the section, then delete
one model at a time).
### Neural Voice Activity Detection
+9
View File
@@ -136,6 +136,15 @@ Vocalinux now offers **three speech recognition engines**:
English-only whisper.cpp specializations limit the language selector to English.
### Removing unused models
Open Settings and go to **Speech Model**. If leftover files are on disk that are
not the model currently selected, **Unused downloads** appears under the model
info card. Expand it to delete leftovers one at a time. Confirming removes those
files from `~/.local/share/vocalinux`. Vocalinux will download a model again if
you select it later. Packaged system-wide VOSK models are left alone. The section
is hidden when there is nothing unused to delete.
### When to Use Each Model
**For real-time dictation:** Use **tiny** or **base** - they're fast enough to keep up with your speech.
+284 -1
View File
@@ -43,10 +43,19 @@ from ..utils.update_checker import ( # noqa: E402
is_update_available,
normalize_channel,
)
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402
from ..utils.vosk_model_info import ( # noqa: E402
SUPPORTED_LANGUAGES,
VOSK_MODEL_INFO,
delete_vosk_model,
list_downloaded_vosk_models,
vosk_model_dirname,
)
from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES
from ..utils.whispercpp_model_info import (
WHISPERCPP_MODEL_INFO,
)
from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model
from ..utils.whispercpp_model_info import (
detect_compute_backend,
detect_vulkan_devices,
get_backend_display_name,
@@ -56,6 +65,9 @@ from ..utils.whispercpp_model_info import get_model_variants as get_whispercpp_m
from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model
from ..utils.whispercpp_model_info import is_english_only_model as is_english_only_whispercpp_model
from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded
from ..utils.whispercpp_model_info import (
list_downloaded_models as list_downloaded_whispercpp_models,
)
from ..version import __copyright__, __description__, __url__, __version__ # noqa: E402
from .config_manager import DEFAULT_CONFIG # noqa: E402
from .keyboard_backends import ( # noqa: E402
@@ -628,6 +640,15 @@ spinbutton {
font-size: 1.0em;
}
/* Unused downloads: one collapsed row until expanded */
.unused-downloads-expander {
padding: 8px 12px;
}
.unused-downloads-expander list {
background-color: transparent;
}
.model-info-subtitle {
font-size: 0.9em;
color: @theme_unfocused_fg_color;
@@ -748,6 +769,36 @@ def _get_whisper_cache_dir() -> str:
return os.path.join(MODELS_DIR, "whisper")
def _whisper_model_files(model_name: str) -> list[str]:
"""Return existing OpenAI Whisper weight files for a catalog model name."""
if model_name not in WHISPER_MODEL_INFO:
return []
filename = f"{model_name}.pt"
candidates = [
os.path.join(_get_whisper_cache_dir(), filename),
os.path.join(os.path.expanduser("~/.cache/whisper"), filename),
]
allowed_parents = {
os.path.realpath(_get_whisper_cache_dir()),
os.path.realpath(os.path.expanduser("~/.cache/whisper")),
}
found: list[str] = []
seen: set[str] = set()
for candidate in candidates:
if not os.path.isfile(candidate):
continue
real = os.path.realpath(candidate)
if real in seen or os.path.dirname(real) not in allowed_parents:
continue
if os.path.basename(real) != filename:
continue
seen.add(real)
found.append(real)
return found
def _is_whisper_model_downloaded(model_name: str) -> bool:
"""Check if a Whisper model is downloaded."""
cache_dir = _get_whisper_cache_dir()
@@ -759,6 +810,26 @@ def _is_whisper_model_downloaded(model_name: str) -> bool:
return os.path.exists(os.path.join(default_cache, f"{model_name}.pt"))
def _list_downloaded_whisper_models() -> list[str]:
"""Return OpenAI Whisper catalog names that are present on disk."""
return [name for name in WHISPER_MODEL_INFO if _is_whisper_model_downloaded(name)]
def _delete_whisper_model(model_name: str) -> list[str]:
"""Delete OpenAI Whisper weight files for a catalog model name."""
if model_name not in WHISPER_MODEL_INFO:
raise ValueError(f"Unknown Whisper model: {model_name}")
files = _whisper_model_files(model_name)
if not files:
raise FileNotFoundError(model_name)
for path in files:
os.remove(path)
logger.info("Deleted Whisper model %s (%s)", model_name, path)
return files
def _format_size(size_mb: int) -> str:
"""Format size in MB to human readable string."""
if size_mb >= 1000:
@@ -982,6 +1053,12 @@ class PreferencesGroup(Gtk.Box):
self.listbox.add(widget)
self.rows.append(widget)
def clear_rows(self):
"""Remove all preference rows from the group."""
for row in list(self.listbox.get_children()):
self.listbox.remove(row)
self.rows.clear()
class PreferenceRow(Gtk.ListBoxRow):
"""A single preference row with title, subtitle, and a control widget."""
@@ -2261,6 +2338,36 @@ class SettingsDialog(Gtk.Dialog):
self.content_box.pack_start(self.model_info_card, False, False, 0)
self.unused_models_group = PreferencesGroup(
keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"),
)
self.unused_models_group.title = "Unused downloads"
self.unused_models_group.description = "On disk, but not the model currently selected"
self.unused_expander = Gtk.Expander(label="Unused downloads")
self.unused_expander.set_expanded(False)
self.unused_expander.set_use_underline(False)
self.unused_expander.set_tooltip_text(
"Leftover model files on disk. Expand to delete them one at a time."
)
self.unused_expander.get_style_context().add_class("unused-downloads-expander")
self.unused_models_scroll = Gtk.ScrolledWindow()
self.unused_models_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.unused_models_scroll.set_shadow_type(Gtk.ShadowType.NONE)
self.unused_models_scroll.set_propagate_natural_height(True)
self.unused_models_scroll.set_max_content_height(220)
# ListBox is GtkScrollable, so wrapping it in a Box forces a Viewport
# and lets the expander child report a real height.
list_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.unused_models_group.remove(self.unused_models_group.listbox)
list_holder.pack_start(self.unused_models_group.listbox, False, False, 0)
self.unused_models_scroll.add(list_holder)
self.unused_expander.add(self.unused_models_scroll)
self.unused_models_group.pack_start(self.unused_expander, False, False, 0)
self.content_box.pack_start(self.unused_models_group, False, False, 0)
# Connect signals
self.engine_combo.connect("changed", self._on_engine_changed)
self.model_combo.connect("changed", self._on_model_changed)
@@ -4052,6 +4159,7 @@ class SettingsDialog(Gtk.Dialog):
logger.info(f"Final selected model: {self.model_combo.get_active_text()}")
finally:
self._populating_models = False
self._refresh_unused_downloads()
def _populate_whispercpp_model_options(self, saved_model_for_engine: str):
"""Populate whisper.cpp size and specialization selectors."""
@@ -4109,6 +4217,177 @@ class SettingsDialog(Gtk.Dialog):
self._set_combo_active_id_or_first(self.model_variant_combo, model_to_set)
self._update_model_picker_tooltips()
def _active_removable_model_id(self) -> Optional[str]:
"""Return the on-disk id of the model currently selected in Settings."""
engine = self._get_selected_engine()
if engine == "whisper_cpp":
return self._get_selected_whispercpp_model()
if engine == "whisper":
model_id = self.model_combo.get_active_id()
return model_id.lower() if model_id else None
if engine == "vosk":
size = (self.model_combo.get_active_id() or "").lower()
language = self.language_combo.get_active_id() or self.language
return vosk_model_dirname(size, language)
return None
def _list_unused_downloads(self) -> list[tuple[str, str, str]]:
"""Return (id, title, size_label) for unused downloaded models."""
return [
(model_id, label, size_label)
for model_id, label, size_label, in_use in self._list_downloaded_models()
if not in_use
]
def _list_downloaded_models(self) -> list[tuple[str, str, str, bool]]:
"""Return (id, title, size_label, in_use) for downloaded models."""
engine = self._get_selected_engine()
active_id = self._active_removable_model_id()
items: list[tuple[str, str, str, bool]] = []
if engine == "whisper_cpp":
for name in list_downloaded_whispercpp_models():
info = WHISPERCPP_MODEL_INFO[name]
size_mb = info["size_mb"] if isinstance(info.get("size_mb"), int) else 0
items.append(
(
name,
_model_display_name(name),
_format_size(size_mb),
name == active_id,
)
)
elif engine == "whisper":
for name in _list_downloaded_whisper_models():
info = WHISPER_MODEL_INFO[name]
size_mb = info["size_mb"] if isinstance(info.get("size_mb"), int) else 0
items.append(
(
name,
_model_display_name(name),
_format_size(size_mb),
name == active_id,
)
)
elif engine == "vosk":
for model in list_downloaded_vosk_models():
lang_info = SUPPORTED_LANGUAGES.get(model.language, {})
lang_name = model.language
if isinstance(lang_info, dict):
name = lang_info.get("name")
if isinstance(name, str):
lang_name = name
items.append(
(
model.dirname,
f"{lang_name} · {_model_display_name(model.size)}",
_format_size(model.size_mb),
model.dirname == active_id,
)
)
return items
def _refresh_unused_downloads(self):
"""Rebuild the Unused downloads list, or hide it when empty."""
if not hasattr(self, "unused_models_group"):
return
if self._get_selected_engine() == "remote_api":
self.unused_models_group.hide()
return
unused = self._list_unused_downloads()
self.unused_models_group.clear_rows()
if not unused:
self.unused_models_group.hide()
return
count = len(unused)
leftover = "leftover model" if count == 1 else "leftover models"
self.unused_expander.set_label(f"Unused downloads ({count} {leftover})")
self.unused_models_scroll.set_min_content_height(min(220, 56 * count))
was_expanded = self.unused_expander.get_expanded()
for model_id, title, size_label in unused:
delete_btn = Gtk.Button(label="Delete")
delete_btn.set_tooltip_text(f"Delete {title} from disk")
delete_btn.get_style_context().add_class("destructive-action")
delete_btn.connect(
"clicked",
self._on_unused_download_delete_clicked,
model_id,
f"{title} ({size_label})",
)
row = PreferenceRow(
title=title,
subtitle=size_label,
widget=delete_btn,
keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"),
)
self.unused_models_group.add_row(row)
self.unused_models_group.show_all()
self.unused_expander.set_expanded(was_expanded)
def _confirm_model_delete(self, text: str, secondary: str) -> bool:
"""Ask before deleting model files from disk."""
dialog = Gtk.MessageDialog(
transient_for=self,
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.NONE,
text=text,
)
dialog.format_secondary_text(secondary)
dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL)
delete_btn = dialog.add_button("_Delete", Gtk.ResponseType.YES)
delete_btn.get_style_context().add_class("destructive-action")
response = dialog.run()
dialog.destroy()
return response == Gtk.ResponseType.YES
def _delete_model_from_disk(self, model_id: str) -> None:
"""Delete a downloaded model for the selected engine."""
engine = self._get_selected_engine()
if engine == "whisper_cpp":
delete_whispercpp_model(model_id)
elif engine == "whisper":
_delete_whisper_model(model_id)
elif engine == "vosk":
delete_vosk_model(model_id)
else:
raise ValueError(f"No local models to delete for engine {engine}")
def _on_unused_download_delete_clicked(self, widget, model_id: str, label: str):
"""Confirm and delete one unused downloaded model."""
if model_id == self._active_removable_model_id():
return
if not self._confirm_model_delete(
"Delete unused download?",
f"{label} will be removed from disk. You can download it again later if needed.",
):
return
try:
self._delete_model_from_disk(model_id)
except (OSError, ValueError, FileNotFoundError) as e:
logger.error("Failed to delete model %s: %s", model_id, e)
err = Gtk.MessageDialog(
transient_for=self,
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Could not delete model",
)
err.format_secondary_text(str(e))
err.run()
err.destroy()
return
self._populate_model_options()
self._update_model_info()
def _on_engine_changed(self, widget):
"""Handle changes in the selected engine."""
engine_text = self.engine_combo.get_active_text()
@@ -4159,6 +4438,7 @@ class SettingsDialog(Gtk.Dialog):
self._sync_language_options_for_selected_model()
self._update_model_info()
self._refresh_unused_downloads()
self._auto_apply_settings()
def _on_model_variant_changed(self, widget):
@@ -4168,6 +4448,7 @@ class SettingsDialog(Gtk.Dialog):
self._sync_language_options_for_selected_model()
self._update_model_info()
self._refresh_unused_downloads()
self._auto_apply_settings()
def _on_vad_changed(self, widget):
@@ -4307,6 +4588,7 @@ class SettingsDialog(Gtk.Dialog):
if is_remote:
self.model_row.hide()
self.model_variant_row.hide()
self.unused_models_group.hide()
self.model_info_card.hide()
self.remote_server_group.show_all()
self.remote_status_label.show()
@@ -4320,6 +4602,7 @@ class SettingsDialog(Gtk.Dialog):
self.remote_status_label.hide()
self._update_model_info()
self._refresh_unused_downloads()
self._update_language_warning()
self._update_model_picker_tooltips()
self._update_advanced_tab_sensitivity()
+19
View File
@@ -28,3 +28,22 @@ def data_dir() -> str:
def models_dir() -> str:
"""Return the directory where speech-recognition models are stored."""
return os.path.join(data_dir(), "models")
def is_within_directory(path: str, directory: str, *, allow_root: bool = False) -> bool:
"""Return whether ``path`` resolves inside ``directory``.
Symlinks are resolved before the check. The directory itself is included
only when ``allow_root`` is true.
"""
real_path = os.path.realpath(path)
real_dir = os.path.realpath(directory)
try:
rel = os.path.relpath(real_path, real_dir)
except ValueError:
return False
if os.path.isabs(rel) or rel == ".." or rel.startswith(".." + os.sep):
return False
if rel == ".":
return allow_root
return True
+109
View File
@@ -1,3 +1,14 @@
"""Language definitions and VOSK model metadata."""
import logging
import os
import shutil
from typing import Any, NamedTuple, Optional
from .paths import is_within_directory, models_dir
logger = logging.getLogger(__name__)
# Language definitions with display names and Whisper/VOSK codes
# Supported languages for speech recognition
#
@@ -269,3 +280,101 @@ VOSK_MODEL_INFO = {
},
},
}
class DownloadedVoskModel(NamedTuple):
"""A VOSK model directory present in the user models folder."""
dirname: str
language: str
size: str
size_mb: int
path: str
def _vosk_catalog_entry(size: str) -> Optional[dict[str, Any]]:
info = VOSK_MODEL_INFO.get(size)
return info if isinstance(info, dict) else None
def _vosk_language_map(info: dict[str, Any]) -> dict[str, str]:
languages = info.get("languages")
if not isinstance(languages, dict):
return {}
return {str(key): str(value) for key, value in languages.items()}
def vosk_model_dirname(size: str, language: str) -> Optional[str]:
"""Return the on-disk directory name for a VOSK size and language."""
info = _vosk_catalog_entry(size)
if info is None:
return None
languages = _vosk_language_map(info)
if language == "auto" or language not in languages:
language = "en-us"
return languages.get(language)
def _known_vosk_dirnames() -> set[str]:
names: set[str] = set()
for info in VOSK_MODEL_INFO.values():
if isinstance(info, dict):
names.update(_vosk_language_map(info).values())
return names
def list_downloaded_vosk_models() -> list[DownloadedVoskModel]:
"""Return unique user-installed VOSK model directories.
System-wide preinstalled models are ignored; only ``models_dir()`` is
scanned so Settings can offer deletion without touching package files.
"""
seen: set[str] = set()
found: list[DownloadedVoskModel] = []
models_root = models_dir()
for size, info in VOSK_MODEL_INFO.items():
if not isinstance(info, dict):
continue
size_mb_raw = info.get("size_mb", 0)
size_mb = int(size_mb_raw) if isinstance(size_mb_raw, (int, float)) else 0
for language, dirname in _vosk_language_map(info).items():
if dirname in seen:
continue
path = os.path.join(models_root, dirname)
if os.path.isdir(path):
seen.add(dirname)
found.append(
DownloadedVoskModel(
dirname=dirname,
language=language,
size=size,
size_mb=size_mb,
path=path,
)
)
return found
def delete_vosk_model(dirname: str) -> str:
"""Delete a user-installed VOSK model directory.
Returns:
The removed filesystem path.
Raises:
ValueError: Unknown model name, or path outside the models directory.
FileNotFoundError: The model directory is not present.
OSError: The directory could not be removed.
"""
if dirname not in _known_vosk_dirnames() or os.path.basename(dirname) != dirname:
raise ValueError(f"Unknown VOSK model: {dirname}")
path = os.path.join(models_dir(), dirname)
if not is_within_directory(path, models_dir()):
raise ValueError("Refusing to delete a path outside the models directory")
if not os.path.isdir(path):
raise FileNotFoundError(path)
shutil.rmtree(path)
logger.info("Deleted VOSK model %s (%s)", dirname, path)
return path
+32 -1
View File
@@ -12,7 +12,7 @@ import subprocess
from functools import lru_cache
from typing import Optional
from .paths import models_dir
from .paths import is_within_directory, models_dir
logger = logging.getLogger(__name__)
@@ -428,6 +428,37 @@ def is_model_downloaded(model_name: str) -> bool:
return os.path.exists(model_path)
def list_downloaded_models() -> list[str]:
"""Return catalog model names whose files are present on disk."""
return [name for name in AVAILABLE_MODELS if is_model_downloaded(name)]
def delete_model(model_name: str) -> str:
"""Delete a downloaded whisper.cpp model file.
Returns:
The removed filesystem path.
Raises:
ValueError: Unknown model name, or path outside the models directory.
FileNotFoundError: The model file is not present.
OSError: The file could not be removed.
"""
if model_name not in WHISPERCPP_MODEL_INFO:
raise ValueError(f"Unknown whisper.cpp model: {model_name}")
model_path = get_model_path(model_name)
whispercpp_dir = os.path.join(models_dir(), "whispercpp")
if not is_within_directory(model_path, whispercpp_dir):
raise ValueError("Refusing to delete a path outside the whisper.cpp models directory")
if not os.path.isfile(model_path):
raise FileNotFoundError(model_path)
os.remove(model_path)
logger.info("Deleted whisper.cpp model %s (%s)", model_name, model_path)
return model_path
def get_backend_display_name(backend: str) -> str:
"""
Get a user-friendly display name for a compute backend.
+114
View File
@@ -0,0 +1,114 @@
"""Tests for deleting downloaded speech models from disk."""
import os
from unittest.mock import patch
import pytest
from vocalinux.utils.vosk_model_info import (
delete_vosk_model,
list_downloaded_vosk_models,
vosk_model_dirname,
)
from vocalinux.utils.whispercpp_model_info import delete_model, list_downloaded_models
def test_whispercpp_list_and_delete(tmp_path):
with patch("vocalinux.utils.whispercpp_model_info.models_dir", return_value=str(tmp_path)):
assert list_downloaded_models() == []
from vocalinux.utils.whispercpp_model_info import get_model_path
tiny = get_model_path("tiny")
base = get_model_path("base")
os.makedirs(os.path.dirname(tiny), exist_ok=True)
with open(tiny, "wb") as handle:
handle.write(b"tiny")
with open(base, "wb") as handle:
handle.write(b"base")
downloaded = list_downloaded_models()
assert downloaded == ["tiny", "base"]
deleted = delete_model("tiny")
assert deleted == tiny
assert not os.path.exists(tiny)
assert os.path.exists(base)
assert list_downloaded_models() == ["base"]
def test_whispercpp_delete_unknown_and_missing(tmp_path):
with patch("vocalinux.utils.whispercpp_model_info.models_dir", return_value=str(tmp_path)):
with pytest.raises(ValueError, match="Unknown whisper.cpp model"):
delete_model("not-a-real-model")
with pytest.raises(FileNotFoundError):
delete_model("tiny")
def test_vosk_dirname_and_unique_list(tmp_path):
assert vosk_model_dirname("small", "en-us") == "vosk-model-small-en-us-0.15"
assert vosk_model_dirname("medium", "en-us") == "vosk-model-en-us-0.22"
# Korean medium reuses the small folder.
assert vosk_model_dirname("small", "ko") == vosk_model_dirname("medium", "ko")
small_en = tmp_path / "vosk-model-small-en-us-0.15"
small_en.mkdir()
(small_en / "am").write_text("x")
korean = tmp_path / "vosk-model-small-ko-0.22"
korean.mkdir()
with patch("vocalinux.utils.vosk_model_info.models_dir", return_value=str(tmp_path)):
found = list_downloaded_vosk_models()
dirnames = [item.dirname for item in found]
assert dirnames == [
"vosk-model-small-en-us-0.15",
"vosk-model-small-ko-0.22",
]
assert dirnames.count("vosk-model-small-ko-0.22") == 1
deleted = delete_vosk_model("vosk-model-small-en-us-0.15")
assert deleted == str(small_en)
assert not small_en.exists()
assert korean.exists()
assert [item.dirname for item in list_downloaded_vosk_models()] == [
"vosk-model-small-ko-0.22"
]
def test_vosk_delete_rejects_unknown_and_missing(tmp_path):
with patch("vocalinux.utils.vosk_model_info.models_dir", return_value=str(tmp_path)):
with pytest.raises(ValueError, match="Unknown VOSK model"):
delete_vosk_model("../etc")
with pytest.raises(ValueError, match="Unknown VOSK model"):
delete_vosk_model("not-a-vosk-model")
with pytest.raises(FileNotFoundError):
delete_vosk_model("vosk-model-small-en-us-0.15")
def test_whisper_list_and_delete(tmp_path):
cache = tmp_path / "whisper"
cache.mkdir()
tiny = cache / "tiny.pt"
tiny.write_bytes(b"weights")
missing_default = tmp_path / "missing-whisper-cache"
from vocalinux.ui.settings_dialog import (
_delete_whisper_model,
_list_downloaded_whisper_models,
)
with (
patch(
"vocalinux.ui.settings_dialog._get_whisper_cache_dir",
return_value=str(cache),
),
patch(
"vocalinux.ui.settings_dialog.os.path.expanduser",
return_value=str(missing_default),
),
):
assert _list_downloaded_whisper_models() == ["tiny"]
deleted = _delete_whisper_model("tiny")
assert deleted == [str(tiny)]
assert not tiny.exists()
assert _list_downloaded_whisper_models() == []
+13
View File
@@ -36,3 +36,16 @@ class TestXdgHelpers:
flatpak_data = "/home/user/.var/app/com.vocalinux.Vocalinux/data"
with patch.dict(os.environ, {"XDG_DATA_HOME": flatpak_data}, clear=True):
assert paths.models_dir() == os.path.join(flatpak_data, "vocalinux", "models")
def test_is_within_directory(self, tmp_path):
nested = tmp_path / "models" / "ggml-tiny.bin"
nested.parent.mkdir()
nested.write_bytes(b"x")
assert paths.is_within_directory(str(nested), str(tmp_path / "models"))
assert not paths.is_within_directory(str(tmp_path / "models"), str(tmp_path / "models"))
assert paths.is_within_directory(
str(tmp_path / "models"), str(tmp_path / "models"), allow_root=True
)
assert not paths.is_within_directory(str(tmp_path / "other"), str(tmp_path / "models"))
assert not paths.is_within_directory("/etc/passwd", str(tmp_path / "models"))
+28
View File
@@ -712,6 +712,34 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase):
)
self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium")
def test_whisper_delete_unknown_model(self):
"""Test deleting an unknown Whisper model raises ValueError."""
from vocalinux.ui.settings_dialog import _delete_whisper_model
with self.assertRaises(ValueError):
_delete_whisper_model("not-a-whisper-model")
def test_unused_downloads_group_exists_in_source(self):
"""Test that unused downloads live in their own group, not Speech Engine."""
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as handle:
source_code = handle.read()
self.assertIn('label="Unused downloads"', source_code)
self.assertIn("self.unused_expander", source_code)
self.assertIn("self._on_unused_download_delete_clicked", source_code)
self.assertNotIn('title="Remove Model"', source_code)
self.assertIn("self._refresh_unused_downloads()", source_code)
class TestLanguageComboSearch(unittest.TestCase):
"""Searchable language ComboBox matching and commit helpers."""