Initial commit
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
32
tests/conftest.py
Normal file
32
tests/conftest.py
Normal 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
|
||||
33
tests/fixtures/vault/daily/2026-01-05.md
vendored
Normal file
33
tests/fixtures/vault/daily/2026-01-05.md
vendored
Normal 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
|
||||
24
tests/fixtures/vault/daily/2026-01-06.md
vendored
Normal file
24
tests/fixtures/vault/daily/2026-01-06.md
vendored
Normal 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.
|
||||
12
tests/fixtures/vault/daily/2026-01-07.md
vendored
Normal file
12
tests/fixtures/vault/daily/2026-01-07.md
vendored
Normal 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
|
||||
18
tests/fixtures/vault/projects/irrigation.md
vendored
Normal file
18
tests/fixtures/vault/projects/irrigation.md
vendored
Normal 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
|
||||
6
tests/fixtures/vault/reference/glossary.md
vendored
Normal file
6
tests/fixtures/vault/reference/glossary.md
vendored
Normal 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
|
||||
218
tests/test_caldav_integration.py
Normal file
218
tests/test_caldav_integration.py
Normal 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
103
tests/test_globs.py
Normal 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
128
tests/test_ical.py
Normal 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
190
tests/test_index.py
Normal 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
193
tests/test_parser.py
Normal 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
108
tests/test_watcher_cli.py
Normal 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
285
tests/test_writer.py
Normal 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*"))
|
||||
Reference in New Issue
Block a user