Files
undercooked/tests/unit/test_client_sprite_map.py
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

149 lines
5.7 KiB
Python

"""Phase 07 task 04 — the client's sprite map must stay equal to the sim's.
`app/static/js/render/canvas.js` holds `REACHABLE_ITEM_FORMS` — the
(client's copy of) the (kind, form) pairs the simulation can produce,
used by `itemSpriteId` to pick an atlas id for every carried or
station-held item. The authoritative source is
`undercooked.tools.gen_art_index.reachable_item_forms()` (a fixpoint
walk over the sim's own `ACCEPTS` / `TRANSITIONS` tables — the same
walk that derives the atlas's expected ids). A drift — a sim change
that orphans a pair, or adds one the client forgot — would render as
a missing sprite. So the parity is a gate, not a convention: this test
reads the JS file (the same source `app/static/index.html` serves) and
asserts set equality in **both directions** (mirroring the
`test_client_enum_parity.py` discipline).
It also pins the two things the renderer's *mapping functions* must
not break: every kind has a `raw` fallback (the unreachable pair's
sprite), and the JS table's kinds are exactly the sim's `ItemKind`
set (no kind left without a table row, no invented kind).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from undercooked.sim.model import ItemForm, ItemKind
from undercooked.tools.gen_art_index import reachable_item_forms
pytestmark = pytest.mark.unit
JS_CANVAS_PATH = (
Path(__file__).resolve().parent.parent.parent
/ "app"
/ "static"
/ "js"
/ "render"
/ "canvas.js"
)
def _js_table(source: str) -> dict[str, list[str]]:
"""The `REACHABLE_ITEM_FORMS` object, as kind → [forms] (a tiny,
stable parser: `kind: ["raw", "chopped", …],` lines inside the
frozen object)."""
match = re.search(
r"export const REACHABLE_ITEM_FORMS = Object\.freeze\(\{(.*?)\}\);",
source,
re.DOTALL,
)
assert match is not None, "REACHABLE_ITEM_FORMS is not a `Object.freeze({...})` export"
body = match.group(1)
assert "'" not in body, (
"the table must use double-quoted literals (the test parses them as JSON)"
)
table: dict[str, list[str]] = {}
for kind_match in re.finditer(r"(\w+):\s*\[(.*?)\]", body):
kind = kind_match.group(1)
forms = json.loads(f"[{kind_match.group(2)}]")
assert all(isinstance(f, str) for f in forms), f"{kind} has a non-string form"
assert kind not in table, f"duplicate kind {kind} in the table"
table[kind] = forms
return table
@pytest.fixture(scope="module")
def js_source() -> str:
assert JS_CANVAS_PATH.is_file(), f"the canvas module is missing: {JS_CANVAS_PATH}"
return JS_CANVAS_PATH.read_text(encoding="utf-8")
def test_table_covers_exactly_the_item_kinds(js_source) -> None:
"""The table's keys are the sim's `ItemKind` values — no kind
missing a row (its unreachable pairs would fall back with no
`raw` row to lean on), no invented kind."""
table = _js_table(js_source)
js_kinds = set(table)
py_kinds = {kind.value for kind in ItemKind}
missing = py_kinds - js_kinds
invented = js_kinds - py_kinds
assert not missing and not invented, (
f"REACHABLE_ITEM_FORMS (JS) kind set drifted from ItemKind (Python): "
f"missing kinds: {sorted(missing) or '—'}, invented kinds: {sorted(invented) or '—'}"
)
def test_forms_match_the_sim_fixpoint(js_source) -> None:
"""Set equality per kind, in both directions, naming the pair."""
table = _js_table(js_source)
sim = reachable_item_forms()
for kind in ItemKind:
js_forms = set(table.get(kind.value, []))
py_forms = {form.value for form in sim[kind]}
only_py = py_forms - js_forms
only_js = js_forms - py_forms
assert not only_py and not only_js, (
f"{kind.value} drifted: missing in the JS table: {sorted(only_py) or '—'}, "
f"missing in the sim: {sorted(only_js) or '—'}"
)
def test_every_table_form_is_a_known_form(js_source) -> None:
"""A form the sim's `ItemForm` does not define is a mapping bug
(the atlas has no `item-<kind>-<form>` for it)."""
table = _js_table(js_source)
known = {form.value for form in ItemForm}
for kind, forms in table.items():
unknown = set(forms) - known
assert not unknown, f"{kind} names unknown form(s): {sorted(unknown)}"
def test_every_kind_has_a_raw_fallback(js_source) -> None:
"""`itemSpriteId`'s unreachable-pair fallback is `item-<kind>-raw` —
every kind must therefore carry `raw` (the sim guarantees it: a
shelf-stocked kind starts raw, and the plate is raw- or
dirty-stocked; the test pins the renderer's contract, not the
sim's)."""
table = _js_table(js_source)
for kind, forms in table.items():
assert "raw" in forms, f"{kind} lacks the `raw` fallback form"
def test_atlas_has_every_reachable_pair() -> None:
"""The closure: every (kind, form) the sim can produce has an
atlas id (the `gen_art_index` contract, checked here against the
shipped `index.json` so a new pair without its sprite fails the
build, naming the pair)."""
index_path = (
Path(__file__).resolve().parent.parent.parent
/ "app"
/ "static"
/ "assets"
/ "index.json"
)
assert index_path.is_file(), f"the atlas index is missing: {index_path}"
index = json.loads(index_path.read_text(encoding="utf-8"))
ids = {entry["id"] if isinstance(entry, dict) else str(entry) for entry in index}
sim = reachable_item_forms()
missing = [
f"item-{kind.value}-{form.value}"
for kind in ItemKind
for form in sim[kind]
if f"item-{kind.value}-{form.value}" not in ids
]
assert not missing, f"the atlas lacks sprite(s): {missing}"