194 lines
6.5 KiB
Python
194 lines
6.5 KiB
Python
"""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"
|