* feat(settings): make the language picker searchable while its list is open Entry completion only ever covered typing into the closed entry. Open the dropdown and the keyboard goes to GTK's own first-letter jump, so nothing typed reaches a filter, and with thirty-odd languages scrolling is the only way through. The first attempt at fixing this (a filtered ComboBox model) had the same limitation for the same reason. Replace the combo with a picker that opens a popover holding a search entry, focused on open, above the list. Every keystroke narrows the rows, Enter takes the first match, a click takes that row. It keeps the GtkComboBoxText methods the dialog calls, mirrors the store's column order in base_model for the shared row helpers, and emits "changed" only when the selection actually changes, so re-selecting the saved language on open is not taken for a user edit. Visibility uses the matcher the typed-text resolver already uses, so the list and Enter cannot disagree. The completion helper and the combo styling skip the picker: it filters its own list, and the ComboBox-only calls do not exist on a Box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * feat(settings): add a simple mode that derives the model from the language The Speech Model panel asked four questions, but only two of them can be answered 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, and the app already computed both — it just printed the answer beside the pickers instead of using it. Simple mode asks for the main language, whether whole texts are also dictated in other languages, and a priority. There is deliberately no second language field: whisper takes one language or none, so a pair cannot be passed, and with a language pinned the multilingual weights already handle inserted foreign words. The switch is the "none" case. Priority moves relative to what hardware detection recommends rather than naming an absolute size, so "most accurate" means something different on a laptop than on a workstation. Opening simple mode reads the priority back from the size already configured, so switching modes never changes the loaded model on its own. Simple mode steers the existing controls rather than writing the configuration itself, so applying, downloading and the info card keep going through one path. Advanced mode is the previous panel, unchanged. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): stop simple mode reloading the engine four times per pick Reported against the installed build: choosing a language locked the window for a moment. Simple mode steers the engine, language, size and variant controls, and every one of those change handlers ends in _auto_apply_settings, so a single pick reconfigured the speech engine up to four times on the UI thread, reloading the model each time. Hold a guard across the steering and apply exactly once when it is done. The simple language box was also a plain list with no entry, so it could not be typed into — a step backwards from the advanced row, and unusable with over thirty languages. It now uses the same searchable combo, with the same commit and restore behaviour for text that resolves to nothing. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): make simple the panel and Advanced a reveal The Simple/Advanced dropdown framed the two as alternatives, which put a mode decision in front of the user before any of the questions they can answer. They are not alternatives: simple is the panel, and advanced is more detail about the same choice. The mode selector is gone. The simple questions are always on screen, with an Advanced switch that slides the engine, size and specialization rows out underneath. Because simple mode steers those rows, they also work as a readout of what the simple answers resolved to, which the old either/or hid. The stored settings_mode becomes a show_advanced flag, remembering whether the detail was left open. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): second language list, and Advanced in its own window Reworked to what the panel is actually for. The main language comes from the keyboard layout. Turning on "I also dictate whole texts in other languages" reveals a second list rather than silently flipping to detection, so the choice is visible. Advanced is a button that opens the detailed controls in their own window, instead of an inline reveal that pushed the page around. Naming a second language resolves to automatic detection, because whisper takes one language or none. The one case where both can still be honoured is two English entries — en-US plus en-IN — where English stays pinned and the English-only weights, same size and better at English, remain available. Also fixes a name collision the reveal had: advanced_revealer already belonged to the power-user toggle on the Advanced page, and the section built later won the attribute. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * feat(settings): use the filtering picker for both simple language lists The main language and the second language are the same thirty-odd-row lists as the advanced one, so they get the same dropdown filtering rather than a completion popup that only reacts while the entry has focus. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): make simple mode do what it says on screen Four things reported from the installed build, all real. The second language list never appeared: the row is flagged no_show_all, and show_all() is a no-op on such a widget, so the flag has to be cleared first — the dance _set_custom_shortcut_row_visible already does. There was no multilingual answer. "Any language (auto-detect)" is now the first entry of the second list and the default when the switch is turned on, so the switch alone visibly does something. Changing the priority showed nothing, because the info card — the only surface that says which model was picked and what it costs — had been moved into the advanced window. It is back on the page under the simple questions. Opening Advanced crashed the compositor. KWin 6.7.4 (Wayland) died with unbounded recursion in KWin::Window::findModal(), a cycle in its transient-for chain, when the advanced window — a long-lived toplevel transient for this dialog, hidden on close and shown again with present() — was opened. Coredumps at 2026-09-06 20:20:10 and 20:39:49; the crashing state is kept as tag kwin-crash-repro. The window now stands on its own, is built on every open and destroyed on close with the controls re-parented out first. KDE Bug 483265. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): bring the advanced window up in front of the dialog Reported: it opened behind Settings. Dropping the transient parent (which crashed KWin) also dropped the only reason the compositor had to stack it over the dialog, and on Wayland a toplevel mapped without an activation token lands behind the focused window. Present it with the timestamp of the click that opened it: that asks the compositor for focus and stacking, and needs no transient link. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): Advanced as an island under the simple card, not a window A second toplevel went wrong twice on KWin 6.7 / Wayland. Transient for the dialog it crashed the compositor (unbounded recursion in KWin::Window::findModal, tag kwin-crash-repro, KDE Bug 483265). Standing on its own it could not be raised above the dialog at all, not even by hand. Advanced is now its own card under the simple questions: collapsed to a header by default, expanding in place, remembered in show_advanced. Both cards are on screen at once, so the expanded rows double as a readout of what the simple answers resolved to, and a change made in the advanced rows is read back into the simple ones — skipped while simple mode is itself steering them. The info card sits between the two, where a priority or language change shows its effect and its cost. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): let a same-size weight on disk stand in before downloading Reported: turning the "I also dictate whole texts in other languages" switch off went from base to base.en — same size, different weights — and sat the user through a modal 141 MB download for it, with the window blocked behind the progress dialog. Turning it on had done the same in the other direction. Simple mode now looks at the disk before settling on a variant. A downloaded weight of the same size that can serve the language stands in: English-only first when English is wanted, the plain multilingual next, quantized ones last. English-only weights never stand in for another language or for detection. When nothing of that size is on disk the derived variant stays and the download runs as before. The info card still offers the better variant to download; it just no longer forces it. Refs #779 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): keep Advanced language pin over stale simple multi _sync_simple_from_advanced restored the other-languages switch and persisted simple_second_language even when Advanced had a pinned language. The next simple edit then decoded to auto and silently replaced the pin. Clear/resync simple multi on a pin, refresh that readout when Advanced language changes, and drop the second-list Enter hook that double-applied through SearchablePicker. Refs #779 * fix(settings): keep two-English simple multi across Advanced pin sync Clearing every leftover second language on a pinned Advanced readout was too broad: en-US plus en-IN still pins English, and wiping that answer forgot the second English choice on the next open. Restore multi only when the stored second would keep the pin; otherwise drop it so a later simple edit cannot decode to auto. Refs #779 * fix(settings): restore simple multi only when it matches live language The pin-side keep left the auto branch restoring any stored second. Two English over Advanced auto still decodes to en-us, so the next simple edit silently replaced detection. Keep leftover multi only when it still decodes to the live Advanced language either way. Refs #779 * fix(settings): keep language-change tests honest after the main merge Mock dialogs treated _initializing as truthy and skipped the handler. Also call _refresh_simple_readout explicitly so the simple card updates. * fix(settings): type the language-entry GTK callbacks Greptile flagged missing param and return annotations on the new activate and focus-out handlers. Match the surrounding Any/bool style. Refs #779 * fix(settings): sort imports for isort after Parakeet merge --------- Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
696 lines
28 KiB
Python
696 lines
28 KiB
Python
"""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")
|