Frontend Features and refactor

This commit is contained in:
2026-10-19 15:10:59 -07:00
parent 7a0dbe19ea
commit 317f5bf746
28 changed files with 3915 additions and 530 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Fuzzes /find with many ways of typing a reference and checks the verses that come back.
Run from the project root: python3 tools/fuzz_refs.py [sample_size]
Expected verse ids come straight from data/kjv.db, not from the app.
"""
import sys, re, random, sqlite3, itertools, json
sys.path.insert(0, ".") # run from the project root
import logging
import app as a
a.app.testing = True
client = a.app.test_client()
db = sqlite3.connect("data/kjv.db")
BOOKS = [r[0] for r in db.execute("select n from key_english order by b")]
ids = {} # (book, chap) -> [(verse, id)]
for vid, b, c, v in db.execute("select id,b,c,v from fts_kjv"):
ids.setdefault((int(b), int(c)), []).append((int(v), int(vid)))
maxch = {b: max(c for (bb, c) in ids if bb == b) for b in range(1, 67)}
NAME_VARIANTS = {
1: ["Gen", "Ge", "Gn"], 2: ["Exod", "Ex", "Exo"], 3: ["Lev", "Lv"], 4: ["Num", "Nm", "Nu"],
5: ["Deut", "Dt", "Deu"], 6: ["Josh", "Jos"], 7: ["Judg", "Jdg", "Jg"], 8: ["Ru"],
9: ["1 Sam", "1 Sm", "1Sa", "I Samuel", "1st Samuel", "First Samuel"],
10: ["2 Sam", "2 Sm", "2Sa", "II Samuel", "2nd Samuel", "Second Samuel"],
11: ["1 Kgs", "1 Ki", "1Kin", "I Kings", "First Kings"], 12: ["2 Kgs", "2 Ki", "II Kings", "Second Kings"],
13: ["1 Chron", "1 Chr", "1Ch", "I Chronicles", "First Chronicles"], 14: ["2 Chron", "2 Chr", "II Chronicles"],
15: ["Ezr"], 16: ["Neh", "Ne"], 17: ["Est", "Esth"], 18: [], 19: ["Ps", "Psa", "Psalm", "Pslm"],
20: ["Prov", "Pr", "Prv"], 21: ["Eccl", "Ecc", "Eccles", "Ecclesiasts"],
22: ["Song", "Song of Songs", "Songs of Solomon", "Song of Sol", "SOS", "Canticles", "SongofSolomon"],
23: ["Isa", "Is"], 24: ["Jer", "Je"], 25: ["Lam"], 26: ["Ezek", "Eze", "Ezk"], 27: ["Dan", "Dn"],
28: ["Hos", "Ho"], 29: ["Joe", "Jl"], 30: ["Am"], 31: ["Obad", "Ob"], 32: ["Jon", "Jnh"], 33: ["Mic"],
34: ["Nah", "Na"], 35: ["Hab"], 36: ["Zeph", "Zep"], 37: ["Hag", "Hg"], 38: ["Zech", "Zec"], 39: ["Mal"],
40: ["Matt", "Mt", "Mat"], 41: ["Mk", "Mar"], 42: ["Lk", "Luk"], 43: ["Jn", "Joh"], 44: ["Act"],
45: ["Rom", "Ro"], 46: ["1 Cor", "1Co", "I Corinthians", "First Corinthians", "1st Corinthians"],
47: ["2 Cor", "2Co", "II Corinthians", "Second Corinthians"], 48: ["Gal", "Ga"], 49: ["Eph"],
50: ["Phil", "Php", "Philip"], 51: ["Col"], 52: ["1 Thess", "1 Th", "1Thes", "I Thessalonians"],
53: ["2 Thess", "2 Th", "II Thessalonians"], 54: ["1 Tim", "1 Ti", "I Timothy", "First Timothy"],
55: ["2 Tim", "2 Ti", "II Timothy"], 56: ["Tit", "Ti"], 57: ["Phlm", "Phm", "Philem"], 58: ["Heb"],
59: ["Jas", "Jam"], 60: ["1 Pet", "1 Pe", "1Pt", "I Peter", "First Peter"], 61: ["2 Pet", "2 Pe", "II Peter"],
62: ["1 Jn", "1 Jo", "1Joh", "I John", "First John", "1st John"], 63: ["2 Jn", "2 John", "II John"],
64: ["3 Jn", "3 John", "III John"], 65: ["Jud"], 66: ["Rev", "Re", "Revelations", "Revelation of John"],
}
def name_forms(b):
full = BOOKS[b - 1]
forms = {full, full.lower(), full.upper(), full.replace(" ", ""), full.lower().replace(" ", "")}
forms |= set(NAME_VARIANTS.get(b, []))
forms |= {f.lower() for f in NAME_VARIANTS.get(b, [])}
return sorted(forms)
def cases():
rnd = random.Random(1611)
for b in range(1, 67):
for nm in name_forms(b):
ch = rnd.randint(1, maxch[b])
vs = ids[(b, ch)]
v1 = rnd.choice(vs)[0]
v2 = min(vs[-1][0], v1 + rnd.randint(1, 3))
chap_ids = [i for _, i in vs]
single = [i for v, i in vs if v == v1]
rng = [i for v, i in vs if v1 <= v <= v2]
key = a.normalize_book_text(nm)
if key in a.BOOK_CANONICAL or key in a.BARE_BOOK_EXTRA: # abbreviations alone stay keyword searches
for sp in ("", " ", " "):
yield (f"{nm}{sp}", [i for _, i in ids[(b, 1)]], "bare") # book alone -> chapter 1
for fmt, exp, kind in (
(f"{nm} {ch}", chap_ids, "chapter"),
(f"{nm}{ch}", chap_ids, "chapter-nospace"),
(f"{nm} {ch}:{v1}", single, "verse"),
(f"{nm}{ch}:{v1}", single, "verse-nospace"),
(f" {nm} {ch} : {v1} ", single, "verse-spaced"),
(f"{nm} {ch}.{v1}", single, "verse-dot"),
(f"{nm} {ch} {v1}", single, "verse-space-sep"),
(f"{nm} {ch}:{v1}-{v2}", rng, "range"),
(f"{nm} {ch}:{v1} - {v2}", rng, "range-spaced"),
(f"{nm} {ch}:{v1}–{v2}", rng, "range-endash"),
(f"{nm.upper()} {ch}:{v1}", single, "verse-upper"),
(f"{nm} {ch}:{v1}-{v2}\t", rng, "range-trailing-tab"),
):
if v2 > v1 or kind not in ("range", "range-spaced", "range-endash", "range-trailing-tab"):
yield fmt, exp, kind
def verse_ids_in(html):
return [int(x) for x in re.findall(r'data-verse-id="(\d+)"', html)]
def run(limit=None, show=25):
allcases = list(cases())
if limit:
random.Random(7).shuffle(allcases); allcases = allcases[:limit]
fails = {}
total = 0
for q, exp, kind in allcases:
total += 1
try:
r = client.get("/find", query_string={"kw": q, "view": "rich"})
got = verse_ids_in(r.get_data(as_text=True)) if r.status_code == 200 else None
except Exception as e:
got = None
ok = got is not None and got[:len(exp)] == exp and (kind.startswith("range") or kind.startswith("verse") or kind == "chapter" or True) and (len(got) == len(exp) if kind != "bare" else got[:len(exp)] == exp)
if not ok:
fails.setdefault(kind, []).append((q, len(exp), None if got is None else len(got)))
nfail = sum(len(v) for v in fails.values())
print(f"{total} cases, {nfail} failed ({100*nfail//max(total,1)}%)")
for k, v in sorted(fails.items(), key=lambda kv: -len(kv[1])):
print(f" {k}: {len(v)} e.g. {v[:3]}")
return fails
if __name__ == "__main__":
logging.disable(logging.CRITICAL)
run(int(sys.argv[1]) if len(sys.argv) > 1 else None)