phase: 07_kiosk_layout_state
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:
@@ -0,0 +1,13 @@
|
||||
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`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 22%]
|
||||
...............................sssss.................................... [ 44%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 88%]
|
||||
.................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 703 32 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
319 passed, 5 skipped, 1 warning in 99.46s (0:01:39)
|
||||
All checks passed!
|
||||
32 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
All four gates green. Task 01 complete.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented (protocol-only, per task scope):**
|
||||
- `Arrange` inbound model in `src/mirror/protocol.py` (`staged: str | None`, `fullscreen: bool | None`, both optional so `model_fields_set` can tell "absent" from "clear"); added to the `ClientMessage` discriminated union
|
||||
- `Hello.kiosk: bool = False` (single word, so no alias to get wrong; `Role` untouched — no third role)
|
||||
- `layout()` outbound builder emitting exactly `type/staged/fullscreen/followers`
|
||||
- `welcome()` gains required keyword-only `layout` (no mutable default); `Registry.join` updated in the same change to pass the placeholder `layout(staged=None, fullscreen=False, followers=0)` for task 02
|
||||
- No changes to `Hub`, `create_app`, Registry policy, or any JS
|
||||
- **Tests:** extended `tests/test_protocol.py` — all three `arrange` shapes parse, `model_fields_set` distinguishes absent vs explicit-`null` vs set (incl. explicit `false`), `hello` kiosk default/true, `layout()` key-set + null stage, `welcome` carries its layout, and `layout` added to the type-advertisement list
|
||||
- **Gates:** `ruff check` + `ruff format --check` clean · `pyright` 0 errors · `uv run pytest` **293 passed, 5 skipped** (realdisplay-gated), e2e green with zero e2e test files edited · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 skipped** (host has no audio source — documented module skip)
|
||||
- **Coverage:** `protocol.py` 100%; new `registry.py` line covered (its 3 uncovered lines are pre-existing guard clauses)
|
||||
- **Docs:** no doc impact (no setup/command/config/API change; README does not enumerate wire frames; AGENTS.md not stale)
|
||||
- **Deviation:** none material — `registry.py` imports the `layout` builder by name (matching its import style) rather than `protocol.layout(...)`
|
||||
- **Next pending task:** `02_registry_layout_policy.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 24%]
|
||||
...............................sssss.................................... [ 48%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 96%]
|
||||
.......... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 191 3 98% 176, 190, 365
|
||||
------------------------------------------------------
|
||||
TOTAL 660 33 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
293 passed, 5 skipped, 1 warning in 97.57s (0:01:37)
|
||||
All checks passed!
|
||||
32 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
All green. Final state verified — diff is clean, task 01's protocol work plus my registry work coexist.
|
||||
|
||||
## Report
|
||||
|
||||
**Implemented (task 02 — `Registry` layout policy, `src/mirror/registry.py` only):**
|
||||
- `UNSET` sentinel (`_Unset`) distinguishing "no opinion" from explicit `null`
|
||||
- `Layout` dataclass (`staged`, `fullscreen`); `Registry.layout` created in `__init__`; `welcome` now carries `self.layout_dto()` (placeholder from task 01 replaced)
|
||||
- `Peer.kiosk` + `Registry.join(kiosk=…)`; follower count derived, never bookkept
|
||||
- `layout_dto()` (single DTO source), `set_layout()` (unknown-peer / unknown-stream refusal reusing existing codes, partial-update rule, no-change → ack-only, change → viewers + sender deduped)
|
||||
- Stale-staged fallback in `_drop_stream()` (single funnel for all removal paths); kiosk join/leave pushes layout to other viewers; double-frame guard in `leave()`
|
||||
- New `TestLayout` class: 16 cases in the task's order (defaults, fan-out without doubling, partial update, null-clear + no-op ack, unknown-stream refusal, repeat ack, unpublish/disconnect/replaced-stream fallbacks, follower counting + join/leave pushes, late `welcome` carrying state)
|
||||
|
||||
**Gates:**
|
||||
- `uv run ruff check . && uv run ruff format --check .` → clean
|
||||
- `uv run pyright` → 0 errors; `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` → nothing (A8 holds)
|
||||
- `uv run pytest` → **309 passed, 5 skipped** (skips = realdisplay-gated)
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (known host audio constraint)
|
||||
- Coverage: `registry.py` 99% (3 pre-existing defensive guards uncovered; all task-02 code covered)
|
||||
|
||||
**Docs:** no doc impact (no endpoint/env/CLI change — those land in task 03; README/AGENTS.md untouched)
|
||||
|
||||
**Decisions:** `set_layout` validates `staged` before mutating (refusal leaves state untouched); kiosk-leave broadcast in `leave()` is skipped when `_drop_stream` already pushed the identical frame (degenerate kiosk-sharer case).
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/07_kiosk_layout_state/03_dispatch_read_endpoint_and_wire_tests.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 22%]
|
||||
...............................sssss.................................... [ 45%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 91%]
|
||||
.......................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 697 33 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
309 passed, 5 skipped, 1 warning in 96.60s (0:01:36)
|
||||
All checks passed!
|
||||
32 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
Task 03 complete.
|
||||
|
||||
**Implemented**
|
||||
- `Hub._introduce` passes `kiosk=hello.kiosk` into `registry.join`
|
||||
- `Hub._dispatch` handles `arrange` via `model_fields_set` (absent = `UNSET`, explicit null = clear), with a comment pinning the rule
|
||||
- `GET /api/kiosk` serves `registry.layout_dto()` with `Cache-Control: no-store`, gated like every route; no `/kiosk` page added (phase 08); no JS touched
|
||||
- `tests/test_app.py`: 5 new socket tests in `TestSignalingSocket` (round-trip to sender/second-viewer/endpoint + late-joiner welcome, follower counting and re-count on leave, `unknown_stream` refusal with socket alive, `fullscreen`-only vs `staged:null`, sharer sender ack without fan-out to other sharers, staged-sharer-leaves reset), `/api/kiosk` in the refused-address parametrize + refused/real-address gate tests, exact-DTO shape test
|
||||
- Manual drive verified on a live server: `hello` → `arrange` → `layout` frame, `curl /api/kiosk` reflects state + `no-store`, sharer exit resets `staged: null`
|
||||
|
||||
**Gates**
|
||||
- `uv run ruff check . && uv run ruff format --check .` — clean
|
||||
- `uv run pyright` — 0 errors
|
||||
- `uv run pytest` — 319 passed, 5 skipped (realdisplay-gated), coverage 96% (new code fully covered; all uncovered `app.py` lines pre-existing defensive branches)
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented no-audio-source host constraint)
|
||||
|
||||
**Docs** — README: "The wall has one shared state" protocol note in *How it works*; SECURITY.md §2: `arrange` capability + no-audio-field guarantee. AGENTS.md: no stale facts, unchanged.
|
||||
|
||||
**Notable decision** — the phase's curl example shows 3 keys, but the endpoint returns the socket's exact DTO (task's literal code + task 02's single-DTO invariant), i.e. 4 keys including `"type":"layout"`; the shape test pins all four.
|
||||
|
||||
**Next pending task** — `08_kiosk_page/01_extract_wall_tiles_and_stage.md` (phase 07 is complete).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 22%]
|
||||
...............................sssss.................................... [ 44%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 88%]
|
||||
.................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 703 32 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
319 passed, 5 skipped, 1 warning in 97.82s (0:01:37)
|
||||
All checks passed!
|
||||
32 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
@@ -59,6 +59,18 @@ roster deltas to viewers.
|
||||
└────── WebRTC (SRTP, LAN, p2p) ─────────┴─────────────────┘
|
||||
```
|
||||
|
||||
**The wall has one shared state.** The server keeps which mirror is on stage, whether
|
||||
it is fullscreen, and how many displays are obeying that (`followers` — peers whose
|
||||
`hello` said `"kiosk": true`). Any connected peer may change it with an `arrange`
|
||||
frame on `/ws` (`{"type":"arrange","staged":"<id>","fullscreen":true}`); an absent
|
||||
field is "no opinion", `"staged": null` is "back to the wall". A change is pushed
|
||||
immediately as a `layout` frame to every viewer (and back to the sender), and the
|
||||
same object is readable at `GET /api/kiosk` — `curl`-debuggable like `/api/streams`,
|
||||
`Cache-Control: no-store`. The state has no audio: sound always traces back to a
|
||||
gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The kiosk
|
||||
page that follows this state arrives in a later release; the wire and the read
|
||||
surface are what any client builds on.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (all optional; a `.env` file is loaded if present):
|
||||
|
||||
@@ -31,6 +31,11 @@ limiting, no accounts, and no audit trail.
|
||||
`MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only
|
||||
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
|
||||
ceiling).
|
||||
* Point the shared wall at any live mirror and toggle its fullscreen state
|
||||
(the `arrange` frame on `/ws`), which every kiosk display follows. The
|
||||
state has no audio field: a remote hand can stage and scale, but it can
|
||||
never make a display *sound* — that still needs a gesture on the device
|
||||
playing it (see §4 “Audio”).
|
||||
|
||||
## 3. What they cannot do (enforced and tested)
|
||||
|
||||
|
||||
+21
-2
@@ -21,7 +21,7 @@ from starlette.websockets import WebSocketDisconnected
|
||||
from . import __version__, protocol
|
||||
from .access import POLICY_VIOLATION, Gate, format_cidrs
|
||||
from .config import Settings
|
||||
from .registry import Envelope, Registry, RegistryError
|
||||
from .registry import UNSET, Envelope, Registry, RegistryError
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
@@ -197,7 +197,7 @@ class Hub:
|
||||
try:
|
||||
hello = protocol.parse_client_message(decoded)
|
||||
peer_id, outbox = self.registry.join(
|
||||
role=hello.role, name=hello.name, device=hello.device
|
||||
role=hello.role, name=hello.name, device=hello.device, kiosk=hello.kiosk
|
||||
)
|
||||
except ValidationError as exc:
|
||||
code = "bad_hello"
|
||||
@@ -245,6 +245,18 @@ class Hub:
|
||||
outbox = self.registry.unsubscribe(peer_id, message.stream_id)
|
||||
elif isinstance(message, protocol.Signal):
|
||||
outbox = self.registry.signal(peer_id, message.to, message.payload)
|
||||
elif isinstance(message, protocol.Arrange):
|
||||
# `model_fields_set` is the one place the "absent ≠ null" rule
|
||||
# survives the trip from JSON to policy: an *absent* field is
|
||||
# UNSET ("no opinion, leave it"), an explicit null clears it.
|
||||
# Do not simplify this to `message.staged` — the model defaults
|
||||
# to None, so that would turn "absent" into "back to the wall".
|
||||
fields = message.model_fields_set
|
||||
outbox = self.registry.set_layout(
|
||||
peer_id,
|
||||
staged=message.staged if "staged" in fields else UNSET,
|
||||
fullscreen=message.fullscreen if "fullscreen" in fields else UNSET,
|
||||
)
|
||||
elif isinstance(message, protocol.Ping):
|
||||
outbox = [(peer_id, protocol.pong())]
|
||||
elif isinstance(message, protocol.Hello):
|
||||
@@ -312,6 +324,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@app.get("/api/kiosk")
|
||||
async def kiosk_layout() -> JSONResponse:
|
||||
"""Server truth about the wall display: what is on stage, fullscreen, and
|
||||
how many displays are obeying it. Read-only by design — the only writer is
|
||||
the `arrange` frame on /ws, so there is one path that can change the wall."""
|
||||
return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"})
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "version": __version__, **registry.stats()})
|
||||
|
||||
+46
-3
@@ -44,6 +44,8 @@ class Hello(_Inbound):
|
||||
role: Role = "viewer"
|
||||
name: str = ""
|
||||
device: str = ""
|
||||
#: true when this peer is a wall display that obeys the shared layout.
|
||||
kiosk: bool = False
|
||||
|
||||
|
||||
class Publish(_Inbound):
|
||||
@@ -73,6 +75,18 @@ class Unsubscribe(_Inbound):
|
||||
stream_id: str
|
||||
|
||||
|
||||
class Arrange(_Inbound):
|
||||
"""Ask the wall to show a given mirror. Absent fields are left alone;
|
||||
|
||||
``staged: null`` explicitly returns the displays to the wall. The caller
|
||||
does not have to be the display's operator — see ``SECURITY.md`` §2.
|
||||
"""
|
||||
|
||||
type: Literal["arrange"]
|
||||
staged: str | None = None
|
||||
fullscreen: bool | None = None
|
||||
|
||||
|
||||
class Signal(_Inbound):
|
||||
"""An opaque WebRTC blob (SDP or ICE candidate) addressed to one peer."""
|
||||
|
||||
@@ -86,7 +100,7 @@ class Ping(_Inbound):
|
||||
|
||||
|
||||
ClientMessage = Annotated[
|
||||
Hello | Publish | Unpublish | Subscribe | Unsubscribe | Signal | Ping,
|
||||
Hello | Publish | Unpublish | Subscribe | Unsubscribe | Arrange | Signal | Ping,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -131,8 +145,37 @@ def stream_dto(
|
||||
}
|
||||
|
||||
|
||||
def welcome(*, peer_id: str, role: Role, streams: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {"type": "welcome", "peerId": peer_id, "role": role, "streams": streams}
|
||||
def welcome(
|
||||
*,
|
||||
peer_id: str,
|
||||
role: Role,
|
||||
streams: list[dict[str, Any]],
|
||||
layout: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""The first server frame of a connection; the page is already arranged."""
|
||||
return {
|
||||
"type": "welcome",
|
||||
"peerId": peer_id,
|
||||
"role": role,
|
||||
"streams": streams,
|
||||
"layout": layout,
|
||||
}
|
||||
|
||||
|
||||
def layout(*, staged: str | None, fullscreen: bool, followers: int) -> dict[str, Any]:
|
||||
"""The wall's presentation state, pushed to every viewer as it changes.
|
||||
|
||||
Key spelling and order are the phase-07 contract (``00_phase.md``) — the
|
||||
outbound
|
||||
builders are hand-written dicts, so there is no alias generator to catch a
|
||||
typo here; the round-trip test in ``tests/test_protocol.py`` is what does.
|
||||
"""
|
||||
return {
|
||||
"type": "layout",
|
||||
"staged": staged,
|
||||
"fullscreen": fullscreen,
|
||||
"followers": followers,
|
||||
}
|
||||
|
||||
|
||||
def stream_added(*, stream: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
+109
-4
@@ -12,6 +12,8 @@ Invariants enforced here:
|
||||
* Every teardown path (explicit, disconnect, replaced stream) emits the same
|
||||
``peer_left`` / ``stream_removed`` notifications, so browsers never leak a
|
||||
``RTCPeerConnection``.
|
||||
* The layout names either nothing or a live mirror; a mirror's removal clears
|
||||
it in the same envelope set, so no display is left pointing at nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +27,7 @@ from typing import Any
|
||||
from .protocol import (
|
||||
MAX_LABEL_LEN,
|
||||
MAX_NAME_LEN,
|
||||
layout,
|
||||
peer_joined,
|
||||
peer_left,
|
||||
relayed_signal,
|
||||
@@ -37,6 +40,13 @@ from .protocol import (
|
||||
Envelope = tuple[str, dict[str, Any]]
|
||||
|
||||
|
||||
class _Unset:
|
||||
"""Marker: the caller expressed no opinion, keep the current value."""
|
||||
|
||||
|
||||
UNSET = _Unset()
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
"""A policy violation. Carries a stable ``code`` for the client."""
|
||||
|
||||
@@ -53,9 +63,19 @@ class Peer:
|
||||
role: str
|
||||
name: str
|
||||
device: str = ""
|
||||
kiosk: bool = False
|
||||
joined_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Layout:
|
||||
"""What the wall displays. One value per server process (A7): there is
|
||||
one wall, and every display obeying it is showing the same thing."""
|
||||
|
||||
staged: str | None = None
|
||||
fullscreen: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Stream:
|
||||
"""One live capture, published by exactly one sharer peer."""
|
||||
@@ -103,6 +123,7 @@ class Registry:
|
||||
self._max_streams = max_streams
|
||||
self.peers: dict[str, Peer] = {}
|
||||
self.streams: dict[str, Stream] = {}
|
||||
self.layout = Layout()
|
||||
#: stream_id -> set of viewer peer ids that currently receive it
|
||||
self._subscriptions_by_viewer: dict[str, set[str]] = {}
|
||||
|
||||
@@ -132,7 +153,14 @@ class Registry:
|
||||
|
||||
# ------------------------------------------------------------ operations
|
||||
|
||||
def join(self, *, role: str, name: str = "", device: str = "") -> tuple[str, list[Envelope]]:
|
||||
def join(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
name: str = "",
|
||||
device: str = "",
|
||||
kiosk: bool = False,
|
||||
) -> tuple[str, list[Envelope]]:
|
||||
"""Register a freshly connected socket. Returns ``(peer_id, outbox)``."""
|
||||
if role not in ("sharer", "viewer"):
|
||||
raise RegistryError("bad_role", f"Unknown role {role!r}.", fatal=True)
|
||||
@@ -142,12 +170,26 @@ class Registry:
|
||||
role=role,
|
||||
name=_clean(name, MAX_NAME_LEN) or ("This device" if role == "sharer" else "Viewer"),
|
||||
device=_clean(device, MAX_NAME_LEN),
|
||||
kiosk=kiosk,
|
||||
)
|
||||
self.peers[peer_id] = peer
|
||||
self._subscriptions_by_viewer[peer_id] = set()
|
||||
outbox: list[Envelope] = [
|
||||
(peer_id, welcome(peer_id=peer_id, role=role, streams=self.roster())) # type: ignore[arg-type]
|
||||
(
|
||||
peer_id,
|
||||
welcome(
|
||||
peer_id=peer_id,
|
||||
role=role, # type: ignore[arg-type]
|
||||
streams=self.roster(),
|
||||
layout=self.layout_dto(),
|
||||
),
|
||||
)
|
||||
]
|
||||
# A new follower changes what the layout frame says; the other viewers
|
||||
# are told now, because nothing else would update them (the joiner got
|
||||
# the state in its welcome).
|
||||
if kiosk:
|
||||
outbox.extend(self._layout_broadcast(exclude=peer_id))
|
||||
return peer_id, outbox
|
||||
|
||||
def leave(self, peer_id: str) -> list[Envelope]:
|
||||
@@ -167,6 +209,10 @@ class Registry:
|
||||
stream.viewers.discard(peer_id)
|
||||
outbox.append((stream.sharer_id, peer_left(stream_id=stream.id, viewer_id=peer_id)))
|
||||
outbox.extend(self._broadcast_update(stream))
|
||||
# A follower leaving changes what the layout frame says. Skip it when a
|
||||
# dropped stream already pushed the (identical) frame in this outbox.
|
||||
if peer.kiosk and not any(message["type"] == "layout" for _, message in outbox):
|
||||
outbox.extend(self._layout_broadcast())
|
||||
return outbox
|
||||
|
||||
def rename(self, peer_id: str, *, name: str = "", device: str = "") -> list[Envelope]:
|
||||
@@ -309,6 +355,57 @@ class Registry:
|
||||
"""The stream ids this viewer is currently receiving."""
|
||||
return set(self._subscriptions_by_viewer.get(viewer_id, set()))
|
||||
|
||||
# -------------------------------------------------------------- layout
|
||||
|
||||
def layout_dto(self) -> dict[str, Any]:
|
||||
"""The wall's state as the wire object, with the follower count
|
||||
derived from the roster. The only place the DTO is built, so the
|
||||
socket and any read endpoint can never disagree about it.
|
||||
"""
|
||||
return layout(
|
||||
staged=self.layout.staged,
|
||||
fullscreen=self.layout.fullscreen,
|
||||
followers=sum(1 for p in self.peers.values() if p.kiosk),
|
||||
)
|
||||
|
||||
def set_layout(
|
||||
self,
|
||||
peer_id: str,
|
||||
*,
|
||||
staged: Any = UNSET,
|
||||
fullscreen: Any = UNSET,
|
||||
) -> list[Envelope]:
|
||||
"""Change the wall's presentation state on behalf of ``peer_id``.
|
||||
|
||||
``UNSET`` means the caller had no opinion (leave the value); an explicit
|
||||
``None`` for ``staged`` clears it. A ``staged`` value that does not
|
||||
name a live mirror is refused with ``unknown_stream`` and changes
|
||||
nothing. An unchanged state replies with the ack to the sender only;
|
||||
a real change is pushed to every viewer plus the sender.
|
||||
"""
|
||||
self._peer(peer_id)
|
||||
if staged is not UNSET and staged is not None:
|
||||
self._stream(staged)
|
||||
changed = False
|
||||
if staged is not UNSET:
|
||||
changed = changed or staged != self.layout.staged
|
||||
self.layout.staged = staged
|
||||
if fullscreen is not UNSET:
|
||||
changed = changed or fullscreen != self.layout.fullscreen
|
||||
self.layout.fullscreen = fullscreen
|
||||
frame = self.layout_dto()
|
||||
if not changed:
|
||||
return [(peer_id, frame)]
|
||||
targets = {p.id for p in self._viewers()} | {peer_id}
|
||||
return [(target, frame) for target in sorted(targets)]
|
||||
|
||||
def _layout_broadcast(self, *, exclude: str | None = None) -> list[Envelope]:
|
||||
"""The current layout frame, pushed to every viewer (minus ``exclude``)."""
|
||||
frame = self.layout_dto()
|
||||
return [
|
||||
(p.id, frame) for p in sorted(self._viewers(), key=lambda p: p.id) if p.id != exclude
|
||||
]
|
||||
|
||||
# -------------------------------------------------------------- read-only
|
||||
|
||||
def roster(self) -> list[dict[str, Any]]:
|
||||
@@ -337,13 +434,21 @@ class Registry:
|
||||
|
||||
Every viewer is told, not only the ones with a live subscription: a tile
|
||||
is drawn as soon as ``stream_added`` lands, so a viewer that has not got
|
||||
round to subscribing still has something on screen to clear.
|
||||
round to subscribing still has something on screen to clear. A display
|
||||
aimed at this mirror falls back to the wall in the *same* frame set —
|
||||
nothing polls the layout, so the correction must ride along.
|
||||
"""
|
||||
self.streams.pop(stream.id, None)
|
||||
for subs in self._subscriptions_by_viewer.values():
|
||||
subs.discard(stream.id)
|
||||
message = stream_removed(stream_id=stream.id)
|
||||
return [(viewer.id, message) for viewer in sorted(self._viewers(), key=lambda p: p.id)]
|
||||
outbox: list[Envelope] = [
|
||||
(viewer.id, message) for viewer in sorted(self._viewers(), key=lambda p: p.id)
|
||||
]
|
||||
if self.layout.staged == stream.id:
|
||||
self.layout.staged = None
|
||||
outbox.extend(self._layout_broadcast())
|
||||
return outbox
|
||||
|
||||
def _broadcast_add(self, stream: Stream) -> list[Envelope]:
|
||||
name = self.peers[stream.sharer_id].name
|
||||
|
||||
+187
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user