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,380 @@
|
||||
"""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": {}}
|
||||
Reference in New Issue
Block a user