Initial commit
Some checks failed
CI / test (push) Failing after 11s
CI / image (push) Has been skipped

This commit is contained in:
2026-08-01 21:03:35 -04:00
commit 8b5cd370ea
41 changed files with 5462 additions and 0 deletions

0
tests/__init__.py Normal file
View File

32
tests/conftest.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from mdcaldav.config import Config
from mdcaldav.parser import parse_file
FIXTURE_VAULT = Path(__file__).parent / "fixtures" / "vault"
@pytest.fixture
def cfg() -> Config:
return Config()
@pytest.fixture
def vault(tmp_path: Path) -> Path:
"""A writable copy of the real note fixtures."""
dest = tmp_path / "vault"
shutil.copytree(FIXTURE_VAULT, dest)
return dest
@pytest.fixture
def tasks_of(cfg: Config):
def _load(vault: Path, rel: str):
return parse_file(vault / rel, rel, cfg)
return _load

View File

@@ -0,0 +1,33 @@
---
date: 2026-01-05
tags: [daily, garden]
---
# Monday, 5 January 2026
## TODO
- [ ] Plan the seed order
- [x] Compare catalog prices
- [x] Baker Creek
- [x] Territorial
- [x] Draft the bed layout
- [x] Measure the north plot
- [ ] Decide on trellis height
- [ ] Check the fence bylaw
- [ ] Email the council office
- [x] Sketch it in the notebook
- [x] Order the cover crop
- Arrived on the 12th, invoice is in the binder
- [x] Sharpen the pruners
- [x] Refill the bird feeder
- [ ] Water the seedlings
## Log
Frost warning posted for the weekend.
### Retrospective
- Start the tomatoes earlier next year
- Buy fewer squash varieties

View File

@@ -0,0 +1,24 @@
---
date: 2026-01-06
tags: [daily, garden]
---
# Tuesday, 6 January 2026
## TODO
- [x] Rotate the compost
- [ ] Water the seedlings
- set up the cold frame
- [x] Cut the polycarbonate
- [ ] Hinge the lid
## Greenhouse repairs
- [x] Replace the cracked pane
- Used the offcut from the shed
- Silicone needs a day to cure
- [ ] Reseal the base
## Notes
The gutter needs clearing before spring.

View File

