1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
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()
|