Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""First run should start from the language the system points at (#777)."""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from vocalinux.ui import config_manager as cm
|
|
from vocalinux.utils import system_language as sl
|
|
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_config(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(cm, "CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setattr(cm, "CONFIG_FILE", str(tmp_path / "config.json"))
|
|
monkeypatch.setattr(cm, "_shared_instance", None)
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"layout,expected",
|
|
[
|
|
("pl", "pl"),
|
|
("de", "de"),
|
|
("cz", "cs"),
|
|
("se", "sv"),
|
|
("si", "sl"),
|
|
("sk", None),
|
|
("us", "en-us"),
|
|
("gb", "en-us"),
|
|
("xyz", None),
|
|
],
|
|
)
|
|
def test_layouts_map_onto_catalogue_entries(layout, expected):
|
|
assert sl._language_for_layout(layout, SUPPORTED_LANGUAGES) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
("pl_PL.UTF-8", "pl"),
|
|
("de_DE@euro", "de"),
|
|
("en_US.UTF-8", "en-us"),
|
|
("en_GB", "en-us"),
|
|
("en_IN", "en-in"),
|
|
("cs", "cs"),
|
|
("cs_CZ.UTF-8", "cs"),
|
|
("ca", "ca"),
|
|
("ca_ES.UTF-8", "ca"),
|
|
("nb_NO", "no"),
|
|
("nb_NO.UTF-8", "no"),
|
|
("nn_NO", "no"),
|
|
("sl_SI.UTF-8", "sl"),
|
|
("sk_SK.UTF-8", None),
|
|
("C", None),
|
|
("POSIX", None),
|
|
("C.UTF-8", None),
|
|
("xx_YY", None),
|
|
],
|
|
)
|
|
def test_locales_map_onto_catalogue_entries(value, expected):
|
|
assert sl._language_for_locale(value, SUPPORTED_LANGUAGES) == expected
|
|
|
|
|
|
def test_a_non_english_layout_beats_an_english_locale():
|
|
"""The reported case: desktop in English, keyboard in Polish, speaks Polish."""
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="pl"):
|
|
detected = sl.detect_system_language(SUPPORTED_LANGUAGES, {"LANG": "en_US.UTF-8"})
|
|
|
|
assert detected == "pl"
|
|
|
|
|
|
def test_a_us_layout_defers_to_the_locale():
|
|
""" "us" is the default layout on many installs, so it carries little signal."""
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(SUPPORTED_LANGUAGES, {"LANG": "pl_PL.UTF-8"})
|
|
|
|
assert detected == "pl"
|
|
|
|
|
|
def test_a_us_layout_is_used_when_the_locale_says_nothing():
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(SUPPORTED_LANGUAGES, {"LANG": "C"})
|
|
|
|
assert detected == "en-us"
|
|
|
|
|
|
def test_nothing_decisive_returns_none():
|
|
with patch.object(sl, "detect_keyboard_layout", return_value=None):
|
|
assert sl.detect_system_language(SUPPORTED_LANGUAGES, {}) is None
|
|
|
|
|
|
def test_a_missing_localectl_does_not_raise():
|
|
with patch.object(sl.subprocess, "run", side_effect=FileNotFoundError):
|
|
assert sl.detect_keyboard_layout() is None
|
|
|
|
|
|
def test_first_run_starts_from_the_detected_language(isolated_config):
|
|
"""Without this the fresh config keeps the packaged "auto"."""
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="pl"):
|
|
with patch.dict("os.environ", {"LANG": "en_US.UTF-8"}, clear=False):
|
|
manager = cm.ConfigManager()
|
|
|
|
assert manager.get("speech_recognition", "language") == "pl"
|
|
|
|
|
|
def test_first_run_keeps_the_default_when_detection_finds_nothing(isolated_config):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value=None):
|
|
with patch.dict("os.environ", {"LANG": "C", "LC_ALL": "C"}, clear=True):
|
|
manager = cm.ConfigManager()
|
|
|
|
assert manager.get("speech_recognition", "language") == "auto"
|
|
|
|
|
|
def test_a_saved_language_is_never_overwritten(isolated_config):
|
|
"""A returning user who chose auto-detect keeps it."""
|
|
import json
|
|
|
|
with open(cm.CONFIG_FILE, "w") as handle:
|
|
json.dump({"speech_recognition": {"language": "auto"}}, handle)
|
|
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="pl"):
|
|
manager = cm.ConfigManager()
|
|
|
|
assert manager.get("speech_recognition", "language") == "auto"
|
|
|
|
|
|
def test_language_env_beats_lang_when_both_set():
|
|
"""LANGUAGE is the gettext preference list; LANG must not mask it (#796)."""
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(
|
|
SUPPORTED_LANGUAGES,
|
|
{"LANG": "en_US.UTF-8", "LANGUAGE": "pl:en"},
|
|
)
|
|
|
|
assert detected == "pl"
|
|
|
|
|
|
def test_language_env_skips_unsupported_preferences():
|
|
"""An unsupported first LANGUAGE pref must not hide a later supported one."""
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(
|
|
SUPPORTED_LANGUAGES,
|
|
{"LANG": "en_US.UTF-8", "LANGUAGE": "xx:pl:en"},
|
|
)
|
|
|
|
assert detected == "pl"
|
|
|
|
|
|
def test_unmapped_language_env_falls_through_to_lang():
|
|
"""An unsupported LANGUAGE list must not hide LANG (#796)."""
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(
|
|
SUPPORTED_LANGUAGES,
|
|
{"LANGUAGE": "xx:yy", "LANG": "pl_PL.UTF-8"},
|
|
)
|
|
|
|
assert detected == "pl"
|
|
|
|
|
|
def test_language_env_czech_is_not_treated_as_c():
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(
|
|
SUPPORTED_LANGUAGES,
|
|
{"LANG": "en_US.UTF-8", "LANGUAGE": "cs"},
|
|
)
|
|
|
|
assert detected == "cs"
|
|
|
|
|
|
def test_nb_no_locale_maps_to_norwegian():
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us"):
|
|
detected = sl.detect_system_language(
|
|
SUPPORTED_LANGUAGES,
|
|
{"LANG": "nb_NO.UTF-8"},
|
|
)
|
|
|
|
assert detected == "no"
|
|
|
|
|
|
# --- Follow the active keyboard layout (#821) -------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sources,current,expected",
|
|
[
|
|
# The bilingual case from the issue: same config, different active index.
|
|
("[('xkb', 'us'), ('xkb', 'fr')]", "uint32 0", "us"),
|
|
("[('xkb', 'us'), ('xkb', 'fr')]", "uint32 1", "fr"),
|
|
# A variant is still that layout.
|
|
("[('xkb', 'fr+oss')]", "uint32 0", "fr"),
|
|
# "uint32 1" must not read as index 32 via a bare \d+ search.
|
|
("[('xkb', 'us'), ('xkb', 'pl')]", "uint32 1", "pl"),
|
|
# A stale index outlives a removed source; GNOME falls back to the first.
|
|
("[('xkb', 'us'), ('xkb', 'fr')]", "uint32 9", "us"),
|
|
("[('xkb', 'us')]", "", "us"),
|
|
# An IBus engine is not an xkb layout and maps to nothing here.
|
|
("[('ibus', 'mozc-jp')]", "uint32 0", None),
|
|
("@a(ss) []", "uint32 0", None),
|
|
("", "uint32 0", None),
|
|
],
|
|
)
|
|
def test_active_source_parsing(sources, current, expected):
|
|
assert sl._parse_active_source(sources, current) == expected
|
|
|
|
|
|
def test_active_layout_reads_gsettings():
|
|
with patch.object(sl, "_gsettings_get", side_effect=["[('xkb', 'fr')]", "uint32 0"]):
|
|
assert sl.detect_active_keyboard_layout() == "fr"
|
|
|
|
|
|
def test_active_layout_is_none_without_gsettings():
|
|
with patch.object(sl, "_gsettings_get", return_value=None):
|
|
assert sl.detect_active_keyboard_layout() is None
|
|
|
|
|
|
def test_active_layout_language_maps_onto_catalogue():
|
|
with patch.object(sl, "_active_input_source", return_value=("xkb", "fr")):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "fr"
|
|
|
|
|
|
def test_active_layout_falls_back_to_configured_layout():
|
|
"""Outside GNOME there is no readable active source; the configured one remains."""
|
|
with patch.object(sl, "_active_input_source", return_value=None):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="pl"):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "pl"
|
|
|
|
|
|
def test_active_layout_language_is_none_when_unmappable():
|
|
with patch.object(sl, "_active_input_source", return_value=("xkb", "xyz")):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None
|
|
|
|
|
|
def test_active_layout_language_is_none_without_any_layout():
|
|
with patch.object(sl, "_active_input_source", return_value=None):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value=None):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None
|
|
|
|
|
|
def test_an_active_ibus_engine_does_not_borrow_the_configured_layout():
|
|
"""Typing Japanese through mozc-jp must not resolve to the us layout's English."""
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "mozc-jp")):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured:
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ja"
|
|
configured.assert_not_called()
|
|
|
|
|
|
def test_an_unmapped_ibus_engine_does_not_borrow_the_configured_layout():
|
|
"""An unknown IBus engine is still a real answer; leave it to auto-detect."""
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "foo-engine")):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured:
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None
|
|
configured.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("engine_id", ["foo-us", "foo-gb", "foo-fr", "foo-de"])
|
|
def test_hyphen_suffix_does_not_false_map_layout_tokens(engine_id):
|
|
"""rsplit last token is not a language code: mozc-us is Japanese, not xkb us."""
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", engine_id)):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured:
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) is None
|
|
configured.assert_not_called()
|
|
|
|
|
|
def test_ibus_mozc_us_maps_to_japanese():
|
|
"""Mozc's documented US-layout engine is still a Japanese IME."""
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "mozc-us")):
|
|
with patch.object(sl, "detect_keyboard_layout", return_value="us") as configured:
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ja"
|
|
configured.assert_not_called()
|
|
|
|
|
|
def test_ibus_hyphen_jp_and_kr_suffixes_still_map():
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "foo-jp")):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ja"
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "foo-kr")):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "ko"
|
|
|
|
|
|
def test_ibus_m17n_hi_itrans_maps_to_hindi():
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "m17n:hi:itrans")):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "hi"
|
|
|
|
|
|
def test_ibus_libpinyin_maps_to_chinese():
|
|
with patch.object(sl, "_active_input_source", return_value=("ibus", "libpinyin")):
|
|
assert sl.language_for_active_layout(SUPPORTED_LANGUAGES) == "zh"
|
|
|
|
|
|
def test_active_source_prefers_mru_and_falls_back_to_the_index():
|
|
values = {
|
|
"mru-sources": "[('ibus', 'mozc-jp'), ('xkb', 'us')]",
|
|
"sources": "[('xkb', 'us')]",
|
|
"current": "uint32 0",
|
|
}
|
|
with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]):
|
|
# The MRU answer wins even though it is unmappable.
|
|
assert tuple(sl._active_input_source()) == ("ibus", "mozc-jp")
|
|
|
|
values["mru-sources"] = "@a(ss) []"
|
|
with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]):
|
|
assert tuple(sl._active_input_source()) == ("xkb", "us")
|
|
|
|
|
|
def test_no_active_source_without_gsettings():
|
|
with patch.object(sl, "_gsettings_get", return_value=None):
|
|
assert sl._active_input_source() is None
|
|
|
|
|
|
def test_active_layout_prefers_mru_over_stale_current_index():
|
|
"""GNOME's "current" index goes stale; the MRU list is what tracks a switch.
|
|
|
|
Observed live: after switching to the second layout, mru-sources led with
|
|
('xkb', 'fr') while current still read 0, which points at 'us' (#497, #738).
|
|
"""
|
|
values = {
|
|
"mru-sources": "[('xkb', 'fr'), ('xkb', 'us')]",
|
|
"sources": "[('xkb', 'us'), ('xkb', 'fr')]",
|
|
"current": "uint32 0",
|
|
}
|
|
with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]):
|
|
assert sl.detect_active_keyboard_layout() == "fr"
|
|
|
|
|
|
def test_active_layout_falls_back_to_index_when_mru_is_empty():
|
|
"""A session that has never switched has no MRU list to go on."""
|
|
values = {
|
|
"mru-sources": "@a(ss) []",
|
|
"sources": "[('xkb', 'us'), ('xkb', 'pl')]",
|
|
"current": "uint32 1",
|
|
}
|
|
with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]):
|
|
assert sl.detect_active_keyboard_layout() == "pl"
|
|
|
|
|
|
def test_active_layout_reports_nothing_for_an_ibus_mru_entry():
|
|
"""An IBus engine at the front of the MRU list must not fall through."""
|
|
values = {
|
|
"mru-sources": "[('ibus', 'mozc-jp'), ('xkb', 'us')]",
|
|
"sources": "[('xkb', 'us')]",
|
|
"current": "uint32 0",
|
|
}
|
|
with patch.object(sl, "_gsettings_get", side_effect=lambda key: values[key]):
|
|
assert sl.detect_active_keyboard_layout() is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
("[('xkb', 'us'), ('xkb', 'fr')]", [("xkb", "us"), ("xkb", "fr")]),
|
|
("@a(ss) [('xkb', 'us')]", [("xkb", "us")]),
|
|
("@a(ss) []", []),
|
|
("not a list", []),
|
|
("", []),
|
|
(None, []),
|
|
],
|
|
)
|
|
def test_source_list_parsing(value, expected):
|
|
assert sl._parse_sources(value) == expected
|