"""Identity, generation and sync-token tests (SPEC 12, tests 4-7/11).""" from __future__ import annotations from pathlib import Path import pytest from mdcaldav.config import Config from mdcaldav.index import Index from mdcaldav.model import Status @pytest.fixture def index(vault: Path, tmp_path: Path) -> Index: cfg = Config() cfg.vault.path = vault cfg.index.db = tmp_path / "index.db" idx = Index(cfg) idx.rescan() yield idx idx.close() def uid_of(index: Index, title: str, rel: str | None = None) -> str: matches = [ t for t in index.tasks.values() if t.title == title and (rel is None or t.source.rel_path == rel) ] assert len(matches) == 1, f"{title!r} matched {len(matches)} tasks" return matches[0].uid def write(vault: Path, rel: str, text: str) -> None: (vault / rel).write_text(text, encoding="utf-8") def test_uids_are_stable_across_an_unchanged_rescan(index: Index): before = {t.uid: t.title for t in index.tasks.values()} index.rescan() after = {t.uid: t.title for t in index.tasks.values()} assert before == after def test_status_change_preserves_uid(index: Index, vault: Path): uid = uid_of(index, "Water the seedlings", "daily/2026-01-07.md") rel = "daily/2026-01-07.md" text = (vault / rel).read_text() write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) index.rescan() assert index.tasks[uid].status is Status.COMPLETED assert uid_of(index, "Water the seedlings", rel) == uid def test_reorder_preserves_uid(index: Index, vault: Path): rel = "daily/2026-01-07.md" uid = uid_of(index, "Call the arborist", rel) text = (vault / rel).read_text() lines = text.splitlines(keepends=True) lines[8], lines[9] = lines[9], lines[8] # swap two task lines write(vault, rel, "".join(lines)) index.rescan() assert uid_of(index, "Call the arborist", rel) == uid def test_retitle_in_place_preserves_uid(index: Index, vault: Path): rel = "daily/2026-01-07.md" uid = uid_of(index, "Tidy the potting bench", rel) text = (vault / rel).read_text() write(vault, rel, text.replace("Tidy the potting bench", "Tidy the potting bench properly")) index.rescan() assert uid_of(index, "Tidy the potting bench properly", rel) == uid def test_carry_forward_duplicates_stay_distinct(index: Index): """SPEC 6.3: the same title in three daily notes is three tasks, not one.""" uids = { rel: uid_of(index, "Water the seedlings", rel) for rel in ( "daily/2026-01-05.md", "daily/2026-01-06.md", "daily/2026-01-07.md", ) } assert len(set(uids.values())) == 3 def test_completing_one_carry_forward_copy_does_not_affect_others( index: Index, vault: Path ): monday = uid_of(index, "Water the seedlings", "daily/2026-01-05.md") wednesday = uid_of(index, "Water the seedlings", "daily/2026-01-07.md") rel = "daily/2026-01-07.md" text = (vault / rel).read_text() write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) index.rescan() assert index.tasks[wednesday].status is Status.COMPLETED assert index.tasks[monday].status is Status.NEEDS_ACTION def test_true_cross_file_move_preserves_uid(index: Index, vault: Path): """A task that disappears from A and appears in B keeps its UID.""" uid = uid_of(index, "Book the soil test", "daily/2026-01-07.md") src = "daily/2026-01-07.md" text = (vault / src).read_text() write(vault, src, text.replace("- [ ] [#A] Book the soil test\n", "")) dest = "daily/2026-01-06.md" write(vault, dest, (vault / dest).read_text() + "\n- [ ] [#A] Book the soil test\n") index.rescan() assert uid_of(index, "Book the soil test", dest) == uid def test_deleted_task_is_tombstoned(index: Index, vault: Path): rel = "daily/2026-01-07.md" uid = uid_of(index, "Call the arborist", rel) text = (vault / rel).read_text() write(vault, rel, text.replace("- [ ] [#B] Call the arborist\n", "")) index.rescan() assert uid not in index.tasks row = index.db.execute( "SELECT deleted_gen FROM tasks WHERE uid = ?", (uid,) ).fetchone() assert row["deleted_gen"] is not None def test_uids_survive_a_restart(vault: Path, tmp_path: Path): cfg = Config() cfg.vault.path = vault cfg.index.db = tmp_path / "index.db" first = Index(cfg) first.rescan() before = {t.uid for t in first.tasks.values()} first.close() second = Index(cfg) second.rescan() assert {t.uid for t in second.tasks.values()} == before second.close() def test_completed_timestamp_recorded_on_transition(index: Index, vault: Path): rel = "daily/2026-01-07.md" uid = uid_of(index, "Water the seedlings", rel) assert index.completed_at(uid) is None text = (vault / rel).read_text() write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) index.rescan() assert index.completed_at(uid) is not None def test_collections_follow_directories(index: Index): assert set(index.collections()) == {"daily", "projects"} def test_sync_returns_only_changed(index: Index, vault: Path): token, hrefs = index.sync("daily") assert hrefs # initial sync returns everything rel = "daily/2026-01-07.md" uid = uid_of(index, "Water the seedlings", rel) text = (vault / rel).read_text() write(vault, rel, text.replace("- [ ] Water the seedlings", "- [x] Water the seedlings")) index.rescan() _, changed = index.sync("daily", token) assert changed == [f"{uid}.ics"] def test_sync_rejects_stale_token(index: Index): index.cfg.index.tombstone_retention = 1 for _ in range(4): index.rescan() with pytest.raises(ValueError): index.sync("daily", "http://mdcaldav.local/ns/sync/0") def test_sync_rejects_malformed_token(index: Index): with pytest.raises(ValueError): index.sync("daily", "not-a-token")