#!/usr/bin/env python3 """ Checkbox parsing, ticking, and the INDEX progress column. Plain stdlib unittest — the scripts under test are stdlib-only by the layering rule, and their tests have no business dragging in a dependency the code they cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not packages, so they go on sys.path the same way the scripts do it to each other. python3 -m unittest discover -s tests -v Nothing here touches tmp/, the network, or the real store: every case builds its own store in a TemporaryDirectory. """ import contextlib import io import os import sys import tempfile import unittest ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts")) import issue # noqa: E402 import issue_ac # noqa: E402 import issue_check # noqa: E402 import issue_index # noqa: E402 # Boxes in two different sections, a wrapped item, a fenced example, and a # plain list item that is not a checkbox at all. Line numbers are 1-based: # the items sit on lines 8, 9, 12, 14 and 15. BODY = """## Summary Что-то про задачу. ## Spec none ## Issues - [x] wire-sqlc-appclick — первая часть - [ ] add-pool-cfg — вторая часть ## Acceptance criteria - [ ] в `issue.py` есть функция разбора чекбоксов тела: возвращает пункты с номером строки, состоянием и текстом - [X] чекбоксы ищутся по всему телу - [ ] пример в блоке кода не считается пунктом: ```markdown - [ ] это разметка из шаблона, а не галочка - [x] и эта тоже ``` ## Constraints - не входит в объём: доставка тела в трекер """ NO_BOXES = """## Summary Тело без единой галочки. ## Spec none ## Notes - обычный пункт списка - ещё один """ # Metadata deliberately out of canonical order and missing optional keys, one # item with trailing whitespace: a round-trip through Issue.to_text() would # rewrite all of that, so this fixture catches a ticking path that re-renders # the file instead of patching one character of it. MESSY = """--- origin: local labels: [type/task] id: messy-issue state: open --- # Messy but valid ## Summary Тело, которое нельзя перерисовывать. ## Spec none ## Acceptance criteria - [ ] первый пункт - [ ] второй пункт - [ ] третий пункт """ TASK_BODY = """## Summary Что нужно сделать. ## Spec none ## Motivation Зачем это нужно. ## Acceptance criteria - [ ] ничего ещё не сделано - [ ] и это тоже не сделано """ def sole_difference(before, after): """The single character position at which the two strings differ. Raises AssertionError when they differ in length or in more than one place — the whole claim of `set_checkbox` is that this never happens.""" assert len(before) == len(after), ( "length changed: %d -> %d" % (len(before), len(after))) diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b] assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff) return diff[0] @contextlib.contextmanager def store(**files): """A throwaway issue store: {id: file text}.""" with tempfile.TemporaryDirectory() as root: for id, text in files.items(): with open(os.path.join(root, "%s.md" % id), "w", newline="") as f: f.write(text) yield root def run(fn, *argv): """Call a script entry point, returning (exit code or None, stdout).""" buf = io.StringIO() with contextlib.redirect_stdout(buf): rc = fn(list(argv)) return rc, buf.getvalue() class TestParse(unittest.TestCase): def test_finds_every_box_in_every_section(self): items = issue.checkboxes(BODY) self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5]) self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15]) self.assertEqual([c.checked for c in items], [True, False, False, True, False]) self.assertEqual([c.section for c in items], ["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3) def test_boxes_outside_acceptance_criteria_are_items_too(self): # A type/feature keeps its children under `## Issues`; binding the # parser to one heading would lose them. under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"] self.assertEqual(len(under_issues), 2) self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick")) def test_continuation_line_is_part_of_the_item(self): item = issue.checkboxes(BODY)[2] self.assertEqual((item.line, item.end_line), (12, 13)) self.assertEqual( item.text, "в `issue.py` есть функция разбора чекбоксов тела: " "возвращает пункты с номером строки, состоянием и текстом") def test_fenced_example_is_not_an_item(self): texts = [c.text for c in issue.checkboxes(BODY)] self.assertNotIn("это разметка из шаблона, а не галочка", texts) self.assertEqual(len(texts), 5) def test_plain_list_item_is_not_a_checkbox(self): self.assertNotIn("не входит в объём: доставка тела в трекер", [c.text for c in issue.checkboxes(BODY)]) def test_markers_and_nesting(self): text = ("* [ ] star\n" "+ [x] plus\n" "1. [ ] ordered\n" "2) [x] ordered too\n" " - [ ] nested\n" "- [x]no space, not an item\n") items = issue.checkboxes(text) self.assertEqual([c.text for c in items], ["star", "plus", "ordered", "ordered too", "nested"]) self.assertEqual([c.checked for c in items], [False, True, False, True, False]) def test_empty_text(self): self.assertEqual(issue.checkboxes(""), []) self.assertEqual(issue.checkboxes(None), []) def test_line_numbers_are_relative_to_the_text_given(self): # Same body, prefixed with a metadata block: the offsets move with it, # which is what lets issue_ac.py work on a whole file. head = "---\nid: x\nstate: open\n---\n# Title\n\n" shift = head.count("\n") self.assertEqual([c.line for c in issue.checkboxes(head + BODY)], [c.line + shift for c in issue.checkboxes(BODY)]) def test_progress(self): self.assertEqual(issue.checkbox_progress(BODY), (2, 5)) self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0)) class TestToggle(unittest.TestCase): def test_ticking_changes_exactly_one_character(self): item = issue.checkboxes(BODY)[1] # line 9, unchecked after = issue.set_checkbox(BODY, item, True) at = sole_difference(BODY, after) self.assertEqual(BODY[at], " ") self.assertEqual(after[at], "x") self.assertEqual(issue.checkbox_progress(after), (3, 5)) def test_unticking_changes_exactly_one_character(self): item = issue.checkboxes(BODY)[0] # line 8, checked after = issue.set_checkbox(BODY, item, False) at = sole_difference(BODY, after) self.assertEqual((BODY[at], after[at]), ("x", " ")) def test_every_item_toggles_in_isolation(self): for item in issue.checkboxes(BODY): after = issue.set_checkbox(BODY, item, not item.checked) at = sole_difference(BODY, after) self.assertEqual(after.splitlines()[item.line - 1].count("["), 1) self.assertLess(at, len(BODY)) def test_no_op_when_already_in_that_state(self): items = issue.checkboxes(BODY) self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY) self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY) def test_capital_x_is_left_alone(self): item = issue.checkboxes(BODY)[3] # `- [X]` self.assertEqual(issue.set_checkbox(BODY, item, True), BODY) def test_accepts_a_line_number(self): after = issue.set_checkbox(BODY, 9, True) self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True)) def test_refuses_a_line_that_is_not_a_checkbox(self): with self.assertRaises(ValueError): issue.set_checkbox(BODY, 1, True) with self.assertRaises(ValueError): issue.set_checkbox(BODY, 9999, True) class TestScript(unittest.TestCase): def test_lists_items_numbered_with_state(self): with store(**{"messy-issue": MESSY}) as root: rc, out = run(issue_ac.main, "messy-issue", "--out", root) self.assertEqual(rc, 0) self.assertIn("messy-issue — 0/3", out) self.assertIn("## Acceptance criteria", out) self.assertIn(" 1 [ ] первый пункт", out) self.assertIn(" 3 [ ] третий пункт", out) def test_check_by_number(self): with store(**{"messy-issue": MESSY}) as root: rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root) with open(os.path.join(root, "messy-issue.md")) as f: after = f.read() self.assertEqual(rc, 0) self.assertIn("checked", out) self.assertIn("1/3", out) self.assertEqual(issue.checkbox_progress(after), (1, 3)) def test_check_by_substring(self): with store(**{"messy-issue": MESSY}) as root: run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root) with open(os.path.join(root, "messy-issue.md")) as f: after = f.read() self.assertTrue(issue.checkboxes(after)[2].checked) self.assertEqual(issue.checkbox_progress(after), (1, 3)) def test_uncheck(self): with store(**{"messy-issue": MESSY}) as root: run(issue_ac.main, "messy-issue", "--check", "1", "--out", root) rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root) with open(os.path.join(root, "messy-issue.md")) as f: after = f.read() self.assertEqual(rc, 0) self.assertIn("unchecked", out) self.assertEqual(after, MESSY) def test_toggling_through_the_script_changes_one_character_of_the_file(self): with store(**{"messy-issue": MESSY}) as root: path = os.path.join(root, "messy-issue.md") with open(path) as f: before = f.read() run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root) with open(path) as f: after = f.read() at = sole_difference(before, after) self.assertEqual((before[at], after[at]), (" ", "x")) # The metadata block was neither reordered nor completed, and the # trailing whitespace on the third item survived. self.assertTrue(after.startswith("---\norigin: local\n")) self.assertIn("- [ ] третий пункт \n", after) def test_crlf_line_endings_survive(self): crlf = MESSY.replace("\n", "\r\n") with store(**{"messy-issue": crlf}) as root: path = os.path.join(root, "messy-issue.md") run(issue_ac.main, "messy-issue", "--check", "1", "--out", root) with open(path, newline="") as f: after = f.read() at = sole_difference(crlf, after) self.assertEqual((crlf[at], after[at]), (" ", "x")) self.assertEqual(after.count("\r\n"), crlf.count("\r\n")) def test_ambiguous_substring_is_an_error_listing_the_matches(self): with store(**{"messy-issue": MESSY}) as root: with self.assertRaises(SystemExit) as cm: run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root) with open(os.path.join(root, "messy-issue.md")) as f: self.assertEqual(f.read(), MESSY) # nothing was picked msg = str(cm.exception) self.assertIn("matches 3 items", msg) for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"): self.assertIn(want, msg) def test_substring_that_matches_nothing(self): with store(**{"messy-issue": MESSY}) as root: with self.assertRaises(SystemExit) as cm: run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root) self.assertIn("nothing matches", str(cm.exception)) def test_number_out_of_range(self): with store(**{"messy-issue": MESSY}) as root: with self.assertRaises(SystemExit) as cm: run(issue_ac.main, "messy-issue", "--check", "9", "--out", root) self.assertIn("no item 9 — the issue has 3", str(cm.exception)) def test_issue_without_checkboxes(self): with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root: rc, out = run(issue_ac.main, "plain", "--out", root) self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes")) with self.assertRaises(SystemExit) as cm: run(issue_ac.main, "plain", "--check", "1", "--out", root) self.assertIn("has no checkboxes", str(cm.exception)) def test_unknown_id(self): with store() as root: with self.assertRaises(SystemExit) as cm: run(issue_ac.main, "nope", "--out", root) self.assertIn("no issue 'nope'", str(cm.exception)) class TestIndexProgress(unittest.TestCase): def files(self): boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n" "origin: local\n---\n# Boxed\n\n" + BODY) plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n" "origin: local\n---\n# Plain\n\n" + NO_BOXES) return {"boxed": boxed, "plain": plain} def index(self, root): issue_index.build(root) with open(os.path.join(root, "INDEX.md")) as f: return f.read() def row(self, text, id): for line in text.splitlines(): if line.startswith("| [%s]" % id): return [c.strip() for c in line.split("|")] self.fail("no row for %r in INDEX.md" % id) def test_column_exists_and_counts_the_body(self): with store(**self.files()) as root: text = self.index(root) self.assertIn("| id | state | progress | type |", text) self.assertEqual(self.row(text, "boxed")[3], "2/5") def test_blank_for_an_issue_without_checkboxes(self): with store(**self.files()) as root: text = self.index(root) self.assertEqual(self.row(text, "plain")[3], "") def test_recomputed_on_the_fly_not_stored(self): with store(**self.files()) as root: self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5") run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root) self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5") # No metadata field anywhere holds it. with open(os.path.join(root, "boxed.md")) as f: head = f.read().split("---")[1] self.assertNotIn("3/5", head) self.assertNotIn("progress", head) class TestCheckIgnoresUntickedBoxes(unittest.TestCase): """An unticked box is work not done yet, not a malformed issue.""" def test_validate_reports_nothing(self): iss = issue.Issue(id="unticked-issue", title="Do the thing", labels=["type/task"], body=TASK_BODY) err, warn = issue.validate(iss, known_ids={"unticked-issue"}) self.assertEqual(err, []) self.assertEqual(warn, []) def test_issue_check_exits_clean(self): text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n" "assignees: []\nmilestone: none\ndepends: []\norigin: local\n" "---\n# Do the thing\n\n" + TASK_BODY) argv = sys.argv with store(**{"unticked-issue": text}) as root: sys.argv = ["issue_check.py", "--out", root] try: buf = io.StringIO() with contextlib.redirect_stdout(buf): rc = issue_check.main() finally: sys.argv = argv out = buf.getvalue() self.assertEqual(rc, 0, out) self.assertIn("ok unticked-issue", out) self.assertNotIn("ERROR", out) self.assertNotIn("warn ", out) if __name__ == "__main__": unittest.main()