- Template.step_builds: build slides without ProPresenter's build feature;
each build in the notes becomes one slide per step from the template's
k-th build slide (last one reused): heading + first item, then one more
line per slide; font runs cut to each step's text
- Build headings (":"/"?"/italic, or above a numbered / same-word list) kept
as Line.lead; TextEdit format marks them with ">"
- Prefer a one-slide-per-step template over a newer old-style one
- Clear message when a notes file in Google Drive can't be read
- README: builds, underline, fonts are never changed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
13 KiB
Python
219 lines
13 KiB
Python
"""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)
|
||
|
||
# 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)
|
||
|
||
# 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. a template with one slide per build step (Build 1, Build 2, ...) instead of the build feature
|
||
STEP = os.path.join(PRO, "_TEMPLATE_GENERIC - DO NOT DELETE 2609-1.pro")
|
||
stpl = deck.Template(open(STEP, "rb").read())
|
||
check("step template detected", stpl.step_builds and len(stpl.builds) == 4 and not tpl.step_builds)
|
||
p8 = notes.parse(open(os.path.join(NOTES, "Slides - Where is Your Faith - Part 8.rtf"), "rb").read())
|
||
out = slides_of(deck.build(stpl, p8, "TEST", stpl.speaker()))
|
||
check("step deck slide count", len(out) == deck.slide_count(stpl, p8), "%d vs %d" % (len(out), deck.slide_count(stpl, p8)))
|
||
steps = [(lab, texts[0][1]) for lab, texts in out if lab.startswith("Build")]
|
||
flaw = ["What is your FLAW?", "1. FEARFUL INSECURITY - Gideon and Barak", "2. FLESHLY IMPULSES - David and Samson",
|
||
"3. FAMILY DYSFUNCTION - David and Jephthah"]
|
||
want = [("Build %d" % k, " / ".join(flaw[:k + 1])) for k in (1, 2, 3)]
|
||
check("build steps: heading + one more line per slide", steps[:3] == want, "%s" % steps[:3])
|
||
check("second build: 3 steps from Build 1-3", [s[0] for s in steps[3:]] == ["Build 1", "Build 2", "Build 3"]
|
||
and steps[5][1].count(" / ") == 3, "%s" % steps[3:])
|
||
check("no STREAM DECK slide with step builds", not any("STREAM DECK" in lab for lab, _ in out))
|
||
nums = deck.slide_numbers(stpl, p8)
|
||
firsts = [nums[i] for i, s in enumerate(p8.slides) if s.kind == "build"]
|
||
check("slide numbers point at each build's first step", [out[n - 1][0] for n in firsts] == ["Build 1", "Build 1"],
|
||
"%s" % firsts)
|
||
five = notes.Plan()
|
||
five.slides = [p8.slides[0], notes.Slide("build", [notes.Line([("Heading:", False, False)], lead=True)] +
|
||
[notes.Line([("Item %d" % k, False, False)]) for k in range(1, 6)])]
|
||
out5 = [(lab, texts[0][1]) for lab, texts in slides_of(deck.build(stpl, five, "TEST", stpl.speaker()))
|
||
if lab.startswith("Build")]
|
||
check("a 5-item build: 5 slides, the last template slide reused",
|
||
[l for l, _ in out5] == ["Build 1", "Build 2", "Build 3", "Build 4", "Build 4"]
|
||
and out5[-1][1] == "Heading: / Item 1 / Item 2 / Item 3 / Item 4 / Item 5", "%s" % out5)
|
||
runs = [{"loc": 0, "len": 10}, {"loc": 10, "len": 30}, {"loc": 40, "len": 5}]
|
||
data = deck.make_rtf(stpl.style("build", "build"), [(0, [("What is your FLAW?", False, False, False)]),
|
||
(0, [("1. FEARFUL", False, False, False)])])
|
||
check("font runs cut to a step's text", deck.clip_runs(runs, data) == [{"loc": 0, "len": 10}, {"loc": 10, "len": 19}])
|
||
rt = plantext.load(plantext.dump(p8, "x"))
|
||
check("build heading survives the TextEdit round trip",
|
||
[l.lead for s in p8.slides if s.kind == "build" for l in s.lines] ==
|
||
[l.lead for s in rt.slides if s.kind == "build" for l in s.lines])
|
||
|
||
# 9. other campuses: their template names, and who preaches there according to the email
|
||
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
|
||
|
||
print()
|
||
print("FAILED: %s" % failures if failures else "all checks passed")
|
||
return 1 if failures else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|