"""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")