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:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+485
View File
@@ -0,0 +1,485 @@
"""Phase 07 task 01: the in-repo SVG art, guarded like the sim's goldens.
The art is a contract, not decoration: the browser can only draw what
`index.json` names, and `index.json` is what
`undercooked.tools.gen_art_index` derives from the *sim's own* vocabulary
(`undercooked.sim.model`'s enums) and pipeline data
(`undercooked.sim.stations`' `ACCEPTS` / `TRANSITIONS`). So these tests
ask the mechanical questions:
* **the served output is faithful** — every served asset exists, is under
40 KB, is a byte-for-byte copy of its `assets_src/` source, and the
committed `index.json` is exactly what the generator derives from the
source tree;
* **every id has a drawable** — each `index.json` id is a real
`<symbol>` in its named file, sized by its `viewBox`, unique across the
whole set, and free of external `href`/`src`/`http` references;
* **the sim's vocabulary is fully drawn** — every `ItemKind` in every form
the sim can reach has a symbol (and the walk itself is pinned against
the sim's pipeline data), and every `ItemForm` value is covered;
* **the palette is one palette** — `assets/palette.css` and
`js/render/palette.js` carry identical hex values in both scopes
(default and colourblind), and every `var()` fallback the SVG art
relies on resolves to a palette entry;
* **the generator fails loudly** — run against a temporary copy, it
writes the index on a good tree and names a missing expected id, a
duplicated id, a drifted served copy, a missing file, and a symbol
that cannot be identified or sized.
"""
from __future__ import annotations
import json
import re
import shutil
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
from undercooked.sim.model import ItemForm, ItemKind
from undercooked.tools import gen_art_index
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
ASSETS_SRC = REPO_ROOT / "assets_src"
ASSETS_OUT = REPO_ROOT / "app" / "static" / "assets"
INDEX_PATH = ASSETS_OUT / "index.json"
SVG_FILES = gen_art_index.SVG_FILES
MAX_BYTES = 40 * 1024
def load_index() -> dict[str, dict[str, object]]:
"""The committed `index.json`, as a dict."""
return json.loads(INDEX_PATH.read_text(encoding="utf-8"))
def symbols_of(path: Path) -> dict[str, ET.Element]:
"""Every id-tagged `<symbol>` in one SVG file."""
root = ET.parse(path) # noqa: S314 - own hand-authored art files
return {
symbol_id: element
for element in root.iter()
if element.tag.endswith("symbol") and (symbol_id := element.get("id"))
}
def copy_tree(tmp_path: Path) -> tuple[Path, Path]:
"""A throwaway (source, served) pair under ``tmp_path``."""
src = tmp_path / "assets_src"
out = tmp_path / "app" / "static" / "assets"
shutil.copytree(ASSETS_SRC, src)
shutil.copytree(ASSETS_OUT, out)
return src, out
def remove_symbol(path: Path, symbol_id: str) -> None:
"""Delete one `<symbol id=...>...</symbol>` block from a file's text."""
text = path.read_text(encoding="utf-8")
start = text.index(f'<symbol id="{symbol_id}"')
end = text.index("</symbol>", start) + len("</symbol>")
path.write_text(text[:start] + text[end:], encoding="utf-8")
# --------------------------------------------------------------------------- the served output
def test_served_assets_exist_and_are_small() -> None:
"""Every served asset is present and under the 40 KB guard."""
for name in [*SVG_FILES, "index.json", "palette.css"]:
path = ASSETS_OUT / name
assert path.is_file(), f"{path} is missing"
for name in SVG_FILES:
size = (ASSETS_OUT / name).stat().st_size
assert size <= MAX_BYTES, f"{name} is {size} bytes (limit {MAX_BYTES})"
def test_served_copies_match_the_source_art() -> None:
"""`app/static/assets/` is a faithful copy of `assets_src/`."""
assert gen_art_index.check_served(ASSETS_SRC, ASSETS_OUT) == []
def test_index_json_is_what_the_generator_derives() -> None:
"""The committed index cannot drift from the source tree."""
assert gen_art_index.build_index(ASSETS_SRC) == load_index()
def test_every_index_id_has_a_symbol_with_matching_size() -> None:
"""Each id is a real `<symbol>` in its named file, sized by viewBox."""
index = load_index()
assert len(index) >= 100, f"the art lost symbols: {len(index)}"
by_file: dict[str, dict[str, ET.Element]] = {}
for name in SVG_FILES:
by_file[name] = symbols_of(ASSETS_OUT / name)
for symbol_id, entry in index.items():
file = str(entry["file"])
assert file in by_file, f"{symbol_id} names an unknown file {file!r}"
symbol = by_file[file].get(symbol_id)
assert symbol is not None, f"no <symbol id={symbol_id!r}> in {file}"
view_box = (symbol.get("viewBox") or "").split()
assert len(view_box) == 4, f"{symbol_id} has no parsable viewBox"
assert (float(view_box[2]), float(view_box[3])) == (
entry["w"],
entry["h"],
), f"{symbol_id}: index {entry['w']}x{entry['h']} vs viewBox {view_box}"
def test_no_symbol_id_is_duplicated() -> None:
ids: list[str] = []
for name in SVG_FILES:
ids.extend(symbols_of(ASSETS_OUT / name))
assert len(ids) == len(set(ids)), sorted({i for i in ids if ids.count(i) > 1})
def test_the_art_references_nothing_external() -> None:
"""No `http` outside the w3.org namespace declarations, no non-fragment href."""
for name in SVG_FILES:
text = (ASSETS_OUT / name).read_text(encoding="utf-8")
for match in re.finditer(r"https?://[^\s\"'>]+", text):
assert match.group(0).startswith(
"http://www.w3.org/"
), f"{name}: external reference {match.group(0)!r}"
for match in re.finditer(r"""(?:href|src)\s*=\s*"([^"]*)" """, text):
assert match.group(1).startswith(
"#"
), f"{name}: non-fragment reference {match.group(1)!r}"
def test_svg_var_fallbacks_resolve_to_the_palette() -> None:
"""A `var(--uc-*, #hex)` fallback in the art must be a palette entry."""
palette = parse_js_palette(
(REPO_ROOT / "app" / "static" / "js" / "render" / "palette.js").read_text(
encoding="utf-8"
)
)["default"]
fallbacks: list[tuple[str, str]] = []
for file_name in SVG_FILES:
text = (ASSETS_OUT / file_name).read_text(encoding="utf-8")
fallbacks.extend(re.findall(r"var\(\s*(--uc-[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)", text))
assert len(fallbacks) >= 32, "the cook symbols stopped using palette var() fallbacks"
for variable, fallback in fallbacks:
key = variable.removeprefix("--uc-").replace("-", "")
assert key in palette, f"{variable} is not a palette entry"
assert palette[key].lower() == fallback.lower(), (
f"{variable} falls back to {fallback} but the palette says {palette[key]}"
)
# --------------------------------------------------------------------------- sim-enum coverage
def test_every_reachable_item_form_has_a_symbol() -> None:
"""The whole (kind, form) surface the sim can produce is drawn."""
index = load_index()
for kind, forms in gen_art_index.reachable_item_forms().items():
for form in forms:
symbol_id = f"item-{kind.value}-{form.value}"
assert symbol_id in index, f"no symbol for {symbol_id}"
covered = set().union(*gen_art_index.reachable_item_forms().values())
assert covered == set(ItemForm), (
f"ItemForm values with no symbol anywhere: {set(ItemForm) - covered}"
)
def test_reachability_walks_the_sim_pipeline() -> None:
"""The derivation is pinned: a sim-data change that moves the forms fails here.
Rice never reaches the chop board (PLAN §5: "raw rice needs no board"),
so `CHOPPED` is unreachable for it; a plate is a prop, so it carries
exactly the clean/dirty/plated states; every other ingredient kind can
be raw, chopped, on the heat, cooked and burnt.
"""
forms = gen_art_index.reachable_item_forms()
assert forms[ItemKind.RICE] == frozenset(
{ItemForm.RAW, ItemForm.COOKING, ItemForm.COOKED, ItemForm.BURNT}
)
assert forms[ItemKind.PLATE] == frozenset(
{ItemForm.RAW, ItemForm.DIRTY, ItemForm.PLATED}
)
full = frozenset(
{
ItemForm.RAW,
ItemForm.CHOPPED,
ItemForm.COOKING,
ItemForm.COOKED,
ItemForm.BURNT,
}
)
for kind in (
ItemKind.TOMATO,
ItemKind.ONION,
ItemKind.LETTUCE,
ItemKind.DOUGH,
ItemKind.PATTY,
ItemKind.FISH,
):
assert forms[kind] == full, f"{kind.value}: {sorted(f.value for f in forms[kind])}"
assert ItemForm.CHOPPED not in forms[ItemKind.RICE]
assert set(forms) == set(ItemKind), "the walk must cover every ItemKind"
# --------------------------------------------------------------------------- the palette
def parse_css_palette(text: str) -> dict[str, dict[str, str]]:
"""The ``--uc-<name>: <hex>`` pairs per CSS scope, keyed like the JS object.
The CSS variable `--uc-cook-1` is the JS object's `cook1`: hyphens
are dropped, so the two files compare key-for-key.
"""
scopes: dict[str, dict[str, str]] = {}
for scope, pattern in (
("default", r":root\s*\{([^}]*)\}"),
("colorblind", r'\[data-palette="cb"\]\s*\{([^}]*)\}'),
):
block = re.search(pattern, text)
assert block is not None, f"palette.css lost its {scope} block"
pairs = re.findall(r"--uc-([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})", block.group(1))
assert len(pairs) == len(set(pairs)), f"duplicate custom property in {scope}"
scopes[scope] = {name.replace("-", ""): hex_value for name, hex_value in pairs}
return scopes
def parse_js_palette(text: str) -> dict[str, dict[str, str]]:
"""The ``name: "<hex>"`` pairs of the two exported palette objects."""
scopes: dict[str, dict[str, str]] = {}
for scope, export in (
("default", "DEFAULT_PALETTE"),
("colorblind", "COLORBLIND_PALETTE"),
):
block = re.search(
export + r"\s*=\s*Object\.freeze\(\{([^}]*)\}\)", text
)
assert block is not None, f"palette.js lost its {export}"
pairs = re.findall(r'(\w+):\s*"(#[0-9a-fA-F]{3,8})"', block.group(1))
assert len(pairs) == len(set(pairs)), f"duplicate key in {export}"
scopes[scope] = dict(pairs)
return scopes
def test_palette_css_and_js_agree() -> None:
"""Both files, both scopes.
The CSS colourblind block is an *override* of its `:root` values, so
the effective colourblind palette is `:root` plus the block; the JS
`COLORBLIND_PALETTE` gets the same merged values from a spread of
`DEFAULT_PALETTE` (or a full entry list, which is equally fine).
"""
js_text = (REPO_ROOT / "app" / "static" / "js" / "render" / "palette.js").read_text(
encoding="utf-8"
)
css = parse_css_palette((ASSETS_OUT / "palette.css").read_text(encoding="utf-8"))
js = parse_js_palette(js_text)
assert len(css["default"]) >= 8, f"the default palette lost entries: {css['default']}"
assert css["default"] == js["default"], (
f"default scope disagrees: CSS {css['default']} vs JS {js['default']}"
)
# The override block only recolors the cooks, and it actually changes
# at least one of them.
assert set(css["colorblind"]) <= {f"cook{n}" for n in (1, 2, 3, 4)}, css["colorblind"]
assert any(css["colorblind"][k] != css["default"][k] for k in css["colorblind"]), (
"the colourblind block overrides nothing"
)
# The JS object spreads the default palette (`...DEFAULT_PALETTE`), so
# its effective colourblind values are default-plus-override, exactly
# like the CSS cascade — unless it lists all eight entries outright.
# A block that is neither is a drift.
colorblind_block = re.search(
r"COLORBLIND_PALETTE\s*=\s*Object\.freeze\(\{([^}]*)\}\)", js_text
)
assert colorblind_block is not None
has_spread = "DEFAULT_PALETTE" in colorblind_block.group(1)
assert has_spread or len(js["colorblind"]) >= 8, (
"COLORBLIND_PALETTE lists neither the spread nor the full entry set"
)
effective_css_cb = {**css["default"], **css["colorblind"]}
effective_js_cb = {**js["default"], **js["colorblind"]}
assert effective_css_cb == effective_js_cb, (
f"colourblind scope disagrees:\n"
f" CSS effective: {effective_css_cb}\n JS effective: {effective_js_cb}"
)
def test_the_default_cook_colours_are_four_distinct_values() -> None:
palette = parse_css_palette((ASSETS_OUT / "palette.css").read_text(encoding="utf-8"))[
"default"
]
cooks = [palette[f"cook{n}"] for n in (1, 2, 3, 4)]
assert len(set(cooks)) == 4, cooks
# --------------------------------------------------------------------------- the generator
def test_generator_writes_the_index_on_a_good_tree(tmp_path: Path) -> None:
src, out = copy_tree(tmp_path)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 0
written = json.loads((out / "index.json").read_text(encoding="utf-8"))
assert written == load_index()
assert len(written) >= 100
def test_generator_defaults_come_from_the_repo(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
src, out = copy_tree(tmp_path)
monkeypatch.setattr(gen_art_index, "DEFAULT_SRC", src)
monkeypatch.setattr(gen_art_index, "DEFAULT_OUT", out)
assert gen_art_index.main([]) == 0
assert (out / "index.json").is_file()
def test_generator_names_a_missing_expected_id(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
for name in (src, out):
remove_symbol(name / "items.svg", "item-rice-cooking")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "item-rice-cooking" in report, report
assert "missing expected ids" in report
def test_generator_names_a_duplicated_symbol_id(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
duplicate = '<symbol id="tile-floor" viewBox="0 0 8 8"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "stations.svg"
text = path.read_text(encoding="utf-8")
path.write_text(text.replace("</svg>", f" {duplicate}\n</svg>"), encoding="utf-8")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "tile-floor" in report and "duplicated" in report, report
def test_generator_rejects_a_diverged_served_copy(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
drifted = out / "ui.svg"
drifted.write_text(drifted.read_text(encoding="utf-8") + "<!-- drifted -->", encoding="utf-8")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "ui.svg" in report and "differs" in report, report
def test_generator_rejects_a_missing_served_copy(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
(out / "fx.svg").unlink()
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "fx.svg" in report and "missing" in report, report
def test_generator_rejects_a_missing_source_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
(src / "ui.svg").unlink()
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "ui.svg" in report and "missing" in report, report
def test_generator_rejects_a_symbol_without_an_id(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
anonymous = '<symbol viewBox="0 0 8 8"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "fx.svg"
path.write_text(
path.read_text(encoding="utf-8").replace("</svg>", f" {anonymous}\n</svg>"),
encoding="utf-8",
)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "fx.svg" in report and "has no id" in report, report
def test_generator_rejects_a_symbol_without_a_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
unsized = '<symbol id="fx-unsized"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "fx.svg"
path.write_text(
path.read_text(encoding="utf-8").replace("</svg>", f" {unsized}\n</svg>"),
encoding="utf-8",
)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "fx-unsized" in report and "no viewBox" in report, report
def test_generator_reports_every_missing_id_not_just_the_first(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
for name in (src, out):
remove_symbol(name / "items.svg", "item-tomato-burnt")
remove_symbol(name / "ui.svg", "ui-crown")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "item-tomato-burnt" in report and "ui-crown" in report, report
def test_parse_symbols_walks_every_symbol_in_order(tmp_path: Path) -> None:
"""The parser is total over the file, and the sizes come from viewBox."""
symbols = gen_art_index.parse_symbols(ASSETS_SRC / "tiles.svg")
assert [symbol.id for symbol in symbols] == [
"tile-floor",
"tile-floor-1",
"tile-floor-2",
"tile-floor-3",
"tile-floor-4",
"tile-counter",
"tile-wall",
"tile-void",
]
assert all((symbol.w, symbol.h) == (32.0, 32.0) for symbol in symbols)
def test_symbol_size_falls_back_to_width_height_attributes(tmp_path: Path) -> None:
"""A symbol without a viewBox may still be sized by width/height."""
path = tmp_path / "sized.svg"
path.write_text(
'<svg xmlns="http://www.w3.org/2000/svg">'
'<symbol id="a" width="16" height="8"><rect/></symbol>'
"<symbol id=\"b\" viewBox=\"0 0 12 6\"><rect/></symbol></svg>",
encoding="utf-8",
)
assert gen_art_index.parse_symbols(path) == [
gen_art_index.Symbol(id="a", w=16.0, h=8.0),
gen_art_index.Symbol(id="b", w=12.0, h=6.0),
]
def test_expected_ids_cover_every_family() -> None:
"""A sanity lock on the derivation: every family is represented."""
ids = gen_art_index.expected_ids()
assert len(ids) >= 100
assert any(i.startswith("tile-floor-") for i in ids)
assert any(i.startswith("station-stove-") for i in ids)
assert sum(1 for i in ids if i.startswith("item-")) >= 37
assert sum(1 for i in ids if i.startswith("cook-1-") or i.startswith("cook-2-")
or i.startswith("cook-3-") or i.startswith("cook-4-")) == 32
assert "cook-carry" in ids and "cook-stun" in ids
assert any(i.startswith("fx-") for i in ids)
assert any(i.startswith("ui-") for i in ids)
def test_the_served_index_and_the_source_tree_are_the_same_build(
tmp_path: Path,
) -> None:
"""Re-running the generator on a copy of the committed tree is a no-op."""
src, out = copy_tree(tmp_path)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 0
assert (out / "index.json").read_bytes() == INDEX_PATH.read_bytes()
+134
View File
@@ -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
+148
View File
@@ -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}"
+1
View File
@@ -207,6 +207,7 @@ def test_no_doc_file_exists_that_nothing_promises() -> None:
def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {
"docs/CLIENT.md",
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
+15 -6
View File
@@ -18,6 +18,12 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
HARNESS_MARKERS = ("unit", "integration", "e2e", "slow")
TEST_SUBDIRS = ("unit", "integration", "sim", "e2e")
#: The technical e2e suites that a phase file names but that belong to no
#: single user story (e.g. the phase-07 art/atlas guard). Every entry is a
#: deliberate, named exception to the story-naming contract in
#: `tests/e2e/README.md`; a new technical suite lands here first.
E2E_TECHNICAL_SUITES = frozenset({"test_atlas_loads.py"})
def test_package_metadata() -> None:
assert undercooked.__version__ == "0.1.0"
@@ -59,12 +65,14 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
def test_e2e_tests_follow_the_story_naming_convention() -> None:
"""`pytest -m e2e --collect-only` selects only `test_usNN_*.py` story suites.
"""`pytest -m e2e --collect-only` selects story suites (`test_usNN_*.py`) and
the allowlisted technical suites only.
Task 01 could only assert "the marker collects nothing yet". Now that the first
browser suite exists the durable invariant is the naming contract in
`tests/e2e/README.md`: a story suite that is not named for its story is one that
nobody can find, and one that cannot be collected is one that never runs.
The durable invariant is the naming contract in `tests/e2e/README.md`: a
story suite that is not named for its story is one that nobody can find,
and a suite that cannot be collected is one that never runs. Technical
suites (guards for a phase's machinery, named by the phase files) are
the documented exception, tracked in `E2E_TECHNICAL_SUITES`.
"""
result = subprocess.run( # fixed argv, repo's own interpreter
[
@@ -92,4 +100,5 @@ def test_e2e_tests_follow_the_story_naming_convention() -> None:
assert files, f"the e2e marker selected nothing:\n{result.stdout}"
for path in files:
assert path.startswith("tests/e2e/"), path
assert re.fullmatch(r"test_us\d{2}_.+\.py", Path(path).name), path
name = Path(path).name
assert re.fullmatch(r"test_us\d{2}_.+\.py", name) or name in E2E_TECHNICAL_SUITES, path
+22 -1
View File
@@ -257,7 +257,9 @@ def test_shipped_index_html_loads_only_same_origin_assets() -> None:
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
tags = re.findall(r"<(script|link|img)\b", html, re.I)
assert len(tags) >= 3, "index.html no longer declares the assets this test guards"
assert 'src="/static/js/app.js"' in html
# The page's single entry module (the composition root, phase 07 task 03;
# `main.js` imports `app.js` and the render/decode modules).
assert 'src="/static/js/main.js"' in html
assert scan_element_urls(STATIC_DIR / "index.html", html) == []
@@ -321,6 +323,18 @@ def test_a_planted_html_reference_fails_the_scan(
'<image href="https://x.example.com/a.png"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
],
)
@@ -364,6 +378,13 @@ def test_a_planted_hosted_library_token_fails_the_scan(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="#tile"/></svg>',
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg"'
' xmlns:xml="http://www.w3.org/XML/1998/namespace"'
' xmlns:xhtml="http://www.w3.org/1999/xhtml">'
'<rect width="16" height="16"/></svg>',
),
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"),
(".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
(".js", 'fetch("/static/api/levels");\n'),