fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects
Tests · UI / test (push) Failing after 10m52s
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user