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,134 @@
|
||||
"""Phase 07 task 03 — the JS wire enums must stay equal to the Python ones.
|
||||
|
||||
`app/static/js/shared/enums.js` is the client's vocabulary for the sim's
|
||||
StrEnums and the protocol's `ErrorCode`. The wire carries the *string
|
||||
values*; a drift — a value added to the Python side and forgotten in the
|
||||
JS set, or removed from one side only — would show up in the client as
|
||||
`undefined` sprites and dead FX. So the parity is a gate, not a
|
||||
convention: this test reads the JS file (the same source
|
||||
`app/static/index.html` serves — no copy that could drift), extracts
|
||||
the frozen sets, and asserts set equality in **both directions**
|
||||
(mirroring the doc-sync discipline of `tests/unit/test_protocol_doc_sync.py`).
|
||||
|
||||
It checks the seven sets named by the task: `TILE_KIND`,
|
||||
`STATION_KIND`, `ITEM_KIND`, `ITEM_FORM`, `FACING`, `EVENT_KIND`,
|
||||
`ERROR_CODE`. Removing a value from either side of one of them makes
|
||||
this test fail, naming the missing value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.server.protocol import ErrorCode
|
||||
from undercooked.sim.model import (
|
||||
EventKind,
|
||||
Facing,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
StationKind,
|
||||
TileKind,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
JS_ENUMS_PATH = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "app"
|
||||
/ "static"
|
||||
/ "js"
|
||||
/ "shared"
|
||||
/ "enums.js"
|
||||
)
|
||||
|
||||
|
||||
def _js_set(source: str, name: str) -> set[str]:
|
||||
"""One frozen JS array, as a set of string literals (a tiny, stable
|
||||
parser: `export const NAME = Object.freeze([ "a", "b" ]);`)."""
|
||||
match = re.search(
|
||||
rf"export const {re.escape(name)} = Object\.freeze\(\[(.*?)\]\)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, f"{name} is not a `Object.freeze([...])` export"
|
||||
body = match.group(1)
|
||||
assert "'" not in body, (
|
||||
f"{name} must use double-quoted literals (the test parses them as JSON)"
|
||||
)
|
||||
# A JS array may end in a trailing comma; JSON may not.
|
||||
body = re.sub(r",\s*$", "", body.rstrip())
|
||||
values = json.loads(f"[{body}]")
|
||||
assert all(isinstance(v, str) for v in values), f"{name} has a non-string entry"
|
||||
return set(values)
|
||||
|
||||
|
||||
def _js_ordered(source: str, name: str) -> list[str]:
|
||||
match = re.search(
|
||||
rf"export const {re.escape(name)} = Object\.freeze\(\[(.*?)\]\)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, f"{name} is not a `Object.freeze([...])` export"
|
||||
body = re.sub(r",\s*$", "", match.group(1).rstrip())
|
||||
return json.loads(f"[{body}]")
|
||||
|
||||
|
||||
def _python_set(enum_cls) -> set[str]:
|
||||
"""A StrEnum's values — or, for a plain constant class (the
|
||||
`ErrorCode`'s `Final` strings), the uppercase string attributes."""
|
||||
if isinstance(enum_cls, type) and issubclass(enum_cls, Enum):
|
||||
return {member.value for member in enum_cls}
|
||||
return {
|
||||
value
|
||||
for name, value in vars(enum_cls).items()
|
||||
if name.isupper() and isinstance(value, str)
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def js_source() -> str:
|
||||
assert JS_ENUMS_PATH.is_file(), f"the shared enum module is missing: {JS_ENUMS_PATH}"
|
||||
return JS_ENUMS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("js_name", "py_name", "enum_cls"),
|
||||
[
|
||||
("TILE_KIND", "TileKind", TileKind),
|
||||
("STATION_KIND", "StationKind", StationKind),
|
||||
("ITEM_KIND", "ItemKind", ItemKind),
|
||||
("ITEM_FORM", "ItemForm", ItemForm),
|
||||
("FACING", "Facing", Facing),
|
||||
("EVENT_KIND", "EventKind", EventKind),
|
||||
("ERROR_CODE", "ErrorCode", ErrorCode),
|
||||
],
|
||||
)
|
||||
def test_enum_parity(js_source, js_name, py_name, enum_cls) -> None:
|
||||
"""Set equality in both directions, naming the missing value."""
|
||||
js = _js_set(js_source, js_name)
|
||||
py = _python_set(enum_cls)
|
||||
only_py = py - js
|
||||
only_js = js - py
|
||||
assert only_py == set() and only_js == set(), (
|
||||
f"{js_name} (JS) drifted from {py_name} (Python): "
|
||||
f"missing in the JS set: {sorted(only_py) or '—'}, "
|
||||
f"missing in Python: {sorted(only_js) or '—'}"
|
||||
)
|
||||
|
||||
|
||||
def test_facing_order_is_the_wire_index(js_source) -> None:
|
||||
"""The wire's `f` is an *index* into the facing list (PLAN §4: 0-3):
|
||||
the JS array's order must be the enum's declaration order, not just
|
||||
its value set — `decode.js`'s `facingName` maps against that order."""
|
||||
js_ordered = _js_ordered(js_source, "FACING")
|
||||
py_ordered = [member.value for member in Facing]
|
||||
assert js_ordered == py_ordered, (
|
||||
f"FACING order drifted (the wire index maps against it): "
|
||||
f"JS {js_ordered} vs Python {py_ordered}"
|
||||
)
|
||||
assert len(js_ordered) == 4
|
||||
Reference in New Issue
Block a user