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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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