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`.
195 lines
5.9 KiB
Python
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")
|