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 here.", "Mark it with ``a. `B` c`` in the text.", "Text 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", "in a one-line HTML comment": f"\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\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" "\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("\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()