import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from wflib import tasks as T from wflib import lanes as L SAMPLE = """\ # Tasks — demo Commands: `make test`. ## Awaiting your decision - **a-smoke**: Smoke screenshots. Need you present. ## Pending Intro prose. - **t-first** [P1] (<1h): First thing. Do it well. - Steps: one - After: [[t-zero]], [[t-other]] Ref: DESIGN.md#terrain, docs/x.md (skill rows) - **t-second** [P2] (5h, interactive) (in progress: master): Second: with colon. Goal here. - **t-third** [P3] (1h) (blocked: [[a-smoke]]): Third. Trailing prose after items. ## Needs human - **t-play** [P1] (<1h): Play-test feel. - [ ] keyboard works - [ ] motion smooth ## Deferred ## Known caveats - plain bullet, mentions [[t-first]] """ class HeaderTest(unittest.TestCase): def setUp(self): self.doc = T.parse(SAMPLE) def item(self, id): section, i = self.doc.find(id) return section.items[i] def test_task_with_priority_and_effort(self): it = self.item("t-first") self.assertEqual((it.id, it.prio, it.effort, it.interactive, it.status, it.text), ("t-first", 1, "<1h", False, None, "First thing. Do it well.")) def test_interactive_and_in_progress(self): it = self.item("t-second") self.assertEqual((it.prio, it.effort, it.interactive, it.status), (2, "5h", True, "in progress: master")) def test_blocked(self): it = self.item("t-third") self.assertEqual(it.status, "blocked: [[a-smoke]]") self.assertEqual(it.blocked_on, "a-smoke") self.assertIsNone(self.item("t-first").blocked_on) def test_awaiting_item_has_no_priority(self): it = self.item("a-smoke") self.assertEqual((it.prio, it.effort, it.text), (None, None, "Smoke screenshots. Need you present.")) def test_header_rebuilds_the_line(self): self.assertEqual(self.item("t-first").header(), "- **t-first** [P1] (<1h): First thing. Do it well.") self.assertEqual(self.item("t-second").header(), "- **t-second** [P2] (5h, interactive) (in progress: master): Second: with colon. Goal here.") self.assertEqual(self.item("t-third").header(), "- **t-third** [P3] (1h) (blocked: [[a-smoke]]): Third.") self.assertEqual(self.item("a-smoke").header(), "- **a-smoke**: Smoke screenshots. Need you present.") def test_title_and_goal(self): it = self.item("t-second") self.assertEqual(it.title, "Second: with colon") self.assertEqual(it.goal, "Goal here.") self.assertEqual(self.item("t-third").title, "Third") self.assertEqual(self.item("t-third").goal, "") def test_after(self): self.assertEqual(self.item("t-first").after, ["t-zero", "t-other"]) self.assertEqual(self.item("t-second").after, []) def test_refs(self): self.assertEqual(self.item("t-first").refs, [("DESIGN.md", "terrain"), ("docs/x.md", None)]) self.assertEqual(self.item("t-second").refs, []) def test_body_lines(self): self.assertEqual(self.item("t-play").body, [" - [ ] keyboard works", " - [ ] motion smooth"]) class StructureTest(unittest.TestCase): def test_round_trip(self): self.assertEqual(T.render(T.parse(SAMPLE)), SAMPLE) def test_sections_and_keys(self): doc = T.parse(SAMPLE) self.assertEqual([(s.heading, s.key) for s in doc.sections], [("Awaiting your decision", "awaiting"), ("Pending", "pending"), ("Needs human", "human"), ("Deferred", "deferred"), ("Known caveats", None)]) self.assertEqual([i.id for i in doc.section("pending").items], ["t-first", "t-second", "t-third"]) self.assertEqual(doc.section("pending").prefix, ["", "Intro prose.", ""]) self.assertEqual(doc.section("pending").suffix, ["Trailing prose after items.", ""]) self.assertEqual(doc.ids(), {"a-smoke", "t-first", "t-second", "t-third", "t-play"}) def test_prose_section_bullets_are_not_items(self): doc = T.parse(SAMPLE) self.assertEqual(doc.sections[-1].items, []) self.assertEqual(doc.sections[-1].prefix, ["", "- plain bullet, mentions [[t-first]]"]) def test_missing_section_raises(self): doc = T.parse("# x\n\n## Pending\n") with self.assertRaisesRegex(T.TaskError, "no '## Deferred' section"): doc.section("deferred") def test_find_unknown_id_names_nearest(self): with self.assertRaisesRegex(T.TaskError, r"unknown id 't-frist' \(nearest: t-first"): T.parse(SAMPLE).find("t-frist") def test_items_are_separated_by_one_blank_line(self): text = "## Pending\n- **t-a** [P1] (1h): A.\n\n\n\n- **t-b** [P1] (1h): B.\n body\n- **t-c** [P1] (1h): C.\n" self.assertEqual(T.render(T.parse(text)), "## Pending\n- **t-a** [P1] (1h): A.\n\n- **t-b** [P1] (1h): B.\n body\n\n- **t-c** [P1] (1h): C.\n") def test_malformed_header_is_kept_and_flagged(self): text = "## Pending\n\n- **t-x** [P9] (1h) no colon\n body\n\n- **t-ok** [P1] (1h): Fine.\n" doc = T.parse(text) bad = doc.section("pending").items[0] self.assertEqual(bad.raw, "- **t-x** [P9] (1h) no colon") self.assertIn("header", bad.error) self.assertEqual(bad.id, "t-x") self.assertEqual(T.render(doc), text) def test_flush_left_line_ends_the_items_and_survives(self): text = ("## Pending\n\n- **t-a** [P1] (1h): A.\n body\nstray continuation\n" "- **t-b** [P1] (1h): B.\n\n## Deferred\n") doc = T.parse(text) pending = doc.section("pending") self.assertEqual([i.id for i in pending.items], ["t-a"]) self.assertEqual(pending.suffix, ["stray continuation", "- **t-b** [P1] (1h): B.", ""]) self.assertEqual(T.render(doc), "## Pending\n\n- **t-a** [P1] (1h): A.\n body\n\nstray continuation\n" "- **t-b** [P1] (1h): B.\n\n## Deferred\n") def test_crlf_is_kept(self): text = "## Pending\r\n\r\n- **t-a** [P1] (1h): A.\r\n body\r\n" doc = T.parse(text) self.assertEqual(doc.newline, "\r\n") self.assertEqual(doc.section("pending").items[0].body, [" body"]) self.assertEqual(T.render(doc), text) def test_missing_final_newline_is_added(self): self.assertEqual(T.render(T.parse("## Pending\n\n- **t-a** [P1] (1h): A.")), "## Pending\n\n- **t-a** [P1] (1h): A.\n") if __name__ == "__main__": unittest.main() EDIT = """\ ## Awaiting your decision - **a-key**: Key needed. Which one? ## Pending - **t-p0** [P0] (1h): Zero. - **t-p1** [P1] (1h): One. - Steps: a - After: [[t-p0]] Ref: docs/a.md - **t-p2** [P2] (1h) (blocked: [[a-key]]): Two. - **t-p3** [P3] (1h): Three. - After: [[t-gone]] ## Needs human - **t-play** [P1] (<1h): Play. - [ ] keyboard works - [x] motion smooth - [ ] gait feels right ## Deferred """ def ids(doc, key): return [i.id for i in doc.section(key).items] class MakeIdTest(unittest.TestCase): def test_slug_of_title(self): self.assertEqual(T.make_id("Guards notice lock picking and busting", set()), "t-guards-notice-lock-picking-and-busting") def test_long_title_is_cut_at_a_word(self): self.assertEqual(T.make_id("Exe function map plus port re-verification of everything", set()), "t-exe-function-map-plus-port-re") self.assertEqual(T.make_id("Thrown weapons and grenades for everyone here", set()), "t-thrown-weapons-and-grenades-for-everyone") def test_taken_id_gets_a_number(self): self.assertEqual(T.make_id("Cave seams", {"t-cave-seams"}), "t-cave-seams-2") self.assertEqual(T.make_id("Cave seams", {"t-cave-seams", "t-cave-seams-2"}), "t-cave-seams-3") def test_empty_slug_is_refused(self): with self.assertRaisesRegex(T.TaskError, "--id"): T.make_id("???", set()) def test_non_ascii_is_dropped(self): self.assertEqual(T.make_id("Åäö test", set()), "t-test") def test_awaiting_prefix(self): self.assertEqual(T.make_id("Smoke screenshots", set(), "a-"), "a-smoke-screenshots") def test_slice_id(self): self.assertEqual(T.slice_id("t-map", {"t-map"}), "t-map-1") self.assertEqual(T.slice_id("t-map", {"t-map", "t-map-1", "t-map-2"}), "t-map-3") class EditTest(unittest.TestCase): def setUp(self): self.doc = T.parse(EDIT) def new(self, id, prio, body=()): return T.Item(id=id, prio=prio, effort="1h", text="New.", body=list(body)) def test_insert_by_priority(self): T.insert(self.doc, self.new("t-n", 1), "pending") self.assertEqual(ids(self.doc, "pending"), ["t-p0", "t-p1", "t-n", "t-p2", "t-p3"]) def test_insert_into_empty_section(self): T.insert(self.doc, self.new("t-n", 0), "deferred") self.assertEqual(ids(self.doc, "deferred"), ["t-n"]) def test_insert_goes_after_its_dependency(self): T.insert(self.doc, self.new("t-n", 1, [" - After: [[t-p3]]"]), "pending") self.assertEqual(ids(self.doc, "pending"), ["t-p0", "t-p1", "t-p2", "t-p3", "t-n"]) def test_insert_refuses_taken_id(self): with self.assertRaisesRegex(T.TaskError, "'t-p1' already exists"): T.insert(self.doc, self.new("t-p1", 1), "pending") def test_insert_refuses_wrong_kind_for_section(self): with self.assertRaisesRegex(T.TaskError, "a- items belong in Awaiting"): T.insert(self.doc, T.Item(id="a-x", text="X."), "pending") with self.assertRaisesRegex(T.TaskError, "needs a priority"): T.insert(self.doc, T.Item(id="t-x", effort="1h", text="X."), "pending") def test_remove(self): item = T.remove(self.doc, "t-p1") self.assertEqual(item.body, [" - Steps: a", " - After: [[t-p0]]", " Ref: docs/a.md"]) self.assertEqual(ids(self.doc, "pending"), ["t-p0", "t-p2", "t-p3"]) def test_remove_unknown_names_nearest(self): with self.assertRaisesRegex(T.TaskError, r"unknown id 't-p9' \(nearest: t-p"): T.remove(self.doc, "t-p9") def test_set_prio_moves_the_item_and_keeps_its_body(self): T.set_prio(self.doc, "t-p1", 3) self.assertEqual(ids(self.doc, "pending"), ["t-p0", "t-p2", "t-p3", "t-p1"]) item = self.doc.item("t-p1") self.assertEqual((item.prio, item.body[0]), (3, " - Steps: a")) def test_set_prio_range(self): with self.assertRaisesRegex(T.TaskError, "priority 0-3"): T.set_prio(self.doc, "t-p1", 4) def test_move_to_section(self): T.move_to(self.doc, "t-p3", "deferred") self.assertEqual(ids(self.doc, "pending"), ["t-p0", "t-p1", "t-p2"]) self.assertEqual(ids(self.doc, "deferred"), ["t-p3"]) def test_move_to_refuses_kind_change(self): with self.assertRaisesRegex(T.TaskError, "a- items belong in Awaiting"): T.move_to(self.doc, "a-key", "pending") def test_move_rel_refuses_priority_break(self): with self.assertRaisesRegex(T.TaskError, "breaks priority order.*--force"): T.move_rel(self.doc, "t-p3", "t-p0", before=True) self.assertEqual(ids(self.doc, "pending"), ["t-p0", "t-p1", "t-p2", "t-p3"]) def test_move_rel_with_force(self): T.move_rel(self.doc, "t-p3", "t-p0", before=True, force=True) self.assertEqual(ids(self.doc, "pending"), ["t-p3", "t-p0", "t-p1", "t-p2"]) def test_move_rel_never_puts_an_item_before_its_dependency(self): with self.assertRaisesRegex(T.TaskError, "'t-p1' is After: \\[\\[t-p0\\]\\]"): T.move_rel(self.doc, "t-p1", "t-p0", before=True, force=True) def test_move_rel_after_into_other_section(self): T.move_rel(self.doc, "t-p1", "t-play", before=False) self.assertEqual(ids(self.doc, "human"), ["t-play", "t-p1"]) def test_status(self): T.set_status(self.doc, "t-p1", "in progress: feature/x") self.assertEqual(self.doc.item("t-p1").header(), "- **t-p1** [P1] (1h) (in progress: feature/x): One.") T.set_status(self.doc, "t-p1", "blocked: [[a-key]]") self.assertEqual(self.doc.item("t-p1").blocked_on, "a-key") T.set_status(self.doc, "t-p1", None) self.assertEqual(self.doc.item("t-p1").header(), "- **t-p1** [P1] (1h): One.") def test_status_blocked_on_unknown_awaiting_item(self): with self.assertRaisesRegex(T.TaskError, "'a-none' is not an open Awaiting item"): T.set_status(self.doc, "t-p1", "blocked: [[a-none]]") def test_set_fields_header(self): T.set_fields(self.doc, "t-p1", title="Uno", effort="5h", interactive=True) self.assertEqual(self.doc.item("t-p1").header(), "- **t-p1** [P1] (5h, interactive): Uno.") T.set_fields(self.doc, "t-p0", title="Nil") self.assertEqual(self.doc.item("t-p0").text, "Nil.") def test_set_fields_title_keeps_goal(self): doc = T.parse("## Pending\n\n- **t-a** [P1] (1h): Old name. The goal. More.\n") T.set_fields(doc, "t-a", title="New name") self.assertEqual(doc.item("t-a").text, "New name. The goal. More.") def test_set_fields_full_text_replaces_goal(self): doc = T.parse("## Awaiting your decision\n\n- **a-q** : Colour. OK?\n\n## Pending\n\n" "- **t-a** [P1] (1h): Old name. Old goal.\n".replace(" :", ":")) T.set_fields(doc, "a-q", title="Colour red. OK?") self.assertEqual(doc.item("a-q").text, "Colour red. OK?") T.set_fields(doc, "t-a", title="New name. New goal") self.assertEqual(doc.item("t-a").text, "New name. New goal.") T.set_fields(doc, "t-a", title="Why not?") self.assertEqual(doc.item("t-a").text, "Why not?") def test_set_fields_bad_effort(self): with self.assertRaisesRegex(T.TaskError, "effort '2h'"): T.set_fields(self.doc, "t-p1", effort="2h") def test_set_after(self): T.set_fields(self.doc, "t-p1", after=[]) self.assertEqual(self.doc.item("t-p1").body, [" - Steps: a", " Ref: docs/a.md"]) T.set_fields(self.doc, "t-p1", after=["t-p0", "t-p2"]) self.assertEqual(self.doc.item("t-p1").body, [" - Steps: a", " - After: [[t-p0]], [[t-p2]]", " Ref: docs/a.md"]) def test_set_done(self): doc = T.parse("## Pending\n\n- **t-a** [P1] (1h): A.\n - Steps: a\n - note\n Model: sonnet\n Ref: x.md\n") T.set_fields(doc, "t-a", done="a works") self.assertEqual(doc.item("t-a").body, [" - Steps: a", " - note", " Done: a works", " Model: sonnet", " Ref: x.md"]) T.set_fields(doc, "t-a", done=" b works ") self.assertEqual(doc.item("t-a").body[2], " Done: b works") T.set_fields(doc, "t-a", done="") self.assertEqual(doc.item("t-a").body, [" - Steps: a", " - note", " Model: sonnet", " Ref: x.md"]) def test_set_refs(self): T.set_fields(self.doc, "t-p1", refs=["docs/b.md#x (rows)", "DESIGN.md"]) self.assertEqual(self.doc.item("t-p1").body[-1], " Ref: docs/b.md#x (rows), DESIGN.md") T.set_fields(self.doc, "t-p0", refs=["docs/a.md"]) self.assertEqual(self.doc.item("t-p0").body, [" Ref: docs/a.md"]) T.set_fields(self.doc, "t-p0", refs=[]) self.assertEqual(self.doc.item("t-p0").body, []) def test_add_note_goes_before_after_and_ref(self): T.add_note(self.doc, "t-p1", "found the cause") self.assertEqual(self.doc.item("t-p1").body, [" - Steps: a", " - found the cause", " - After: [[t-p0]]", " Ref: docs/a.md"]) def test_set_body_keeps_after_and_ref(self): T.set_body(self.doc, "t-p1", ["- Steps: b", " - sub", "", "- Done: c"]) self.assertEqual(self.doc.item("t-p1").body, [" - Steps: b", " - sub", "", " - Done: c", " - After: [[t-p0]]", " Ref: docs/a.md"]) def test_set_body_bare_after_ids_become_links(self): T.set_body(self.doc, "t-p1", ["- Done: c", "- After: t-a, [[t-b]]"]) self.assertEqual(self.doc.item("t-p1").body[:2], [" - Done: c", " - After: [[t-a]], [[t-b]]"]) def test_tick_by_number_and_text(self): self.assertEqual(T.tick(self.doc, "t-play", "1"), "keyboard works") self.assertEqual(T.tick(self.doc, "t-play", "gait"), "gait feels right") self.assertEqual(self.doc.item("t-play").body, [" - [x] keyboard works", " - [x] motion smooth", " - [x] gait feels right"]) def test_tick_errors(self): with self.assertRaisesRegex(T.TaskError, "already ticked"): T.tick(self.doc, "t-play", "2") with self.assertRaisesRegex(T.TaskError, "no box matches 'jump'"): T.tick(self.doc, "t-play", "jump") with self.assertRaisesRegex(T.TaskError, "no box 7"): T.tick(self.doc, "t-play", "7") def test_unblock(self): self.assertEqual(T.unblock(self.doc, "a-key"), ["t-p2"]) self.assertIsNone(self.doc.item("t-p2").status) class PickTest(unittest.TestCase): def test_skips_blocked_and_open_dependencies(self): doc = T.parse(EDIT) T.remove(doc, "t-p0") T.remove(doc, "t-p1") item, skipped = L.pick(doc, set(), L.DEFAULT_LANES, "1h") self.assertIsNone(item) self.assertEqual([(i.id, why) for i, why in skipped], [("t-p2", "blocked: a-key"), ("t-p3", "after: t-gone")]) def test_archived_dependency_counts_as_done(self): doc = T.parse(EDIT) T.remove(doc, "t-p0") T.remove(doc, "t-p1") item, skipped = L.pick(doc, {"t-gone"}, L.DEFAULT_LANES, "1h") self.assertEqual(item.id, "t-p3") self.assertEqual([i.id for i, _ in skipped], ["t-p2"]) def test_first_item_when_free(self): item, skipped = L.pick(T.parse(EDIT), set(), L.DEFAULT_LANES, "1h") self.assertEqual((item.id, skipped), ("t-p0", [])) def test_open_dependency_in_file(self): doc = T.parse(EDIT) T.move_to(doc, "t-p0", "deferred") item, skipped = L.pick(doc, set(), L.DEFAULT_LANES, "1h") self.assertIsNone(item) self.assertEqual(skipped[0][1], "after: t-p0") class SliceTest(unittest.TestCase): def test_open_slices(self): doc = T.parse("## Pending\n\n- **t-p** [P1] (10h): Parent.\n\n- **t-p-2** [P1] (1h): Two.\n\n" "- **t-px-1** [P1] (1h): Other.\n") self.assertEqual(T.open_slices(doc, "t-p"), ["t-p-2"]) def test_open_slices_by_name(self): doc = T.parse(SLICED) self.assertEqual(T.open_slices(doc, "t-p"), ["t-map", "t-p-3"]) self.assertEqual(T.parent_of(doc, "t-map"), "t-p") self.assertEqual(T.parent_of(doc, "t-p-3"), "t-p") self.assertIsNone(T.parent_of(doc, "t-q")) def test_pick_skips_parent_with_open_slices(self): item, skipped = L.pick(T.parse(SLICED), {"t-done"}, L.DEFAULT_LANES, "1h") self.assertEqual(item.id, "t-q") self.assertEqual([(i.id, why) for i, why in skipped], [("t-map", "after: t-x"), ("t-p-3", "after: t-map"), ("t-p", "open slices: t-map, t-p-3")]) class RenameTest(unittest.TestCase): def test_rename_changes_id_and_links(self): doc = T.parse(SLICED) n = T.rename(doc, "t-map", "t-map-scaffold", taken={"t-old"}) self.assertEqual(n, 2) self.assertEqual(T.render(doc), SLICED.replace("t-map", "t-map-scaffold")) def test_rename_awaiting_updates_blocked_status(self): doc = T.parse("## Awaiting your decision\n\n- **a-a-foo**: Foo?\n\n## Pending\n\n" "- **t-a** [P1] (1h) (blocked: [[a-a-foo]]): A.\n") self.assertEqual(T.rename(doc, "a-a-foo", "a-foo", taken=set()), 1) self.assertEqual(doc.item("t-a").blocked_on, "a-foo") def test_rename_refusals(self): doc = T.parse(SLICED) for new, err in [("t-q", "id 't-q' already exists"), ("t-old", "id 't-old' already used in the archive"), ("a-map", "a rename keeps the kind"), ("t-Bad", "bad id 't-Bad'")]: with self.assertRaisesRegex(T.TaskError, err): T.rename(doc, "t-map", new, taken={"t-old"}) with self.assertRaisesRegex(T.TaskError, "unknown id 't-none'"): T.rename(doc, "t-none", "t-x", taken=set()) self.assertEqual(T.render(doc), SLICED) SLICED = """\ ## Pending - **t-p** [P1] (10h): Parent. - Slices: [[t-done]], [[t-map]], [[t-p-3]] - **t-map** [P1] (1h): Map. - After: [[t-x]] - **t-p-3** [P1] (1h): Three. - After: [[t-map]] - **t-q** [P2] (1h): Free. """ class ArchiveTest(unittest.TestCase): def test_line(self): item = T.Item(id="t-x", prio=1, effort="1h", text="Cave seams. Close the slit.") self.assertEqual(T.archive_line("2026-09-29", item, "closed with side walls"), "- 2026-09-29 **t-x** Cave seams — closed with side walls") def test_prepend(self): self.assertEqual(T.archive_prepend("# Archive (newest first)\n\n- old one\n- older\n", "- new"), "# Archive (newest first)\n\n- new\n- old one\n- older\n") def test_prepend_to_empty_list(self): self.assertEqual(T.archive_prepend("# Archive (newest first)\n", "- new"), "# Archive (newest first)\n\n- new\n") def test_ids(self): text = "# A\n\n- 2026-09-29 **t-x** X — done\n- old line without id\n- 2026-09-01 **t-y-2** Y — z\n" self.assertEqual(T.archive_ids(text), {"t-x", "t-y-2"}) class ParseBlockTest(unittest.TestCase): def test_block_is_reindented(self): item = T.parse_block("- **t-n** [P2] (1h): New. Goal.\n - Steps: a\n - sub\n Ref: docs/a.md\n") self.assertEqual(item.lines(), ["- **t-n** [P2] (1h): New. Goal.", " - Steps: a", " - sub", " Ref: docs/a.md"]) def test_old_numbered_format_is_refused(self): with self.assertRaisesRegex(T.TaskError, "old numbered format"): T.parse_block("3. **[P2] Old** (Effort: 1h) — goal.") def test_bad_header_is_refused(self): with self.assertRaisesRegex(T.TaskError, "bad header"): T.parse_block("- **t-n** no colon")