"""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": {}}