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,278 @@
|
||||
"""Phase 07 task 01 e2e: the SVG atlas loads in a real browser and draws.
|
||||
|
||||
`TestClient` cannot prove that a `file.svg#symbol-id` fragment decodes to
|
||||
pixels in an actual engine, so this suite drives headless Chromium: it
|
||||
imports the atlas module the way the renderer will (`import()` of the
|
||||
served ES module), waits for `atlas.ready()`, and draws onto a probe
|
||||
canvas. A symbol that the engine cannot render (no `naturalWidth`) makes
|
||||
`atlas.draw` throw instead of painting nothing — the non-blank pixel
|
||||
assertions below are what catch that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
INDEX_PATH = REPO_ROOT / "app" / "static" / "assets" / "index.json"
|
||||
|
||||
# A spread across the six files, dense enough to assert on: an
|
||||
# id the engine failed to decode paints zero pixels and fails loudly.
|
||||
PROBE_IDS = [
|
||||
"tile-floor",
|
||||
"item-tomato-raw",
|
||||
"item-plate-plated",
|
||||
"cook-1-down-0",
|
||||
"station-stove-ready",
|
||||
"ui-star",
|
||||
"fx-score",
|
||||
"fx-flame",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_errors(page: Page) -> list[str]:
|
||||
"""Console errors and uncaught exceptions raised while the page runs (empty = good)."""
|
||||
errors: list[str] = []
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: errors.append(f"{message.type}: {message.text}")
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}"))
|
||||
return errors
|
||||
|
||||
|
||||
def import_atlas(page: Page) -> None:
|
||||
"""The same import the renderer uses; a failure is a loud test error."""
|
||||
result = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
return atlas.size();
|
||||
}"""
|
||||
)
|
||||
assert result >= 100, f"the atlas decoded only {result} symbols"
|
||||
|
||||
|
||||
def test_atlas_ready_resolves_and_covers_the_index(
|
||||
page: Page, live_server: str, console_errors: list[str]
|
||||
) -> None:
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
count = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
return atlas.size();
|
||||
}"""
|
||||
)
|
||||
assert count == len(json.loads(INDEX_PATH.read_text(encoding="utf-8")))
|
||||
assert count >= 100
|
||||
assert console_errors == []
|
||||
|
||||
|
||||
def test_atlas_meta_answers_for_known_and_unknown_ids(
|
||||
page: Page, live_server: str
|
||||
) -> None:
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
meta = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
return { known: atlas.meta("item-rice-raw"), unknown: atlas.meta("nope") };
|
||||
}"""
|
||||
)
|
||||
assert meta["known"] is not None and meta["known"]["file"] == "items.svg"
|
||||
assert meta["known"]["w"] == 32 and meta["known"]["h"] == 32
|
||||
assert meta["unknown"] is None
|
||||
|
||||
|
||||
def test_draw_produces_non_blank_pixels(
|
||||
page: Page, live_server: str, console_errors: list[str]
|
||||
) -> None:
|
||||
"""A blank kitchen is the failure mode this phase exists to rule out."""
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
results = page.evaluate(
|
||||
"""async (ids) => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const results = {};
|
||||
for (const id of ids) {
|
||||
await atlas.draw(ctx, id, 0, 0, 64, 64);
|
||||
const data = ctx.getImageData(0, 0, 64, 64).data;
|
||||
let nonBlank = 0;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) nonBlank += 1;
|
||||
}
|
||||
results[id] = nonBlank;
|
||||
}
|
||||
return results;
|
||||
}""",
|
||||
PROBE_IDS,
|
||||
)
|
||||
for symbol_id, non_blank in results.items():
|
||||
assert non_blank > 80, f"{symbol_id} painted only {non_blank} pixels"
|
||||
assert console_errors == []
|
||||
|
||||
|
||||
def test_unknown_id_thrrows_and_failed_loads_are_loud(
|
||||
page: Page, live_server: str
|
||||
) -> None:
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
error = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 8;
|
||||
canvas.height = 8;
|
||||
const ctx = canvas.getContext("2d");
|
||||
try {
|
||||
await atlas.draw(ctx, "definitely-not-a-sprite", 0, 0, 8, 8);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
}"""
|
||||
)
|
||||
assert error is not None
|
||||
assert "unknown atlas id: definitely-not-a-sprite" in error
|
||||
|
||||
|
||||
def test_alpha_fades_the_sprite(page: Page, live_server: str) -> None:
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
max_alpha = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
await atlas.draw(ctx, "tile-floor", 0, 0, 64, 64, { alpha: 0.4 });
|
||||
const data = ctx.getImageData(0, 0, 64, 64).data;
|
||||
let max = 0;
|
||||
for (let i = 3; i < data.length; i += 4) max = Math.max(max, data[i]);
|
||||
return max;
|
||||
}"""
|
||||
)
|
||||
assert 0 < max_alpha < 200, max_alpha
|
||||
|
||||
|
||||
def test_tint_recolours_and_flipx_mirrors(page: Page, live_server: str) -> None:
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
result = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
const make = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
return canvas.getContext("2d");
|
||||
};
|
||||
const read = (ctx) => Array.from(ctx.getImageData(0, 0, 64, 64).data);
|
||||
const distanceTo = (pixels, r, g, b) => {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
if (pixels[i + 3] < 200) continue;
|
||||
sum += Math.abs(pixels[i] - r) + Math.abs(pixels[i + 1] - g);
|
||||
sum += Math.abs(pixels[i + 2] - b);
|
||||
count += 1;
|
||||
}
|
||||
return count === 0 ? null : sum / count;
|
||||
};
|
||||
const baseCtx = make();
|
||||
await atlas.draw(baseCtx, "item-tomato-raw", 0, 0, 64, 64);
|
||||
const tintCtx = make();
|
||||
await atlas.draw(tintCtx, "item-tomato-raw", 0, 0, 64, 64, { tint: "#3d84a8" });
|
||||
const before = distanceTo(read(baseCtx), 0x3d, 0x84, 0xa8);
|
||||
const after = distanceTo(read(tintCtx), 0x3d, 0x84, 0xa8);
|
||||
const plainCtx = make();
|
||||
await atlas.draw(plainCtx, "item-fish-raw", 0, 0, 64, 64);
|
||||
const plain = read(plainCtx);
|
||||
const flippedCtx = make();
|
||||
await atlas.draw(flippedCtx, "item-fish-raw", 0, 0, 64, 64, { flipX: true });
|
||||
const flipped = read(flippedCtx);
|
||||
let mismatchedMirror = 0;
|
||||
let mismatchedIdentity = 0;
|
||||
for (let row = 0; row < 64; row += 1) {
|
||||
for (let col = 0; col < 32; col += 1) {
|
||||
const mirror = ((row * 64) + (63 - col)) * 4;
|
||||
const identity = ((row * 64) + col) * 4;
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
if (flipped[identity + k] !== plain[mirror + k]) {
|
||||
mismatchedMirror += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
if (flipped[identity + k] !== plain[identity + k]) {
|
||||
mismatchedIdentity += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { before, after, mismatchedMirror, mismatchedIdentity };
|
||||
}"""
|
||||
)
|
||||
assert result["before"] is not None and result["after"] is not None
|
||||
assert result["after"] < result["before"], (
|
||||
f"tinting did not move pixels toward the tint: {result}"
|
||||
)
|
||||
# The fish is asymmetric (tail right): the mirrored draw must match
|
||||
# the mirror of the plain draw far better than the plain draw itself.
|
||||
assert result["mismatchedMirror"] < 100, result
|
||||
assert result["mismatchedMirror"] < result["mismatchedIdentity"], result
|
||||
|
||||
|
||||
def test_the_atlas_only_requests_its_own_origin(
|
||||
page: Page, live_server: str, console_errors: list[str]
|
||||
) -> None:
|
||||
"""A12 in the atlas: the index and all six sprite files, and nothing else."""
|
||||
requested: list[str] = []
|
||||
page.on("request", lambda request: requested.append(request.url))
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
import_atlas(page)
|
||||
off_origin = [url for url in requested if not url.startswith(live_server)]
|
||||
assert off_origin == [], off_origin
|
||||
assert f"{live_server}/static/assets/index.json" in requested
|
||||
svg_files = {
|
||||
url.split("#")[0]
|
||||
for url in requested
|
||||
if "/static/assets/" in url and url.split("#")[0].endswith(".svg")
|
||||
}
|
||||
assert svg_files == {
|
||||
f"{live_server}/static/assets/{name}"
|
||||
for name in ("tiles.svg", "stations.svg", "items.svg", "cooks.svg", "fx.svg", "ui.svg")
|
||||
}, svg_files
|
||||
assert console_errors == []
|
||||
|
||||
|
||||
def test_meta_unknown_id_returns_null_before_any_draw(page: Page, live_server: str) -> None:
|
||||
"""`meta` is a read-only probe: it answers for unknown ids without throwing."""
|
||||
page.goto(f"{live_server}/", wait_until="domcontentloaded")
|
||||
meta = page.evaluate(
|
||||
"""async () => {
|
||||
const atlas = await import("/static/js/render/atlas.js");
|
||||
await atlas.ready();
|
||||
return atlas.meta("definitely-not-a-sprite");
|
||||
}"""
|
||||
)
|
||||
assert meta is None
|
||||
Reference in New Issue
Block a user