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
+71 -1
View File
@@ -48,6 +48,49 @@ class TestInbound:
with pytest.raises(ValidationError):
protocol.parse_client_message({"type": "subscribe"})
def test_arrange_with_no_fields_changes_nothing(self):
message = protocol.parse_client_message({"type": "arrange"})
assert message.staged is None
assert message.fullscreen is None
# Nothing was mentioned, so nothing may be applied: this is the whole
# reason the fields are read with ``model_fields_set`` rather than
# defaults.
assert "staged" not in message.model_fields_set
assert "fullscreen" not in message.model_fields_set
def test_arrange_with_an_explicit_null_staged_returns_the_displays_to_the_wall(self):
message = protocol.parse_client_message({"type": "arrange", "staged": None})
assert message.staged is None
# Explicit ``null`` is a different request from "absent": it says
# "clear the stage", so ``staged`` must be in ``model_fields_set``.
assert "staged" in message.model_fields_set
assert "fullscreen" not in message.model_fields_set
def test_arrange_carries_its_values_and_marks_only_what_was_mentioned(self):
message = protocol.parse_client_message(
{"type": "arrange", "staged": "p1", "fullscreen": True}
)
assert message.staged == "p1"
assert message.fullscreen is True
assert message.model_fields_set == {"type", "staged", "fullscreen"}
def test_arrange_accepts_an_explicit_false_fullscreen(self):
message = protocol.parse_client_message(
{"type": "arrange", "staged": "p1", "fullscreen": False}
)
assert message.fullscreen is False
assert "fullscreen" in message.model_fields_set
def test_hello_defaults_kiosk_to_false(self):
message = protocol.parse_client_message({"type": "hello", "role": "viewer"})
assert message.kiosk is False
def test_hello_accepts_a_kiosk_display(self):
message = protocol.parse_client_message(
{"type": "hello", "role": "viewer", "name": "Wall", "kiosk": True}
)
assert message.kiosk is True
def test_dimensions_are_bounded(self):
for bad in (-1, 16385, "wide"):
with pytest.raises(ValidationError):
@@ -93,9 +136,35 @@ class TestOutbound:
}
assert dto["displaySurface"] == "window"
def test_the_layout_frame_has_exactly_the_contract_keys(self):
frame = protocol.layout(staged="p1", fullscreen=True, followers=2)
# The outbound dicts are hand-written, so there is no alias generator to
# catch a typo here; the key set is the guard.
assert set(frame) == {"type", "staged", "fullscreen", "followers"}
assert frame["type"] == "layout"
assert frame["staged"] == "p1"
assert frame["fullscreen"] is True
assert frame["followers"] == 2
def test_a_layout_with_no_stage_says_null_not_absent(self):
frame = protocol.layout(staged=None, fullscreen=False, followers=0)
assert frame["staged"] is None
def test_welcome_carries_the_layout_it_was_given(self):
state = protocol.layout(staged="p1", fullscreen=True, followers=3)
frame = protocol.welcome(peer_id="p1", role="viewer", streams=[], layout=state)
assert frame["layout"] == state
assert set(frame) == {"type", "peerId", "role", "streams", "layout"}
def test_every_server_message_advertises_its_type(self):
messages = [
protocol.welcome(peer_id="p1", role="viewer", streams=[]),
protocol.welcome(
peer_id="p1",
role="viewer",
streams=[],
layout=protocol.layout(staged=None, fullscreen=False, followers=0),
),
protocol.layout(staged=None, fullscreen=False, followers=0),
protocol.stream_added(stream={"id": "p1"}),
protocol.stream_updated(stream={"id": "p1"}),
protocol.stream_removed(stream_id="p1"),
@@ -107,6 +176,7 @@ class TestOutbound:
]
assert [m["type"] for m in messages] == [
"welcome",
"layout",
"stream_added",
"stream_updated",
"stream_removed",
+154
View File
@@ -315,6 +315,160 @@ class TestTeardown:
registry.signal(v1, sharer, {})
class TestLayout:
def test_the_default_is_the_wall_not_fullscreen_with_zero_followers(self, registry):
registry.join(role="viewer")
assert registry.layout.staged is None
assert registry.layout.fullscreen is False
assert registry.layout_dto() == {
"type": "layout",
"staged": None,
"fullscreen": False,
"followers": 0,
}
def test_staging_broadcasts_to_viewers_and_the_sender_without_doubling(self, mesh):
sharer, v1, v2, registry = mesh
outbox = registry.set_layout(sharer, staged=sharer)
assert {t for t, _ in outbox} == {sharer, v1, v2}
for peer_id in (sharer, v1, v2):
assert types_for(outbox, peer_id) == ["layout"]
expected = {"type": "layout", "staged": sharer, "fullscreen": False, "followers": 0}
assert all(m == expected for _t, m in outbox)
assert len(outbox) == 3 # one frame per target; nobody receives it twice
def test_a_partial_update_touches_only_the_named_fields(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.set_layout(sharer, fullscreen=True)
dto = message_for(outbox, sharer, "layout")
assert registry.layout.staged == sharer # absent field left alone
assert registry.layout.fullscreen is True
assert dto == {"type": "layout", "staged": sharer, "fullscreen": True, "followers": 0}
def test_an_explicit_null_clears_and_an_opinionless_arrange_is_ack_only(self, mesh):
sharer, v1, v2, registry = mesh
registry.set_layout(sharer, staged=sharer, fullscreen=True)
outbox = registry.set_layout(v1, staged=None)
assert registry.layout.staged is None
assert registry.layout.fullscreen is True # absent field left alone
# The clearing is a real change: viewers plus the sender (deduped) are told.
assert {t for t, _ in outbox} == {v1, v2}
# No opinion at all: the sender is acked and nobody else hears anything.
outbox = registry.set_layout(v2)
assert outbox == [
(v2, {"type": "layout", "staged": None, "fullscreen": True, "followers": 0})
]
def test_an_unknown_staged_mirror_is_refused_and_changes_nothing(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer, fullscreen=True)
with pytest.raises(RegistryError) as excinfo:
registry.set_layout(sharer, staged="ghost", fullscreen=False)
assert excinfo.value.code == "unknown_stream"
assert registry.layout.staged == sharer
assert registry.layout.fullscreen is True
def test_repeating_the_same_state_replies_with_the_ack_only(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.set_layout(sharer, staged=sharer, fullscreen=False)
assert outbox == [
(sharer, {"type": "layout", "staged": sharer, "fullscreen": False, "followers": 0})
]
def test_unpublishing_the_staged_mirror_falls_back_to_the_wall(self, mesh):
sharer, v1, v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.unpublish(sharer)
for peer_id in (v1, v2):
# Exactly one layout frame, riding the same envelope set as the removal.
assert types_for(outbox, peer_id) == ["stream_removed", "layout"]
assert message_for(outbox, peer_id, "layout")["staged"] is None
assert registry.layout.staged is None
def test_a_sharer_disconnecting_falls_back_to_the_wall(self, mesh):
sharer, v1, v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
outbox = registry.leave(sharer)
for peer_id in (v1, v2):
assert types_for(outbox, peer_id) == ["stream_removed", "layout"]
assert message_for(outbox, peer_id, "layout")["staged"] is None
assert registry.layout.staged is None
def test_a_replaced_stream_leaves_the_stage_cleared(self, mesh):
sharer, v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer)
registry.leave(sharer) # the old mirror goes with its socket
fresh, _ = registry.join(role="sharer", name="Linux Box")
outbox = registry.publish(fresh, label="Terminal", width=1280, height=720)
# The replacement has a new id; the stage must not still point at the
# old one, and nothing auto-stages the newcomer.
assert fresh != sharer
assert registry.layout.staged is None
assert "layout" not in types_for(outbox, v1)
assert [s["id"] for s in registry.roster()] == [fresh]
def test_the_stage_is_said_once_when_a_kiosk_sharer_leaves(self, registry):
kiosk_sharer, _ = registry.join(role="sharer", kiosk=True)
viewer, _ = registry.join(role="viewer")
registry.publish(kiosk_sharer, label="Terminal")
registry.set_layout(kiosk_sharer, staged=kiosk_sharer)
outbox = registry.leave(kiosk_sharer)
# The fallback and the follower change must not double the layout frame.
assert types_for(outbox, viewer) == ["stream_removed", "layout"]
def test_followers_counts_only_kiosk_peers(self, registry):
registry.join(role="viewer")
registry.join(role="viewer")
registry.join(role="viewer", kiosk=True)
registry.join(role="sharer")
assert registry.layout_dto()["followers"] == 1
def test_a_kiosk_joining_pushes_the_new_follower_count(self, mesh):
_sharer, v1, v2, registry = mesh
_id, outbox = registry.join(role="viewer", kiosk=True)
# Told to the other viewers; the joiner got the state in its welcome,
# not as a layout frame.
assert {t for t, m in outbox if m["type"] == "layout"} == {v1, v2}
assert types_for(outbox, _id) == ["welcome"]
assert message_for(outbox, v1, "layout")["followers"] == 1
assert message_for(outbox, v1, "layout") == message_for(outbox, v2, "layout")
assert registry.layout_dto()["followers"] == 1
def test_a_non_kiosk_join_says_nothing_about_the_layout(self, mesh):
_sharer, v1, _v2, registry = mesh
_id, outbox = registry.join(role="viewer")
assert "layout" not in types_for(outbox, v1)
def test_a_kiosk_leaving_pushes_the_new_follower_count(self, mesh):
_sharer, v1, v2, registry = mesh
kiosk, _ = registry.join(role="viewer", kiosk=True)
assert registry.layout_dto()["followers"] == 1
outbox = registry.leave(kiosk)
assert {t for t, _ in outbox} == {v1, v2}
assert message_for(outbox, v1, "layout")["followers"] == 0
assert registry.layout_dto()["followers"] == 0
def test_a_peer_joined_after_an_arrange_arrives_already_arranged(self, mesh):
sharer, _v1, _v2, registry = mesh
registry.set_layout(sharer, staged=sharer, fullscreen=True)
late, outbox = registry.join(role="viewer", kiosk=True)
welcome = message_for(outbox, late, "welcome")
assert welcome["layout"] == {
"type": "layout",
"staged": sharer,
"fullscreen": True,
"followers": 1,
}
def test_a_dead_peer_cannot_arrange_the_wall(self, mesh):
_sharer, _v1, _v2, registry = mesh
with pytest.raises(RegistryError) as excinfo:
registry.set_layout("ghost", staged="ghost")
assert excinfo.value.code == "unknown_peer"
class TestRenameAndStats:
def test_rename_refreshes_the_mirrors_that_show_it(self, mesh):
sharer, v1, _v2, registry = mesh