feat: let a teacher ask the sanctuary to call a child's parent

The coordinator already existed on the control computer and its contract
is frozen, so this is the classroom half of a contract that was read from
its README and source rather than guessed: four fields out, the status
topic subscribed before anything is published, and every word the teacher
reads taken from the coordinator's own message field.

It is a separate process, not a Plasma applet. A broker library, a
network timeout and a text entry have no business inside the only way a
teacher has of getting anywhere; if this dies the bar is untouched.

The panel mark is Breeze's call-outgoing. The old one was a bell, sitting
two inches from Plasma's notification bell in the same bar and meaning
something entirely different.

Two things it will not do: claim a call was displayed, and let a teacher
type the room. The machine already knows which room it is.

Sixty tests cover the contract without a broker. The live path was proven
against the real coordinator running in the lab, on a loopback broker,
with ProPresenter mocked and the church's networks rejected from inside
the container first.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 10:37:06 -05:00
co-authored by Claude Opus 5
parent 380f031092
commit 7790003c03
17 changed files with 1478 additions and 28 deletions
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - <<'PY'
import json
from classroom_parent_call import (
ERROR, EXPIRED, MODE_APPROVAL, MODE_AUTO, MODE_OFF, REJECTED,
SENT_TO_SANCTUARY, SUBMITTED, UNAVAILABLE, WAITING_APPROVAL, WORDING,
Topics, availability, clean_child, new_request_id, read_coordinator,
read_mode, read_status, request_payload,
)
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
print("1. the coordinator's topics, exactly")
t = Topics()
check("root is the church's, with no product name",
t.root == "classroom/parent-call")
check("mode", t.mode == "classroom/parent-call/mode")
check("coordinator", t.coordinator == "classroom/parent-call/coordinator")
check("request", t.request == "classroom/parent-call/request")
check("status is per request",
t.status("nursery-8f21c3") == "classroom/parent-call/status/nursery-8f21c3")
check("a different root carries through",
Topics("test/pc").request == "test/pc/request")
check("a trailing slash does not double up",
Topics("test/pc/").mode == "test/pc/mode")
print()
print("2. a request is four fields and nothing else")
body = request_payload("nursery-8f21c3", "nursery", "Emma", now=1780000000)
check("exactly the four the coordinator reads",
set(body) == {"id", "room", "child", "created_at"}, sorted(body))
check("created_at is a number", isinstance(body["created_at"], int))
check("it round-trips as json", json.loads(json.dumps(body)) == body)
print()
print("3. ids the coordinator will accept")
check("room-scoped", new_request_id("nursery", "8f21c3") == "nursery-8f21c3")
check("random by default, and different each time",
new_request_id("k2") != new_request_id("k2"))
check("no topic syntax can get into one",
"/" not in new_request_id("older") and "#" not in new_request_id("older"))
check("two rooms cannot collide on a fixed part",
new_request_id("k2", "aaa") != new_request_id("older", "aaa"))
print()
print("4. the name the teacher typed is the name that is sent")
check("ordinary name", clean_child("Emma") == "Emma")
check("outer space is trimmed", clean_child(" Emma ") == "Emma")
check("inner runs collapse", clean_child("Emma Grace") == "Emma Grace")
check("a newline becomes a space, not a weld",
clean_child("Emma\nGrace") == "Emma Grace")
check("a zero width joiner cannot hide in it",
clean_child("Em‍ma") == "Em ma")
check("empty is not a name", clean_child(" ") is None)
check("nothing is not a name", clean_child(None) is None)
check("longer than the coordinator accepts is refused here first",
clean_child("E" * 41) is None)
check("exactly the limit is fine", clean_child("E" * 40) == "E" * 40)
check("accents normalise to one form",
clean_child("José") == "José")
print()
print("5. reading the mode")
for raw, expected in [(b"auto", MODE_AUTO), ("approval", MODE_APPROVAL),
(" off\n", MODE_OFF), ("AUTO", MODE_AUTO)]:
check(f"{raw!r} -> {expected}", read_mode(raw) == expected)
check("anything else is not a mode", read_mode("banana") is None)
check("an empty retained mode is not a mode", read_mode("") is None)
print()
print("6. is the sanctuary end up")
check("online", read_coordinator('{"online": true}') is True)
check("explicitly offline", read_coordinator('{"online": false}') is False)
check("a cleared retained topic is not up", read_coordinator("") is False)
check("junk is not up", read_coordinator("yes please") is False)
check("a bare true is not the contract", read_coordinator("true") is False)
print()
print("7. statuses, as the coordinator sends them")
def status(**kw):
body = {"id": "nursery-8f21c3", "room": "nursery",
"status": SUBMITTED, "message": "Sending", "updated_at": 1}
body.update(kw)
return json.dumps(body)
seen = read_status(status(status=WAITING_APPROVAL, message="Waiting for approval"))
check("waiting for approval is not terminal",
seen["status"] == WAITING_APPROVAL and not seen["terminal"])
check("its wording is the coordinator's, verbatim",
seen["message"] == "Waiting for approval")
seen = read_status(status(status=SENT_TO_SANCTUARY, message="Sent to sanctuary"))
check("sent to sanctuary is terminal and good",
seen["terminal"] and seen["good"])
check("and it never says displayed",
"displayed" not in seen["message"].lower())
for bad in (REJECTED, EXPIRED, ERROR, UNAVAILABLE):
seen = read_status(status(status=bad))
check(f"{bad} is terminal but not good",
seen["terminal"] and not seen["good"])
check("an unknown status is not shown",
read_status(status(status="displayed")) is None)
check("json that is not an object is not a status",
read_status("[1,2,3]") is None)
check("junk is not a status", read_status("not json") is None)
check("a status with no usable message falls back to the coordinator's word",
read_status(status(message=""))["message"] == WORDING[SUBMITTED])
check("another room's request is never shown here",
read_status(status(id="k2-ffffff"), request_id="nursery-8f21c3") is None)
check("our own request is",
read_status(status(), request_id="nursery-8f21c3")["status"] == SUBMITTED)
check("a detail is carried but bounded",
len(read_status(status(detail="x" * 500))["detail"]) == 200)
print()
print("8. when a teacher may send at all")
can, why = availability(MODE_AUTO, True)
check("auto with the coordinator up", can and why is None)
can, why = availability(MODE_APPROVAL, True)
check("approval with the coordinator up", can and why is None)
can, why = availability(MODE_OFF, True)
check("off cannot send", not can)
check("and says so in the coordinator's own words",
why == WORDING[UNAVAILABLE], why)
can, why = availability(MODE_AUTO, False)
check("the coordinator being down stops it", not can)
check("and does not pretend a call would arrive",
"not running" in why, why)
can, why = availability(MODE_AUTO, True, broker_reachable=False)
check("no broker stops it", not can)
check("and says the network, not the sanctuary",
"network" in why, why)
can, why = availability(None, True)
check("a broker with no retained mode stops it", not can)
check("and says it is not set up", "set up" in why, why)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY