import subprocess import tempfile import unittest from pathlib import Path from test_cli import Cli from test_claims import git from test_merge import IDENT from wflib import areas # noqa: E402 (tests/ run with the repo root on sys.path) NOTES = """\ # CLAUDE.md ## Areas ### Parser - Code map: `parse_item`, `NOPE_GONE` - Test recipe: python3 -m unittest tests.test_p - Paths: src/ - Checked: {sha} ### Docs - Code map: `intro` ## Gotchas - x """ def build(root: Path) -> tuple[str, str]: """Repo with src/p.py + docs.md, then 3 commits touching src/; returns (sha1, notes).""" git(root, "init", "-q", "-b", "master") (root / "src").mkdir(exist_ok=True) (root / "src" / "p.py").write_text("def parse_item(): pass\n") (root / "docs.md").write_text("intro\n") (root / ".gitignore").write_text(".wf/\n") git(root, "add", "-A") git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1") sha1 = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=root, capture_output=True, text=True).stdout.strip() for n in "abc": (root / "src" / f"{n}.txt").write_text(n) git(root, "add", "-A") git(root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", n) return sha1, NOTES.format(sha=sha1) class AreasTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name).resolve() self.sha1, self.notes = build(self.root) def tearDown(self): self.tmp.cleanup() def test_parse(self): a = areas.parse(NOTES.format(sha="abc1234")) self.assertEqual([(x.name, x.slug, x.anchors, x.paths, x.checked) for x in a], [ ("Parser", "parser", ["parse_item", "NOPE_GONE"], ["src/"], "abc1234"), ("Docs", "docs", ["intro"], [], None)]) def test_missing_and_commits(self): parser, docs = areas.parse(self.notes) self.assertEqual(areas.missing(self.root, parser), ["NOPE_GONE"]) self.assertEqual(areas.commits_since(self.root, parser), 3) self.assertEqual(areas.missing(self.root, docs), []) self.assertIsNone(areas.commits_since(self.root, docs)) def test_stale(self): parser, docs = areas.parse(self.notes) self.assertEqual(areas.status(self.root, parser, 20), (True, ["NOPE_GONE"], 3)) self.assertEqual(areas.status(self.root, docs, 20), (False, [], None)) fixed = areas.parse(self.notes.replace(", `NOPE_GONE`", ""))[0] self.assertEqual(areas.status(self.root, fixed, 3), (True, [], 3)) self.assertEqual(areas.status(self.root, fixed, 4), (False, [], 3)) def test_unknown_checked_sha(self): a = areas.parse(NOTES.format(sha="deadbee"))[0] self.assertIsNone(areas.commits_since(self.root, a)) def test_mark(self): out = areas.mark(NOTES.format(sha="abc1234"), "Docs", "f00ba44") self.assertIn("### Docs\n- Code map: `intro`\n- Checked: f00ba44\n\n## Gotchas", out) out = areas.mark(out, "Parser", "1111111") self.assertIn("- Paths: src/\n- Checked: 1111111\n", out) class MatchTest(unittest.TestCase): def test_matches(self): parser, docs = areas.parse(NOTES.format(sha="abc1234")) self.assertTrue(areas.matches(parser, "Fix src/p.py crash")) self.assertTrue(areas.matches(parser, "see x.py/src/p.py")) self.assertTrue(areas.matches(parser, "parse_item() loops")) self.assertTrue(areas.matches(parser, "the parser area")) self.assertFalse(areas.matches(parser, "parse_items and srcs")) self.assertFalse(areas.matches(docs, "introduction")) self.assertTrue(areas.matches(docs, "docs intro")) self.assertFalse(areas.matches(parser, "Fix src/p.py crash", {"src"})) def test_shared_paths(self): a = areas.parse("## Areas\n### A\n- Paths: x.py lib/\n### B\n- Paths: ./x.py y.py\n### C\n- Paths: lib\n") self.assertEqual(areas.shared_paths(a), {"x.py", "lib"}) def test_block(self): text = NOTES.format(sha="abc1234") self.assertEqual(areas.block(text, "Docs"), "### Docs\n- Code map: `intro`") self.assertEqual(areas.block(text, "Parser").splitlines()[-1], "- Checked: abc1234") self.assertEqual(areas.block(text, "X"), "") class CodeRootTest(Cli): """code_root: area anchors / Checked / staleness live in another repo than the project.""" def setUp(self): super().setUp() self.code = self.root.parent / "code" self.code.mkdir() self.sha1, notes = build(self.code) (self.root / "CLAUDE.md").write_text(notes) with open(self.root / "workflow.toml", "a") as f: f.write('code_root = "../code"\n') git(self.root, "init", "-q", "-b", "master") (self.root / "engine").mkdir() (self.root / "engine" / "x.py").write_text("x") def test_areas_use_code_root(self): self.assertEqual(self.ok("areas"), f"Parser: stale · missing: NOPE_GONE · 3 commits since {self.sha1}\n" "Docs: ok · no Checked\n") def test_mark_uses_code_head(self): self.ok("areas", "--mark", "Docs") head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=self.code, capture_output=True, text=True).stdout.strip() self.assertIn(f"- Checked: {head}", (self.root / "CLAUDE.md").read_text()) def test_check_uses_code_root(self): out = self.ok("check") self.assertIn("area Parser: anchor NOPE_GONE not found", out) self.assertNotIn("Checked", out) self.assertNotIn("anchor intro", out) def test_no_uncovered_nudge(self): self.assertNotIn("uncovered", self.ok("areas")) class CtxAreaTest(Cli): tasks_text = ("# T\n\n## Pending\n\n- **t-a** [P1] (<1h): A. Fix src/p.py.\n\n" "- **t-b** [P1] (<1h): B. Unrelated.\n") def setUp(self): super().setUp() (self.root / "CLAUDE.md").write_text(NOTES.format(sha="abc1234")) def test_ctx_prints_matching_area(self): out = self.ok("ctx", "t-a") self.assertIn("\nArea Parser (CLAUDE.md):\n### Parser\n- Code map: `parse_item`, `NOPE_GONE`\n" "- Test recipe: python3 -m unittest tests.test_p\n", out) self.assertNotIn("### Docs", out) def test_ctx_no_match(self): self.assertNotIn("Area ", self.ok("ctx", "t-b")) def test_ctx_shared_path_no_match(self): (self.root / "CLAUDE.md").write_text(NOTES.format(sha="abc1234").replace("- Code map: `intro`", "- Code map: `intro`\n- Paths: src/")) self.assertNotIn("Area ", self.ok("ctx", "t-a")) class AreasCliTest(Cli): def setUp(self): super().setUp() sha1, notes = build(self.root) self.sha1 = sha1 (self.root / "CLAUDE.md").write_text(notes) def test_areas_output(self): out = self.ok("areas") self.assertEqual(out, f"Parser: stale · missing: NOPE_GONE · 3 commits since {self.sha1}\n" "Docs: ok · no Checked\n") def test_mark_then_show(self): self.ok("areas", "--mark", "Docs") head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=self.root, capture_output=True, text=True).stdout.strip() self.assertEqual(self.ok("areas", "Docs"), f"Docs: ok · 0 commits since {head}\n") def test_mark_unique_prefix(self): self.ok("areas", "--mark", "Do") head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=self.root, capture_output=True, text=True).stdout.strip() self.assertEqual(self.ok("areas", "Doc"), f"Docs: ok · 0 commits since {head}\n") def test_ambiguous_prefix(self): txt = (self.root / "CLAUDE.md").read_text().replace("### Docs", "### Parser2 (x)") (self.root / "CLAUDE.md").write_text(txt) err = self.fails("areas", "--mark", "Par", code=2) self.assertIn("ambiguous area 'Par'", err) self.assertIn("Parser, Parser2 (x)", err) self.ok("areas", "--mark", "Parser") def test_unknown_area(self): err = self.fails("areas", "X", code=2) self.assertIn("wf: no area 'X' in CLAUDE.md (Parser, Docs)", err) def test_check_warnings(self): out = self.ok("check") self.assertIn("area Parser: anchor NOPE_GONE not found", out) (self.root / "CLAUDE.md").write_text(NOTES.format(sha="deadbee").replace("### Parser", "### X")) self.assertIn("area X: Checked deadbee unknown", self.ok("check")) class AutoTaskTest(Cli): tasks_text = "# T\n\n## Pending\n\n- **t-a** [P1] (<1h): A. Do.\n\n- **t-b** [P1] (<1h): B. Do.\n" def setUp(self): super().setUp() sha1, notes = build(self.root) (self.root / "CLAUDE.md").write_text(notes) def test_auto_task(self): out = self.ok("done", "t-a", "-m", "x") self.assertIn("added t-map-parser (area map stale)", out) shown = self.ok("show", "t-map-parser") self.assertIn("- **t-map-parser** [P1] (<1h): Refresh Parser area map.", shown) self.assertIn("Steps: wf areas Parser; fix missing anchors + test recipe from git log --stat " "..HEAD -- ; wf areas --mark Parser.", shown) self.assertIn("Done: wf areas Parser shows ok.", shown) self.assertIn("Model: sonnet", shown) def test_auto_task_once(self): self.ok("done", "t-a", "-m", "x") out = self.ok("done", "t-b", "-m", "y") self.assertNotIn("added", out) self.assertEqual(self.ok("list").count("t-map-parser"), 1) def test_archived_gets_suffix(self): self.ok("done", "t-a", "-m", "x") out = self.ok("done", "t-map-parser", "-m", "z") self.assertIn("added t-map-parser-2 (area map stale)", out) def test_no_areas_file(self): (self.root / "CLAUDE.md").unlink() self.assertNotIn("added", self.ok("done", "t-a", "-m", "x")) class UncoveredTest(unittest.TestCase): def test_uncovered_groups_two_levels(self): a = areas.parse(NOTES.format(sha="abc1234")) files = ["src/p.py", "lib/x/y/z.py", "lib/x/w.py", "lib/q.py", "README.md", "tests/t.py", ".github/ci.yml", "TASKS.md"] self.assertEqual(areas.uncovered(files, a, ["tests"]), [("lib/x", 2), ("lib", 1)]) def test_paths_globs_and_files(self): a = areas.parse("## Areas\n### A\n- Paths: app/*.py tools/run.sh\n") self.assertEqual(areas.uncovered(["app/m.py", "tools/run.sh", "tools/other.sh"], a, []), [("tools", 1)]) def test_no_paths_anywhere_no_nudge(self): a = areas.parse("## Areas\n### A\n- Code map: `x`\n") self.assertEqual(areas.uncovered(["lib/q.py"], a, []), []) class AnchorPathsTest(unittest.TestCase): def test_anchor_folders_stand_in(self): with tempfile.TemporaryDirectory() as d: root = Path(d) build(root) a = areas.parse("## Areas\n### A\n- Code map: `parse_item`, `intro`\n### B\n- Paths: lib/\n") self.assertEqual(areas.with_anchor_paths(root, a[0]).paths, ["docs.md", "src"]) self.assertEqual(areas.with_anchor_paths(root, a[1]).paths, ["lib/"]) class UncoveredCliTest(Cli): tasks_text = "# T\n\n## Pending\n\n- **t-a** [P1] (<1h): A. Do.\n\n- **t-b** [P1] (<1h): B. Do.\n" def setUp(self): super().setUp() build(self.root) notes = NOTES.format(sha="x").replace(", `NOPE_GONE`", "").replace("- Checked: x\n", "") (self.root / "CLAUDE.md").write_text(notes) git(self.root, "add", "-A") git(self.root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "notes") (self.root / "engine" / "net").mkdir(parents=True) (self.root / "engine" / "net" / "sock.py").write_text("x") (self.root / "docs.md").write_text("changed") def test_done_adds_map_task(self): out = self.ok("done", "t-a", "-m", "x") self.assertIn("added t-map-engine-net (uncovered area: engine/net, 1 file)", out) shown = self.ok("show", "t-map-engine-net") self.assertIn("- **t-map-engine-net** [P1] (<1h): Map engine/net area. " "No area's Paths covers it.", shown) self.assertIn("Done: wf areas lists the area ok and no longer names engine/net uncovered.", shown) self.assertIn("Model: sonnet", shown) self.assertNotIn("added", self.ok("done", "t-b", "-m", "y")) def test_areas_names_uncovered(self): self.assertIn("uncovered: engine/net (1 file)", self.ok("areas")) def test_branch_diff_counts(self): git(self.root, "switch", "-qc", "topic") git(self.root, "add", "-A") git(self.root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "work") self.assertIn("uncovered: engine/net (1 file)", self.ok("areas")) def test_no_paths_uses_anchor_folders(self): (self.root / "CLAUDE.md").write_text("## Areas\n### P\n- Code map: `parse_item`\n") (self.root / "src" / "n.py").write_text("x") out = self.ok("areas") self.assertIn("uncovered: engine/net (1 file)", out) self.assertNotIn("uncovered: src", out) def test_ignore_config(self): with open(self.root / "workflow.toml", "a") as f: f.write('area_ignore = ["engine"]\n') self.assertNotIn("uncovered", self.ok("areas")) self.assertNotIn("added", self.ok("done", "t-a", "-m", "x")) if __name__ == "__main__": unittest.main()