#!/usr/bin/env python3 """ The container <-> child edge points ONE way: container -> child. A `type/feature` is closed when its children are closed, and that is a dependency relation, so the container lists its children in `depends:`. A child belongs to a feature, which is a membership relation, and membership has no place in a dependency graph — so a child never names its container back. These tests pin that direction down in all three places it shows up: the validator, the desync warning, and the drawn tree. python3 -m unittest discover -s tests -v Stdlib only, like the scripts under test. `skills/*/scripts/` are directories, not packages, so they go on sys.path by hand. """ import contextlib import io import os import sys import tempfile import unittest REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts") if SCRIPTS not in sys.path: sys.path.insert(0, SCRIPTS) import issue # noqa: E402 import issue_check # noqa: E402 import issue_new # noqa: E402 import issue_tree # noqa: E402 def run(module, argv): """Call a script's main() with argv, returning (exit_code, stdout). stderr is swallowed: issue_new.py notes on it when a `--depends` id is not in the store yet, which is fine and not what these tests are about.""" buf = io.StringIO() old = sys.argv sys.argv = [module.__name__ + ".py"] + argv try: with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()): code = module.main() except SystemExit as e: # argparse / sys.exit("msg") code = e.code if isinstance(e.code, int) else 1 finally: sys.argv = old return (code or 0), buf.getvalue() def drawn(tree_output): """The rows inside the tree's code fence, header and blanks dropped.""" return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")] class StoreCase(unittest.TestCase): """A scratch store per test. Never touches tmp/issues/.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.root = self._tmp.name self.addCleanup(self._tmp.cleanup) def new(self, type, id, title, depends=()): argv = ["--type", type, "--id", id, "--title", title, "--out", self.root] for d in depends: argv += ["--depends", d] code, _ = run(issue_new, argv) self.assertEqual(code, 0, "issue_new.py failed for %s" % id) def edit(self, id, old, new): p = issue.path_of(self.root, id) with open(p) as f: text = f.read() self.assertIn(old, text, "%s.md does not contain %r" % (id, old)) with open(p, "w") as f: f.write(text.replace(old, new)) def fill_issues_section(self, id, *children): """Replace the type/feature template's `## Issues` placeholder.""" self.edit(id, "- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n", "".join("- [ ] %s — часть\n" % c for c in children)) def container_and_child(self): """The canonical shape from references/format.md: the container names the child in `depends:` AND in `## Issues`; the child names nobody.""" self.new("task", "child-y", "Child y") self.new("feature", "feat-x", "Container x", depends=["child-y"]) self.fill_issues_section("feat-x", "child-y") class CanonicalContainerIsClean(StoreCase): """A container from the template plus a child per format.md: green.""" def test_check_is_silent_and_exits_zero(self): self.container_and_child() code, out = run(issue_check, ["--out", self.root]) self.assertEqual(code, 0, out) self.assertNotIn("ERROR", out) self.assertNotIn("warn", out) self.assertIn("ok feat-x", out) self.assertIn("ok child-y", out) def test_validate_reports_nothing_for_either_issue(self): self.container_and_child() issues = issue.load_all(self.root) for id in ("feat-x", "child-y"): err, warn = issue.validate(issues[id], known_ids=set(issues)) self.assertEqual((err, warn), ([], []), id) def test_the_child_does_not_depend_on_its_container(self): self.container_and_child() issues = issue.load_all(self.root) self.assertEqual(issues["feat-x"].depends, ["child-y"]) self.assertEqual(issues["child-y"].depends, []) class OnlyOneDirectionIsLegal(StoreCase): """format.md and the validator agree on container -> child, and the reverse edge is an error rather than a matter of taste.""" def test_the_reverse_edge_is_a_cycle(self): self.container_and_child() self.edit("child-y", "depends: []", "depends: [feat-x]") code, out = run(issue_check, ["--out", self.root]) self.assertEqual(code, 1, out) self.assertIn("ERROR cycle:", out) self.assertIn("feat-x", out) self.assertIn("child-y", out) def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self): """The shape format.md used to document: the child depends on the container and the container's depends: is empty. It no longer matches what the container's own `## Issues` says, so the check complains.""" self.new("feature", "feat-x", "Container x") self.new("task", "child-y", "Child y", depends=["feat-x"]) self.fill_issues_section("feat-x", "child-y") _, out = run(issue_check, ["--out", self.root]) self.assertIn("warn feat-x:", out) def test_issues_section_is_an_edge_source_pointing_down(self): body = "## Issues\n- [ ] child-y — часть\n" self.assertEqual(issue.body_dep_refs(body), ["child-y"]) class WarningNamesItsOwnSection(StoreCase): """The desync warning quotes the section the reference came from, not `## Depends on` unconditionally — a container has no such section.""" def test_container_warning_says_issues(self): self.new("feature", "feat-x", "Container x") self.new("task", "child-y", "Child y") self.fill_issues_section("feat-x", "child-y") # but not depends: issues = issue.load_all(self.root) err, warn = issue.validate(issues["feat-x"], known_ids=set(issues)) self.assertEqual(err, []) self.assertEqual( warn, ["## Issues mentions 'child-y' but `depends:` does not list it"]) self.assertNotIn("## Depends on", "\n".join(warn)) body = issue.load(self.root, "feat-x").body self.assertNotIn("## Depends on", body, "the warning must not name a section that is not in the file") def test_plain_issue_warning_still_says_depends_on(self): self.new("task", "child-y", "Child y", depends=["migrate-schema"]) self.edit("child-y", "depends: [migrate-schema]", "depends: []") issues = issue.load_all(self.root) _, warn = issue.validate(issues["child-y"]) self.assertEqual( warn, ["## Depends on mentions 'migrate-schema' but `depends:` does not list it"]) def test_each_reference_is_named_with_its_own_section(self): body = ("## Depends on\n- migrate-schema\n\n" "## Issues\n- [ ] child-y — часть\n") self.assertEqual( issue.body_dep_ref_sections(body), [("## Depends on", "migrate-schema"), ("## Issues", "child-y")]) def test_body_dep_refs_still_returns_bare_strings(self): """skills/sync/scripts/map.py filters this list for `#N` refs.""" body = "## Depends on\n- migrate-schema\n- #42\n" refs = issue.body_dep_refs(body) self.assertEqual(refs, ["migrate-schema", "#42"]) self.assertTrue(all(isinstance(r, str) for r in refs)) def test_tracker_numbers_never_warn(self): """`#42` is a tracker handle, not a slug; `depends:` holds ids only.""" self.new("task", "child-y", "Child y") self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation") issues = issue.load_all(self.root) _, warn = issue.validate(issues["child-y"]) self.assertEqual(warn, []) class TreePutsTheContainerOnTop(StoreCase): def test_container_is_the_root_and_children_hang_below(self): self.container_and_child() code, out = run(issue_tree, ["--out", self.root]) self.assertEqual(code, 0, out) rows = drawn(out) self.assertEqual(len(rows), 2, out) self.assertTrue(rows[0].startswith("feat-x "), out) self.assertTrue(rows[1].startswith("└── child-y "), out) self.assertEqual(out.count("child-y ["), 1, "child drawn more than once") def test_the_container_is_the_only_root(self): self.container_and_child() _, out = run(issue_tree, ["--out", self.root]) self.assertIn("# Dependency tree — feat-x", out) def test_two_children_hang_off_one_container(self): self.new("task", "child-y", "Child y") self.new("task", "child-z", "Child z") self.new("feature", "feat-x", "Container x", depends=["child-y", "child-z"]) self.fill_issues_section("feat-x", "child-y", "child-z") code, out = run(issue_check, ["--out", self.root]) self.assertEqual(code, 0, out) _, tree = run(issue_tree, ["--out", self.root]) self.assertEqual(tree.count("feat-x ["), 1, "the container must not repeat once per child") self.assertEqual([r.split(" ")[0] for r in drawn(tree)], ["feat-x", "├──", "└──"], tree) self.assertIn("├── child-y ", tree) self.assertIn("└── child-z ", tree) if __name__ == "__main__": unittest.main()