summaryrefslogtreecommitdiff
path: root/tools/test_check_citations.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/test_check_citations.py')
-rw-r--r--tools/test_check_citations.py268
1 files changed, 268 insertions, 0 deletions
diff --git a/tools/test_check_citations.py b/tools/test_check_citations.py
new file mode 100644
index 0000000..3d7722d
--- /dev/null
+++ b/tools/test_check_citations.py
@@ -0,0 +1,268 @@
+#!/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()