// Phase 9 end-to-end checks for the map renderer playground. // // Each Playwright project exercises a different rendering backend: // chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile // projects pick their default. The window.__galaxyMap surface // (defined in src/routes/__debug/map/+page.svelte) lets the spec // read the camera and viewport state without poking Pixi internals. import { expect, test, type Page } from "@playwright/test"; interface DebugMapSurface { ready: true; getMode(): "torus" | "no-wrap"; setMode(mode: "torus" | "no-wrap"): void; getCamera(): { centerX: number; centerY: number; scale: number }; getViewport(): { widthPx: number; heightPx: number }; getBackend(): string; getWorldSize(): { width: number; height: number }; hitAt(x: number, y: number): number | null; } declare global { interface Window { __galaxyMap?: DebugMapSurface; } } function preferenceFor(projectName: string): "webgpu" | "webgl" | null { if (projectName === "chromium-desktop") return "webgpu"; if (projectName === "webkit-desktop") return "webgl"; return null; } async function bootMap(page: Page, preference: "webgpu" | "webgl" | null): Promise { const url = preference !== null ? `/__debug/map?renderer=${preference}` : "/__debug/map"; await page.goto(url); await page.waitForFunction(() => window.__galaxyMap?.ready === true, undefined, { timeout: 15_000, }); await expect(page.getByTestId("backend")).toBeVisible(); } test("map mounts in the requested backend and reports it via data-backend", async ({ page, }, testInfo) => { const pref = preferenceFor(testInfo.project.name); await bootMap(page, pref); const backend = await page.getByTestId("backend").getAttribute("data-backend"); expect(backend).not.toBeNull(); if (pref === null) { // Mobile projects auto-pick; just assert a real backend was chosen. expect(["webgl", "webgpu", "canvas"]).toContain(backend); } else { // The renderer should honour the requested preference unless the // runner lacks a working WebGPU adapter, in which case Pixi // falls back to WebGL. Both are acceptable. expect(["webgl", "webgpu"]).toContain(backend); } }); test("wheel zoom-in increases camera scale", async ({ page }, testInfo) => { await bootMap(page, preferenceFor(testInfo.project.name)); const before = await page.evaluate(() => window.__galaxyMap!.getCamera()); const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (box === null) return; const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; await page.mouse.move(cx, cy); for (let i = 0; i < 5; i++) { await page.mouse.wheel(0, -120); await page.waitForTimeout(40); } await page.waitForTimeout(100); const after = await page.evaluate(() => window.__galaxyMap!.getCamera()); expect(after.scale).toBeGreaterThan(before.scale); }); test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo) => { await bootMap(page, preferenceFor(testInfo.project.name)); expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); await page.getByTestId("mode-toggle").click(); expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("no-wrap"); await page.getByTestId("mode-toggle").click(); expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); }); test("torus mode clamps zoom-out to minScale so the world is never smaller than the viewport", async ({ page, }, testInfo) => { await bootMap(page, preferenceFor(testInfo.project.name)); expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (box === null) return; const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; await page.mouse.move(cx, cy); // Wheel out aggressively. Without the torus-mode clampZoom the // camera scale would drop below `minScale = max(viewport/world)`, // exposing the 3×3 grid of wrap copies. With the fix in place the // scale floor is never crossed. for (let i = 0; i < 15; i++) { await page.mouse.wheel(0, 600); await page.waitForTimeout(40); } await page.waitForTimeout(200); const snapshot = await page.evaluate(() => ({ cam: window.__galaxyMap!.getCamera(), vp: window.__galaxyMap!.getViewport(), world: window.__galaxyMap!.getWorldSize(), })); const minScale = Math.max( snapshot.vp.widthPx / snapshot.world.width, snapshot.vp.heightPx / snapshot.world.height, ); const tol = 1e-3; expect(snapshot.cam.scale).toBeGreaterThanOrEqual(minScale - tol); }); test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({ page, }, testInfo) => { await bootMap(page, preferenceFor(testInfo.project.name)); await page.evaluate(() => window.__galaxyMap!.setMode("no-wrap")); await page.waitForTimeout(50); const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (box === null) return; // Drag right-to-left across most of the canvas so the camera // would, without clamp, push past the right edge of the world. const startX = box.x + box.width * 0.85; const endX = box.x + box.width * 0.15; const y = box.y + box.height / 2; await page.mouse.move(startX, y); await page.mouse.down(); for (let step = 1; step <= 20; step++) { const x = startX + ((endX - startX) * step) / 20; await page.mouse.move(x, y); } await page.mouse.up(); await page.waitForTimeout(200); const { cam, vp, world } = await page.evaluate(() => ({ cam: window.__galaxyMap!.getCamera(), vp: window.__galaxyMap!.getViewport(), world: window.__galaxyMap!.getWorldSize(), })); const halfSpanX = vp.widthPx / (2 * cam.scale); const tol = 1; // tolerance in world units; clamp is applied in pixels expect(cam.centerX).toBeGreaterThanOrEqual(halfSpanX - tol); expect(cam.centerX).toBeLessThanOrEqual(world.width - halfSpanX + tol); }); test("hitAt returns a primitive id when the cursor is over the world centre", async ({ page, }, testInfo) => { await bootMap(page, preferenceFor(testInfo.project.name)); const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (box === null) return; const cx = Math.round(box.width / 2); const cy = Math.round(box.height / 2); // The fixture world is dense (~950 stars in 4000×4000). Anywhere // within the canvas should land near at least one primitive. // We sweep a small grid around the centre to find any hit; the // goal is to confirm the hit-test plumbing works against the // live renderer, not to assert a specific id. const found = await page.evaluate( ({ cx, cy }) => { const m = window.__galaxyMap!; for (let dy = -40; dy <= 40; dy += 8) { for (let dx = -40; dx <= 40; dx += 8) { const id = m.hitAt(cx + dx, cy + dy); if (id !== null) return id; } } return null; }, { cx, cy }, ); expect(found).not.toBeNull(); });