Files
voculinux/tests/test_legacy_import.py
T
xavierk 15a527beb1 fix(legacy): repoint a stored dictionary path even when no file was copied
A stored dictionary path to an importer-owned legacy file was rewritten
only when this run copied that file. A user who never created a legacy
dictionary, or who already has a new dictionary.txt, kept the legacy path,
and the first edit was written back into the legacy directory. The path is
now rewritten to the file's new location whenever it names a payload file,
copied this run or not. Paths outside the legacy payload are kept as stored.

Refs #18
2026-11-06 20:02:44 +05:30

280 lines
12 KiB
Python

"""Ticket #18: one-time legacy Vocalinux import into the Voculinux locations.
Seams under test (all offline, no hardware):
- A fresh run with a legacy install present imports ``config.json``, the
custom dictionary, and the portal restore token exactly once and logs it;
a second run imports nothing.
- A fresh run with an existing new configuration never imports, merges, or
writes to the legacy location.
- A fresh run with no legacy install starts from defaults (nothing imported).
- Models are not migrated.
"""
import json
import os
from pathlib import Path
import pytest
from vocalinux import legacy_import
from vocalinux.legacy_import import import_legacy_once
def _isolate(monkeypatch, tmp_path: Path) -> tuple[Path, Path]:
"""Point XDG roots at scratch dirs; return (config_root, data_root)."""
config_root = tmp_path / "config"
data_root = tmp_path / "data"
monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root))
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
return config_root, data_root
def _seed_legacy(config_root: Path, data_root: Path) -> dict[str, Path]:
"""Create a legacy install's payload; return the created files."""
legacy_config = config_root / "vocalinux"
legacy_data = data_root / "vocalinux"
legacy_config.mkdir(parents=True)
legacy_data.mkdir(parents=True)
created = {}
for name, content in (
("config.json", '{"general": {"first_run": false}}'),
("dictionary.txt", "Supabase\n"),
("custom-dictionary-corrections.json", json.dumps({"version": 1, "corrections": []})),
):
target = legacy_config / name
target.write_text(content, encoding="utf-8")
created[name] = target
token = legacy_data / "remote_desktop_restore_token"
token.write_text("legacy-token\n", encoding="utf-8")
created["remote_desktop_restore_token"] = token
return created
def test_fresh_run_with_legacy_install_imports_once(monkeypatch, tmp_path, caplog) -> None:
"""Legacy config, dictionary, and restore token copy over exactly once."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
_seed_legacy(config_root, data_root)
with caplog.at_level("INFO", logger="vocalinux.legacy_import"):
imported = import_legacy_once()
assert len(imported) == 4
assert (
config_root / "voculinux" / "config.json"
).read_text() == '{"general": {"first_run": false}}'
assert (config_root / "voculinux" / "dictionary.txt").read_text() == "Supabase\n"
assert json.loads(
(config_root / "voculinux" / "custom-dictionary-corrections.json").read_text()
) == {
"version": 1,
"corrections": [],
}
assert (
data_root / "voculinux" / "remote_desktop_restore_token"
).read_text() == "legacy-token\n"
assert any("Legacy Vocalinux import complete" in r.message for r in caplog.records)
# A second run imports nothing.
assert import_legacy_once() == []
def test_existing_new_config_never_imports_or_touches_legacy(monkeypatch, tmp_path) -> None:
"""An existing new configuration always wins: no merge, no write-back."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
seeded = _seed_legacy(config_root, data_root)
before = {name: path.read_bytes() for name, path in seeded.items()}
new_config = config_root / "voculinux"
new_config.mkdir(parents=True)
(new_config / "config.json").write_text('{"general": {"first_run": true}}', encoding="utf-8")
assert import_legacy_once() == []
assert (new_config / "config.json").read_text() == '{"general": {"first_run": true}}'
assert not (new_config / "dictionary.txt").exists()
assert not (data_root / "voculinux").exists()
for name, path in seeded.items():
assert path.read_bytes() == before[name], name
def test_no_legacy_install_starts_from_defaults(monkeypatch, tmp_path) -> None:
"""No legacy directories means nothing is imported."""
_isolate(monkeypatch, tmp_path)
assert import_legacy_once() == []
def test_models_are_not_migrated(monkeypatch, tmp_path) -> None:
"""A legacy models directory is left alone; rung files re-download."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
_seed_legacy(config_root, data_root)
legacy_models = data_root / "vocalinux" / "models"
legacy_models.mkdir(parents=True)
(legacy_models / "ggml-large-v3-turbo-q8_0.bin").write_bytes(b"fake-model")
imported = import_legacy_once()
assert not (data_root / "voculinux" / "models").exists()
assert {Path(p).name for p in imported} == {
"config.json",
"dictionary.txt",
"custom-dictionary-corrections.json",
"remote_desktop_restore_token",
}
def test_partial_legacy_payload_imports_what_exists(monkeypatch, tmp_path) -> None:
"""A legacy install with only a config still imports just that file."""
config_root, _data_root = _isolate(monkeypatch, tmp_path)
legacy_config = config_root / "vocalinux"
legacy_config.mkdir(parents=True)
(legacy_config / "config.json").write_text("{}", encoding="utf-8")
imported = import_legacy_once()
assert imported == [str(config_root / "voculinux" / "config.json")]
assert import_legacy_once() == []
def test_new_dictionary_never_overwritten_by_legacy(monkeypatch, tmp_path) -> None:
"""A new dictionary file is never clobbered even when config is absent."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
_seed_legacy(config_root, data_root)
new_config = config_root / "voculinux"
new_config.mkdir(parents=True)
(new_config / "dictionary.txt").write_text("Mine\n", encoding="utf-8")
import_legacy_once()
# No new config.json exists, so the import ran — but the existing new
# dictionary file must survive untouched.
assert (new_config / "dictionary.txt").read_text() == "Mine\n"
assert legacy_import.CONFIG_FILENAME == "config.json"
#: A fixed old timestamp, so any write to the legacy file moves its mtime visibly.
_OLD_MTIME_NS = 1_000_000_000_000_000_000
def _import_with_stored_dictionary_path(
monkeypatch, config_root: Path, data_root: Path, stored_path: str
) -> Path:
"""Seed a legacy install whose config stores ``stored_path``, then import it.
Returns the legacy dictionary file. The ConfigManager paths are pointed at
the copied configuration, so nothing outside the scratch directories is read
or written.
"""
seeded = _seed_legacy(config_root, data_root)
seeded["config.json"].write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": stored_path}}),
encoding="utf-8",
)
import_legacy_once()
_point_config_manager_at(monkeypatch, config_root / "voculinux")
return seeded["dictionary.txt"]
def _point_config_manager_at(monkeypatch, new_config_dir: Path) -> None:
"""Make ConfigManager read and write only the given (scratch) configuration dir."""
from vocalinux.ui import config_manager
monkeypatch.setattr(config_manager, "CONFIG_DIR", str(new_config_dir))
monkeypatch.setattr(config_manager, "CONFIG_FILE", str(new_config_dir / "config.json"))
def _add_term_through_migrated_config(monkeypatch, new_config_dir: Path, term: str) -> None:
"""Add a dictionary term exactly as the app does, using the migrated configuration."""
_point_config_manager_at(monkeypatch, new_config_dir)
from vocalinux.custom_dictionary import CustomDictionaryManager
from vocalinux.ui.config_manager import ConfigManager
assert CustomDictionaryManager(ConfigManager()).add_term(term) is True
@pytest.mark.parametrize("form", ["absolute-legacy", "tilde-legacy"])
def test_migrated_dictionary_edit_never_writes_the_legacy_file(monkeypatch, tmp_path, form) -> None:
"""The copied config points the dictionary at the copy, so an edit leaves legacy alone."""
if form == "tilde-legacy":
# The tilde form names the legacy file only when XDG_CONFIG_HOME is ~/.config.
home = tmp_path / "home"
monkeypatch.setenv("HOME", str(home))
config_root = home / ".config"
monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root))
data_root = tmp_path / "data"
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
stored = "~/.config/vocalinux/dictionary.txt"
else:
config_root, data_root = _isolate(monkeypatch, tmp_path)
stored = str(config_root / "vocalinux" / "dictionary.txt")
legacy_terms = _import_with_stored_dictionary_path(monkeypatch, config_root, data_root, stored)
os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS))
legacy_bytes = legacy_terms.read_bytes()
legacy_mtime = legacy_terms.stat().st_mtime_ns
from vocalinux.custom_dictionary import CustomDictionaryManager
from vocalinux.ui.config_manager import ConfigManager
dictionary = CustomDictionaryManager(ConfigManager())
assert dictionary.add_term("Kubernetes") is True
assert legacy_terms.read_bytes() == legacy_bytes
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
new_terms = config_root / "voculinux" / "dictionary.txt"
assert "Kubernetes" in new_terms.read_text(encoding="utf-8")
def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_path) -> None:
"""A path the user chose outside the legacy directory is not rewritten."""
chosen = tmp_path / "shared" / "terms.txt"
chosen.parent.mkdir(parents=True)
chosen.write_text("Chosen\n", encoding="utf-8")
config_root, data_root = _isolate(monkeypatch, tmp_path)
_import_with_stored_dictionary_path(monkeypatch, config_root, data_root, str(chosen))
migrated = json.loads((config_root / "voculinux" / "config.json").read_text(encoding="utf-8"))
assert migrated["dictionary"]["file_path"] == str(chosen)
assert chosen.read_text(encoding="utf-8") == "Chosen\n"
def test_absent_legacy_dictionary_edit_lands_in_the_new_file(monkeypatch, tmp_path) -> None:
"""The default dictionary path was saved but the file was never created."""
config_root, _data_root = _isolate(monkeypatch, tmp_path)
legacy_config = config_root / "vocalinux"
legacy_config.mkdir(parents=True)
legacy_terms = legacy_config / "dictionary.txt"
(legacy_config / "config.json").write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
encoding="utf-8",
)
import_legacy_once()
_add_term_through_migrated_config(monkeypatch, config_root / "voculinux", "Kubernetes")
assert not legacy_terms.exists()
assert "Kubernetes" in (config_root / "voculinux" / "dictionary.txt").read_text(
encoding="utf-8"
)
def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch, tmp_path) -> None:
"""A new dictionary already present wins; the legacy file is never written."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
seeded = _seed_legacy(config_root, data_root)
legacy_terms = seeded["dictionary.txt"]
seeded["config.json"].write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
encoding="utf-8",
)
new_config_dir = config_root / "voculinux"
new_config_dir.mkdir(parents=True)
(new_config_dir / "dictionary.txt").write_text("Mine\n", encoding="utf-8")
os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS))
legacy_bytes = legacy_terms.read_bytes()
legacy_mtime = legacy_terms.stat().st_mtime_ns
import_legacy_once()
_add_term_through_migrated_config(monkeypatch, new_config_dir, "Kubernetes")
assert legacy_terms.read_bytes() == legacy_bytes
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"