Files
galaxy-game/ui/frontend/tests/e2e/tables.spec.ts
T
Ilia Denisov 80ed11e3b6
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)
Lights up three previously-stubbed table active views and tightens the
existing one:

  - table-planets: 4 kind checkboxes (own / foreign / uninhabited /
    unknown) + race dropdown that filters the foreign slice; row click
    selects + centres the planet on the map.
  - table-ship-groups: local + foreign groups in one grid, owner
    checkboxes, planet dropdown (destination OR origin), class
    dropdown; on-planet click focuses the destination planet, in-space
    click focuses the ship group itself (camera follows interpolated
    position).
  - table-fleets: own fleets only with the shared planet dropdown;
    on-planet click focuses the planet, in-space click centres the
    camera on the interpolated fleet position without altering the
    selection (no fleet variant in Selected).
  - table-ship-classes: per-row Delete is disabled with a count tooltip
    while at least one local ship group references the class. The
    engine refuses the removal anyway; the UI pre-empts the surface.

Wires the click → map flow through a transient `SelectionStore.focus`
/ `focusPoint` channel that `map.svelte` consumes once on mount —
in-memory only, so an F5 does not re-centre.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:35:38 +02:00

144 lines
4.1 KiB
TypeScript

// F8-10 end-to-end coverage for the planets table → map navigation.
// Boots an authenticated session, mocks the gateway with a small
// planets-only report, navigates to `table → planets`, clicks the
// first row, and asserts the active view switches to the map (which
// also implicitly proves that the `SelectionStore.focus` → map mount
// hand-off lands inside the live shell). Ship-groups and fleets are
// covered by their vitest specs — one e2e is enough to smoke the
// composed flow.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
const SESSION_ID = "f8-10-tables-session";
const GAME_ID = "f8101010-0000-4000-8000-101010101010";
async function mockGateway(page: Page): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "F8-10 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: 1,
};
await page.route(
"**/edge.v1.Gateway/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 payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report":
payload = buildReportPayload({
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
localPlanets: [
{ number: 1, name: "Home", x: 1000, y: 1000 },
],
otherPlanets: [
{
number: 2,
name: "Frontier",
x: 2000,
y: 1500,
owner: "Federation",
},
],
uninhabitedPlanets: [
{ number: 3, name: "Rock", x: 1500, y: 2200 },
],
});
break;
default:
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode: "ok",
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
// Hold SubscribeEvents open — mirrors the pattern in other e2e
// specs to avoid the revocation watcher signing the session out.
await page.route(
"**/edge.v1.Gateway/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,
);
}
test("clicking a row in the planets table opens the map", async ({ page }) => {
await mockGateway(page);
await bootSession(page);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "planets",
}),
GAME_ID,
);
const table = page.getByTestId("planets-table");
await expect(table).toBeVisible();
const rows = page.getByTestId("planets-row");
await expect(rows).toHaveCount(3);
// Click the foreign planet — the data-* stamps let the spec assert
// against a deterministic row regardless of default sort.
await page
.locator('[data-testid="planets-row"][data-number="2"]')
.click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
});