fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects
Tests · UI / test (push) Failing after 10m52s

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) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 22:02:15 +02:00
parent 2bd1b54936
commit 2528d63b51
5 changed files with 108 additions and 67 deletions
+64 -66
View File
@@ -221,26 +221,33 @@ interface PrimitiveLite {
async function visiblePlanets(page: Page): Promise<number[]> {
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<number> {
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);
});
@@ -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 {