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