ui/phase-15: planet inspector production controls + order-draft collapse

Adds the second end-to-end command (`setProductionType`) with a
collapse-by-`planetNumber` rule on the order draft, the segmented
production-controls component on the planet inspector, the FBS
encoder/decoder pair for `CommandPlanetProduce`, and the
`localShipClass` projection on `GameReport`. Forecast number is
deferred and tracked in the new `ui/docs/calc-bridge.md`.
This commit is contained in:
Ilia Denisov
2026-05-09 15:54:30 +02:00
parent c4f1409329
commit 915b4372dd
31 changed files with 2200 additions and 76 deletions
@@ -0,0 +1,375 @@
// 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<MockHandle> {
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<void>(() => {});
},
);
return {
get lastSubmitted() {
return lastSubmitted;
},
get submitCount() {
return submitCount;
},
};
}
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,
);
}
async function clickPlanetCentre(page: Page): Promise<void> {
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,
);
});