From 2528d63b51df7b8f2118d331a393763c9d8fdcb6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 22:02:15 +0200 Subject: [PATCH] fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the fresh-Phase-29 suite red on CI #216: 1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets in signed space — high-bit-prefix primitives (cargo route 0x80…, battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and leaked into the planet list. Filter switched to a `0 < id < 1e7` window that matches the engine planet-number range exactly. 2. The `visibleHighBitCount` helper now ToUint32-converts the id before masking so the bitmask comparison works regardless of whether the id is stored as positive or negative. 3. The fog and wrap-mode tests read the renderer state synchronously after the click — the Svelte effect re-runs asynchronously, so the tests saw stale state. Both now `waitForFunction` on the canonical "settled" signal: empty fog circles for the fog flip, and a new `getMapMode()` debug accessor for the wrap-mode remount. Renderer side: registers a `MapModeProvider` next to the existing camera / fog providers and exposes `getMapMode()` through the debug surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/active-view/map.svelte | 5 + ui/frontend/src/lib/debug-surface.svelte.ts | 32 ++++- .../src/routes/__debug/store/+page.svelte | 6 + ui/frontend/tests/e2e/map-toggles.spec.ts | 130 +++++++++--------- .../e2e/storage-keypair-persistence.spec.ts | 2 + 5 files changed, 108 insertions(+), 67 deletions(-) diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 801daed..9e211b4 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -69,6 +69,7 @@ preference the store already manages. installRendererDebugSurface, registerMapCameraProvider, registerMapFogProvider, + registerMapModeProvider, registerMapPickStateProvider, registerMapPrimitivesProvider, type MapCameraSnapshot, @@ -505,11 +506,15 @@ preference the store already manages. const detachFog = registerMapFogProvider(() => ({ circles: currentFogCircles.map((c) => ({ ...c })), }) satisfies MapFogSnapshot); + const detachMode = registerMapModeProvider(() => + handle === null ? null : handle.getMode(), + ); detachDebugProviders = (): void => { detachPrim(); detachPick(); detachCamera(); detachFog(); + detachMode(); }; mountedTurn = report.turn; mountedGameId = targetGameId; diff --git a/ui/frontend/src/lib/debug-surface.svelte.ts b/ui/frontend/src/lib/debug-surface.svelte.ts index 5cc9b23..41b4afb 100644 --- a/ui/frontend/src/lib/debug-surface.svelte.ts +++ b/ui/frontend/src/lib/debug-surface.svelte.ts @@ -10,7 +10,7 @@ // lazily on every read so the returned data always reflects the // current frame, not the value at registration time. -import type { Primitive, PrimitiveID } from "../map/world"; +import type { Primitive, PrimitiveID, WrapMode } from "../map/world"; /** Snapshot returned by `getMapPrimitives()`. The renderer applies * pick-mode dimming via the underlying `Graphics.alpha`, so the @@ -73,11 +73,13 @@ type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[]; type PickStateProvider = () => MapPickStateSnapshot; type CameraProvider = () => MapCameraSnapshot | null; type FogProvider = () => MapFogSnapshot; +type ModeProvider = () => WrapMode | null; let primitivesProvider: PrimitivesProvider | null = null; let pickStateProvider: PickStateProvider | null = null; let cameraProvider: CameraProvider | null = null; let fogProvider: FogProvider | null = null; +let modeProvider: ModeProvider | null = null; /** * registerMapPrimitivesProvider attaches a provider that yields the @@ -134,6 +136,22 @@ export function registerMapFogProvider(provider: FogProvider): () => void { }; } +/** + * registerMapModeProvider attaches a provider that yields the + * renderer's current `WrapMode` ('torus' or 'no-wrap'). Used by + * Phase 29 e2e specs to await the renderer remount after a + * wrap-mode flip — `getMapCamera()` alone is not a reliable signal + * because the same camera survives across a remount, so the spec + * watches the mode flip instead. Same idempotent semantics as the + * other providers. + */ +export function registerMapModeProvider(provider: ModeProvider): () => void { + modeProvider = provider; + return () => { + if (modeProvider === provider) modeProvider = null; + }; +} + const EMPTY_PICK_STATE: MapPickStateSnapshot = { active: false, sourcePlanetNumber: null, @@ -166,12 +184,20 @@ export function getMapFog(): MapFogSnapshot { return fogProvider?.() ?? { circles: [] }; } +/** Pulls the renderer's current `WrapMode`. Returns `null` when no + * map view is mounted (the surface is queried during navigation or + * before the first render). */ +export function getMapMode(): WrapMode | null { + return modeProvider?.() ?? null; +} + interface RendererDebugWindow { __galaxyDebug?: { getMapPrimitives?: () => readonly MapPrimitiveSnapshot[]; getMapPickState?: () => MapPickStateSnapshot; getMapCamera?: () => MapCameraSnapshot | null; getMapFog?: () => MapFogSnapshot; + getMapMode?: () => WrapMode | null; [key: string]: unknown; }; } @@ -195,6 +221,7 @@ export function installRendererDebugSurface(): () => void { getMapPickState, getMapCamera, getMapFog, + getMapMode, }; win.__galaxyDebug = surface; return (): void => { @@ -215,5 +242,8 @@ export function installRendererDebugSurface(): () => void { if (current.getMapFog === getMapFog) { delete current.getMapFog; } + if (current.getMapMode === getMapMode) { + delete current.getMapMode; + } }; } diff --git a/ui/frontend/src/routes/__debug/store/+page.svelte b/ui/frontend/src/routes/__debug/store/+page.svelte index 0534432..5cc2262 100644 --- a/ui/frontend/src/routes/__debug/store/+page.svelte +++ b/ui/frontend/src/routes/__debug/store/+page.svelte @@ -10,6 +10,7 @@ import { getMapCamera, getMapFog, + getMapMode, getMapPickState, getMapPrimitives, type MapCameraSnapshot, @@ -17,6 +18,7 @@ type MapPickStateSnapshot, type MapPrimitiveSnapshot, } from "../../../lib/debug-surface.svelte"; + import type { WrapMode } from "../../../map/world"; interface DebugSnapshot { publicKey: number[]; @@ -42,6 +44,7 @@ getMapPickState(): MapPickStateSnapshot; getMapCamera(): MapCameraSnapshot | null; getMapFog(): MapFogSnapshot; + getMapMode(): WrapMode | null; } type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface }; @@ -142,6 +145,9 @@ getMapFog() { return getMapFog(); }, + getMapMode() { + return getMapMode(); + }, }; (window as DebugWindow).__galaxyDebug = surface; ready = true; diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index e851a9e..857ec6a 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -221,26 +221,33 @@ interface PrimitiveLite { async function visiblePlanets(page: Page): Promise { return await page.evaluate(() => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; - // Planet primitive ids are the engine planet numbers (small); - // every other category uses a high-bit prefix. + // Planet primitive ids are the engine planet numbers — small + // positive integers ≤ planetCount. Other categories use either + // signed-negative high-bit-prefix ids (cargo route 0x80…, battle + // 0xa0…, bombing 0xc0…) or large positive offsets (ship groups + // at 1e8+). The `0 < id < 1e7` window covers the planet range + // and excludes both. return prims - .filter((p) => p.visible && p.id < 1_000_000) + .filter((p) => p.visible && p.id > 0 && p.id < 10_000_000) .map((p) => p.id) .sort((a, b) => a - b); }); } -async function visibleCount( +async function visibleHighBitCount( page: Page, - predicate: (id: number) => boolean, + prefix: number, ): Promise { - return await page.evaluate((pred: string) => { - const fn = new Function("id", `return (${pred})(id);`) as ( - id: number, - ) => boolean; + // Convert ids to uint32 before masking so the comparison works + // for ids stored as signed-negative numbers (JS bitwise ops force + // ToInt32). `prefix >>> 0` keeps the literal in uint32 space too. + return await page.evaluate((p: number) => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; - return prims.filter((p) => p.visible && fn(p.id)).length; - }, predicate.toString()); + return prims.filter( + (prim) => + prim.visible && ((prim.id >>> 0) & 0xf0000000) === (p >>> 0), + ).length; + }, prefix); } test("gear popover toggles a planet kind off and cascades onto its markers", async ({ @@ -253,40 +260,30 @@ test("gear popover toggles a planet kind off and cascades onto its markers", asy // Baseline — every planet shows up, plus the battle X-cross (2 // LinePrim) and the bombing ring on the foreign planet. expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]); - // Two battle marker line primitives (high-bit prefix 0xa0000000). - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xa0000000, - ), - ).toBe(2); - // One bombing ring (prefix 0xc0000000). - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xc0000000, - ), - ).toBe(1); + expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2); + expect(await visibleHighBitCount(page, 0xc0000000)).toBe(1); await page.getByTestId("map-toggles-trigger").click(); await expect(page.getByTestId("map-toggles-surface")).toBeVisible(); await page.getByTestId("map-toggles-foreign-planets").click(); - // The foreign planet (id 3) is gone — and its battle / bombing - // markers cascaded with it. + // The cascade applies asynchronously through the Svelte effect; + // wait for the foreign planet to drop out of the visible set + // before asserting on the markers — both updates happen in the + // same effect tick so once the planet is gone the markers are + // too. + await page.waitForFunction(() => { + const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly { + id: number; + visible: boolean; + }[]; + const planet3 = prims.find((p) => p.id === 3); + return planet3 !== undefined && planet3.visible === false; + }); + expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]); - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xa0000000, - ), - ).toBe(0); - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xc0000000, - ), - ).toBe(0); + expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0); + expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0); }); test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({ @@ -308,17 +305,17 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-visibility-fog").click(); - const offFog = await page.evaluate( - () => window.__galaxyDebug!.getMapFog!().circles, + // The effect re-run is async; wait for the fog payload to clear + // instead of reading it on the next tick. + await page.waitForFunction( + () => window.__galaxyDebug!.getMapFog!().circles.length === 0, ); - expect(offFog).toEqual([]); // Toggling back on rebuilds the fog circles for the same planets. await page.getByTestId("map-toggles-visibility-fog").click(); - const onAgain = await page.evaluate( - () => window.__galaxyDebug!.getMapFog!().circles, + await page.waitForFunction( + () => window.__galaxyDebug!.getMapFog!().circles.length === 2, ); - expect(onAgain.length).toBe(2); }); test("wrap mode radios flip the renderer and the camera centre survives", async ({ @@ -328,6 +325,10 @@ test("wrap mode radios flip the renderer and the camera centre survives", async await bootSession(page); await openGame(page); + // Confirm the renderer starts in torus mode. + await page.waitForFunction( + () => window.__galaxyDebug?.getMapMode?.() === "torus", + ); const initial = await page.evaluate(() => window.__galaxyDebug!.getMapCamera!(), ); @@ -337,19 +338,26 @@ test("wrap mode radios flip the renderer and the camera centre survives", async await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-wrap-no-wrap").click(); - // Mount path is async (Pixi re-init takes a frame). Wait for the - // camera reading to settle into the new mount and assert the - // centre is within 1 px of the pre-toggle value. - await page.waitForFunction(() => { - const c = window.__galaxyDebug?.getMapCamera?.(); - return c !== null && c !== undefined && c.camera.centerX !== undefined; - }); + // `setWrapMode` triggers a full Pixi remount; wait for the + // renderer to settle into the new mode and the debug surface to + // re-register before reading the camera. The mode provider is + // re-bound inside `runSerializedMount` after `createRenderer` + // resolves, so observing `getMapMode() === "no-wrap"` is the + // canonical "remount complete" signal. + await page.waitForFunction( + () => window.__galaxyDebug?.getMapMode?.() === "no-wrap", + ); + const after = await page.evaluate(() => window.__galaxyDebug!.getMapCamera!(), ); expect(after).not.toBeNull(); - expect(Math.abs(after!.camera.centerX - startCentre.centerX)).toBeLessThanOrEqual(1); - expect(Math.abs(after!.camera.centerY - startCentre.centerY)).toBeLessThanOrEqual(1); + expect( + Math.abs(after!.camera.centerX - startCentre.centerX), + ).toBeLessThanOrEqual(1); + expect( + Math.abs(after!.camera.centerY - startCentre.centerY), + ).toBeLessThanOrEqual(1); }); test("toggle state persists across a page reload", async ({ page }) => { @@ -386,16 +394,6 @@ test("toggle state persists across a page reload", async ({ page }) => { await page.getByTestId("map-toggles-bombing-markers").isChecked(), ).toBe(false); // Battle X-cross and bombing ring are hidden in the renderer. - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xa0000000, - ), - ).toBe(0); - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xc0000000, - ), - ).toBe(0); + expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0); + expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0); }); diff --git a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts index 938afca..e7fce43 100644 --- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -19,6 +19,7 @@ import type { MapPickStateSnapshot, MapPrimitiveSnapshot, } from "../../src/lib/debug-surface.svelte"; +import type { WrapMode } from "../../src/map/world"; // Mirrors the surface mounted by `routes/__debug/store/+page.svelte`. // Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`, @@ -48,6 +49,7 @@ interface DebugSurface { getMapPickState(): MapPickStateSnapshot; getMapCamera(): MapCameraSnapshot | null; getMapFog(): MapFogSnapshot; + getMapMode(): WrapMode | null; } declare global {