191 lines
5.9 KiB
Python
191 lines
5.9 KiB
Python
"""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")
|