Files
status-indicators/tests/test_apply.py
T
christianandClaude Fable 5.1 a33e344cae Steps 4-6: the jobs, the set-up tab with generated scripts, Install the staged folder, site bundles and the campus template
- Overview jobs: Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer,
  Make a PC program startable; 'Add a key for it' on unwatched Companion connections.
- Computers > Setup: steps with a [how] each and the set-up scripts generated per computer by
  settings/scripts.py from the templates in remote-access/ (no site-shaped value left in them);
  Remove this computer drops the fixes and checks that used it.
- Site setup > Site: the running and staged code, Install the staged folder (a one-shot launchd job
  running install.sh --keep-config, refused while live), Export site bundle (zip), New campus from a
  bundle (this campus's site block stays; every copied computer is flagged until touched).
- sites/: na -> the repo config, _template = the empty campus; install.sh --site <id>.
- Review reads appended checks as 'added'; tests for all of it.
- Deployed page-only to NA Ingest. (config.json, status_panel.py and readiness.py are left to the
  READY-page work in progress in the same tree.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 14:53:08 -04:00

235 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Saving a change, offline: the shared draft, what Apply will do, the pipeline against a temp data
dir with a fake launchctl and a fake Companion, rollback when the watchdog does not come back, the
recording guard with its Emergency override, history and Go back."""
import json
import os
import sys
import tempfile
import threading
import time
import unittest
from unittest import mock
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import status_panel as sp # noqa: E402
from settings import apply as ap # noqa: E402
from settings import server # noqa: E402
from test_status_panel import CONFIG # noqa: E402
class FakeLaunchctl:
"""Records calls; a kickstart or bootstrap "starts" the watchdog by writing started.json
(with the sha of the config on disk) unless told to fail."""
def __init__(self, data_dir, fail=False):
self.data_dir, self.fail, self.calls, self.pid = data_dir, fail, [], 100
def __call__(self, argv, **kw):
self.calls.append(argv)
class R:
returncode, stdout, stderr = 0, "", ""
r = R()
if argv[1] == "print":
r.stdout = "state = running\n\tpid = %d\n" % self.pid
elif argv[1] in ("kickstart", "bootstrap"):
if not self.fail:
self.pid += 1
cfg_sha = ap.sha(open(os.path.join(self.data_dir, "config.json"), "rb").read())
sp.write_json(os.path.join(self.data_dir, "started.json"), {"pid": self.pid, "started_at": time.time(), "config_sha": cfg_sha, "version": "test"})
return r
class TestDraftAndPlan(unittest.TestCase):
def setUp(self):
self.data = tempfile.mkdtemp(prefix="sp-apply-")
self.cfg = json.loads(json.dumps(CONFIG))
self.cfg["companion"] = {"host": "127.0.0.1", "port": 1}
with open(os.path.join(self.data, "config.json"), "wb") as f:
f.write(ap.config_text(self.cfg))
def test_draft_is_shared_and_never_overwritten_silently(self):
d = ap.Draft(self.data)
a, _ = d.update({"x": 1}, "sam", "sha1")
self.assertEqual(oct(os.stat(d.path).st_mode)[-3:], "600")
b, conflict = d.update({"x": 2}, "chris", "sha1", expect=a["version"])
self.assertIsNone(conflict)
_, conflict = d.update({"x": 3}, "sam", "sha1", expect=a["version"]) # sam's tab is behind
self.assertEqual(conflict["by"], "chris")
self.assertEqual(d.read()["config"], {"x": 2})
def test_what_apply_does(self):
hashes = ap.build_hashes(self.cfg)
same = json.loads(json.dumps(self.cfg))
self.assertEqual(ap.what_apply_does(self.cfg, same, hashes)["changes"], [])
notes = json.loads(json.dumps(self.cfg))
notes["gear"] = {"UHDJr": {"location": "lobby rack"}}
plan = ap.what_apply_does(self.cfg, notes, hashes)
self.assertFalse(plan["restart"]) # page-only data
self.assertEqual(plan["pages"], [])
timing = json.loads(json.dumps(self.cfg))
timing["debounce_seconds"] = 4
self.assertTrue(ap.what_apply_does(self.cfg, timing, hashes)["restart"])
self.assertEqual(ap.what_apply_does(self.cfg, timing, hashes)["pages"], [])
moved = json.loads(json.dumps(self.cfg))
next(t for t in moved["tiles"] if t["id"] == "led")["pos"] = [2, 8]
plan = ap.what_apply_does(self.cfg, moved, hashes)
self.assertEqual(plan["pages"], ["status"])
self.assertTrue(plan["nav"])
label = json.loads(json.dumps(self.cfg))
label["fixes"]["vista"]["label"] = "start Vista please" # only a key note changes
self.assertEqual(ap.what_apply_does(self.cfg, label, hashes)["pages"], [])
def test_words(self):
after = json.loads(json.dumps(self.cfg))
hd2 = next(t for t in after["tiles"] if t["id"] == "hd2")
hd2["checks"][0]["optional"] = False
after["site"]["editing_code"] = "new words"
after["hosts"]["lighting"]["notes"] = "keep it Public"
texts = [ap.words_for(p, a, b, self.cfg, after)["text"] for p, a, b in ap.changed_paths(self.cfg, after)]
self.assertIn("key HD2 › check 1 › optional: yes → no", texts)
self.assertIn("computer Lighting PC › notes: Keep the network profile on Public: rtpMIDI's Bonjour rules only allow Public. → keep it Public", texts)
self.assertTrue(any(t == "site › editing code changed" for t in texts), texts) # a secret: never its value
more = json.loads(json.dumps(self.cfg))
midi = next(t for t in more["tiles"] if t["id"] == "midi")
midi["checks"].append({"type": "midi_peer", "session": "NA Tracks", "peer": "Studio B", "tag": "STUDIO B"})
texts = [ap.words_for(p, a, b, self.cfg, more)["text"] for p, a, b in ap.changed_paths(self.cfg, more)]
self.assertEqual(len(texts), 1)
self.assertTrue(texts[0].startswith("key MIDI › check %d added: " % (len(midi["checks"]))), texts)
class TestPipeline(unittest.TestCase):
def setUp(self):
self.data = tempfile.mkdtemp(prefix="sp-pipe-")
self.cfg = json.loads(json.dumps(CONFIG))
self.cfg["companion"] = {"host": "127.0.0.1", "port": 1}
with open(os.path.join(self.data, "config.json"), "wb") as f:
f.write(ap.config_text(self.cfg))
open(os.path.join(self.data, "atem-reference.xml"), "w").write("<x/>")
self.site = server.Site(self.data, companion=mock.Mock())
self.launchctl = FakeLaunchctl(self.data)
self.applier = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: None)
def run_apply(self, new_cfg, note="t", emergency=None):
r = self.applier.start(new_cfg, note, "sam", "10.1.1.50", emergency)
if r.get("ok"):
self.applier.thread.join(30)
return r
def test_a_timing_change_restarts_and_is_kept_in_history(self):
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 4
r = self.run_apply(new, "slower")
self.assertTrue(r["ok"])
st = self.applier.status
self.assertEqual(st["state"], "done", st)
names = {s["name"]: s["state"] for s in st["steps"]}
self.assertEqual((names["check"], names["save"], names["restart"], names["pages"]), ("ok", "ok", "ok", "skipped"))
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 4)
self.assertTrue(any(a[1] == "kickstart" for a in self.launchctl.calls))
entries = ap.history_entries(self.data)
self.assertEqual((entries[0]["by"], entries[0]["note"], entries[0]["actions"]), ("sam", "slower", ["restart"]))
self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before
self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600")
def test_page_only_data_needs_no_restart(self):
new = json.loads(json.dumps(self.cfg))
new["gear"] = {"UHDJr": {"location": "lobby"}}
self.run_apply(new)
names = {s["name"]: s["state"] for s in self.applier.status["steps"]}
self.assertEqual(names["restart"], "skipped")
self.assertFalse(any(a[1] == "kickstart" for a in self.launchctl.calls))
def test_a_bad_config_is_refused_before_anything_happens(self):
new = json.loads(json.dumps(self.cfg))
new["fixes"]["vista"]["host"] = "nowhere"
self.run_apply(new)
self.assertEqual(self.applier.status["state"], "failed")
self.assertIn("unknown computer nowhere", self.applier.status["outcome"])
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["fixes"]["vista"]["host"], "lighting")
self.assertEqual(ap.history_entries(self.data), [])
def test_rollback_when_the_watchdog_does_not_come_back(self):
self.launchctl.fail = True
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 5
self.run_apply(new, "will fail")
st = self.applier.status
self.assertEqual(st["state"], "failed")
self.assertIn("previous settings", st["outcome"].lower())
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 3) # put back
self.assertTrue(os.path.exists(os.path.join(self.data, "last-apply-failed.json")))
meta = ap.history_entries(self.data)[0]
self.assertIn("rolled back", meta["outcome"])
def test_layout_change_imports_the_page_and_recolours_the_nav_keys(self):
new = json.loads(json.dumps(self.cfg))
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
pushed, coloured, calls = [], [], {"n": 0}
def fetch(h, p, page): # before the push: only page keys; after: as built
calls["n"] += 1
return {"page": {"name": "19\\nStatus", "controls": {}}} if calls["n"] % 2 == 1 else ap.make_page.build(new)
with mock.patch.object(ap.push_page, "fetch_page", fetch), \
mock.patch.object(ap.push_page, "push", lambda *a: pushed.append(a[2])), \
mock.patch.object(ap.nav_keys, "colour", lambda *a: coloured.append(a) or 96):
self.run_apply(new, "move LED")
st = self.applier.status
self.assertEqual(st["state"], "done", st)
self.assertEqual(pushed, [19])
self.assertEqual(len(coloured), 1)
self.assertEqual(json.load(open(os.path.join(self.data, "build-hashes.json")))["status"], ap.build_hashes(new)["status"])
def test_a_foreign_key_on_the_page_stops_the_import_and_rolls_back(self):
new = json.loads(json.dumps(self.cfg))
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
busy = {"page": {"name": "19\\nStatus", "controls": {"2": {"3": {"type": "button-layered", "options": {"notes": "someone's key"}}}}}}
with mock.patch.object(ap.push_page, "fetch_page", lambda h, p, page: busy), mock.patch.object(ap.push_page, "push", lambda *a: self.fail("must not push")):
self.run_apply(new, "move LED")
st = self.applier.status
self.assertEqual(st["state"], "failed")
self.assertIn("row 2 col 3", st["outcome"])
self.assertEqual(next(t for t in json.load(open(os.path.join(self.data, "config.json")))["tiles"] if t["id"] == "led")["pos"], [3, 6])
self.assertTrue(os.path.exists(os.path.join(self.data, "pages-out-of-sync.json")))
def test_live_refuses_unless_emergency(self):
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "a HyperDeck is recording", "time": time.time()})
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 4
r = self.run_apply(new)
self.assertFalse(r["ok"])
self.assertTrue(r.get("live"))
r = self.run_apply(new, "urgent", {"word": "LIVE", "reason": "the deck is wrong"})
self.assertTrue(r["ok"])
self.assertEqual(self.applier.status["state"], "done")
self.assertIn("EMERGENCY apply by sam", open(os.path.join(self.data, "apply.log")).read())
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "stale", "time": time.time() - 120})
self.assertEqual(self.applier.live(), (False, "")) # stale: the schedule (none) decides
def test_a_new_switcher_file_goes_with_the_config_and_is_kept(self):
pending = os.path.join(self.data, "draft-atem.xml")
open(pending, "w").write("<y/>")
new = json.loads(json.dumps(self.cfg))
r = self.applier.start(new, "new switcher file", "sam", "10.1.1.50", None, pending)
self.applier.thread.join(30)
self.assertEqual(open(os.path.join(self.data, "atem-reference.xml")).read(), "<y/>")
self.assertTrue(os.listdir(os.path.join(self.data, "history", "atem"))) # the old one, dated
self.assertFalse(os.path.exists(pending))
self.assertIn("switcher file", ap.history_entries(self.data)[0]["actions"])
def test_one_apply_at_a_time(self):
gate = threading.Event()
slow = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: gate.wait(2))
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 4
self.assertTrue(slow.start(new, "a", "sam", "x")["ok"])
self.assertIn("already running", slow.start(new, "b", "sam", "x")["error"])
gate.set()
slow.thread.join(30)
if __name__ == "__main__":
unittest.main()