ui/phase-20: ship-group inspector actions
Eight ship-group operations land on the inspector behind a single inline-form panel: split, send, load, unload, modernize, dismantle, transfer, join fleet. Each action either appends a typed command to the local order draft or surfaces a tooltip explaining the disabled state. Partial-ship operations emit an implicit breakShipGroup command before the targeted action so the engine sees a clean (Break, Action) pair on the wire. `pkg/calc.BlockUpgradeCost` migrates from `game/internal/controller/ship_group_upgrade.go` so the calc bridge can wrap a pure pkg/calc formula; the controller now imports it. The bridge surfaces the function as `core.blockUpgradeCost`, which the inspector calls once per ship block to render the modernize cost preview. `GameReport.otherRaces` is decoded from the report's player block (non-extinct, ≠ self) and feeds the transfer-to-race picker. The planet inspector's stationed-ship rows become clickable for own groups so the actions panel is reachable from the standard click flow (the renderer continues to hide on-planet groups). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// Phase 20 end-to-end coverage for the ship-group Send action.
|
||||
// Loads a synthetic report with a local group of three Frontier
|
||||
// ships in orbit over Earth and a reachable destination planet
|
||||
// (Mars), opens the inspector by clicking the rendered group,
|
||||
// drives the Send form (asking for 2 ships out of 3), picks Mars
|
||||
// through the map-pick service, and asserts the resulting order
|
||||
// draft has both an implicit `breakShipGroup` and the targeted
|
||||
// `sendShipGroup` whose `groupId` references the freshly minted
|
||||
// sub-group ID. The synthetic flow uses a non-UUID game id, so
|
||||
// the auto-sync pipeline skips the network — the assertion
|
||||
// targets the in-memory draft via the order-tab UI.
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SESSION_ID = "phase-20-send-session";
|
||||
|
||||
interface DebugSurface {
|
||||
ready?: boolean;
|
||||
loadSession(): Promise<unknown>;
|
||||
clearSession?(): Promise<void>;
|
||||
setDeviceSessionId(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__galaxyDebug?: DebugSurface;
|
||||
}
|
||||
}
|
||||
|
||||
const SYNTHETIC_FIXTURE = {
|
||||
turn: 1,
|
||||
mapWidth: 200,
|
||||
mapHeight: 200,
|
||||
mapPlanets: 2,
|
||||
race: "Earthlings",
|
||||
player: [
|
||||
{
|
||||
name: "Earthlings",
|
||||
drive: 5,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 1,
|
||||
population: 1000,
|
||||
industry: 1000,
|
||||
planets: 1,
|
||||
relation: "-",
|
||||
votes: 0,
|
||||
extinct: false,
|
||||
},
|
||||
{
|
||||
name: "Aliens",
|
||||
drive: 4,
|
||||
weapons: 2,
|
||||
shields: 1,
|
||||
cargo: 1,
|
||||
population: 800,
|
||||
industry: 800,
|
||||
planets: 1,
|
||||
relation: "-",
|
||||
votes: 0,
|
||||
extinct: false,
|
||||
},
|
||||
],
|
||||
localPlanet: [
|
||||
{
|
||||
number: 1,
|
||||
name: "Earth",
|
||||
x: 100,
|
||||
y: 100,
|
||||
size: 1000,
|
||||
population: 1000,
|
||||
industry: 1000,
|
||||
resources: 10,
|
||||
production: "Capital",
|
||||
capital: 0,
|
||||
material: 0,
|
||||
colonists: 100,
|
||||
freeIndustry: 1000,
|
||||
},
|
||||
],
|
||||
otherPlanet: [
|
||||
{
|
||||
number: 2,
|
||||
name: "Mars",
|
||||
x: 110,
|
||||
y: 100,
|
||||
size: 800,
|
||||
population: 800,
|
||||
industry: 800,
|
||||
resources: 8,
|
||||
production: "Capital",
|
||||
capital: 0,
|
||||
material: 0,
|
||||
colonists: 80,
|
||||
freeIndustry: 800,
|
||||
owner: "Aliens",
|
||||
},
|
||||
],
|
||||
uninhabitedPlanet: [],
|
||||
unidentifiedPlanet: [],
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Frontier",
|
||||
drive: 5,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 1,
|
||||
mass: 12,
|
||||
},
|
||||
],
|
||||
localGroup: [
|
||||
{
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
number: 3,
|
||||
class: "Frontier",
|
||||
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
|
||||
cargo: "-",
|
||||
load: 0,
|
||||
destination: 1,
|
||||
speed: 25,
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
},
|
||||
],
|
||||
otherGroup: [],
|
||||
incomingGroup: [],
|
||||
unidentifiedGroup: [],
|
||||
localFleet: [],
|
||||
};
|
||||
|
||||
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(async () => {
|
||||
const debug = window.__galaxyDebug!;
|
||||
await debug.loadSession();
|
||||
await debug.setDeviceSessionId("phase-20-send-session");
|
||||
});
|
||||
void SESSION_ID;
|
||||
}
|
||||
|
||||
async function loadSyntheticGame(page: Page): Promise<void> {
|
||||
await page.goto("/lobby");
|
||||
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
||||
const file = page.getByTestId("lobby-synthetic-file");
|
||||
await file.setInputFiles({
|
||||
name: "phase20.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
|
||||
});
|
||||
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
}
|
||||
|
||||
// projectWorldToScreen returns the pixel coordinates of a world-space
|
||||
// point (x, y) relative to the document, using the renderer's
|
||||
// debug-surface camera snapshot. Waits for the renderer to register
|
||||
// its debug providers (the in-game shell calls
|
||||
// `installRendererDebugSurface` on mount, then the providers attach
|
||||
// when `mountRenderer` resolves) so the spec is robust against the
|
||||
// async Pixi boot.
|
||||
async function projectWorldToScreen(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<{ x: number; y: number }> {
|
||||
await page.waitForFunction(() => {
|
||||
const dbg = window.__galaxyDebug as unknown as
|
||||
| { getMapCamera(): unknown }
|
||||
| undefined;
|
||||
if (dbg === undefined) return false;
|
||||
return dbg.getMapCamera() !== null;
|
||||
});
|
||||
return page.evaluate(({ wx, wy }) => {
|
||||
const debug = window.__galaxyDebug as unknown as {
|
||||
getMapCamera(): {
|
||||
camera: { centerX: number; centerY: number; scale: number };
|
||||
viewport: { widthPx: number; heightPx: number };
|
||||
canvasOrigin: { x: number; y: number };
|
||||
} | null;
|
||||
};
|
||||
const cam = debug.getMapCamera();
|
||||
if (cam === null) throw new Error("camera unavailable");
|
||||
const sx = cam.canvasOrigin.x + cam.viewport.widthPx / 2 +
|
||||
(wx - cam.camera.centerX) * cam.camera.scale;
|
||||
const sy = cam.canvasOrigin.y + cam.viewport.heightPx / 2 +
|
||||
(wy - cam.camera.centerY) * cam.camera.scale;
|
||||
return { x: sx, y: sy };
|
||||
}, { wx: x, wy: y });
|
||||
}
|
||||
|
||||
test("send 2 of 3 ships emits implicit Break + Send into the order draft", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 20 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
await bootSession(page);
|
||||
await loadSyntheticGame(page);
|
||||
|
||||
// On-planet ship groups are *not* rendered as map primitives (the
|
||||
// renderer hides them to avoid crowding); the player navigates to
|
||||
// them through the planet inspector's stationed-ship row, which
|
||||
// pivots the SelectionStore to the ship-group variant.
|
||||
const earthScreen = await projectWorldToScreen(page, 100, 100);
|
||||
await page.mouse.click(earthScreen.x, earthScreen.y);
|
||||
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
|
||||
await sidebar
|
||||
.getByTestId("inspector-planet-ship-groups-select")
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-ship-group-class"),
|
||||
).toHaveText("Frontier");
|
||||
|
||||
// Open Send.
|
||||
await sidebar.getByTestId("inspector-ship-group-action-send").click();
|
||||
const sendShips = sidebar.getByTestId("inspector-ship-group-form-send-ships");
|
||||
await sendShips.fill("2");
|
||||
|
||||
// Pick Mars on the map.
|
||||
await sidebar.getByTestId("inspector-ship-group-form-send-pick").click();
|
||||
const marsScreen = await projectWorldToScreen(page, 110, 100);
|
||||
await page.mouse.click(marsScreen.x, marsScreen.y);
|
||||
await expect(
|
||||
sidebar.getByTestId("inspector-ship-group-form-send-destination"),
|
||||
).toContainText("Mars");
|
||||
|
||||
// Confirm.
|
||||
await sidebar.getByTestId("inspector-ship-group-form-send-confirm").click();
|
||||
|
||||
// Verify the order tab carries both commands in submission order.
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
|
||||
"split group",
|
||||
);
|
||||
await expect(orderTool.getByTestId("order-command-label-1")).toContainText(
|
||||
"send group",
|
||||
);
|
||||
});
|
||||
@@ -217,5 +217,6 @@ function mockCore(opts: MockCoreOptions): Core & {
|
||||
speed: () => 0,
|
||||
cargoCapacity: () => 0,
|
||||
carryingMass: () => 0,
|
||||
blockUpgradeCost: () => 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
incomingShipGroups: ReportIncomingShipGroup[];
|
||||
unidentifiedShipGroups: ReportUnidentifiedShipGroup[];
|
||||
localFleets: ReportLocalFleet[];
|
||||
otherRaces: string[];
|
||||
} = {
|
||||
localShipGroups: [],
|
||||
otherShipGroups: [],
|
||||
incomingShipGroups: [],
|
||||
unidentifiedShipGroups: [],
|
||||
localFleets: [],
|
||||
otherRaces: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Vitest coverage for Phase 20's ship-group action panel. Exercises
|
||||
// the disabled-with-tooltip rules per action, the implicit-split
|
||||
// pattern (an action targeting fewer ships than the group holds
|
||||
// emits a `breakShipGroup` command before the action), and the
|
||||
// happy-path commits of every variant. The dismantle confirmation
|
||||
// for foreign-COL groups lives in its own file
|
||||
// (`inspector-ship-group-dismantle-confirm.test.ts`); the modernize
|
||||
// cost preview lives in `inspector-ship-group-modernize-cost.test.ts`.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
ReportLocalFleet,
|
||||
ReportLocalShipGroup,
|
||||
ReportPlanet,
|
||||
ShipClassSummary,
|
||||
} from "../src/api/game-state";
|
||||
import ShipGroup, {
|
||||
type ShipGroupSelection,
|
||||
} from "../src/lib/inspectors/ship-group.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
const PLANETS: ReportPlanet[] = [
|
||||
planet({ number: 17, name: "Castle", x: 100, y: 100, kind: "local" }),
|
||||
planet({ number: 99, name: "Outpost", x: 110, y: 110, kind: "other", owner: "Foreign" }),
|
||||
planet({ number: 33, name: "Reach", x: 150, y: 150, kind: "uninhabited" }),
|
||||
];
|
||||
|
||||
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
|
||||
name: "Frontier",
|
||||
drive: 5,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-ship-group-actions-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
draft.dispose();
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function planet(
|
||||
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "name" | "x" | "y" | "kind">,
|
||||
): ReportPlanet {
|
||||
return {
|
||||
owner: null,
|
||||
size: 1000,
|
||||
resources: 5,
|
||||
industryStockpile: 100,
|
||||
materialsStockpile: 100,
|
||||
industry: 100,
|
||||
population: 100,
|
||||
colonists: 100,
|
||||
production: null,
|
||||
freeIndustry: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function localGroup(
|
||||
overrides: Partial<ReportLocalShipGroup> = {},
|
||||
): ReportLocalShipGroup {
|
||||
return {
|
||||
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
count: 3,
|
||||
class: "Frontier",
|
||||
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
|
||||
cargo: "NONE",
|
||||
load: 0,
|
||||
destination: 17,
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 0,
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(
|
||||
group: ReportLocalShipGroup,
|
||||
options: {
|
||||
otherRaces?: string[];
|
||||
localFleets?: ReportLocalFleet[];
|
||||
localPlayerDrive?: number;
|
||||
} = {},
|
||||
) {
|
||||
const selection: ShipGroupSelection = { variant: "local", group };
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
]);
|
||||
return render(ShipGroup, {
|
||||
props: {
|
||||
selection,
|
||||
planets: PLANETS,
|
||||
localShipClass: [SHIP_CLASS_FRONTIER],
|
||||
localFleets: options.localFleets ?? [],
|
||||
otherRaces: options.otherRaces ?? ["Aliens"],
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
localPlayerDrive: options.localPlayerDrive ?? 5,
|
||||
localPlayerWeapons: 1,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 2,
|
||||
},
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ship-group inspector — action enablement", () => {
|
||||
test("non-orbit groups disable every action with the busy tooltip", () => {
|
||||
const ui = mount(localGroup({ state: "In_Space" }));
|
||||
for (const id of [
|
||||
"inspector-ship-group-action-split",
|
||||
"inspector-ship-group-action-send",
|
||||
"inspector-ship-group-action-load",
|
||||
"inspector-ship-group-action-unload",
|
||||
"inspector-ship-group-action-modernize",
|
||||
"inspector-ship-group-action-dismantle",
|
||||
"inspector-ship-group-action-transfer",
|
||||
"inspector-ship-group-action-join-fleet",
|
||||
]) {
|
||||
const button = ui.getByTestId(id);
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("title")).toMatch(/ships are busy/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("send is disabled when no planet is in drive range", () => {
|
||||
const ui = mount(localGroup({ destination: 17 }), { localPlayerDrive: 0 });
|
||||
const button = ui.getByTestId("inspector-ship-group-action-send");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("title")).toMatch(/no planets are within drive range/i);
|
||||
});
|
||||
|
||||
test("transfer is disabled when there are no other races", () => {
|
||||
const ui = mount(localGroup(), { otherRaces: [] });
|
||||
const button = ui.getByTestId("inspector-ship-group-action-transfer");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("title")).toMatch(/no other non-extinct races/i);
|
||||
});
|
||||
|
||||
test("unload is disabled when the group carries no cargo", () => {
|
||||
const ui = mount(localGroup({ cargo: "NONE", load: 0 }));
|
||||
const button = ui.getByTestId("inspector-ship-group-action-unload");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("title")).toMatch(/empty/i);
|
||||
});
|
||||
|
||||
test("unload of colonists is blocked over a foreign planet", () => {
|
||||
const ui = mount(localGroup({ destination: 99, cargo: "COL", load: 1.5 }));
|
||||
const button = ui.getByTestId("inspector-ship-group-action-unload");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("title")).toMatch(/colonists cannot be unloaded over a foreign planet/i);
|
||||
});
|
||||
|
||||
test("load is blocked over a foreign planet", () => {
|
||||
const ui = mount(localGroup({ destination: 99 }));
|
||||
const button = ui.getByTestId("inspector-ship-group-action-load");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button.getAttribute("title")).toMatch(/own or unowned planets/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ship-group inspector — implicit split + action", () => {
|
||||
test("split with K=1 of 3 emits a single breakShipGroup", async () => {
|
||||
const ui = mount(localGroup({ count: 3 }));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-split"));
|
||||
const input = ui.getByTestId("inspector-ship-group-form-split-ships") as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "1" } });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-split-confirm"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("breakShipGroup");
|
||||
if (cmd.kind !== "breakShipGroup") return;
|
||||
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
expect(cmd.quantity).toBe(1);
|
||||
});
|
||||
|
||||
test("dismantle on the whole group emits a single dismantleShipGroup", async () => {
|
||||
const ui = mount(localGroup({ count: 2 }));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("dismantleShipGroup");
|
||||
if (cmd.kind !== "dismantleShipGroup") return;
|
||||
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
test("dismantle on a subset emits implicit Break + Dismantle on the new group", async () => {
|
||||
const ui = mount(localGroup({ count: 3 }));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
|
||||
const input = ui.getByTestId("inspector-ship-group-form-dismantle-ships") as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "2" } });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(2));
|
||||
const [breakCmd, action] = draft.commands;
|
||||
if (breakCmd?.kind !== "breakShipGroup") throw new Error("expected break first");
|
||||
if (action?.kind !== "dismantleShipGroup") throw new Error("expected dismantle second");
|
||||
expect(breakCmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
expect(breakCmd.quantity).toBe(2);
|
||||
expect(action.groupId).toBe(breakCmd.newGroupId);
|
||||
});
|
||||
|
||||
test("transfer to the only available race emits a transferShipGroup", async () => {
|
||||
const ui = mount(localGroup(), { otherRaces: ["Aliens"] });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-transfer"));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-transfer-confirm"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
if (cmd.kind !== "transferShipGroup") throw new Error("wrong kind");
|
||||
expect(cmd.acceptor).toBe("Aliens");
|
||||
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
test("join fleet with a fresh name emits joinFleetShipGroup", async () => {
|
||||
const ui = mount(localGroup());
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-join-fleet"));
|
||||
const input = ui.getByTestId("inspector-ship-group-form-join-fleet-new") as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "Vanguard" } });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-form-join-fleet-confirm"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
if (cmd.kind !== "joinFleetShipGroup") throw new Error("wrong kind");
|
||||
expect(cmd.name).toBe("Vanguard");
|
||||
expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// Vitest coverage for the Phase 20 dismantle confirmation. The
|
||||
// inspector requires an explicit second click ("colonists die") when
|
||||
// the player tries to dismantle a colonist-laden group over a
|
||||
// foreign planet — engine rule reference:
|
||||
// `controller/ship_group.go.shipGroupDismantle:177-179` (over a
|
||||
// foreign planet, `UnloadColonists` is not called and the cargo is
|
||||
// lost).
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
ReportLocalShipGroup,
|
||||
ReportPlanet,
|
||||
ShipClassSummary,
|
||||
} from "../src/api/game-state";
|
||||
import ShipGroup, {
|
||||
type ShipGroupSelection,
|
||||
} from "../src/lib/inspectors/ship-group.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
const PLANETS: ReportPlanet[] = [
|
||||
{
|
||||
number: 99,
|
||||
name: "Outpost",
|
||||
x: 100,
|
||||
y: 100,
|
||||
kind: "other",
|
||||
owner: "Foreign",
|
||||
size: 500,
|
||||
resources: 5,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
industry: 500,
|
||||
population: 500,
|
||||
colonists: 100,
|
||||
production: "Capital",
|
||||
freeIndustry: 500,
|
||||
},
|
||||
{
|
||||
number: 17,
|
||||
name: "Castle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: 1000,
|
||||
resources: 5,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
industry: 1000,
|
||||
population: 1000,
|
||||
colonists: 100,
|
||||
production: "Capital",
|
||||
freeIndustry: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
|
||||
name: "Frontier",
|
||||
drive: 5,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-ship-group-dismantle-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
draft.dispose();
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function group(
|
||||
overrides: Partial<ReportLocalShipGroup> = {},
|
||||
): ReportLocalShipGroup {
|
||||
return {
|
||||
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
count: 2,
|
||||
class: "Frontier",
|
||||
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
|
||||
cargo: "COL",
|
||||
load: 1.5,
|
||||
destination: 99,
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 0,
|
||||
mass: 12,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(g: ReportLocalShipGroup) {
|
||||
const selection: ShipGroupSelection = { variant: "local", group: g };
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
]);
|
||||
return render(ShipGroup, {
|
||||
props: {
|
||||
selection,
|
||||
planets: PLANETS,
|
||||
localShipClass: [SHIP_CLASS_FRONTIER],
|
||||
localFleets: [],
|
||||
otherRaces: ["Aliens"],
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
localPlayerDrive: 5,
|
||||
localPlayerWeapons: 1,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 2,
|
||||
},
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ship-group inspector — dismantle confirmation", () => {
|
||||
test("first click on dismantle of foreign-COL group shows the warning and adds nothing", async () => {
|
||||
const ui = mount(group());
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
|
||||
expect(
|
||||
ui.getByTestId("inspector-ship-group-form-dismantle-warning"),
|
||||
).toBeInTheDocument();
|
||||
const confirm = ui.getByTestId(
|
||||
"inspector-ship-group-form-dismantle-confirm",
|
||||
);
|
||||
expect(confirm).toHaveTextContent(/colonists die/i);
|
||||
await fireEvent.click(confirm);
|
||||
expect(draft.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("second click on the colonists-die confirm emits dismantleShipGroup", async () => {
|
||||
const ui = mount(group());
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
|
||||
const confirm = ui.getByTestId(
|
||||
"inspector-ship-group-form-dismantle-confirm",
|
||||
);
|
||||
await fireEvent.click(confirm);
|
||||
await fireEvent.click(confirm);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("dismantleShipGroup");
|
||||
});
|
||||
|
||||
test("dismantle over own planet skips the warning even with COL aboard", async () => {
|
||||
const ui = mount(group({ destination: 17 }));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
|
||||
expect(
|
||||
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
|
||||
).toBeNull();
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
|
||||
);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
expect(draft.commands[0]!.kind).toBe("dismantleShipGroup");
|
||||
});
|
||||
|
||||
test("dismantle over foreign planet without colonists skips the warning", async () => {
|
||||
const ui = mount(group({ cargo: "NONE", load: 0 }));
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
|
||||
expect(
|
||||
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
|
||||
).toBeNull();
|
||||
await fireEvent.click(
|
||||
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
|
||||
);
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// Vitest coverage for the Phase 20 modernize cost preview. The
|
||||
// preview line in the inspector calls `core.blockUpgradeCost` once
|
||||
// per ship block and multiplies the per-ship total by the number of
|
||||
// targeted ships. The preview hides when `Core` is unavailable; when
|
||||
// `tech === "ALL"` the targets are the player's race tech levels;
|
||||
// otherwise only the picked block contributes to the cost.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
ReportLocalShipGroup,
|
||||
ReportPlanet,
|
||||
ShipClassSummary,
|
||||
} from "../src/api/game-state";
|
||||
import ShipGroup, {
|
||||
type ShipGroupSelection,
|
||||
} from "../src/lib/inspectors/ship-group.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
||||
import type { Core } from "../src/platform/core/index";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
const PLANETS: ReportPlanet[] = [
|
||||
{
|
||||
number: 17,
|
||||
name: "Castle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: 1000,
|
||||
resources: 5,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
industry: 1000,
|
||||
population: 1000,
|
||||
colonists: 0,
|
||||
production: "Capital",
|
||||
freeIndustry: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
const SHIP_CLASS_CRUISER: ShipClassSummary = {
|
||||
name: "Cruiser",
|
||||
drive: 5,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 5,
|
||||
cargo: 5,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-ship-group-modernize-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
draft.dispose();
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function group(
|
||||
overrides: Partial<ReportLocalShipGroup> = {},
|
||||
): ReportLocalShipGroup {
|
||||
return {
|
||||
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
count: 4,
|
||||
class: "Cruiser",
|
||||
tech: { drive: 1, weapons: 0, shields: 1, cargo: 1 },
|
||||
cargo: "NONE",
|
||||
load: 0,
|
||||
destination: 17,
|
||||
origin: null,
|
||||
range: null,
|
||||
speed: 0,
|
||||
mass: 25,
|
||||
state: "In_Orbit",
|
||||
fleet: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// stubCore mirrors `pkg/calc/ship.go.BlockUpgradeCost` exactly so the
|
||||
// preview line shows the same number the WASM bridge would produce.
|
||||
// The other Core methods are no-ops because the modernize preview
|
||||
// only consults `weaponsBlockMass` (returns null when armament is
|
||||
// zero) and `blockUpgradeCost`.
|
||||
function stubCore(): Core {
|
||||
return {
|
||||
signRequest: () => new Uint8Array(),
|
||||
verifyResponse: () => true,
|
||||
verifyEvent: () => true,
|
||||
verifyPayloadHash: () => true,
|
||||
driveEffective: ({ drive, driveTech }) => drive * driveTech,
|
||||
emptyMass: () => 0,
|
||||
weaponsBlockMass: ({ weapons, armament }) => {
|
||||
if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) {
|
||||
return null;
|
||||
}
|
||||
return (armament + 1) * (weapons / 2);
|
||||
},
|
||||
fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass,
|
||||
speed: () => 0,
|
||||
cargoCapacity: () => 0,
|
||||
carryingMass: () => 0,
|
||||
blockUpgradeCost: ({ blockMass, currentTech, targetTech }) => {
|
||||
if (blockMass === 0 || targetTech <= currentTech) return 0;
|
||||
return (1 - currentTech / targetTech) * 10 * blockMass;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mount(
|
||||
g: ReportLocalShipGroup,
|
||||
options: { core?: Core | null } = {},
|
||||
) {
|
||||
const selection: ShipGroupSelection = { variant: "local", group: g };
|
||||
const holder = new CoreHolder();
|
||||
if (options.core !== undefined) holder.set(options.core);
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[CORE_CONTEXT_KEY, holder],
|
||||
]);
|
||||
return render(ShipGroup, {
|
||||
props: {
|
||||
selection,
|
||||
planets: PLANETS,
|
||||
localShipClass: [SHIP_CLASS_CRUISER],
|
||||
localFleets: [],
|
||||
otherRaces: [],
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
localPlayerDrive: 2,
|
||||
localPlayerWeapons: 2,
|
||||
localPlayerShields: 2,
|
||||
localPlayerCargo: 2,
|
||||
},
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ship-group inspector — modernize cost preview", () => {
|
||||
test("ALL upgrade preview matches the BlockUpgradeCost formula × ship count", async () => {
|
||||
// drive: mass=5 current=1 target=2 → (1 - 0.5) * 10 * 5 = 25
|
||||
// shields: mass=5 current=1 target=2 → 25
|
||||
// cargo: mass=5 current=1 target=2 → 25
|
||||
// weapons: armament=0 weapons=0 → block mass 0 → 0
|
||||
// per-ship = 75; group of 4 → 300
|
||||
const ui = mount(group(), { core: stubCore() });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
|
||||
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
|
||||
expect(preview).toHaveTextContent("300");
|
||||
});
|
||||
|
||||
test("per-block tech with custom level uses only that block", async () => {
|
||||
// DRIVE only, target=2: 25 per ship × 4 = 100.
|
||||
const ui = mount(group(), { core: stubCore() });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
|
||||
await fireEvent.change(
|
||||
ui.getByTestId("inspector-ship-group-form-modernize-tech"),
|
||||
{ target: { value: "DRIVE" } },
|
||||
);
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("inspector-ship-group-form-modernize-level"),
|
||||
{ target: { value: "2" } },
|
||||
);
|
||||
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
|
||||
expect(preview).toHaveTextContent("100");
|
||||
});
|
||||
|
||||
test("preview is unavailable when Core is not loaded", async () => {
|
||||
const ui = mount(group(), { core: null });
|
||||
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-modernize"));
|
||||
const preview = ui.getByTestId("inspector-ship-group-form-modernize-cost");
|
||||
expect(preview).toHaveTextContent(/preview unavailable/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
// Vitest coverage for the Phase 20 ship-group command shapes —
|
||||
// `validateCommand` for each of the eight new variants. The
|
||||
// validator is invoked through the public `OrderDraftStore.add`
|
||||
// path so a regression in either layer surfaces here.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { OrderDraftStore } from "../src/sync/order-draft.svelte";
|
||||
import type { OrderCommand } from "../src/sync/order-types";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
const NEW_GROUP_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-validate-ship-group-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
async function statusOf(cmd: OrderCommand): Promise<string> {
|
||||
await draft.add(cmd);
|
||||
return draft.statuses[cmd.id]!;
|
||||
}
|
||||
|
||||
describe("validateCommand — ship-group variants", () => {
|
||||
test("breakShipGroup with positive quantity is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "breakShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
newGroupId: NEW_GROUP_ID,
|
||||
quantity: 2,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("breakShipGroup with quantity 0 is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "breakShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
newGroupId: NEW_GROUP_ID,
|
||||
quantity: 0,
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("breakShipGroup with same source and new id is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "breakShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
newGroupId: GROUP_ID,
|
||||
quantity: 1,
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("sendShipGroup with positive destination is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "sendShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 7,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("sendShipGroup to planet 0 is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "sendShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 0,
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("loadShipGroup with valid cargo and quantity is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "loadShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
cargo: "COL",
|
||||
quantity: 1.5,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("loadShipGroup with zero quantity is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "loadShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
cargo: "COL",
|
||||
quantity: 0,
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("unloadShipGroup with positive quantity is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "unloadShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
quantity: 0.5,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("upgradeShipGroup ALL with level 0 is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "upgradeShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
tech: "ALL",
|
||||
level: 0,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("upgradeShipGroup ALL with non-zero level is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "upgradeShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
tech: "ALL",
|
||||
level: 2,
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("upgradeShipGroup DRIVE with positive level is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "upgradeShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
tech: "DRIVE",
|
||||
level: 1.5,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("upgradeShipGroup DRIVE with level 0 is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "upgradeShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
tech: "DRIVE",
|
||||
level: 0,
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("dismantleShipGroup with valid uuid is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "dismantleShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("transferShipGroup with valid acceptor name is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "transferShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
acceptor: "Aliens",
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("transferShipGroup with empty acceptor is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "transferShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
acceptor: "",
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
|
||||
test("joinFleetShipGroup with valid name is valid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "joinFleetShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
name: "Vanguard",
|
||||
}),
|
||||
).toBe("valid");
|
||||
});
|
||||
|
||||
test("joinFleetShipGroup with empty name is invalid", async () => {
|
||||
expect(
|
||||
await statusOf({
|
||||
kind: "joinFleetShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
name: "",
|
||||
}),
|
||||
).toBe("invalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
// Vitest round-trip coverage for the eight Phase 20 ship-group
|
||||
// command shapes. The encoder lives in `sync/submit.ts`; the
|
||||
// decoder lives in `sync/order-load.ts`. We capture the request
|
||||
// bytes the encoder produces, re-emit them inside a
|
||||
// `UserGamesOrderGetResponse` envelope, and feed that to
|
||||
// `fetchOrder`. The decoded command must match the original — any
|
||||
// drift between encoder and decoder fails here first.
|
||||
|
||||
import { Builder, ByteBuffer } from "flatbuffers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import { uuidToHiLo } from "../src/api/game-state";
|
||||
import { UUID } from "../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPayload,
|
||||
CommandShipGroupBreak,
|
||||
CommandShipGroupDismantle,
|
||||
CommandShipGroupJoinFleet,
|
||||
CommandShipGroupLoad,
|
||||
CommandShipGroupSend,
|
||||
CommandShipGroupTransfer,
|
||||
CommandShipGroupUnload,
|
||||
CommandShipGroupUpgrade,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGetResponse,
|
||||
UserGamesOrderResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
import { fetchOrder } from "../src/sync/order-load";
|
||||
import { submitOrder } from "../src/sync/submit";
|
||||
import type { OrderCommand } from "../src/sync/order-types";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
|
||||
function mockClient(
|
||||
executeCommand: (
|
||||
messageType: string,
|
||||
payload: Uint8Array,
|
||||
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
|
||||
): GalaxyClient {
|
||||
return { executeCommand } as unknown as GalaxyClient;
|
||||
}
|
||||
|
||||
// captureRequestBytes runs submitOrder against a mock that records
|
||||
// the outgoing payload, then returns those bytes (which are a valid
|
||||
// `UserGamesOrder` envelope).
|
||||
async function captureRequestBytes(cmds: OrderCommand[]): Promise<Uint8Array> {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_msg: string, payload: Uint8Array) => {
|
||||
captured = payload;
|
||||
const builder = new Builder(64);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(0));
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
return { resultCode: "ok", payloadBytes: builder.asUint8Array() };
|
||||
});
|
||||
const result = await submitOrder(mockClient(exec), GAME_ID, cmds);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(captured).not.toBeNull();
|
||||
return captured!;
|
||||
}
|
||||
|
||||
// wrapAsGetResponse rebuilds the captured `UserGamesOrder` inside a
|
||||
// `UserGamesOrderGetResponse` envelope by walking each
|
||||
// `CommandItem`, copying its identity fields, and re-packing each
|
||||
// payload through `unpack().pack(builder)` — the FBS-generated
|
||||
// helper that round-trips a typed table into a fresh builder.
|
||||
function wrapAsGetResponse(orderBytes: Uint8Array): Uint8Array {
|
||||
const order = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new ByteBuffer(orderBytes),
|
||||
);
|
||||
const builder = new Builder(256);
|
||||
const itemOffsets: number[] = [];
|
||||
for (let i = 0; i < order.commandsLength(); i++) {
|
||||
const item = order.commands(i);
|
||||
if (item === null) continue;
|
||||
const cmdIdOffset = builder.createString(item.cmdId() ?? "");
|
||||
const payloadType = item.payloadType();
|
||||
const payloadOffset = packPayload(builder, item, payloadType);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addPayloadType(builder, payloadType);
|
||||
CommandItem.addPayload(builder, payloadOffset);
|
||||
itemOffsets.push(CommandItem.endCommandItem(builder));
|
||||
}
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, order.updatedAt());
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, true);
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
const resOffset =
|
||||
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
||||
builder.finish(resOffset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
function packPayload(
|
||||
builder: Builder,
|
||||
item: NonNullable<ReturnType<UserGamesOrder["commands"]>>,
|
||||
payloadType: CommandPayload,
|
||||
): number {
|
||||
switch (payloadType) {
|
||||
case CommandPayload.CommandShipGroupBreak: {
|
||||
const inner = new CommandShipGroupBreak();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupSend: {
|
||||
const inner = new CommandShipGroupSend();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupLoad: {
|
||||
const inner = new CommandShipGroupLoad();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupUnload: {
|
||||
const inner = new CommandShipGroupUnload();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupUpgrade: {
|
||||
const inner = new CommandShipGroupUpgrade();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupDismantle: {
|
||||
const inner = new CommandShipGroupDismantle();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupTransfer: {
|
||||
const inner = new CommandShipGroupTransfer();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
case CommandPayload.CommandShipGroupJoinFleet: {
|
||||
const inner = new CommandShipGroupJoinFleet();
|
||||
item.payload(inner);
|
||||
return inner.unpack().pack(builder);
|
||||
}
|
||||
default:
|
||||
throw new Error(`unsupported payload type ${payloadType}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function roundTrip(cmd: OrderCommand): Promise<OrderCommand> {
|
||||
const requestBytes = await captureRequestBytes([cmd]);
|
||||
const responseBytes = wrapAsGetResponse(requestBytes);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: responseBytes,
|
||||
}));
|
||||
const result = await fetchOrder(mockClient(exec), GAME_ID, 0);
|
||||
expect(result.commands).toHaveLength(1);
|
||||
return result.commands[0]!;
|
||||
}
|
||||
|
||||
describe("submit + order-load round-trip — ship-group commands", () => {
|
||||
test("breakShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "breakShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
newGroupId: "11112222-3333-4444-5555-666677778888",
|
||||
quantity: 3,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("sendShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "sendShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
destinationPlanetNumber: 42,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("loadShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "loadShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
cargo: "MAT",
|
||||
quantity: 12.5,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("unloadShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "unloadShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
quantity: 6.5,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("upgradeShipGroup ALL", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "upgradeShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
tech: "ALL",
|
||||
level: 0,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("upgradeShipGroup DRIVE level 1.5", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "upgradeShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
tech: "DRIVE",
|
||||
level: 1.5,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("dismantleShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "dismantleShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("transferShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "transferShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
acceptor: "Aliens",
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
|
||||
test("joinFleetShipGroup", async () => {
|
||||
const cmd: OrderCommand = {
|
||||
kind: "joinFleetShipGroup",
|
||||
id: crypto.randomUUID(),
|
||||
groupId: GROUP_ID,
|
||||
name: "Vanguard",
|
||||
};
|
||||
expect(await roundTrip(cmd)).toEqual(cmd);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user