Files
voculinux/tests/test_follow_keyboard_layout.py
7bdbedce0b feat(language): follow the active keyboard layout while dictating (#837)
* feat(language): follow the active keyboard layout while dictating

Adds a "Follow keyboard layout" switch to Settings -> Speech Model ->
Advanced. With it on, the active keyboard layout is re-read at the start
of every dictation and the matching language is used, so someone who
switches layouts to work in two languages gets the right one without
opening Settings.

#796 already seeded the language once on first run, but from the
*configured* primary layout, which never changes as the user switches
between them: a "fr,us" setup reports fr forever. detect_active_keyboard_
layout() reads GNOME's input sources instead. It takes mru-sources rather
than the current index, because that index goes stale -- observed still
reading 0 after a switch to the second layout -- which is why the IBus
injection path already trusts the MRU list (#497, #738).

An active source that is an IBus engine rather than an xkb layout is a
real answer that cannot be mapped, so the language is left to the engine's
own auto-detect. Borrowing the configured xkb layout there would pin
English for someone typing Japanese through mozc-jp.

The mode is a switch over the language picker rather than an entry inside
it, since it is a mode and not a language. While on, the picker is
disabled and shows what the layout resolves to; turning it off pins that.

A language change no longer re-initialises the engine when the loaded
model takes the language per utterance, which is the whole whisper
family, so following a layout costs a settings read rather than a model
reload. VOSK loads one model per language, Parakeet ignores the picker,
and an English-only model has no non-English weights, so Settings refuses
the mode for all three rather than offering a switch that does nothing.

Fixes #821

* fix(language): keep follow mode armed and map IBus engines

Swap English-only whisper.cpp weights to the multilingual sibling while
passing the layout sentinel into reconfigure, so follow mode stays armed.
Map common GNOME IBus engines onto catalogue languages instead of
leaving them unmapped.

* fix(language): stop IBus hyphen false-maps and gate the sibling swap

The last-token hyphen heuristic mapped mozc-us and foo-us to English.
Restrict it to *-jp/*-kr and name Mozc's extra engines as Japanese.
Only whisper.cpp may consult the whisper.cpp download catalog.

* fix(language): keep multilingual weights while following layout

The picker shows the active layout while follow is on, often en-us.
Deriving whisper.cpp .en from that display made Settings refuse the
mode as English-only, then the next auto-apply persisted a concrete
language and disarmed follow. Use LANGUAGE_FOLLOWS_LAYOUT for .en vs
multilingual derivation instead, including on the load path before
the switch is synced.

* fix(language): type follow helpers and log sibling-swap failures

* fix(test): stub follow-layout model rec so mocked psutil does not blow up

follow-mode English-only guard calls get_recommended_whispercpp_model, which
reads RAM through psutil. Full CI leaves a MagicMock in sys.modules["psutil"],
so ram_gb >= 16 raises TypeError. Patch the settings_dialog alias to return a
multilingual tiny rec so the assertion still holds without real RAM.

* fix(language): catch specific errors on layout lookup and sibling swap

* fix(language): swallow more sibling-swap reconfigure failures

ImportError, TypeError, AttributeError, and ChecksumError from the
multilingual swap must not abort dictation or disarm follow mode.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 01:06:32 -07:00

354 lines
13 KiB
Python

"""The follow-keyboard-layout mode is a switch over the language picker (#821)."""
from __future__ import annotations
import importlib
import subprocess
import sys
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
import vocalinux.ui
from vocalinux.utils.model_checksums import ChecksumError
@pytest.fixture(scope="module")
def settings_dialog() -> Iterator[Any]:
"""Import settings_dialog with real base classes for its GTK subclasses.
Same approach as tests/test_simple_model_settings.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: Any) -> type[Any]:
return settings_dialog.SettingsDialog
def test_mode_is_off_for_a_bare_mock_dialog(settings_dialog: Any) -> None:
"""The guard must not read truthy on a Mock, or it inverts every caller.
Written as a module function precisely because ``self._method()`` on a Mock
dialog returns a truthy Mock; an earlier version of this guard silently
skipped the whole simple-mode sync for that reason.
"""
assert settings_dialog._is_following_layout(Mock()) is False
@pytest.mark.parametrize(
"value,expected",
[(True, True), (False, False), (None, False), ("yes", False), (1, False)],
)
def test_mode_reads_the_cached_bool_strictly(
settings_dialog: Any, value: object, expected: bool
) -> None:
dialog = Mock()
dialog._follow_layout_active = value
assert settings_dialog._is_following_layout(dialog) is expected
def test_mode_is_off_when_the_attribute_is_absent(settings_dialog: Any) -> None:
class Bare:
pass
assert settings_dialog._is_following_layout(Bare()) is False
def _controls_stub(switch_on: bool, supported: bool, english_only: bool = False) -> Mock:
dialog = Mock()
dialog.follow_layout_switch.get_active.return_value = switch_on
dialog._follow_layout_supported.return_value = supported
dialog._is_selected_whispercpp_model_english_only.return_value = english_only
dialog._processing_language_change = False
return dialog
def test_turning_the_mode_on_disables_the_language_picker(dialog_class: type[Any]) -> None:
"""The picker still shows a language, but the mode owns it."""
dialog = _controls_stub(switch_on=True, supported=True)
dialog_class._sync_follow_layout_controls(dialog)
assert dialog._follow_layout_active is True
dialog.follow_layout_row.set_sensitive.assert_called_once_with(True)
dialog.language_row.set_sensitive.assert_called_once_with(False)
def test_leaving_the_mode_off_leaves_the_picker_usable(dialog_class: type[Any]) -> None:
dialog = _controls_stub(switch_on=False, supported=True)
dialog_class._sync_follow_layout_controls(dialog)
assert dialog._follow_layout_active is False
dialog.language_row.set_sensitive.assert_called_once_with(True)
def test_an_engine_that_cannot_follow_turns_the_mode_off(dialog_class: type[Any]) -> None:
"""VOSK loads a model per language; Parakeet ignores the picker entirely."""
dialog = _controls_stub(switch_on=True, supported=False)
dialog_class._sync_follow_layout_controls(dialog)
dialog.follow_layout_row.set_sensitive.assert_called_once_with(False)
dialog.follow_layout_switch.set_active.assert_called_once_with(False)
assert dialog._follow_layout_active is False
# The picker is the only way to choose a language on those engines.
dialog.language_row.set_sensitive.assert_called_once_with(True)
@pytest.mark.parametrize(
"engine,expected",
[
("whisper_cpp", True),
("whisper", True),
("faster_whisper", True),
("remote_api", True),
("vosk", False),
("parakeet", False),
],
)
def test_engine_support(dialog_class: type[Any], engine: str, expected: bool) -> None:
dialog = Mock()
assert dialog_class._follow_layout_supported(dialog, engine) is expected
def test_an_english_only_model_cannot_follow_a_layout(dialog_class: type[Any]) -> None:
"""small.en has no non-English weights, so the mode would silently do nothing."""
dialog = _controls_stub(switch_on=True, supported=True, english_only=True)
dialog_class._sync_follow_layout_controls(dialog)
dialog.follow_layout_row.set_sensitive.assert_called_once_with(False)
dialog.follow_layout_switch.set_active.assert_called_once_with(False)
assert dialog._follow_layout_active is False
# --- The mode must stay armed across dictations -----------------------------
def _manager_stub(model_size: str, language: str = "en-us") -> Any:
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager.__new__(SpeechRecognitionManager)
manager.engine = "whisper_cpp"
manager.model_size = model_size
manager.language = language
manager.language_preference = "layout"
manager._faster_whisper_engine = None
manager.command_processor = MagicMock()
manager.reconfigure = MagicMock()
return manager
@pytest.mark.parametrize(
"error",
[
OSError("gsettings gone"),
FileNotFoundError("gsettings"),
subprocess.SubprocessError(),
ValueError("bad parse"),
TypeError("unexpected type"),
KeyError("missing"),
AttributeError("no attr"),
],
)
def test_layout_lookup_failure_falls_back_to_auto(error: Exception) -> None:
"""A layout helper error must not block dictation."""
import vocalinux.speech_recognition.recognition_manager as rm
with patch.object(rm, "language_for_active_layout", side_effect=error):
assert rm.resolve_language_preference("layout") == "auto"
def test_following_a_layout_keeps_the_mode_armed() -> None:
"""A switch must not turn the sentinel into a concrete language.
reconfigure() stores whatever language it is handed as the new preference,
so calling it here would disarm the mode after a single switch and the
remaining dictations would silently stop following anything.
"""
import vocalinux.speech_recognition.recognition_manager as rm
manager = _manager_stub("small")
with patch.object(rm, "language_for_active_layout", return_value="fr"):
manager._refresh_language_from_layout()
assert manager.language == "fr"
assert manager.language_preference == "layout"
# And again, from the other direction.
with patch.object(rm, "language_for_active_layout", return_value="en-us"):
manager._refresh_language_from_layout()
assert manager.language == "en-us"
assert manager.language_preference == "layout"
manager.reconfigure.assert_not_called()
def test_an_english_only_model_reloads_the_multilingual_sibling() -> None:
"""English-only weights cannot honour a non-English layout; swap the sibling.
reconfigure() stores whatever language it is handed as the new preference,
so the resolved language must never be passed in -- only the layout sentinel
and the multilingual model id.
"""
import vocalinux.speech_recognition.recognition_manager as rm
manager = _manager_stub("small.en")
with patch.object(rm, "language_for_active_layout", return_value="fr"):
with patch.object(rm, "is_model_downloaded", return_value=True):
manager._refresh_language_from_layout()
manager.reconfigure.assert_called_once_with(model_size="small", language="layout")
assert manager.language_preference == "layout"
assert "fr" not in manager.reconfigure.call_args.args
def test_an_english_only_model_skips_reload_when_already_english() -> None:
"""The loaded English-only model already matches an English layout."""
import vocalinux.speech_recognition.recognition_manager as rm
manager = _manager_stub("small.en")
with patch.object(rm, "language_for_active_layout", return_value="en-us"):
manager._refresh_language_from_layout()
manager.reconfigure.assert_not_called()
assert manager.language == "en-us"
assert manager.language_preference == "layout"
@pytest.mark.parametrize(
"error",
[
ImportError("missing backend"),
TypeError("bad args"),
AttributeError("no attr"),
ChecksumError("digest mismatch"),
],
)
def test_sibling_swap_reconfigure_failure_does_not_abort_dictation(
error: Exception,
) -> None:
"""A sibling-swap reconfigure error must not block dictation or disarm follow."""
import vocalinux.speech_recognition.recognition_manager as rm
manager = _manager_stub("small.en")
manager.reconfigure.side_effect = error
with patch.object(rm, "language_for_active_layout", return_value="fr"):
with patch.object(rm, "is_model_downloaded", return_value=True):
manager._refresh_language_from_layout()
manager.reconfigure.assert_called_once_with(model_size="small", language="layout")
assert manager.language_preference == "layout"
def test_an_english_only_model_skips_reload_when_sibling_is_missing() -> None:
"""Do not fetch the multilingual sibling on the dictation hotkey."""
import vocalinux.speech_recognition.recognition_manager as rm
manager = _manager_stub("small.en")
with patch.object(rm, "language_for_active_layout", return_value="fr"):
with patch.object(rm, "is_model_downloaded", return_value=False):
manager._refresh_language_from_layout()
manager.reconfigure.assert_not_called()
assert manager.language_preference == "layout"
@pytest.mark.parametrize("engine", ["whisper", "faster_whisper"])
def test_english_only_non_whispercpp_does_not_use_whispercpp_catalog(engine: str) -> None:
"""Follow is offered for these engines; the whisper.cpp download check is the wrong catalog."""
import vocalinux.speech_recognition.recognition_manager as rm
manager = _manager_stub("small.en")
manager.engine = engine
with patch.object(rm, "language_for_active_layout", return_value="fr"):
with patch.object(rm, "is_model_downloaded", return_value=True) as downloaded:
manager._refresh_language_from_layout()
downloaded.assert_not_called()
manager.reconfigure.assert_not_called()
assert manager.language_preference == "layout"
def _variant_dialog(
language: str = "en-us",
follow_active: bool = False,
follow_saved: bool = False,
initializing: bool = False,
) -> Mock:
dialog = Mock()
dialog.language = language
dialog.language_combo.get_active_id.return_value = language
dialog.config_manager.get_model_variant_for_engine.return_value = ""
dialog._follow_layout_active = follow_active
dialog._follow_layout_saved = follow_saved
dialog._initializing = initializing
return dialog
def test_follow_mode_does_not_derive_english_only_from_the_displayed_layout(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""The picker shows the current layout while follow is on, often English.
Deriving .en from that display would make Settings refuse the mode as
English-only, and the next auto-apply would persist a concrete language.
"""
dialog = _variant_dialog(follow_active=True)
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny"
assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny"
with patch.object(
settings_dialog,
"get_recommended_whispercpp_model",
return_value=("tiny", "test"),
):
recommended, _ = dialog_class._get_recommended_whispercpp_model_for_language(dialog)
assert not settings_dialog.is_english_only_whispercpp_model(recommended)
def test_follow_mode_load_path_keeps_multilingual_before_the_switch_is_synced(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
"""On load the saved sentinel is follow, but _follow_layout_active is still false."""
dialog = _variant_dialog(follow_saved=True, initializing=True)
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny"
assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny"
def test_follow_off_still_derives_english_only_from_an_english_picker(
settings_dialog: Any, dialog_class: type[Any]
) -> None:
dialog = _variant_dialog()
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny.en"
assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny.en"
def test_layout_sentinel_does_not_retarget_a_bare_size_onto_english_only(
settings_dialog: Any,
) -> None:
assert settings_dialog._whispercpp_variant_for_language("tiny", "layout") == "tiny"
assert settings_dialog._whispercpp_variant_for_language("tiny.en", "layout") == "tiny"