Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo
- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back, the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo. - settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog, the switcher file upload / pick newest / discard, gear notes). - settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always, members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply with live progress, History with Go back and Undo, Run this fix now. - schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass). - Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+81
-3
@@ -20,10 +20,11 @@ from settings import keyguide, probes, server # noqa: E402
|
||||
from test_status_panel import CONFIG, FakeCompanion, healthy_snapshot # noqa: E402
|
||||
|
||||
|
||||
def get(port, path, json_body=None, origin=None):
|
||||
def get(port, path, json_body=None, origin=None, method=None, code=None, who=None):
|
||||
data = json.dumps(json_body).encode() if json_body is not None else None
|
||||
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data,
|
||||
headers={"Content-Type": "application/json", **({"Origin": origin} if origin else {})})
|
||||
headers = {"Content-Type": "application/json", **({"Origin": origin} if origin else {}),
|
||||
**({"X-Edit-Key": code} if code else {}), **({"X-Editor": who} if who else {})}
|
||||
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = r.read()
|
||||
@@ -134,6 +135,83 @@ class TestServer(unittest.TestCase):
|
||||
self.site.attempts.clear()
|
||||
self.assertEqual(get(self.port, "/api/editing/check", {"code": "x"}, origin="http://evil.example")[0], 403)
|
||||
|
||||
# ---- step 2: the write routes ----
|
||||
CODE = "apple river stone"
|
||||
|
||||
def put(self, path, body, method="PUT", who="sam", code=None):
|
||||
st, raw = get(self.port, path, body, method=method, code=code or self.CODE, who=who)
|
||||
return st, json.loads(raw)
|
||||
|
||||
def test_writes_need_the_code_and_a_name(self):
|
||||
self.site.attempts.clear()
|
||||
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT")[0], 401)
|
||||
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code="wrong", who="sam")[0], 401)
|
||||
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code=self.CODE)[0], 400) # no name
|
||||
self.site.attempts.clear()
|
||||
|
||||
def test_draft_round_trip_conflict_and_discard(self):
|
||||
self.site.attempts.clear()
|
||||
live = self.site.load()
|
||||
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 4
|
||||
st, r = self.put("/api/draft", {"config": mine})
|
||||
self.assertEqual(st, 200)
|
||||
v1 = r["draft"]["version"]
|
||||
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 4)
|
||||
self.assertTrue(r["draft"]["config"]["site"]["editing_code"].startswith("set (")) # masked on the way out
|
||||
self.assertEqual(json.loads(get(self.port, "/api/draft")[1])["draft"]["by"], "sam")
|
||||
theirs = json.loads(json.dumps(live)); theirs["debounce_seconds"] = 5
|
||||
st, r = self.put("/api/draft", {"config": theirs, "expect": v1}, who="chris")
|
||||
self.assertEqual(st, 200)
|
||||
st, r = self.put("/api/draft", {"config": mine, "expect": v1}) # sam's tab is behind
|
||||
self.assertEqual(st, 409)
|
||||
self.assertIn("chris", r["error"])
|
||||
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 5)
|
||||
self.assertEqual(self.site.draft.read()["config"]["site"]["editing_code"], self.CODE) # the secret survived the masked round trip
|
||||
st, r = self.put("/api/validate", {})
|
||||
self.assertEqual(st, 200)
|
||||
self.assertTrue(any("debounce seconds: %d → 5" % live["debounce_seconds"] in c["text"] for c in r["changes"]), r["changes"])
|
||||
self.assertIn("purple", r["feel"].lower())
|
||||
self.assertTrue(r["live"]["live"])
|
||||
self.assertEqual(self.put("/api/draft", {}, method="DELETE")[0], 200)
|
||||
self.assertIsNone(json.loads(get(self.port, "/api/draft")[1])["draft"])
|
||||
|
||||
def test_apply_waits_for_live_then_runs_restores_and_undoes(self):
|
||||
from test_apply import FakeLaunchctl
|
||||
self.site.attempts.clear()
|
||||
fake = FakeLaunchctl(self.data)
|
||||
self.site.applier.run, self.site.applier.sleep = fake, lambda s: None
|
||||
live = self.site.load()
|
||||
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 6
|
||||
self.put("/api/draft", {"config": mine})
|
||||
st, r = self.put("/api/apply", {"note": "slower"}, method="POST")
|
||||
self.assertEqual(st, 409)
|
||||
self.assertTrue(r.get("live"))
|
||||
self.assertIsNotNone(self.site.draft.read()) # the draft is kept
|
||||
st, r = self.put("/api/apply", {"note": "slower", "emergency": {"word": "LIVE", "reason": "deck wrong"}}, method="POST")
|
||||
self.assertEqual(st, 200, r)
|
||||
self.site.applier.thread.join(30)
|
||||
self.assertIsNone(self.site.draft.read())
|
||||
status = json.loads(get(self.port, "/api/apply/status")[1])
|
||||
self.assertEqual(status["state"], "done", status)
|
||||
self.assertEqual(self.site.load()["debounce_seconds"], 6)
|
||||
hist = json.loads(get(self.port, "/api/history")[1])["entries"]
|
||||
self.assertEqual((hist[0]["by"], hist[0]["note"]), ("sam", "slower"))
|
||||
detail = json.loads(get(self.port, "/api/history/" + hist[0]["id"])[1])
|
||||
self.assertTrue(any("debounce seconds: %d → 6" % live["debounce_seconds"] in c["text"] for c in detail["changes_to_now"]), detail)
|
||||
st, r = self.put("/api/restore/" + hist[0]["id"], {}, method="POST")
|
||||
self.assertEqual(r["draft"]["config"]["debounce_seconds"], live["debounce_seconds"])
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
st, r = self.put("/api/undo", {}, method="POST")
|
||||
self.assertEqual((st, r["draft"]["config"]["debounce_seconds"], r["note"]), (200, live["debounce_seconds"], "slower"))
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
# watchdog control waits for live too; a command goes to the watchdog through Companion
|
||||
self.assertEqual(self.put("/api/watchdog/restart", {}, method="POST")[0], 409)
|
||||
st, r = self.put("/api/command", {"text": "service off"}, method="POST")
|
||||
self.assertEqual((st, r["sent"]), (200, "service off"))
|
||||
self.assertIn("service off", FakeCompanion.store["custom"]["sp_cmd"])
|
||||
self.assertEqual(self.put("/api/command", {"text": "rm -rf"}, method="POST")[0], 400)
|
||||
self.assertEqual(self.put("/api/command", {"text": "test hd2 vista 0"}, method="POST")[0], 409) # no test while live
|
||||
|
||||
def test_probe_is_refused_while_a_fix_runs(self):
|
||||
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user