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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user