diff options
Diffstat (limited to 'tests/test_cli.py')
| -rw-r--r-- | tests/test_cli.py | 752 |
1 files changed, 752 insertions, 0 deletions
diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..43135c9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,752 @@ +import datetime +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = Path(__file__).resolve().parent.parent +WF = HERE / "wf.py" +sys.path.insert(0, str(HERE)) + +TOML = ('format = 1\ntasks = "TASKS.md"\narchive = "tasks/archive.md"\ndocs = ["DESIGN.md", "docs/"]\n' + 'verify = ["make test"]\ndone = ["update CATALOG"]\nledgers = ".superpowers/sdd"\n') +TASKS = """\ +# Tasks — demo + +## Awaiting your decision + +- **a-key**: Key needed. Which one? + +## Pending + +- **t-one** [P1] (1h) (in progress: master): First thing. Do it. + - Steps: a + Ref: DESIGN.md#terrain, docs/plan.md + +- **t-two** [P2] (5h) (blocked: [[a-key]]): Second thing. + +- **t-three** [P2] (<1h): Third thing. Later. + - After: [[t-one]] + +## Needs human + +- **t-play** [P1] (<1h): Play-test feel. + - [ ] keyboard works + +## Deferred + +- **t-far** [P3] (100h): Far future. +""" +ARCHIVE = "# Archive (newest first)\n\n- 2026-09-01 **t-done** Done thing — ok\n- 2026-08-01 **t-older** Older thing — fine\n" +TODAY = datetime.date.today().isoformat() + + +class Cli(unittest.TestCase): + tasks_text = TASKS + toml = TOML + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() / "demo" + (self.root / "tasks").mkdir(parents=True) + (self.root / "docs").mkdir() + (self.root / "workflow.toml").write_text(self.toml) + (self.root / "TASKS.md").write_bytes(self.tasks_text.encode()) + (self.root / "tasks" / "archive.md").write_text(ARCHIVE) + (self.root / "DESIGN.md").write_text("# Design\n\n## terrain\n\nHeightmap.\n") + (self.root / "docs" / "plan.md").write_text("# The Plan\n\n**Goal:** Ship it.\n") + self.inbox = self.root.parent / "inbox.md" + + def tearDown(self): + self.tmp.cleanup() + + def wf(self, *args, stdin="", cwd=None, project=True, env=None, hints=False): + cmd = [sys.executable, str(WF), *(["--project", str(self.root)] if project else []), *args] + full = {**os.environ, "WF_INBOX": str(self.inbox), "WF_ROOT": str(self.root.parent), + "WF_TOOL_ROOT": str(self.root.parent / "no-tool"), "CLAUDE_CONFIG_DIR": str(self.root.parent / "no-claude"), **(env or {})} + r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, cwd=cwd or self.root, env=full, timeout=30) + err = r.stderr if hints else "".join(l for l in r.stderr.splitlines(True) if not l.startswith("hint:")) + return r.returncode, r.stdout, err + + def ok(self, *args, **kw): + code, out, err = self.wf(*args, **kw) + self.assertEqual((code, err), (0, ""), out + err) + return out + + def fails(self, *args, code=1, **kw): + got, out, err = self.wf(*args, **kw) + self.assertEqual(got, code, out + err) + self.assertNotIn("Traceback", err) + return err + + def tasks(self): + return (self.root / "TASKS.md").read_text() + + def archive(self): + return (self.root / "tasks" / "archive.md").read_text() + + def item(self, id): + return self.ok("show", id) + + +class ReadTest(Cli): + def test_list_default_is_pending(self): + self.assertEqual(self.ok("list"), + "t-one P1 1h prog opus slow First thing\n" + "t-two P2 5h blkd opus fast Second thing\n" + "t-three P2 <1h - opus fast Third thing\n" + "pending 3 · human 1 · awaiting 1 · deferred 1\n") + + def test_list_all(self): + self.assertEqual(self.ok("list", "-s", "all"), + "== Awaiting your decision\n" + "a-key -- - - - - Key needed\n" + "== Pending\n" + "t-one P1 1h prog opus slow First thing\n" + "t-two P2 5h blkd opus fast Second thing\n" + "t-three P2 <1h - opus fast Third thing\n" + "== Needs human\n" + "t-play P1 <1h - opus fast Play-test feel\n" + "== Deferred\n" + "t-far P3 100h - opus fast Far future\n" + "pending 3 · human 1 · awaiting 1 · deferred 1\n") + + def first_column(self, *args): + return [l.split()[0] for l in self.ok("list", *args).splitlines()[:-1]] + + def test_list_filters(self): + self.assertEqual(self.first_column("-p", "1"), ["t-one"]) + self.assertEqual(self.first_column("--ready"), ["t-one"]) + self.assertEqual(self.first_column("--blocked"), ["t-two"]) + self.assertEqual(self.first_column("--progress"), ["t-one"]) + self.assertEqual(self.first_column("--ref", "DESIGN.md"), ["t-one"]) + self.assertEqual(self.first_column("--ref", "DESIGN.md#terrain"), ["t-one"]) + self.assertEqual(self.first_column("--ref", "DESIGN.md#other"), []) + self.assertEqual(self.first_column("-n", "2"), ["t-one", "t-two"]) + self.assertEqual(self.first_column("-s", "deferred"), ["t-far"]) + + def test_list_cuts_long_titles_to_100_columns(self): + self.ok("set", "t-far", "--title", "x" * 150) + line = self.ok("list", "-s", "deferred").splitlines()[0] + self.assertEqual(len(line), 100) + self.assertTrue(line.endswith("xxx…")) + + def test_show(self): + self.assertEqual(self.ok("show", "t-three", "a-key"), + "- **t-three** [P2] (<1h): Third thing. Later.\n - After: [[t-one]]\n\n" + "- **a-key**: Key needed. Which one?\n") + + def test_show_by_unique_prefix(self): + self.assertEqual(self.ok("show", "t-thr"), "- **t-three** [P2] (<1h): Third thing. Later.\n - After: [[t-one]]\n") + + def test_show_ambiguous_prefix(self): + self.assertEqual(self.fails("show", "t-t"), "wf: 't-t' matches t-three, t-two\n") + + def test_show_unknown(self): + self.assertRegex(self.fails("show", "t-zzz"), r"^wf: unknown id 't-zzz' \(nearest: .*\)\n$") + + def test_next(self): + self.assertEqual(self.ok("next", "--as", "opus"), + "===== Awaiting your decision (mention, don't block) =====\n" + "- a-key: Key needed. Which one?\n\n" + "===== Needs human (not picked) =====\n" + "- t-play: Play-test feel\n\n" + "===== Lanes =====\n" + "fast: 0 pickable · 2 waiting · no session\n" + "slow: 1 pickable · 0 waiting · no session → orchestrator or owner: start one (wf next --lane slow)\n\n" + "===== Next task =====\n" + "- **t-one** [P1] (1h) (in progress: master): First thing. Do it.\n" + " - Steps: a\n" + " Ref: DESIGN.md#terrain, docs/plan.md\n\n" + "===== DESIGN.md#terrain (line 3) =====\n## terrain\n\nHeightmap.\n\n" + "docs/plan.md: The Plan — Ship it.\n") + + def test_next_brief(self): + self.assertEqual(self.ok("next", "--as", "opus", "--brief"), + "- **t-one** [P1] (1h) (in progress: master): First thing. Do it.\n" + " - Steps: a\n Ref: DESIGN.md#terrain, docs/plan.md\n") + + def test_next_lists_what_it_skipped(self): + self.ok("move", "t-one", "deferred") + self.ok("prio", "t-far", "3") + code, out, err = self.wf("next", "--as", "opus") + self.assertEqual(code, 1) + self.assertIn("===== Skipped =====\n- t-two: blocked: a-key\n- t-three: after: t-one\n", out) + self.assertEqual(err, "wf: nothing pickable for all lanes (opus) in Pending\n") + + def test_next_shows_ledgers_and_inbox(self): + sdd = self.root / ".superpowers" / "sdd" / "p" + sdd.mkdir(parents=True) + (self.root / "docs" / "p.md").write_text("# P\n**Goal:** g\n### Task 1: A\n### Task 2: B\n### Task 3: C\n") + (sdd / "progress.md").write_text("# SDD ledger — plan: docs/p.md\nTask 1: complete (x)\nTask 2: Ruling: y\nTask 2: complete (z)\n") + self.inbox.write_text("- 2026-09-29 demo bug: x @abc\n- 2026-09-29 demo idea: y @abc\n") + out = self.ok("next", "--as", "opus") + self.assertIn("===== In-flight plans =====\n- docs/p.md: done 1, 2; resume at Task 3 (task-start PLAN 3) " + "(1 rulings; ledger .superpowers/sdd/p/progress.md)\n\n", out) + self.assertTrue(out.endswith("\nworkflow inbox: 2 reports (triage: workflow session)\n"), out) + + def test_ctx_item(self): + self.assertEqual(self.ok("ctx", "t-one"), + "- **t-one** [P1] (1h) (in progress: master): First thing. Do it.\n" + " - Steps: a\n" + " Ref: DESIGN.md#terrain, docs/plan.md\n\n" + "Section: Pending\n" + "Needed by: t-three\n" + "Verify (run before wf finish): make test\n\n" + "===== DESIGN.md#terrain (line 3) =====\n## terrain\n\nHeightmap.\n\n" + "docs/plan.md: The Plan — Ship it.\n") + + def test_ctx_dependencies_and_links(self): + out = self.ok("ctx", "t-three") + self.assertIn("Section: Pending\nAfter: t-one (open, Pending)\n", out) + self.assertEqual(self.ok("ctx", "a-key").split("\n\n", 1)[1], "Section: Awaiting your decision\nBlocks: t-two\n") + + def test_ctx_archived_id(self): + self.assertEqual(self.ok("ctx", "t-done"), "done: - 2026-09-01 **t-done** Done thing — ok\n") + + def test_ctx_anchor(self): + self.assertEqual(self.ok("ctx", "DESIGN.md#terrain"), + "===== DESIGN.md#terrain (line 3) =====\n## terrain\n\nHeightmap.\n\n" + "Tasks: t-one\n") + + def test_search(self): + self.assertEqual(self.ok("search", "thing", "later"), + "t-three Third thing. Later.\n" + "t-one First thing. Do it.\n" + "t-two Second thing.\n" + "tasks/archive.md:3 t-done Done thing — ok\n" + "tasks/archive.md:4 t-older Older thing — fine\n") + + def test_search_docs(self): + self.assertEqual(self.ok("search", "--docs", "height"), "DESIGN.md:3 terrain — Heightmap.\n") + + def test_search_nothing(self): + self.assertEqual(self.fails("search", "zzz"), "wf: no hits\n") + + def test_log(self): + self.assertEqual(self.ok("log", "-n", "1"), "- 2026-09-01 **t-done** Done thing — ok\n") + self.assertEqual(self.ok("log", "older"), "- 2026-08-01 **t-older** Older thing — fine\n") + + def test_check_clean(self): + self.assertEqual(self.ok("check"), "OK: 0 errors · 0 warnings\n") + + def test_check_errors(self): + (self.root / "TASKS.md").write_text(TASKS.replace("[[t-one]]", "[[t-gone]]")) + code, out, err = self.wf("check") + self.assertEqual((code, err), (1, "")) + self.assertEqual(out, "ERROR: TASKS.md:16: [[t-gone]]: no such id in TASKS or archive\n1 errors · 0 warnings\n") + + def test_projects(self): + other = self.root.parent / "group" / "other" + (other / "tasks").mkdir(parents=True) + (other / "workflow.toml").write_text('format = 1\ntasks = "TASKS.md"\narchive = "tasks/archive.md"\n') + (other / "TASKS.md").write_text("## Awaiting your decision\n\n## Pending\n\n- **t-x** [P9] (1h): X.\n\n## Needs human\n\n## Deferred\n") + (other / "tasks" / "archive.md").write_text("# Archive\n") + hidden = self.root.parent / ".gate" + hidden.mkdir() + (hidden / "workflow.toml").write_text(TOML) + tool = self.root.parent / "workflow" # a wf checkout: its templates/ is no project + (tool / "wflib").mkdir(parents=True) + (tool / "templates").mkdir() + (tool / "wf.py").write_text("") + (tool / "templates" / "workflow.toml").write_text(TOML) + self.assertEqual(self.ok("projects", project=False), + "demo pending 3 human 1 awaiting 1 errors 0 next: t-one First thing\n" + "group/other pending 1 human 0 awaiting 0 errors 1 next: t-x X\n") + + def test_outside_a_project(self): + err = self.fails("list", project=False, cwd=self.root.parent) + self.assertRegex(err, r"^wf: no workflow.toml in .* or above: not a wf project \(wf init makes one\)\n$") + + def test_broken_config(self): + (self.root / "workflow.toml").write_text(TOML + "verfy = []\n") + self.assertEqual(self.fails("list"), "wf: workflow.toml: unknown key 'verfy'\n") + + def test_finds_project_from_subfolder(self): + self.assertIn("t-one", self.ok("list", project=False, cwd=self.root / "docs")) + + def test_no_command_is_usage_error(self): + self.fails(code=2) + + +class AddTest(Cli): + def test_add_places_by_priority_and_prints_header(self): + out = self.ok("add", "Cave seams. Close the slit.", "-p", "1", "-e", "<1h") + self.assertEqual(out, "- **t-cave-seams** [P1] (<1h): Cave seams. Close the slit.\n") + self.assertEqual(self.first_ids(), ["t-one", "t-cave-seams", "t-two", "t-three"]) + + def first_ids(self, section="pending"): + return [l.split()[0] for l in self.ok("list", "-s", section).splitlines()[:-1]] + + def test_add_with_options(self): + self.ok("add", "Docs pass", "-p", "2", "-e", "5h", "--sessions", "owner", "--id", "t-docs", + "--after", "t-one,t-done", "--ref", "docs/plan.md,DESIGN.md#terrain") + self.assertEqual(self.item("t-docs"), + "- **t-docs** [P2] (5h): Docs pass.\n" + " Sessions: owner\n" + " - After: [[t-one]], [[t-done]]\n" + " Ref: docs/plan.md, DESIGN.md#terrain\n") + + def test_add_cloud(self): + self.ok("add", "Code fix", "-p", "1", "-e", "1h", "--done", "tests pass", "--model", "sonnet", "--cloud", "yes", + "--id", "t-cf") + self.assertEqual(self.item("t-cf"), "- **t-cf** [P1] (1h): Code fix.\n Done: tests pass\n" + " Model: sonnet\n Cloud: yes\n") + self.ok("add", "Gui fix", "-p", "1", "-e", "1h", "--cloud", "no", "--id", "t-gf") + self.assertIn(" Cloud: no\n", self.item("t-gf")) + + def test_add_body_from_stdin(self): + self.ok("add", "With body", "-p", "3", "-e", "1h", "-b", stdin="- Steps: a\n - sub\n- Done: b\n") + self.assertEqual(self.item("t-with-body"), + "- **t-with-body** [P3] (1h): With body.\n - Steps: a\n - sub\n - Done: b\n") + + def test_add_to_other_sections(self): + self.assertEqual(self.ok("add", "Which colour. Red or blue?", "-s", "awaiting"), + "- **a-which-colour** : Which colour. Red or blue?\n".replace(" :", ":")) + self.ok("add", "Feel check", "-p", "2", "-e", "<1h", "-s", "human") + self.assertEqual(self.first_ids("human"), ["t-play", "t-feel-check"]) + + def test_add_slice(self): + self.ok("add", "Map scaffold", "-e", "1h", "--parent", "t-far") + self.ok("add", "Combat rows", "-e", "1h", "--parent", "t-far") + self.assertEqual(self.ok("show", "t-far", "t-far-1", "t-far-2"), + "- **t-far** [P3] (100h): Far future.\n - Slices: [[t-far-1]], [[t-far-2]]\n\n" + "- **t-far-1** [P3] (1h): Map scaffold.\n\n" + "- **t-far-2** [P3] (1h): Combat rows.\n - After: [[t-far-1]]\n") + self.assertEqual(self.first_ids("deferred"), ["t-far", "t-far-1", "t-far-2"]) + + def test_add_slice_skips_deferred_previous_slice(self): + self.ok("add", "Map scaffold", "-e", "1h", "--parent", "t-far") + self.ok("add", "Combat rows", "-e", "1h", "--parent", "t-far", "-s", "pending") + self.ok("add", "Loot rows", "-e", "1h", "--parent", "t-far", "-s", "pending") + self.assertEqual(self.ok("show", "t-far-2", "t-far-3"), + "- **t-far-2** [P3] (1h): Combat rows.\n\n" + "- **t-far-3** [P3] (1h): Loot rows.\n - After: [[t-far-2]]\n") + + def test_add_named_slice_chains_after_previous(self): + self.ok("add", "Map scaffold", "-e", "1h", "--parent", "t-far", "--id", "t-map") + self.ok("add", "Combat rows", "-e", "1h", "--parent", "t-far", "--id", "t-combat") + self.assertEqual(self.ok("show", "t-far", "t-combat"), + "- **t-far** [P3] (100h): Far future.\n - Slices: [[t-map]], [[t-combat]]\n\n" + "- **t-combat** [P3] (1h): Combat rows.\n - After: [[t-map]]\n") + self.assertEqual(self.fails("done", "t-far", "-m", "x"), "wf: 't-far' has open slices: t-map, t-combat\n") + self.ok("done", "t-map", "-m", "x") + self.assertIn("last slice of t-far: finish it", self.ok("done", "t-combat", "-m", "x")) + + def test_add_leading_id_in_text(self): + self.assertEqual(self.ok("add", "a-colour: Which colour? Red or blue?", "-s", "awaiting"), + "- **a-colour**: Which colour? Red or blue?\n") + self.assertEqual(self.ok("add", "t-seams: Cave seams. Close them", "-p", "1", "-e", "1h"), + "- **t-seams** [P1] (1h): Cave seams. Close them.\n") + + def test_add_help_says_id_works_with_parent(self): + self.assertIn("--parent", self.ok("add", "-h").split("--id ID", 2)[2].split("--after")[0]) + + def test_add_block(self): + self.ok("add", "-", stdin="- **t-blk** [P0] (1h): Block. Goal.\n - Steps: a\n Done: x\n") + self.assertEqual(self.item("t-blk"), "- **t-blk** [P0] (1h): Block. Goal.\n - Steps: a\n Done: x\n") + self.assertEqual(self.first_ids()[0], "t-blk") + + def test_add_block_in_old_format_is_refused(self): + self.assertEqual(self.fails("add", "-", stdin="3. **[P2] Old** (Effort: 1h) — goal.\n"), + "wf: old numbered format: write '- **id** [Pn] (effort): Title. Goal.'\n") + + def test_add_needs_priority_and_effort(self): + self.assertIn("-p", self.fails("add", "No prio", "-e", "1h", code=2)) + self.assertIn("-e", self.fails("add", "No effort", "-p", "1", code=2)) + self.assertEqual(self.tasks(), TASKS) + + def test_add_title_without_letters(self): + self.assertEqual(self.fails("add", "???", "-p", "1", "-e", "1h"), + "wf: no id can be made from title '???': give one with --id\n") + + def test_add_same_title_twice(self): + self.ok("add", "Cave seams", "-p", "1", "-e", "1h") + self.assertEqual(self.ok("add", "Cave seams", "-p", "1", "-e", "1h"), "- **t-cave-seams-2** [P1] (1h): Cave seams.\n") + + def test_add_refused_when_result_fails_check(self): + self.assertEqual(self.fails("add", "Bad ref", "-p", "1", "-e", "1h", "--ref", "docs/none.md"), + "wf: refused: nothing written, the change adds problems:\n TASKS.md:14: t-bad-ref: Ref 'docs/none.md' does not exist\n") + self.assertEqual(self.tasks(), TASKS) + + def test_dry_run(self): + out = self.ok("add", "Cave seams", "-p", "1", "-e", "1h", "--dry-run") + self.assertIn("+- **t-cave-seams** [P1] (1h): Cave seams.\n", out) + self.assertIn("--- TASKS.md\n+++ TASKS.md (new)\n", out) + self.assertEqual(self.tasks(), TASKS) + + +class DoneTest(Cli): + def test_done_builtin_checklist_empty_cfg(self): + self.toml = TOML.replace('verify = ["make test"]\ndone = ["update CATALOG"]\n', '') + self.setUp() + out = self.ok("done", "t-one", "-m", "x") + self.assertEqual(out, "done: t-one → tasks/archive.md\nfast work now pickable: t-three (no session: tell the owner)\nchecklist:\n - re-learned anything (>3 greps to find)? one anchor line → that area's code map\n") + + def test_done(self): + out = self.ok("done", "t-one", "-m", "shipped\nwith care") + self.assertEqual(out, "done: t-one → tasks/archive.md\nfast work now pickable: t-three (no session: tell the owner)\nverify:\n make test\nchecklist:\n - re-learned anything (>3 greps to find)? one anchor line → that area's code map\n - update CATALOG\n") + self.assertNotIn("t-one**", self.tasks()) + self.assertEqual(self.archive().splitlines()[2], f"- {TODAY} **t-one** First thing — shipped with care") + self.assertEqual(self.ok("check"), "OK: 0 errors · 0 warnings\n") + self.assertEqual(self.ok("next", "--as", "opus", "--brief").splitlines()[0], "- **t-three** [P2] (<1h): Third thing. Later.") + + def test_done_needs_entry(self): + self.assertEqual(self.fails("done", "t-one", code=2), "wf: done: -m \"<entry>\" is required for a single task\n") + + def test_done_several_uses_goals(self): + self.ok("done", "t-one", "t-three") + self.assertEqual(self.archive().splitlines()[2:4], + [f"- {TODAY} **t-three** Third thing — Later.", f"- {TODAY} **t-one** First thing — Do it."]) + + def test_done_refuses_parent_with_open_slices(self): + self.ok("add", "Map scaffold", "-e", "1h", "--parent", "t-far") + self.assertEqual(self.fails("done", "t-far", "-m", "x"), "wf: 't-far' has open slices: t-far-1\n") + + def test_done_awaiting_item_unblocks(self): + self.assertEqual(self.ok("done", "a-key"), "removed: a-key\nunblocked: t-two\n") + self.assertEqual(self.item("t-two"), "- **t-two** [P2] (5h): Second thing.\n") + self.assertEqual(self.archive(), ARCHIVE) + + def test_done_unknown(self): + self.assertRegex(self.fails("done", "t-on", "-m", "x"), r"^wf: unknown id 't-on' \(nearest: t-one") + self.assertEqual(self.archive(), ARCHIVE) + + +class EditTest(Cli): + def order(self): + return [l.split()[0] for l in self.ok("list").splitlines()[:-1]] + + def test_prio(self): + self.assertEqual(self.ok("prio", "t-three", "0"), "- **t-three** [P0] (<1h): Third thing. Later.\n") + + def test_prio_refused_when_it_jumps_a_dependency(self): + # t-three is After: t-one, the insert rule keeps it behind t-one + self.ok("prio", "t-three", "0") + self.assertEqual(self.order(), ["t-one", "t-three", "t-two"]) + + def test_move_section(self): + self.assertEqual(self.ok("move", "t-two", "deferred"), "- **t-two** [P2] (5h) (blocked: [[a-key]]): Second thing.\n") + self.assertEqual(self.order(), ["t-one", "t-three"]) + + def test_move_relative(self): + self.ok("move", "t-three", "--before", "t-two") + self.assertEqual(self.order(), ["t-one", "t-three", "t-two"]) + self.assertEqual(self.fails("move", "t-two", "--before", "t-one"), + "wf: moving 't-two' there breaks priority order (wf prio, or --force)\n") + self.ok("move", "t-two", "--before", "t-one", "--force") + self.assertEqual(self.order(), ["t-two", "t-one", "t-three"]) + + def test_move_needs_one_target(self): + self.fails("move", "t-two", code=2) + self.fails("move", "t-two", "deferred", "--before", "t-one", code=2) + + def test_status(self): + self.assertEqual(self.ok("status", "t-three", "progress", "feature/x"), + "- **t-three** [P2] (<1h) (in progress: feature/x): Third thing. Later.\n") + self.assertEqual(self.ok("status", "t-three", "blocked", "a-key"), + "- **t-three** [P2] (<1h) (blocked: [[a-key]]): Third thing. Later.\n") + self.assertEqual(self.ok("status", "t-three", "clear"), "- **t-three** [P2] (<1h): Third thing. Later.\n") + self.assertEqual(self.fails("status", "t-three", "blocked", "a-none"), "wf: 'a-none' is not an open Awaiting item\n") + self.fails("status", "t-three", "progress", code=2) + + def test_set(self): + self.ok("set", "t-two", "--title", "Second: renamed", "--effort", "10h", "--sessions", "owner", + "--after", "t-one", "--ref", "docs/plan.md (goal)") + self.assertEqual(self.item("t-two"), + "- **t-two** [P2] (10h) (blocked: [[a-key]]): Second: renamed.\n" + " Sessions: owner\n - After: [[t-one]]\n Ref: docs/plan.md (goal)\n") + self.ok("set", "t-two", "--after", "", "--ref", "", "--sessions", "") + self.assertEqual(self.item("t-two"), "- **t-two** [P2] (10h) (blocked: [[a-key]]): Second: renamed.\n") + + def test_set_done(self): + self.ok("set", "t-two", "--done", "two works") + self.assertEqual(self.item("t-two"), "- **t-two** [P2] (5h) (blocked: [[a-key]]): Second thing.\n Done: two works\n") + self.ok("set", "t-two", "--done", "") + self.assertEqual(self.item("t-two"), "- **t-two** [P2] (5h) (blocked: [[a-key]]): Second thing.\n") + + def test_rename(self): + self.assertEqual(self.ok("rename", "a-key", "a-which-key"), "renamed: a-key → a-which-key (1 link)\n") + self.assertEqual(self.item("t-two"), "- **t-two** [P2] (5h) (blocked: [[a-which-key]]): Second thing.\n") + self.assertEqual(self.fails("rename", "t-one", "t-done"), "wf: id 't-done' already used in the archive (ids are never reused)\n") + + def test_set_refused_on_bad_ref(self): + self.assertIn("Ref 'missing.md' does not exist", self.fails("set", "t-two", "--ref", "missing.md")) + self.assertEqual(self.tasks(), TASKS) + + def test_set_without_fields(self): + self.fails("set", "t-two", code=2) + + def test_note(self): + self.ok("note", "t-one", "cause found") + self.assertEqual(self.item("t-one"), + "- **t-one** [P1] (1h) (in progress: master): First thing. Do it.\n" + " - Steps: a\n - cause found\n Ref: DESIGN.md#terrain, docs/plan.md\n") + + def test_body(self): + self.ok("body", "t-one", stdin="- Steps: b\n- Done: c\n") + self.assertEqual(self.item("t-one"), + "- **t-one** [P1] (1h) (in progress: master): First thing. Do it.\n" + " - Steps: b\n - Done: c\n Ref: DESIGN.md#terrain, docs/plan.md\n") + + def test_body_refusal_says_refused_and_writes_nothing(self): + before = self.tasks() + for _ in range(2): + err = self.fails("body", "t-one", stdin="- After: [[t-three]]\n") + self.assertTrue(err.startswith("wf: refused: nothing written"), err) + self.assertIn("placed before 't-three'", err) + self.assertEqual(self.tasks(), before) + + def test_body_positional_text_hints_stdin(self): + self.assertIn("stdin", self.fails("body", "t-one", "some", "text")) + self.assertIn("stdin", self.ok("body", "-h")) + + def test_tick(self): + self.assertEqual(self.ok("tick", "t-play", "keyboard"), "ticked: keyboard works\n") + self.assertIn(" - [x] keyboard works\n", self.tasks()) + self.assertEqual(self.fails("tick", "t-play", "1"), "wf: box already ticked: keyboard works\n") + + def test_write_needs_exact_id(self): + self.assertRegex(self.fails("prio", "t-thr", "1"), r"^wf: unknown id 't-thr' \(nearest: t-three") + + def test_untouched_text_survives_a_write(self): + self.ok("note", "t-far", "x") + self.assertEqual(self.tasks(), TASKS.replace("Far future.\n", "Far future.\n - x\n")) + + +class OldProblemsTest(Cli): + tasks_text = TASKS + "\n## Notes\n\nSee [[t-lost]].\n" + + def test_existing_problems_do_not_block_other_edits(self): + self.ok("note", "t-far", "x") + self.assertIn(" - x\n", self.tasks()) + + +class CrlfTest(Cli): + tasks_text = TASKS.replace("\n", "\r\n") + + def test_write_keeps_crlf(self): + self.ok("add", "Cave seams", "-p", "1", "-e", "1h") + raw = (self.root / "TASKS.md").read_bytes() + self.assertIn(b"- **t-cave-seams** [P1] (1h): Cave seams.\r\n", raw) + self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) + self.assertTrue(raw.endswith(b"Far future.\r\n")) + + def test_read_commands_work(self): + self.assertEqual(self.ok("show", "a-key"), "- **a-key**: Key needed. Which one?\n") + + +class FormatGateTest(Cli): + toml = TOML.replace("format = 1", "format = 0") + + def test_write_refused(self): + self.assertEqual(self.fails("note", "t-one", "x"), + "wf: TASKS format 0, wf needs 1: run wf migrate --write (idle project, one commit)\n") + self.assertEqual(self.tasks(), TASKS) + + def test_read_works(self): + self.assertIn("t-one", self.ok("list")) + + +class NewerFormatTest(Cli): + toml = TOML.replace("format = 1", "format = 2") + + def test_refused(self): + self.assertEqual(self.fails("list"), "wf: project format 2 is newer than this wf (1): update /projects/public/workflow\n") + + +class ReportTest(Cli): + def test_report_line(self): + self.assertEqual(self.ok("report", "prio change\nneeds two commands", "--kind", "friction", "--cmd", "wf prio x 1"), + "reported (workflow inbox); carry on\n") + self.assertRegex(self.inbox.read_text(), + rf"^- {TODAY} demo friction: prio change needs two commands \(cmd: wf prio x 1\) @[0-9a-f]{{7}}\n$") + + def test_default_kind_and_outside_project(self): + self.ok("report", "something", project=False, cwd=self.root.parent) + self.assertRegex(self.inbox.read_text(), rf"^- {TODAY} {self.root.parent.name} bug: something @") + + def test_parallel_reports_stay_whole(self): + env = {**os.environ, "WF_INBOX": str(self.inbox)} + procs = [subprocess.Popen([sys.executable, str(WF), "--project", str(self.root), "report", f"report {n} " + "x" * 3000], + env=env, stdout=subprocess.DEVNULL) for n in range(8)] + for p in procs: + self.assertEqual(p.wait(timeout=30), 0) + lines = self.inbox.read_text().splitlines() + self.assertEqual(len(lines), 8) + for line in lines: + self.assertRegex(line, rf"^- {TODAY} demo bug: report \d x{{3000}} @[0-9a-f]{{7}}$") + + def test_empty_report(self): + self.fails("report", " ", code=2) + + +class InitTest(Cli): + def test_init(self): + new = self.root.parent / "fresh" + new.mkdir() + self.assertEqual(self.ok("init", project=False, cwd=new), + "wrote workflow.toml\nwrote TASKS.md\nwrote tasks/archive.md\nwrote CLAUDE.md\n") + self.assertTrue((new / "TASKS.md").read_text().startswith("# Tasks — fresh\n")) + self.assertTrue((new / "CLAUDE.md").read_text().startswith("# CLAUDE.md — fresh\n")) + self.assertEqual(self.ok("check", project=False, cwd=new), "OK: 0 errors · 0 warnings\n") + self.assertEqual(self.ok("add", "First", "-p", "1", "-e", "1h", project=False, cwd=new), "- **t-first** [P1] (1h): First.\n") + + def test_init_twice(self): + self.assertEqual(self.fails("init"), f"wf: {self.root}/workflow.toml exists already\n") + + def test_init_keeps_existing_tasks_file(self): + new = self.root.parent / "old" + new.mkdir() + (new / "TASKS.md").write_text("mine\n") + self.assertEqual(self.ok("init", project=False, cwd=new), + "wrote workflow.toml\nkept TASKS.md (exists; old format → wf migrate)\nwrote tasks/archive.md\nwrote CLAUDE.md\n") + self.assertEqual((new / "TASKS.md").read_text(), "mine\n") + + def test_init_keeps_existing_claude_md(self): + new = self.root.parent / "oldc" + new.mkdir() + (new / "CLAUDE.md").write_text("mine\n") + out = self.ok("init", project=False, cwd=new) + self.assertIn("kept CLAUDE.md (exists)\n", out) + self.assertEqual((new / "CLAUDE.md").read_text(), "mine\n") + + def test_init_inside_a_project_subfolder_makes_a_new_project_there(self): + self.assertEqual(self.ok("init", project=False, cwd=self.root / "docs").splitlines()[0], "wrote workflow.toml") + self.assertTrue((self.root / "docs" / "workflow.toml").is_file()) + + +class ProjectsRootTest(unittest.TestCase): + def setUp(self): + import wf + self.wf = wf + self.tmp = tempfile.TemporaryDirectory() + self.top = Path(self.tmp.name) + self.tool = self.top / "public" / "workflow" + self.tool.mkdir(parents=True) + + def tearDown(self): + self.tmp.cleanup() + + def project(self, rel): + (self.top / rel).mkdir(parents=True) + (self.top / rel / "workflow.toml").write_text("") + + def test_parent_with_projects_wins(self): + self.project("public/a") + self.project("games/b") + self.assertEqual(self.wf.default_root(self.tool), self.top / "public") + + def test_parent_without_projects_falls_back_to_grandparent(self): + self.project("games/b") + self.assertEqual(self.wf.default_root(self.tool), self.top) + + def test_no_projects_anywhere_keeps_parent(self): + self.assertEqual(self.wf.default_root(self.tool), self.top / "public") + + +class WriteSafetyTest(unittest.TestCase): + def setUp(self): + import wf + self.wf = wf + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "TASKS.md" + self.path.write_text("one\n") + + def tearDown(self): + self.tmp.cleanup() + + def test_write(self): + stamp = self.wf.stamp(self.path) + self.wf.write_if_unchanged(self.path, "two\n", stamp) + self.assertEqual(self.path.read_text(), "two\n") + self.assertEqual(sorted(p.name for p in self.path.parent.iterdir()), ["TASKS.md"]) + + def test_stops_when_the_file_changed_since_read(self): + stamp = self.wf.stamp(self.path) + self.path.write_text("someone else wrote this\n") + with self.assertRaisesRegex(self.wf.Failure, "TASKS.md changed on disk since it was read: nothing written, run again"): + self.wf.write_if_unchanged(self.path, "two\n", stamp) + self.assertEqual(self.path.read_text(), "someone else wrote this\n") + + def test_keeps_file_mode(self): + self.path.chmod(0o640) + self.wf.write_if_unchanged(self.path, "two\n", self.wf.stamp(self.path)) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o640) + + +if __name__ == "__main__": + unittest.main() + + +OLD_TASKS = """\ +# Tasks — demo + +## Pending + +1. **[P1] First thing** (Effort: 1h) — do it. + - Steps: a + Reference: [DESIGN.md#terrain](DESIGN.md#terrain) + +2. **[P2] Second thing** (Effort: 5h) — later. + - After: First thing + +## Needs human + +## Awaiting your decision + +- Which key to use. +""" +MIGRATED = """\ +# Tasks — demo + +## Pending + +- **t-first-thing** [P1] (1h): First thing. do it. + - Steps: a + Ref: DESIGN.md#terrain + +- **t-second-thing** [P2] (5h): Second thing. later. + - After: [[t-first-thing]] + +## Needs human + +## Awaiting your decision + +- **a-which-key-to-use**: Which key to use. + +## Deferred +""" + + +class MigrateCliTest(Cli): + tasks_text = OLD_TASKS + toml = TOML.replace("format = 1", "format = 0") + + def test_dry_run_prints_diff_and_map_and_writes_nothing(self): + out = self.ok("migrate") + self.assertIn("+- **t-first-thing** [P1] (1h): First thing. do it.\n", out) + self.assertIn("\nids:\n t-first-thing First thing\n t-second-thing Second thing\n a-which-key-to-use Which key to use\n", out) + self.assertTrue(out.endswith("check after migrate: 0 errors\ndry run: nothing written (wf migrate --write)\n"), out) + self.assertEqual(self.tasks(), OLD_TASKS) + self.assertIn("format = 0", (self.root / "workflow.toml").read_text()) + + def test_write(self): + out = self.ok("migrate", "--write") + self.assertTrue(out.endswith("check after migrate: 0 errors\nwrote TASKS.md, workflow.toml (format = 1)\n"), out) + self.assertEqual(self.tasks(), MIGRATED) + self.assertEqual((self.root / "workflow.toml").read_text(), TOML) + self.assertEqual(self.ok("check"), "OK: 0 errors · 0 warnings\n") + self.assertEqual(self.ok("next", "--as", "opus", "--brief").splitlines()[0], "- **t-first-thing** [P1] (1h): First thing. do it.") + + def test_second_run_changes_nothing(self): + self.ok("migrate", "--write") + self.assertEqual(self.ok("migrate", "--write"), "nothing to migrate\n") + self.assertEqual(self.tasks(), MIGRATED) + + def test_ids_avoid_archived_ones(self): + (self.root / "tasks" / "archive.md").write_text("# A\n\n- 2026-09-01 **t-first-thing** First thing — old\n") + self.ok("migrate", "--write") + self.assertIn("- **t-first-thing-2** [P1] (1h): First thing. do it.\n", self.tasks()) |
