// Phase 21 end-to-end coverage for the science CRUD flow + the // production-picker integration. Boots an authenticated session, // mocks the gateway with a single local planet plus an empty // `localScience` projection, navigates to the sciences table, opens // the designer, fills the form (four percentages summing to 100), // and asserts that: // // 1. Save adds a `createScience` row to the local order draft, // auto-syncs through `user.games.order`, and the new science // 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 (sum-not-100 + duplicate name); // 3. setting a planet's production to the new science via the // Research sub-row of the planet inspector dispatches // `setProductionType("SCIENCE", )` and the optimistic // overlay flips the planet's production string immediately; // 4. Delete on a row adds a `removeScience` and the science // disappears from the table; the order tab reflects both rows. 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, CommandPlanetProduce, CommandScienceCreate, CommandScienceRemove, 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 ScienceFixture, } from "./fixtures/report-fbs"; import { buildOrderGetResponsePayload, buildOrderResponsePayload, type CommandResultFixture, } from "./fixtures/order-fbs"; const SESSION_ID = "phase-21-sciences-session"; const GAME_ID = "21212121-2121-2121-2121-212121212121"; interface MockOpts { createOutcome: "applied" | "rejected"; initialSciences?: ScienceFixture[]; } interface MockHandle { get lastCreate(): { name: string; drive: number; weapons: number; shields: number; cargo: number; } | null; get lastRemove(): { name: string } | null; get lastProduce(): { planetNumber: number; productionType: string; subject: string; } | null; } async function mockGateway(page: Page, opts: MockOpts): Promise { const game: GameFixture = { gameId: GAME_ID, gameName: "Phase 21 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 lastProduce: MockHandle["lastProduce"] = null; const reportSciences: ScienceFixture[] = [...(opts.initialSciences ?? [])]; 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: 2000, y: 2000, size: 1000, resources: 5, population: 800, industry: 600, }, ], localScience: reportSciences, }); break; } case "user.games.order": { const decoded = UserGamesOrder.getRootAsUserGamesOrder( new ByteBuffer(req.payloadBytes), ); 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.CommandScienceCreate) { const inner = new CommandScienceCreate(); item.payload(inner); lastCreate = { name: inner.name() ?? "", drive: inner.drive(), weapons: inner.weapons(), shields: inner.shields(), cargo: inner.cargo(), }; const applied = opts.createOutcome === "applied"; fixtures.push({ kind: "createScience", cmdId, name: lastCreate.name, drive: lastCreate.drive, weapons: lastCreate.weapons, shields: lastCreate.shields, cargo: lastCreate.cargo, applied, errorCode: applied ? null : 1, }); continue; } if (payloadType === CommandPayload.CommandScienceRemove) { const inner = new CommandScienceRemove(); item.payload(inner); lastRemove = { name: inner.name() ?? "" }; fixtures.push({ kind: "removeScience", cmdId, name: lastRemove.name, applied: true, errorCode: null, }); continue; } if (payloadType === CommandPayload.CommandPlanetProduce) { const inner = new CommandPlanetProduce(); item.payload(inner); lastProduce = { planetNumber: Number(inner.number()), productionType: String(inner.production()), subject: inner.subject() ?? "", }; fixtures.push({ kind: "setProductionType", cmdId, planetNumber: Number(inner.number()), productionType: "SCIENCE", subject: inner.subject() ?? "", 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(() => {}); }, ); return { get lastCreate() { return lastCreate; }, get lastRemove() { return lastRemove; }, get lastProduce() { return lastProduce; }, }; } async function bootSession(page: Page): Promise { 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 science via the table + designer", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "phase 21 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/sciences`); const tableHost = page.getByTestId("active-view-table"); await expect(tableHost).toBeVisible(); await expect(page.getByTestId("sciences-empty")).toBeVisible(); await page.getByTestId("sciences-new").click(); await expect(page.getByTestId("active-view-designer-science")).toHaveAttribute( "data-mode", "new", ); await page.getByTestId("designer-science-input-name").fill("FirstStep"); await page.getByTestId("designer-science-input-drive").fill("25"); await page.getByTestId("designer-science-input-weapons").fill("25"); await page.getByTestId("designer-science-input-shields").fill("25"); await page.getByTestId("designer-science-input-cargo").fill("25"); const save = page.getByTestId("designer-science-save"); await expect(save).toBeEnabled(); await save.click(); // Returns to the table; the optimistic overlay shows the new science. await expect(page.getByTestId("sciences-table")).toBeVisible(); const row = page.getByTestId("sciences-row"); await expect(row).toHaveAttribute("data-name", "FirstStep"); await expect(page.getByTestId("sciences-cell-drive")).toHaveText("25"); // 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( "FirstStep", ); await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( "applied", ); expect(handle.lastCreate?.name).toBe("FirstStep"); expect(handle.lastCreate?.drive).toBeCloseTo(0.25, 12); // Delete the science 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("sciences-delete").click(); await expect(page.getByTestId("sciences-empty")).toBeVisible(); await page.getByTestId("sidebar-tab-order").click(); await expect(orderTool.getByTestId("order-command-label-1")).toContainText( "FirstStep", ); expect(handle.lastRemove?.name).toBe("FirstStep"); }); test("designer keeps Save disabled while the form is invalid", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "phase 21 spec covers desktop layout; mobile inherits the same store", ); await mockGateway(page, { createOutcome: "applied" }); await bootSession(page); await page.goto(`/games/${GAME_ID}/designer/science`); const save = page.getByTestId("designer-science-save"); await expect(save).toBeDisabled(); // Empty name surfaces the entity-name error. await expect(page.getByTestId("designer-science-error")).toHaveText( "name cannot be empty", ); // Sum off — error stays visible and Save remains disabled. await page.getByTestId("designer-science-input-name").fill("Bad"); await page.getByTestId("designer-science-input-drive").fill("50"); await expect(page.getByTestId("designer-science-error")).toHaveText( "the four percentages must sum to exactly 100", ); await expect(save).toBeDisabled(); // Filling the rest to total 100 enables Save. await page.getByTestId("designer-science-input-weapons").fill("50"); await expect(save).toBeEnabled(); }); test("planet production picker exposes user sciences in the Research sub-row", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "phase 21 spec covers desktop layout; mobile inherits the same store", ); const handle = await mockGateway(page, { createOutcome: "applied", initialSciences: [ { name: "FirstStep", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25 }, ], }); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); // Click the planet on the map canvas to seed the inspector // selection — the Phase 11 map auto-centres on the single planet. const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); if (box === null) throw new Error("canvas has no bounding box"); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); const sidebar = page.getByTestId("sidebar-tool-inspector"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); // Expand the Research segment. await sidebar .getByTestId("inspector-planet-production-segment-research") .click(); // Tech buttons + the user's science button are both rendered. await expect( sidebar.getByTestId("inspector-planet-production-research-drive"), ).toBeVisible(); const scienceButton = sidebar.getByTestId( "inspector-planet-production-science-FirstStep", ); await expect(scienceButton).toBeVisible(); // Click the science → setProductionType("SCIENCE", "FirstStep") // lands in the draft and auto-syncs. await scienceButton.click(); await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep"); expect(handle.lastProduce?.planetNumber).toBe(1); });