"""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("") 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("") 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(), "") 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()