"""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 `` 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 `` 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 `...` block from a file's text.""" text = path.read_text(encoding="utf-8") start = text.index(f'", start) + len("") 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 `` 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 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-: `` 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: ""`` 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 = '' for name in (src, out): path = name / "stations.svg" text = path.read_text(encoding="utf-8") path.write_text(text.replace("", f" {duplicate}\n"), 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") + "", 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 = '' for name in (src, out): path = name / "fx.svg" path.write_text( path.read_text(encoding="utf-8").replace("", f" {anonymous}\n"), 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 = '' for name in (src, out): path = name / "fx.svg" path.write_text( path.read_text(encoding="utf-8").replace("", f" {unsized}\n"), 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( '' '' "", 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()