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,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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user