phase: 07_kiosk_layout_state
Build and Push Containers / test (push) Successful in 39s
Build and Push Containers / build-and-push-app (push) Successful in 12s

Every AGENTS.md rule claim checks out against the code — no stale statements. All verification is complete.

**Phase 07 — final verification pass report**

- **Verified, not re-implemented:** all 3 tasks already in `complete/` — `Arrange`/`layout` frames + `hello.kiosk` (protocol), `Layout`/`set_layout`/`layout_dto` policy (registry), `arrange` dispatch + `GET /api/kiosk` (app).
- **Live check:** idle `/api/kiosk` → `{"staged": null, "fullscreen": false, "followers": 0}`; drove `arrange` over a real WebSocket (websocat absent → python `websockets` client): state reflected in `/api/kiosk`, kiosk join bumped `followers` to 1 and pushed to other viewers, sharer teardown reset `staged` to `null` in exactly one `layout` frame — no stale id.
- **Gates (all 4):** `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **319 passed, 5 skipped** (realdisplay, by design); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (documented host has no audio source).
- **Criteria 1/4/5:** targeted suite 154 passed incl. all new layout cases; stale-fallback unit tests cover unpublish, disconnect, and stream-replacement; `git diff` of `src/mirror/static/` and `tests/e2e/` is empty, e2e green within the full run.
- **Criterion 6:** full suite 100% green; coverage 95% total — new/modified code: protocol 100%, registry 99%, app 94% (all >90%).
- **Criterion 7:** `PLAN.md` untouched.
- **Documentation:** README.md reviewed in full and cross-checked against code (routes, CLI flags, config table incl. 25 Mbit/s default, quality-preset table, watch/share key tables, docs references, the phase-07 shared-state paragraph) — consistent, no fixes needed. AGENTS.md — verified, no stale statements, no changes.
- **Note:** `PLAN.md` §6 says the default `MIRROR_MAX_BITRATE` is "12 Mbit/s" but code/README say 25 — pre-existing, in a LOCKED file, left as-is (README is correct).
- **Next pending phase:** `08_kiosk_page`.
This commit is contained in:
2026-11-03 09:53:38 -05:00
parent 1f60a6b40c
commit 736da1c1ef
28 changed files with 826 additions and 11 deletions
+187 -1
View File
@@ -459,7 +459,9 @@ class TestAddressGate:
with gated(client_address="203.0.113.9") as client:
assert client.get("/watch").status_code == 200
@pytest.mark.parametrize("path", ["/", "/mirror", "/watch", "/api/streams", "/static/app.css"])
@pytest.mark.parametrize(
"path", ["/", "/mirror", "/watch", "/api/streams", "/api/kiosk", "/static/app.css"]
)
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
with gated(self.ALLOW) as client:
assert client.get(path).status_code == 403
@@ -502,6 +504,18 @@ class TestAddressGate:
response = client.get("/api/config", headers=headers)
assert response.headers["cache-control"] == "no-store"
def test_a_refused_address_never_learns_the_layout(self):
with gated(self.ALLOW) as client:
response = client.get("/api/kiosk")
assert response.status_code == 403
assert "staged" not in response.text
def test_the_real_address_gets_the_layout_read_with_no_store(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
response = client.get("/api/kiosk")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
def test_the_socket_closes_before_it_accepts(self):
with (
gated(self.ALLOW) as client,
@@ -562,6 +576,17 @@ class TestJsonApi:
def test_healthz(self, client):
assert client.get("/healthz").json()["status"] == "ok"
def test_kiosk_answers_exactly_the_layout_dto(self, client):
"""Shape guard: the read endpoint is the socket's `layout` DTO, nothing more.
It answers the same object the socket pushes (task 02's single-DTO
invariant), so the key set — including the `type` tag — is pinned: a new
key here is a decision (00_phase.md), not a leak.
"""
body = client.get("/api/kiosk").json()
assert set(body) == {"type", "staged", "fullscreen", "followers"}
assert body == {"type": "layout", "staged": None, "fullscreen": False, "followers": 0}
class TestSignalingSocket:
def test_hello_must_come_first(self, client):
@@ -816,3 +841,164 @@ class TestSignalingSocket:
assert wait_for(viewer, "error")["code"] == "not_sharer"
assert client.get("/api/streams").json()["stats"]["mirrors"] == 1
assert sharer_id and viewer
def test_arrange_roundtrips_to_viewers_the_sender_and_the_read_endpoint(self, client):
"""The whole path: publish, arrange, and every observer — the sender, the
other viewers, and `GET /api/kiosk` — says the same thing."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer,
client.websocket_connect("/ws") as second,
):
sharer_id = handshake(sharer, "sharer", "A")
handshake(viewer, "viewer", "V1")
handshake(second, "viewer", "V2")
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer, "stream_added")
wait_for(second, "stream_added")
viewer.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
sent = wait_for(viewer, "layout")
assert sent["staged"] == sharer_id
assert sent["fullscreen"] is True
assert wait_for(second, "layout") == sent
body = client.get("/api/kiosk").json()
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 0)
# A peer that joins after the arrange is already arranged: its
# welcome carries the state, so a page can navigate and obey
# without asking. (Joins while the wall still exists — closing the
# sharer's socket would legitimately reset the stage.)
with client.websocket_connect("/ws") as late:
late.send_json({"type": "hello", "role": "viewer", "name": "late"})
welcome = late.receive_json()
assert welcome["layout"]["staged"] == sharer_id
assert welcome["layout"]["fullscreen"] is True
def test_kiosk_followers_are_counted_and_recounted_on_leave(self, client):
with (
client.websocket_connect("/ws") as plain,
client.websocket_connect("/ws") as kiosk1,
client.websocket_connect("/ws") as kiosk2,
):
handshake(plain, "viewer", "plain")
kiosk1.send_json({"type": "hello", "role": "viewer", "name": "K1", "kiosk": True})
assert kiosk1.receive_json()["type"] == "welcome"
kiosk2.send_json({"type": "hello", "role": "viewer", "name": "K2", "kiosk": True})
# The joiner counts itself: it is connected and it declared kiosk.
assert kiosk2.receive_json()["layout"]["followers"] == 2
# The plain viewer was told by every kiosk join, in order.
assert wait_for(plain, "layout")["followers"] == 1
assert wait_for(plain, "layout")["followers"] == 2
assert wait_for(kiosk1, "layout")["followers"] == 2
kiosk1.send_json({"type": "arrange", "fullscreen": True})
sent = wait_for(kiosk1, "layout")
assert sent["fullscreen"] is True
assert sent["followers"] == 2
assert wait_for(plain, "layout")["followers"] == 2
assert wait_for(kiosk2, "layout")["followers"] == 2
kiosk1.close()
# A follower leaving re-counts: the remaining viewers hear `1`.
assert wait_for(kiosk2, "layout")["followers"] == 1
assert wait_for(plain, "layout")["followers"] == 1
assert client.get("/api/kiosk").json()["followers"] == 1
def test_arranging_a_mirror_that_is_not_there_is_refused_without_closing(self, client):
with client.websocket_connect("/ws") as viewer:
handshake(viewer, "viewer")
viewer.send_json({"type": "arrange", "staged": "p404"})
error = wait_for(viewer, "error")
assert error["code"] == "unknown_stream"
assert error["fatal"] is False
# The socket is still up, and nothing changed.
viewer.send_json({"type": "ping"})
assert viewer.receive_json() == {"type": "pong"}
assert client.get("/api/kiosk").json()["staged"] is None
def test_a_fullscreen_only_arrange_leaves_the_stage_and_null_returns_the_wall(self, client):
"""End to end: absent ≠ null. A `fullscreen`-only frame keeps the staged
mirror; an explicit `staged: null` is the only way back to the wall."""
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer,
):
sharer_id = handshake(sharer, "sharer")
handshake(viewer, "viewer")
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer, "stream_added")
viewer.send_json({"type": "arrange", "staged": sharer_id})
wait_for(viewer, "layout")
viewer.send_json({"type": "arrange", "fullscreen": True})
frame = wait_for(viewer, "layout")
assert frame["staged"] == sharer_id # absent field left alone
assert frame["fullscreen"] is True
viewer.send_json({"type": "arrange", "staged": None})
frame = wait_for(viewer, "layout")
assert frame["staged"] is None # explicit null returns the wall
assert frame["fullscreen"] is True # and touches nothing else
body = client.get("/api/kiosk").json()
assert (body["staged"], body["fullscreen"]) == (None, True)
def test_a_sharer_may_arrange_but_only_viewers_and_the_sender_are_told(self, client):
with (
client.websocket_connect("/ws") as sharer_a,
client.websocket_connect("/ws") as sharer_b,
client.websocket_connect("/ws") as viewer,
):
a_id = handshake(sharer_a, "sharer", "A")
b_id = handshake(sharer_b, "sharer", "B")
handshake(viewer, "viewer")
sharer_a.send_json({"type": "publish", "label": "A"})
wait_for(viewer, "stream_added")
sharer_b.send_json({"type": "publish", "label": "B"})
wait_for(viewer, "stream_added")
viewer.send_json({"type": "subscribe", "streamId": a_id})
wait_for(sharer_a, "peer_joined")
wait_for(sharer_a, "stream_updated")
viewer.send_json({"type": "subscribe", "streamId": b_id})
wait_for(sharer_b, "peer_joined")
wait_for(sharer_b, "stream_updated")
for _ in range(2):
wait_for(viewer, "stream_updated")
sharer_a.send_json({"type": "arrange", "staged": a_id, "fullscreen": True})
sent = wait_for(sharer_a, "layout") # a sharer-sender gets its ack
assert sent["staged"] == a_id
assert wait_for(viewer, "layout")["fullscreen"] is True
# sharer_b is neither the sender nor a viewer: its next frame must be
# the unsubscribe below, not a layout — a fanned-out layout would
# have arrived first and broken this.
viewer.send_json({"type": "unsubscribe", "streamId": b_id})
message = sharer_b.receive_json()
assert message["type"] == "peer_left", (
f"a non-viewing sharer received {message!r}: layout may not fan out to sharers"
)
def test_the_staged_mirror_going_away_resets_the_stage_for_every_viewer(self, client):
with (
client.websocket_connect("/ws") as sharer,
client.websocket_connect("/ws") as viewer1,
client.websocket_connect("/ws") as viewer2,
):
sharer_id = handshake(sharer, "sharer")
handshake(viewer1, "viewer")
handshake(viewer2, "viewer")
sharer.send_json({"type": "publish", "label": "Screen"})
wait_for(viewer1, "stream_added")
wait_for(viewer2, "stream_added")
viewer1.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
wait_for(viewer1, "layout")
wait_for(viewer2, "layout")
sharer.close()
assert wait_for(viewer1, "layout")["staged"] is None
assert wait_for(viewer2, "layout")["staged"] is None
assert client.get("/api/kiosk").json()["staged"] is None