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