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`.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user