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