#!/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. REGRESSION vs CHARACTERISATION -- LABELLED HONESTLY, PER TEST (round 2 fix 5). A review found that of round 1's seven fixture-driven tests, only TWO actually failed against the pre-round-1 script -- the rest passed on both sides of round 1's fix and regression-tested nothing despite their names. Every test below now carries one of two tags in its docstring, verified, not asserted: REGRESSION -- shown to FAIL when run against a named prior version of check_citations.py (the exact command used to check this is recorded next to the tag). Losing the fix this test guards would turn it red again. CHARACTERISATION -- passes against the named prior version too (usually because the prior version has no equivalent behaviour or API surface at all -- a KeyError/AttributeError/TypeError rather than a meaningful same-shape failure). Still valuable (it pins what the CURRENT tool does, and would catch a future regression from here on), but it is not evidence that round 1 or round 2 fixed anything -- there is no "before" for it to have failed against. Two prior versions are named throughout: ROUND-0 = the script as it shipped before round 1's hardening (git rev 22824ef, saved for this audit at /tmp/check_citations_round1.py's OWN predecessor -- see the round-2 branch report for the exact commands run). ROUND-1 = the script as hardened by round 1, before round 2's five fixes below (git HEAD at the start of this round). Round-1-era tests (the original seven fixture cases plus the two added after them) are checked against ROUND-0. Round-2 tests (fixes 1-4) are checked against ROUND-1, since that is the version each one is proving a regression against. """ 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. # # Line map (1-indexed): # 10 Festum Aurorae Caelestis -- alpha's real heading # 11 Prima Classis # 13 Festum Umbrae Nocturnae -- beta's real heading # 14 Secunda Classis -- beta wrongly cites here (off by 1) # 16 Festum Solis Invicti -- gamma's real heading / epsilon's # self-poisoning target # 17 Tertia Classis -- iota wrongly cites here # 18 Festum Gloriae -- delta's heading, part 1 (wraps) # 19 Aeternae Perpetuae -- delta's heading, part 2; also # gamma wrongly cites here (off by 3) # 20 Rara Vox Singularis -- mu's real heading (rare word) # 21 Communis Verbum Omnibus -- nu's real heading (common words) # 24 (a single very long line, "communis"/"verbum" x250 each) -- # frequency-table filler ONLY, never itself a citation target: this # is what pushes communis/verbum's corpus-wide count past the # evidence threshold, the same way real words like "classis" (507 # occurrences) are common throughout LT.txt without living on any # one line the checker is asked to verify against. # 26 Magnum -- kappa/lambda heading, part 1 # 27 Festum -- kappa/lambda heading, part 2 # 28 Peregrinum -- kappa/lambda heading, part 3 # (26-28 is a genuine 3-line wrap, at the MAX_RANGE_WIDTH cap; line # 29 is unrelated filler included only by lambda's over-wide range) _FREQUENCY_FILLER = " ".join(["communis", "verbum"] * 250) LT_LINES = ( ["(filler)"] * 9 + [ "Festum Aurorae Caelestis", # 10 "Prima Classis", # 11 "", # 12 "Festum Umbrae Nocturnae", # 13 "Secunda Classis", # 14 "", # 15 "Festum Solis Invicti", # 16 "Tertia Classis", # 17 "Festum Gloriae", # 18 "Aeternae Perpetuae", # 19 "Rara Vox Singularis", # 20 "Communis Verbum Omnibus", # 21 "(filler)", # 22 "(filler)", # 23 _FREQUENCY_FILLER, # 24 "(filler)", # 25 "Magnum", # 26 "Festum", # 27 "Peregrinum", # 28 "Ultra", # 29 ] ) # A fixture covering every case both hardening rounds 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 -- entirely stopwords, nothing to check at all -> CANNOT VERIFY # eta -- PATTERN, no citation at all -> skipped entirely # theta -- PATTERN, trailing on itself only # iota -- SAME BLOCK as theta, no PATTERN of its own, # its own genuinely wrong citation -> FAIL # (round 2 fix 1: theta's PATTERN must not silence this) # mu -- single word, occurs ONCE in the whole corpus -> PASS # (round 2 fix 3a) # nu -- two words, both occur 251 times in the corpus -> CANNOT VERIFY # (round 2 fix 3b -- stricter than round 1, which # would have passed this on word-count alone) # xi -- "12,459"-shaped citation, thousands-typo for # one number -> MALFORMED # (round 2 fix 4) # kappa -- explicit 3-line range, AT the width cap -> PASS # (round 2 fix 2a) # lambda_ -- same heading, 4-line range, OVER the width cap -> MALFORMED # (round 2 fix 2b; note the trailing underscore -- # "lambda" is a Python keyword-adjacent builtin, avoided # only to keep the la.ini key itself plain "lambda") 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 = In Sancta Dominica ; LT.txt:11. ; eta -- PATTERN, constructed name; no heading for this day survives in ; the source at all. eta = Aliquid Fictum theta = Ignotum Simulatum ; PATTERN, invented for this self-test; no real heading survives for ; theta specifically. iota = Festum Umbrae Nocturnae ; LT.txt:17. mu = Singularis ; LT.txt:20. nu = Communis Verbum ; LT.txt:21. xi = Numerus Fictus ; LT.txt:12,459. kappa = Magnum Festum Peregrinum ; LT.txt:26-28. lambda = Magnum Festum Peregrinum ; LT.txt:26-29. """ def entries_field(items, label, entries_substring=None): """Find the finding/unverifiable/malformed dict whose citation label matches (and, if given, whose entries description contains `entries_substring` -- needed on the rare occasion two different entries cite the identical wrong line number), or None.""" for item in items: if item["label"] != label: continue if entries_substring is not None and entries_substring not in item["entries"]: continue 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): """CHARACTERISATION (pins the current tool's own output schema and aggregate counts -- ROUND-1 has no 'malformed' key at all, so this exact assertion cannot even be asked of it; it is not evidence of a fix, it is a pin against future drift).""" # 12 citation EVENTS: alpha, beta, gamma, delta(1 range), epsilon, # zeta, iota, mu, nu, xi, kappa, lambda -- theta/eta contribute none. self.assertEqual(self.result["checked"], 12) self.assertEqual(self.result["passed"], 4) # alpha, delta, mu, kappa self.assertEqual(len(self.result["findings"]), 4) # beta, gamma, epsilon, iota self.assertEqual(len(self.result["unverifiable"]), 2) # zeta, nu self.assertEqual(len(self.result["malformed"]), 2) # xi, lambda def test_correct_citation_passes(self): """CHARACTERISATION: verified against ROUND-0 (git rev 22824ef) -- alpha's own block has no quoted phrases, so the old quote-pooling bug never touches it; alpha passes on both sides.""" found_wrong = {f["label"] for f in self.result["findings"]} found_unverifiable = {u["label"] for u in self.result["unverifiable"]} self.assertNotIn("10", found_wrong) self.assertNotIn("10", found_unverifiable) def test_off_by_one_line_fails(self): """REGRESSION, verified against ROUND-0: ROUND-0's blanket +-2-line tolerance means a window of LT.txt[12..16] is checked for citation "14", which contains line 13 (beta's REAL heading) -- so ROUND-0 reports beta as a PASS and this test's assertIsNotNone(...) fails against it. Confirmed by direct replay of ROUND-0's check() against this exact fixture shape (see the round-2 branch report).""" f = entries_field(self.result["findings"], "14", "beta") 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): """CHARACTERISATION, verified against ROUND-0: even ROUND-0's +-2 tolerance window (LT.txt[17..21]) does not reach line 16 (gamma's real heading), so ROUND-0 already reports this as wrong. This test does not regression-test the +-2 removal; test_off_by_one above does.""" f = entries_field(self.result["findings"], "19", "gamma") 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): """CHARACTERISATION, verified against ROUND-0: explicit A-B ranges already existed in ROUND-0's `expand_citation_spec` (identical regex); ROUND-0 additionally pads each expanded line with its own +-2 tolerance, so this passes there too, just for a sloppier reason. test_wrap_range_required_not_just_first_line below is the test that actually isolates the range syntax doing real work.""" found_wrong = {f["label"] for f in self.result["findings"]} found_unverifiable = {u["label"] for u in self.result["unverifiable"]} found_malformed = {m["label"] for m in self.result["malformed"]} self.assertNotIn("18-19", found_wrong) self.assertNotIn("18-19", found_unverifiable) self.assertNotIn("18-19", found_malformed) def test_wrap_range_required_not_just_first_line(self): """REGRESSION, verified against ROUND-0: citing only delta's first physical line ("LT.txt:18") still falls inside ROUND-0's own +-2 window (16..20), which reaches line 19 and lets it pass -- ROUND-0 never reports a finding here, so this test's assertIsNotNone(...) fails against it. This is the concrete proof that the range syntax is doing real work, not merely being tolerated by leftover slack.""" 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): """REGRESSION, verified against ROUND-0: 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). ROUND-0 pools every double-quoted phrase from the WHOLE block comment, so the quote itself becomes a pool item, matches the window trivially, and ROUND-0 reports "0 look wrong" for it -- confirmed by direct replay. Must FAIL here.""" 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_empty_pool_is_cannot_verify_not_pass(self): """REGRESSION, verified against ROUND-0 (by the letter of the definition -- see below for the nuance): zeta's entry text is entirely stopwords ("In Sancta Dominica"), so there is nothing to check either way. ROUND-0 has no CANNOT-VERIFY concept at all: a fully empty pool item never satisfies `any(item <= window_words for item in pool_items)` over an empty pool, so ROUND-0 reports it as an ordinary WRONG finding instead -- confirmed by direct replay. This test's specific assertion (that it lands in `unverifiable`) therefore fails against ROUND-0, though the underlying "not a silent pass" property does hold there too, just through a coarser, undifferentiated classification. ROUND-1 already has the current three-way split (as "too thin", word count rather than "no distinctive words", rarity) and passes this test unchanged.""" u = entries_field(self.result["unverifiable"], "11") self.assertIsNotNone(u, "zeta's all-stopword 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, "an empty pool must never be reported as a silent PASS") def test_pattern_block_skipped_entirely(self): """CHARACTERISATION, verified against ROUND-0: eta's block contains only eta itself, so ROUND-0's whole-block PATTERN skip and the current tool's entry-scoped skip have the identical effect for this single-entry case -- the difference only shows up in a MULTI-entry block, which is test_pattern_does_not_silence_a_ different_entry_in_the_same_block below (the real fix-1 regression test).""" 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"])) for m in self.result["malformed"]: self.assertNotIn("eta", keys_of(m["entries"])) def test_pattern_does_not_silence_a_different_entry_in_the_same_block(self): """REGRESSION, verified against ROUND-1 (git HEAD before round 2): theta and iota share ONE block (no blank line between them, the same shape as [season]'s real back-to-back trailing-comment style). theta's own trailing comment says "PATTERN"; iota is a completely different entry with its own genuinely wrong citation and no PATTERN marker at all. ROUND-1's `check()` tested `"PATTERN" in block_comment` -- a single substring search over every comment in the WHOLE block -- and skipped the entire block on a match, so iota's wrong citation was never even looked at: confirmed by direct replay of ROUND-1's check() against this exact fixture (see the round-2 branch report). Must FAIL here.""" f = entries_field(self.result["findings"], "17") self.assertIsNotNone( f, "iota's own wrong citation must FAIL even though theta, in the same block, is PATTERN-marked" ) self.assertIn("iota", f["entries"]) # And theta itself must still be excluded, exactly like eta. for f in self.result["findings"]: self.assertNotIn("theta=", f["entries"]) def test_rare_single_word_match_passes(self): """REGRESSION, verified against ROUND-1: mu's entry is a single word, "Singularis", occurring exactly once in the whole corpus. ROUND-1's word-COUNT gate (`MIN_DISTINCTIVE_WORDS = 2`) excluded any one-word pool item from matching at all, regardless of how rare that word is, and reported it CANNOT VERIFY unconditionally -- confirmed by direct replay. This is round 2 fix 3's own primary example (the real "S. Antonii Abb." shape): a single occurrence in a 26,000+-line corpus is essentially conclusive and must PASS.""" passed_labels_not_flagged = ( "20" not in {f["label"] for f in self.result["findings"]} and "20" not in {u["label"] for u in self.result["unverifiable"]} ) self.assertTrue(passed_labels_not_flagged, "a rare (freq=1) single-word match must PASS, not be flagged") def test_many_common_tokens_match_is_cannot_verify(self): """REGRESSION, verified against ROUND-1: nu's entry has TWO distinctive words ("Communis", "Verbum"), clearing ROUND-1's own `MIN_DISTINCTIVE_WORDS = 2` gate on word count alone -- ROUND-1 reports this a PASS purely because there are two words, without ever checking how common either one is (both occur 251 times in this fixture's corpus). Confirmed by direct replay. Round 2 fix 3 requires this to stay CANNOT VERIFY regardless of word count -- the STRICTER half of the rarity rule, not just the looser half rare-word tests exercise.""" u = entries_field(self.result["unverifiable"], "21") self.assertIsNotNone( u, "a match consisting only of common (251-occurrence) tokens must be CANNOT VERIFY" ) found_wrong = {f["label"] for f in self.result["findings"]} self.assertNotIn("21", found_wrong, "common-word-only should be CANNOT VERIFY, not a silent FAIL either") def test_thousands_typo_citation_is_malformed(self): """REGRESSION, verified against ROUND-1: ROUND-1's CITATION_RE and `parse_citation_spec` happily parse "12,459" as two independent bare citations, 12 and 459, and check them separately -- neither anywhere near the real intended line, but the malformed spec is never reported as such; confirmed by direct replay (ROUND-1 raises no exception and produces two ordinary, uninteresting citation events instead of one flagged one). Round 2 fix 4 requires the whole spec to be rejected as MALFORMED instead.""" m = entries_field(self.result["malformed"], "12,459") self.assertIsNotNone(m, "the thousands-typo-shaped citation must be reported MALFORMED") self.assertIn("xi", m["entries"]) # And it must not ALSO sneak through as two ordinary citations. self.assertIsNone(entries_field(self.result["findings"], "12")) self.assertIsNone(entries_field(self.result["findings"], "459")) def test_range_at_cap_width_passes(self): """REGRESSION, verified against ROUND-1: this specific 3-line range already passes on ROUND-1 too (ROUND-1 also has no upper cap), so by itself this is CHARACTERISATION -- it is paired here with test_range_over_cap_width_is_malformed below to show the cap is drawn in the RIGHT place (exactly at MAX_RANGE_WIDTH, not one line short of it).""" found_wrong = {f["label"] for f in self.result["findings"]} found_malformed = {m["label"] for m in self.result["malformed"]} self.assertNotIn("26-28", found_wrong) self.assertNotIn("26-28", found_malformed) def test_range_over_cap_width_is_malformed(self): """REGRESSION, verified against ROUND-1: "LT.txt:26-29" is a 4-line range citing the identical heading text kappa already cites correctly at the 3-line cap -- ROUND-1 has no upper bound at all (only the pre-existing 200-line absurdity guard), so it silently accepts this and checks it exactly like kappa's; confirmed by direct replay. Round 2 fix 2 requires anything over MAX_RANGE_WIDTH to be rejected as MALFORMED, regardless of whether the content would otherwise have matched.""" m = entries_field(self.result["malformed"], "26-29") self.assertIsNotNone(m, "a range wider than MAX_RANGE_WIDTH must be MALFORMED") self.assertIn("lambda", m["entries"]) self.assertIn(str(cc.MAX_RANGE_WIDTH), m["reason"]) class TestHelpers(unittest.TestCase): def test_distinctive_words_strips_stopwords_and_short_tokens(self): """CHARACTERISATION: `distinctive_words`/`normalize_word` are byte-identical to ROUND-0 and ROUND-1 -- neither hardening round touched them. Pins current behaviour only.""" 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): """CHARACTERISATION: see above.""" 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): """CHARACTERISATION: ROUND-0/ROUND-1 both expand a bare number the same way, just under a different function name/return shape (`expand_citation_spec` -> a flat list of ints, no malformed channel). Pins the current tuple-returning API.""" refs, malformed = cc.parse_citation_spec("8609") self.assertEqual(len(refs), 1) self.assertEqual(refs[0].lines, [8609]) self.assertEqual(malformed, []) def test_parse_citation_spec_range_is_one_ref(self): """CHARACTERISATION: see above.""" refs, malformed = cc.parse_citation_spec("8609-8610") self.assertEqual(len(refs), 1) self.assertEqual(refs[0].lines, [8609, 8610]) self.assertEqual(malformed, []) def test_parse_citation_spec_comma_list_is_several_refs(self): """CHARACTERISATION: see above.""" refs, malformed = cc.parse_citation_spec("8618,8620,8622") self.assertEqual([r.lines for r in refs], [[8618], [8620], [8622]]) self.assertEqual(malformed, []) def test_parse_citation_spec_mixed_list(self): """CHARACTERISATION: see above.""" refs, malformed = cc.parse_citation_spec("8786,8788-8789,8791-8792") self.assertEqual( [r.lines for r in refs], [[8786], [8788, 8789], [8791, 8792]], ) self.assertEqual(malformed, []) def test_parse_citation_spec_rejects_backwards_range(self): """REGRESSION, verified against ROUND-1: ROUND-1's `parse_citation_spec` SILENTLY DROPPED a backwards range (empty refs list, no report at all) -- confirmed by direct replay. Round 2 surfaces it as MALFORMED instead of discarding it invisibly.""" refs, malformed = cc.parse_citation_spec("100-50") self.assertEqual(refs, []) self.assertEqual(len(malformed), 1) self.assertIn("backwards", malformed[0]["reason"]) def test_parse_citation_spec_rejects_range_over_cap(self): """REGRESSION, verified against ROUND-1: ROUND-1 accepted any range up to 200 lines wide as a normal, silently-checked citation -- "1000-999999" exceeded even that old 200-line guard and was silently dropped (empty list, no report); a merely-wide-but-under -200 range like "1000-1100" was silently ACCEPTED and checked as if it were a legitimate wrap, which is the actual defeat this fix closes. Both shapes are confirmed by direct replay against ROUND-1. Round 2 caps at MAX_RANGE_WIDTH and reports the excess width by name rather than silently accepting or silently dropping.""" refs, malformed = cc.parse_citation_spec("1000-1100") self.assertEqual(refs, [], "a 101-line range must not be silently accepted") self.assertEqual(len(malformed), 1) self.assertIn("101", malformed[0]["reason"]) def test_parse_citation_spec_range_at_cap_boundary(self): """CHARACTERISATION: pins the exact boundary -- a range exactly MAX_RANGE_WIDTH lines wide is accepted, not rejected.""" refs, malformed = cc.parse_citation_spec("2000-2002") self.assertEqual(len(refs), 1) self.assertEqual(malformed, []) def test_parse_citation_spec_thousands_typo(self): """REGRESSION, verified against ROUND-1: ROUND-1 parses "12,459" as two ordinary bare citations (12 and 459) with no indication anything is wrong -- confirmed by direct replay. Round 2 fix 4 rejects the whole spec instead.""" refs, malformed = cc.parse_citation_spec("12,459") self.assertEqual(refs, []) self.assertEqual(len(malformed), 1) self.assertIn("thousands", malformed[0]["reason"]) def test_parse_citation_spec_similar_looking_list_is_not_flagged(self): """CHARACTERISATION: guards the thousands-typo heuristic against false positives on a genuine multi-citation list -- two 4-digit numbers close together must still parse normally, not be rejected just because they happen to sit next to each other in a list.""" refs, malformed = cc.parse_citation_spec("8618,8620,8622") self.assertEqual(malformed, []) self.assertEqual(len(refs), 3) def test_item_evidence_hapax_is_strong(self): """CHARACTERISATION of the new (round 2) rarity machinery: a word occurring once in a corpus of many contributes evidence 1.0, well past EVIDENCE_THRESHOLD. No prior round had this function at all.""" freq = cc.build_frequency_table(["Singularis Verbum", "Aliud Verbum"]) self.assertEqual(cc.item_evidence({"singularis"}, freq), 1.0) def test_item_evidence_common_word_is_weak(self): """CHARACTERISATION of the new rarity machinery: a word occurring 500 times contributes far below EVIDENCE_THRESHOLD.""" freq = cc.build_frequency_table([" ".join(["classis"] * 500)]) self.assertLess(cc.item_evidence({"classis"}, freq), cc.EVIDENCE_THRESHOLD) def test_item_evidence_is_the_max_not_the_sum(self): """CHARACTERISATION: an item combining one rare word and one very common word takes its evidence from the RARE one -- a real match is not penalised for also containing an ordinary word beside it.""" freq = cc.build_frequency_table( ["Singularis Verbum"] + [" ".join(["communis"] * 300)] ) ev = cc.item_evidence({"singularis", "communis"}, freq) self.assertEqual(ev, 1.0) def test_word_evidence_unseen_word_is_zero(self): """CHARACTERISATION: a word absent from the corpus entirely (freq 0) contributes zero evidence rather than raising or dividing by zero -- it can never legitimately be "contained" in a real window either, so this only matters defensively.""" freq = cc.build_frequency_table(["Aliud Verbum"]) self.assertEqual(cc.word_evidence("nusquam", freq), 0.0) 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. Labelled per-test against ROUND-1 (the --file/--lt-file plumbing itself is a ROUND-1 feature; ROUND-0's main() takes no arguments at all and reads the real lang/la.ini and real docs/research/LT.txt unconditionally, so ROUND-0 is not a meaningful comparison for any test in this class).""" 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): """CHARACTERISATION, verified against ROUND-1: identical SKIPPED behaviour, unchanged by round 2.""" 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_all_three_classes(self): """REGRESSION, verified against ROUND-1: run as a real subprocess against this exact fixture, ROUND-1's CLI never prints "MALFORMED" anywhere (it has no such concept) -- confirmed by direct replay (`WRONG`/`CANNOT VERIFY` both appear, `MALFORMED` does not).""" proc = self.run_cli(LA_INI_TEXT, "\n".join(LT_LINES)) self.assertEqual(proc.returncode, 2) self.assertIn("WRONG", proc.stdout) self.assertIn("MALFORMED", proc.stdout) self.assertIn("CANNOT VERIFY", proc.stdout) def test_all_clean_fixture_exits_zero(self): """CHARACTERISATION, verified against ROUND-1: identical clean-exit behaviour, unchanged by round 2.""" 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()