Files
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
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`.
2026-10-08 05:17:21 -04:00

195 lines
5.9 KiB
Python

"""Unit: the Crockford code alphabet, minting and input normalisation (A15).
`random.Random` seeds are deliberate: the minting contract is *determinism
given a seed*, so the suite re-plays the same draws. The production path
(`rng=None`) is exercised for its shape, not its bytes.
"""
from __future__ import annotations
import random
import pytest
from undercooked.server.codes import (
CODE_LEN,
CROCKFORD,
MAX_MINT_RETRIES,
CodeSpaceFull,
new_code,
normalize_code,
)
pytestmark = pytest.mark.unit
EXPECTED_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
# ------------------------------------------------------------------------ alphabet
def test_the_alphabet_is_crockford_base32_excluding_ilo_u() -> None:
assert len(CROCKFORD) == 32
assert set(CROCKFORD) == set(EXPECTED_ALPHABET)
assert len(set(CROCKFORD)) == 32 # no duplicate symbols
for excluded in "ILOU":
assert excluded not in CROCKFORD, f"{excluded} must be excluded"
for digit in "0123456789":
assert digit in CROCKFORD
def test_codes_are_five_characters_long() -> None:
assert CODE_LEN == 5
# ------------------------------------------------------------------------ minting
def test_twenty_thousand_minted_codes_are_unique_and_in_the_alphabet() -> None:
taken: set[str] = set()
rng = random.Random(0x5EED_0401) # noqa: S311 - seeded, deliberate test draws
for _ in range(20_000):
code = new_code(taken, rng)
taken.add(code)
assert len(taken) == 20_000
for code in taken:
assert len(code) == CODE_LEN
assert set(code) <= set(CROCKFORD)
sample = min(taken)
assert "I" not in sample and "L" not in sample and "O" not in sample and "U" not in sample
def test_new_code_retries_a_collision_until_the_draw_is_free() -> None:
rng = random.Random(7) # noqa: S311 - seeded, deliberate test draws
first = new_code(set(), rng)
second = new_code({first}, rng)
assert second != first
assert second not in {first}
assert len(second) == CODE_LEN
def test_a_deterministic_seed_mints_a_deterministic_first_code() -> None:
"""The same seed must mint the same first code — the A4/A5 determinism
contract, applied to the minting draws."""
a = new_code(set(), random.Random(42)) # noqa: S311 - seeded, deliberate
b = new_code(set(), random.Random(42)) # noqa: S311 - seeded, deliberate
assert a == b
assert len(a) == CODE_LEN
assert set(a) <= set(CROCKFORD)
class _AlwaysTaken:
def __contains__(self, _code: str) -> bool:
return True
class _CountingRandom:
"""A `random.Random` lookalike that counts draws and always collides."""
def __init__(self) -> None:
self.choice_calls = 0
def choice(self, _sequence: str) -> str:
self.choice_calls += 1
return "A"
def test_a_full_space_raises_code_space_full_after_exactly_100_draws() -> None:
rng = _CountingRandom()
with pytest.raises(CodeSpaceFull):
new_code(_AlwaysTaken(), rng) # type: ignore[arg-type] - duck-typed lookalike
assert rng.choice_calls == MAX_MINT_RETRIES * CODE_LEN
def test_code_space_full_names_the_retries_in_its_message() -> None:
with pytest.raises(CodeSpaceFull, match="100 draws all collided"):
new_code(_AlwaysTaken())
def test_production_minting_without_an_rng_yields_a_legal_code() -> None:
code = new_code(set()) # secrets.choice path
assert len(code) == CODE_LEN
assert set(code) <= set(CROCKFORD)
# ------------------------------------------------------------------------ normalisation
@pytest.mark.parametrize(
("raw", "expected"),
[
("ABCDE", "ABCDE"),
("abcde", "ABCDE"),
("aBcDe", "ABCDE"),
("a b c d e", "ABCDE"),
("A B-C D-E", "ABCDE"),
(" AB\tCD E ", "ABCDE"),
("12345", "12345"),
("99999", "99999"),
("VWXYZ", "VWXYZ"),
],
)
def test_normalize_accepts_legal_codes_with_case_and_separators(
raw: str, expected: str
) -> None:
assert normalize_code(raw) == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("i2345", "12345"),
("I2345", "12345"),
("l2345", "12345"),
("L2345", "12345"),
("o2345", "02345"),
("O2345", "02345"),
("u2345", "V2345"),
("U2345", "V2345"),
],
)
def test_normalize_maps_every_excluded_confusable_to_its_intended_symbol(
raw: str, expected: str
) -> None:
assert normalize_code(raw) == expected
@pytest.mark.parametrize(
"raw",
[
"",
"A",
"AB",
"ABC", # too short
"ABCDEF",
"ABCDEFG", # too long
"AB12CD\n", # six characters after the newline is stripped
"ABC?E", # the question mark is not an alphabet character
"AB.CD", # the dot is not a separator: it stays, and it is invalid
"AB-CD", # the dash is stripped, leaving four characters
"I", # maps to `1`, which is still one character, not five
"U", # maps to `V`, still one character
"O", # maps to `0`, still one character
"L", # maps to `1`, still one character
"?????",
],
)
def test_normalize_rejects_wrong_lengths_and_invalid_characters(raw: str) -> None:
assert normalize_code(raw) is None
@pytest.mark.parametrize("raw", ["ABCDE\n", "ABCDE ", "ABC DE"])
def test_normalize_strips_whitespace_without_making_the_code_illegal(raw: str) -> None:
"""Whitespace is stripped, not fatal: `ABCDE\n` and `ABCDE ` are still
five-character codes, and `ABC DE` normalises to `ABCDE`."""
assert normalize_code(raw) == "ABCDE"
def test_normalize_is_a_pure_format_check_never_a_registry_query() -> None:
"""The US-02 criterion-4 contract: the result depends on the input
alone, so a caller cannot tell "malformed" from "no such room"."""
for _ in range(5):
assert normalize_code("i2345") == "12345"
assert normalize_code("nope!") is None
assert normalize_code("ABCDE") == normalize_code("ABCDE")