Files
galaxy-game/ui/frontend/tests/e2e/sciences.spec.ts
T
Ilia Denisov 7bea22b0b5 ui/phase-21: sciences CRUD list, designer, and production-picker integration
Lights up the player-defined sciences feature: a table view with sort
and filter, a designer with four percent inputs and a strict
sum-equals-100 gate, and a Research-sub-row integration so the
planet production picker lists the user's sciences alongside the
four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md
(no UpdateScience on the wire — write-once via createScience +
removeScience; percentages instead of fractions; sciences live under
the existing Research segment).

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

424 lines
13 KiB
TypeScript

// 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", <name>)` 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<MockHandle> {
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<void>(() => {});
},
);
return {
get lastCreate() {
return lastCreate;
},
get lastRemove() {
return lastRemove;
},
get lastProduce() {
return lastProduce;
},
};
}
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 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);
});