merge: tick in-body checkboxes from a domain script
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user