* feat(dictionary): combined custom dictionary with corrections Rebased onto latest main (conflict-resolution squash for PR #818). * fix(dictionary): stabilize the terms and corrections file contracts - corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over. - _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask. - build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment. * fix(settings): restore the selected dictionary pane after a search Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose. * fix(dictionary): close the remaining custom dictionary file-contract gaps - A --dictionary-file session override no longer imports a corrections file found beside the override into the standard config directory; the override scopes to terms only. - Terms reads validate the whole file before yielding: corrupt UTF-8 mid-file now yields no terms at all, matching the invalid status Settings reports instead of leaking a partial dictionary to recognition. - The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger file is invalid everywhere with a dedicated status instead of being read on every segment. * fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides * fix(dictionary): cap yielded terms and keep added terms visible on oversized files * fix(dictionary): keep added terms visible without dropping scanner-owned lines * fix(dictionary): keep oversized terms manageable and honor mid-search pane switches - terms list gains a Show all / Show first 500 toggle; adding a term beyond the cap auto-expands so the new row renders with a Remove button instead of being invisible until the file is hand-edited - a manual dictionary pane switch during an active search drops the saved pre-search pane so clearing the query no longer rolls back the user's newer selection - annotate the new settings tests and type model_info so the parakeet branch stops adding fresh mypy errors * fix(settings): do not auto-expand terms on add past display cap Rebuilding up to thousands of term rows on the UI thread stalls the dialog; the overflow button already shows the true count, so the added term stays one click away. * test(dictionary): stub segment_callbacks on bare manager for history dispatch * fix(dictionary): stream oversized terms files when building the prompt build_initial_prompt decoded the whole 1 MiB read window before every transcription segment even though it only needs the first max_words terms. _iter_terms_for_prompt now reads oversized files line by line and stops at the prompt limit, keeping the same contract: complete leading lines only, invalid UTF-8 inside the usable window yields nothing. * fix(history): only the ended session's worker may extend its snippet A late segment could extend the newest history entry regardless of which session produced it, so a worker finishing after the next dictation session leaked its text into that session's entry. Track the worker that owned the ended session and require late segments to arrive on the same thread before merging; anything else forms its own snippet. * fix(speech): transcribe queued segments the reload worker never drained The recognition worker can exit during stop_recognition's bounded audio-thread join, so the released key's final buffer lands in the queue after the worker is gone and was silently dropped when the reload teardown replaced the queue. Drain whatever remains before swapping it — except on capture failure or an explicit cancel, which still discard the session's leftovers. * fix(settings): build term rows in idle slices and pin hidden additions Two problems shared one cause. Rebuilding up to 4096 rows synchronously stalled the dialog, and a term appended past the 500-row cap reported success while staying invisible. Rows now build in bounded GLib idle slices guarded by a refresh token, and a saved term outside the visible window is pinned to the top of the capped list until it disappears or becomes visible on its own. Adding under "show all" scrolls to the new term instead. * test(engine): annotate new test method return types * fix(ci): match a candidate row that leads the latest track block snapcraft status normally prints candidate as a continuation line under the latest track (channel in column 1). If candidate ever leads the track block it rides the track line instead (channel in column 3), and the resolver missed it and refused to promote. Match both layouts in the resolve and the pre-release re-verify. * fix(dictionary): bound prompt reads and scroll to landed terms - _iter_terms_for_prompt now reads each line via readline() capped by the remaining byte window, so an overlong line can never pull more than MAX_TERMS_FILE_BYTES into memory - the prompt path keeps decoding every complete line inside the usable window after the term limit is collected, so invalid UTF-8 anywhere in the window still yields no terms and matches the reader's invalid status - under "show all" Settings scrolls to the index where an added term landed (prepended or appended), verified against the row's label, instead of always scrolling to the end * fix(history,reload): keep late-segment ownership and drain stamped tuples - Track session_workers_seen so a worker from an earlier session can never merge into a newer snippet while a session is open - Unpack (segment, language) queue tuples in _transcribe_queued_segments; passing the tuple as audio raised TypeError and lost final speech - Refuse 'delete that' when a manual pad edit blurred segment boundaries - Translate term-row coordinates into the scrolled card's space so the scroll actually reaches rows below the fold - Type _on_dictionary_pane_changed's varargs * style: drop unneeded nonlocal for mutated worker set --------- Co-authored-by: Graham <graham@thegeeky.ninja>
1107 lines
46 KiB
Python
1107 lines
46 KiB
Python
"""Tests for the file-backed custom dictionary contract."""
|
|
|
|
import json
|
|
import re
|
|
import stat
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
from vocalinux.custom_dictionary import (
|
|
CORRECTIONS_FILENAME,
|
|
DEFAULT_TERMS_PATH,
|
|
LEGACY_DEFAULT_TERMS_PATH,
|
|
MAX_CORRECTION_CHARACTERS,
|
|
MAX_TERMS_FILE_BYTES,
|
|
MAX_TERMS_YIELDED,
|
|
TERMS_FILENAME,
|
|
CustomDictionaryManager,
|
|
apply_corrections,
|
|
normalize_corrections,
|
|
)
|
|
from vocalinux.main import parse_arguments
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
|
|
class FakeConfig:
|
|
"""Minimal ConfigManager substitute with controllable persistence."""
|
|
|
|
def __init__(self, values: dict[str, dict[str, Any]] | None = None, save_result: bool = True):
|
|
self.values = values or {
|
|
"dictionary": {
|
|
"enabled": False,
|
|
"file_path": DEFAULT_TERMS_PATH,
|
|
"max_words": 200,
|
|
}
|
|
}
|
|
self.save_result = save_result
|
|
|
|
def get(self, section: str, key: str, default: Any = None) -> Any:
|
|
"""Return one configured value."""
|
|
return self.values.get(section, {}).get(key, default)
|
|
|
|
def set(self, section: str, key: str, value: Any) -> bool:
|
|
"""Set one configured value."""
|
|
self.values.setdefault(section, {})[key] = value
|
|
return True
|
|
|
|
def save_config(self) -> bool:
|
|
"""Return the requested persistence result."""
|
|
return self.save_result
|
|
|
|
|
|
def manager_at(
|
|
tmp_path: Path, monkeypatch, config: FakeConfig | None = None
|
|
) -> CustomDictionaryManager:
|
|
"""Create a manager whose configured files live in pytest's temp directory."""
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(tmp_path))
|
|
config = config or FakeConfig()
|
|
dictionary = config.values.setdefault("dictionary", {})
|
|
if dictionary.get("file_path") in (None, DEFAULT_TERMS_PATH):
|
|
dictionary["file_path"] = str(tmp_path / TERMS_FILENAME)
|
|
return CustomDictionaryManager(config)
|
|
|
|
|
|
def recognition_manager(
|
|
engine: str, dictionary: CustomDictionaryManager, advanced_prompt: str = ""
|
|
) -> SpeechRecognitionManager:
|
|
"""Build a recognition manager without loading a real speech model."""
|
|
with patch.object(SpeechRecognitionManager, "_init_vosk"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whisper"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
return SpeechRecognitionManager(
|
|
engine=engine,
|
|
dictionary_manager=dictionary,
|
|
whispercpp_initial_prompt=advanced_prompt,
|
|
)
|
|
|
|
|
|
def mock_numpy() -> MagicMock:
|
|
"""Return the minimum NumPy surface used by transcription helpers."""
|
|
numpy = MagicMock()
|
|
numpy.frombuffer.return_value = MagicMock(__len__=lambda _: 16_000)
|
|
numpy.frombuffer.return_value.astype.return_value = numpy.frombuffer.return_value
|
|
numpy.int16 = "int16"
|
|
numpy.float32 = "float32"
|
|
return numpy
|
|
|
|
|
|
def test_terms_are_live_reloaded_and_scanner_friendly(tmp_path: Path, monkeypatch) -> None:
|
|
"""The UTF-8 line file accepts comments and applies an external replacement."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
terms_file = tmp_path / TERMS_FILENAME
|
|
terms_file.write_text("# scanner comment\nVocaLinux\nvocalinux\nSupabase\n", encoding="utf-8")
|
|
|
|
assert manager.get_terms() == ["VocaLinux", "Supabase"]
|
|
assert manager.build_initial_prompt() == "VocaLinux Supabase"
|
|
|
|
terms_file.write_text("PyGObject\n", encoding="utf-8")
|
|
assert manager.build_initial_prompt() == "PyGObject"
|
|
|
|
|
|
def test_terms_save_is_atomic_shape_and_transient_override_is_not_written(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""UI saves standard terms safely while CLI terms remain session-only."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
assert manager.save_terms(["VocaLinux", "vocalinux", "# ignored", " PyGObject "])
|
|
assert (tmp_path / TERMS_FILENAME).read_text(encoding="utf-8") == "VocaLinux\nPyGObject\n"
|
|
|
|
override = tmp_path / "override.txt"
|
|
override.write_text("Override\n", encoding="utf-8")
|
|
transient = CustomDictionaryManager(FakeConfig(), str(override))
|
|
assert transient.terms_enabled()
|
|
assert transient.get_terms() == ["Override"]
|
|
assert not transient.save_terms(["No write"])
|
|
|
|
|
|
def test_incremental_term_edits_preserve_comments_and_blank_lines(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""GTK add/remove operations retain unrelated scanner-managed source lines."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
terms_file = tmp_path / TERMS_FILENAME
|
|
terms_file.write_text("# scanner note\n\nVocaLinux\n# retain me\nPyGObject\n", encoding="utf-8")
|
|
|
|
assert manager.add_term("Supabase")
|
|
assert terms_file.read_text(encoding="utf-8") == (
|
|
"# scanner note\n\nVocaLinux\n# retain me\nPyGObject\nSupabase\n"
|
|
)
|
|
assert manager.remove_term("PyGObject")
|
|
assert terms_file.read_text(encoding="utf-8") == (
|
|
"# scanner note\n\nVocaLinux\n# retain me\nSupabase\n"
|
|
)
|
|
|
|
|
|
def test_enable_rollback_when_config_persistence_fails(tmp_path: Path, monkeypatch) -> None:
|
|
"""A failed config save preserves the previous in-memory setting."""
|
|
config = FakeConfig(save_result=False)
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
|
|
assert not manager.set_terms_enabled(True)
|
|
assert not manager.terms_enabled()
|
|
|
|
|
|
def test_prompt_read_stops_at_the_terms_limit(tmp_path: Path, monkeypatch) -> None:
|
|
"""Prompt assembly stops processing terms after the configured term cap."""
|
|
config = FakeConfig({"dictionary": {"enabled": True, "max_words": 2}})
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
(tmp_path / TERMS_FILENAME).write_text("one\ntwo\nthree\n", encoding="utf-8")
|
|
|
|
assert manager.build_initial_prompt() == "one two"
|
|
|
|
|
|
def test_partially_invalid_terms_file_fails_closed(tmp_path: Path, monkeypatch) -> None:
|
|
"""Valid lines before corrupt UTF-8 must not reach recognition."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
payload = b"one\ntwo\n" + b"x" * 9000 + b"\xff\nthree\n"
|
|
(tmp_path / TERMS_FILENAME).write_bytes(payload)
|
|
|
|
assert manager.get_terms() == []
|
|
assert manager.build_initial_prompt() is None
|
|
assert manager.terms_status() == "Terms file is not valid UTF-8."
|
|
|
|
|
|
def test_oversized_terms_file_supplies_its_leading_lines(tmp_path: Path, monkeypatch) -> None:
|
|
"""A file beyond the read bound still supplies its complete leading lines."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
line_count = MAX_TERMS_FILE_BYTES // 8 + 2 # 8-byte lines plus a partial tail
|
|
(tmp_path / TERMS_FILENAME).write_bytes(
|
|
b"".join(f"t{i:06d}\n".encode() for i in range(line_count))
|
|
)
|
|
|
|
terms = manager.get_terms()
|
|
assert terms
|
|
assert all(re.fullmatch(r"t\d{6}", term) for term in terms)
|
|
assert len(terms) == MAX_TERMS_YIELDED
|
|
assert manager.build_initial_prompt() is not None
|
|
assert "leading lines only" in manager.terms_status()
|
|
|
|
|
|
def test_prompt_on_oversized_file_streams_only_the_needed_prefix(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Prompt assembly stops after max_terms instead of decoding the bound."""
|
|
config = FakeConfig({"dictionary": {"enabled": True, "max_words": 3}})
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
line_count = MAX_TERMS_FILE_BYTES // 8 + 2
|
|
(tmp_path / TERMS_FILENAME).write_bytes(
|
|
b"".join(f"t{i:06d}\n".encode() for i in range(line_count))
|
|
)
|
|
|
|
assert manager.build_initial_prompt() == "t000000 t000001 t000002"
|
|
|
|
|
|
def test_prompt_on_oversized_file_with_corrupt_prefix_still_fails_closed(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Invalid UTF-8 inside the usable prefix supplies no prompt at all."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
line_count = MAX_TERMS_FILE_BYTES // 8 + 2
|
|
(tmp_path / TERMS_FILENAME).write_bytes(
|
|
b"one\ntwo\n" + b"\xff\n" + b"".join(f"t{i:06d}\n".encode() for i in range(line_count))
|
|
)
|
|
|
|
assert manager.build_initial_prompt() is None
|
|
|
|
|
|
def test_prompt_on_oversized_file_validates_past_the_term_limit(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Invalid UTF-8 after the collected prompt terms still supplies none."""
|
|
config = FakeConfig({"dictionary": {"enabled": True, "max_words": 2}})
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
line_count = MAX_TERMS_FILE_BYTES // 8 + 2
|
|
(tmp_path / TERMS_FILENAME).write_bytes(
|
|
b"one\ntwo\nthree\n"
|
|
+ b"\xff\n"
|
|
+ b"".join(f"t{i:06d}\n".encode() for i in range(line_count))
|
|
)
|
|
|
|
assert manager.build_initial_prompt() is None
|
|
assert manager.get_terms() == []
|
|
|
|
|
|
def test_prompt_on_oversized_file_with_single_huge_line_stays_bounded(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A first line longer than the read bound supplies no prompt terms."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
(tmp_path / TERMS_FILENAME).write_bytes(b"x" * (MAX_TERMS_FILE_BYTES + 10) + b"\nvalid\n")
|
|
|
|
assert manager.build_initial_prompt() is None
|
|
|
|
|
|
def test_add_term_on_oversized_file_stays_visible(tmp_path: Path, monkeypatch) -> None:
|
|
"""Prepending past the read window keeps the new term visible while
|
|
preserving every scanner-owned line, comment, and blank line."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
line_count = MAX_TERMS_FILE_BYTES // 8 + 2
|
|
original = b"# scanner tail\n" + b"".join(f"t{i:06d}\n".encode() for i in range(line_count))
|
|
(tmp_path / TERMS_FILENAME).write_bytes(original)
|
|
|
|
assert manager.add_term("brandnew")
|
|
assert (tmp_path / TERMS_FILENAME).read_bytes() == b"brandnew\n" + original
|
|
terms = manager.get_terms()
|
|
assert terms[0] == "brandnew"
|
|
assert len(terms) == MAX_TERMS_YIELDED
|
|
|
|
|
|
def test_add_term_on_yield_capped_file_stays_visible(tmp_path: Path, monkeypatch) -> None:
|
|
"""A file at the yield cap but under the byte bound also needs the new
|
|
term prepended so Settings and the prompt can see it."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
original = "".join(f"t{i:05d}\n" for i in range(MAX_TERMS_YIELDED))
|
|
terms_file = tmp_path / TERMS_FILENAME
|
|
terms_file.write_text(original, encoding="utf-8")
|
|
|
|
assert manager.add_term("brandnew")
|
|
assert terms_file.read_text(encoding="utf-8") == f"brandnew\n{original}"
|
|
terms = manager.get_terms()
|
|
assert terms[0] == "brandnew"
|
|
assert len(terms) == MAX_TERMS_YIELDED
|
|
|
|
|
|
def test_oversized_terms_file_without_complete_line_yields_nothing(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A huge file whose first line never ends supplies no terms."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
(tmp_path / TERMS_FILENAME).write_bytes(b"x" * (MAX_TERMS_FILE_BYTES + 10))
|
|
|
|
assert manager.get_terms() == []
|
|
|
|
|
|
def test_pr_767_dictionary_configuration_keys_and_contract_are_preserved(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Existing enabled, file_path, and max_words settings remain effective."""
|
|
configured_path = tmp_path / TERMS_FILENAME
|
|
configured_path.write_text("VocaLinux\nPyGObject\n", encoding="utf-8")
|
|
config = FakeConfig(
|
|
{
|
|
"dictionary": {
|
|
"enabled": True,
|
|
"file_path": str(configured_path),
|
|
"max_words": 1,
|
|
}
|
|
}
|
|
)
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
|
|
assert TERMS_FILENAME == "dictionary.txt"
|
|
assert Path(DEFAULT_TERMS_PATH).name == TERMS_FILENAME
|
|
assert manager.terms_enabled()
|
|
assert manager.terms_path() == configured_path
|
|
assert manager.build_initial_prompt() == "VocaLinux"
|
|
|
|
|
|
def test_default_terms_path_follows_xdg_config_home(tmp_path: Path, monkeypatch) -> None:
|
|
"""A missing saved path uses the same XDG-aware root as corrections."""
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(tmp_path))
|
|
manager = CustomDictionaryManager(FakeConfig({"dictionary": {}}))
|
|
|
|
assert manager.terms_path() == tmp_path / TERMS_FILENAME
|
|
assert manager.corrections_path() == tmp_path / CORRECTIONS_FILENAME
|
|
|
|
|
|
def test_legacy_vocalinux_terms_root_keeps_corrections_at_config_dir(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Persisted ~/.config/vocalinux terms do not move the corrections file."""
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(tmp_path))
|
|
legacy_corrections = Path.home() / ".config" / "vocalinux" / CORRECTIONS_FILENAME
|
|
expanded_legacy = str(Path.home() / ".config" / "vocalinux" / TERMS_FILENAME)
|
|
assert legacy_corrections != tmp_path / CORRECTIONS_FILENAME
|
|
|
|
tilde_manager = CustomDictionaryManager(
|
|
FakeConfig({"dictionary": {"file_path": LEGACY_DEFAULT_TERMS_PATH}})
|
|
)
|
|
assert tilde_manager.corrections_path() == tmp_path / CORRECTIONS_FILENAME
|
|
|
|
expanded_manager = CustomDictionaryManager(
|
|
FakeConfig({"dictionary": {"file_path": expanded_legacy}})
|
|
)
|
|
assert expanded_manager.corrections_path() == tmp_path / CORRECTIONS_FILENAME
|
|
|
|
|
|
def test_explicit_legacy_terms_path_keeps_corrections_at_config_dir(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""An explicit leftover pre-XDG terms path cannot relocate corrections."""
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(tmp_path))
|
|
legacy_root = Path.home() / ".config" / "vocalinux"
|
|
expanded_legacy = str(legacy_root / TERMS_FILENAME)
|
|
assert legacy_root / CORRECTIONS_FILENAME != tmp_path / CORRECTIONS_FILENAME
|
|
|
|
tilde_manager = CustomDictionaryManager(
|
|
FakeConfig(
|
|
{
|
|
"dictionary": {
|
|
"file_path": LEGACY_DEFAULT_TERMS_PATH,
|
|
"file_path_explicit": True,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
assert tilde_manager.corrections_path() == tmp_path / CORRECTIONS_FILENAME
|
|
|
|
stamped = FakeConfig({"dictionary": {"file_path": expanded_legacy}})
|
|
stamped_manager = CustomDictionaryManager(stamped)
|
|
stamped_manager.terms_path_text()
|
|
assert stamped.get("dictionary", "file_path_explicit") is True
|
|
assert stamped_manager.corrections_path() == tmp_path / CORRECTIONS_FILENAME
|
|
|
|
|
|
def test_corrections_fall_back_from_terms_dir_when_primary_missing(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Leftover terms-adjacent corrections are read and copied to the standard file."""
|
|
fake_home = tmp_path / "home"
|
|
xdg_dir = tmp_path / "xdg-config"
|
|
legacy_root = fake_home / ".config" / "vocalinux"
|
|
legacy_root.mkdir(parents=True)
|
|
xdg_dir.mkdir()
|
|
monkeypatch.setenv("HOME", str(fake_home))
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(xdg_dir))
|
|
|
|
old_path = Path(LEGACY_DEFAULT_TERMS_PATH).expanduser().parent / CORRECTIONS_FILENAME
|
|
payload = {
|
|
"version": 1,
|
|
"corrections": [{"heard": "super base", "replacement": "Supabase"}],
|
|
}
|
|
old_path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
|
|
|
manager = CustomDictionaryManager(
|
|
FakeConfig(
|
|
{
|
|
"dictionary": {
|
|
"file_path": LEGACY_DEFAULT_TERMS_PATH,
|
|
"file_path_explicit": True,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
primary = xdg_dir / CORRECTIONS_FILENAME
|
|
assert manager.corrections_path() == primary
|
|
assert primary != old_path
|
|
assert not primary.exists()
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert primary.is_file()
|
|
old_path.unlink()
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
|
|
|
|
def test_corrections_fallback_survives_copy_failure(tmp_path: Path, monkeypatch) -> None:
|
|
"""A failed copy-once still returns leftover terms-adjacent corrections."""
|
|
terms_root = tmp_path / "legacy-root"
|
|
xdg_dir = tmp_path / "xdg-config"
|
|
terms_root.mkdir()
|
|
xdg_dir.mkdir()
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(xdg_dir))
|
|
old_path = terms_root / CORRECTIONS_FILENAME
|
|
payload = {
|
|
"version": 1,
|
|
"corrections": [{"heard": "super base", "replacement": "Supabase"}],
|
|
}
|
|
old_path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
|
|
|
manager = CustomDictionaryManager(
|
|
FakeConfig(
|
|
{
|
|
"dictionary": {
|
|
"file_path": str(terms_root / TERMS_FILENAME),
|
|
"file_path_explicit": True,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
primary = xdg_dir / CORRECTIONS_FILENAME
|
|
|
|
def fail_replace(source: Path, destination: Path) -> None:
|
|
"""Simulate a filesystem failure during atomic replacement."""
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.os.replace", fail_replace)
|
|
|
|
assert manager.corrections_path() == primary
|
|
assert not primary.exists()
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert not primary.exists()
|
|
|
|
|
|
def test_corrections_stay_at_config_dir_with_arbitrary_terms_parent(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Corrections ignore the terms file location outside a vocalinux directory."""
|
|
xdg_dir = tmp_path / "xdg-config"
|
|
xdg_dir.mkdir()
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(xdg_dir))
|
|
terms = tmp_path / "my-terms.txt"
|
|
manager = CustomDictionaryManager(
|
|
FakeConfig(
|
|
{
|
|
"dictionary": {
|
|
"file_path": str(terms),
|
|
"file_path_explicit": True,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
assert manager.corrections_path() == xdg_dir / CORRECTIONS_FILENAME
|
|
assert manager.corrections_path() != tmp_path / CORRECTIONS_FILENAME
|
|
|
|
|
|
def test_transient_terms_override_keeps_corrections_at_config_dir(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A --dictionary-file session override must not move the corrections file."""
|
|
xdg_dir = tmp_path / "xdg-config"
|
|
xdg_dir.mkdir()
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(xdg_dir))
|
|
override = tmp_path / "override.txt"
|
|
override.write_text("Override\n", encoding="utf-8")
|
|
|
|
manager = CustomDictionaryManager(FakeConfig(), str(override))
|
|
|
|
assert manager.corrections_path() == xdg_dir / CORRECTIONS_FILENAME
|
|
assert manager.corrections_path() != tmp_path / CORRECTIONS_FILENAME
|
|
|
|
|
|
def test_transient_terms_override_does_not_import_beside_corrections(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A --dictionary-file session must not import the override's neighbor file."""
|
|
xdg_dir = tmp_path / "xdg-config"
|
|
override_dir = tmp_path / "elsewhere"
|
|
xdg_dir.mkdir()
|
|
override_dir.mkdir()
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(xdg_dir))
|
|
override = override_dir / "terms.txt"
|
|
override.write_text("Override\n", encoding="utf-8")
|
|
payload = {
|
|
"version": 1,
|
|
"corrections": [{"heard": "super base", "replacement": "Supabase"}],
|
|
}
|
|
(override_dir / CORRECTIONS_FILENAME).write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
|
|
|
manager = CustomDictionaryManager(FakeConfig(), str(override))
|
|
|
|
assert manager.get_corrections() == []
|
|
assert manager.get_corrections_for_edit() == []
|
|
assert not (xdg_dir / CORRECTIONS_FILENAME).exists()
|
|
|
|
|
|
def test_historical_legacy_looking_path_without_explicit_marker_is_not_migrated(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A leftover pre-XDG path stays put when XDG_CONFIG_HOME points elsewhere."""
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(tmp_path))
|
|
xdg_path = str(tmp_path / TERMS_FILENAME)
|
|
tilde_path = LEGACY_DEFAULT_TERMS_PATH
|
|
expanded_legacy = str(Path.home() / ".config" / "vocalinux" / TERMS_FILENAME)
|
|
assert expanded_legacy != xdg_path
|
|
|
|
config = FakeConfig({"dictionary": {"file_path": tilde_path}})
|
|
manager = CustomDictionaryManager(config)
|
|
|
|
assert manager.terms_path_text() == tilde_path
|
|
assert manager.terms_path() == Path(tilde_path).expanduser()
|
|
assert config.get("dictionary", "file_path") == tilde_path
|
|
assert config.get("dictionary", "file_path") != xdg_path
|
|
assert config.get("dictionary", "file_path_explicit") is True
|
|
|
|
failing = FakeConfig(
|
|
{"dictionary": {"file_path": tilde_path}},
|
|
save_result=False,
|
|
)
|
|
failing_manager = CustomDictionaryManager(failing)
|
|
assert failing_manager.terms_path_text() == tilde_path
|
|
assert failing.get("dictionary", "file_path") == tilde_path
|
|
assert failing.get("dictionary", "file_path") != xdg_path
|
|
assert not failing.get("dictionary", "file_path_explicit", False)
|
|
|
|
expanded_config = FakeConfig({"dictionary": {"file_path": expanded_legacy}})
|
|
expanded_manager = CustomDictionaryManager(expanded_config)
|
|
assert expanded_manager.terms_path_text() == expanded_legacy
|
|
assert expanded_config.get("dictionary", "file_path") == expanded_legacy
|
|
assert expanded_config.get("dictionary", "file_path") != xdg_path
|
|
assert expanded_config.get("dictionary", "file_path_explicit") is True
|
|
|
|
custom = str(tmp_path / "custom-terms.txt")
|
|
custom_config = FakeConfig({"dictionary": {"file_path": custom}})
|
|
custom_manager = CustomDictionaryManager(custom_config)
|
|
assert custom_manager.terms_path_text() == custom
|
|
assert custom_config.get("dictionary", "file_path") == custom
|
|
|
|
|
|
def test_explicit_legacy_terms_path_is_not_migrated(tmp_path: Path, monkeypatch) -> None:
|
|
"""set_terms_path of the pre-XDG string is kept when XDG points elsewhere."""
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.config_dir", lambda: str(tmp_path))
|
|
xdg_path = str(tmp_path / TERMS_FILENAME)
|
|
expanded_legacy = str(Path.home() / ".config" / "vocalinux" / TERMS_FILENAME)
|
|
assert expanded_legacy != xdg_path
|
|
|
|
tilde_config = FakeConfig({"dictionary": {"file_path": xdg_path}})
|
|
tilde_manager = CustomDictionaryManager(tilde_config)
|
|
assert tilde_manager.set_terms_path(LEGACY_DEFAULT_TERMS_PATH)
|
|
assert tilde_config.get("dictionary", "file_path") == LEGACY_DEFAULT_TERMS_PATH
|
|
assert tilde_config.get("dictionary", "file_path_explicit") is True
|
|
assert tilde_manager.terms_path_text() == LEGACY_DEFAULT_TERMS_PATH
|
|
assert tilde_config.get("dictionary", "file_path") == LEGACY_DEFAULT_TERMS_PATH
|
|
|
|
expanded_config = FakeConfig({"dictionary": {"file_path": xdg_path}})
|
|
expanded_manager = CustomDictionaryManager(expanded_config)
|
|
assert expanded_manager.set_terms_path(expanded_legacy)
|
|
assert expanded_config.get("dictionary", "file_path") == expanded_legacy
|
|
assert expanded_config.get("dictionary", "file_path_explicit") is True
|
|
assert expanded_manager.terms_path_text() == expanded_legacy
|
|
assert expanded_config.get("dictionary", "file_path") == expanded_legacy
|
|
|
|
|
|
def test_invalid_or_unreadable_configured_terms_path_is_not_persisted(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Invalid paths are harmless and leave the previous configured path intact."""
|
|
previous_path = str(tmp_path / TERMS_FILENAME)
|
|
config = FakeConfig({"dictionary": {"file_path": previous_path}})
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
|
|
assert not manager.set_terms_path("~vocalinux-user-does-not-exist/dictionary.txt")
|
|
assert config.get("dictionary", "file_path") == previous_path
|
|
|
|
assert not manager.set_terms_path(str(tmp_path))
|
|
assert config.get("dictionary", "file_path") == previous_path
|
|
|
|
|
|
def test_invalid_saved_terms_path_is_safe_to_read_and_report(tmp_path: Path, monkeypatch) -> None:
|
|
"""A pre-existing unresolved path cannot crash recognition or Settings."""
|
|
config = FakeConfig(
|
|
{
|
|
"dictionary": {
|
|
"enabled": True,
|
|
"file_path": "~vocalinux-user-does-not-exist/dictionary.txt",
|
|
}
|
|
}
|
|
)
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
|
|
assert manager.terms_path() is None
|
|
assert manager.get_terms() == []
|
|
assert manager.build_initial_prompt() is None
|
|
assert manager.terms_status() == "Configured terms path is invalid or cannot be expanded."
|
|
|
|
|
|
def test_terms_path_save_failure_preserves_the_previous_configured_path(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A failed config save never claims a new path persisted."""
|
|
previous_path = str(tmp_path / TERMS_FILENAME)
|
|
config = FakeConfig({"dictionary": {"file_path": previous_path}}, save_result=False)
|
|
manager = manager_at(tmp_path, monkeypatch, config)
|
|
|
|
assert not manager.set_terms_path(str(tmp_path / "new-terms.txt"))
|
|
assert config.get("dictionary", "file_path") == previous_path
|
|
assert not config.get("dictionary", "file_path_explicit", False)
|
|
|
|
|
|
def test_invalid_transient_terms_path_does_not_crash(tmp_path: Path, monkeypatch) -> None:
|
|
"""An unresolved CLI tilde path disables only its session's terms safely."""
|
|
manager_at(tmp_path, monkeypatch)
|
|
manager = CustomDictionaryManager(FakeConfig(), "~vocalinux-user-does-not-exist/dictionary.txt")
|
|
|
|
assert manager.terms_enabled()
|
|
assert manager.terms_path() is None
|
|
assert manager.get_terms() == []
|
|
assert manager.terms_status() == "Configured terms path is invalid or cannot be expanded."
|
|
|
|
|
|
def test_atomic_save_failure_preserves_existing_terms_file(tmp_path: Path, monkeypatch) -> None:
|
|
"""A failed replacement leaves the scanner-visible file intact."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
terms_file = tmp_path / TERMS_FILENAME
|
|
terms_file.write_text("Existing\n", encoding="utf-8")
|
|
|
|
def fail_replace(source: Path, destination: Path) -> None:
|
|
"""Simulate a filesystem failure during atomic replacement."""
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr("vocalinux.custom_dictionary.os.replace", fail_replace)
|
|
|
|
assert not manager.save_terms(["New"])
|
|
assert terms_file.read_text(encoding="utf-8") == "Existing\n"
|
|
|
|
|
|
def test_file_edits_preserve_existing_permissions(tmp_path: Path, monkeypatch) -> None:
|
|
"""Replacing either dictionary file keeps the mode the owner set on it."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
|
|
terms_file = tmp_path / TERMS_FILENAME
|
|
terms_file.write_text("VocaLinux\n", encoding="utf-8")
|
|
terms_file.chmod(0o600)
|
|
assert manager.save_terms(["Supabase"])
|
|
assert stat.S_IMODE(terms_file.stat().st_mode) == 0o600
|
|
|
|
corrections_file = tmp_path / CORRECTIONS_FILENAME
|
|
corrections_file.write_text('{"version": 1, "corrections": []}\n', encoding="utf-8")
|
|
corrections_file.chmod(0o640)
|
|
assert manager.save_corrections([{"heard": "a", "replacement": "b"}])
|
|
assert stat.S_IMODE(corrections_file.stat().st_mode) == 0o640
|
|
|
|
|
|
def test_dictionary_file_argument_is_available_for_a_session_override(monkeypatch) -> None:
|
|
"""The retained CLI option accepts a terms file without changing config."""
|
|
monkeypatch.setattr("sys.argv", ["vocalinux", "--dictionary-file", "/tmp/terms.txt"])
|
|
|
|
assert parse_arguments().dictionary_file == "/tmp/terms.txt"
|
|
|
|
|
|
def test_corrections_use_versioned_json_and_live_reload(tmp_path: Path, monkeypatch) -> None:
|
|
"""Structured corrections use exact replacement spelling on every read."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
assert manager.save_corrections([{"heard": "super base", "replacement": "Supabase"}])
|
|
stored = json.loads((tmp_path / CORRECTIONS_FILENAME).read_text(encoding="utf-8"))
|
|
assert stored == {
|
|
"version": 1,
|
|
"corrections": [{"heard": "super base", "replacement": "Supabase"}],
|
|
}
|
|
assert manager.get_corrections_for_edit() == [
|
|
{"heard": "super base", "replacement": "Supabase"}
|
|
]
|
|
assert manager.apply_corrections("super base is useful") == "Supabase is useful"
|
|
|
|
assert manager.save_corrections([{"heard": "pie object", "replacement": "PyGObject"}])
|
|
assert manager.apply_corrections("pie object") == "PyGObject"
|
|
|
|
|
|
def test_corrections_are_literal_longest_first_and_unicode_safe() -> None:
|
|
"""Matching is whole-phrase, case-insensitive, and never maps through lower()."""
|
|
entries = normalize_corrections(
|
|
[
|
|
{"heard": "super", "replacement": "short"},
|
|
{"heard": "super base", "replacement": "Supabase"},
|
|
{"heard": "C++", "replacement": "C plus plus"},
|
|
{"heard": "i", "replacement": "letter"},
|
|
]
|
|
)
|
|
|
|
assert apply_corrections("SUPER BASE and super", entries) == "Supabase and short"
|
|
assert apply_corrections("C++ is not C++foo", entries) == "C plus plus is not C++foo"
|
|
assert apply_corrections("İ", entries) == "letter"
|
|
assert apply_corrections("e\u0301clair", [{"heard": "e", "replacement": "E"}]) == "éclair"
|
|
|
|
|
|
def test_invalid_corrections_file_fails_closed(tmp_path: Path, monkeypatch) -> None:
|
|
"""Malformed JSON never aborts a segment or invents replacements."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
(tmp_path / CORRECTIONS_FILENAME).write_text("{not json", encoding="utf-8")
|
|
|
|
assert manager.get_corrections() == []
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert manager.apply_corrections("super base") == "super base"
|
|
|
|
|
|
def test_partially_invalid_corrections_cannot_be_destructively_edited(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""UI write-back is refused when runtime reads had to filter source entries."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
original = {
|
|
"version": 1,
|
|
"corrections": [
|
|
{"heard": "super base", "replacement": "Supabase"},
|
|
{"heard": "missing replacement"},
|
|
],
|
|
}
|
|
path.write_text(json.dumps(original), encoding="utf-8")
|
|
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert json.loads(path.read_text(encoding="utf-8")) == original
|
|
|
|
|
|
def test_corrections_with_extra_fields_cannot_be_destructively_edited(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Unknown fields still apply at runtime but block a UI rewrite that would drop them."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
original = {
|
|
"version": 1,
|
|
"corrections": [
|
|
{
|
|
"heard": "super base",
|
|
"replacement": "Supabase",
|
|
"note": "scanner annotation",
|
|
}
|
|
],
|
|
}
|
|
path.write_text(json.dumps(original), encoding="utf-8")
|
|
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert manager.apply_corrections("super base is useful") == "Supabase is useful"
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert json.loads(path.read_text(encoding="utf-8")) == original
|
|
|
|
|
|
def test_corrections_with_top_level_metadata_cannot_be_destructively_edited(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Unknown top-level keys apply at runtime but block a UI rewrite that would drop them."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
original = {
|
|
"version": 1,
|
|
"metadata": {"author": "scanner"},
|
|
"corrections": [{"heard": "super base", "replacement": "Supabase"}],
|
|
}
|
|
path.write_text(json.dumps(original), encoding="utf-8")
|
|
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert manager.apply_corrections("super base is useful") == "Supabase is useful"
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert json.loads(path.read_text(encoding="utf-8")) == original
|
|
|
|
|
|
def test_corrections_with_whitespace_cannot_be_destructively_edited(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Leading or trailing whitespace still applies at runtime but blocks a UI rewrite."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
original = {
|
|
"version": 1,
|
|
"corrections": [{"heard": " super base ", "replacement": " Supabase "}],
|
|
}
|
|
path.write_text(json.dumps(original), encoding="utf-8")
|
|
|
|
assert manager.get_corrections() == [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert manager.apply_corrections("super base is useful") == "Supabase is useful"
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert json.loads(path.read_text(encoding="utf-8")) == original
|
|
|
|
|
|
def test_corrections_with_decomposed_unicode_cannot_be_destructively_edited(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""NFD heard values still apply at runtime but block a UI rewrite to NFC."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
original = {
|
|
"version": 1,
|
|
"corrections": [{"heard": "e\u0301clair", "replacement": "éclair"}],
|
|
}
|
|
path.write_text(json.dumps(original, ensure_ascii=False), encoding="utf-8")
|
|
|
|
assert manager.get_corrections() == [{"heard": "éclair", "replacement": "éclair"}]
|
|
assert manager.apply_corrections("e\u0301clair") == "éclair"
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert json.loads(path.read_text(encoding="utf-8")) == original
|
|
|
|
|
|
def test_corrections_with_duplicate_keys_cannot_be_destructively_edited(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Duplicate JSON keys still apply at runtime but block a UI rewrite that would drop them."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
cases = [
|
|
(
|
|
"{\n"
|
|
' "version": 1,\n'
|
|
' "corrections": [{"heard": "pie object", "replacement": "PyGObject"}],\n'
|
|
' "corrections": [{"heard": "super base", "replacement": "Supabase"}]\n'
|
|
"}\n",
|
|
[{"heard": "super base", "replacement": "Supabase"}],
|
|
),
|
|
(
|
|
"{\n"
|
|
' "version": 1,\n'
|
|
' "corrections": ['
|
|
'{"heard": "super base", "replacement": "Wrong", "replacement": "Supabase"}'
|
|
"]\n}\n",
|
|
[{"heard": "super base", "replacement": "Supabase"}],
|
|
),
|
|
]
|
|
for original, expected_runtime in cases:
|
|
path.write_text(original, encoding="utf-8")
|
|
before = path.read_bytes()
|
|
|
|
assert manager.get_corrections() == expected_runtime
|
|
assert manager.apply_corrections("super base is useful") == "Supabase is useful"
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert path.read_bytes() == before
|
|
|
|
|
|
def test_invalid_correction_update_does_not_wipe_existing_entry(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A too-long replacement must not delete the previous same-heard correction."""
|
|
import importlib
|
|
import sys
|
|
|
|
import vocalinux.ui as ui_pkg
|
|
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
original = [{"heard": "super base", "replacement": "Supabase"}]
|
|
assert manager.save_corrections(original)
|
|
corrections_path = tmp_path / CORRECTIONS_FILENAME
|
|
before = corrections_path.read_text(encoding="utf-8")
|
|
|
|
repository = sys.modules["gi.repository"]
|
|
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
|
|
saved = sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
try:
|
|
with patch.object(repository, "Gtk", MagicMock(**bases)):
|
|
settings_dialog = importlib.import_module("vocalinux.ui.settings_dialog")
|
|
finally:
|
|
sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
if saved is not None:
|
|
sys.modules["vocalinux.ui.settings_dialog"] = saved
|
|
ui_pkg.settings_dialog = saved
|
|
else:
|
|
ui_pkg.__dict__.pop("settings_dialog", None)
|
|
|
|
dialog = Mock()
|
|
dialog._initializing = False
|
|
dialog._applying_settings = False
|
|
dialog._dictionary_available.return_value = True
|
|
dialog.dictionary_manager = manager
|
|
dialog.dictionary_heard_entry.get_text.return_value = "super base"
|
|
dialog.dictionary_replacement_entry.get_text.return_value = "x" * (
|
|
MAX_CORRECTION_CHARACTERS + 1
|
|
)
|
|
|
|
settings_dialog.SettingsDialog._on_dictionary_add_correction(dialog, None)
|
|
|
|
assert corrections_path.read_text(encoding="utf-8") == before
|
|
assert manager.get_corrections() == original
|
|
dialog.dictionary_heard_entry.set_text.assert_not_called()
|
|
dialog.dictionary_replacement_entry.set_text.assert_not_called()
|
|
dialog.dictionary_feedback_label.set_text.assert_called_once_with(
|
|
"That correction is not valid for the file."
|
|
)
|
|
|
|
|
|
def test_nfd_correction_update_replaces_nfc_entry(tmp_path: Path, monkeypatch) -> None:
|
|
"""A decomposed heard phrase must update the existing NFC correction."""
|
|
import importlib
|
|
import sys
|
|
|
|
import vocalinux.ui as ui_pkg
|
|
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
original = [{"heard": "éclair", "replacement": "OLD"}]
|
|
assert manager.save_corrections(original)
|
|
corrections_path = tmp_path / CORRECTIONS_FILENAME
|
|
|
|
repository = sys.modules["gi.repository"]
|
|
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
|
|
saved = sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
try:
|
|
with patch.object(repository, "Gtk", MagicMock(**bases)):
|
|
settings_dialog = importlib.import_module("vocalinux.ui.settings_dialog")
|
|
finally:
|
|
sys.modules.pop("vocalinux.ui.settings_dialog", None)
|
|
if saved is not None:
|
|
sys.modules["vocalinux.ui.settings_dialog"] = saved
|
|
ui_pkg.settings_dialog = saved
|
|
else:
|
|
ui_pkg.__dict__.pop("settings_dialog", None)
|
|
|
|
dialog = Mock()
|
|
dialog._initializing = False
|
|
dialog._applying_settings = False
|
|
dialog._dictionary_available.return_value = True
|
|
dialog.dictionary_manager = manager
|
|
dialog.dictionary_heard_entry.get_text.return_value = "e\u0301clair"
|
|
dialog.dictionary_replacement_entry.get_text.return_value = "NEW"
|
|
|
|
settings_dialog.SettingsDialog._on_dictionary_add_correction(dialog, None)
|
|
|
|
stored = json.loads(corrections_path.read_text(encoding="utf-8"))
|
|
assert stored == {
|
|
"version": 1,
|
|
"corrections": [{"heard": "éclair", "replacement": "NEW"}],
|
|
}
|
|
assert manager.get_corrections() == [{"heard": "éclair", "replacement": "NEW"}]
|
|
dialog.dictionary_feedback_label.set_text.assert_called_once_with("Correction updated.")
|
|
|
|
|
|
def test_non_integer_corrections_version_fails_closed(tmp_path: Path, monkeypatch) -> None:
|
|
"""Boolean, float, and string versions must not be treated as schema 1."""
|
|
manager = manager_at(tmp_path, monkeypatch)
|
|
path = tmp_path / CORRECTIONS_FILENAME
|
|
cases = (
|
|
'{"version": true, "corrections": [{"heard": "super base", "replacement": "Supabase"}]}\n',
|
|
'{"version": 1.0, "corrections": [{"heard": "super base", "replacement": "Supabase"}]}\n',
|
|
'{"version": "1", "corrections": [{"heard": "super base", "replacement": "Supabase"}]}\n',
|
|
'{"version": 1e0, "corrections": [{"heard": "super base", "replacement": "Supabase"}]}\n',
|
|
)
|
|
for original in cases:
|
|
path.write_text(original, encoding="utf-8")
|
|
before = path.read_bytes()
|
|
|
|
assert manager.get_corrections() == []
|
|
assert manager.get_corrections_for_edit() is None
|
|
assert path.read_bytes() == before
|
|
|
|
|
|
def test_invalid_terms_file_is_ignored_and_explained(tmp_path: Path, monkeypatch) -> None:
|
|
"""An invalid scanner file leaves prompt bias empty with a clear status."""
|
|
manager = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
(tmp_path / TERMS_FILENAME).write_bytes(b"\xff\xfe")
|
|
|
|
assert manager.build_initial_prompt() is None
|
|
assert manager.terms_status() == "Terms file is not valid UTF-8."
|
|
|
|
|
|
def test_whisper_receives_the_live_custom_terms_prompt(tmp_path: Path, monkeypatch) -> None:
|
|
"""Whisper gets the current file contents as initial_prompt at transcription time."""
|
|
dictionary = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
(tmp_path / TERMS_FILENAME).write_text("VocaLinux\n", encoding="utf-8")
|
|
manager = recognition_manager("whisper", dictionary)
|
|
manager.model = MagicMock()
|
|
manager.model.transcribe.return_value = {"text": "ok"}
|
|
manager.model.device = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"numpy": mock_numpy(), "torch": MagicMock()}):
|
|
assert manager._transcribe_with_whisper([b"\x00\x00"]) == "ok"
|
|
|
|
assert manager.model.transcribe.call_args.kwargs["initial_prompt"] == "VocaLinux"
|
|
|
|
|
|
def test_faster_whisper_receives_the_live_custom_terms_prompt(tmp_path: Path, monkeypatch) -> None:
|
|
"""Faster Whisper receives the same live vocabulary bias as OpenAI Whisper."""
|
|
dictionary = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
(tmp_path / TERMS_FILENAME).write_text("VocaLinux\n", encoding="utf-8")
|
|
manager = object.__new__(SpeechRecognitionManager)
|
|
manager.dictionary_manager = dictionary
|
|
manager._faster_whisper_engine = MagicMock()
|
|
manager._faster_whisper_engine.is_ready.return_value = True
|
|
manager._faster_whisper_engine.transcribe.return_value = "ok"
|
|
|
|
assert manager._transcribe_with_faster_whisper([b"\x00\x00"]) == "ok"
|
|
manager._faster_whisper_engine.transcribe.assert_called_once_with(
|
|
[b"\x00\x00"], language="auto", initial_prompt="VocaLinux"
|
|
)
|
|
|
|
|
|
def test_whispercpp_composes_and_clears_live_terms_prompt(tmp_path: Path, monkeypatch) -> None:
|
|
"""whisper.cpp keeps Advanced context and explicitly clears a stale prompt."""
|
|
config = FakeConfig({"dictionary": {"enabled": True}})
|
|
dictionary = manager_at(tmp_path, monkeypatch, config)
|
|
terms_file = tmp_path / TERMS_FILENAME
|
|
terms_file.write_text("VocaLinux\n", encoding="utf-8")
|
|
manager = recognition_manager("whisper_cpp", dictionary, advanced_prompt="Explicit context")
|
|
manager._model_lock = threading.Lock()
|
|
manager.model = MagicMock()
|
|
manager.model.transcribe.return_value = [MagicMock(text="ok")]
|
|
|
|
with patch.dict("sys.modules", {"numpy": mock_numpy()}):
|
|
assert manager._transcribe_with_whispercpp([b"\x00\x00"]) == "ok"
|
|
config.values["dictionary"]["enabled"] = False
|
|
assert manager._transcribe_with_whispercpp([b"\x00\x00"]) == "ok"
|
|
manager.whispercpp_initial_prompt = ""
|
|
assert manager._transcribe_with_whispercpp([b"\x00\x00"]) == "ok"
|
|
|
|
prompts = [call.kwargs["initial_prompt"] for call in manager.model.transcribe.call_args_list]
|
|
assert prompts == ["Explicit context VocaLinux", "Explicit context", ""]
|
|
|
|
|
|
def test_vosk_warns_once_for_bias_while_corrections_remain_available(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""VOSK rejects vocabulary bias only; its transcript can still be corrected."""
|
|
dictionary = manager_at(tmp_path, monkeypatch, FakeConfig({"dictionary": {"enabled": True}}))
|
|
(tmp_path / TERMS_FILENAME).write_text("VocaLinux\n", encoding="utf-8")
|
|
manager = recognition_manager("vosk", dictionary)
|
|
manager._get_vosk_model_path = MagicMock(return_value="/models/vosk")
|
|
vosk = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"vosk": vosk}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.os.path.exists", return_value=True
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.logger.warning"
|
|
) as warning:
|
|
manager._init_vosk()
|
|
manager._init_vosk()
|
|
|
|
warning.assert_called_once_with(
|
|
"Custom terms are ignored by VOSK; transcript corrections still apply."
|
|
)
|
|
|
|
|
|
def test_whispercpp_prompt_composes_advanced_and_terms() -> None:
|
|
"""Advanced prompt remains a prefix and an emptied terms file clears the value."""
|
|
recognition_manager = object.__new__(SpeechRecognitionManager)
|
|
recognition_manager.whispercpp_initial_prompt = "Write product names accurately."
|
|
recognition_manager.dictionary_manager = type(
|
|
"Dictionary", (), {"build_initial_prompt": lambda self: "VocaLinux"}
|
|
)()
|
|
|
|
assert (
|
|
recognition_manager._get_whispercpp_prompt() == "Write product names accurately. VocaLinux"
|
|
)
|
|
recognition_manager.dictionary_manager = type(
|
|
"Dictionary", (), {"build_initial_prompt": lambda self: None}
|
|
)()
|
|
assert recognition_manager._get_whispercpp_prompt() == "Write product names accurately."
|
|
|
|
|
|
def test_corrections_run_before_voice_commands() -> None:
|
|
"""A correction changes raw text before the command processor can act on it."""
|
|
|
|
class Recognizer:
|
|
"""Minimal VOSK recognizer."""
|
|
|
|
def AcceptWaveform(self, data: bytes) -> None:
|
|
"""Accept a fake audio chunk."""
|
|
|
|
def FinalResult(self) -> str:
|
|
"""Return the raw model text."""
|
|
return '{"text": "delete that"}'
|
|
|
|
recognition_manager = object.__new__(SpeechRecognitionManager)
|
|
recognition_manager.engine = "vosk"
|
|
recognition_manager.recognizer = Recognizer()
|
|
recognition_manager._model_lock = threading.Lock()
|
|
recognition_manager._voice_commands_enabled = True
|
|
recognition_manager.dictionary_manager = type(
|
|
"Dictionary", (), {"apply_corrections": lambda self, text: "delete note"}
|
|
)()
|
|
received: list[str] = []
|
|
recognition_manager.command_processor = type(
|
|
"Commands", (), {"process_text": lambda self, text: (text, [])}
|
|
)()
|
|
recognition_manager.text_callbacks = [received.append]
|
|
recognition_manager.action_callbacks = []
|
|
recognition_manager.segment_callbacks = []
|
|
|
|
recognition_manager._process_audio_buffer([b"audio"])
|
|
|
|
assert received == ["delete note"]
|
|
|
|
|
|
def test_correction_stage_is_shared_by_every_engine() -> None:
|
|
"""The common post-transcription stage is available to local and remote engines."""
|
|
for engine in (
|
|
"vosk",
|
|
"whisper",
|
|
"whisper_cpp",
|
|
"faster_whisper",
|
|
"parakeet",
|
|
"remote_api",
|
|
):
|
|
manager = object.__new__(SpeechRecognitionManager)
|
|
manager.engine = engine
|
|
manager.dictionary_manager = type(
|
|
"Dictionary", (), {"apply_corrections": lambda self, text: "Supabase"}
|
|
)()
|
|
|
|
assert manager._apply_dictionary_corrections("super base") == "Supabase"
|