"""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