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) ─────────┴─────────────────┘
|
└────── 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
|
## Configuration
|
||||||
|
|
||||||
Environment variables (all optional; a `.env` file is loaded if present):
|
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
|
`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
|
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
|
||||||
ceiling).
|
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)
|
## 3. What they cannot do (enforced and tested)
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -21,7 +21,7 @@ from starlette.websockets import WebSocketDisconnected
|
|||||||
from . import __version__, protocol
|
from . import __version__, protocol
|
||||||
from .access import POLICY_VIOLATION, Gate, format_cidrs
|
from .access import POLICY_VIOLATION, Gate, format_cidrs
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .registry import Envelope, Registry, RegistryError
|
from .registry import UNSET, Envelope, Registry, RegistryError
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ class Hub:
|
|||||||
try:
|
try:
|
||||||
hello = protocol.parse_client_message(decoded)
|
hello = protocol.parse_client_message(decoded)
|
||||||
peer_id, outbox = self.registry.join(
|
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:
|
except ValidationError as exc:
|
||||||
code = "bad_hello"
|
code = "bad_hello"
|
||||||
@@ -245,6 +245,18 @@ class Hub:
|
|||||||
outbox = self.registry.unsubscribe(peer_id, message.stream_id)
|
outbox = self.registry.unsubscribe(peer_id, message.stream_id)
|
||||||
elif isinstance(message, protocol.Signal):
|
elif isinstance(message, protocol.Signal):
|
||||||
outbox = self.registry.signal(peer_id, message.to, message.payload)
|
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):
|
elif isinstance(message, protocol.Ping):
|
||||||
outbox = [(peer_id, protocol.pong())]
|
outbox = [(peer_id, protocol.pong())]
|
||||||
elif isinstance(message, protocol.Hello):
|
elif isinstance(message, protocol.Hello):
|
||||||
@@ -312,6 +324,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
headers={"Cache-Control": "no-store"},
|
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")
|
@app.get("/healthz")
|
||||||
async def healthz() -> JSONResponse:
|
async def healthz() -> JSONResponse:
|
||||||
return JSONResponse({"status": "ok", "version": __version__, **registry.stats()})
|
return JSONResponse({"status": "ok", "version": __version__, **registry.stats()})
|
||||||
|
|||||||
+46
-3
@@ -44,6 +44,8 @@ class Hello(_Inbound):
|
|||||||
role: Role = "viewer"
|
role: Role = "viewer"
|
||||||
name: str = ""
|
name: str = ""
|
||||||
device: str = ""
|
device: str = ""
|
||||||
|
#: true when this peer is a wall display that obeys the shared layout.
|
||||||
|
kiosk: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Publish(_Inbound):
|
class Publish(_Inbound):
|
||||||
@@ -73,6 +75,18 @@ class Unsubscribe(_Inbound):
|
|||||||
stream_id: str
|
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):
|
class Signal(_Inbound):
|
||||||
"""An opaque WebRTC blob (SDP or ICE candidate) addressed to one peer."""
|
"""An opaque WebRTC blob (SDP or ICE candidate) addressed to one peer."""
|
||||||
|
|
||||||
@@ -86,7 +100,7 @@ class Ping(_Inbound):
|
|||||||
|
|
||||||
|
|
||||||
ClientMessage = Annotated[
|
ClientMessage = Annotated[
|
||||||
Hello | Publish | Unpublish | Subscribe | Unsubscribe | Signal | Ping,
|
Hello | Publish | Unpublish | Subscribe | Unsubscribe | Arrange | Signal | Ping,
|
||||||
Field(discriminator="type"),
|
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]:
|
def welcome(
|
||||||
return {"type": "welcome", "peerId": peer_id, "role": role, "streams": streams}
|
*,
|
||||||
|
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]:
|
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
|
* Every teardown path (explicit, disconnect, replaced stream) emits the same
|
||||||
``peer_left`` / ``stream_removed`` notifications, so browsers never leak a
|
``peer_left`` / ``stream_removed`` notifications, so browsers never leak a
|
||||||
``RTCPeerConnection``.
|
``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
|
from __future__ import annotations
|
||||||
@@ -25,6 +27,7 @@ from typing import Any
|
|||||||
from .protocol import (
|
from .protocol import (
|
||||||
MAX_LABEL_LEN,
|
MAX_LABEL_LEN,
|
||||||
MAX_NAME_LEN,
|
MAX_NAME_LEN,
|
||||||
|
layout,
|
||||||
peer_joined,
|
peer_joined,
|
||||||
peer_left,
|
peer_left,
|
||||||
relayed_signal,
|
relayed_signal,
|
||||||
@@ -37,6 +40,13 @@ from .protocol import (
|
|||||||
Envelope = tuple[str, dict[str, Any]]
|
Envelope = tuple[str, dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class _Unset:
|
||||||
|
"""Marker: the caller expressed no opinion, keep the current value."""
|
||||||
|
|
||||||
|
|
||||||
|
UNSET = _Unset()
|
||||||
|
|
||||||
|
|
||||||
class RegistryError(Exception):
|
class RegistryError(Exception):
|
||||||
"""A policy violation. Carries a stable ``code`` for the client."""
|
"""A policy violation. Carries a stable ``code`` for the client."""
|
||||||
|
|
||||||
@@ -53,9 +63,19 @@ class Peer:
|
|||||||
role: str
|
role: str
|
||||||
name: str
|
name: str
|
||||||
device: str = ""
|
device: str = ""
|
||||||
|
kiosk: bool = False
|
||||||
joined_at: float = field(default_factory=time.time)
|
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)
|
@dataclass(slots=True)
|
||||||
class Stream:
|
class Stream:
|
||||||
"""One live capture, published by exactly one sharer peer."""
|
"""One live capture, published by exactly one sharer peer."""
|
||||||
@@ -103,6 +123,7 @@ class Registry:
|
|||||||
self._max_streams = max_streams
|
self._max_streams = max_streams
|
||||||
self.peers: dict[str, Peer] = {}
|
self.peers: dict[str, Peer] = {}
|
||||||
self.streams: dict[str, Stream] = {}
|
self.streams: dict[str, Stream] = {}
|
||||||
|
self.layout = Layout()
|
||||||
#: stream_id -> set of viewer peer ids that currently receive it
|
#: stream_id -> set of viewer peer ids that currently receive it
|
||||||
self._subscriptions_by_viewer: dict[str, set[str]] = {}
|
self._subscriptions_by_viewer: dict[str, set[str]] = {}
|
||||||
|
|
||||||
@@ -132,7 +153,14 @@ class Registry:
|
|||||||
|
|
||||||
# ------------------------------------------------------------ operations
|
# ------------------------------------------------------------ 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)``."""
|
"""Register a freshly connected socket. Returns ``(peer_id, outbox)``."""
|
||||||
if role not in ("sharer", "viewer"):
|
if role not in ("sharer", "viewer"):
|
||||||
raise RegistryError("bad_role", f"Unknown role {role!r}.", fatal=True)
|
raise RegistryError("bad_role", f"Unknown role {role!r}.", fatal=True)
|
||||||
@@ -142,12 +170,26 @@ class Registry:
|
|||||||
role=role,
|
role=role,
|
||||||
name=_clean(name, MAX_NAME_LEN) or ("This device" if role == "sharer" else "Viewer"),
|
name=_clean(name, MAX_NAME_LEN) or ("This device" if role == "sharer" else "Viewer"),
|
||||||
device=_clean(device, MAX_NAME_LEN),
|
device=_clean(device, MAX_NAME_LEN),
|
||||||
|
kiosk=kiosk,
|
||||||
)
|
)
|
||||||
self.peers[peer_id] = peer
|
self.peers[peer_id] = peer
|
||||||
self._subscriptions_by_viewer[peer_id] = set()
|
self._subscriptions_by_viewer[peer_id] = set()
|
||||||
outbox: list[Envelope] = [
|
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
|
return peer_id, outbox
|
||||||
|
|
||||||
def leave(self, peer_id: str) -> list[Envelope]:
|
def leave(self, peer_id: str) -> list[Envelope]:
|
||||||
@@ -167,6 +209,10 @@ class Registry:
|
|||||||
stream.viewers.discard(peer_id)
|
stream.viewers.discard(peer_id)
|
||||||
outbox.append((stream.sharer_id, peer_left(stream_id=stream.id, viewer_id=peer_id)))
|
outbox.append((stream.sharer_id, peer_left(stream_id=stream.id, viewer_id=peer_id)))
|
||||||
outbox.extend(self._broadcast_update(stream))
|
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
|
return outbox
|
||||||
|
|
||||||
def rename(self, peer_id: str, *, name: str = "", device: str = "") -> list[Envelope]:
|
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."""
|
"""The stream ids this viewer is currently receiving."""
|
||||||
return set(self._subscriptions_by_viewer.get(viewer_id, set()))
|
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
|
# -------------------------------------------------------------- read-only
|
||||||
|
|
||||||
def roster(self) -> list[dict[str, Any]]:
|
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
|
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
|
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)
|
self.streams.pop(stream.id, None)
|
||||||
for subs in self._subscriptions_by_viewer.values():
|
for subs in self._subscriptions_by_viewer.values():
|
||||||
subs.discard(stream.id)
|
subs.discard(stream.id)
|
||||||
message = stream_removed(stream_id=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]:
|
def _broadcast_add(self, stream: Stream) -> list[Envelope]:
|
||||||
name = self.peers[stream.sharer_id].name
|
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:
|
with gated(client_address="203.0.113.9") as client:
|
||||||
assert client.get("/watch").status_code == 200
|
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):
|
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
|
||||||
with gated(self.ALLOW) as client:
|
with gated(self.ALLOW) as client:
|
||||||
assert client.get(path).status_code == 403
|
assert client.get(path).status_code == 403
|
||||||
@@ -502,6 +504,18 @@ class TestAddressGate:
|
|||||||
response = client.get("/api/config", headers=headers)
|
response = client.get("/api/config", headers=headers)
|
||||||
assert response.headers["cache-control"] == "no-store"
|
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):
|
def test_the_socket_closes_before_it_accepts(self):
|
||||||
with (
|
with (
|
||||||
gated(self.ALLOW) as client,
|
gated(self.ALLOW) as client,
|
||||||
@@ -562,6 +576,17 @@ class TestJsonApi:
|
|||||||
def test_healthz(self, client):
|
def test_healthz(self, client):
|
||||||
assert client.get("/healthz").json()["status"] == "ok"
|
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:
|
class TestSignalingSocket:
|
||||||
def test_hello_must_come_first(self, client):
|
def test_hello_must_come_first(self, client):
|
||||||
@@ -816,3 +841,164 @@ class TestSignalingSocket:
|
|||||||
assert wait_for(viewer, "error")["code"] == "not_sharer"
|
assert wait_for(viewer, "error")["code"] == "not_sharer"
|
||||||
assert client.get("/api/streams").json()["stats"]["mirrors"] == 1
|
assert client.get("/api/streams").json()["stats"]["mirrors"] == 1
|
||||||
assert sharer_id and viewer
|
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):
|
with pytest.raises(ValidationError):
|
||||||
protocol.parse_client_message({"type": "subscribe"})
|
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):
|
def test_dimensions_are_bounded(self):
|
||||||
for bad in (-1, 16385, "wide"):
|
for bad in (-1, 16385, "wide"):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
@@ -93,9 +136,35 @@ class TestOutbound:
|
|||||||
}
|
}
|
||||||
assert dto["displaySurface"] == "window"
|
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):
|
def test_every_server_message_advertises_its_type(self):
|
||||||
messages = [
|
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_added(stream={"id": "p1"}),
|
||||||
protocol.stream_updated(stream={"id": "p1"}),
|
protocol.stream_updated(stream={"id": "p1"}),
|
||||||
protocol.stream_removed(stream_id="p1"),
|
protocol.stream_removed(stream_id="p1"),
|
||||||
@@ -107,6 +176,7 @@ class TestOutbound:
|
|||||||
]
|
]
|
||||||
assert [m["type"] for m in messages] == [
|
assert [m["type"] for m in messages] == [
|
||||||
"welcome",
|
"welcome",
|
||||||
|
"layout",
|
||||||
"stream_added",
|
"stream_added",
|
||||||
"stream_updated",
|
"stream_updated",
|
||||||
"stream_removed",
|
"stream_removed",
|
||||||
|
|||||||
@@ -315,6 +315,160 @@ class TestTeardown:
|
|||||||
registry.signal(v1, sharer, {})
|
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:
|
class TestRenameAndStats:
|
||||||
def test_rename_refreshes_the_mirrors_that_show_it(self, mesh):
|
def test_rename_refreshes_the_mirrors_that_show_it(self, mesh):
|
||||||
sharer, v1, _v2, registry = mesh
|
sharer, v1, _v2, registry = mesh
|
||||||
|
|||||||
Reference in New Issue
Block a user