#!/usr/bin/env python3 """Self-test for check_citations.py. This is the regression suite the tool itself did not have when the self-poisoning bug (see check_citations.py's own module docstring) shipped undetected: the corrective comment documenting a past wrong citation quoted the wrong historical value, and that quote sat in the same word pool the checker verified against, so re-introducing the exact bug produced "0 look wrong" instead of a failure. A verification tool with no tests of its own is exactly how you get one that passes for the wrong reason -- this file is the fix for that, not merely for the bug it happened to expose. Runs under `dune test` via tools/dune's own `(rule (alias runtest) ...)`, not only as a standalone script or a `make` target, so it cannot rot unnoticed. Also runnable directly: `python3 tools/test_check_citations.py`. Everything below is a SYNTHETIC fixture -- a tiny made-up "LT.txt" and a tiny made-up la.ini-shaped fragment, entirely in memory. Nothing here reads the real docs/research/LT.txt (gitignored, absent on a fresh clone) or the real lang/la.ini, so this suite runs identically everywhere, always. """ import subprocess import sys import tempfile import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import check_citations as cc # noqa: E402 (path insert must come first) # --------------------------------------------------------------------------- # Synthetic "LT.txt". 0-indexed list where element 0 IS line 1 (matching # check_citations.py's own convention: lt_lines[n - 1] is line n). Real # citations are always >= 2 digits (CITATION_RE requires \d{2,6}, matching # realistic LT.txt line numbers, which run into the thousands) -- padded # with filler so every cited line here is two digits too, the same # constraint real data has. LT_LINES = ["(filler)"] * 9 + [ "Festum Aurorae Caelestis", # line 10 "Prima Classis", # line 11 "", # line 12 "Festum Umbrae Nocturnae", # line 13 "Secunda Classis", # line 14 "", # line 15 "Festum Solis Invicti", # line 16 "Tertia Classis", # line 17 "Festum Gloriae", # line 18 (heading continues on line 19) "Aeternae Perpetuae", # line 19 ] # A fixture covering every case the hardening brief asked for: # alpha -- correct citation -> PASS # beta -- off by one line -> FAIL # gamma -- off by three lines -> FAIL # delta -- explicit n-m range, heading genuinely wraps -> PASS # epsilon -- THE SELF-POISONING CASE: comment quotes a # DIFFERENT heading's text, citation points # at that other heading's real line -> FAIL # zeta -- degenerate pool (a single-word entry) -> CANNOT VERIFY # eta -- PATTERN, no citation at all -> skipped entirely LA_INI_TEXT = """ [test] alpha = Festum Aurorae Caelestis ; LT.txt:10. beta = Festum Umbrae Nocturnae ; LT.txt:14. gamma = Festum Solis Invicti ; LT.txt:19. delta = Festum Gloriae Aeternae ; LT.txt:18-19. epsilon = Festum Lunae Argenteae ; CORRECTED: an earlier draft wrongly attributed this to "Festum Solis ; Invicti" -- LT.txt:16. zeta = Ordo ; LT.txt:11. ; eta -- PATTERN, constructed name; no heading for this day survives in ; the source at all. eta = Aliquid Fictum """ def entries_field(items, label): """Find the finding/unverifiable dict whose citation label matches, or None. Small helper so assertions read by name, not by list position.""" for item in items: if item["label"] == label: return item return None class TestCheckLogic(unittest.TestCase): """Unit-level: exercises check() directly against the synthetic fixture.""" def setUp(self): self.result = cc.check(LA_INI_TEXT, LT_LINES) def test_totals(self): # alpha, beta, gamma, delta, epsilon, zeta = 6 citation EVENTS. # eta contributes nothing (PATTERN, and has no citation anyway). self.assertEqual(self.result["checked"], 6) self.assertEqual(self.result["passed"], 2) # alpha, delta self.assertEqual(len(self.result["findings"]), 3) # beta, gamma, epsilon self.assertEqual(len(self.result["unverifiable"]), 1) # zeta def test_correct_citation_passes(self): passed_labels = {"10"} # alpha's own label found_wrong = {f["label"] for f in self.result["findings"]} found_unverifiable = {u["label"] for u in self.result["unverifiable"]} self.assertFalse(passed_labels & found_wrong) self.assertFalse(passed_labels & found_unverifiable) def test_off_by_one_line_fails(self): f = entries_field(self.result["findings"], "14") self.assertIsNotNone(f, "beta's off-by-one citation (LT.txt:14) must FAIL") self.assertIn("beta", f["entries"]) def test_off_by_three_lines_fails(self): f = entries_field(self.result["findings"], "19") # NOTE: delta ALSO legitimately cites "18-19" as a range (a distinct # citation event, checked separately) -- gamma's bad citation is # the bare, single-number "19" token, which is what must fail here. # A finding's label is the raw token as written, so "19" (gamma) # and "18-19" (delta) never collide. self.assertIsNotNone(f, "gamma's off-by-three citation (LT.txt:19) must FAIL") self.assertIn("gamma", f["entries"]) def test_explicit_wrap_range_passes(self): found_wrong = {f["label"] for f in self.result["findings"]} found_unverifiable = {u["label"] for u in self.result["unverifiable"]} self.assertNotIn("18-19", found_wrong) self.assertNotIn("18-19", found_unverifiable) def test_wrap_range_required_not_just_first_line(self): # Without the explicit range, citing only delta's FIRST physical # line must fail -- this is the concrete proof that the range # syntax is doing real work, not merely being tolerated. text = LA_INI_TEXT.replace("; LT.txt:18-19.", "; LT.txt:18.") result = cc.check(text, LT_LINES) f = entries_field(result["findings"], "18") self.assertIsNotNone( f, "citing only the first physical line of a wrapped heading must FAIL" ) def test_self_poisoning_quote_does_not_pass(self): """THE regression test for the historical bug: epsilon's own comment quotes "Festum Solis Invicti" (a DIFFERENT heading, gamma's own), and cites that other heading's real line (LT.txt:16). A checker that pools quoted text from the surrounding comment would pass this, exactly as the pre-hardening script did. It must FAIL.""" found_wrong = {f["label"]: f for f in self.result["findings"]} self.assertIn("16", found_wrong, "the self-poisoning citation must be a FAIL, not a pass") self.assertIn("epsilon", found_wrong["16"]["entries"]) found_unverifiable = {u["label"] for u in self.result["unverifiable"]} self.assertNotIn("16", found_unverifiable, "must be a real FAIL, not laundered into CANNOT VERIFY") def test_degenerate_pool_is_cannot_verify_not_pass(self): u = entries_field(self.result["unverifiable"], "11") self.assertIsNotNone(u, "zeta's single-word entry must be CANNOT VERIFY") self.assertIn("zeta", u["entries"]) found_wrong = {f["label"] for f in self.result["findings"]} self.assertNotIn("11", found_wrong, "a degenerate pool must never be reported as a silent PASS") def test_pattern_block_skipped_entirely(self): def keys_of(entries_desc): return {pair.split("=", 1)[0] for pair in entries_desc.split(", ")} for f in self.result["findings"]: self.assertNotIn("eta", keys_of(f["entries"])) for u in self.result["unverifiable"]: self.assertNotIn("eta", keys_of(u["entries"])) class TestHelpers(unittest.TestCase): def test_distinctive_words_strips_stopwords_and_short_tokens(self): words = cc.distinctive_words("Dominica I Adventus") self.assertEqual(words, {"adventus"}) # "Dominica" stopword, "I" too short def test_distinctive_words_normalises_j_and_ligatures(self): self.assertEqual(cc.distinctive_words("Jesu"), cc.distinctive_words("Iesu")) self.assertEqual(cc.distinctive_words("praesulaeque"), cc.distinctive_words("praesulæque")) def test_parse_citation_spec_bare_number(self): refs = cc.parse_citation_spec("8609") self.assertEqual(len(refs), 1) self.assertEqual(refs[0].lines, [8609]) def test_parse_citation_spec_range_is_one_ref(self): refs = cc.parse_citation_spec("8609-8610") self.assertEqual(len(refs), 1) self.assertEqual(refs[0].lines, [8609, 8610]) def test_parse_citation_spec_comma_list_is_several_refs(self): refs = cc.parse_citation_spec("8618,8620,8622") self.assertEqual([r.lines for r in refs], [[8618], [8620], [8622]]) def test_parse_citation_spec_mixed_list(self): refs = cc.parse_citation_spec("8786,8788-8789,8791-8792") self.assertEqual( [r.lines for r in refs], [[8786], [8788, 8789], [8791, 8792]], ) def test_parse_citation_spec_rejects_backwards_range(self): self.assertEqual(cc.parse_citation_spec("100-50"), []) def test_parse_citation_spec_rejects_absurdly_wide_range(self): self.assertEqual(cc.parse_citation_spec("1000-999999"), []) class TestCliIntegration(unittest.TestCase): """End-to-end: invokes the real main() as a subprocess, exactly how `make check-citations` does, using --file/--lt-file to point at temporary fixtures so the real lang/la.ini is never touched.""" def run_cli(self, la_ini_text, lt_text, lt_present=True): with tempfile.TemporaryDirectory() as td: tdp = Path(td) la_ini_path = tdp / "la.ini" la_ini_path.write_text(la_ini_text, encoding="utf-8") lt_path = tdp / "LT.txt" if lt_present: lt_path.write_text(lt_text, encoding="utf-8") proc = subprocess.run( [ sys.executable, str(Path(__file__).resolve().parent / "check_citations.py"), "--file", str(la_ini_path), "--lt-file", str(lt_path), ], capture_output=True, text=True, ) return proc def test_skipped_when_lt_txt_absent(self): proc = self.run_cli(LA_INI_TEXT, "", lt_present=False) self.assertEqual(proc.returncode, 0) self.assertIn("SKIPPED", proc.stdout) def test_mixed_fixture_exits_nonzero_and_reports_both_classes(self): proc = self.run_cli(LA_INI_TEXT, "\n".join(LT_LINES)) self.assertEqual(proc.returncode, 2) self.assertIn("WRONG", proc.stdout) self.assertIn("CANNOT VERIFY", proc.stdout) def test_all_clean_fixture_exits_zero(self): clean_text = """ [test] alpha = Festum Aurorae Caelestis ; LT.txt:10. """ proc = self.run_cli(clean_text, "\n".join(LT_LINES)) self.assertEqual(proc.returncode, 0) self.assertIn("0 look wrong", proc.stdout) if __name__ == "__main__": unittest.main()