@@ -0,0 +1,12 @@
---
date: 2026-01-07
tags: [daily, garden]
---
# Wednesday, 7 January 2026
## TODO
- [ ] [#A] Book the soil test
- [ ] [#B] Call the arborist
- [ ] [#C] Tidy the potting bench
- [ ] Water the seedlings

View File

@@ -0,0 +1,18 @@
# Irrigation rebuild
## Phase one
- [ ] Trench the main line
- [ ] Rent the trencher
- Depth has to clear the frost line
- [x] Price the manifold
## Reference
Nothing actionable in this section.
```sh
- [ ] this line is a code sample, not a task
```
- [ ] indented code block, also not a task

View File

@@ -0,0 +1,6 @@
# Glossary
A file with no tasks at all, to prove empty files are handled.
- Cold frame: an unheated box with a transparent lid
- Cover crop: a planting grown to protect soil rather than to harvest

View File

@@ -0,0 +1,218 @@
"""End-to-end: a real CalDAV client against Radicale + this storage plugin.
SPEC 12 test 10. Exercises the actual HTTP surface (PROPFIND, REPORT, PUT,
DELETE) rather than calling the storage API directly.
"""
from __future__ import annotations
import threading
from pathlib import Path
from wsgiref.simple_server import WSGIRequestHandler, make_server
import pytest
import mdcaldav.storage as storage_module
from mdcaldav.config import Config
caldav = pytest.importorskip("caldav")
radicale = pytest.importorskip("radicale")
class _QuietHandler(WSGIRequestHandler):
def log_message(self, *args): # keep test output readable
pass
@pytest.fixture
def server(vault: Path, tmp_path: Path):
from radicale import Application
from radicale import config as radicale_config
cfg = Config()
cfg.vault.path = vault
cfg.index.db = tmp_path / "index.db"
storage_module.set_config(cfg)
configuration = radicale_config.load()
configuration.update(
{
"storage": {"type": "mdcaldav.storage"},
"auth": {"type": "none"},
"logging": {"level": "critical"},
},
"test",
)
httpd = make_server("localhost", 0, Application(configuration), handler_class=_QuietHandler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
url = f"http://localhost:{httpd.server_port}"
try:
yield url, vault
finally:
httpd.shutdown()
thread.join(timeout=5)
storage_module.set_config(Config())
def _calendars(url: str):
client = caldav.DAVClient(url=url, username="test", password="test")
return client.principal().calendars()
def test_root_propfind_reports_current_user_principal(server):
"""Regression: the root collection's path must be "" or Radicale drops it.
A root item whose path was the storage's own user name got filtered out of
the multistatus, so clients could not discover the principal at all. Only
surfaced under htpasswd auth, hence this direct check.
"""
import base64
import urllib.request
url, _ = server
body = (
b'<?xml version="1.0"?><propfind xmlns="DAV:">'
b"<prop><current-user-principal/></prop></propfind>"
)
# Radicale answers 401 for current-user-principal when unauthenticated.
token = base64.b64encode(b"tyler:x").decode()
request = urllib.request.Request(
url + "/",
data=body,
method="PROPFIND",
headers={"Depth": "0", "Authorization": f"Basic {token}"},
)
payload = urllib.request.urlopen(request, timeout=10).read().decode()
assert "current-user-principal" in payload
assert "<href>/</href>" in payload
def test_collections_are_discoverable(server):
url, _ = server
names = {cal.url.path.rstrip("/").rsplit("/", 1)[-1] for cal in _calendars(url)}
assert {"daily", "projects"} <= names
def test_todos_are_listed_with_fields(server):
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todos = daily.todos(include_completed=True)
assert todos
summaries = {str(t.icalendar_component.get("SUMMARY")) for t in todos}
assert "Water the seedlings" in summaries
assert "Book the soil test" in summaries
soil = next(
t
for t in todos
if str(t.icalendar_component.get("SUMMARY")) == "Book the soil test"
)
assert int(soil.icalendar_component.get("PRIORITY")) == 1
def test_subtasks_carry_related_to(server):
url, _ = server
projects = next(
c for c in _calendars(url) if c.url.path.rstrip("/").endswith("projects")
)
todos = projects.todos(include_completed=True)
by_summary = {str(t.icalendar_component.get("SUMMARY")): t for t in todos}
parent = by_summary["Trench the main line"]
child = by_summary["Rent the trencher"]
related = child.icalendar_component.get("RELATED-TO")
assert str(related) == str(parent.icalendar_component.get("UID"))
assert related.params.get("RELTYPE") == "PARENT"
def test_completing_a_todo_rewrites_the_markdown(server):
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Book the soil test"
)
todo.complete()
text = (vault / "daily/2026-01-07.md").read_text()
assert "- [x] [#A] Book the soil test" in text
assert "- [ ] [#B] Call the arborist" in text # neighbour untouched
def test_description_edit_round_trips_to_markdown(server):
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
todo.icalendar_component["DESCRIPTION"] = "Ask about the oak"
todo.save()
text = (vault / "daily/2026-01-07.md").read_text()
assert "- [ ] [#B] Call the arborist\n - Ask about the oak\n" in text
def test_creating_a_todo_lands_in_the_collections_inbox(server):
"""A task created in `daily` must stay in `daily`, not jump to another list."""
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
daily.save_todo(summary="Buy netting", priority=1)
assert "- [ ] [#A] Buy netting" in (vault / "daily/inbox.md").read_text()
assert not (vault / "inbox.md").exists()
summaries = {
str(t.icalendar_component.get("SUMMARY"))
for t in daily.todos(include_completed=True)
}
assert "Buy netting" in summaries # visible in the list it was created in
def test_created_todo_keeps_the_client_uid(server):
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
created = daily.save_todo(summary="Mulch the beds")
uid = str(created.icalendar_component.get("UID"))
fetched = daily.todo_by_uid(uid)
assert str(fetched.icalendar_component.get("SUMMARY")) == "Mulch the beds"
def test_deleting_a_todo_removes_the_line(server):
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
todo.delete()
text = (vault / "daily/2026-01-07.md").read_text()
assert "Call the arborist" not in text
assert "Book the soil test" in text
def test_external_vim_edit_becomes_visible(server):
"""A change made on disk shows up on the next client read."""
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
rel = vault / "daily/2026-01-07.md"
rel.write_text(rel.read_text() + "- [ ] Order more compost\n")
summaries = {
str(t.icalendar_component.get("SUMMARY"))
for t in daily.todos(include_completed=True)
}
assert "Order more compost" in summaries

103
tests/test_globs.py Normal file
View File

@@ -0,0 +1,103 @@
"""Path filtering, including Syncthing-shaped vaults (SPEC 2.6)."""
from __future__ import annotations
from pathlib import Path
import pytest
from mdcaldav.config import Config
from mdcaldav.globs import matches
from mdcaldav.index import Index
CONFLICT = "daily/2026-01-05.sync-conflict-20260731-120000-ABCDEFG.md"
@pytest.mark.parametrize(
"path,pattern,expected",
[
# `**/` must mean "zero or more segments", so top-level files match.
("README.md", "**/*.md", True),
("daily/a.md", "**/*.md", True),
("a/b/c/d.md", "**/*.md", True),
# `*` must not cross a separator.
("a/b.tmp", "*.tmp", False),
("b.tmp", "*.tmp", True),
# Syncthing artefacts.
(".stversions/daily/2026-01-05.md", ".stversions/**", True),
(CONFLICT, "**/*.sync-conflict-*", True),
("daily/.syncthing.a.md.tmp", "**/.syncthing.*", True),
("daily/~syncthing~a.md.tmp", "**/~syncthing~*", True),
# Ordinary notes must survive all of the above.
("daily/2026-01-05.md", "**/*.sync-conflict-*", False),
("daily/2026-01-05.md", ".stversions/**", False),
],
)
def test_glob_semantics(path: str, pattern: str, expected: bool):
assert matches(path, pattern) is expected
@pytest.fixture
def syncthing_vault(vault: Path) -> Path:
"""A vault shaped like a live Syncthing folder."""
(vault / ".stfolder").mkdir()
(vault / ".stversions/daily").mkdir(parents=True)
# An old copy of a real note, exactly as Syncthing retains it.
(vault / ".stversions/daily/2026-01-07.md").write_text(
(vault / "daily/2026-01-07.md").read_text(), encoding="utf-8"
)
(vault / CONFLICT).write_text("- [ ] Book the soil test\n", encoding="utf-8")
(vault / "daily/.syncthing.2026-01-07.md.tmp").write_text("- [ ] partial\n")
(vault / "ROADMAP.md").write_text("- [ ] Top level task\n", encoding="utf-8")
return vault
@pytest.fixture
def index(syncthing_vault: Path, tmp_path: Path):
cfg = Config()
cfg.vault.path = syncthing_vault
cfg.index.db = tmp_path / "index.db"
idx = Index(cfg)
idx.rescan()
yield idx
idx.close()
def test_top_level_notes_are_indexed(index: Index):
"""Regression: `**/*.md` used to skip files at the vault root."""
assert "Top level task" in {t.title for t in index.tasks.values()}
def test_syncthing_artefacts_are_not_indexed(index: Index):
sources = {t.source.rel_path for t in index.tasks.values()}
assert not any(s.startswith(".stversions/") for s in sources)
assert not any(s.startswith(".stfolder/") for s in sources)
assert CONFLICT not in sources
assert not any(".syncthing." in s for s in sources)
def test_versioned_copies_do_not_duplicate_tasks(index: Index):
"""The failure mode that matters: one task per note, not one per version."""
titles = [t.title for t in index.tasks.values()]
assert titles.count("Book the soil test") == 1
def test_targeted_rescan_also_filters(index: Index):
"""The watcher path must filter too, or Syncthing writes poison the index."""
index.rescan([".stversions/daily/2026-01-07.md", CONFLICT])
titles = [t.title for t in index.tasks.values()]
assert titles.count("Book the soil test") == 1
sources = {t.source.rel_path for t in index.tasks.values()}
assert not any(s.startswith(".stversions/") for s in sources)
def test_doctor_reports_conflict_files(syncthing_vault: Path, tmp_path, capsys, monkeypatch):
from mdcaldav.cli import main
monkeypatch.setenv("HOME", str(tmp_path))
main(["--vault", str(syncthing_vault), "doctor"])
out = capsys.readouterr().out
assert "sync-conflict files present" in out
assert "merge and delete them" in out

128
tests/test_ical.py Normal file
View File

@@ -0,0 +1,128 @@
"""iCalendar mapping tests (SPEC 4)."""
from __future__ import annotations
from datetime import datetime, timezone
from mdcaldav.config import Config
from mdcaldav.ical import apply_to_task, escape, fold, from_ics, to_ics, unescape
from mdcaldav.model import Status
from mdcaldav.parser import parse
def prop(text: str, name: str) -> str | None:
from mdcaldav.ical import unfold
for line in unfold(text):
key = line.split(":", 1)[0].split(";", 1)[0]
if key == name:
return line.split(":", 1)[1]
return None
def one(src: bytes, cfg: Config):
return parse(src, "t.md", cfg)[0]
def test_basic_vtodo(cfg: Config):
text = to_ics(one(b"- [ ] Water the seedlings\n", cfg))
assert "BEGIN:VTODO" in text and "END:VTODO" in text
assert prop(text, "SUMMARY") == "Water the seedlings"
assert prop(text, "STATUS") == "NEEDS-ACTION"
assert prop(text, "PRIORITY") is None
assert text.endswith("\r\n")
def test_completed_maps_percent_and_timestamp(cfg: Config):
when = datetime(2026, 1, 7, 15, 30, tzinfo=timezone.utc)
text = to_ics(one(b"- [x] Rotate the compost\n", cfg), completed_at=when)
assert prop(text, "STATUS") == "COMPLETED"
assert prop(text, "PERCENT-COMPLETE") == "100"
assert prop(text, "COMPLETED") == "20260107T153000Z"
def test_priority_bands(cfg: Config):
for marker, expected in (("A", "1"), ("B", "5"), ("C", "9")):
text = to_ics(one(f"- [ ] [#{marker}] x\n".encode(), cfg))
assert prop(text, "PRIORITY") == expected
def test_description_and_categories(cfg: Config):
src = b"## Garden\n\n- [ ] Prune\n - Use the long loppers\n"
task = parse(src, "t.md", cfg)[0]
text = to_ics(task)
assert prop(text, "DESCRIPTION") == "Use the long loppers"
assert "Garden" in (prop(text, "CATEGORIES") or "")
def test_parent_relation(cfg: Config):
tasks = parse(b"- [ ] parent\n - [ ] child\n", "t.md", cfg)
text = to_ics(tasks[1])
assert f"RELATED-TO;RELTYPE=PARENT:{tasks[0].uid}" in text
def test_text_escaping_round_trip():
raw = "Comma, semi; back\\slash\nnewline"
assert unescape(escape(raw)) == raw
def test_special_characters_survive_serialization(cfg: Config):
src = "- [ ] Buy: milk, eggs; and a note\\here\n".encode()
text = to_ics(one(src, cfg))
assert from_ics(text)["title"] == "Buy: milk, eggs; and a note\\here"
def test_folding_respects_character_boundaries():
line = "SUMMARY:" + "é" * 100
folded = fold(line)
assert all(len(seg.encode()) <= 75 for seg in folded.split("\r\n "))
from mdcaldav.ical import unfold
assert unfold(folded)[0] == line
def test_long_summary_round_trips(cfg: Config):
title = "Plan " + "the very long garden bed layout " * 6
text = to_ics(one(f"- [ ] {title}\n".encode(), cfg))
assert from_ics(text)["title"] == title.strip()
def test_from_ics_ignores_unmodelled_properties():
text = (
"BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:Task\r\n"
"RRULE:FREQ=WEEKLY\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\n"
"SUMMARY:Alarm text\r\nEND:VALARM\r\nSTATUS:COMPLETED\r\n"
"END:VTODO\r\nEND:VCALENDAR\r\n"
)
fields = from_ics(text)
assert fields["title"] == "Task" # not clobbered by the VALARM's SUMMARY
assert fields["status"] is Status.COMPLETED
assert "rrule" not in fields
def test_percent_complete_implies_completion():
text = (
"BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:T\r\n"
"PERCENT-COMPLETE:100\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
)
assert from_ics(text)["status"] is Status.COMPLETED
def test_apply_to_task_reports_only_real_changes(cfg: Config):
task = one(b"- [ ] [#A] Ship it\n", cfg)
same = from_ics(to_ics(task))
assert apply_to_task(task, same, cfg) == {}
changed = dict(same, status=Status.COMPLETED, title="Ship it now")
assert apply_to_task(task, changed, cfg) == {
"status": Status.COMPLETED,
"title": "Ship it now",
}
def test_priority_snaps_to_configured_bands(cfg: Config):
task = one(b"- [ ] [#A] Ship it\n", cfg)
# A client sending 3 stays in the "high" band → still [#A], so no change.
assert apply_to_task(task, {"priority": 3}, cfg) == {}
assert apply_to_task(task, {"priority": 9}, cfg) == {"priority": 9}
assert apply_to_task(task, {"priority": 0}, cfg) == {"priority": None}

190
tests/test_index.py Normal file
View File

@@ -0,0 +1,190 @@
"""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")

193
tests/test_parser.py Normal file
View File

@@ -0,0 +1,193 @@
"""Parser tests against synthetic note fixtures (SPEC 12, tests 1/2/9)."""
from __future__ import annotations
from mdcaldav.config import Config
from mdcaldav.model import Status
from mdcaldav.parser import parse
def titles(tasks):
return [t.title for t in tasks]
def by_title(tasks, title):
return next(t for t in tasks if t.title == title)
def test_flat_daily_note(vault, tasks_of):
tasks = tasks_of(vault, "daily/2026-01-07.md")
assert titles(tasks) == [
"Book the soil test",
"Call the arborist",
"Tidy the potting bench",
"Water the seedlings",
]
assert [t.priority for t in tasks] == [1, 5, 9, None]
assert all(t.status is Status.NEEDS_ACTION for t in tasks)
def test_nesting_and_parentage(vault, tasks_of):
tasks = tasks_of(vault, "daily/2026-01-06.md")
parent = by_title(tasks, "Replace the cracked pane")
assert parent.parent_uid is None
assert parent.depth == 0
tasks = tasks_of(vault, "projects/irrigation.md")
trench = by_title(tasks, "Trench the main line")
rent = by_title(tasks, "Rent the trencher")
assert rent.parent_uid == trench.uid
assert rent.uid in trench.children
assert (trench.depth, rent.depth) == (0, 1)
def test_deep_nesting_five_levels(vault, tasks_of):
tasks = tasks_of(vault, "daily/2026-01-05.md")
deep = by_title(tasks, "Email the council office")
assert deep.depth == 4
lookup = {t.uid: t for t in tasks}
chain, node = [], deep
while node.parent_uid:
node = lookup[node.parent_uid]
chain.append(node.title)
assert chain == [
"Check the fence bylaw",
"Decide on trellis height",
"Draft the bed layout",
"Plan the seed order",
]
def test_sub_bullet_becomes_description(vault, tasks_of):
"""SPEC 2.3: non-checkbox bullet with no checkbox descendants."""
tasks = tasks_of(vault, "daily/2026-01-05.md")
assert by_title(tasks, "Order the cover crop").description == (
"Arrived on the 12th, invoice is in the binder"
)
assert by_title(tasks, "Sharpen the pruners").description is None
def test_multiple_description_bullets_join(vault, tasks_of):
tasks = tasks_of(vault, "daily/2026-01-06.md")
assert by_title(tasks, "Replace the cracked pane").description == (
"Used the offcut from the shed\nSilicone needs a day to cure"
)
def test_group_node_is_not_a_task(vault, tasks_of):
"""SPEC 2.3: a plain bullet parenting checkboxes contributes context only."""
tasks = tasks_of(vault, "daily/2026-01-06.md")
label = "set up the cold frame"
assert label not in titles(tasks)
cut = by_title(tasks, "Cut the polycarbonate")
assert cut.group_path == (label,)
assert label in cut.categories
assert cut.parent_uid is None # re-parented to nearest task ancestor: none
assert cut.depth == 0
def test_note_bullets_produce_nothing(vault, tasks_of):
"""SPEC 2.3: plain bullets with no task ancestor are dropped entirely."""
tasks = tasks_of(vault, "daily/2026-01-05.md")
assert "Buy fewer squash varieties" not in titles(tasks)
assert not any(
t.description and "squash" in t.description for t in tasks
)
def test_tasks_collected_under_any_heading(vault, tasks_of):
tasks = tasks_of(vault, "daily/2026-01-06.md")
# heading_path is the full stack, so the H1 note title leads.
assert by_title(tasks, "Reseal the base").heading_path == (
"Tuesday, 6 January 2026",
"Greenhouse repairs",
)
def test_file_with_no_tasks(vault, tasks_of):
assert tasks_of(vault, "reference/glossary.md") == []
def test_code_blocks_are_not_tasks(vault, tasks_of):
"""SPEC 2.6: fenced and indented code must not yield tasks."""
tasks = tasks_of(vault, "projects/irrigation.md")
assert titles(tasks) == [
"Trench the main line",
"Rent the trencher",
"Price the manifold",
]
def test_tilde_fence(cfg: Config):
src = b"- [ ] real\n\n~~~\n- [ ] fake\n~~~\n"
assert titles(parse(src, "f.md", cfg)) == ["real"]
def test_states(cfg: Config):
tasks = parse(b"- [ ] a\n- [x] b\n- [X] c\n- [/] d\n- [-] e\n- [?] f\n", "s.md", cfg)
assert [t.status for t in tasks] == [
Status.NEEDS_ACTION,
Status.COMPLETED,
Status.COMPLETED,
Status.IN_PROCESS,
Status.CANCELLED,
Status.NEEDS_ACTION,
]
assert tasks[-1].raw_marker == "?" # unknown marker preserved verbatim
def test_frontmatter_is_not_content(cfg: Config):
src = b"---\ndate: 2026-01-05\ntags: [daily]\n---\n\n- [ ] only\n"
assert titles(parse(src, "f.md", cfg)) == ["only"]
def test_spans_locate_exact_bytes(cfg: Config):
src = b"- [ ] [#A] Ship it\n"
(task,) = parse(src, "s.md", cfg)
assert src[slice(*task.source.marker_span)] == b" "
assert src[slice(*task.source.title_span)] == b"Ship it"
assert src[slice(*task.source.prio_span)] == b"[#A]"
def test_spans_are_byte_offsets_with_unicode(cfg: Config):
"""Byte spans must stay correct when the line contains multi-byte characters."""
src = "- [x] café → naïve\n".encode()
(task,) = parse(src, "u.md", cfg)
assert src[slice(*task.source.marker_span)] == b"x"
assert src[slice(*task.source.title_span)].decode() == "café → naïve"
def test_crlf_line_endings(cfg: Config):
(task,) = parse(b"- [ ] windows\r\n", "w.md", cfg)
assert task.title == "windows"
assert task.source.line_span == (0, 13) # excludes the \r\n
def test_bullet_variants(cfg: Config):
tasks = parse(b"- [ ] dash\n* [ ] star\n+ [ ] plus\n", "b.md", cfg)
assert titles(tasks) == ["dash", "star", "plus"]
assert [t.source.bullet for t in tasks] == ["-", "*", "+"]
def test_tags_become_categories(cfg: Config):
(task,) = parse(b"- [ ] Fix #1923 for #garden\n", "t.md", cfg)
assert "garden" in task.categories
assert "1923" not in task.categories # bare numbers are not tags
assert task.title == "Fix #1923 for #garden" # tags stay in the title
def test_sibling_index_disambiguates_duplicates(cfg: Config):
tasks = parse(b"- [ ] same\n- [ ] same\n- [ ] same\n", "d.md", cfg)
assert [t.sibling_index for t in tasks] == [0, 1, 2]
def test_due_syntax_opt_in(cfg: Config):
src = b"- [ ] Prune the apple tree @due(2026-03-01)\n"
assert parse(src, "n.md", cfg)[0].due is None # disabled by default
cfg.tasks.due_syntax = ["@due(%Y-%m-%d)"]
task = parse(src, "n.md", cfg)[0]
assert task.due is not None and task.due.isoformat() == "2026-03-01"
assert task.title == "Prune the apple tree"

108
tests/test_watcher_cli.py Normal file
View File

@@ -0,0 +1,108 @@
"""Watcher debounce and CLI smoke tests (SPEC 8, 10.1)."""
from __future__ import annotations
import time
from pathlib import Path
import pytest
from mdcaldav.cli import main
from mdcaldav.config import Config
from mdcaldav.index import Index
from mdcaldav.watcher import Watcher
@pytest.fixture
def index(vault: Path, tmp_path: Path):
cfg = Config()
cfg.vault.path = vault
cfg.index.db = tmp_path / "index.db"
idx = Index(cfg)
idx.rescan()
yield idx
idx.close()
def _titles(index: Index) -> set[str]:
return {t.title for t in index.tasks.values()}
def test_watcher_picks_up_a_new_task(index: Index, vault: Path):
watcher = Watcher(index.cfg, index)
watcher.start()
try:
rel = vault / "daily/2026-01-07.md"
rel.write_text(rel.read_text() + "- [ ] Order more compost\n")
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if "Order more compost" in _titles(index):
break
time.sleep(0.05)
assert "Order more compost" in _titles(index)
finally:
watcher.stop()
def test_watcher_debounces_a_burst(index: Index, vault: Path):
"""Many rapid saves should collapse into far fewer rescans."""
watcher = Watcher(index.cfg, index)
rel = vault / "daily/2026-01-07.md"
base = index.generation
for n in range(10):
rel.write_text(rel.read_text() + f"- [ ] burst {n}\n")
watcher.touch(rel)
watcher.flush()
assert index.generation - base == 1 # one rescan, not ten
assert "burst 9" in _titles(index)
watcher.stop()
def test_watcher_ignores_paths_outside_the_vault(index: Index, tmp_path: Path):
watcher = Watcher(index.cfg, index)
watcher.touch(tmp_path / "elsewhere.md")
assert watcher._pending == set()
watcher.stop()
def test_refresh_if_stale_is_a_noop_when_unchanged(index: Index):
assert index.refresh_if_stale() is False
def test_refresh_if_stale_detects_deletion(index: Index, vault: Path):
(vault / "daily/2026-01-07.md").unlink()
assert index.refresh_if_stale() is True
assert "Book the soil test" not in _titles(index)
def test_cli_scan(vault: Path, tmp_path: Path, capsys, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
rc = main(["--vault", str(vault), "scan"])
out = capsys.readouterr().out
assert rc == 0
assert "Plan the seed order" in out
assert "Email the council office" in out # nested tasks are shown
assert "[#A] Book the soil test" in out # priority rendered
assert "tasks in" in out
def test_cli_doctor(vault: Path, tmp_path: Path, capsys, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
rc = main(["--vault", str(vault), "doctor"])
out = capsys.readouterr().out
assert rc == 0
assert "collections:" in out
assert "daily" in out
def test_cli_doctor_flags_ambiguous_titles(vault: Path, tmp_path: Path, capsys, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
(vault / "daily/dupes.md").write_text("- [ ] same thing\n- [ ] same thing\n")
main(["--vault", str(vault), "doctor"])
assert "ambiguous titles" in capsys.readouterr().out

285
tests/test_writer.py Normal file
View File

@@ -0,0 +1,285 @@
"""Write-back tests: surgical edits, atomicity, conflicts (SPEC 12, tests 3/8)."""
from __future__ import annotations
import difflib
from pathlib import Path
import pytest
from mdcaldav.config import Config
from mdcaldav.index import Index
from mdcaldav.model import Status
from mdcaldav.writer import ConflictError, NotAllowedError, Writer
@pytest.fixture
def env(vault: Path, tmp_path: Path):
cfg = Config()
cfg.vault.path = vault
cfg.index.db = tmp_path / "index.db"
index = Index(cfg)
index.rescan()
yield cfg, index, Writer(cfg, index), vault
index.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)}"
return matches[0].uid
def diff_lines(before: str, after: str) -> list[str]:
return [
line
for line in difflib.unified_diff(
before.splitlines(), after.splitlines(), lineterm="", n=0
)
if line[:1] in "+-" and not line.startswith(("---", "+++"))
]
def test_completion_changes_exactly_one_line(env):
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
before = (vault / rel).read_text()
writer.apply(uid_of(index, "Water the seedlings", rel), {"status": Status.COMPLETED})
after = (vault / rel).read_text()
assert diff_lines(before, after) == [
"-- [ ] Water the seedlings",
"+- [x] Water the seedlings",
]
def test_completion_toggles_back(env):
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
before = (vault / rel).read_bytes()
uid = uid_of(index, "Water the seedlings", rel)
writer.apply(uid, {"status": Status.COMPLETED})
writer.apply(uid, {"status": Status.NEEDS_ACTION})
assert (vault / rel).read_bytes() == before # round trip is byte-identical
def test_nested_task_completion_leaves_siblings_alone(env):
_, index, writer, vault = env
rel = "daily/2026-01-05.md"
before = (vault / rel).read_text()
writer.apply(
uid_of(index, "Decide on trellis height", rel), {"status": Status.COMPLETED}
)
after = (vault / rel).read_text()
assert diff_lines(before, after) == [
"- - [ ] Decide on trellis height",
"+ - [x] Decide on trellis height",
]
def test_retitle_preserves_indent_bullet_and_priority(env):
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
writer.apply(uid_of(index, "Book the soil test", rel), {"title": "Book the soil test today"})
assert "- [ ] [#A] Book the soil test today" in (vault / rel).read_text()
def test_priority_set_clear_and_change(env):
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
uid = uid_of(index, "Water the seedlings", rel)
writer.apply(uid, {"priority": 1})
assert "- [ ] [#A] Water the seedlings" in (vault / rel).read_text()
writer.apply(uid, {"priority": 9})
assert "- [ ] [#C] Water the seedlings" in (vault / rel).read_text()
writer.apply(uid, {"priority": None})
assert "- [ ] Water the seedlings" in (vault / rel).read_text()
assert "[#" not in (vault / rel).read_text().split("Water the seedlings")[0].splitlines()[-1]
def test_description_replaced_in_place(env):
_, index, writer, vault = env
rel = "daily/2026-01-05.md"
writer.apply(
uid_of(index, "Order the cover crop", rel), {"description": "Invoice filed"}
)
text = (vault / rel).read_text()
assert " - Invoice filed" in text
assert "Arrived on the 12th" not in text
def test_description_added_where_none_existed(env):
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
writer.apply(
uid_of(index, "Call the arborist", rel), {"description": "Ask about the oak"}
)
text = (vault / rel).read_text()
assert "- [ ] [#B] Call the arborist\n - Ask about the oak\n" in text
def test_description_removed(env):
_, index, writer, vault = env
rel = "daily/2026-01-05.md"
writer.apply(uid_of(index, "Order the cover crop", rel), {"description": None})
text = (vault / rel).read_text()
assert "Arrived on the 12th" not in text
assert "- [x] Order the cover crop\n - [x] Sharpen the pruners" in text
def test_untouched_files_are_not_rewritten(env):
_, index, writer, vault = env
others = {
rel: (vault / rel).read_bytes()
for rel in ("daily/2026-01-06.md", "projects/irrigation.md", "reference/glossary.md")
}
writer.apply(
uid_of(index, "Water the seedlings", "daily/2026-01-07.md"),
{"status": Status.COMPLETED},
)
for rel, content in others.items():
assert (vault / rel).read_bytes() == content
def test_crlf_and_missing_final_newline_preserved(env, tmp_path: Path):
cfg, index, writer, vault = env
rel = "daily/crlf.md"
(vault / rel).write_bytes(b"# T\r\n\r\n- [ ] alpha\r\n- [ ] omega") # no final newline
index.rescan()
writer.apply(uid_of(index, "alpha", rel), {"status": Status.COMPLETED})
data = (vault / rel).read_bytes()
assert data == b"# T\r\n\r\n- [x] alpha\r\n- [ ] omega"
def test_unicode_title_edit_keeps_bytes_aligned(env):
_, index, writer, vault = env
rel = "daily/uni.md"
(vault / rel).write_text("- [ ] café → naïve\n- [ ] after\n", encoding="utf-8")
index.rescan()
writer.apply(uid_of(index, "café → naïve", rel), {"status": Status.COMPLETED})
assert (vault / rel).read_text() == "- [x] café → naïve\n- [ ] after\n"
def test_conflict_when_task_deleted_underneath(env):
"""The task the client addressed no longer exists → refuse, don't guess."""
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
uid = uid_of(index, "Water the seedlings", rel)
text = (vault / rel).read_text()
(vault / rel).write_text(text.replace("- [ ] Water the seedlings\n", ""))
before = (vault / rel).read_bytes()
with pytest.raises(ConflictError):
writer.apply(uid, {"status": Status.COMPLETED})
assert (vault / rel).read_bytes() == before # not corrupted
def test_external_retitle_is_the_same_task(env):
"""Identity rule 2 treats an in-place retitle as the same task, so the
write lands on the renamed line rather than failing. The client's own
staleness is caught a layer up by Radicale's If-Match/ETag check."""
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
uid = uid_of(index, "Water the seedlings", rel)
text = (vault / rel).read_text()
(vault / rel).write_text(text.replace("Water the seedlings", "Water the beds"))
writer.apply(uid, {"status": Status.COMPLETED})
assert "- [x] Water the beds" in (vault / rel).read_text()
def test_stale_spans_are_never_used(env):
"""An edit that shifts line offsets must not corrupt a later write."""
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
uid = uid_of(index, "Water the seedlings", rel)
# Insert lines above the target, invalidating every cached byte offset.
text = (vault / rel).read_text()
(vault / rel).write_text(text.replace("## TODO\n", "## TODO\n\nSome new prose here.\n\n"))
writer.apply(uid, {"status": Status.COMPLETED})
result = (vault / rel).read_text()
assert "- [x] Water the seedlings" in result
assert "Some new prose here." in result
assert "- [ ] [#A] Book the soil test" in result # neighbours intact
def test_unrelated_external_edit_is_absorbed(env):
"""A change elsewhere in the file must not block an unrelated write."""
_, index, writer, vault = env
rel = "daily/2026-01-07.md"
uid = uid_of(index, "Water the seedlings", rel)
text = (vault / rel).read_text()
(vault / rel).write_text(text.replace("# Wednesday", "# Weds"))
writer.apply(uid, {"status": Status.COMPLETED})
assert "- [x] Water the seedlings" in (vault / rel).read_text()
def test_delete_cascades_to_subtree(env):
_, index, writer, vault = env
rel = "projects/irrigation.md"
writer.delete(uid_of(index, "Trench the main line", rel))
text = (vault / rel).read_text()
assert "Trench the main line" not in text
assert "Rent the trencher" not in text # cascaded
assert "frost line" not in text # its description went too
assert "Price the manifold" in text # sibling untouched
def test_delete_reject_policy(env):
cfg, index, writer, vault = env
cfg.write.delete_children = "reject"
with pytest.raises(NotAllowedError):
writer.delete(uid_of(index, "Trench the main line", "projects/irrigation.md"))
def test_delete_disabled(env):
cfg, index, writer, _ = env
cfg.write.allow_delete = False
with pytest.raises(NotAllowedError):
writer.delete(uid_of(index, "Reseal the base", "daily/2026-01-06.md"))
def test_create_lands_in_inbox(env):
_, index, writer, vault = env
uid = writer.create({"title": "Buy netting", "priority": 1})
text = (vault / "inbox.md").read_text()
assert "## Inbox" in text
assert "- [ ] [#A] Buy netting" in text
assert index.tasks[uid].title == "Buy netting"
def test_create_disabled(env):
cfg, _, writer, _ = env
cfg.write.allow_create = False
with pytest.raises(NotAllowedError):
writer.create({"title": "nope"})
def test_atomic_write_leaves_no_temp_files(env):
_, index, writer, vault = env
writer.apply(
uid_of(index, "Water the seedlings", "daily/2026-01-07.md"),
{"status": Status.COMPLETED},
)
assert not list((vault / "daily").glob(".*tmp*"))