2026-10-13 15:58:03 -04:00
|
|
|
|
"""Offline checks (no ProPresenter, no fonts needed). Run: python3 tests/run_tests.py"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
sys.path.insert(0, os.path.join(HERE, "..", "src"))
|
|
|
|
|
|
import deck # noqa: E402
|
|
|
|
|
|
import notes # noqa: E402
|
|
|
|
|
|
import pbwire # noqa: E402
|
|
|
|
|
|
import plantext # noqa: E402
|
|
|
|
|
|
import rtf # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
NOTES = os.path.join(HERE, "notes")
|
|
|
|
|
|
PRO = os.path.join(HERE, "pro")
|
|
|
|
|
|
SERIES = os.path.join(PRO, "_TEMPLATE - DO NOT DELETE 2608 Lower Thirds.pro")
|
|
|
|
|
|
GENERIC = os.path.join(PRO, "_TEMPLATE_GENERIC - DO NOT DELETE 2609.pro")
|
|
|
|
|
|
failures = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
|
|
|
|
print("%-6s %s%s" % ("ok" if cond else "FAIL", name, (" -- " + detail) if (detail and not cond) else ""))
|
|
|
|
|
|
if not cond:
|
|
|
|
|
|
failures.append(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def slides_of(data):
|
|
|
|
|
|
p = deck.C.decode(data, "rv.data.Presentation")
|
|
|
|
|
|
cues = {deck.uuid_str(deck.C.get(c, "uuid")): c for c in deck.C.all(p, "cues")}
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for g in deck.C.all(p, "cue_groups"):
|
|
|
|
|
|
for cid in deck.C.all(g, "cue_identifiers"):
|
|
|
|
|
|
a = deck.slide_action(cues[deck.uuid_str(cid)])
|
|
|
|
|
|
texts = []
|
|
|
|
|
|
for el in deck.slide_elements(a):
|
|
|
|
|
|
t = deck.element_text(el)
|
|
|
|
|
|
d = deck.C.get(t, "rtf_data") if t is not None else None
|
|
|
|
|
|
if d:
|
|
|
|
|
|
s = " / ".join(p_.text for p_ in rtf.read(d).paras).strip()
|
|
|
|
|
|
if s:
|
|
|
|
|
|
texts.append((deck.element_name(el), s))
|
|
|
|
|
|
out.append((deck.action_label(a), texts))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
# 1. every .pro file survives decode/encode byte for byte
|
|
|
|
|
|
for f in sorted(os.listdir(PRO)):
|
|
|
|
|
|
if f.endswith(".pro"):
|
|
|
|
|
|
b = open(os.path.join(PRO, f), "rb").read()
|
|
|
|
|
|
check("roundtrip %s" % f, deck.C.encode(deck.C.decode(b, "rv.data.Presentation")) == b)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. every notes file parses; the slide list survives the TextEdit format
|
|
|
|
|
|
for f in sorted(os.listdir(NOTES)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = notes.parse(open(os.path.join(NOTES, f), "rb").read())
|
|
|
|
|
|
p2 = plantext.load(plantext.dump(p, f))
|
|
|
|
|
|
same = [(s.kind, s.ref, [(l.text, l.lead) for l in s.lines]) for s in p.slides] == \
|
|
|
|
|
|
[(s.kind, s.ref, [(l.text, l.lead) for l in s.lines]) for s in p2.slides]
|
|
|
|
|
|
check("notes %s (%d slides)" % (f, len(p.slides)), same and p.title_slide is not None)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
check("notes %s" % f, False, traceback.format_exc().splitlines()[-1])
|
|
|
|
|
|
|
|
|
|
|
|
# 3. Part 7 from its notes matches the hand-made Part 7 (apart from the known differences)
|
|
|
|
|
|
tpl = deck.Template(open(SERIES, "rb").read())
|
|
|
|
|
|
plan = notes.parse(open(os.path.join(NOTES, "Slides - Where Is Your Faith - Part 7.rtf"), "rb").read())
|
|
|
|
|
|
data = deck.build(tpl, plan, "TEST", ("LOCATION PASTOR", "Mehdi Chniouli"))
|
|
|
|
|
|
gen = slides_of(data)
|
|
|
|
|
|
hand = slides_of(open(os.path.join(PRO, "261011 Where Is Your Faith Part 7.pro"), "rb").read())
|
|
|
|
|
|
check("part 7 slide count", len(gen) == len(hand), "%d vs %d" % (len(gen), len(hand)))
|
|
|
|
|
|
check("part 7 labels", [g[0] for g in gen] == [h[0] for h in hand])
|
|
|
|
|
|
norm = lambda s: re.sub(r"\s+", " ", s.replace(" ", " ")).strip()
|
|
|
|
|
|
diffs = []
|
|
|
|
|
|
for k, (g, h) in enumerate(zip(gen, hand), 1):
|
|
|
|
|
|
if [(n, norm(t)) for n, t in g[1]] != [(n, norm(t)) for n, t in h[1]]:
|
|
|
|
|
|
diffs.append(k)
|
|
|
|
|
|
# 17: the operator added a manual line break ("can't follow / me THROUGH")
|
|
|
|
|
|
check("part 7 texts identical except slide 17", diffs == [17], "differs on %s" % diffs)
|
|
|
|
|
|
|
|
|
|
|
|
# 4. both templates build without text measurement, and the result decodes
|
|
|
|
|
|
for path in (SERIES, GENERIC):
|
|
|
|
|
|
t = deck.Template(open(path, "rb").read())
|
|
|
|
|
|
for f in ("Slides - 7 Letters - Part 2.rtf", "Slides - May 2026 Forge Night.rtf"):
|
|
|
|
|
|
p = notes.parse(open(os.path.join(NOTES, f), "rb").read())
|
|
|
|
|
|
d = deck.build(t, p, "TEST", t.speaker())
|
|
|
|
|
|
out = slides_of(d)
|
|
|
|
|
|
n_build = sum(1 for s in p.slides if s.kind == "build")
|
|
|
|
|
|
want = len(t.head) + sum(1 for s in p.slides if s.kind != "title") + n_build + len(t.tail)
|
|
|
|
|
|
check("build %s + %s" % (os.path.basename(path)[:22], f), len(out) == want, "%d vs %d" % (len(out), want))
|
|
|
|
|
|
|
|
|
|
|
|
# 5. fresh ids everywhere, references to shared things untouched
|
|
|
|
|
|
tu = set(re.findall(rb"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}", open(SERIES, "rb").read()))
|
|
|
|
|
|
gu = set(re.findall(rb"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}", data))
|
|
|
|
|
|
shared = tu & gu
|
|
|
|
|
|
check("only shared references reused (%d)" % len(shared), all(
|
|
|
|
|
|
x.decode() in open(SERIES, "rb").read().decode("latin-1") for x in shared) and len(shared) <= 12)
|
|
|
|
|
|
|
2026-10-14 12:37:18 -04:00
|
|
|
|
# 6. typo check (macOS's spelling checker, so this needs a Mac): planted typos are fixed,
|
|
|
|
|
|
# real notes are left alone
|
|
|
|
|
|
import spelling
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = notes.parse(open(os.path.join(HERE, "typos", "Slides - Typo Test - Part 7.rtf"), "rb").read())
|
|
|
|
|
|
rep = spelling.check_and_fix(p, skip=set())
|
|
|
|
|
|
got = sorted((f.word, f.new) for f in rep.fixes)
|
|
|
|
|
|
want = sorted([("teh", "the"), ("the the", "the"), ("OBEDIANCE", "OBEDIENCE"), ("beleived", "believed")])
|
|
|
|
|
|
check("typos: planted typos fixed", got == want, "got %s" % got)
|
|
|
|
|
|
for f in sorted(os.listdir(NOTES)):
|
|
|
|
|
|
p = notes.parse(open(os.path.join(NOTES, f), "rb").read())
|
|
|
|
|
|
rep = spelling.check_and_fix(p)
|
|
|
|
|
|
check("typos: nothing changed in %s" % f, not rep.fixes, ", ".join("%s->%s" % (x.word, x.new) for x in rep.fixes))
|
|
|
|
|
|
except spelling.Unavailable as e:
|
|
|
|
|
|
print("skip typo checks (%s)" % e)
|
|
|
|
|
|
|
2026-10-19 12:11:30 -04:00
|
|
|
|
# 7. underlined words in the notes are underlined on the slides (as the operator does by hand)
|
|
|
|
|
|
def underlined_runs(data):
|
|
|
|
|
|
p = deck.C.decode(data, "rv.data.Presentation")
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for c in deck.C.all(p, "cues"):
|
|
|
|
|
|
a = deck.slide_action(c)
|
|
|
|
|
|
for el in (deck.slide_elements(a) if a is not None else []):
|
|
|
|
|
|
t = deck.element_text(el)
|
|
|
|
|
|
d = deck.C.get(t, "rtf_data") if t is not None else None
|
|
|
|
|
|
for para in (rtf.read(d).paras if d else []):
|
|
|
|
|
|
out += [r.text.strip() for r in para.runs if "ul" in r.state and r.text.strip()]
|
|
|
|
|
|
return sorted(out)
|
|
|
|
|
|
|
|
|
|
|
|
p8 = notes.parse(open(os.path.join(NOTES, "Slides - Where is Your Faith - Part 8.rtf"), "rb").read())
|
|
|
|
|
|
got = underlined_runs(deck.build(tpl, p8, "TEST", tpl.speaker()))
|
|
|
|
|
|
hand = underlined_runs(open(os.path.join(PRO, "261018 Where Is Your Faith Part 8.pro"), "rb").read())
|
|
|
|
|
|
check("part 8 underlines match the hand-fixed deck", got == hand == ["saved", "saves"], "%s vs %s" % (got, hand))
|
|
|
|
|
|
p7l = notes.parse(open(os.path.join(NOTES, "Slides - 7 Letters - Part 2.rtf"), "rb").read())
|
|
|
|
|
|
got = underlined_runs(deck.build(tpl, p7l, "TEST", tpl.speaker()))
|
|
|
|
|
|
hand = underlined_runs(open(os.path.join(PRO, "260628 The 7 Letters Part 2.pro"), "rb").read())
|
|
|
|
|
|
check("7 Letters 2 underlines match the hand-made deck", got == hand, "%s vs %s" % (got, hand))
|
|
|
|
|
|
tt = notes.parse(open(os.path.join(NOTES, "Slides - Together Tuesday - October 2026.rtf"), "rb").read())
|
|
|
|
|
|
pt = [s for s in tt.slides if s.kind == "point" and s.lines[0].text.startswith("Prophesy")]
|
|
|
|
|
|
check("underline kept in a point", pt and [x[0] for x in pt[0].lines[0].segs_plain() if x[3]] == ["Prophesy,"],
|
|
|
|
|
|
"%s" % (pt[0].lines[0].segs_plain() if pt else "no point"))
|
|
|
|
|
|
rt = plantext.load(plantext.dump(p8, "x"))
|
|
|
|
|
|
styled = lambda plan: [[(ch,) + tuple(seg[1:]) for seg in l.segs for ch in seg[0] if not ch.isspace()]
|
|
|
|
|
|
for s in plan.slides for l in s.lines] # spaces at bold edges may move
|
|
|
|
|
|
check("underline survives the TextEdit round trip", styled(p8) == styled(rt))
|
|
|
|
|
|
check("fill-in blanks are not underline marks", plantext.parse_bold("Faith is ______.") ==
|
|
|
|
|
|
[("Faith is ______.", False, False, False)])
|
|
|
|
|
|
|
|
|
|
|
|
# 8. other campuses: their template names, and who preaches there according to the email
|
2026-10-14 12:57:44 -04:00
|
|
|
|
import app
|
|
|
|
|
|
import settings
|
|
|
|
|
|
for n, want in (("_TEMPLATE - DO NOT DELETE 2608 Lower Thirds.pro", True),
|
|
|
|
|
|
("_TEMPLATE_GENERIC - DO NOT DELETE 2601.pro", True),
|
|
|
|
|
|
("_RESI MESSAGE TEMPLATE.pro", False), ("._TEMPLATE - DO NOT DELETE 2608.pro", False),
|
|
|
|
|
|
("261011 Where Is Your Faith Part 7.pro", False)):
|
|
|
|
|
|
check("template name %s" % n, settings.is_template(n) == want)
|
|
|
|
|
|
known = [("LOCATION PASTOR", "Mehdi Chniouli"), ("PASTOR", "Test Geyser"), ("LEAD PASTOR", "Tim Hatch"),
|
|
|
|
|
|
("PASTOR", "Other Geyser")]
|
|
|
|
|
|
mail = ("Subject: Notes/Slides - Where Is Your Faith - Part 7\n\nHi all,\nPastor Mehdi will be preaching live "
|
|
|
|
|
|
"from NA with RESI to SC and WS. I have attached the notes/slides. Pastor Geyser will be preaching "
|
|
|
|
|
|
"live in AB. Please have blank notes ready.\n")
|
|
|
|
|
|
real_load = settings.load
|
|
|
|
|
|
try:
|
|
|
|
|
|
for campus, want in (("NA", "Mehdi Chniouli"), ("SC", "Mehdi Chniouli"), ("WS", "Mehdi Chniouli"),
|
|
|
|
|
|
("AB", "Test Geyser")):
|
|
|
|
|
|
settings.load = lambda c=campus: {"campus": c}
|
|
|
|
|
|
got = app.speaker_from_email(mail, known)
|
|
|
|
|
|
check("speaker at %s" % campus, got and got[1] == want, "got %s" % (got,))
|
|
|
|
|
|
settings.load = lambda: {"campus": "AB"}
|
|
|
|
|
|
got = app.speaker_from_email("Pastor Tim will be preaching live from NA with RESI to all other locations.", known)
|
|
|
|
|
|
check("speaker at AB from 'all other locations'", got and got[1] == "Tim Hatch", "got %s" % (got,))
|
|
|
|
|
|
check("youtube only at NA", not settings.youtube_enabled())
|
|
|
|
|
|
settings.load = lambda: {"campus": "NA"}
|
|
|
|
|
|
check("youtube at NA", settings.youtube_enabled())
|
|
|
|
|
|
finally:
|
|
|
|
|
|
settings.load = real_load
|
|
|
|
|
|
|
2026-10-13 15:58:03 -04:00
|
|
|
|
print()
|
|
|
|
|
|
print("FAILED: %s" % failures if failures else "all checks passed")
|
|
|
|
|
|
return 1 if failures else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|