Files
galaxy-game/ui/frontend/tests/e2e/ship-classes.spec.ts
Ilia Denisov 785c3483f8 ui/phase-17: ship-class CRUD without calc
Phase 17 lights up the ship-class table and designer active views,
extends the order-draft pipeline with createShipClass and
removeShipClass commands, and projects pending Save/Delete actions
through applyOrderOverlay so the table reflects the player's
intent before auto-sync lands. The plan is corrected in the same
patch: per game/rules.txt, ship classes are designed once and
cannot be edited — the engine has no Update command, so the UI
exposes only Create + Delete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:44:21 +02:00

386 lines
12 KiB
TypeScript

// Phase 17 end-to-end coverage for the ship-class CRUD flow. Boots
// an authenticated session, mocks the gateway with a single local
// planet plus an empty `localShipClass` projection, navigates to
// the ship-classes table, opens the designer, fills the form, and
// asserts that:
//
// 1. Save adds a `createShipClass` row to the local order draft,
// auto-syncs through `user.games.order`, and the new class
// appears in the table immediately (overlay) and in the
// sidebar order tab as `applied`;
// 2. invalid input keeps the Save button disabled and surfaces
// the localised reason;
// 3. Delete on a row adds a `removeShipClass` and the class
// disappears from the table; the order tab reflects both rows;
// 4. a rejected `createShipClass` (engine-side `cmdApplied=false`)
// surfaces as `rejected` in the order tab and the table no
// longer shows the optimistic class.
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 {
CommandPayload,
CommandShipClassCreate,
CommandShipClassRemove,
UserGamesOrder,
UserGamesOrderGet,
} from "../../src/proto/galaxy/fbs/order";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import {
buildReportPayload,
type ShipClassFixture,
} from "./fixtures/report-fbs";
import {
buildOrderGetResponsePayload,
buildOrderResponsePayload,
type CommandResultFixture,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-17-ship-class-session";
const GAME_ID = "17171717-1717-1717-1717-171717171717";
interface MockOpts {
createOutcome: "applied" | "rejected";
initialClasses?: ShipClassFixture[];
}
interface MockHandle {
get lastCreate(): {
name: string;
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
} | null;
get lastRemove(): { name: string } | null;
get submittedCount(): number;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 17 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,
};
let storedOrder: CommandResultFixture[] = [];
let lastCreate: MockHandle["lastCreate"] = null;
let lastRemove: MockHandle["lastRemove"] = null;
let submittedCount = 0;
const reportClasses: ShipClassFixture[] = [...(opts.initialClasses ?? [])];
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: 1,
mapWidth: 4000,
mapHeight: 4000,
race: "Earthlings",
players: [{ name: "Earthlings", drive: 1 }],
localPlanets: [
{
number: 1,
name: "Earth",
x: 1000,
y: 1000,
size: 1000,
resources: 5,
population: 800,
industry: 600,
},
],
localShipClass: reportClasses,
});
break;
}
case "user.games.order": {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(req.payloadBytes),
);
submittedCount += 1;
const length = decoded.commandsLength();
const fixtures: CommandResultFixture[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const cmdId = item.cmdId() ?? "";
const payloadType = item.payloadType();
if (payloadType === CommandPayload.CommandShipClassCreate) {
const inner = new CommandShipClassCreate();
item.payload(inner);
lastCreate = {
name: inner.name() ?? "",
drive: inner.drive(),
armament: Number(inner.armament()),
weapons: inner.weapons(),
shields: inner.shields(),
cargo: inner.cargo(),
};
const applied = opts.createOutcome === "applied";
fixtures.push({
kind: "createShipClass",
cmdId,
name: lastCreate.name,
drive: lastCreate.drive,
armament: lastCreate.armament,
weapons: lastCreate.weapons,
shields: lastCreate.shields,
cargo: lastCreate.cargo,
applied,
errorCode: applied ? null : 1,
});
continue;
}
if (payloadType === CommandPayload.CommandShipClassRemove) {
const inner = new CommandShipClassRemove();
item.payload(inner);
lastRemove = { name: inner.name() ?? "" };
fixtures.push({
kind: "removeShipClass",
cmdId,
name: lastRemove.name,
applied: true,
errorCode: null,
});
continue;
}
}
storedOrder = fixtures;
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
}
case "user.games.order.get": {
UserGamesOrderGet.getRootAsUserGamesOrderGet(
new ByteBuffer(req.payloadBytes),
);
payload = buildOrderGetResponsePayload(
GAME_ID,
storedOrder,
Date.now(),
storedOrder.length > 0,
);
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,
});
},
);
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
return {
get lastCreate() {
return lastCreate;
},
get lastRemove() {
return lastRemove;
},
get submittedCount() {
return submittedCount;
},
};
}
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,
);
await page.evaluate(
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
GAME_ID,
);
}
test("create / list / delete ship class via the table + designer", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 17 spec covers desktop layout; mobile inherits the same store",
);
const handle = await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible();
await expect(page.getByTestId("ship-classes-empty")).toBeVisible();
await page.getByTestId("ship-classes-new").click();
await expect(page.getByTestId("active-view-designer-ship-class")).toHaveAttribute(
"data-mode",
"new",
);
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
await page.getByTestId("designer-ship-class-input-drive").fill("1");
const save = page.getByTestId("designer-ship-class-save");
await expect(save).toBeEnabled();
await save.click();
// Returns to the table; the optimistic overlay shows the new class.
await expect(page.getByTestId("ship-classes-table")).toBeVisible();
const row = page.getByTestId("ship-classes-row");
await expect(row).toHaveAttribute("data-name", "Drone");
await expect(page.getByTestId("ship-classes-cell-drive")).toHaveText("1");
// The auto-sync round-trip lands as applied.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
"Drone",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"applied",
);
expect(handle.lastCreate?.name).toBe("Drone");
expect(handle.lastCreate?.drive).toBe(1);
// Delete the class through the table action; the row disappears
// and the order tab gets a second row.
await page.getByTestId("sidebar-tab-inspector").click();
await page.getByTestId("ship-classes-delete").click();
await expect(page.getByTestId("ship-classes-empty")).toBeVisible();
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-command-label-1")).toContainText(
"Drone",
);
expect(handle.lastRemove?.name).toBe("Drone");
});
test("designer keeps Save disabled while the form is invalid", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 17 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
const save = page.getByTestId("designer-ship-class-save");
await expect(save).toBeDisabled();
// Empty name surfaces the entity-name error.
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
"name cannot be empty",
);
// Mismatched armament / weapons triggers the pair rule.
await page.getByTestId("designer-ship-class-input-name").fill("Bad");
await page.getByTestId("designer-ship-class-input-armament").fill("1");
await page.getByTestId("designer-ship-class-input-drive").fill("1");
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
"armament and weapons must be both zero or both nonzero",
);
await expect(save).toBeDisabled();
// Filling weapons resolves the pair rule.
await page.getByTestId("designer-ship-class-input-weapons").fill("1");
await expect(save).toBeEnabled();
});
test("rejected createShipClass keeps the table empty and surfaces the failure", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 17 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { createOutcome: "rejected" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
await page.getByTestId("designer-ship-class-input-drive").fill("1");
await page.getByTestId("designer-ship-class-save").click();
// Designer's save() calls SvelteKit `goto` to navigate back to
// the table. SPA navigation keeps the per-game `OrderDraftStore`
// alive so the auto-sync round-trip (which flips the status from
// `submitting` to `rejected`) lands while the table is showing.
// Order tab carries a `rejected` row; the optimistic overlay
// drops the class once the engine answers `cmdApplied=false`.
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"rejected",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"error",
);
// Switch sidebar back to inspector so the active-view (table)
// regains focus, and assert the optimistic class is gone.
await page.getByTestId("sidebar-tab-inspector").click();
await expect(page.getByTestId("ship-classes-empty")).toBeVisible();
});