"""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"