// Phase 15 end-to-end coverage for the planet-production flow. Boots // an authenticated session, mocks the lobby + report + order routes // (including a seeded `Scout` ship class so the Build-Ship branch is // reachable), drives a click into the renderer to select a planet, // then walks the segmented control through three production choices. // The final assertion verifies that the order tab carries exactly // one row at all times (the collapse-by-`planetNumber` rule), that // the gateway received the latest choice, and that the row survives // a reload via `user.games.order.get`. 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 { CommandPlanetProduce, PlanetProduction, 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 } from "./fixtures/report-fbs"; import { buildOrderGetResponsePayload, buildOrderResponsePayload, type CommandResultFixture, } from "./fixtures/order-fbs"; const SESSION_ID = "phase-15-production-session"; const GAME_ID = "15151515-1515-1515-1515-151515151515"; const WORLD = 4000; const CENTRE = WORLD / 2; const TURN = 5; const SHIP_CLASS = "Scout"; interface MockHandle { get lastSubmitted(): { productionType: PlanetProduction; subject: string; planetNumber: number; } | null; get submitCount(): number; } async function mockGateway(page: Page): Promise { const game: GameFixture = { gameId: GAME_ID, gameName: "Phase 15 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: TURN, }; let storedOrder: CommandResultFixture[] = []; let lastReportProduction = "Drive"; let lastSubmitted: { productionType: PlanetProduction; subject: string; planetNumber: number; } | null = null; let submitCount = 0; 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: TURN, mapWidth: WORLD, mapHeight: WORLD, localPlanets: [ { number: 17, name: "Earth", x: CENTRE, y: CENTRE, size: 1000, resources: 10, capital: 0, material: 0, population: 850, colonists: 25, industry: 700, production: lastReportProduction, freeIndustry: 175, }, ], localShipClass: [{ name: SHIP_CLASS }], }); break; } case "user.games.order": { const decoded = UserGamesOrder.getRootAsUserGamesOrder( new ByteBuffer(req.payloadBytes), ); submitCount += 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 inner = new CommandPlanetProduce(); item.payload(inner); const productionType = inner.production(); const subject = inner.subject() ?? ""; const planetNumber = Number(inner.number()); lastSubmitted = { productionType, subject, planetNumber }; fixtures.push({ kind: "setProductionType", cmdId, planetNumber, productionType: planetProductionToLiteral(productionType), subject, applied: true, errorCode: null, }); } storedOrder = fixtures; if (lastSubmitted !== null) { lastReportProduction = displayFromSubmitted(lastSubmitted); } 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 lastSubmitted() { return lastSubmitted; }, get submitCount() { return submitCount; }, }; } 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, ); } async function clickPlanetCentre(page: Page): Promise { const canvas = page.locator("canvas"); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); 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); } function planetProductionToLiteral( value: PlanetProduction, ): "MAT" | "CAP" | "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO" | "SCIENCE" | "SHIP" { switch (value) { case PlanetProduction.MAT: return "MAT"; case PlanetProduction.CAP: return "CAP"; case PlanetProduction.DRIVE: return "DRIVE"; case PlanetProduction.WEAPONS: return "WEAPONS"; case PlanetProduction.SHIELDS: return "SHIELDS"; case PlanetProduction.CARGO: return "CARGO"; case PlanetProduction.SCIENCE: return "SCIENCE"; case PlanetProduction.SHIP: return "SHIP"; default: throw new Error(`unexpected production enum ${value}`); } } function displayFromSubmitted(value: { productionType: PlanetProduction; subject: string; }): string { switch (value.productionType) { case PlanetProduction.MAT: return "Material"; case PlanetProduction.CAP: return "Capital"; case PlanetProduction.DRIVE: return "Drive"; case PlanetProduction.WEAPONS: return "Weapons"; case PlanetProduction.SHIELDS: return "Shields"; case PlanetProduction.CARGO: return "Cargo"; case PlanetProduction.SCIENCE: case PlanetProduction.SHIP: return value.subject; default: return ""; } } test("switching production three times collapses to one auto-synced row", async ({ page, }, testInfo) => { test.skip( testInfo.project.name.startsWith("chromium-mobile"), "phase 15 spec covers desktop layout; mobile inherits the same store", ); const handle = await mockGateway(page); await bootSession(page); await page.goto(`/games/${GAME_ID}/map`); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await clickPlanetCentre(page); const sidebar = page.getByTestId("sidebar-tool-inspector"); await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth"); // Initial state: report.production = "Drive" → research segment is // active, sub-row reveals Drive as the highlighted tech. await expect( sidebar.getByTestId("inspector-planet-production-segment-research"), ).toHaveClass(/active/); // Click 1: Industry → CAP await sidebar .getByTestId("inspector-planet-production-segment-industry") .click(); await page.getByTestId("sidebar-tab-order").click(); const orderTool = page.getByTestId("sidebar-tool-order"); await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( 1, ); await expect(orderTool.getByTestId("order-command-label-0")).toContainText( "Capital", ); await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( "applied", ); // Click 2: Materials → MAT (replaces CAP via collapse) await page.getByTestId("sidebar-tab-inspector").click(); await sidebar .getByTestId("inspector-planet-production-segment-materials") .click(); await page.getByTestId("sidebar-tab-order").click(); await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( 1, ); await expect(orderTool.getByTestId("order-command-label-0")).toContainText( "Material", ); // Click 3: Build Ship → expand sub-row → Scout (replaces MAT) await page.getByTestId("sidebar-tab-inspector").click(); await sidebar .getByTestId("inspector-planet-production-segment-ship") .click(); await sidebar .getByTestId(`inspector-planet-production-ship-${SHIP_CLASS}`) .click(); await page.getByTestId("sidebar-tab-order").click(); await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( 1, ); await expect(orderTool.getByTestId("order-command-label-0")).toContainText( SHIP_CLASS, ); await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( "data-sync-status", "synced", ); expect(handle.lastSubmitted).not.toBeNull(); expect(handle.lastSubmitted!.planetNumber).toBe(17); expect(handle.lastSubmitted!.productionType).toBe(PlanetProduction.SHIP); expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS); expect(handle.submitCount).toBeGreaterThanOrEqual(3); // Reload: the layout polls user.games.order.get on boot, so the // row is restored from the server's stored state even when the // local cache is wiped. await page.reload(); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", ); await page.getByTestId("sidebar-tab-order").click(); await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount( 1, ); await expect(orderTool.getByTestId("order-command-label-0")).toContainText( SHIP_CLASS, ); });