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
+278
View File
@@ -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