**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`.
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""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
|