**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`.
486 lines
19 KiB
Python
486 lines
19 KiB
Python
"""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()
|