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
+84 -14
View File
@@ -1,7 +1,9 @@
// FlatBuffers payload builders for the Phase 14 Playwright suite.
// Mirrors what `pkg/transcoder/order.go` produces in production for
// the `user.games.order` POST response and the
// `user.games.order.get` GET response.
// FlatBuffers payload builders for the Phase 14 / Phase 15 Playwright
// suites. Mirrors what `pkg/transcoder/order.go` produces in production
// for the `user.games.order` POST response and the
// `user.games.order.get` GET response. Phase 15 extends the fixture
// with a `setProductionType` variant so a single mocked gateway can
// echo either rename or production-switch commands back to the client.
import { Builder } from "flatbuffers";
@@ -10,20 +12,46 @@ import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../../../src/proto/galaxy/fbs/order";
export interface CommandResultFixture {
interface CommandResultFixtureBase {
cmdId: string;
planetNumber: number;
name: string;
applied: boolean | null;
errorCode: number | null;
}
export interface PlanetRenameResultFixture extends CommandResultFixtureBase {
kind: "planetRename";
planetNumber: number;
name: string;
}
export interface SetProductionTypeResultFixture
extends CommandResultFixtureBase {
kind: "setProductionType";
planetNumber: number;
productionType:
| "MAT"
| "CAP"
| "DRIVE"
| "WEAPONS"
| "SHIELDS"
| "CARGO"
| "SCIENCE"
| "SHIP";
subject: string;
}
export type CommandResultFixture =
| PlanetRenameResultFixture
| SetProductionTypeResultFixture;
export function buildOrderResponsePayload(
gameId: string,
commands: CommandResultFixture[],
@@ -83,19 +111,61 @@ export function buildOrderGetResponsePayload(
function encodeItem(builder: Builder, c: CommandResultFixture): number {
const cmdIdOffset = builder.createString(c.cmdId);
const nameOffset = builder.createString(c.name);
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(c.planetNumber),
nameOffset,
);
let payloadType: CommandPayload;
let inner: number;
switch (c.kind) {
case "planetRename": {
const nameOffset = builder.createString(c.name);
inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(c.planetNumber),
nameOffset,
);
payloadType = CommandPayload.CommandPlanetRename;
break;
}
case "setProductionType": {
const subjectOffset = builder.createString(c.subject);
inner = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(c.planetNumber),
productionTypeToFBS(c.productionType),
subjectOffset,
);
payloadType = CommandPayload.CommandPlanetProduce;
break;
}
}
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
if (c.errorCode !== null) {
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
}
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
}
function productionTypeToFBS(
value: SetProductionTypeResultFixture["productionType"],
): PlanetProduction {
switch (value) {
case "MAT":
return PlanetProduction.MAT;
case "CAP":
return PlanetProduction.CAP;
case "DRIVE":
return PlanetProduction.DRIVE;
case "WEAPONS":
return PlanetProduction.WEAPONS;
case "SHIELDS":
return PlanetProduction.SHIELDS;
case "CARGO":
return PlanetProduction.CARGO;
case "SCIENCE":
return PlanetProduction.SCIENCE;
case "SHIP":
return PlanetProduction.SHIP;
}
}
+24 -2
View File
@@ -8,8 +8,11 @@
// fixture with the optional rich planet fields (size, resources,
// stockpiles, population, industry, colonists, production, free
// industry) so the inspector e2e can drive the read-only display
// against realistic values. Later phases extend the helper as ships,
// fleets, sciences, etc. land.
// against realistic values. Phase 15 adds a minimal `LocalShipClass`
// projection so the planet inspector's Build-Ship sub-picker has data
// in e2e specs (`name` only — Phase 17 widens this when ship-class
// CRUD lands). Later phases extend the helper as fleets, sciences,
// etc. land.
import { Builder } from "flatbuffers";
@@ -17,6 +20,7 @@ import {
LocalPlanet,
OtherPlanet,
Report,
ShipClass,
UnidentifiedPlanet,
UninhabitedPlanet,
} from "../../../src/proto/galaxy/fbs/report";
@@ -44,6 +48,10 @@ export interface OtherPlanetFixture extends InhabitedFixture {
owner: string;
}
export interface ShipClassFixture {
name: string;
}
export interface ReportFixture {
turn: number;
mapWidth?: number;
@@ -52,6 +60,7 @@ export interface ReportFixture {
otherPlanets?: OtherPlanetFixture[];
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
localShipClass?: ShipClassFixture[];
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -131,6 +140,13 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
},
);
const localShipClassOffsets = (fixture.localShipClass ?? []).map((cls) => {
const name = builder.createString(cls.name);
ShipClass.startShipClass(builder);
ShipClass.addName(builder, name);
return ShipClass.endShipClass(builder);
});
const localVec =
localOffsets.length === 0
? null
@@ -147,6 +163,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
unidentifiedOffsets.length === 0
? null
: Report.createUnidentifiedPlanetVector(builder, unidentifiedOffsets);
const localShipClassVec =
localShipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, localShipClassOffsets);
const totalPlanets =
(fixture.localPlanets ?? []).length +
@@ -163,6 +183,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
if (localShipClassVec !== null)
Report.addLocalShipClass(builder, localShipClassVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
@@ -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,
);
});
+5 -1
View File
@@ -131,6 +131,7 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
lastSubmittedName = submittedName;
const applied = opts.submitOutcome === "applied";
fixtures.push({
kind: "planetRename",
cmdId,
planetNumber: Number(inner.number()),
name: submittedName,
@@ -140,7 +141,10 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
}
if (opts.submitOutcome === "applied") {
storedOrder = fixtures;
lastReportName = fixtures[0]?.name ?? lastReportName;
const head = fixtures[0];
if (head !== undefined && head.kind === "planetRename") {
lastReportName = head.name;
}
}
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
@@ -39,6 +39,7 @@ function withGameState(opts: {
planetCount: 0,
planets: [],
race: opts.race ?? "",
localShipClass: [],
};
store.status = "ready";
}
@@ -73,6 +73,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
+34
View File
@@ -26,6 +26,7 @@ import { UUID } from "../src/proto/galaxy/fbs/common";
import {
LocalPlanet,
Report,
ShipClass,
} from "../src/proto/galaxy/fbs/report";
const listMyGamesSpy = vi.fn();
@@ -102,6 +103,7 @@ function buildReportPayload(opts: {
width?: number;
height?: number;
planets?: PlanetFixture[];
shipClasses?: { name: string }[];
}): Uint8Array {
const builder = new Builder(256);
const planetOffsets = (opts.planets ?? []).map((planet) => {
@@ -115,10 +117,20 @@ function buildReportPayload(opts: {
LocalPlanet.addResources(builder, 0.5);
return LocalPlanet.endLocalPlanet(builder);
});
const shipClassOffsets = (opts.shipClasses ?? []).map((cls) => {
const name = builder.createString(cls.name);
ShipClass.startShipClass(builder);
ShipClass.addName(builder, name);
return ShipClass.endShipClass(builder);
});
const localPlanetVec =
planetOffsets.length === 0
? null
: Report.createLocalPlanetVector(builder, planetOffsets);
const localShipClassVec =
shipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, shipClassOffsets);
Report.startReport(builder);
Report.addTurn(builder, BigInt(opts.turn));
@@ -128,6 +140,9 @@ function buildReportPayload(opts: {
if (localPlanetVec !== null) {
Report.addLocalPlanet(builder, localPlanetVec);
}
if (localShipClassVec !== null) {
Report.addLocalShipClass(builder, localShipClassVec);
}
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
@@ -261,4 +276,23 @@ describe("GameStateStore", () => {
expect(store.status).toBe("error");
expect(store.error).toBe("device session missing");
});
test("decodeReport surfaces the localShipClass projection by name", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(1)]);
const client = makeFakeClient(async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload({
turn: 1,
planets: [{ number: 1, name: "Earth", x: 100, y: 100 }],
shipClasses: [{ name: "Scout" }, { name: "Destroyer" }],
}),
}));
const store = new GameStateStore();
await store.init({ client, cache, gameId: GAME_ID });
expect(store.report?.localShipClass).toEqual([
{ name: "Scout" },
{ name: "Destroyer" },
]);
store.dispose();
});
});
@@ -80,6 +80,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
@@ -0,0 +1,283 @@
// Vitest component coverage for the Phase 15 production-controls
// subsection of the planet inspector. Drives the component against a
// real `OrderDraftStore` (with `fake-indexeddb` standing in for the
// browser's IDB factory) so the collapse-by-`planetNumber` rule and
// the per-row status side-effects are exercised end-to-end.
//
// The active-segment derivation is covered by direct render-and-
// query assertions: the parser is small enough that a table-driven
// pass over the canonical engine display strings catches every
// branch.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportPlanet,
ShipClassSummary,
} from "../src/api/game-state";
import Production from "../src/lib/inspectors/planet/production.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-production-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function localPlanet(
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number">,
): ReportPlanet {
return {
name: "Earth",
x: 0,
y: 0,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 100,
population: 100,
colonists: 0,
production: null,
freeIndustry: 100,
...overrides,
};
}
function mountProduction(
planet: ReportPlanet,
localShipClass: ShipClassSummary[] = [],
) {
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(Production, {
props: { planet, localShipClass },
context,
});
}
describe("planet inspector — production controls", () => {
test("renders the four main segments with localised labels", () => {
const ui = mountProduction(localPlanet({ number: 1 }));
expect(
ui.getByTestId("inspector-planet-production-segment-industry"),
).toHaveTextContent("industry");
expect(
ui.getByTestId("inspector-planet-production-segment-materials"),
).toHaveTextContent("materials");
expect(
ui.getByTestId("inspector-planet-production-segment-research"),
).toHaveTextContent("research");
expect(
ui.getByTestId("inspector-planet-production-segment-ship"),
).toHaveTextContent("build ship");
});
test("Industry click emits a CAP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setProductionType");
if (cmd.kind !== "setProductionType") return;
expect(cmd.planetNumber).toBe(7);
expect(cmd.productionType).toBe("CAP");
expect(cmd.subject).toBe("");
});
test("Materials click emits a MAT setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("MAT");
});
test("Research click reveals the four tech sub-buttons without emitting", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
expect(
ui.queryByTestId("inspector-planet-production-research-row"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-research"),
);
expect(
ui.getByTestId("inspector-planet-production-research-row"),
).toBeInTheDocument();
expect(draft.commands).toHaveLength(0);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-drive"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("DRIVE");
expect(cmd.subject).toBe("");
});
test("Build-Ship segment shows the empty placeholder when no classes designed", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), []);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-ship"),
);
expect(
ui.getByTestId("inspector-planet-production-ship-empty"),
).toBeInTheDocument();
});
test("Build-Ship click on a class emits a SHIP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [
{ name: "Scout" },
{ name: "Destroyer" },
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-ship"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-ship-Scout"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("SHIP");
expect(cmd.subject).toBe("Scout");
});
test("re-clicks on the same planet collapse to the latest entry via the store", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [
{ name: "Scout" },
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-research"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-cargo"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("CARGO");
});
test("active main segment derives from planet.production display string", () => {
const cases: ReadonlyArray<{
production: string | null;
expected: "industry" | "materials" | "research" | "ship" | "none";
}> = [
{ production: "Capital", expected: "industry" },
{ production: "Material", expected: "materials" },
{ production: "Drive", expected: "research" },
{ production: "Weapons", expected: "research" },
{ production: "Shields", expected: "research" },
{ production: "Cargo", expected: "research" },
{ production: "Scout", expected: "ship" },
{ production: "-", expected: "none" },
{ production: null, expected: "none" },
{ production: "UnknownThing", expected: "none" },
];
for (const tc of cases) {
const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }),
[{ name: "Scout" }],
);
const ids: ReadonlyArray<
"industry" | "materials" | "research" | "ship"
> = ["industry", "materials", "research", "ship"];
for (const id of ids) {
const el = ui.getByTestId(
`inspector-planet-production-segment-${id}`,
);
if (tc.expected === id) {
expect(el.classList.contains("active")).toBe(true);
} else {
expect(el.classList.contains("active")).toBe(false);
}
}
ui.unmount();
}
});
test("active research sub-button highlights for known display strings", () => {
const cases: ReadonlyArray<{
production: string;
slug: "drive" | "weapons" | "shields" | "cargo";
}> = [
{ production: "Drive", slug: "drive" },
{ production: "Weapons", slug: "weapons" },
{ production: "Shields", slug: "shields" },
{ production: "Cargo", slug: "cargo" },
];
for (const tc of cases) {
const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }),
);
const el = ui.getByTestId(
`inspector-planet-production-research-${tc.slug}`,
);
expect(el.classList.contains("active")).toBe(true);
ui.unmount();
}
});
test("ship class sub-row matches when production equals a class name", async () => {
const ui = mountProduction(
localPlanet({ number: 1, production: "Scout" }),
[{ name: "Scout" }, { name: "Destroyer" }],
);
expect(
ui.getByTestId("inspector-planet-production-ship-Scout").classList
.contains("active"),
).toBe(true);
expect(
ui
.getByTestId("inspector-planet-production-ship-Destroyer")
.classList.contains("active"),
).toBe(false);
});
});
+27 -8
View File
@@ -61,9 +61,10 @@ describe("planet inspector", () => {
industry: 800,
industryStockpile: 12.5,
materialsStockpile: 30,
production: "drive",
production: "Drive",
freeIndustry: 187.5,
}),
localShipClass: [],
},
});
const section = ui.getByTestId("inspector-planet");
@@ -99,9 +100,10 @@ describe("planet inspector", () => {
expect(
ui.getByTestId("inspector-planet-field-materials_stockpile"),
).toHaveTextContent("30");
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("drive");
// Phase 15: the static "current production" row is replaced by
// the interactive Production component for owned planets.
expect(ui.queryByTestId("inspector-planet-field-production")).toBeNull();
expect(ui.getByTestId("inspector-planet-production")).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-field-free_industry"),
).toHaveTextContent("187.5");
@@ -127,6 +129,7 @@ describe("planet inspector", () => {
production: "weapons",
freeIndustry: 75,
}),
localShipClass: [],
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -138,6 +141,11 @@ describe("planet inspector", () => {
expect(
ui.getByTestId("inspector-planet-field-population"),
).toHaveTextContent("500");
// Non-local planets keep the read-only production row.
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("weapons");
expect(ui.queryByTestId("inspector-planet-production")).toBeNull();
});
test("uninhabited planet hides population, industry, and production rows", () => {
@@ -152,6 +160,7 @@ describe("planet inspector", () => {
industryStockpile: 0,
materialsStockpile: 0,
}),
localShipClass: [],
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -183,6 +192,7 @@ describe("planet inspector", () => {
x: 1234,
y: -5,
}),
localShipClass: [],
},
});
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -210,6 +220,7 @@ describe("planet inspector", () => {
size: 100,
resources: 5,
}),
localShipClass: [],
},
});
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
@@ -238,9 +249,10 @@ describe("planet inspector", () => {
industry: 0,
industryStockpile: 0,
materialsStockpile: 0,
production: "drive",
production: "Drive",
freeIndustry: 0,
}),
localShipClass: [],
},
context,
});
@@ -300,9 +312,10 @@ describe("planet inspector", () => {
industry: 0,
industryStockpile: 0,
materialsStockpile: 0,
production: "drive",
production: "Drive",
freeIndustry: 0,
}),
localShipClass: [],
},
context,
});
@@ -314,13 +327,14 @@ describe("planet inspector", () => {
db.close();
});
test("missing production string falls back to the localised placeholder", () => {
test("non-local planets fall back to the localised production placeholder", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
number: 5,
name: "Idle",
kind: "local",
kind: "other",
owner: "Drift",
size: 800,
resources: 1,
population: 1,
@@ -331,8 +345,13 @@ describe("planet inspector", () => {
production: "",
freeIndustry: 0,
}),
localShipClass: [],
},
});
// Empty production strings collapse to the localised "none"
// placeholder on the read-only path. The local-planet branch
// owns the production surface via the interactive component
// instead and is covered by `inspector-planet-production.test.ts`.
expect(
ui.getByTestId("inspector-planet-field-production"),
).toHaveTextContent("none");
+131
View File
@@ -197,6 +197,137 @@ describe("OrderDraftStore", () => {
store.dispose();
});
test("setProductionType validates locally per the engine's subject rule", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setProductionType",
id: "cap",
planetNumber: 1,
productionType: "CAP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "drive",
planetNumber: 2,
productionType: "DRIVE",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "ship-ok",
planetNumber: 3,
productionType: "SHIP",
subject: "Scout",
});
await store.add({
kind: "setProductionType",
id: "ship-empty",
planetNumber: 4,
productionType: "SHIP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "science-bad",
planetNumber: 5,
productionType: "SCIENCE",
subject: "Bad Name",
});
expect(store.statuses["cap"]).toBe("valid");
expect(store.statuses["drive"]).toBe("valid");
expect(store.statuses["ship-ok"]).toBe("valid");
expect(store.statuses["ship-empty"]).toBe("invalid");
expect(store.statuses["science-bad"]).toBe("invalid");
store.dispose();
});
test("setProductionType collapses to the latest entry per planet", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setProductionType",
id: "first",
planetNumber: 7,
productionType: "CAP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "second",
planetNumber: 7,
productionType: "MAT",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "third",
planetNumber: 7,
productionType: "DRIVE",
subject: "",
});
expect(store.commands).toHaveLength(1);
const only = store.commands[0]!;
expect(only.id).toBe("third");
if (only.kind !== "setProductionType") {
throw new Error("expected setProductionType");
}
expect(only.productionType).toBe("DRIVE");
// Old ids are scrubbed from statuses so the order tab does not
// keep ghost rows.
expect(store.statuses["first"]).toBeUndefined();
expect(store.statuses["second"]).toBeUndefined();
expect(store.statuses["third"]).toBe("valid");
store.dispose();
});
test("setProductionType for different planets stay independent", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setProductionType",
id: "p7-cap",
planetNumber: 7,
productionType: "CAP",
subject: "",
});
await store.add({
kind: "setProductionType",
id: "p9-mat",
planetNumber: 9,
productionType: "MAT",
subject: "",
});
expect(store.commands.map((c) => c.id)).toEqual([
"p7-cap",
"p9-mat",
]);
store.dispose();
});
test("planetRename and setProductionType on the same planet keep both", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "planetRename",
id: "ren",
planetNumber: 7,
name: "Earth",
});
await store.add({
kind: "setProductionType",
id: "prod",
planetNumber: 7,
productionType: "CAP",
subject: "",
});
expect(store.commands.map((c) => c.id)).toEqual(["ren", "prod"]);
expect(store.statuses["ren"]).toBe("valid");
expect(store.statuses["prod"]).toBe("valid");
store.dispose();
});
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [
+89
View File
@@ -11,7 +11,9 @@ import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
PlanetProduction,
UserGamesOrder,
UserGamesOrderGet,
UserGamesOrderGetResponse,
@@ -130,6 +132,93 @@ describe("fetchOrder", () => {
});
});
test("decodes a CommandPlanetProduce envelope into setProductionType", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-prod");
const subjectOffset = builder.createString("Scout");
const inner = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(17),
PlanetProduction.SHIP,
subjectOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetProduce);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(13));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const responsePayload = builder.asUint8Array();
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responsePayload,
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toHaveLength(1);
const cmd = result.commands[0]!;
expect(cmd.kind).toBe("setProductionType");
if (cmd.kind !== "setProductionType") return;
expect(cmd.id).toBe("cmd-prod");
expect(cmd.planetNumber).toBe(17);
expect(cmd.productionType).toBe("SHIP");
expect(cmd.subject).toBe("Scout");
expect(result.updatedAt).toBe(13);
});
test("skips a CommandPlanetProduce with PlanetProduction.UNKNOWN", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-unknown");
const subjectOffset = builder.createString("");
const inner = CommandPlanetProduce.createCommandPlanetProduce(
builder,
BigInt(0),
PlanetProduction.UNKNOWN,
subjectOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetProduce);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(0));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toEqual([]);
});
test("posts a well-formed UserGamesOrderGet payload", async () => {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
+124 -4
View File
@@ -1,16 +1,22 @@
// Vitest unit coverage for the pure `applyOrderOverlay` projection.
// Phase 14 understands `planetRename` only; future phases (set
// production, route updates) will extend the overlay and need
// equivalent cases here.
// Phase 14 introduced the overlay for `planetRename`; Phase 15
// extends it to `setProductionType` and shares the same eligibility
// rule. Future phases (route updates, etc.) will extend the overlay
// and need equivalent cases here.
import { describe, expect, test } from "vitest";
import {
applyOrderOverlay,
productionDisplayFromCommand,
type GameReport,
type ReportPlanet,
} from "../src/api/game-state";
import type { CommandStatus, OrderCommand } from "../src/sync/order-types";
import type {
CommandStatus,
OrderCommand,
ProductionType,
} from "../src/sync/order-types";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
@@ -41,6 +47,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
@@ -153,4 +160,117 @@ describe("applyOrderOverlay", () => {
});
expect(out.planets[0]!.name).toBe("Final");
});
test("setProductionType rewrites planet.production for valid statuses", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 1,
productionType: "DRIVE",
subject: "",
};
for (const status of ["valid", "submitting", "applied"] as const) {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.production).toBe("Drive");
}
});
test("setProductionType skips draft / invalid / rejected statuses", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 1,
productionType: "DRIVE",
subject: "",
};
for (const status of ["draft", "invalid", "rejected"] as const) {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
expect(out.planets[0]!.production).toBe("Capital");
}
});
test("setProductionType applied with subject mirrors the engine's display", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 1,
productionType: "SHIP",
subject: "Scout",
};
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
expect(out.planets[0]!.production).toBe("Scout");
});
test("setProductionType + planetRename for the same planet compose", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const rename: OrderCommand = {
kind: "planetRename",
id: "cmd-rename",
planetNumber: 1,
name: "New-Earth",
};
const setProd: OrderCommand = {
kind: "setProductionType",
id: "cmd-prod",
planetNumber: 1,
productionType: "DRIVE",
subject: "",
};
const out = applyOrderOverlay(report, [rename, setProd], {
"cmd-rename": "applied",
"cmd-prod": "applied",
});
expect(out.planets[0]!.name).toBe("New-Earth");
expect(out.planets[0]!.production).toBe("Drive");
});
test("ignores setProductionType for missing planet (visibility lost)", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth", production: "Capital" }),
]);
const cmd: OrderCommand = {
kind: "setProductionType",
id: "cmd-1",
planetNumber: 99,
productionType: "DRIVE",
subject: "",
};
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
expect(out).toBe(report);
});
});
describe("productionDisplayFromCommand", () => {
const cases: ReadonlyArray<{
productionType: ProductionType;
subject: string;
expected: string;
}> = [
{ productionType: "MAT", subject: "", expected: "Material" },
{ productionType: "CAP", subject: "", expected: "Capital" },
{ productionType: "DRIVE", subject: "", expected: "Drive" },
{ productionType: "WEAPONS", subject: "", expected: "Weapons" },
{ productionType: "SHIELDS", subject: "", expected: "Shields" },
{ productionType: "CARGO", subject: "", expected: "Cargo" },
{ productionType: "SCIENCE", subject: "AlphaSci", expected: "AlphaSci" },
{ productionType: "SHIP", subject: "Scout", expected: "Scout" },
];
for (const tc of cases) {
test(`${tc.productionType}${tc.expected}`, () => {
expect(productionDisplayFromCommand(tc.productionType, tc.subject)).toBe(
tc.expected,
);
});
}
});
+1
View File
@@ -20,6 +20,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
...overrides,
};
}
+92 -1
View File
@@ -11,13 +11,18 @@ import { uuidToHiLo } from "../src/api/game-state";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPlanetProduce,
CommandPlanetRename,
CommandPayload,
PlanetProduction,
UserGamesOrder,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { submitOrder } from "../src/sync/submit";
import type { OrderCommand } from "../src/sync/order-types";
import type {
OrderCommand,
ProductionType,
} from "../src/sync/order-types";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
@@ -178,4 +183,90 @@ describe("submitOrder", () => {
expect(Number(inner.number())).toBe(7);
expect(inner.name()).toBe("Earth");
});
test("encodes setProductionType as CommandPlanetProduce on the wire", async () => {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "setProductionType",
id: "00000000-0000-0000-0000-00000000cccc",
planetNumber: 17,
productionType: "SHIP",
subject: "Scout",
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
expect(captured).not.toBeNull();
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
expect(decoded.commandsLength()).toBe(1);
const item = decoded.commands(0);
expect(item).not.toBeNull();
expect(item!.cmdId()).toBe(cmd.id);
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetProduce);
const inner = new CommandPlanetProduce();
item!.payload(inner);
expect(Number(inner.number())).toBe(17);
expect(inner.production()).toBe(PlanetProduction.SHIP);
expect(inner.subject()).toBe("Scout");
});
test("maps every productionType literal to its FBS enum value", async () => {
const cases: Array<{
productionType: ProductionType;
fbs: PlanetProduction;
subject: string;
}> = [
{ productionType: "MAT", fbs: PlanetProduction.MAT, subject: "" },
{ productionType: "CAP", fbs: PlanetProduction.CAP, subject: "" },
{ productionType: "DRIVE", fbs: PlanetProduction.DRIVE, subject: "" },
{
productionType: "WEAPONS",
fbs: PlanetProduction.WEAPONS,
subject: "",
},
{
productionType: "SHIELDS",
fbs: PlanetProduction.SHIELDS,
subject: "",
},
{ productionType: "CARGO", fbs: PlanetProduction.CARGO, subject: "" },
{
productionType: "SCIENCE",
fbs: PlanetProduction.SCIENCE,
subject: "AlphaSci",
},
{
productionType: "SHIP",
fbs: PlanetProduction.SHIP,
subject: "Scout",
},
];
for (const tc of cases) {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "setProductionType",
id: `id-${tc.productionType}`,
planetNumber: 5,
productionType: tc.productionType,
subject: tc.subject,
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
expect(captured).not.toBeNull();
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
const inner = new CommandPlanetProduce();
decoded.commands(0)!.payload(inner);
expect(inner.production()).toBe(tc.fbs);
expect(inner.subject()).toBe(tc.subject);
}
});
});