diff --git a/src/vocalinux/legacy_import.py b/src/vocalinux/legacy_import.py index 1383874..466535f 100644 --- a/src/vocalinux/legacy_import.py +++ b/src/vocalinux/legacy_import.py @@ -9,9 +9,11 @@ always wins: no merging, no write-back to the legacy location. Exactly-once falls out of existence checks — a copied file is never copied again, and a run that already has new locations imports nothing. -A stored custom-dictionary path that names a legacy file copied in this run is -rewritten in the copied configuration to the new location, so edits never land -in the legacy file. Any other path (a location the user chose elsewhere) is kept. +A stored custom-dictionary path that names an importer-owned legacy file (one of +the payload files above) is rewritten in the copied configuration to that file's +new location, whether or not this run copied it. Edits then never land in the +legacy file. Any other path, including a user-chosen file elsewhere or another +file inside the legacy directory, is kept as stored. """ import json @@ -83,20 +85,20 @@ def _copy_once(src: str, dst: str) -> bool: return True -def _copied_destination(stored: str, copied: dict[str, str]) -> Optional[str]: - """Return the new location of the copied legacy file ``stored`` names, if any.""" +def _payload_destination(stored: str, payload: dict[str, str]) -> Optional[str]: + """Return the new location of the importer-owned legacy file ``stored`` names.""" try: target = os.path.realpath(os.path.expanduser(stored)) except (OSError, ValueError): return None - for source, destination in copied.items(): + for source, destination in payload.items(): if target == os.path.realpath(source): return destination return None -def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None: - """Rewrite the copied config's dictionary path when it names a copied legacy file. +def _repoint_dictionary_path(config_file: str, payload: dict[str, str]) -> None: + """Rewrite the copied config's dictionary path when it names a payload file. Only the new configuration copy is written; the legacy file is only read. """ @@ -112,7 +114,7 @@ def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None: stored = dictionary.get("file_path") if not isinstance(stored, str) or not stored.strip(): return - destination = _copied_destination(stored, copied) + destination = _payload_destination(stored, payload) if destination is None: return dictionary["file_path"] = destination @@ -146,24 +148,25 @@ def import_legacy_once() -> list[str]: return [] imported: list[str] = [] - copied: dict[str, str] = {} + # Every importer-owned legacy file and its new location, copied this run or + # not: a stored path to one of them must never resolve to the legacy file. + payload: dict[str, str] = {} for filename in CONFIG_PAYLOAD: src = os.path.join(legacy_config, filename) dst = os.path.join(new_config_dir(), filename) + payload[src] = dst if _copy_once(src, dst): imported.append(dst) - copied[src] = dst legacy_data = legacy_data_dir() - if os.path.isdir(legacy_data): - for filename in DATA_PAYLOAD: - src = os.path.join(legacy_data, filename) - dst = os.path.join(new_data_dir(), filename) - if _copy_once(src, dst): - imported.append(dst) - copied[src] = dst + for filename in DATA_PAYLOAD: + src = os.path.join(legacy_data, filename) + dst = os.path.join(new_data_dir(), filename) + payload[src] = dst + if os.path.isdir(legacy_data) and _copy_once(src, dst): + imported.append(dst) copied_config = os.path.join(new_config_dir(), CONFIG_FILENAME) if copied_config in imported: - _repoint_dictionary_path(copied_config, copied) + _repoint_dictionary_path(copied_config, payload) if imported: logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported)) return imported diff --git a/tests/test_legacy_import.py b/tests/test_legacy_import.py index a0a4bf6..721dae5 100644 --- a/tests/test_legacy_import.py +++ b/tests/test_legacy_import.py @@ -169,13 +169,25 @@ def _import_with_stored_dictionary_path( encoding="utf-8", ) import_legacy_once() + _point_config_manager_at(monkeypatch, config_root / "voculinux") + return seeded["dictionary.txt"] - new_config_dir = config_root / "voculinux" + +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")) - return seeded["dictionary.txt"] + + +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"]) @@ -221,3 +233,47 @@ def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_p 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"