"""Unit: the display-name boundary (phase 05, task 03; US-02 criterion 6). `sanitize_name` is the one place a player-typed name becomes a stored name (`server/seats.py`): strip Unicode control and format characters (zero-width marks, RTL overrides, soft hyphen) and `<>"'`, collapse runs of whitespace, trim, cap at 16; an empty result falls back to `Cook #{seat_index + 1}`. Names are non-unique by design and are rendered by the client as text (`textContent`, never HTML) — this table pins the shape of that boundary: the output is always `len <= 16`, never contains `<`, `>` or `"`, and an empty name names the seat. """ from __future__ import annotations import pytest from undercooked.server.rooms import sanitize_name as rooms_sanitize from undercooked.server.seats import NAME_MAX_LEN, sanitize_name pytestmark = pytest.mark.unit def test_the_table_of_raw_to_stored_names() -> None: """The boundary, case by case: fallbacks, control/format stripping, whitespace collapsing, the 16-char cap, and the non-ASCII keeps.""" cases: list[tuple[str | None, int, str]] = [ # The fallback: no name (or an empty one) names the seat. (None, 0, "Cook #1"), (None, 2, "Cook #3"), ("", 0, "Cook #1"), (" \t\n ", 1, "Cook #2"), # Plain names pass (whitespace runs collapse to one space). ("Ada", 0, "Ada"), ("Tom Jerry", 0, "Tom Jerry"), (" Hello World ", 0, "Hello World"), ("Ünïcödé ☂", 0, "Ünïcödé ☂"), # Control characters (Cc) are stripped, not collapsed: the newline # between two letters disappears. ("A\nB\x00C\x07D", 0, "ABCD"), ("\tMi la \t", 0, "Mi la"), # Markup (the angle/quote pair) is stripped — the `textContent` # backstop. ("O'Brien", 0, "OBrien"), ('"quoted"', 0, "quoted"), ("", 0, "scriptalert(1)/s"), # The cap: 16 chars, trimmed after the cut. ("A" * 40, 0, "A" * 16), ("x" * 1000, 0, "x" * 16), ("a" * 15 + " b", 0, "a" * 15), ("a b " * 20, 0, "a b a b a b a b"), ("a" * 15 + "b", 0, "a" * 15 + "b"), # Unicode format characters (Cf) are stripped: zero-width, RTL # override, soft hyphen — a zero-width-only name is a fallback. ("\u202bHELLO", 0, "HELLO"), ("\u200b\u200b\u200b", 0, "Cook #1"), ("\u200bA\u200bB", 0, "AB"), ("Mi\u00adna", 0, "Mina"), # Emoji and accents are kept (So / Ll), and the cap counts them. ("🍳🍅🧅🐟", 0, "🍳🍅🧅🐟"), ("🍳" * 20, 0, "🍳" * 16), ("Café ☕ ", 0, "Café ☕"), ] for raw, seat_index, expected in cases: assert sanitize_name(raw, seat_index) == expected, (raw, seat_index) assert NAME_MAX_LEN == 16 def test_the_output_is_bounded_and_markup_free() -> None: """The shape contract, property-style, over the nasty inputs: the output is always `len <= 16` and never carries `<`, `>` or `"` — whatever the input tried to do.""" nasty = [ "", "<<<>>>>>>>", '"', "A" * 1000, (" " * 500) + "x" * 500, "\u200b" * 100, "\u202b\u202c\u202d\u202e\u2066\u2067" + "Name" * 100, "Mi\u00adna\u00ad\u00ad" * 10, "<'\"\\>\x00\x01\x1f\x7f", "é" * 500, ] for raw in nasty: out = sanitize_name(raw, 3) assert len(out) <= 16, (raw[:40], out) assert not {"<", ">", '"'} & set(out), (raw[:40], out) def test_the_fallback_names_the_seat() -> None: """An empty (or control-only) name is `Cook #{seat_index + 1}` — the client can always address the cook.""" assert sanitize_name(None, 0) == "Cook #1" assert sanitize_name(None, 7) == "Cook #8" assert sanitize_name(" \x00\t ", 4) == "Cook #5" assert sanitize_name("\u200b\u202b", 9) == "Cook #10" def test_rooms_reexports_the_same_boundary() -> None: """`server/rooms.py` keeps its task-01 export: the supervisor's boundary and the seats' boundary are the one function (imported, not duplicated).""" assert rooms_sanitize is sanitize_name