phase: 06_realtime_websocket_protocol
All green. Final verification complete — writing the report.
**Final pass report — phase 06 `realtime_websocket_protocol`**
- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched
Next pending phase: `07_canvas_client_and_hud`.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""Unit: `docs/PROTOCOL.md` and `protocol.py` cannot drift (phase 06, task 01).
|
||||
|
||||
The phase's completion criteria demand that "every field in code appears in
|
||||
the doc" — and, in the other direction, that the doc never names a field
|
||||
that does not exist. This test is the guard: for **every** message model
|
||||
in both directions (the nine client→server, the eight server→client, and
|
||||
the six wire objects they nest) it extracts the field names from the doc's
|
||||
``### ModelName`` table and compares them with the model's ``model_fields``
|
||||
— set-equal, in both directions. It also keeps the doc's error-code and
|
||||
close-code tables equal to the `ErrorCode` / `CloseCode` constants.
|
||||
|
||||
Doc convention (what this test relies on): every ``###`` heading in
|
||||
``docs/PROTOCOL.md`` is exactly one model section (no other ``###``
|
||||
headings — use ``##`` for prose sections), and each section holds one
|
||||
markdown table whose first column is the field name. So: an added field
|
||||
that is not documented fails here, a documented field that is not in the
|
||||
model fails here, a renamed model fails here, and a new error code without
|
||||
a doc row fails here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from undercooked.server.protocol import (
|
||||
CloseCode,
|
||||
CountdownMsg,
|
||||
EmoteMsg,
|
||||
ErrMsg,
|
||||
ErrorCode,
|
||||
EventWire,
|
||||
EvMsg,
|
||||
HelloMsg,
|
||||
InputMsg,
|
||||
LobbyMsg,
|
||||
NextLevelMsg,
|
||||
PingMsg,
|
||||
PlayerScoreWire,
|
||||
PongMsg,
|
||||
ReadyMsg,
|
||||
RoundMsg,
|
||||
SeatWire,
|
||||
SetLevelMsg,
|
||||
SnapMsg,
|
||||
SpectatorWire,
|
||||
StartMsg,
|
||||
ToLobbyMsg,
|
||||
Vec2Msg,
|
||||
WelcomeMsg,
|
||||
YouWire,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
DOCS = Path(__file__).resolve().parents[2] / "docs" / "PROTOCOL.md"
|
||||
|
||||
#: Every message model in both directions, plus the wire objects they nest.
|
||||
C_TO_S_MODELS: dict[str, type[BaseModel]] = {
|
||||
"HelloMsg": HelloMsg,
|
||||
"ReadyMsg": ReadyMsg,
|
||||
"SetLevelMsg": SetLevelMsg,
|
||||
"StartMsg": StartMsg,
|
||||
"InputMsg": InputMsg,
|
||||
"EmoteMsg": EmoteMsg,
|
||||
"ToLobbyMsg": ToLobbyMsg,
|
||||
"NextLevelMsg": NextLevelMsg,
|
||||
"PingMsg": PingMsg,
|
||||
}
|
||||
S_TO_C_MODELS: dict[str, type[BaseModel]] = {
|
||||
"WelcomeMsg": WelcomeMsg,
|
||||
"LobbyMsg": LobbyMsg,
|
||||
"CountdownMsg": CountdownMsg,
|
||||
"SnapMsg": SnapMsg,
|
||||
"RoundMsg": RoundMsg,
|
||||
"EvMsg": EvMsg,
|
||||
"ErrMsg": ErrMsg,
|
||||
"PongMsg": PongMsg,
|
||||
}
|
||||
WIRE_MODELS: dict[str, type[BaseModel]] = {
|
||||
"Vec2Msg": Vec2Msg,
|
||||
"YouWire": YouWire,
|
||||
"SeatWire": SeatWire,
|
||||
"SpectatorWire": SpectatorWire,
|
||||
"EventWire": EventWire,
|
||||
"PlayerScoreWire": PlayerScoreWire,
|
||||
}
|
||||
ALL_MODELS: dict[str, type[BaseModel]] = {
|
||||
**C_TO_S_MODELS,
|
||||
**S_TO_C_MODELS,
|
||||
**WIRE_MODELS,
|
||||
}
|
||||
|
||||
SECTION_HEADING = re.compile(r"^###\s+(\S+)\s*$")
|
||||
|
||||
|
||||
def _clean(cell: str) -> str:
|
||||
return cell.strip().strip("`").strip()
|
||||
|
||||
|
||||
def extract_model_sections(text: str) -> dict[str, list[str]]:
|
||||
"""Field names (first table column) per ``###`` section.
|
||||
|
||||
A section runs from its ``###`` heading to the next heading of any
|
||||
level; inside it, only table rows count, and the header row (first cell
|
||||
``Field``) and the separator row (dashes/colons) are skipped.
|
||||
"""
|
||||
sections: dict[str, list[str]] = {}
|
||||
current: str | None = None
|
||||
for line in text.splitlines():
|
||||
heading = SECTION_HEADING.match(line)
|
||||
if heading is not None:
|
||||
name = heading.group(1)
|
||||
sections.setdefault(name, [])
|
||||
current = name
|
||||
continue
|
||||
if line.lstrip().startswith("#"):
|
||||
current = None # any other heading ends the section
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
cells = [_clean(cell) for cell in stripped.strip("|").split("|")]
|
||||
if not cells or cells[0] in {"Field", ""}:
|
||||
continue
|
||||
if set(cells[0]) <= {"-", ":"}:
|
||||
continue
|
||||
sections[current].append(cells[0])
|
||||
return sections
|
||||
|
||||
|
||||
def extract_section_first_cells(text: str, heading: str) -> list[str]:
|
||||
"""First table column of one ``##`` section (the code tables)."""
|
||||
lines = text.splitlines()
|
||||
try:
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == heading)
|
||||
except StopIteration:
|
||||
pytest.fail(f"{heading!r} section not found in docs/PROTOCOL.md")
|
||||
cells: list[str] = []
|
||||
for line in lines[start + 1 :]:
|
||||
if line.lstrip().startswith("#"):
|
||||
break
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
first = _clean(stripped.strip("|").split("|")[0])
|
||||
if first in {"Code", ""} or set(first) <= {"-", ":"}:
|
||||
continue
|
||||
cells.append(first)
|
||||
return cells
|
||||
|
||||
|
||||
def test_the_doc_exists() -> None:
|
||||
assert DOCS.is_file(), "docs/PROTOCOL.md is the phase deliverable — it must exist"
|
||||
|
||||
|
||||
def test_documented_sections_are_exactly_the_model_registry() -> None:
|
||||
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
|
||||
missing = set(ALL_MODELS) - set(sections)
|
||||
extra = set(sections) - set(ALL_MODELS)
|
||||
assert not missing, f"models with no ### section in the doc: {sorted(missing)}"
|
||||
assert not extra, (
|
||||
f"### sections that are not model sections (doc convention): {sorted(extra)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(ALL_MODELS))
|
||||
def test_the_doc_fields_match_the_model_fields_in_both_directions(name: str) -> None:
|
||||
model = ALL_MODELS[name]
|
||||
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
|
||||
assert name in sections, f"no ### {name} section in docs/PROTOCOL.md"
|
||||
documented = set(sections[name])
|
||||
implemented = set(model.model_fields)
|
||||
undocumented = implemented - documented
|
||||
ghost = documented - implemented
|
||||
assert not undocumented, (
|
||||
f"{name}: fields in the model but missing from the doc: {sorted(undocumented)}"
|
||||
)
|
||||
assert not ghost, (
|
||||
f"{name}: fields documented but not in the model: {sorted(ghost)}"
|
||||
)
|
||||
|
||||
|
||||
def test_both_directions_are_covered_by_the_registry() -> None:
|
||||
assert len(C_TO_S_MODELS) == 9
|
||||
assert len(S_TO_C_MODELS) == 8
|
||||
assert len(WIRE_MODELS) == 6
|
||||
assert set(ALL_MODELS) == set(C_TO_S_MODELS) | set(S_TO_C_MODELS) | set(WIRE_MODELS)
|
||||
|
||||
|
||||
def test_the_error_code_table_matches_the_constants() -> None:
|
||||
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Error codes")
|
||||
assert len(cells) == len(set(cells)), "duplicate rows in the error-code table"
|
||||
documented = set(cells)
|
||||
implemented = {
|
||||
ErrorCode.UNKNOWN_TYPE,
|
||||
ErrorCode.BAD_JSON,
|
||||
ErrorCode.BAD_FIELD,
|
||||
ErrorCode.TOO_BIG,
|
||||
ErrorCode.RATE_LIMITED,
|
||||
ErrorCode.NOT_SEATED,
|
||||
ErrorCode.NOT_HOST,
|
||||
ErrorCode.LEVEL_LOCKED,
|
||||
ErrorCode.WRONG_STATE,
|
||||
ErrorCode.ROOM_FULL,
|
||||
ErrorCode.ROOM_GONE,
|
||||
ErrorCode.PROTOCOL_MISMATCH,
|
||||
ErrorCode.EVICTED,
|
||||
ErrorCode.INTERNAL,
|
||||
}
|
||||
assert documented == implemented, (
|
||||
f"error-code table drift: missing {sorted(implemented - documented)}, "
|
||||
f"ghosts {sorted(documented - implemented)}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_close_code_table_matches_the_constants() -> None:
|
||||
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Close codes")
|
||||
documented = {int(cell) for cell in cells}
|
||||
implemented = {
|
||||
CloseCode.NORMAL,
|
||||
CloseCode.PROTOCOL_MISMATCH,
|
||||
CloseCode.ROOM_GONE,
|
||||
CloseCode.ROOM_FULL,
|
||||
CloseCode.EVICTED,
|
||||
CloseCode.ABUSE,
|
||||
}
|
||||
assert documented == implemented, (
|
||||
f"close-code table drift: missing {sorted(implemented - documented)}, "
|
||||
f"ghosts {sorted(documented - implemented)}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_whole_contract_round_trips_through_the_models() -> None:
|
||||
"""Every model here is a real Pydantic model with a `type` discriminator
|
||||
(C→S) or a defaulted one (S→C) — a smoke over the registry itself."""
|
||||
for name, model in ALL_MODELS.items():
|
||||
assert isinstance(model, type)
|
||||
assert issubclass(model, BaseModel)
|
||||
fields = set(model.model_fields)
|
||||
if name in C_TO_S_MODELS:
|
||||
assert "type" in fields, f"{name} has no discriminator"
|
||||
Reference in New Issue
Block a user