All green. Final report:
**Phase 05 final verification pass — complete, 100% green.**
**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions
**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed
**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed
**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).
**Next pending phase:** `06_realtime_websocket_protocol`.
104 lines
4.1 KiB
Python
104 lines
4.1 KiB
Python
"""Unit: the display-name boundary (phase 05, task 03; US-02 criterion 6).
|
|
|
|
`sanitize_name` is the one place a player-typed name becomes a stored name
|
|
(`server/seats.py`): strip Unicode control and format characters (zero-width
|
|
marks, RTL overrides, soft hyphen) and `<>"'`, collapse runs of whitespace,
|
|
trim, cap at 16; an empty result falls back to `Cook #{seat_index + 1}`.
|
|
Names are non-unique by design and are rendered by the client as text
|
|
(`textContent`, never HTML) — this table pins the shape of that boundary:
|
|
the output is always `len <= 16`, never contains `<`, `>` or `"`, and an
|
|
empty name names the seat.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from undercooked.server.rooms import sanitize_name as rooms_sanitize
|
|
from undercooked.server.seats import NAME_MAX_LEN, sanitize_name
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def test_the_table_of_raw_to_stored_names() -> None:
|
|
"""The boundary, case by case: fallbacks, control/format stripping,
|
|
whitespace collapsing, the 16-char cap, and the non-ASCII keeps."""
|
|
cases: list[tuple[str | None, int, str]] = [
|
|
# The fallback: no name (or an empty one) names the seat.
|
|
(None, 0, "Cook #1"),
|
|
(None, 2, "Cook #3"),
|
|
("", 0, "Cook #1"),
|
|
(" \t\n ", 1, "Cook #2"),
|
|
# Plain names pass (whitespace runs collapse to one space).
|
|
("Ada", 0, "Ada"),
|
|
("Tom Jerry", 0, "Tom Jerry"),
|
|
(" Hello World ", 0, "Hello World"),
|
|
("Ünïcödé ☂", 0, "Ünïcödé ☂"),
|
|
# Control characters (Cc) are stripped, not collapsed: the newline
|
|
# between two letters disappears.
|
|
("A\nB\x00C\x07D", 0, "ABCD"),
|
|
("\tMi la \t", 0, "Mi la"),
|
|
# Markup (the angle/quote pair) is stripped — the `textContent`
|
|
# backstop.
|
|
("O'Brien", 0, "OBrien"),
|
|
('"quoted"', 0, "quoted"),
|
|
("<script>alert(1)</script>", 0, "scriptalert(1)/s"),
|
|
# The cap: 16 chars, trimmed after the cut.
|
|
("A" * 40, 0, "A" * 16),
|
|
("x" * 1000, 0, "x" * 16),
|
|
("a" * 15 + " b", 0, "a" * 15),
|
|
("a b " * 20, 0, "a b a b a b a b"),
|
|
("a" * 15 + "b", 0, "a" * 15 + "b"),
|
|
# Unicode format characters (Cf) are stripped: zero-width, RTL
|
|
# override, soft hyphen — a zero-width-only name is a fallback.
|
|
("\u202bHELLO", 0, "HELLO"),
|
|
("\u200b\u200b\u200b", 0, "Cook #1"),
|
|
("\u200bA\u200bB", 0, "AB"),
|
|
("Mi\u00adna", 0, "Mina"),
|
|
# Emoji and accents are kept (So / Ll), and the cap counts them.
|
|
("🍳🍅🧅🐟", 0, "🍳🍅🧅🐟"),
|
|
("🍳" * 20, 0, "🍳" * 16),
|
|
("Café ☕ ", 0, "Café ☕"),
|
|
]
|
|
for raw, seat_index, expected in cases:
|
|
assert sanitize_name(raw, seat_index) == expected, (raw, seat_index)
|
|
assert NAME_MAX_LEN == 16
|
|
|
|
|
|
def test_the_output_is_bounded_and_markup_free() -> None:
|
|
"""The shape contract, property-style, over the nasty inputs: the
|
|
output is always `len <= 16` and never carries `<`, `>` or `"` —
|
|
whatever the input tried to do."""
|
|
nasty = [
|
|
"<script>alert(1)</script>",
|
|
"<<<>>>>>>>",
|
|
'"',
|
|
"A" * 1000,
|
|
(" " * 500) + "x" * 500,
|
|
"\u200b" * 100,
|
|
"\u202b\u202c\u202d\u202e\u2066\u2067" + "Name" * 100,
|
|
"Mi\u00adna\u00ad\u00ad" * 10,
|
|
"<'\"\\>\x00\x01\x1f\x7f",
|
|
"é" * 500,
|
|
]
|
|
for raw in nasty:
|
|
out = sanitize_name(raw, 3)
|
|
assert len(out) <= 16, (raw[:40], out)
|
|
assert not {"<", ">", '"'} & set(out), (raw[:40], out)
|
|
|
|
|
|
def test_the_fallback_names_the_seat() -> None:
|
|
"""An empty (or control-only) name is `Cook #{seat_index + 1}` — the
|
|
client can always address the cook."""
|
|
assert sanitize_name(None, 0) == "Cook #1"
|
|
assert sanitize_name(None, 7) == "Cook #8"
|
|
assert sanitize_name(" \x00\t ", 4) == "Cook #5"
|
|
assert sanitize_name("\u200b\u202b", 9) == "Cook #10"
|
|
|
|
|
|
def test_rooms_reexports_the_same_boundary() -> None:
|
|
"""`server/rooms.py` keeps its task-01 export: the supervisor's
|
|
boundary and the seats' boundary are the one function (imported, not
|
|
duplicated)."""
|
|
assert rooms_sanitize is sanitize_name
|