Files
voculinux/tests/test_legacy_import.py
T
xavierk 2a76793109
CI / security (push) Successful in 1m13s
CI / lint (push) Successful in 24s
CI / ai-review (push) Skipped
fix(app): detect CUDA outside PATH, warn on IBus pin on niri, query Gitea for updates, repoint migrated dictionary (#36)
CUDA toolkit detection also checks CUDA_HOME, CUDA_PATH and /usr/local/cuda*, and the backend order names only backends the installed build compiled in. An explicit IBus pin on niri is honoured with one warning. The update checker queries this fork's Gitea releases and packaged installs make no network request. The one-time legacy import repoints a migrated dictionary path at the copied file.
2026-11-06 14:38:43 +00:00

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"