Files
galaxy-game/ui/frontend/tests/inspector-ship-group-modernize-cost.test.ts
Ilia Denisov 3626998a33 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>
2026-05-10 16:27:55 +02:00

205 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});