"""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"