Files
undercooked/tests/unit/test_protocol_parse.py
T
ducoterra f26849c1f3 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`.
2026-10-09 11:13:25 -04:00

381 lines
13 KiB
Python

"""Unit: the wire contract's accept/reject surface (phase 06, task 01).
`parse_client_message` is the whole *shape* check — JSON decode,
discriminator dispatch, per-model field validation, the `hello` `p` gate —
and it must never raise: every bad frame comes back as a `ProtocolError`
with a stable `code`. This table pins the whole surface: the nine message
types parse (including the boundary `move` magnitudes), and every documented
bad shape yields exactly its documented `code` (`bad_json`, `unknown_type`,
`bad_field`, `protocol_mismatch`).
Also pinned here: the `ErrorCode` / `CloseCode` constant sets (a new code
must be documented before it exists), `ProtocolError.to_err`, and the
server→client models' wire shape (every model encodes with its exact
`model_fields` and the right `type` / `p`).
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel
from undercooked.server.protocol import (
MIN_SUPPORTED_VERSION,
PROTOCOL_VERSION,
ClientMessage,
CloseCode,
CountdownMsg,
EmoteMsg,
ErrMsg,
ErrorCode,
EventWire,
EvMsg,
HelloMsg,
InputMsg,
LobbyMsg,
NextLevelMsg,
PingMsg,
PlayerScoreWire,
PongMsg,
ProtocolError,
ReadyMsg,
RoundMsg,
SeatWire,
SetLevelMsg,
SnapMsg,
SpectatorWire,
StartMsg,
ToLobbyMsg,
Vec2Msg,
WelcomeMsg,
YouWire,
parse_client_message,
wire_dict,
)
pytestmark = pytest.mark.unit
#: A fixture session id (named to keep `S105`/`S106` quiet).
SESSION_ID = "tok-1"
def _error_of(result: ClientMessage | ProtocolError) -> ProtocolError | None:
return result if isinstance(result, ProtocolError) else None
# ------------------------------------------------------------------- accept
ACCEPT_CASES: list[tuple[bytes, type[BaseModel]]] = [
(b'{"type":"hello","p":1}', HelloMsg),
(b'{"type":"hello","p":1,"token":"tok-1234","name":"Ada"}', HelloMsg),
(b'{"type":"ready"}', ReadyMsg),
(b'{"type":"set_level","level":"01_first_kitchen"}', SetLevelMsg),
(b'{"type":"start"}', StartMsg),
(b'{"type":"input","seq":0,"move":{"x":0.0,"y":0.0},"act":false,"face":null}', InputMsg),
(b'{"type":"input","seq":7,"move":{"x":0.6,"y":0.8},"act":true,"face":null}', InputMsg),
(b'{"type":"input","seq":8,"move":{"x":-1.0,"y":0.0},"act":false,"face":"up"}', InputMsg),
(
b'{"type":"input","seq":9,"move":{"x":0.7,"y":0.7},"act":false,'
b'"face":"right"}',
InputMsg,
),
(b'{"type":"emote","id":"wave"}', EmoteMsg),
(b'{"type":"to_lobby"}', ToLobbyMsg),
(b'{"type":"next_level"}', NextLevelMsg),
(b'{"type":"ping","t":0}', PingMsg),
(b'{"type":"ping","t":1234567}', PingMsg),
]
@pytest.mark.parametrize(("raw", "expected"), ACCEPT_CASES)
def test_every_message_type_parses(raw: bytes, expected: type[BaseModel]) -> None:
result = parse_client_message(raw)
assert not isinstance(result, ProtocolError)
assert type(result) is expected
def test_anonymous_hello_parses_and_carries_no_token() -> None:
result = parse_client_message(b'{"type":"hello","p":1}')
assert isinstance(result, HelloMsg)
assert result.token is None
assert result.name is None
def test_a_boundary_move_magnitude_is_accepted() -> None:
# hypot(0.6, 0.8) is 1.0 exactly: the boundary is inclusive.
result = parse_client_message(
b'{"type":"input","seq":1,"move":{"x":0.6,"y":0.8},"act":false,"face":null}'
)
assert isinstance(result, InputMsg)
assert isinstance(result.move, Vec2Msg)
# ------------------------------------------------------------------- reject
REJECT_CASES: list[tuple[bytes, str]] = [
# bad_json — not JSON at all.
(b"{", "bad_json"),
(b"", "bad_json"),
(b"[1, 2]", "bad_field"),
(b'"just a string"', "bad_field"),
(b"123.5", "bad_field"),
# bad_field — valid JSON, wrong shape.
(b"null", "bad_field"),
(b"{}", "bad_field"),
(b'{"type":5}', "bad_field"),
(b'{"type":"hello"}', "bad_field"), # p missing
(b'{"type":"hello","p":"1"}', "bad_field"), # p is a string
(b'{"type":"hello","p":3.5}', "bad_field"), # p is not an int
(b'{"type":"hello","p":1,"token":""}', "bad_field"), # token too short
(b'{"type":"hello","p":1,"token":"' + b"a" * 129 + b'"}', "bad_field"),
(b'{"type":"hello","p":1,"name":"' + b"x" * 65 + b'"}', "bad_field"),
(b'{"type":"input","seq":-1,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3.5,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":"3","move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":"true","face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":1,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":50,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":1.01,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":1,"y":1},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":"1","y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":NaN,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":Infinity},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,"face":"north"}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false}', "bad_field"), # face missing
(
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
b'"face":null,"extra":1}',
"bad_field",
),
(
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
b'"face":null,"pos":{"x":9}}',
"bad_field",
),
(b'{"type":"ping","t":-1}', "bad_field"),
(b'{"type":"ping","t":"1"}', "bad_field"),
(b'{"type":"emote","id":""}', "bad_field"),
(b'{"type":"set_level","level":""}', "bad_field"),
(b'{"type":"set_level"}', "bad_field"),
# unknown_type — a `type` the server does not know.
(b'{"type":"bogus"}', "unknown_type"),
(b'{"type":"HELLO"}', "unknown_type"), # discriminators are case-sensitive
(b'{"type":"position","x":1}', "unknown_type"),
# protocol_mismatch — `p` outside [MIN_SUPPORTED_VERSION, PROTOCOL_VERSION].
(b'{"type":"hello","p":0}', "protocol_mismatch"),
(b'{"type":"hello","p":2}', "protocol_mismatch"),
(b'{"type":"hello","p":99}', "protocol_mismatch"),
]
@pytest.mark.parametrize(("raw", "expected_code"), REJECT_CASES)
def test_bad_frames_yield_their_documented_code(raw: bytes, expected_code: str) -> None:
result = parse_client_message(raw)
error = _error_of(result)
assert error is not None, f"{raw!r} parsed into a message, expected {expected_code}"
assert error.code == expected_code
def test_parse_never_raises_over_the_whole_table() -> None:
for raw, _ in ACCEPT_CASES + REJECT_CASES:
try:
parse_client_message(raw)
except Exception as exc:
pytest.fail(f"parse_client_message raised {exc!r} for {raw!r}")
def test_parse_of_a_valid_ping_is_not_an_error() -> None:
result = parse_client_message(b'{"type":"ping","t":1}')
assert not isinstance(result, ProtocolError)
def test_the_task_criterion_truncated_frame_is_bad_json() -> None:
error = _error_of(parse_client_message(b"{"))
assert error is not None
assert error.code == "bad_json"
def test_unknown_type_error_names_the_offending_type() -> None:
error = _error_of(parse_client_message(b'{"type":"teleport"}'))
assert error is not None
assert "teleport" in error.detail
# ------------------------------------------------------------- error model
def test_protocol_error_to_err_builds_the_wire_frame() -> None:
error = ProtocolError(code="bad_field", detail="x: input should be a valid number")
err = error.to_err()
assert err.type == "err"
assert err.code == "bad_field"
assert err.msg == "x: input should be a valid number"
assert err.seq is None
with_seq = error.to_err(seq=42)
assert with_seq.seq == 42
def test_protocol_error_without_detail_falls_back_to_the_code() -> None:
err = ProtocolError(code="wrong_state").to_err()
assert err.msg == "wrong_state"
def test_the_error_code_set_is_pinned() -> None:
expected = {
"unknown_type",
"bad_json",
"bad_field",
"too_big",
"rate_limited",
"not_seated",
"not_host",
"level_locked",
"wrong_state",
"room_full",
"room_gone",
"protocol_mismatch",
"evicted",
"internal",
}
codes = {
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 codes == expected
def test_the_close_code_set_is_pinned() -> None:
assert CloseCode.NORMAL == 1000
assert CloseCode.PROTOCOL_MISMATCH == 4400
assert CloseCode.ROOM_GONE == 4401
assert CloseCode.ROOM_FULL == 4402
assert CloseCode.EVICTED == 4403
assert CloseCode.ABUSE == 4404
# ------------------------------------------------------------ server → client
def test_the_protocol_version_constants() -> None:
assert PROTOCOL_VERSION == 1
assert MIN_SUPPORTED_VERSION == 1
def test_welcome_encodes_its_exact_fields() -> None:
seats = [
SeatWire(index=0, name="Ada", color="#e5484d", ready=True, host=True, connected=True)
]
msg = WelcomeMsg(
you=YouWire(token=SESSION_ID, seat=0, host=True),
room="ABCDE",
state="lobby",
level="01_first_kitchen",
seats=seats,
)
dumped = wire_dict(msg)
assert set(dumped) == {
"type",
"p",
"you",
"room",
"state",
"level",
"seats",
"rejoined",
}
assert dumped["type"] == "welcome"
assert dumped["p"] == PROTOCOL_VERSION
assert dumped["you"] == {"token": SESSION_ID, "seat": 0, "host": True}
# A fresh join (the default) is not a rejoin (task 05: `rejoined`
# is `true` only when a `hello` reclaims a disconnected seat).
assert dumped["rejoined"] is False
def test_lobby_encodes_its_exact_fields() -> None:
msg = LobbyMsg(
code="ABCDE",
state="lobby",
level=None,
host_seat=0,
can_start=False,
seats=[],
spectators=[SpectatorWire(name="Mallory", color="#3b9eff")],
unlocked=1,
round_no=0,
)
dumped = wire_dict(msg)
assert set(dumped) == {
"type", "p", "code", "state", "level", "host_seat", "can_start",
"seats", "spectators", "unlocked", "round_no",
}
assert dumped["type"] == "lobby"
def test_countdown_snap_round_ev_pong_encode_their_fields() -> None:
countdown = wire_dict(CountdownMsg(n=3))
assert set(countdown) == {"type", "p", "n"} and countdown["n"] == 3
snap = wire_dict(SnapMsg(raw={"t": 4821, "p": []}))
assert set(snap) == {"type", "p", "raw"}
raw_body = snap["raw"]
assert isinstance(raw_body, dict) and raw_body["t"] == 4821
round_ = wire_dict(
RoundMsg(
result="won",
score=140,
target=140,
stars=3,
time_left=60.0,
counters={"served": 5, "missed": 0, "wasted": 0},
longest_streak=5,
players=[
PlayerScoreWire(
id="tok-1", name="Ada", score=140, dishes_served=5, missed_contrib=0
)
],
level_id="01_first_kitchen",
seed=1234,
ticks=1200,
unlocked="02_double_shift",
)
)
assert set(round_) == {
"type", "p", "result", "score", "target", "stars", "time_left", "counters",
"longest_streak", "players", "level_id", "seed", "ticks", "unlocked",
}
ev = wire_dict(EvMsg(events=[EventWire(kind="served", x=9.0, y=1.0, payload={"i": 17})]))
assert set(ev) == {"type", "p", "events"}
assert ev["events"] == [{"kind": "served", "x": 9.0, "y": 1.0, "payload": {"i": 17}}]
pong = wire_dict(PongMsg(t=42, now=12.5))
assert set(pong) == {"type", "p", "t", "now"} and pong["t"] == 42
def test_err_msg_encodes_with_an_optional_seq() -> None:
dumped = wire_dict(ErrMsg(code="bad_field", msg="no", seq=7))
assert set(dumped) == {"type", "p", "code", "msg", "seq"} and dumped["seq"] == 7
anonymous = wire_dict(ErrMsg(code="wrong_state", msg="no"))
assert anonymous["seq"] is None
def test_event_wire_payload_defaults_to_empty() -> None:
event = EventWire(kind="chopped", x=3.0, y=2.0)
assert event.payload == {}
assert wire_dict(event) == {"kind": "chopped", "x": 3.0, "y": 2.0, "payload": {}}