Files
propresenter-auto-notes/tests/run_tests.py
T
christianandClaude Opus 5.5 042fe30396 Builds as one slide per step when the template has Build 1, 2, ... (v1.3.2)
- 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>
2026-10-22 11:40:04 -04:00

219 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())