diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index cbef910..87cfc69 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -77,6 +77,11 @@ DEFAULT_CONFIG = { # the variant is then derived from the selected language, because a bare size # name ("medium") is indistinguishable from the multilingual variant id. "whisper_cpp_model_variant": "", + # The second language offered once "I also dictate in other languages" is + # on. Empty means the main language is pinned on its own (#779). + "simple_second_language": "", + # Whether the Advanced island under the simple questions is left open. + "show_advanced": False, "parakeet_model_size": "v3-european", # Parakeet TDT 0.6B v3 (25 European languages) "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) "silence_timeout": 2.0, # Seconds of silence before stopping diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 3fa85c2..6bf7a7a 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -35,6 +35,13 @@ from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 from ..speech_recognition.silero_vad import is_silero_available # noqa: E402 from ..utils import parakeet_model_info as parakeet # noqa: E402 +from ..utils.model_choice import ( + BALANCED, + PRIORITIES, + PRIORITY_LABELS, + priority_for_size, + size_for_priority, +) from ..utils.paths import models_dir # noqa: E402 from ..utils.update_checker import ( # noqa: E402 DEFAULT_UPDATE_CHANNEL, @@ -249,6 +256,25 @@ def _language_is_english(language_id: str) -> bool: return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en" +def _decode_simple_languages(primary: str, wants_second: bool, secondary: Optional[str]) -> str: + """Resolve the two simple language answers into what the engine accepts. + + whisper takes one language or none, so naming a second language has to mean + automatic detection. The one exception is two English entries - en-US plus + en-IN, say - where English can still be pinned and the English-only weights, + which are the same size and better at English, stay available. + """ + if not wants_second: + return primary + if not secondary or secondary == primary: + return primary + if secondary == "auto": + return "auto" + if _language_is_english(primary) and _language_is_english(secondary): + return primary + return "auto" + + def _vosk_display_is_coerced_fallback( previous_engine: Optional[str], displayed: Optional[str], @@ -1931,6 +1957,18 @@ class SettingsDialog(Gtk.Dialog): self._test_result = "" self._initializing = True # Flag to prevent auto-apply during initialization self._populating_models = False # Flag to prevent model change handler during population + # Guards the simple-mode widgets while they are being pointed at the + # live configuration, so syncing them does not look like a user edit. + self._simple_syncing = False + # Set while simple mode steers the advanced controls, so their own change + # handlers do not each trigger a separate engine reload. + self._simple_driving = False + self.advanced_box = None + self.advanced_island = None + self.advanced_expander = None + self.simple_page = None + self.simple_group = None + self.engine_group = None self._processing_language_change = ( False # Flag to prevent recursive language change handling ) @@ -2051,6 +2089,7 @@ class SettingsDialog(Gtk.Dialog): # Build UI sections into their topic pages self._build_shortcuts_section() self._build_recognition_section() + self._build_simple_model_section() self._build_engine_section() self._build_remote_server_section() self._build_audio_section() @@ -2085,6 +2124,13 @@ class SettingsDialog(Gtk.Dialog): else: self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0)) + # Restore the saved mode and point the simple questions at the live model + # before the first visibility pass, so nothing flashes the wrong group. + self._sync_simple_from_advanced() + self.advanced_expander.set_expanded( + bool(self.config_manager.get("speech_recognition", "show_advanced", False)) + ) + # Then update visibility of engine-specific elements self._update_engine_specific_ui() @@ -2937,9 +2983,134 @@ class SettingsDialog(Gtk.Dialog): self._tone_preview_kind = "stop" if kind == "start" else "start" self._sync_tone_preview_button(tone_id) + def _build_simple_model_section(self): + """Build the simple questions and the Advanced reveal (#779).""" + self.simple_group = PreferencesGroup(title="What you dictate") + + # Searchable, like the advanced row: over thirty languages is too many to + # scroll, and a list you cannot type into is a step backwards. + self.simple_language_combo = SearchablePicker() + _style_combo(self.simple_language_combo) + _prevent_scroll_on_hover(self.simple_language_combo) + for language_id, info in SUPPORTED_LANGUAGES.items(): + # Auto-detect is the switch below, not a language you speak. + if language_id != "auto": + self.simple_language_combo.append(language_id, info["name"]) + _attach_language_combo_search(self.simple_language_combo) + simple_language_entry = self.simple_language_combo.get_child() + if simple_language_entry is not None: + simple_language_entry.connect("activate", self._on_simple_language_entry_activate) + simple_language_entry.connect( + "focus-out-event", self._on_simple_language_entry_focus_out + ) + self.simple_language_row = PreferenceRow( + title="Main language", + subtitle="Type to search, or pick from the list", + widget=self.simple_language_combo, + keywords=("language", "speak"), + ) + self.simple_group.add_row(self.simple_language_row) + + # The engine takes one language or none, so this is the only other option + # that exists. A second language field would promise something it cannot do. + self.simple_multi_switch = Gtk.Switch() + self.simple_multi_switch.set_valign(Gtk.Align.CENTER) + self.simple_multi_row = PreferenceRow( + title="I also dictate whole texts in other languages", + subtitle="Detects the language per utterance; can be wrong on short ones", + widget=self.simple_multi_switch, + keywords=("multilingual", "auto", "detect"), + ) + self.simple_group.add_row(self.simple_multi_row) + + self.simple_second_language_combo = SearchablePicker() + _style_combo(self.simple_second_language_combo) + _prevent_scroll_on_hover(self.simple_second_language_combo) + # First entry is the multilingual answer: any language, detected per + # utterance. Naming one specific second language means the same thing + # to the engine, but lets the user say which one they had in mind. + self.simple_second_language_combo.append("auto", "Any language (auto-detect)") + for language_id, info in SUPPORTED_LANGUAGES.items(): + if language_id != "auto": + self.simple_second_language_combo.append(language_id, info["name"]) + _attach_language_combo_search(self.simple_second_language_combo) + # SearchablePicker already emits "changed" on Enter/row pick; do not also + # hook activate or a single choice would apply twice. + self.simple_second_language_row = PreferenceRow( + title="Other language", + # Honest about what the engine does: whisper takes one language or + # none, so any second language means detection per utterance. + subtitle="Recognition detects the language of each utterance", + widget=self.simple_second_language_combo, + keywords=("second", "language", "other"), + ) + self.simple_second_language_row.set_no_show_all(True) + self.simple_group.add_row(self.simple_second_language_row) + + self.simple_priority_combo = Gtk.ComboBoxText() + _style_combo(self.simple_priority_combo) + _prevent_scroll_on_hover(self.simple_priority_combo) + for priority in PRIORITIES: + self.simple_priority_combo.append(priority, PRIORITY_LABELS[priority]) + self.simple_priority_row = PreferenceRow( + title="Priority", + subtitle="Balanced follows what your hardware can run", + widget=self.simple_priority_combo, + keywords=("speed", "accuracy", "priority"), + ) + self.simple_group.add_row(self.simple_priority_row) + + # The simple card and, under it, the info card the engine section adds. + self.simple_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.simple_page.pack_start(self.simple_group, False, False, 0) + self.content_box.pack_start(self.simple_page, False, False, 0) + + # Advanced is its own island under the simple card: collapsed to a + # header by default, expanding in place. Not a second window — that went + # wrong twice on KWin/Wayland: transient for the dialog it crashed the + # compositor (findModal recursion, tag kwin-crash-repro); standing alone + # it could not be raised above the dialog at all. Both cards visible at + # once also makes the expanded rows a readout of what the simple answers + # resolved to. + self.advanced_island = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.advanced_island.get_style_context().add_class("preferences-group") + + self.advanced_expander = Gtk.Expander() + header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title = Gtk.Label(label="Advanced", xalign=0) + title.get_style_context().add_class("preferences-group-title") + subtitle = Gtk.Label( + label="Engine, model size, specialization, downloads and the remote server", + xalign=0, + wrap=True, + ) + subtitle.get_style_context().add_class("preference-row-subtitle") + header.pack_start(title, False, False, 0) + header.pack_start(subtitle, False, False, 0) + self.advanced_expander.set_label_widget(header) + self.advanced_expander.set_margin_top(12) + self.advanced_expander.set_margin_bottom(12) + self.advanced_expander.set_margin_start(16) + self.advanced_expander.set_margin_end(16) + + # Everything the detailed view holds is packed in here; the sections + # built after this one append to it. + self.advanced_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.advanced_box.set_margin_top(8) + self.advanced_expander.add(self.advanced_box) + self.advanced_island.pack_start(self.advanced_expander, False, False, 0) + self.content_box.pack_start(self.advanced_island, False, False, 0) + + self.advanced_expander.connect("notify::expanded", self._on_advanced_expanded) + self.simple_language_combo.connect("changed", self._on_simple_choice_changed) + self.simple_second_language_combo.connect("changed", self._on_simple_choice_changed) + self.simple_multi_switch.connect("notify::active", self._on_simple_choice_changed) + self.simple_priority_combo.connect("changed", self._on_simple_choice_changed) + def _build_engine_section(self): """Build the Speech Engine section.""" group = PreferencesGroup(title="Speech Engine") + self.engine_group = group # Engine selection self.engine_combo = Gtk.ComboBoxText() @@ -2996,7 +3167,8 @@ class SettingsDialog(Gtk.Dialog): self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP) group.add_row(self.language_row) - self.content_box.pack_start(group, False, False, 0) + # Lives inside the revealer built above, so the Advanced switch slides it out. + self.advanced_box.pack_start(group, False, False, 0) # Model info card (shown below the group) self.model_info_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) @@ -3036,7 +3208,9 @@ class SettingsDialog(Gtk.Dialog): self.language_warning.set_no_show_all(True) self.model_info_card.pack_start(self.language_warning, False, False, 0) - self.content_box.pack_start(self.model_info_card, False, False, 0) + # On the page, under the simple questions: it is the only feedback that a + # priority or language change did anything, and what it will cost. + self.simple_page.pack_start(self.model_info_card, False, False, 0) self.unused_models_group = PreferencesGroup( keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"), @@ -3075,7 +3249,7 @@ class SettingsDialog(Gtk.Dialog): "notify::expanded", lambda *_args: self._fit_unused_downloads_height() ) self.unused_models_group.pack_start(self.unused_expander, False, False, 0) - self.content_box.pack_start(self.unused_models_group, False, False, 0) + self.advanced_box.pack_start(self.unused_models_group, False, False, 0) # Connect signals self.engine_combo.connect("changed", self._on_engine_changed) @@ -4636,14 +4810,14 @@ class SettingsDialog(Gtk.Dialog): ) self.remote_server_group.add_row(remote_test_row) - self.content_box.pack_start(self.remote_server_group, False, False, 0) + self.advanced_box.pack_start(self.remote_server_group, False, False, 0) # Status label below the group self.remote_status_label = Gtk.Label(label="", use_markup=True, xalign=0) self.remote_status_label.set_margin_start(16) self.remote_status_label.set_margin_top(4) self.remote_status_label.get_style_context().add_class("status-info") - self.content_box.pack_start(self.remote_status_label, False, False, 0) + self.advanced_box.pack_start(self.remote_status_label, False, False, 0) # Load saved values into the widgets saved_url = self.config_manager.get("speech_recognition", "remote_api_url", "") @@ -5730,16 +5904,19 @@ class SettingsDialog(Gtk.Dialog): self._last_non_parakeet_language = lang_code self._populate_model_options() self._update_language_warning() + # Explicit refresh: Mock tests assert this call; _update_model_info + # also refreshes on a real dialog. + self._refresh_simple_readout() self._update_model_info() self._auto_apply_settings() finally: self._processing_language_change = False - def _on_language_entry_activate(self, entry): + def _on_language_entry_activate(self, entry: Any) -> None: """Commit a unique typed match when Enter is pressed in the language entry.""" self._commit_or_restore_language_entry() - def _on_language_entry_focus_out(self, entry, event): + def _on_language_entry_focus_out(self, entry: Any, event: Any) -> bool: """Commit or restore the language after the entry loses focus.""" # Defer so a completion click can set the active id before we restore. GLib.idle_add(self._commit_or_restore_language_entry) @@ -5765,6 +5942,232 @@ class SettingsDialog(Gtk.Dialog): self._processing_language_change = False return False + def _sync_simple_from_advanced(self) -> None: + """Point the simple questions at the configuration that is actually live. + + Switching modes must not change the model on its own, so the priority is + read back from the size already chosen rather than reset to a default. + + A leftover simple multi answer is restored only when it still decodes + to the live Advanced language. Otherwise the next simple edit would + silently replace it: a named second language over a pin becomes auto, + and two English answers over Advanced auto pin English. Two English + answers still pin English, so that configuration is kept on an English + pin and dropped when Advanced is auto. + """ + language = self.language_combo.get_active_id() or self.language or "auto" + is_auto = language == "auto" + + stored_second = self.config_manager.get("speech_recognition", "simple_second_language", "") + + self._simple_syncing = True + try: + if is_auto: + self.simple_multi_switch.set_active(True) + primary = self.simple_language_combo.get_active_id() + if not primary: + self.simple_language_combo.set_active_id("en-us") + primary = "en-us" + else: + self.simple_language_combo.set_active_id(language) + primary = language + + keeps_live = bool(stored_second) and ( + _decode_simple_languages(primary, True, stored_second) == language + ) + if keeps_live: + if not is_auto: + self.simple_multi_switch.set_active(True) + self.simple_second_language_combo.set_active_id(stored_second) + else: + if not is_auto: + self.simple_multi_switch.set_active(False) + if stored_second: + self.config_manager.set("speech_recognition", "simple_second_language", "") + # Reset so turning the switch on later starts from "any", not a + # stale pick that Advanced already superseded. + self.simple_second_language_combo.set_active_id("auto") + + recommended, _ = self._get_recommended_whispercpp_model_for_language() + current = self._get_selected_whispercpp_model() + priority = priority_for_size( + get_whispercpp_model_size(recommended), get_whispercpp_model_size(current) + ) + self.simple_priority_combo.set_active_id(priority) + finally: + self._simple_syncing = False + self._update_simple_visibility() + + def _simple_decoding_language(self) -> str: + """Resolve the two simple language answers into what the engine accepts.""" + primary = self.simple_language_combo.get_active_id() or "en-us" + return _decode_simple_languages( + primary, + self.simple_multi_switch.get_active(), + self.simple_second_language_combo.get_active_id(), + ) + + def _on_disk_stand_in(self, variant: str, size: str, language: str) -> str: + """Prefer a downloaded weight of the same size over fetching a sibling. + + Flipping the "other languages" switch swapped base for base.en, or back: + same size, different weights, and a modal download each way, blocking the + window for it. A same-size weight already on disk that can serve the + language stands in instead; the info card still offers the better variant + as a download, it just no longer forces it. + """ + if is_whispercpp_model_downloaded(variant): + return variant + wants_english = _language_is_english(language) + candidates = [ + name + for name in get_whispercpp_model_variants(size) + if is_whispercpp_model_downloaded(name) + and (wants_english or not is_english_only_whispercpp_model(name)) + ] + if not candidates: + return variant + + def rank(name: str) -> tuple: + # Closest to what was derived: English-only first when English is + # wanted, the plain multilingual next, quantized ones last. + english_first = 0 if wants_english and is_english_only_whispercpp_model(name) else 1 + quantized = 1 if "-q" in name else 0 + return (english_first, quantized, name) + + return min(candidates, key=rank) + + def _apply_simple_choice(self) -> None: + """Drive the advanced controls from the simple questions. + + Simple mode deliberately steers the existing widgets instead of writing the + configuration itself, so applying, downloading and the info card keep going + through exactly one code path. + """ + language = self._simple_decoding_language() + priority = self.simple_priority_combo.get_active_id() or BALANCED + + self.engine_combo.set_active_id("whisper_cpp") + self._set_combo_active_id_or_first(self.language_combo, language) + self.language = language + + recommended, _ = self._get_recommended_whispercpp_model_for_language() + size = size_for_priority(get_whispercpp_model_size(recommended), priority) + variant = _default_whispercpp_variant_for_size(size, language) or size + variant = self._on_disk_stand_in(variant, size, language) + + self.model_combo.set_active_id(size) + self._populate_whispercpp_variant_options(size, variant) + self.model_variant_combo.set_active_id(variant) + + def _on_advanced_expanded(self, expander, _param) -> None: + """Expand or collapse the advanced island, remembering the choice.""" + expanded = expander.get_expanded() + if expanded: + self.advanced_box.show_all() + # Per-engine visibility has to run after show_all, which would + # otherwise reveal rows the active engine does not use. + self._update_engine_specific_ui() + else: + self._sync_simple_from_advanced() + if not self._initializing: + self.config_manager.set("speech_recognition", "show_advanced", expanded) + self.config_manager.save_settings() + + def _refresh_simple_readout(self) -> None: + """Keep the simple answers describing the live model. + + With both cards on screen, a change made in the advanced rows has to show + in the simple ones too, or the two would contradict each other. Skipped + while simple mode is itself steering the advanced rows, and during init. + """ + if self._initializing or self._simple_driving or self._simple_syncing: + return + self._sync_simple_from_advanced() + + def _on_simple_choice_changed(self, *_args) -> None: + """React to one of the simple questions changing. + + Driving the four advanced controls emits "changed" on each of them, and + every one of those handlers ends in _auto_apply_settings, so a single pick + used to reconfigure the engine up to four times and freeze the window while + each reload ran. Suppress those while steering, then apply exactly once. + """ + if self._initializing or self._simple_syncing or self._applying_settings: + return + if self._simple_driving: + return + + # Turning the switch on with nothing picked yet means "any language"; + # otherwise the switch alone would visibly do nothing. + if ( + self.simple_multi_switch.get_active() + and not self.simple_second_language_combo.get_active_id() + ): + self._simple_syncing = True + try: + self.simple_second_language_combo.set_active_id("auto") + finally: + self._simple_syncing = False + + self._simple_driving = True + try: + self._apply_simple_choice() + finally: + self._simple_driving = False + + second = ( + self.simple_second_language_combo.get_active_id() + if self.simple_multi_switch.get_active() + else "" + ) + self.config_manager.set("speech_recognition", "simple_second_language", second or "") + self._update_simple_visibility() + self._auto_apply_settings() + + def _commit_or_restore_simple_language_entry(self) -> bool: + """Resolve text typed into the simple language box, or restore the last pick.""" + if self._initializing or self._simple_syncing: + return False + if self.simple_language_combo.get_active_id(): + return False + + entry = self.simple_language_combo.get_child() + typed = entry.get_text() if entry is not None else "" + match_id = _resolve_combo_text_query(typed, _combo_text_rows(self.simple_language_combo)) + if match_id: + self.simple_language_combo.set_active_id(match_id) + return False + + # Restoring the same language must not re-apply settings. + self._simple_syncing = True + try: + fallback = self.language if self.language != "auto" else "en-us" + self._set_combo_active_id_or_first(self.simple_language_combo, fallback) + finally: + self._simple_syncing = False + return False + + def _on_simple_language_entry_activate(self, _entry: Any) -> None: + self._commit_or_restore_simple_language_entry() + + def _on_simple_language_entry_focus_out(self, _entry: Any, _event: Any) -> bool: + return self._commit_or_restore_simple_language_entry() + + def _update_simple_visibility(self) -> None: + """Show the simple questions, with the second language only when asked for.""" + self.simple_group.show_all() + wants_second = self.simple_multi_switch.get_active() + # show_all() is a no-op on a widget flagged no_show_all, so the flag has + # to be cleared before showing and restored after hiding — the same + # dance _set_custom_shortcut_row_visible does. + if wants_second: + self.simple_second_language_row.set_no_show_all(False) + self.simple_second_language_row.show_all() + else: + self.simple_second_language_row.hide() + self.simple_second_language_row.set_no_show_all(True) + def _update_engine_specific_ui(self): """Show/hide UI elements driven by the active engine.""" engine_text = self.engine_combo.get_active_text() @@ -5787,6 +6190,8 @@ class SettingsDialog(Gtk.Dialog): self.remote_server_group.hide() self.remote_status_label.hide() + self._update_simple_visibility() + self._update_model_info() self._refresh_unused_downloads() self._update_language_warning() @@ -5825,6 +6230,7 @@ class SettingsDialog(Gtk.Dialog): def _update_model_info(self): """Update the model info card display.""" + self._refresh_simple_readout() engine_text = self.engine_combo.get_active_text() if not engine_text: self.model_info_card.hide() @@ -5935,6 +6341,11 @@ class SettingsDialog(Gtk.Dialog): if self._populating_models: return + # Simple mode is mid-way through steering the advanced controls; it applies + # once itself when it is done. + if self._simple_driving: + return + self._applying_settings = True # Already-downloaded apply is handed to a worker that clears this flag # via GLib.idle_add. Download still holds it for the modal run() below. diff --git a/src/vocalinux/utils/model_choice.py b/src/vocalinux/utils/model_choice.py new file mode 100644 index 0000000..324ff6c --- /dev/null +++ b/src/vocalinux/utils/model_choice.py @@ -0,0 +1,58 @@ +"""Turn what the user actually knows into a model choice (#779). + +The Speech Model panel asks four questions, but only two of them are answerable +without knowing the hardware: what language you speak, and whether you want it +faster or more accurate. Size follows from the machine, and the specialization +follows from the language. These helpers do the size half; the language half is +the variant derivation that already lives in the settings dialog. + +Priority is expressed relative to what the hardware detection recommends rather +than as an absolute size, so "most accurate" means something different on a +laptop and on a workstation, which is the point. +""" + +from .whispercpp_model_info import MODEL_SIZES + +FASTEST = "fastest" +BALANCED = "balanced" +ACCURATE = "accurate" + +PRIORITIES = (FASTEST, BALANCED, ACCURATE) + +PRIORITY_LABELS = { + FASTEST: "Fastest", + BALANCED: "Balanced", + ACCURATE: "Most accurate", +} + +# How far to move from the hardware recommendation, in size steps. +_PRIORITY_OFFSET = {FASTEST: -1, BALANCED: 0, ACCURATE: 1} + + +def _clamp(index: int) -> int: + return max(0, min(len(MODEL_SIZES) - 1, index)) + + +def size_for_priority(recommended_size: str, priority: str) -> str: + """Return the size a priority asks for, relative to the recommendation.""" + if recommended_size not in MODEL_SIZES: + recommended_size = MODEL_SIZES[0] + offset = _PRIORITY_OFFSET.get(priority, 0) + return MODEL_SIZES[_clamp(MODEL_SIZES.index(recommended_size) + offset)] + + +def priority_for_size(recommended_size: str, size: str) -> str: + """Return the priority that best describes an already-chosen size. + + Used to open simple mode showing the truth about the current configuration + instead of resetting it, so switching modes never silently changes the model. + """ + if recommended_size not in MODEL_SIZES or size not in MODEL_SIZES: + return BALANCED + + delta = MODEL_SIZES.index(size) - MODEL_SIZES.index(recommended_size) + if delta < 0: + return FASTEST + if delta > 0: + return ACCURATE + return BALANCED diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index d67d3c6..6f36850 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -528,8 +528,12 @@ class TestSettingsDialogInstantApply(unittest.TestCase): with open(source_path, "r") as f: source_code = f.read() - self.assertIn("self.content_box.pack_start(self.remote_server_group", source_code) - self.assertIn("self.content_box.pack_start(self.remote_status_label", source_code) + # Reached from the Speech Model page: the detailed controls now live in the + # window the Advanced button opens, next to the engine picker that selects + # remote_api. The point of this test is that they are not exiled to the + # separate Advanced page, which the advanced_tab assertions below still guard. + self.assertIn("self.advanced_box.pack_start(self.remote_server_group", source_code) + self.assertIn("self.advanced_box.pack_start(self.remote_status_label", source_code) self.assertIn("self.remote_api_model_entry", source_code) self.assertIn("OpenAI/FunASR", source_code) self.assertNotIn("advanced_tab.pack_start(self.remote_server_group", source_code) diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index b26e172..b5f12f0 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -61,6 +61,9 @@ def _dialog_stub() -> Mock: dialog._test_active = False dialog._populating_models = False dialog._processing_language_change = False + # A Mock attribute is truthy, which would make _auto_apply_settings think + # simple mode is mid-way through steering the controls (#779). + dialog._simple_driving = False dialog.language = "en-us" dialog._last_non_parakeet_language = None dialog._engine_for_language_memory = None diff --git a/tests/test_simple_model_settings.py b/tests/test_simple_model_settings.py new file mode 100644 index 0000000..258fa2a --- /dev/null +++ b/tests/test_simple_model_settings.py @@ -0,0 +1,695 @@ +"""Simple mode asks what the user knows and derives the rest (#779).""" + +import importlib +import sys +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import vocalinux.ui +from vocalinux.utils.model_choice import ( + ACCURATE, + BALANCED, + FASTEST, + priority_for_size, + size_for_priority, +) + + +@pytest.fixture(scope="module") +def settings_dialog(): + """Import settings_dialog with real base classes for its GTK subclasses. + + Same reasoning as tests/test_settings_model_state.py; both sys.modules and the + package attribute are restored so later tests patching the module by name do + not reach a second module object (#686). + """ + 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 + + +@pytest.fixture +def dialog_class(settings_dialog): + return settings_dialog.SettingsDialog + + +def _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="small", second=None): + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog._applying_settings = False + # A Mock attribute is truthy, which would trip the steering guard. + dialog._simple_driving = False + dialog.language = language + dialog.simple_language_combo.get_active_id.return_value = language + dialog.simple_multi_switch.get_active.return_value = multi + 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") + # 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) + ) + # Disk lookups are covered separately; here the derived variant stands. + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + return dialog + + +def _sd(): + import vocalinux.ui.settings_dialog as module + + return module + + +# --- the size half of the derivation ------------------------------------- + + +@pytest.mark.parametrize( + "recommended,priority,expected", + [ + ("small", FASTEST, "base"), + ("small", BALANCED, "small"), + ("small", ACCURATE, "medium"), + ("tiny", FASTEST, "tiny"), # clamped at the bottom + ("large", ACCURATE, "large"), # clamped at the top + ], +) +def test_priority_moves_relative_to_the_hardware_recommendation(recommended, priority, expected): + assert size_for_priority(recommended, priority) == expected + + +def test_priority_reads_back_from_an_already_chosen_size(): + """Opening simple mode must describe the current model, not reset it.""" + assert priority_for_size("small", "base") == FASTEST + assert priority_for_size("small", "small") == BALANCED + assert priority_for_size("small", "medium") == ACCURATE + + +# --- simple mode driving the advanced controls --------------------------- + + +def test_a_single_language_pins_it_and_picks_the_matching_variant(settings_dialog, dialog_class): + dialog = _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="small") + + 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") + # Polish has no English-only weights, so the multilingual variant is correct. + dialog.model_variant_combo.set_active_id.assert_called_once_with("small") + + +def test_english_gets_the_english_only_variant(settings_dialog, dialog_class): + """Same size, better recognition, so simple mode must not leave it on multilingual.""" + dialog = _dialog_stub(language="en-us", multi=False, priority=BALANCED, recommended="small") + + dialog_class._apply_simple_choice(dialog) + + dialog.model_variant_combo.set_active_id.assert_called_once_with("small.en") + + +def test_naming_a_second_language_turns_on_auto_detect(settings_dialog, dialog_class): + """Two languages cannot be pinned, so the engine gets detection instead.""" + dialog = _dialog_stub(language="pl", multi=True, second="en-us", recommended="small") + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "auto") + assert dialog.language == "auto" + # Detection cannot use English-only weights. + dialog.model_variant_combo.set_active_id.assert_called_once_with("small") + + +def test_most_accurate_moves_a_size_up(settings_dialog, dialog_class): + dialog = _dialog_stub(language="en-us", multi=False, priority=ACCURATE, recommended="small") + + dialog_class._apply_simple_choice(dialog) + + dialog.model_combo.set_active_id.assert_called_once_with("medium") + dialog.model_variant_combo.set_active_id.assert_called_once_with("medium.en") + + +def test_most_accurate_on_large_falls_back_to_multilingual_for_english( + settings_dialog, dialog_class +): + """large ships no English-only weights, so the variant must stay real.""" + dialog = _dialog_stub(language="en-us", multi=False, priority=ACCURATE, recommended="large") + + dialog_class._apply_simple_choice(dialog) + + dialog.model_combo.set_active_id.assert_called_once_with("large") + chosen = dialog.model_variant_combo.set_active_id.call_args[0][0] + assert chosen in settings_dialog.WHISPERCPP_MODEL_INFO + assert not settings_dialog.is_english_only_whispercpp_model(chosen) + + +# --- guards --------------------------------------------------------------- + + +def test_syncing_the_widgets_does_not_count_as_a_user_edit(settings_dialog, dialog_class): + """Otherwise opening the page would re-apply and trigger a download.""" + dialog = _dialog_stub() + dialog._simple_syncing = True + + dialog_class._on_simple_choice_changed(dialog) + + dialog._apply_simple_choice.assert_not_called() + dialog._auto_apply_settings.assert_not_called() + + +def test_a_real_edit_applies_and_saves(settings_dialog, dialog_class): + dialog = _dialog_stub() + + dialog_class._on_simple_choice_changed(dialog) + + dialog._apply_simple_choice.assert_called_once() + dialog._auto_apply_settings.assert_called_once() + + +def test_the_second_language_list_appears_only_when_asked_for(settings_dialog, dialog_class): + """show_all() is a no-op on a no_show_all widget: the flag must go first. + + Reported from the installed build — the switch turned on and nothing + appeared, because the row was shown with the flag still set. + """ + dialog = Mock() + dialog.simple_multi_switch.get_active.return_value = True + row = dialog.simple_second_language_row + order = [] + row.set_no_show_all.side_effect = lambda flag: order.append(("no_show_all", flag)) + row.show_all.side_effect = lambda: order.append(("show_all", None)) + + dialog_class._update_simple_visibility(dialog) + + assert order == [("no_show_all", False), ("show_all", None)] + + +def test_the_second_language_list_is_hidden_by_default(settings_dialog, dialog_class): + dialog = Mock() + dialog.simple_multi_switch.get_active.return_value = False + row = dialog.simple_second_language_row + order = [] + row.hide.side_effect = lambda: order.append("hide") + row.set_no_show_all.side_effect = lambda flag: order.append(("no_show_all", flag)) + + dialog_class._update_simple_visibility(dialog) + + assert order == ["hide", ("no_show_all", True)] + + +def test_any_language_as_the_second_answer_means_detection(settings_dialog, dialog_class): + """The explicit multilingual option.""" + dialog = _dialog_stub(language="pl", multi=True, second="auto") + + assert dialog_class._simple_decoding_language(dialog) == "auto" + + +def test_turning_the_switch_on_defaults_the_second_list_to_any_language( + settings_dialog, dialog_class +): + """Otherwise flipping the switch alone would visibly do nothing.""" + dialog = _dialog_stub(language="pl", multi=True, second=None) + + dialog_class._on_simple_choice_changed(dialog) + + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("auto") + dialog._apply_simple_choice.assert_called_once() + + +def test_the_info_card_stays_on_the_page(settings_dialog): + """It is the only feedback that a priority change did anything, and its cost.""" + import inspect + + source = inspect.getsource(settings_dialog.SettingsDialog._build_engine_section) + + assert "self.simple_page.pack_start(self.model_info_card" in source + assert "self.advanced_box.pack_start(self.model_info_card" not in source + + +def test_a_second_language_switches_decoding_to_detection(settings_dialog, dialog_class): + """whisper takes one language or none, so two means automatic detection.""" + dialog = _dialog_stub(language="pl", multi=True, second="en-us") + + assert dialog_class._simple_decoding_language(dialog) == "auto" + + +def test_two_english_entries_still_pin_english(settings_dialog, dialog_class): + """en-US plus en-IN is still English, so the .en weights stay usable.""" + dialog = _dialog_stub(language="en-us", multi=True, second="en-in") + + assert dialog_class._simple_decoding_language(dialog) == "en-us" + + +def test_the_switch_without_a_second_language_keeps_the_main_one(settings_dialog, dialog_class): + dialog = _dialog_stub(language="pl", multi=True, second=None) + + assert dialog_class._simple_decoding_language(dialog) == "pl" + + +def test_no_switch_pins_the_main_language(settings_dialog, dialog_class): + dialog = _dialog_stub(language="pl", multi=False, second="en-us") + + assert dialog_class._simple_decoding_language(dialog) == "pl" + + +def test_expanding_the_island_shows_the_rows_for_the_active_engine(settings_dialog, dialog_class): + """show_all() first, then the per-engine pass, or rows the engine does not + use would be revealed.""" + dialog = Mock() + dialog._initializing = False + expander = Mock() + expander.get_expanded.return_value = True + order = [] + dialog.advanced_box.show_all.side_effect = lambda: order.append("show_all") + dialog._update_engine_specific_ui.side_effect = lambda: order.append("engine_ui") + + dialog_class._on_advanced_expanded(dialog, expander, None) + + assert order == ["show_all", "engine_ui"] + dialog.config_manager.set.assert_called_once_with("speech_recognition", "show_advanced", True) + + +def test_collapsing_the_island_rereads_the_live_model(settings_dialog, dialog_class): + dialog = Mock() + dialog._initializing = False + expander = Mock() + expander.get_expanded.return_value = False + + dialog_class._on_advanced_expanded(dialog, expander, None) + + dialog._sync_simple_from_advanced.assert_called_once() + dialog.config_manager.set.assert_called_once_with("speech_recognition", "show_advanced", False) + + +def test_restoring_the_island_on_open_does_not_write_the_config(settings_dialog, dialog_class): + """Setting the saved state during init must not count as a user choice.""" + dialog = Mock() + dialog._initializing = True + expander = Mock() + expander.get_expanded.return_value = True + + dialog_class._on_advanced_expanded(dialog, expander, None) + + dialog.config_manager.set.assert_not_called() + + +def test_a_change_in_the_advanced_rows_shows_up_in_the_simple_answers( + settings_dialog, dialog_class +): + """Both cards are on screen; they must not contradict each other.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_driving = False + dialog._simple_syncing = False + + dialog_class._refresh_simple_readout(dialog) + + dialog._sync_simple_from_advanced.assert_called_once() + + +@pytest.mark.parametrize("flag", ["_initializing", "_simple_driving", "_simple_syncing"]) +def test_the_readout_stays_quiet_while_simple_mode_is_steering(settings_dialog, dialog_class, flag): + """Re-syncing mid-steer would read half-set controls back into the answers.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_driving = False + dialog._simple_syncing = False + setattr(dialog, flag, True) + + dialog_class._refresh_simple_readout(dialog) + + dialog._sync_simple_from_advanced.assert_not_called() + + +def test_no_second_toplevel_is_created_for_advanced(settings_dialog, dialog_class): + """A second window failed twice on KWin/Wayland: transient for the dialog it + crashed the compositor (tag kwin-crash-repro); standing alone it could not + be raised above the dialog. The island lives in the same window.""" + import inspect + + source = inspect.getsource(dialog_class._build_simple_model_section) + + assert "Gtk.Window(" not in source + assert "set_transient_for" not in source + assert "Gtk.Expander" in source + + +def test_opening_simple_mode_describes_the_current_model_instead_of_resetting_it( + settings_dialog, dialog_class +): + """Switching to simple must not silently change which model is loaded.""" + dialog = Mock() + dialog.language = "en-us" + dialog.language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = "" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small.en", "reason") + dialog._get_selected_whispercpp_model.return_value = "medium.en" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(False) + dialog.simple_language_combo.set_active_id.assert_called_once_with("en-us") + # medium sits one step above the recommended small, which is "most accurate". + dialog.simple_priority_combo.set_active_id.assert_called_once_with(ACCURATE) + assert dialog._simple_syncing is False + + +def test_auto_detect_shows_up_as_the_other_languages_switch(settings_dialog, dialog_class): + dialog = Mock() + dialog.language = "auto" + dialog.language_combo.get_active_id.return_value = "auto" + dialog.simple_language_combo.get_active_id.return_value = None + dialog.config_manager.get.return_value = "" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + + +@pytest.mark.parametrize("stale_second", ["auto", "en-us"]) +def test_pinned_advanced_language_plus_stale_stored_second_does_not_replace_the_pin( + settings_dialog, dialog_class, stale_second +): + """A leftover simple second answer must not override a pinned Advanced language. + + Sync used to re-enable the multi switch from persisted ``simple_second_language``. + The next simple edit then decoded to auto and silently replaced the pin. + """ + dialog = Mock() + dialog.language = "pl" + dialog.language_combo.get_active_id.return_value = "pl" + dialog.config_manager.get.return_value = stale_second + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(False) + dialog.simple_language_combo.set_active_id.assert_called_once_with("pl") + dialog.config_manager.set.assert_called_with("speech_recognition", "simple_second_language", "") + dialog.simple_second_language_combo.set_active_id.assert_called_with("auto") + + # Widgets after sync: multi off, main language pinned. A later simple apply + # must keep the pin rather than silently writing auto. + dialog.simple_multi_switch.get_active.return_value = False + dialog.simple_language_combo.get_active_id.return_value = "pl" + dialog.simple_second_language_combo.get_active_id.return_value = "auto" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "pl") + assert dialog.language == "pl" + + +def test_two_english_second_language_survives_sync_from_a_pin(settings_dialog, dialog_class): + """en-US plus en-IN still pins English; sync must not treat that as leftover auto. + + Dropping the switch and wiping ``simple_second_language`` on every pinned + readout would forget the second English answer the next time settings open. + """ + dialog = Mock() + dialog.language = "en-us" + dialog.language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = "en-in" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small.en", "reason") + dialog._get_selected_whispercpp_model.return_value = "small.en" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + dialog.simple_language_combo.set_active_id.assert_called_once_with("en-us") + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("en-in") + dialog.config_manager.set.assert_not_called() + + dialog.simple_multi_switch.get_active.return_value = True + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.simple_second_language_combo.get_active_id.return_value = "en-in" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "en-us") + assert dialog.language == "en-us" + + +@pytest.mark.parametrize("stale_second", ["en-in", "en-us"]) +def test_two_english_second_language_does_not_override_advanced_auto( + settings_dialog, dialog_class, stale_second +): + """en-US plus another English still pins English, so it cannot sit on auto. + + Restoring that leftover over Advanced auto would make the next simple edit + silently replace auto with en-us. Duplicate en-US is the same pin. + """ + dialog = Mock() + dialog.language = "auto" + dialog.language_combo.get_active_id.return_value = "auto" + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = stale_second + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + dialog.simple_language_combo.set_active_id.assert_not_called() + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("auto") + dialog.config_manager.set.assert_called_with("speech_recognition", "simple_second_language", "") + + dialog.simple_multi_switch.get_active.return_value = True + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.simple_second_language_combo.get_active_id.return_value = "auto" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "auto") + assert dialog.language == "auto" + + +def test_named_second_language_that_still_means_auto_survives_auto_sync( + settings_dialog, dialog_class +): + """English plus Polish is detection; Advanced auto must keep that second pick.""" + dialog = Mock() + dialog.language = "auto" + dialog.language_combo.get_active_id.return_value = "auto" + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = "pl" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("pl") + dialog.config_manager.set.assert_not_called() + + dialog.simple_multi_switch.get_active.return_value = True + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.simple_second_language_combo.get_active_id.return_value = "pl" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "auto") + assert dialog.language == "auto" + + +def test_changing_advanced_language_refreshes_the_simple_readout(settings_dialog, dialog_class): + """Otherwise a pin in Advanced leaves a stale multi switch on screen.""" + dialog = Mock() + dialog._processing_language_change = False + dialog._initializing = False + dialog._applying_settings = False + dialog.language_combo.get_active_id.return_value = "pl" + dialog.engine_combo.get_active_text.return_value = "Local (whisper.cpp)" + + dialog_class._on_language_changed(dialog, None) + + dialog._refresh_simple_readout.assert_called_once() + + +# --- regressions reported from the installed build ----------------------- + + +def test_the_steering_guard_is_held_for_the_whole_pick(settings_dialog, dialog_class): + """Picking a language froze the window: each steered combo re-applied. + + _apply_simple_choice sets engine, language, size and variant, and every one of + those handlers ends in _auto_apply_settings, so one pick reconfigured the engine + up to four times on the UI thread. The guard has to be held for the whole of the + steering and released before the single apply that follows. + """ + dialog = _dialog_stub() + held = [] + dialog._apply_simple_choice.side_effect = lambda: held.append(dialog._simple_driving) + + dialog_class._on_simple_choice_changed(dialog) + + assert held == [True], "guard was not held while the controls were being steered" + assert dialog._simple_driving is False, "guard was not released afterwards" + dialog._auto_apply_settings.assert_called_once() + + +def test_auto_apply_is_suppressed_while_simple_mode_is_steering(settings_dialog, dialog_class): + """The guard itself: nothing runs while the controls are being pointed.""" + dialog = Mock() + dialog._applying_settings = False + dialog._initializing = False + dialog._test_active = False + dialog._populating_models = False + dialog._simple_driving = True + + dialog_class._auto_apply_settings(dialog) + + dialog.get_selected_settings.assert_not_called() + + +def test_the_simple_language_box_accepts_typing(settings_dialog, dialog_class): + """A list of thirty-plus languages you cannot type into is not usable.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog.simple_language_combo.get_active_id.return_value = None + dialog.simple_language_combo.get_child.return_value.get_text.return_value = "Pol" + + with patch.object(settings_dialog, "_combo_text_rows", return_value=[("pl", "Polish")]): + dialog_class._commit_or_restore_simple_language_entry(dialog) + + dialog.simple_language_combo.set_active_id.assert_called_once_with("pl") + + +def test_unresolvable_typing_restores_the_previous_language(settings_dialog, dialog_class): + """Typing nonsense must not leave the box in a state the config never had.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog.language = "pl" + dialog.simple_language_combo.get_active_id.return_value = None + dialog.simple_language_combo.get_child.return_value.get_text.return_value = "zzzz" + + with patch.object(settings_dialog, "_combo_text_rows", return_value=[("pl", "Polish")]): + dialog_class._commit_or_restore_simple_language_entry(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.simple_language_combo, "pl") + + +def test_restoring_after_auto_detect_falls_back_to_a_real_language(settings_dialog, dialog_class): + """ "auto" is the switch, not an entry in this list, so it cannot be restored.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog.language = "auto" + dialog.simple_language_combo.get_active_id.return_value = None + dialog.simple_language_combo.get_child.return_value.get_text.return_value = "" + + with patch.object(settings_dialog, "_combo_text_rows", return_value=[("pl", "Polish")]): + dialog_class._commit_or_restore_simple_language_entry(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with( + dialog.simple_language_combo, "en-us" + ) + + +# --- reuse what is on disk instead of downloading a sibling --------------- + + +def _with_disk(settings_dialog, downloaded): + return ( + patch.object( + settings_dialog, + "is_whispercpp_model_downloaded", + side_effect=lambda name: name in downloaded, + ), + patch.object( + settings_dialog, + "get_whispercpp_model_variants", + return_value=["base", "base.en", "base-q5_1", "base.en-q5_1", "base-q8_0"], + ), + ) + + +def test_a_same_size_weight_on_disk_stands_in_for_a_missing_sibling(settings_dialog, dialog_class): + """Reported: switching the other-languages switch off went from base to + base.en and sat through a 141 MB modal download for the same size.""" + on_disk, variants = _with_disk(settings_dialog, ["base"]) + with on_disk, variants: + chosen = dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") + assert chosen == "base" + + +def test_the_derived_variant_is_kept_when_it_is_already_on_disk(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base", "base.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base.en" + + +def test_english_only_weights_never_stand_in_for_another_language(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base", "base", "pl") == "base" + + +def test_english_only_weights_never_stand_in_for_detection(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base", "base", "auto") == "base" + + +def test_nothing_of_that_size_on_disk_means_the_derived_variant_and_a_download( + settings_dialog, dialog_class +): + on_disk, variants = _with_disk(settings_dialog, ["medium.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base.en" + + +def test_a_plain_weight_is_preferred_over_a_quantized_one(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base-q5_1", "base"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base" + + +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" + + dialog_class._apply_simple_choice(dialog) + + dialog._on_disk_stand_in.assert_called_once_with("small.en", "small", "en-us") + dialog.model_variant_combo.set_active_id.assert_called_once_with("small")