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()