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>
This commit is contained in:
Ilia Denisov
2026-05-10 21:32:37 +02:00
parent 0509f2cde2
commit 7bea22b0b5
31 changed files with 2751 additions and 71 deletions
+302
View File
@@ -0,0 +1,302 @@
// Vitest coverage for the Phase 21 science designer. Drives the
// component against a real `OrderDraftStore` (with `fake-indexeddb`
// standing in for the browser's IDB factory) so the local-validation
// + auto-sync side-effects are exercised end-to-end. The optimistic
// overlay arrives through a synthetic `RenderedReportSource` instead
// of a live report so the tests do not have to thread a full
// `GameStateStore` boot.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { GameReport, ScienceSummary } from "../src/api/game-state";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.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";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/designer/science"),
params: { id: "g1" } as Record<string, string>,
}));
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
}));
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-designer-science-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
pageMock.params = { id: "g1" };
gotoMock.mockClear();
});
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 science(
overrides: Partial<ScienceSummary> & Pick<ScienceSummary, "name">,
): ScienceSummary {
return {
drive: 0,
weapons: 0,
shields: 0,
cargo: 0,
...overrides,
};
}
function makeReport(localScience: ScienceSummary[] = []): GameReport {
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
localScience,
};
}
function mountDesigner(opts: {
scienceId?: string;
report?: GameReport | null;
}) {
const report = opts.report ?? makeReport();
pageMock.params = opts.scienceId
? { id: "g1", scienceId: opts.scienceId }
: { id: "g1" };
const renderedReport = {
get report() {
return report;
},
};
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
]);
return render(DesignerScience, { context });
}
describe("science designer (new mode)", () => {
test("renders the form with Save disabled by default (empty name + zero sum)", () => {
const ui = mountDesigner({});
expect(ui.getByTestId("active-view-designer-science")).toHaveAttribute(
"data-mode",
"new",
);
expect(ui.getByTestId("designer-science-save")).toBeDisabled();
expect(ui.getByTestId("designer-science-error")).toHaveTextContent(
"name cannot be empty",
);
});
test("Save adds a createScience to the draft after a valid edit", async () => {
const ui = mountDesigner({});
await fireEvent.input(ui.getByTestId("designer-science-input-name"), {
target: { value: "Even" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-drive"), {
target: { value: "25" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-weapons"), {
target: { value: "25" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-shields"), {
target: { value: "25" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-cargo"), {
target: { value: "25" },
});
await waitFor(() =>
expect(ui.getByTestId("designer-science-save")).not.toBeDisabled(),
);
await fireEvent.click(ui.getByTestId("designer-science-save"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "createScience") throw new Error("wrong kind");
expect(cmd.name).toBe("Even");
expect(cmd.drive).toBeCloseTo(0.25, 12);
expect(cmd.weapons).toBeCloseTo(0.25, 12);
expect(cmd.shields).toBeCloseTo(0.25, 12);
expect(cmd.cargo).toBeCloseTo(0.25, 12);
await waitFor(() =>
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"),
);
});
test("sum readout reflects the live total", async () => {
const ui = mountDesigner({});
await fireEvent.input(ui.getByTestId("designer-science-input-drive"), {
target: { value: "30" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-weapons"), {
target: { value: "20" },
});
await waitFor(() =>
expect(ui.getByTestId("designer-science-sum")).toHaveTextContent("50"),
);
});
test("rejects sum that does not equal 100", async () => {
const ui = mountDesigner({});
await fireEvent.input(ui.getByTestId("designer-science-input-name"), {
target: { value: "Bad" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-drive"), {
target: { value: "50" },
});
await waitFor(() =>
expect(ui.getByTestId("designer-science-error")).toHaveTextContent(
"must sum to exactly 100",
),
);
expect(ui.getByTestId("designer-science-save")).toBeDisabled();
});
test("rejects a duplicate name from the overlay before any sync", async () => {
const ui = mountDesigner({
report: makeReport([
science({ name: "Even", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25 }),
]),
});
await fireEvent.input(ui.getByTestId("designer-science-input-name"), {
target: { value: "Even" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-drive"), {
target: { value: "25" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-weapons"), {
target: { value: "25" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-shields"), {
target: { value: "25" },
});
await fireEvent.input(ui.getByTestId("designer-science-input-cargo"), {
target: { value: "25" },
});
await waitFor(() =>
expect(ui.getByTestId("designer-science-error")).toHaveTextContent(
"already exists",
),
);
expect(ui.getByTestId("designer-science-save")).toBeDisabled();
});
test("Cancel navigates back without mutating the draft", async () => {
const ui = mountDesigner({});
await fireEvent.click(ui.getByTestId("designer-science-cancel"));
expect(draft.commands).toHaveLength(0);
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences");
});
});
describe("science designer (view mode)", () => {
test("renders the read-only summary plus Delete + Back affordances", () => {
const ui = mountDesigner({
scienceId: "FirstStep",
report: makeReport([
science({
name: "FirstStep",
drive: 0.222,
weapons: 0.111,
shields: 0.667,
cargo: 0,
}),
]),
});
expect(ui.getByTestId("active-view-designer-science")).toHaveAttribute(
"data-mode",
"view",
);
expect(ui.getByTestId("designer-science-view-name")).toHaveTextContent(
"FirstStep",
);
expect(ui.getByTestId("designer-science-view-drive")).toHaveTextContent(
"22.2",
);
expect(ui.getByTestId("designer-science-view-shields")).toHaveTextContent(
"66.7",
);
expect(ui.getByTestId("designer-science-delete")).toBeInTheDocument();
expect(ui.getByTestId("designer-science-back")).toBeInTheDocument();
});
test("Delete adds a removeScience and navigates back", async () => {
const ui = mountDesigner({
scienceId: "FirstStep",
report: makeReport([
science({ name: "FirstStep", drive: 0.5, shields: 0.5 }),
]),
});
await fireEvent.click(ui.getByTestId("designer-science-delete"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "removeScience") throw new Error("wrong kind");
expect(cmd.name).toBe("FirstStep");
await waitFor(() =>
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"),
);
});
test("renders a not-found message when the science is missing from the overlay", () => {
const ui = mountDesigner({
scienceId: "Ghost",
report: makeReport([]),
});
expect(ui.getByTestId("designer-science-not-found")).toHaveTextContent(
"Ghost",
);
});
});
+41 -1
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
PlanetProduction,
@@ -82,13 +84,29 @@ export interface RemoveShipClassResultFixture extends CommandResultFixtureBase {
name: string;
}
export interface CreateScienceResultFixture extends CommandResultFixtureBase {
kind: "createScience";
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export interface RemoveScienceResultFixture extends CommandResultFixtureBase {
kind: "removeScience";
name: string;
}
export type CommandResultFixture =
| PlanetRenameResultFixture
| SetProductionTypeResultFixture
| SetCargoRouteResultFixture
| RemoveCargoRouteResultFixture
| CreateShipClassResultFixture
| RemoveShipClassResultFixture;
| RemoveShipClassResultFixture
| CreateScienceResultFixture
| RemoveScienceResultFixture;
export function buildOrderResponsePayload(
gameId: string,
@@ -215,6 +233,28 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
payloadType = CommandPayload.CommandShipClassRemove;
break;
}
case "createScience": {
const nameOffset = builder.createString(c.name);
inner = CommandScienceCreate.createCommandScienceCreate(
builder,
nameOffset,
c.drive,
c.weapons,
c.shields,
c.cargo,
);
payloadType = CommandPayload.CommandScienceCreate;
break;
}
case "removeScience": {
const nameOffset = builder.createString(c.name);
inner = CommandScienceRemove.createCommandScienceRemove(
builder,
nameOffset,
);
payloadType = CommandPayload.CommandScienceRemove;
break;
}
}
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
+30 -2
View File
@@ -11,8 +11,9 @@
// 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.
// CRUD lands). Phase 21 adds a `LocalScience` projection so the
// sciences table and the planet production picker's Research sub-row
// have data in e2e specs.
import { Builder } from "flatbuffers";
@@ -23,6 +24,7 @@ import {
Report,
Route,
RouteEntry,
Science,
ShipClass,
UnidentifiedPlanet,
UninhabitedPlanet,
@@ -60,6 +62,14 @@ export interface ShipClassFixture {
cargo?: number;
}
export interface ScienceFixture {
name: string;
drive?: number;
weapons?: number;
shields?: number;
cargo?: number;
}
export interface PlayerFixture {
name: string;
drive?: number;
@@ -84,6 +94,7 @@ export interface ReportFixture {
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
localShipClass?: ShipClassFixture[];
localScience?: ScienceFixture[];
race?: string;
players?: PlayerFixture[];
routes?: RouteFixture[];
@@ -178,6 +189,17 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
return ShipClass.endShipClass(builder);
});
const localScienceOffsets = (fixture.localScience ?? []).map((sci) => {
const name = builder.createString(sci.name);
Science.startScience(builder);
Science.addName(builder, name);
Science.addDrive(builder, sci.drive ?? 0);
Science.addWeapons(builder, sci.weapons ?? 0);
Science.addShields(builder, sci.shields ?? 0);
Science.addCargo(builder, sci.cargo ?? 0);
return Science.endScience(builder);
});
const playerOffsets = (fixture.players ?? []).map((p) => {
const name = builder.createString(p.name);
Player.startPlayer(builder);
@@ -221,6 +243,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
localShipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, localShipClassOffsets);
const localScienceVec =
localScienceOffsets.length === 0
? null
: Report.createLocalScienceVector(builder, localScienceOffsets);
const playerVec =
playerOffsets.length === 0
? null
@@ -251,6 +277,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
if (localShipClassVec !== null)
Report.addLocalShipClass(builder, localShipClassVec);
if (localScienceVec !== null)
Report.addLocalScience(builder, localScienceVec);
if (routeVec !== null) Report.addRoute(builder, routeVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
+423
View File
@@ -0,0 +1,423 @@
// 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);
});
+8 -13
View File
@@ -2,12 +2,13 @@
// stub renders the localised view title plus the `coming soon` body
// copy and exposes a stable `data-testid` so later phases can replace
// the content without renaming the test hook. Phase 17 lit up the
// ship-classes table and the ship-class designer, so the assertions
// for those slugs / components moved to the dedicated suites
// (`table-ship-classes.test.ts`, `designer-ship-class.test.ts`); the
// `table.svelte` router still falls back to the stub for the
// not-yet-implemented entities (planets, ship-groups, fleets,
// sciences, races) and that fallback is exercised here.
// ship-classes table and the ship-class designer; Phase 21 lit up
// the sciences table and the science designer. Their assertions
// moved to dedicated suites (`table-ship-classes.test.ts`,
// `designer-ship-class.test.ts`, `table-sciences.test.ts`,
// `designer-science.test.ts`); the `table.svelte` router still falls
// back to the stub for the remaining entities (planets, ship-groups,
// fleets, races) and that fallback is exercised here.
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte";
@@ -20,7 +21,6 @@ import TableView from "../src/lib/active-view/table.svelte";
import ReportView from "../src/lib/active-view/report.svelte";
import BattleView from "../src/lib/active-view/battle.svelte";
import MailView from "../src/lib/active-view/mail.svelte";
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
beforeEach(() => {
i18n.resetForTests("en");
@@ -56,7 +56,7 @@ describe("active-view stubs", () => {
expect(node).toHaveTextContent("ship groups");
});
test("report / mail / designer-science stubs render their localised titles", () => {
test("report / mail stubs render their localised titles", () => {
const r = render(ReportView);
expect(r.getByTestId("active-view-report")).toHaveTextContent(
"turn report",
@@ -66,11 +66,6 @@ describe("active-view stubs", () => {
expect(m.getByTestId("active-view-mail")).toHaveTextContent(
"diplomatic mail",
);
const sci = render(DesignerScience);
expect(
sci.getByTestId("active-view-designer-science"),
).toHaveTextContent("science designer");
});
test("battle stub stamps the battleId on the host element", () => {
@@ -1,8 +1,8 @@
// EMPTY_SHIP_GROUPS supplies empty arrays for the five ship-group /
// fleet fields added to GameReport in Phase 19. Test fixtures spread
// it into their report objects so the fixture body still focuses on
// the fields under test, without forcing every spec to enumerate
// the full GameReport surface.
// EMPTY_SHIP_GROUPS supplies empty arrays for the ancillary report
// fields added in Phase 19 (ship-groups + fleets) and Phase 21
// (sciences). Test fixtures spread it into their report objects so
// the fixture body still focuses on the fields under test, without
// forcing every spec to enumerate the full GameReport surface.
import type {
ReportIncomingShipGroup,
@@ -10,6 +10,7 @@ import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportUnidentifiedShipGroup,
ScienceSummary,
} from "../../src/api/game-state";
export const EMPTY_SHIP_GROUPS: {
@@ -19,6 +20,7 @@ export const EMPTY_SHIP_GROUPS: {
unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
localFleets: ReportLocalFleet[];
otherRaces: string[];
localScience: ScienceSummary[];
} = {
localShipGroups: [],
otherShipGroups: [],
@@ -26,4 +28,5 @@ export const EMPTY_SHIP_GROUPS: {
unidentifiedShipGroups: [],
localFleets: [],
otherRaces: [],
localScience: [],
};
@@ -17,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type {
ReportPlanet,
ScienceSummary,
ShipClassSummary,
} from "../src/api/game-state";
import Production from "../src/lib/inspectors/planet/production.svelte";
@@ -94,12 +95,13 @@ function shipClass(
function mountProduction(
planet: ReportPlanet,
localShipClass: ShipClassSummary[] = [],
localScience: ScienceSummary[] = [],
) {
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(Production, {
props: { planet, localShipClass },
props: { planet, localShipClass, localScience },
context,
});
}
@@ -65,6 +65,7 @@ describe("planet inspector", () => {
freeIndustry: 187.5,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -138,6 +139,7 @@ describe("planet inspector", () => {
freeIndustry: 75,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -177,6 +179,7 @@ describe("planet inspector", () => {
materialsStockpile: 0,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -217,6 +220,7 @@ describe("planet inspector", () => {
y: -5,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -253,6 +257,7 @@ describe("planet inspector", () => {
resources: 5,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -293,6 +298,7 @@ describe("planet inspector", () => {
freeIndustry: 0,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -364,6 +370,7 @@ describe("planet inspector", () => {
freeIndustry: 0,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -402,6 +409,7 @@ describe("planet inspector", () => {
freeIndustry: 0,
}),
localShipClass: [],
localScience: [],
routes: [],
planets: [],
mapWidth: 1,
@@ -58,6 +58,7 @@ function makeReport(
planetCount: overrides.planets.length,
race: "Earthlings",
localShipClass: [],
localScience: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
@@ -0,0 +1,190 @@
// Vitest coverage for `lib/util/science-validation.ts`. The
// validator is the TS-side mirror of
// `pkg/calc/validator.go.ValidateScienceValues` plus the
// `validateEntityName` rules and a UX-only duplicate-name check.
// The designer composes percentages (`[0, 100]` summing to `100`)
// and the validator returns canonical fractions (`[0, 1]` summing
// to `1.0`) on success — so the exhaustive coverage below also
// pins the percent → fraction conversion contract.
import { describe, expect, test } from "vitest";
import {
SUM_EPSILON_PERCENT,
fractionsToPercent,
validateScience,
type ScienceDraft,
} from "../src/lib/util/science-validation";
function draft(overrides: Partial<ScienceDraft>): ScienceDraft {
return {
name: "FirstStep",
drive: 25,
weapons: 25,
shields: 25,
cargo: 25,
...overrides,
};
}
describe("validateScience", () => {
test("accepts an even-split science and converts to fractions", () => {
const result = validateScience(draft({ name: "Even" }));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.name).toBe("Even");
expect(result.value.drive).toBeCloseTo(0.25, 12);
expect(result.value.weapons).toBeCloseTo(0.25, 12);
expect(result.value.shields).toBeCloseTo(0.25, 12);
expect(result.value.cargo).toBeCloseTo(0.25, 12);
});
test("trims surrounding whitespace from the name", () => {
const result = validateScience(draft({ name: " Beta " }));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.name).toBe("Beta");
});
test("rejects empty name", () => {
const result = validateScience(draft({ name: "" }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("empty");
});
test("rejects name longer than 30 runes", () => {
const result = validateScience(draft({ name: "a".repeat(31) }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("too_long");
});
test("rejects name with whitespace inside", () => {
const result = validateScience(draft({ name: "Big Name" }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("whitespace");
});
test.each([
{ value: -0.1, reason: "drive_value" as const, field: "drive" as const },
{ value: 100.1, reason: "drive_value" as const, field: "drive" as const },
{
value: Number.POSITIVE_INFINITY,
reason: "drive_value" as const,
field: "drive" as const,
},
{
value: Number.NaN,
reason: "weapons_value" as const,
field: "weapons" as const,
},
{
value: -1,
reason: "shields_value" as const,
field: "shields" as const,
},
{ value: 101, reason: "cargo_value" as const, field: "cargo" as const },
])(
"rejects $field = $value with reason $reason",
({ value, reason, field }) => {
const overrides: Partial<ScienceDraft> = {
drive: 25,
weapons: 25,
shields: 25,
cargo: 25,
};
(overrides as Record<typeof field, number>)[field] = value;
const result = validateScience(draft(overrides));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe(reason);
},
);
test("rejects sum off by more than the epsilon", () => {
const result = validateScience(
draft({ drive: 30, weapons: 30, shields: 30, cargo: 5 }),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("sum_not_hundred");
});
test("accepts the canonical First Step fixture from rules.txt", () => {
// 10 Drive + 5 Weapons + 30 Shields + 0 Cargo, normalised:
// 10/45 ≈ 22.222… %, 5/45 ≈ 11.111… %, 30/45 ≈ 66.666… %,
// 0/45 = 0 %. Snapped to one decimal at input time:
// 22.2 / 11.1 / 66.7 / 0 → sum = 100. The float arithmetic is
// well within `SUM_EPSILON_PERCENT`.
const result = validateScience(
draft({ name: "FirstStep", drive: 22.2, weapons: 11.1, shields: 66.7, cargo: 0 }),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.drive).toBeCloseTo(0.222, 6);
expect(result.value.weapons).toBeCloseTo(0.111, 6);
expect(result.value.shields).toBeCloseTo(0.667, 6);
expect(result.value.cargo).toBe(0);
});
test("accepts a 100/0/0/0 single-axis science", () => {
const result = validateScience(
draft({ name: "PureDrive", drive: 100, weapons: 0, shields: 0, cargo: 0 }),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.drive).toBe(1);
});
test("accepts sums within the float tolerance", () => {
// Build a sum that drifts a hair off due to FP arithmetic but
// stays inside `SUM_EPSILON_PERCENT`.
const sumOk = 100 - SUM_EPSILON_PERCENT / 2;
const result = validateScience(
draft({ drive: sumOk, weapons: 0, shields: 0, cargo: 0 }),
);
expect(result.ok).toBe(true);
});
test("flags duplicate names against existingNames", () => {
const result = validateScience(draft({ name: "Beta" }), {
existingNames: ["Alpha", "Beta"],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("duplicate_name");
});
test("compares duplicate names after trimming", () => {
const result = validateScience(draft({ name: " Beta " }), {
existingNames: ["Beta"],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("duplicate_name");
});
test("does not flag duplicates when existingNames is empty", () => {
const result = validateScience(draft({ name: "Beta" }), {
existingNames: [],
});
expect(result.ok).toBe(true);
});
});
describe("fractionsToPercent", () => {
test("inverts the percent → fraction conversion", () => {
const back = fractionsToPercent({
drive: 0.25,
weapons: 0.111,
shields: 0.5,
cargo: 0.139,
});
expect(back.drive).toBeCloseTo(25, 6);
expect(back.weapons).toBeCloseTo(11.1, 6);
expect(back.shields).toBeCloseTo(50, 6);
expect(back.cargo).toBeCloseTo(13.9, 6);
});
});
+215
View File
@@ -0,0 +1,215 @@
// Vitest coverage for the Phase 21 sciences table active view.
// The component renders against a synthetic `RenderedReportSource`
// (so the suite does not need a live `GameStateStore`) and a real
// `OrderDraftStore` (so the per-row Delete affordance exercises
// the `removeScience` add path including persistence).
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { GameReport, ScienceSummary } from "../src/api/game-state";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.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";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const pageMock = vi.hoisted(() => ({
url: new URL("http://localhost/games/g1/table/sciences"),
params: { id: "g1" } as Record<string, string>,
}));
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock("$app/state", () => ({
page: pageMock,
}));
vi.mock("$app/navigation", () => ({
goto: gotoMock,
}));
import TableSciences from "../src/lib/active-view/table-sciences.svelte";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-table-sciences-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
pageMock.params = { id: "g1" };
gotoMock.mockClear();
});
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 science(
overrides: Partial<ScienceSummary> & Pick<ScienceSummary, "name">,
): ScienceSummary {
return {
drive: 0,
weapons: 0,
shields: 0,
cargo: 0,
...overrides,
};
}
function makeReport(localScience: ScienceSummary[]): GameReport {
const baseEmpty = { ...EMPTY_SHIP_GROUPS, localScience };
return {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...baseEmpty,
};
}
function mountTable(report: GameReport | null) {
const renderedReport = {
get report() {
return report;
},
};
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
]);
return render(TableSciences, { context });
}
describe("sciences table", () => {
test("renders a loading placeholder before the report lands", () => {
const ui = mountTable(null);
expect(ui.getByTestId("sciences-loading")).toBeInTheDocument();
});
test("renders an empty placeholder when no sciences are defined", () => {
const ui = mountTable(makeReport([]));
expect(ui.getByTestId("sciences-empty")).toBeInTheDocument();
});
test("renders one row per science with percent-formatted attributes", () => {
const ui = mountTable(
makeReport([
science({
name: "FirstStep",
drive: 0.222,
weapons: 0.111,
shields: 0.667,
cargo: 0,
}),
]),
);
const rows = ui.getAllByTestId("sciences-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveAttribute("data-name", "FirstStep");
expect(ui.getByTestId("sciences-cell-drive")).toHaveTextContent("22.2");
expect(ui.getByTestId("sciences-cell-weapons")).toHaveTextContent("11.1");
expect(ui.getByTestId("sciences-cell-shields")).toHaveTextContent("66.7");
expect(ui.getByTestId("sciences-cell-cargo")).toHaveTextContent("0");
});
test("filters rows by case-insensitive name match", async () => {
const ui = mountTable(
makeReport([
science({ name: "Alpha", drive: 1 }),
science({ name: "Beta", drive: 1 }),
science({ name: "Gamma", drive: 1 }),
]),
);
await fireEvent.input(ui.getByTestId("sciences-filter"), {
target: { value: "ph" },
});
const rows = ui.getAllByTestId("sciences-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveAttribute("data-name", "Alpha");
});
test("toggles sort direction when the same column is clicked twice", async () => {
const ui = mountTable(
makeReport([
science({ name: "Alpha", drive: 0.1 }),
science({ name: "Beta", drive: 0.5 }),
science({ name: "Gamma", drive: 0.3 }),
]),
);
const driveHeader = ui.getByTestId("sciences-column-drive");
await fireEvent.click(driveHeader);
let names = ui
.getAllByTestId("sciences-row")
.map((row) => row.getAttribute("data-name"));
expect(names).toEqual(["Alpha", "Gamma", "Beta"]);
await fireEvent.click(driveHeader);
names = ui
.getAllByTestId("sciences-row")
.map((row) => row.getAttribute("data-name"));
expect(names).toEqual(["Beta", "Gamma", "Alpha"]);
});
test("dblclick on a row navigates to the designer for that science", async () => {
const ui = mountTable(
makeReport([science({ name: "FirstStep", drive: 1 })]),
);
await fireEvent.dblClick(ui.getByTestId("sciences-row"));
expect(gotoMock).toHaveBeenCalledWith(
"/games/g1/designer/science/FirstStep",
);
});
test("delete button adds a removeScience to the draft", async () => {
const ui = mountTable(makeReport([science({ name: "FirstStep", drive: 1 })]));
await fireEvent.click(ui.getByTestId("sciences-delete"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "removeScience") throw new Error("wrong kind");
expect(cmd.name).toBe("FirstStep");
});
test("new button navigates to the empty designer", async () => {
const ui = mountTable(makeReport([]));
await fireEvent.click(ui.getByTestId("sciences-new"));
expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/science");
});
});