Files
werkrevisor/tests/test_writing.py
claude-code 4685c8db84
werkator build succeeded after 00:11
werkator/werkrevisor-statistics build succeeded after 00:26
werkator/release build succeeded after 00:15
werkrevisor 0.49.1: the reviews skip generated sections of the docs (#122)
Generated sections (`<!-- generated by … -->` up to the next heading) are no longer checked by the writing rules, and the model and Codex are told to report nothing about them.

See docs/prs/.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #122
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-18 20:16:33 +02:00

244 lines
12 KiB
Python

import os
import tomllib
import unittest
from tests.fakes import ROOT, FakeGitea, FakeModel, config, deps, diff_for, gitea_file, pr_ref
from werkrevisor.cli import execute
from werkrevisor.config import ConfigError, WritingConfig, parse_config
from werkrevisor.diff import parse_unified_diff
from werkrevisor.redact import Redactor
from werkrevisor.rules import files_from_gitea
from werkrevisor.verdict import Verdict
from werkrevisor.writing import check_text, find_violations, is_checked, sentence_count, writing_reason
WRITING = WritingConfig()
DOC = "doc/guide.md"
def violations(text, changed=None):
lines = text.splitlines()
changed = set(range(1, len(lines) + 1)) if changed is None else set(changed)
return [str(v) for v in check_text(text, changed, DOC, WRITING)]
class SentenceSplitTest(unittest.TestCase):
def test_sentences_are_split_at_terminal_punctuation_before_a_capital(self):
for text, count in (("The gate stops. A human decides.", 2), ("Is it red? No! It is orange.", 3),
("It ends with **bold.** Then more.", 2), ("Ein Satz. Ähnlich ein zweiter.", 2),
("One sentence, then lowercase. and more", 1), ("", 0)):
with self.subTest(text=text):
self.assertEqual(sentence_count(text), count)
def test_abbreviations_numbers_and_file_names_do_not_split(self):
for text in ("Use a glob, e.g. Docs or Tests, i.e. A pattern.",
"Das gilt z. B. Für Tests, d. h. Nicht für Doku, usw. Und mehr.",
"See Nr. 5 vs. Version 1.5 and werkrevisor 0.9.0 in config.py.",
"Use e. g. A list, etc. And more.",
"Am 3. Oktober kam J. R. Tolkien. ",
"Wait... Then go.",
"Preise 2,00 USD pro 1 Mio. Tokens.",
"Ab 0.8.0 gilt es, z.B. In tests/fixtures/a.md, u.a. Hier."):
with self.subTest(text=text):
self.assertEqual(sentence_count(text), 1)
def test_code_spans_and_urls_do_not_split(self):
for text in ("Call `x. Y` or https://example.org/a. B with a mark.",
"See [the guide](https://example.org/x. Y) and <https://example.org/a. B> here.",
"Mark it with ``a. `B` c`` in the text.",
"Text <!-- a note. With a capital --> goes on."):
with self.subTest(text=text):
self.assertEqual(sentence_count(text), 1)
class LineRuleTest(unittest.TestCase):
def test_more_than_one_sentence_on_a_line(self):
self.assertEqual(violations("The gate stops. A human decides.\n"),
[f"{DOC}:1: more than one sentence on a line"])
self.assertEqual(violations("- The gate stops.\n- A human decides.\n1. First.\n2. Second.\n"), [])
def test_long_line_is_a_violation(self):
self.assertEqual(violations("a" * 129 + "\n" + "b" * 128 + "\n"),
[f"{DOC}:1: line longer than 128 characters (129)"])
def test_long_line_exceptions(self):
long = "word " * 40
cases = {
"a table row": f"| {long} | x |\n",
"a heading": f"## {long}\n",
"a line with only a link": f"- [{long}](https://example.org/{'a' * 100})\n",
"a line with only a URL": f"https://example.org/{'a' * 200}\n",
"a link reference definition": f"[guide]: https://example.org/{'a' * 200}\n",
"in a fenced code block": f"```sh\n{long}. Next. More.\n```\n",
"in a tilde fence with a longer close": f"~~~\n{long}\n~~~~\n",
"in an HTML comment": f"<!--\n{long}. Next.\n-->\n",
"in a one-line HTML comment": f"<!-- {long} -->\n",
"in the front matter": f"---\ntitle: {long}. Next.\n---\n",
}
for kind, text in cases.items():
with self.subTest(kind=kind):
self.assertEqual(violations(text), [])
def test_text_after_a_closed_block_counts_again(self):
text = "```\ncode\n```\n" + "a" * 130 + "\n<!-- c -->\nOne. Two.\n"
self.assertEqual(violations(text), [f"{DOC}:4: line longer than 128 characters (130)",
f"{DOC}:6: more than one sentence on a line"])
GENERATED_SECTION = (
"##### Verified by\n\n"
"<!-- generated by tools/generate-verified-by -->\n"
f"- [HsOfficeScenarioTests.shouldFetch](../../src/test/java/{'a' * 150}.java)\n"
f" using the use-case [FetchStatus](../../src/test/java/{'b' * 150}.java)\n"
f"- Scenario-Reports “3021: Fetch Status”: [Branch](https://example.org/{'c' * 200}),"
f" [`master`](https://example.org/{'d' * 200})\n"
"- One. Two. Three. Four. Five. Six.\n\n"
)
class GeneratedSectionTest(unittest.TestCase):
def test_a_long_line_in_a_generated_section_is_no_violation(self):
self.assertEqual(violations(GENERATED_SECTION), [])
def test_the_next_heading_ends_the_generated_section(self):
self.assertEqual(violations(GENERATED_SECTION + "#### Scenario#1.02: next\n\n" + "a" * 130 + "\n"),
[f"{DOC}:11: line longer than 128 characters (130)"])
def test_a_section_without_the_marker_is_checked(self):
hand_written = GENERATED_SECTION.replace("<!-- generated by tools/generate-verified-by -->\n", "")
self.assertIn(f"{DOC}:4: line longer than 128 characters", "\n".join(violations(hand_written)))
class ParagraphRuleTest(unittest.TestCase):
SIX = "".join(f"Sentence {i} is here.\n" for i in range(1, 7))
def test_paragraph_with_more_than_five_sentences(self):
self.assertEqual(violations(self.SIX, changed=[4]), [f"{DOC}:1: paragraph with 6 sentences, more than 5"])
self.assertEqual(violations(self.SIX.replace("Sentence 6 is here.\n", "")), [])
wrapped = "This sentence is long,\nand it goes on.\n" * 3
self.assertEqual(violations(wrapped), [])
def test_an_answered_question_puts_its_answer_on_a_continuation_line(self):
self.assertEqual(violations("- [x] Is it red?\n No: it is orange.\n"), [])
self.assertEqual(violations("- [x] Is it red? No: it is orange.\n"),
[f"{DOC}:1: more than one sentence on a line"])
def test_list_items_are_separate_paragraphs(self):
self.assertEqual(violations("".join(f"- Item {i} is here.\n" for i in range(1, 8))), [])
item = "- " + self.SIX.replace("\n", "\n ", 5)
self.assertEqual(violations(item), [f"{DOC}:1: paragraph with 6 sentences, more than 5"])
def test_blank_lines_headings_and_tables_end_a_paragraph(self):
text = self.SIX.replace("Sentence 4", "\nSentence 4")
self.assertEqual(violations(text), [])
self.assertEqual(violations(self.SIX.replace("Sentence 4 is here.", "## Heading")), [])
self.assertEqual(violations(self.SIX.replace("Sentence 4 is here.", "| a | b |")), [])
class ChangedLinesTest(unittest.TestCase):
OLD = "The gate stops. A human decides.\n" + "a" * 130 + "\n\n" + ParagraphRuleTest.SIX + "\nNew text.\n"
def test_untouched_lines_and_paragraphs_do_not_count(self):
self.assertEqual(violations(self.OLD, changed=[11]), [])
def test_a_changed_line_makes_its_paragraph_count(self):
self.assertEqual(violations(self.OLD, changed=[2, 6]),
[f"{DOC}:2: line longer than 128 characters (130)",
f"{DOC}:4: paragraph with 6 sentences, more than 5"])
def checked_config(**writing):
return config(writing=writing)
class SelectionTest(unittest.TestCase):
def test_which_files_are_checked(self):
cases = [
({}, {}, "doc/guide.md", True),
({}, {}, "src/main/Foo.java", False),
({}, {}, "src/test/resources/fixture.md", False),
({"globs": ["docs/prs/*.md"]}, {}, "doc/guide.md", False),
({"globs": ["doc/*.txt"]}, {}, "doc/guide.txt", False),
({}, {"globs": ["notes/*.md"]}, "notes/pr.md", True),
({"enabled": False}, {}, "doc/guide.md", False),
]
for writing, pr_doc, path, expected in cases:
with self.subTest(writing=writing, pr_doc=pr_doc, path=path):
cfg = config(writing=writing, pr_doc=pr_doc, categories={"docs": ["doc/**"]})
self.assertIs(is_checked(path, cfg), expected)
def reasons_for(text, path=DOC, added=None, cfg=None):
lines = text.splitlines()
diff = parse_unified_diff(diff_for(path, added=added if added is not None else lines))
files = files_from_gitea([gitea_file(path, additions=len(lines))])
return writing_reason(find_violations(files, diff, lambda p: text.encode(), cfg or config()))
class ReasonTest(unittest.TestCase):
def test_one_reason_with_three_examples_and_the_count_of_the_rest(self):
text = "".join(f"Line {i} ends. Two.\n\n" for i in range(1, 6))
reasons = reasons_for(text)
self.assertEqual(len(reasons), 1)
self.assertEqual(reasons[0].code, "writing_rules")
self.assertIs(reasons[0].verdict, Verdict.GREEN)
self.assertEqual(reasons[0].text, f"writing rules violated in the changed docs: "
f"{DOC}:1: more than one sentence on a line; "
f"{DOC}:3: more than one sentence on a line; "
f"{DOC}:5: more than one sentence on a line; and 2 more")
def test_no_reason_without_violation_or_for_binary_and_deleted_files(self):
self.assertEqual(reasons_for("One sentence.\n"), [])
diff = parse_unified_diff(diff_for(DOC, added=["x"]))
self.assertEqual(writing_reason(find_violations(files_from_gitea([gitea_file(DOC)]), diff,
lambda p: b"\0One. Two.", config())), [])
self.assertEqual(writing_reason(find_violations(files_from_gitea([gitea_file(DOC, status="deleted")]), diff,
lambda p: b"One. Two.", config())), [])
class ConfigTest(unittest.TestCase):
def raw(self):
with open(os.path.join(ROOT, "config", "werkprobe.toml"), "rb") as f:
return tomllib.load(f)
def test_writing_section_is_required_and_validated(self):
data = self.raw()
self.assertEqual(parse_config(data).writing, WritingConfig())
del data["writing"]
with self.assertRaisesRegex(ConfigError, r"sections missing: \[writing\]"):
parse_config(data)
for key, value, message in (("max_line_length", 0, "max_line_length must be a positive integer"),
("max_sentences_per_paragraph", True, "must be a positive integer"),
("enabled", "yes", "enabled must be true or false"),
("globs", ["", "a"], "globs must be a list of non-empty strings"),
("max_lines", 3, "unknown keys: max_lines")):
with self.subTest(key=key):
data = self.raw()
data["writing"][key] = value
with self.assertRaisesRegex(ConfigError, message):
parse_config(data)
class WritingFlowTest(unittest.TestCase):
def test_violation_is_a_hint_and_the_model_still_reviews(self):
gitea = FakeGitea(files=[gitea_file(DOC)], diff=diff_for(DOC, added=["The gate stops. A human decides."]))
gitea.raw_files[DOC] = b"The gate stops. A human decides.\n"
model = FakeModel(prompt_files=[DOC])
outcome = execute(config(gate={"yellow_gate": "block"}), pr_ref(), deps(gitea, model), Redactor())
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertIn("- **hint** writing rules violated in the changed docs", gitea.created_comments[0])
self.assertIn("writing rules violated in the changed docs: doc/guide.md:1: more than one sentence on a line",
[r.text for r in outcome.result.reasons])
self.assertEqual(len(model.calls), 1)
self.assertEqual(outcome.result.exit_code, 0)
def test_clean_docs_can_be_green(self):
gitea = FakeGitea(files=[gitea_file(DOC)], diff=diff_for(DOC, added=["The gate stops."]))
gitea.raw_files[DOC] = b"The gate stops.\n"
outcome = execute(config(gate={"yellow_gate": "block"}), pr_ref(),
deps(gitea, FakeModel(prompt_files=[DOC])), Redactor())
self.assertIs(outcome.result.verdict, Verdict.GREEN)
if __name__ == "__main__":
unittest.main()