Files
werkrevisor/tests/test_open_questions.py
claude-code bbb52fcba2
werkator build succeeded after 00:09
werkator/werkrevisor-statistics build succeeded after 00:52
werkrevisor 0.21.2: "None." is no open question (#42)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-07 12:38:29 +02:00

160 lines
8.0 KiB
Python

import unittest
from tests.fakes import HEAD, FakeGitea, FakeModel, config, diff_for, gitea_file
from tests.test_flow import run
from werkrevisor.rules import files_from_gitea, open_question_reasons
from werkrevisor.specdocs import open_questions
from werkrevisor.verdict import Verdict
HEADINGS = ("Open Questions", "Offene Fragen")
DOC = "doc/PR/2026-10-05-PR#42-x.md"
def lines(text):
return [q.line for q in open_questions(text, DOC, HEADINGS)]
class OpenQuestionParserTest(unittest.TestCase):
def test_done_items_pass(self):
self.assertEqual(lines("# PR\n\n## Open Questions\n\n- [x] TODO.spec: decided\n* [X] also done\n"), [])
def test_unchecked_item_is_open(self):
self.assertEqual(lines("## Open Questions\n\n- [x] done\n- [ ] who decides?\n"), [4])
def test_todo_item_is_deferred(self):
found = open_questions("## Offene Fragen\n\n- [ ] TODO.tool: raise the forks\n once the server has more cores.\n"
"- TODO.spec-x: later\n- [ ] who decides?\n- [ ] TODO: no topic\n", DOC, HEADINGS)
self.assertEqual([(q.line, q.deferred) for q in found], [(3, True), (5, True), (6, False), (7, False)])
def test_plain_bullet_is_open(self):
self.assertEqual(lines("## Open Questions\n\n- Who decides?\n1. And when?\n"), [3, 4])
def test_a_none_paragraph_is_no_open_question(self):
self.assertEqual(lines("## Open Questions\n\nNone.\n\n## Offene Fragen\n\nkeine\n"), [])
def test_a_none_item_is_no_open_question(self):
self.assertEqual(lines("## Open Questions\n\n- None.\n"), [])
def test_a_sentence_starting_with_none_stays_open(self):
self.assertEqual(lines("## Open Questions\n\nNone so far, but who decides the limit?\n"), [3])
def test_paragraph_is_open_once(self):
self.assertEqual(lines("## Open Questions\n\nWe do not know yet\nwhat the team wants.\n\nNext one.\n"), [3, 6])
def test_empty_section_blank_lines_and_comments_pass(self):
text = "## Open Questions\n\n<!-- none yet -->\n<!--\n- [ ] in a comment\n-->\n\n## Solution\n\nText.\n"
self.assertEqual(lines(text), [])
def test_continuation_lines_of_a_done_item_pass(self):
text = ("## Offene Fragen\n\n- [x] TODO.spec: decided on 2026-10-05,\n see the discussion in the PR.\n"
"lazy continuation without indentation\n\n indented continuation after a blank line\n")
self.assertEqual(lines(text), [])
def test_unindented_paragraph_after_a_blank_line_is_open(self):
self.assertEqual(lines("## Open Questions\n\n- [x] done\n\nA new paragraph.\n"), [5])
def test_nested_subheading_stays_in_the_section(self):
text = ("## Open Questions\n\n### Backend\n\n- [ ] open\n\n#### Detail\n\nAlso open.\n"
"## Solution\n\n- not a question\n")
self.assertEqual(lines(text), [5, 9])
def test_same_or_higher_level_ends_the_section(self):
self.assertEqual(lines("### Open Questions\n\n- [x] done\n\n### Next\n\n- free text\n"), [])
self.assertEqual(lines("### Open Questions\n\n- [x] done\n\n## Higher\n\n- free text\n"), [])
def test_any_heading_level_and_both_names(self):
for heading in ("# Open Questions", "#### Offene Fragen", "## open questions:"):
with self.subTest(heading=heading):
self.assertEqual(lines(f"{heading}\n\n- [ ] open\n"), [3])
def test_other_sections_do_not_count(self):
self.assertEqual(lines("## Solution\n\n- [ ] not a question section\n"), [])
def test_heading_in_a_code_block_does_not_start_the_section(self):
self.assertEqual(lines("## Example\n\n```\n## Open Questions\n- [ ] sample\n```\n"), [])
def test_text(self):
found = open_questions("## Open Questions\n\n- [ ] who decides?\n", DOC, HEADINGS)
self.assertEqual((found[0].path, found[0].text), (DOC, "- [ ] who decides?"))
def doc_bytes(items):
return ("# PR#42\n\n## Open Questions\n\n" + "".join(f"- [ ] question {i}\n" for i in range(items))).encode()
class OpenQuestionRuleTest(unittest.TestCase):
def reasons(self, files, raw):
return open_question_reasons(files_from_gitea(files), lambda path: raw[path], config().pr_doc)
def test_lists_three_items_and_counts_the_rest(self):
reasons = self.reasons([gitea_file(DOC)], {DOC: doc_bytes(5)})
self.assertEqual([(r.code, r.verdict) for r in reasons], [("pr_doc_open_questions", Verdict.RED)])
self.assertEqual(reasons[0].text, f"open questions in the PR document: {DOC}:5: `- [ ] question 0`; "
f"{DOC}:6: `- [ ] question 1`; {DOC}:7: `- [ ] question 2`; and 2 more")
def test_only_changed_pr_documents_count(self):
raw = {"doc/PR/TEMPLATE.md": doc_bytes(1), "doc/other.md": doc_bytes(1), DOC: doc_bytes(1)}
files = [gitea_file("doc/PR/TEMPLATE.md"), gitea_file("doc/other.md"), gitea_file(DOC, status="deleted")]
self.assertEqual(self.reasons(files, raw), [])
def test_deferred_todos_are_a_hint(self):
raw = b"## Open Questions\n\n- [ ] TODO.tool: raise the forks later\n"
reasons = self.reasons([gitea_file(DOC)], {DOC: raw})
self.assertEqual([(r.code, r.text, r.verdict) for r in reasons],
[("pr_doc_deferred_todos", f"deferred TODOs in the PR document: "
f"{DOC}:3: `- [ ] TODO.tool: raise the forks later`", Verdict.GREEN)])
def test_binary_document_is_yellow(self):
reasons = self.reasons([gitea_file(DOC)], {DOC: b"\0\1"})
self.assertEqual([(r.code, r.verdict) for r in reasons], [("pr_doc_unreadable", Verdict.YELLOW)])
class OpenQuestionFlowTest(unittest.TestCase):
def gitea(self, raw, **kw):
gitea = FakeGitea(files=[gitea_file(DOC)], diff=diff_for(DOC, added=["x"]), **kw)
gitea.raw_files[DOC] = raw
return gitea
def test_open_question_is_red_without_model_call(self):
model = FakeModel(prompt_files=[DOC])
gitea = self.gitea(doc_bytes(1))
outcome, _ = run(gitea, model)
self.assertIs(outcome.result.verdict, Verdict.RED)
self.assertEqual(outcome.result.exit_code, 20)
self.assertEqual(model.calls, [])
self.assertEqual(gitea.statuses[0][1], "failure")
self.assertIn("## 🔴 werkrevisor: RED / CHANGES_REQUIRED", gitea.created_comments[0])
self.assertIn(f"{DOC}:5: `- [ ] question 0`", gitea.created_comments[0])
def test_red_is_not_unlocked_by_a_human_approval(self):
approval = {"state": "APPROVED", "commit_id": HEAD, "user": {"login": "alice"}}
gitea = self.gitea(doc_bytes(1), reviews=[approval])
outcome, _ = run(gitea, cfg=config(gate={"yellow_gate": "block", "human_reviewers": ["alice"]}))
self.assertEqual(outcome.result.exit_code, 20)
def test_open_question_gives_a_comment_review(self):
gitea = self.gitea(doc_bytes(1))
outcome, _ = run(gitea, cfg=config(gate={"yellow_gate": "approval", "human_reviewers": ["alice"]}))
self.assertEqual(outcome.result.exit_code, 20)
event, commit_id, body = gitea.created_reviews[0]
self.assertEqual((event, commit_id), ("COMMENT", HEAD))
self.assertIn(f"{DOC}:5: `- [ ] question 0`", body)
self.assertEqual(gitea.requested, [])
def test_done_questions_get_the_normal_review(self):
model = FakeModel(prompt_files=[DOC])
outcome, _ = run(self.gitea(b"# PR#42\n\n## Open Questions\n\n- [x] decided\n"), model)
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertEqual(len(model.calls), 1)
def test_pr_document_not_touched_by_the_pr_is_not_checked(self):
gitea = FakeGitea(files=[gitea_file("README.md")], diff=diff_for("README.md", added=["x"]))
gitea.raw_files[DOC] = doc_bytes(1)
outcome, _ = run(gitea, FakeModel(prompt_files=["README.md"]))
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertNotIn(DOC, [path for path, _ in gitea.raw_requests])
if __name__ == "__main__":
unittest.main()