READY page: the weekly worship checklist, checked against Planning Center
- readiness.py looks every 2 minutes (never while a HyperDeck records) at this weekend's PCO plan and what is in place for it: the Ableton set (songs, mix, routing, lighting cues), ProPresenter (every PCO item, MC announcements, baptism names, timelines, backgrounds), the LTC note of every song against the timecode the tracks play, the Band Lineup TV, Resolume's LED cross, and the tracks and ProP bundles for the locations. - READY key on the Status page (row 2, col 7; SPOTIFY now shows inside SHOW) opens page 24. Amber until done, blinking red from Saturday noon; not part of ALL. Tap keys for BOH mix, mics and stage reset every Monday. - Holds fix what can be fixed: re-sync the lineup, put the cross back up, write LTC into PCO, copy tracks to locations, look again. Hold READY fixes everything. - Settings page: a READY page tab (live keys, drag to move, why each key is amber), and Apply re-imports page 24. - Auto-fix: "while_muted" fixes keep running with ALARMS muted (Spotify playlist). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
"""The READY page, offline: readiness.py's verdicts on this weekend's checklist (from trimmed copies of
|
||||
what PCO, ProPresenter, the Ableton set, ProdDash and Resolume really said on 2026-10-14), and how the
|
||||
watchdog and the page builder show them. Run: python3 -m unittest discover -s tests"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import make_page # noqa: E402
|
||||
import readiness as rd # noqa: E402
|
||||
import status_panel as sp # noqa: E402
|
||||
|
||||
sp.log.addHandler(logging.NullHandler())
|
||||
sp.log.propagate = False
|
||||
|
||||
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||
RC = CONFIG["ready"]
|
||||
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
|
||||
SATURDAY = datetime(2026, 10, 17, 16, 0).timestamp() # inside "due": gaps are red
|
||||
SUNDAY_PLAN = datetime(2026, 10, 18, 9, 30).timestamp()
|
||||
|
||||
|
||||
def item(title, type_="item", position="during", description="", notes=None):
|
||||
return {"id": "i-" + title, "note_ids": {k: "n-" + title for k in (notes or {})}, "title": title, "type": type_,
|
||||
"position": position, "description": description, "notes": notes or {}}
|
||||
|
||||
|
||||
def plan():
|
||||
return {"id": "92013595", "title": "Part 8", "dates": "October 18, 2026", "time": SUNDAY_PLAN,
|
||||
"items": [
|
||||
item("Doors Open", "header"),
|
||||
item("Preservice", position="pre"),
|
||||
item("MC Announcements", "media", "pre", "- Celebrate recovery\n- WK Team night\n- Peru missions trip\n- Family skate night"),
|
||||
item("Worship", "header"),
|
||||
item("Rejoice Again", "song", notes={"LTC": "00:01:17:26"}),
|
||||
item("Can't Help But Praise (Live)", "song", notes={"LTC": "00:06:43:21"}),
|
||||
item("Holy Forever", "song", notes={"LTC": "00:13:33:07"}),
|
||||
item("Baptisms", "media", description="730 930 None\n1130 Samantha Lopez"),
|
||||
item("Message", "header"),
|
||||
item("Message", description="Pastor Tim Live"),
|
||||
item("Covered By The Blood TAG", "song", notes={"LTC": "00:24:13:07"}),
|
||||
item("Postservice", "song", "post"),
|
||||
],
|
||||
"team": [
|
||||
{"name": "Jonah Perdue", "position": "[Leader]", "team": "Band", "status": "C"},
|
||||
{"name": "Krista Perdue", "position": "Vocalist", "team": "Band", "status": "C"},
|
||||
{"name": "Joshua Tineo", "position": "Vocalist", "team": "Band", "status": "D"},
|
||||
{"name": "Kevin Michel", "position": "Piano/Pad", "team": "Band", "status": "C"},
|
||||
{"name": "Luis Decamps", "position": "Drums", "team": "Band", "status": "D"},
|
||||
{"name": "Anthony Hoyt", "position": "MC", "team": "Communication", "status": "C"},
|
||||
{"name": "Tim Hatch", "position": "Speaker", "team": "Communication", "status": "C"},
|
||||
{"name": "Chris Meireles", "position": "Audio - FOH", "team": "Sunday Production", "status": "C"},
|
||||
]}
|
||||
|
||||
|
||||
def slide(label="", text="", notes="", enabled=True):
|
||||
return {"label": label, "text": text, "notes": notes, "enabled": enabled}
|
||||
|
||||
|
||||
def pres(name, slides, timeline=False, cues=None):
|
||||
return {"name": name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
|
||||
"cues": len(slides) if cues is None else cues, "slides": slides}
|
||||
|
||||
|
||||
OLD_NOTES = "- Operation Christmas child\n- 5k\n- Movie night\n- Flourish"
|
||||
|
||||
|
||||
def prop():
|
||||
song = lambda name, bg="Heavenly_Lights_1_4k.mp4": pres(name, [slide(bg, "Verse"), slide("", "Chorus")], timeline=True)
|
||||
return {"playlist": "Where is Your Faith? - Part 8 - October 18, 2026", "items": [
|
||||
{"name": "Doors Open", "type": "header", "pco": True, "pres": None},
|
||||
{"name": "Preservice", "type": "presentation", "pco": True, "pres": pres("Preservice", [slide("CLICK THIS TO START")])},
|
||||
{"name": "MC Announcements", "type": "presentation", "pco": True,
|
||||
"pres": pres("MC Announcements", [slide("ANNOUNCEMENTS", notes=OLD_NOTES)] + [slide(notes=OLD_NOTES)] * 4)},
|
||||
{"name": "Worship", "type": "header", "pco": True, "pres": None},
|
||||
{"name": "Rejoice Again - [ Full ]", "type": "presentation", "pco": True, "pres": song("Rejoice Again")},
|
||||
{"name": "Can't Help But Praise LM - [ Full ]", "type": "presentation", "pco": True, "pres": song("Can't Help But Praise LM")},
|
||||
{"name": "Holy Forever - [ Full ]", "type": "presentation", "pco": True, "pres": song("Holy Forever", bg="")},
|
||||
{"name": "Baptisms", "type": "presentation", "pco": True,
|
||||
"pres": pres("Baptisms", [slide("Zoey 1130"), slide("LIVE BAPTISM"), slide("Bobby 1130")])},
|
||||
{"name": "Message", "type": "header", "pco": True, "pres": None},
|
||||
{"name": "Message Bumper", "type": "presentation", "pco": True, "pres": pres("Message Bumper", [slide("Bumper.mp4")])},
|
||||
{"name": "Covered By The Blood TAG - [ TAG ]", "type": "presentation", "pco": True,
|
||||
"pres": pres("Covered By The Blood TAG", [slide("Grainyents_Dark_2_4k.mp4")], timeline=False, cues=0)},
|
||||
{"name": "Postservice", "type": "presentation", "pco": True, "pres": pres("Postservice", [slide("24_Prayer-1.png")])},
|
||||
]}
|
||||
|
||||
|
||||
def track(id_, name, kind="AudioTrack", group="-1", vol=1.0, ext=True, clips=((0, 100, None),)):
|
||||
return {"id": id_, "kind": kind, "name": name, "group": group, "vol": vol, "ext": ext,
|
||||
"out": "Ext. Out 5/6 Keys" if ext else "Master", "clips": [list(c) for c in clips]}
|
||||
|
||||
|
||||
def ableton(lighting=((0, 50, 12), (100, 150, 8))):
|
||||
tracks = [
|
||||
track("1", "LIGHTING CUES", "MidiTrack", clips=lighting),
|
||||
track("10", "REJOICE AGAIN", "GroupTrack", clips=()),
|
||||
track("11", "21-Piano", group="10", vol=0.25, clips=((0, 90, None),)),
|
||||
track("12", "22-Keys", group="10", clips=((0, 90, None),)),
|
||||
track("20", "CAN'T HELP BUT PRAISE", "GroupTrack", clips=()),
|
||||
track("21", "44-Piano", group="20", clips=((100, 190, None),)),
|
||||
track("22", "45-Piano 2", group="20", clips=((100, 190, None),)),
|
||||
track("23", "Sub group", "GroupTrack", group="20", clips=()),
|
||||
track("24", "46-EG", group="23", clips=((100, 190, None),), ext=False),
|
||||
track("30", "HOLY FOREVER", "GroupTrack", clips=()),
|
||||
track("31", "72-Piano", group="30", vol=0.5, clips=((200, 290, None),)),
|
||||
track("40", "COVERED BY THE BLOOD", "GroupTrack", clips=()),
|
||||
track("41", "80-Bass", group="40", vol=0.7, clips=((300, 390, None),)),
|
||||
]
|
||||
path = "/Projects/NA/261018 NA Project/261018 NA Project.als"
|
||||
# The real tempo map and locators of 261018 NA Project (Rejoice 151 BPM, Can't Help 143, Holy Forever 72, Covered 76).
|
||||
tempo = {"manual": 68.3, "events": [[-63072000, 143], [0, 143], [0, 151], [1016, 151], [1016, 143], [1992, 143],
|
||||
[1992, 72], [2760, 72], [2760, 76], [5599.5, 76], [5599.5, 71]]}
|
||||
locators = [{"name": "1- CALL TO WORSHIP ", "time": 0}, {"name": "LOOP", "time": 76},
|
||||
{"name": "2 - REJOICE AGAIN - Bb", "time": 196}, {"name": "3- CAN'T HELP BUT PRAISE - Eb", "time": 1016},
|
||||
{"name": "4- HOLY FOREVER - B", "time": 1992}, {"name": "5- COVERED BY THE BLOOD- B", "time": 2760}]
|
||||
ltc = {"start": 0.0, "name": "LTC_00000000_60mins_30fps_48000x24", "warped": False, "offset": 0.0}
|
||||
return {"set": path, "live_set": path, "mtime": WEDNESDAY,
|
||||
"summary": {"tracks": tracks, "locators": locators, "tempo": tempo, "ltc": ltc},
|
||||
"bundles": {"AB": {"folder": "/Projects/AB/261018 AB Project", "als": True, "copying": False},
|
||||
"SC": {"folder": "/Projects/SC/261018 SC Project", "als": False, "copying": False}}}
|
||||
|
||||
|
||||
def lineup(names, plan_id="92013595"):
|
||||
return {"state": {"plan": {"id": plan_id, "dates": "October 18, 2026"}, "lastError": "",
|
||||
"slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}}
|
||||
|
||||
|
||||
def composition(deck="Service", playing=True):
|
||||
return {"name": {"value": "Crosses"},
|
||||
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
|
||||
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
|
||||
"layers": [{"clips": [{"name": {"value": "SS_StainedGlass005"}, "connected": {"value": "Disconnected"}},
|
||||
{"name": {"value": "Stained Glass 11"}, "connected": {"value": "Connected" if playing else "Disconnected"}}]}]}
|
||||
|
||||
|
||||
def msgs(line):
|
||||
return [m for _, m, _ in line.faults]
|
||||
|
||||
|
||||
class TestNames(unittest.TestCase):
|
||||
def test_titles_match_across_pco_propresenter_and_ableton(self):
|
||||
self.assertTrue(rd.same("Can't Help But Praise (Live)", "Can't Help But Praise LM - [ Full ]"))
|
||||
self.assertTrue(rd.same("Can't Help But Praise (Live)", "3- CAN’T HELP BUT PRAISE - Eb"))
|
||||
self.assertTrue(rd.same("Covered By The Blood TAG", "COVERED BY THE BLOOD"))
|
||||
self.assertTrue(rd.same("Message", "Message Bumper"))
|
||||
self.assertFalse(rd.same("Holy Forever", "Holy Spirit"))
|
||||
self.assertFalse(rd.same("", "Anything"))
|
||||
|
||||
def test_lists_in_descriptions(self):
|
||||
self.assertEqual(rd.bullets("- Celebrate recovery\n- WK Team night\nnot a bullet"), ["Celebrate recovery", "WK Team night"])
|
||||
self.assertEqual(rd.listed_names("730 930 None\n1130 Samantha Lopez, Bob Ray & Ann Lee"),
|
||||
["Samantha Lopez", "Bob Ray", "Ann Lee"])
|
||||
|
||||
def test_the_plan_is_todays_or_the_next(self):
|
||||
p = lambda pid, iso: {"id": pid, "attributes": {"sort_date": iso}}
|
||||
plans = [p("old", "2026-10-11T13:30:00Z"), p("next", "2026-10-18T13:30:00Z"), p("later", "2026-10-25T13:30:00Z")]
|
||||
self.assertEqual(rd.pick_plan(plans, WEDNESDAY)[0]["id"], "next")
|
||||
self.assertEqual(rd.pick_plan(plans, datetime(2026, 10, 11, 12, 0).timestamp())[0]["id"], "old") # Sunday itself
|
||||
self.assertEqual(rd.pick_plan([], WEDNESDAY), (None, None))
|
||||
|
||||
def test_week_starts_monday_midnight(self):
|
||||
self.assertEqual(rd.week_start(WEDNESDAY), datetime(2026, 10, 12).timestamp())
|
||||
self.assertEqual(rd.week_start(datetime(2026, 10, 12, 0, 0).timestamp()), datetime(2026, 10, 12).timestamp())
|
||||
|
||||
|
||||
class TestPropresenter(unittest.TestCase):
|
||||
def test_pco_against_the_playlist(self):
|
||||
out = rd.eval_prop(RC, plan(), prop())
|
||||
self.assertEqual(msgs(out["prop"]), ["MC SLIDES OLD?", "BAPTISM: NO SAMANTHA"])
|
||||
self.assertEqual(msgs(out["timecode"]), ["NO TC COVERED BY THE."])
|
||||
self.assertEqual(msgs(out["backgrounds"]), ["NO BG HOLY FOREVER"])
|
||||
|
||||
def test_too_few_announcement_slides_and_a_missing_item(self):
|
||||
p = plan()
|
||||
p["items"][2]["description"] += "\n- Fifth thing\n- Sixth thing"
|
||||
p["items"].insert(4, item("Welcome Video", "media"))
|
||||
pp = prop()
|
||||
pp["items"][2]["pres"]["slides"] = [slide(notes=p["items"][2]["description"])] * 3
|
||||
out = rd.eval_prop(RC, p, pp)
|
||||
self.assertIn("MC 3/6 SLIDES", msgs(out["prop"]))
|
||||
self.assertIn("MISSING WELCOME VIDEO", msgs(out["prop"]))
|
||||
self.assertNotIn("MC SLIDES OLD?", msgs(out["prop"])) # the notes are this week's
|
||||
|
||||
def test_no_playlist_for_the_weekend(self):
|
||||
out = rd.eval_prop(RC, plan(), {"playlist": None, "items": []})
|
||||
self.assertTrue(all(msgs(line)[0].startswith("NO PLAYLIST") for line in out.values()))
|
||||
|
||||
|
||||
class TestAbleton(unittest.TestCase):
|
||||
def test_mixed_routed_and_lit(self):
|
||||
out = rd.eval_ableton(RC, plan(), ableton())
|
||||
self.assertEqual(msgs(out["tracks"]), [])
|
||||
# every Can't Help track is at 0 dB, and one of them goes to Master; Holy Forever has no lighting cue
|
||||
self.assertEqual(msgs(out["mix_foh"]), ["NOT MIXED CANT HELP BUT P.", "TO MASTER CANT HELP BUT P."])
|
||||
self.assertEqual(out["mix_foh"].faults[0][0], rd.WARN)
|
||||
self.assertEqual(msgs(out["lights"]), ["NO LIGHTS HOLY FOREVER", "NO LIGHTS COVERED BY THE."])
|
||||
|
||||
def test_a_song_with_no_tracks_and_an_old_set_open(self):
|
||||
al = ableton()
|
||||
al["summary"]["tracks"] = [t for t in al["summary"]["tracks"] if t["id"] not in ("30", "31")]
|
||||
al["live_set"] = "/Projects/NA/261011 NA Project/261011 NA Project.als"
|
||||
out = rd.eval_ableton(RC, plan(), al)
|
||||
self.assertEqual(msgs(out["tracks"]), ["NO TRACKS HOLY FOREVER", "LIVE HAS OLD SET"])
|
||||
|
||||
def test_no_set_for_the_weekend(self):
|
||||
out = rd.eval_ableton(RC, plan(), {"set": None, "bundles": {}})
|
||||
self.assertEqual(msgs(out["tracks"]), ["NO SET 261018"])
|
||||
|
||||
def test_tracks_sent_to_the_locations(self):
|
||||
self.assertEqual(msgs(rd.eval_track_bundle(RC, ableton())), ["NOT SENT SC"])
|
||||
|
||||
def test_the_remote_scripts_are_valid_python(self):
|
||||
params = json.dumps(rd.ableton_params(RC, plan()))
|
||||
for script in (rd.ALS_SCRIPT % params, rd.COPY_SCRIPT % params, rd.BUNDLE_SCRIPT % json.dumps({"folder": "x", "pattern": "*"})):
|
||||
compile(script, "remote", "exec")
|
||||
self.assertEqual(json.loads(params)["date"], "261018")
|
||||
|
||||
|
||||
class TestTimecode(unittest.TestCase):
|
||||
def test_seconds_through_the_tempo_map(self):
|
||||
tempo = ableton()["summary"]["tempo"]
|
||||
self.assertAlmostEqual(rd.seconds_at(tempo, 1016), 403.7086, places=3) # Live's own warp marker
|
||||
self.assertAlmostEqual(rd.seconds_at(tempo, 2760), 1453.2191, places=3)
|
||||
self.assertAlmostEqual(rd.seconds_at({"manual": 120, "events": []}, 8), 4.0)
|
||||
self.assertAlmostEqual(rd.seconds_at({"manual": 0, "events": [[0, 60], [4, 120]]}, 4), 60 * 4 / 60 * __import__("math").log(2))
|
||||
self.assertEqual(rd.ltc_clip_info("LTC_01000000_60mins_25fps"), (3600.0, 25.0))
|
||||
self.assertEqual(rd.timecode(813.2191, 30), "00:13:33:07")
|
||||
|
||||
def test_tracks_timecode_is_what_pco_says(self):
|
||||
out = [(s["title"], s["tracks"]) for s in rd.song_ltc(RC, plan(), ableton())]
|
||||
self.assertEqual(out, [("Rejoice Again", "00:01:17:26"), ("Can't Help But Praise (Live)", "00:06:43:21"),
|
||||
("Holy Forever", "00:13:33:07"), ("Covered By The Blood TAG", "00:24:13:07")])
|
||||
self.assertEqual(msgs(rd.eval_ltc(RC, plan(), ableton(), rd.Line())), [])
|
||||
|
||||
def test_wrong_or_missing_ltc_in_pco(self):
|
||||
p = plan()
|
||||
p["items"][4]["notes"] = {} # Rejoice: no LTC note
|
||||
p["items"][6]["notes"]["LTC"] = "00:13:30:00" # Holy Forever: moved in the tracks
|
||||
self.assertEqual(msgs(rd.eval_ltc(RC, p, ableton(), rd.Line())), ["NO LTC REJOICE AGAIN", "LTC WRONG HOLY FOREVER"])
|
||||
|
||||
def test_hold_writes_the_tracks_timecode_into_pco(self):
|
||||
p = plan()
|
||||
p["items"][4]["notes"], p["items"][4]["note_ids"] = {}, {}
|
||||
p["items"][6]["notes"]["LTC"] = "00:13:30:00"
|
||||
songs = rd.song_ltc(RC, p, ableton())
|
||||
calls, real_get, real_login, real_http = [], rd.pco_get, rd.pco_login, rd.http_json
|
||||
rd.pco_login = lambda rc: ("Basic x", "275974")
|
||||
rd.pco_get = lambda auth, path, pages=10: ([{"id": "77", "attributes": {"name": "LTC"}}], [])
|
||||
rd.http_json = lambda url, headers=None, method="GET", data=None, timeout=10: calls.append(
|
||||
(method, url.split("/plans/")[1], json.loads(data)["data"]["attributes"])) or {}
|
||||
try:
|
||||
self.assertTrue(rd.write_ltc(RC, songs, "275974", "92013595"))
|
||||
finally:
|
||||
rd.pco_get, rd.pco_login, rd.http_json = real_get, real_login, real_http
|
||||
self.assertEqual(calls, [
|
||||
("POST", "92013595/items/i-Rejoice Again/item_notes", {"content": "00:01:17:26", "item_note_category_id": "77"}),
|
||||
("PATCH", "92013595/items/i-Holy Forever/item_notes/n-Holy Forever", {"content": "00:13:33:07"})])
|
||||
self.assertTrue(rd.write_ltc(RC, rd.song_ltc(RC, plan(), ableton()), "275974", "92013595")) # all right: no writes
|
||||
|
||||
|
||||
class TestLineupCrossBundle(unittest.TestCase):
|
||||
def test_lineup_against_pco(self):
|
||||
line = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "DJ McLean", "Luis Decamps",
|
||||
"Anthony Hoyt", "Tim Hatch", None]))
|
||||
self.assertEqual(msgs(line), ["DJ MCLEAN NOT ON PLAN", "LUIS DECAMPS DECLINED", "KEVIN MICHEL MISSING"])
|
||||
good = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]))
|
||||
self.assertEqual(msgs(good), [])
|
||||
old = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"], "91"))
|
||||
self.assertEqual(msgs(old), ["OLD PLAN"])
|
||||
|
||||
def test_short_names_on_the_board(self):
|
||||
p = plan()
|
||||
p["team"] += [{"name": "Alex Gaitan", "position": "Drums", "team": "Band", "status": "C"},
|
||||
{"name": "Alexander Zinni", "position": "Guitar [Bass]", "team": "Band", "status": "C"}]
|
||||
board = ["Jonah Perdue", "KRISTA", "Kevin M", "AlexG", "Alex Zinni", "Anthony Hoyt", "Tim Hatch", "JONAH", "Luis"]
|
||||
self.assertEqual(msgs(rd.eval_lineup(RC, p, lineup(board))), ["LUIS DECLINED"])
|
||||
self.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure
|
||||
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
|
||||
|
||||
def test_led_cross(self):
|
||||
memory = {}
|
||||
self.assertEqual(msgs(rd.eval_resolume(RC, composition(), memory)), [])
|
||||
self.assertEqual(memory["led_clip"], "Stained Glass 11")
|
||||
self.assertEqual(msgs(rd.eval_resolume(RC, composition("Test", False), {})), ["WRONG DECK", "NOTHING ON"])
|
||||
|
||||
def test_prop_bundle_this_week(self):
|
||||
since = rd.week_start(WEDNESDAY)
|
||||
self.assertEqual(msgs(rd.eval_prop_bundle(RC, [["Message Templates.probundle", WEDNESDAY - 3600]], since)), [])
|
||||
self.assertEqual(msgs(rd.eval_prop_bundle(RC, [["Old.probundle", since - 86400]], since)), ["NOT SENT (LAST 10/11)"])
|
||||
|
||||
def test_mics_from_pco(self):
|
||||
self.assertEqual(rd.mics_needed(RC, plan()), "2 VOX MC TIM")
|
||||
|
||||
|
||||
class TestLook(unittest.TestCase):
|
||||
def sources(self, **over):
|
||||
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
|
||||
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
|
||||
resolume=composition, prop_bundle=lambda: [["Message Templates.probundle", WEDNESDAY]])
|
||||
src.update(over)
|
||||
return src
|
||||
|
||||
def test_everything_at_once(self):
|
||||
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources())
|
||||
self.assertEqual(r["plan"]["dates"], "October 18, 2026")
|
||||
self.assertEqual(set(r["items"]), set(sp.READY_ITEMS))
|
||||
self.assertEqual(r["items"]["lineup"]["faults"], [])
|
||||
self.assertEqual(r["items"]["mics"]["info"], "2 VOX MC TIM")
|
||||
self.assertEqual(r["items"]["track_bundle"]["faults"], [["err", "NOT SENT SC"]])
|
||||
|
||||
def test_one_source_down_does_not_hide_the_others(self):
|
||||
def down(*a):
|
||||
raise rd.ReadyError("PROP UNREACHABLE")
|
||||
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(prop=down))
|
||||
self.assertEqual(r["items"]["prop"]["faults"], [["warn", "PROP UNREACHABLE"]])
|
||||
self.assertEqual(r["items"]["lineup"]["faults"], [])
|
||||
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(pco=lambda: (_ for _ in ()).throw(rd.ReadyError("NO PCO LOGIN"))))
|
||||
self.assertEqual(r["items"]["tracks"]["faults"], [["warn", "NO PCO LOGIN"]])
|
||||
self.assertEqual(r["items"]["led_cross"]["faults"], []) # Resolume needs no plan
|
||||
|
||||
def test_a_campus_that_only_receives(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
for k in ("locations", "prop_bundle_host", "resolume_url", "lineup_url"):
|
||||
cfg["ready"].pop(k)
|
||||
cfg["ready"]["prop_url"] = ""
|
||||
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources())
|
||||
self.assertEqual(sorted(r["items"]), ["lights", "mics", "mix_foh", "timecode", "tracks"]) # LTC: tracks vs PCO
|
||||
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
|
||||
snap.ready = r
|
||||
tiles = engine.step(snap, WEDNESDAY)
|
||||
self.assertEqual(tiles["rd_trk_out"]["x"], "TRACKS OUT\nNOT SET UP")
|
||||
self.assertEqual(tiles["rd_prop"]["x"], "PROP\nNOT SET UP")
|
||||
|
||||
|
||||
class TestWatchdog(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = sp.Engine(CONFIG, None)
|
||||
self.snap = sp.Snapshot()
|
||||
self.snap.conns_time = 0
|
||||
|
||||
def step(self, now):
|
||||
self.engine.step(self.snap, now)
|
||||
return self.engine.step(self.snap, now + 5)
|
||||
|
||||
def test_amber_until_due_then_red(self):
|
||||
self.snap.ready = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources())
|
||||
tiles = self.step(WEDNESDAY)
|
||||
self.assertEqual(tiles["rd_trk_out"]["x"], "TRACKS OUT\nNOT SENT SC")
|
||||
self.assertEqual(tiles["rd_trk_out"]["b"], sp.COLORS["warn"][0][0])
|
||||
self.assertEqual(tiles["rd_boh"]["x"], "BOH MIX\nTAP WHEN DONE")
|
||||
self.assertEqual(tiles["rd_mics"]["x"], "MICS\n2 VOX MC TIM\nTAP WHEN SET")
|
||||
self.assertEqual(tiles["rd_cross"]["b"], sp.COLORS["ok"][0][0])
|
||||
self.assertTrue(tiles["ready"]["x"].startswith("READY\n"))
|
||||
self.assertIn("TO DO", tiles["ready"]["x"])
|
||||
# the checklist never reaches ALL: a weekday gap is not an alarm
|
||||
self.assertNotIn("rd_trk_out", self.engine.detail_ids)
|
||||
self.assertFalse(any(k.startswith("rd_") for k in self.engine.keys_for("all")))
|
||||
tiles = self.step(SATURDAY)
|
||||
self.assertEqual(tiles["rd_trk_out"]["b"], sp.COLORS["err"][0][0]) # due: red, blinking
|
||||
self.assertEqual(tiles["rd_boh"]["b"], sp.COLORS["err"][0][0])
|
||||
foh = {res.msg: lv for lv, res, _ in self.engine.items["rd_foh"]}
|
||||
self.assertEqual(foh, {"NOT MIXED CANT HELP BUT P.": "warn", "TO MASTER CANT HELP BUT P.": "err"}) # NOT MIXED stays a warning
|
||||
|
||||
def test_tap_done_lasts_the_week(self):
|
||||
self.snap.ready = {"time": WEDNESDAY, "items": {}}
|
||||
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY)
|
||||
tiles = self.step(WEDNESDAY + 10)
|
||||
self.assertEqual(tiles["rd_boh"]["x"], "BOH MIX\nDONE WED 15:00")
|
||||
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY + 20) # tapped again: taken back
|
||||
self.assertNotIn("mix_boh", self.engine.ready_done(WEDNESDAY + 30))
|
||||
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY + 40)
|
||||
next_monday = datetime(2026, 10, 19, 9, 0).timestamp()
|
||||
self.assertIn("mix_boh", self.engine.ready_done(SATURDAY))
|
||||
self.assertNotIn("mix_boh", self.engine.ready_done(next_monday)) # a new week starts clean
|
||||
self.assertIsNone(self.engine.toggle_done("rd_tracks", WEDNESDAY)) # not a tap key
|
||||
|
||||
def test_one_hold_fixes_everything_that_can_be_fixed(self):
|
||||
look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources(
|
||||
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test", False)))
|
||||
self.snap.ready = look
|
||||
self.step(WEDNESDAY)
|
||||
plan = self.engine.fix_plan("ready", self.snap)
|
||||
fixes = sorted(what for _, kind, what, _ in plan if kind == "fix")
|
||||
self.assertEqual(fixes, ["ready_copy_tracks", "ready_led_cross", "ready_lineup", "ready_ltc", "ready_recheck"])
|
||||
ran = []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
|
||||
self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10)
|
||||
for t in self.engine.fix_threads:
|
||||
t.join(2)
|
||||
self.assertEqual(sorted(ran), fixes)
|
||||
self.assertEqual(self.engine.fix_plan("rd_all", self.snap), plan) # the key on the READY page too
|
||||
|
||||
def test_checking_before_the_first_look(self):
|
||||
tiles = self.step(WEDNESDAY)
|
||||
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nCHECKING")
|
||||
self.assertEqual(tiles["ready"]["x"].split("\n")[0], "READY")
|
||||
|
||||
|
||||
class TestPages(unittest.TestCase):
|
||||
def actions(self, export, row, col):
|
||||
step = export["page"]["controls"][str(row)][str(col)]["steps"]["0"]["action_sets"]
|
||||
return {k: [(a["definitionId"], {o: v["value"] for o, v in a["options"].items()}) for a in v] for k, v in step.items()}
|
||||
|
||||
def test_status_page_ready_key_opens_the_page(self):
|
||||
acts = self.actions(make_page.build(CONFIG), 2, 7)
|
||||
self.assertEqual(acts["up"], [("set_page", {"surfaceId": "self", "page": 24})])
|
||||
self.assertEqual(acts["2000"][0][1]["value"], "$(custom:sp_cmd);restart ready $(internal:time_unix)")
|
||||
|
||||
def test_ready_page(self):
|
||||
export = make_page.build_ready(CONFIG)
|
||||
self.assertEqual(export["page"]["name"], "24\\nReady")
|
||||
self.assertEqual(self.actions(export, 0, 3)["up"][0][1]["value"], "$(custom:sp_cmd);done rd_boh")
|
||||
self.assertNotIn("2000", self.actions(export, 0, 3)) # a tap key has no hold
|
||||
self.assertEqual(self.actions(export, 2, 1)["up"][0][1]["value"], "$(custom:sp_cmd);ack rd_lineup")
|
||||
self.assertIn("2000", self.actions(export, 2, 1))
|
||||
self.assertEqual(self.actions(export, 3, 0)["up"], [("set_page", {"surfaceId": "self", "page": 19})])
|
||||
notes = [c["options"]["notes"] for row in export["page"]["controls"].values() for c in row.values() if c and "options" in c]
|
||||
self.assertTrue(all(any(m in n for m in make_page.MARKERS) for n in notes)) # push_page may overwrite it
|
||||
|
||||
def test_settings_page_applies_page_24(self):
|
||||
from settings import apply as ap
|
||||
self.assertIn("ready", ap.build_hashes(CONFIG))
|
||||
moved = json.loads(json.dumps(CONFIG))
|
||||
moved["ready_page"]["keys"][0]["pos"] = [3, 5]
|
||||
self.assertEqual(ap.what_apply_does(CONFIG, moved, ap.build_hashes(CONFIG))["pages"], ["ready"])
|
||||
|
||||
def test_rules(self):
|
||||
bad = json.loads(json.dumps(CONFIG))
|
||||
bad["ready_page"]["keys"][1]["pos"] = bad["ready_page"]["keys"][0]["pos"]
|
||||
bad["ready_page"]["keys"][0]["checks"][0]["item"] = "nonsense"
|
||||
bad["ready_page"]["page"] = 19
|
||||
bad["fixes"]["ready_lineup"]["action"] = "explode"
|
||||
import schema
|
||||
errors = "\n".join(schema.validate(bad)[0])
|
||||
for expect in ("share row 0 column 1", "unknown checklist line nonsense", "cannot be the Status or Restart page",
|
||||
"unknown READY action explode"):
|
||||
self.assertIn(expect, errors)
|
||||
|
||||
|
||||
class TestFixes(unittest.TestCase):
|
||||
def test_lineup_sync_waits_for_the_new_pull(self):
|
||||
calls, states = [], iter([{"lastFetched": 1}, {"lastFetched": 1}, {"lastFetched": 2, "lastError": ""}])
|
||||
real = rd.http_json
|
||||
|
||||
def fake(url, headers=None, method="GET", data=None, timeout=10):
|
||||
calls.append((method, url.rsplit("/", 1)[-1]))
|
||||
return {"ok": True} if method == "POST" else {"state": next(states)}
|
||||
rd.http_json = fake
|
||||
try:
|
||||
self.assertTrue(rd.run_action(CONFIG, "lineup_sync", sleep=lambda s: None))
|
||||
finally:
|
||||
rd.http_json = real
|
||||
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
|
||||
|
||||
def test_cross_comes_back_with_the_clip_last_seen(self):
|
||||
calls = []
|
||||
real = rd.http_json
|
||||
comp = composition("Test", False)
|
||||
|
||||
def fake(url, headers=None, method="GET", data=None, timeout=10):
|
||||
calls.append((method, url.split("/api/v1/composition")[-1]))
|
||||
return comp
|
||||
rd.http_json = fake
|
||||
rd.MEMORY["led_clip"] = "Stained Glass 11"
|
||||
try:
|
||||
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
|
||||
finally:
|
||||
rd.http_json = real
|
||||
rd.MEMORY.clear()
|
||||
self.assertIn(("POST", "/decks/1/select"), calls)
|
||||
self.assertIn(("POST", "/layers/1/clips/2/connect"), calls)
|
||||
|
||||
|
||||
class TestSpotifyWhileMuted(unittest.TestCase):
|
||||
def test_spotify_still_plays_with_alarms_muted(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg["auto_fix"]["log_dir"] = __import__("tempfile").mkdtemp(prefix="sp-autolog-")
|
||||
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
|
||||
wed = datetime(2026, 10, 14, 20, 20).timestamp() # inside Wednesday's auto-fix window
|
||||
engine.service.set(False, wed) # ALARMS muted
|
||||
snap.spotify = {"running": True, "state": "paused"}
|
||||
snap.remote = {"tracks\0pgrep -xq Live": False} # a gentle fix that must still wait
|
||||
ran = []
|
||||
engine.executor = lambda c, fix_id, chk: ran.append(fix_id) or True
|
||||
for t in range(0, 40, 2):
|
||||
engine.step(snap, wed + t)
|
||||
engine.auto_fix(snap, None, wed + 40)
|
||||
for th in engine.fix_threads:
|
||||
th.join(2)
|
||||
self.assertEqual(ran, ["spotify_play"])
|
||||
cfg["auto_fix"]["while_muted"] = [] # without it: nothing while muted
|
||||
engine2 = sp.Engine(cfg, None)
|
||||
engine2.service.set(False, wed)
|
||||
engine2.executor = engine.executor
|
||||
for t in range(0, 40, 2):
|
||||
engine2.step(snap, wed + t)
|
||||
self.assertEqual(engine2.auto_fix(snap, None, wed + 40), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+16
-10
@@ -16,22 +16,24 @@ import make_page # noqa: E402
|
||||
import schema # noqa: E402
|
||||
import status_panel as sp # noqa: E402
|
||||
|
||||
WALKED = ["status_panel.py", "make_page.py", "push_page.py", "nav_keys.py", "netgear.py"]
|
||||
WALKED = ["status_panel.py", "make_page.py", "push_page.py", "nav_keys.py", "netgear.py", "readiness.py"]
|
||||
|
||||
# What a variable holds, by name, when nothing in the function says otherwise (parameters mostly).
|
||||
NAMES = {"cfg": "top", "config": "top", "chk": "check", "c": "check", "fix": "fix", "f": "fix", "t": "tile",
|
||||
"tile": "tile", "k": "restart_key", "key": "restart_key", "rp": "restart_page", "w": "window",
|
||||
"names": "variables", "host": "host", "h": "host", "windows": "windows", "site": "site"}
|
||||
"names": "variables", "host": "host", "h": "host", "windows": "windows", "site": "site", "rc": "ready"}
|
||||
ATTRS = {"self.cfg": "top", "engine.cfg": "top", "self.net": "network", "self.auto": "auto", "self.site": "site",
|
||||
"self.atem": "atem",
|
||||
"self.atem": "atem", "self.ready_cfg": "ready",
|
||||
"self.tiles": "tiles", "engine.tiles": "tiles", "self.restart_keys": "restart_keys",
|
||||
"engine.restart_keys": "restart_keys"}
|
||||
# A key of a top-level dict that holds a record of another kind.
|
||||
TOP = {"companion": "companion", "network": "network", "variables": "variables", "auto_fix": "auto", "site": "site",
|
||||
"atem": "atem", "spotify": "spotify", "timing": "timing", "flap": "flap", "hosts": "hosts", "fixes": "fixes",
|
||||
"tiles": "tiles", "restart_page": "restart_page", "gear": "gears", "service_windows": "windows"}
|
||||
"tiles": "tiles", "restart_page": "restart_page", "gear": "gears", "service_windows": "windows",
|
||||
"ready": "ready", "ready_page": "ready_page"}
|
||||
SUB = {("auto", "windows"): "windows", ("site", "live_services"): "windows", ("tile", "checks"): "checks",
|
||||
("restart_page", "keys"): "restart_keys", ("tile", "members"): "names"}
|
||||
("restart_page", "keys"): "restart_keys", ("tile", "members"): "names", ("ready", "due"): "windows",
|
||||
("ready_page", "keys"): "tiles"}
|
||||
ELEMENT = {"tiles": "tile", "checks": "check", "restart_keys": "restart_key", "windows": "window", "fixes": "fix",
|
||||
"hosts": "host", "gears": "gear"}
|
||||
# Keys the code reads that are the watchdog's own working data, not settings.
|
||||
@@ -67,6 +69,8 @@ def collect(path):
|
||||
return step(base, key)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("items", "values"):
|
||||
return kind_of(node.func.value, local)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("all_tiles", "ready_keys"):
|
||||
return "tiles" # the Status page's tiles and the READY page's keys
|
||||
if isinstance(node, ast.Call) and dotted(node.func) == "json.load":
|
||||
return "jsonfile" # a config file when it lands in cfg / config
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("enumerate", "dict", "sorted", "list") and node.args:
|
||||
@@ -141,7 +145,7 @@ def path_of(kind, key):
|
||||
"restart_key": "restart_page.keys[].", "restart_page": "restart_page.", "auto": "auto_fix.",
|
||||
"network": "network.", "variables": "variables.", "site": "site.", "atem": "atem.", "spotify": "spotify.",
|
||||
"companion": "companion.", "timing": "timing.", "flap": "flap.", "gear": "gear.*.",
|
||||
"window": "auto_fix.windows[]."}.get(kind)
|
||||
"window": "auto_fix.windows[].", "ready": "ready.", "ready_page": "ready_page."}.get(kind)
|
||||
return None if prefix is None else prefix + key
|
||||
|
||||
|
||||
@@ -173,17 +177,18 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.contacts", "site.editing_code",
|
||||
"site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role",
|
||||
"hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done",
|
||||
"gear", "gear.*.location", "gear.*.notes", "atem.source_folder", "fixes.*.template", "fixes.*.params",
|
||||
"gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "atem.source_folder", "fixes.*.template", "fixes.*.params",
|
||||
"tiles[].checks[].template", "tiles[].checks[].params", "spotify.name", "site", "timing",
|
||||
"fixes", "hosts", "tiles", "flap", "auto_fix", "network", "variables", "companion", "atem", "spotify",
|
||||
"restart_page", "service_windows", "restart_page.keys", "tiles[].checks", "auto_fix.windows",
|
||||
"site.live_services", "tiles[].checks[].type", "fixes.*.playlist", "tiles[].checks[].playlist"}
|
||||
"site.live_services", "tiles[].checks[].type", "fixes.*.playlist", "tiles[].checks[].playlist",
|
||||
"ready_page", "ready_page.keys", "ready"}
|
||||
read = {path_of(kind, key) for _, kind, key, _, _ in all_reads()}
|
||||
read |= {"service_windows[].days", "service_windows[].start", "service_windows[].end",
|
||||
"site.live_services[].days", "site.live_services[].start", "site.live_services[].end"}
|
||||
for w in ("days", "start", "end"):
|
||||
if "auto_fix.windows[]." + w in read:
|
||||
read |= {"service_windows[].%s" % w, "site.live_services[].%s" % w}
|
||||
read |= {"service_windows[].%s" % w, "site.live_services[].%s" % w, "ready.due[].%s" % w}
|
||||
dead = sorted(p for p in schema.F if p not in read and p not in ui_only)
|
||||
self.assertEqual(dead, [], "declared settings nothing reads")
|
||||
|
||||
@@ -230,7 +235,8 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
if ctl and "options" in ctl:
|
||||
ctl["options"].pop("notes", None)
|
||||
return export
|
||||
for build, name in ((make_page.build, "status-page.companionconfig"), (make_page.build_restart, "restart-page.companionconfig")):
|
||||
for build, name in ((make_page.build, "status-page.companionconfig"), (make_page.build_restart, "restart-page.companionconfig"),
|
||||
(make_page.build_ready, "ready-page.companionconfig")):
|
||||
on_disk = json.loads(gzip.open(os.path.join(ROOT, name)).read())
|
||||
self.assertEqual(strip(build(cfg)), strip(on_disk), name)
|
||||
self.assertNotIn("restart_page", {k for k in cfg if k == "nothing"})
|
||||
|
||||
Reference in New Issue
Block a user