Files
galaxy-game/ui/frontend/tests/e2e/map-toggles.spec.ts
T
Ilia Denisov 51902b995f
Tests · UI / test (push) Successful in 1m55s
Tests · UI / test (pull_request) Successful in 2m4s
fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.

Stage 1 (vector-preserving, no rasterisation):

- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
  single Ticker.shared flush gated on viewport.dirty (camera) plus an
  internal requestRender() from every content mutation (fog / hide-set /
  extras / wrap mode / resize / pick overlay). An idle map now does zero
  GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
  instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
  deterministic e2e assertions.

Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.

If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:28:18 +02:00

509 lines
16 KiB
TypeScript

// Phase 29 end-to-end coverage for the gear popover. The spec mocks
// the gateway with a mixed-kind report (local + foreign + uninhabited
// + unidentified planets, a battle, a bombing, a cargo route, a
// non-zero drive tech for fog math), then walks the popover through
// the toggles and asserts the renderer state via the
// `__galaxyDebug` accessors:
//
// * `getMapPrimitives()` — every primitive carries a `visible`
// flag mirroring the renderer's hide set. The spec counts the
// visible-foreign-planet primitives, etc.
// * `getMapFog()` — the current visibility-fog circle list.
// * `getMapCamera()` — the wrap-mode test reads the centre before
// and after the flip to confirm camera preservation.
// * `getMapRenderCount()` — painted-frame counter used by the
// render-on-demand specs at the bottom of this file: an idle map
// must not keep repainting, and a released drag must not coast
// (the `decelerate` plugin was removed).
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
const SESSION_ID = "phase-29-map-toggles-session";
const GAME_ID = "29292929-2929-2929-2929-292929292929";
const RACE = "Earthlings";
// FlightDistance = driveTech * 40; pick drive=10 → reach 400.
// VisibilityDistance = driveTech * 30 → fog radius 300.
const DRIVE_TECH = 10;
interface MockOpts {
currentTurn: number;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 29 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: opts.currentTurn,
};
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: opts.currentTurn,
mapWidth: 4000,
mapHeight: 4000,
race: RACE,
players: [{ name: RACE, drive: DRIVE_TECH }],
// Two LOCAL planets near the centre so the reach +
// fog math has anchors. The foreign planet at
// (1500, 1000) is 500 units from the closest LOCAL
// — outside reach (400), so the unreachable filter
// toggles flag this one when enabled.
localPlanets: [
{
number: 1,
name: "Earth",
x: 1000,
y: 1000,
size: 1000,
resources: 10,
},
{
number: 2,
name: "Mars",
x: 1200,
y: 1000,
size: 1000,
resources: 10,
},
],
otherPlanets: [
{
number: 3,
name: "Frontier",
x: 1500,
y: 1000,
owner: "Federation",
size: 800,
resources: 5,
},
],
uninhabitedPlanets: [
{
number: 4,
name: "Rock",
x: 1100,
y: 1100,
size: 500,
resources: 1,
},
],
unidentifiedPlanets: [
{ number: 5, x: 2500, y: 1000 },
],
battles: [
{ id: "8c0c1f64-b0f8-4e7d-8c2c-3e1d0a0b0001", planet: 3, shots: 4 },
],
bombings: [
{
planetNumber: 3,
planet: "Frontier",
owner: "Federation",
attacker: RACE,
production: "",
industry: 100,
population: 200,
colonists: 50,
attackPower: 5,
wiped: false,
},
],
routes: [
{
sourcePlanetNumber: 1,
entries: [
{ loadType: "COL", destinationPlanetNumber: 2 },
],
},
],
});
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
// Keep the push stream open so the revocation watcher does not
// sign the session out mid-test (same convention as
// `game-shell-map.spec.ts`).
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
}
async function openGame(page: Page): Promise<void> {
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
// Wait for the renderer's debug accessor to register so the
// `getMapPrimitives` call below picks up real data instead of an
// empty stub. The renderer registers it inside
// `runSerializedMount`, which awaits Pixi init.
await page.waitForFunction(() => {
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
return prims.length > 0;
});
}
interface PrimitiveLite {
id: number;
visible: boolean;
}
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
// 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 > 0 && p.id < 10_000_000)
.map((p) => p.id)
.sort((a, b) => a - b);
});
}
async function visibleHighBitCount(
page: Page,
prefix: number,
): Promise<number> {
// JS bitwise `&` always returns a signed int32. Convert both
// sides to uint32 via `>>> 0` AFTER the mask so the comparison
// is well-defined for high-bit-prefix ids that arrive as
// negative Numbers (cargo route 0x80…, battle 0xa0…, bombing
// 0xc0…) as well as for the positive `prefix` literal passed in.
return await page.evaluate((p: number) => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
const expected = p >>> 0;
return prims.filter(
(prim) =>
prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected,
).length;
}, prefix);
}
test("gear popover toggles a planet kind off and cascades onto its markers", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// 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]);
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 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 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 ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Defaults: fog on; one circle per LOCAL planet, radius
// `30 * driveTech = 300`.
const initialFog = await page.evaluate(
() => window.__galaxyDebug!.getMapFog!().circles,
);
expect(initialFog.length).toBe(2);
expect(initialFog[0].radius).toBe(300);
expect(initialFog[1].radius).toBe(300);
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-visible-hyperspace").click();
// 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,
);
// Toggling back on rebuilds the fog circles for the same planets.
await page.getByTestId("map-toggles-visible-hyperspace").click();
await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 2,
);
});
test("wrap mode radios flip the renderer and the camera centre survives", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
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!(),
);
expect(initial).not.toBeNull();
const startCentre = initial!.camera;
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click();
// `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);
});
test("toggle state persists across a page reload", async ({ page }) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-battle-markers").click();
await page.getByTestId("map-toggles-bombing-markers").click();
// Independent flips: turning battle off must not touch bombing.
expect(
await page.getByTestId("map-toggles-battle-markers").isChecked(),
).toBe(false);
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.waitForFunction(() => {
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
return prims.length > 0;
});
await page.getByTestId("map-toggles-trigger").click();
expect(
await page.getByTestId("map-toggles-battle-markers").isChecked(),
).toBe(false);
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
// Battle X-cross and bombing ring are hidden in the renderer.
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
});
// settledRenderCount waits out the mount/resize paint burst and returns
// the painted-frame count once it stops advancing. The renderer runs
// render-on-demand, so the count goes flat as soon as the scene is
// static; the loop bails after a fixed number of samples so a renderer
// that never settles fails the spec instead of hanging.
async function settledRenderCount(page: Page): Promise<number> {
await page.waitForFunction(
() => (window.__galaxyDebug?.getMapRenderCount?.() ?? 0) > 0,
);
return await page.evaluate(async () => {
const read = (): number =>
window.__galaxyDebug!.getMapRenderCount!() ?? 0;
let prev = read();
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 150));
const cur = read();
if (cur === prev) return cur;
prev = cur;
}
return prev;
});
}
test("render-on-demand: an idle map does not repaint, a content mutation does", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
const settled = await settledRenderCount(page);
// Idle window: no pointer interaction, no toggle. A continuous
// auto-render loop would add ~40 frames over 700ms at 60fps; render
// -on-demand adds none. The +2 slack tolerates a lone stray frame
// (e.g. a late layout settle) while still failing hard if the
// always-on loop ever comes back.
await page.waitForTimeout(700);
const afterIdle = await page.evaluate(
() => window.__galaxyDebug!.getMapRenderCount!(),
);
expect(afterIdle).toBeLessThanOrEqual(settled + 2);
// Toggling the fog mutates the scene graph and must repaint.
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-visible-hyperspace").click();
await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
);
// The repaint lands on the next shared-ticker frame after the fog
// input changed, so poll for the counter to advance rather than
// reading it synchronously (the timing of that frame is racy).
await page.waitForFunction(
(baseline) => window.__galaxyDebug!.getMapRenderCount!() > baseline,
afterIdle,
);
});
test("pan stops immediately on release: no inertia tail after a drag", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
await settledRenderCount(page);
const canvas = page.getByTestId("active-view-map").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;
// Decisive drag with intermediate steps so pixi-viewport's drag
// plugin clears its movement threshold.
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let step = 1; step <= 16; step++) {
await page.mouse.move(cx - (160 * step) / 16, cy - (120 * step) / 16);
}
await page.mouse.up();
// Let the final drag frame flush, then snapshot the camera centre
// and confirm it does not drift over the next ~500ms. Without the
// `decelerate` plugin the viewport freezes the instant the drag
// ends, so the centre is identical; a re-introduced inertia tail
// would coast it by many world units. (If the synthetic drag never
// registered the centre is also static, so the spec never
// false-fails — it only catches a returning inertia tail.)
await page.waitForTimeout(120);
const atRelease = await page.evaluate(
() => window.__galaxyDebug!.getMapCamera!()!.camera,
);
await page.waitForTimeout(500);
const later = await page.evaluate(
() => window.__galaxyDebug!.getMapCamera!()!.camera,
);
expect(Math.abs(later.centerX - atRelease.centerX)).toBeLessThan(1);
expect(Math.abs(later.centerY - atRelease.centerY)).toBeLessThan(1);
});