feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
computeCalculator,
|
||||
computePlanetBuild,
|
||||
type CalculatorInput,
|
||||
} from "../src/lib/calculator/calc-model";
|
||||
import { makeFakeCore } from "./fake-core";
|
||||
|
||||
function input(overrides: Partial<CalculatorInput> = {}): CalculatorInput {
|
||||
return {
|
||||
blocks: { drive: 10, armament: 0, weapons: 0, shields: 5, cargo: 5 },
|
||||
driveTech: 1.2,
|
||||
weaponsTech: 1.5,
|
||||
shieldsTech: 1,
|
||||
cargoTech: 1,
|
||||
loadMode: "full",
|
||||
customLoad: 0,
|
||||
lock: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeCalculator forward", () => {
|
||||
test("returns null outputs without a Core", () => {
|
||||
const result = computeCalculator(input(), null);
|
||||
expect(result.outputs).toBeNull();
|
||||
expect(result.valuesValid).toBe(false);
|
||||
});
|
||||
|
||||
test("computes outputs for a valid design", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(input(), core);
|
||||
expect(result.valuesValid).toBe(true);
|
||||
expect(result.outputs).not.toBeNull();
|
||||
// empty mass = drive + shields + cargo = 20 (no weapons block).
|
||||
expect(result.outputs?.emptyMass).toBeCloseTo(20, 9);
|
||||
// cargo capacity = 1 * (5 + 25/20) = 6.25, full load mass = 26.25.
|
||||
expect(result.load).toBeCloseTo(6.25, 9);
|
||||
expect(result.outputs?.loadedMass).toBeCloseTo(26.25, 9);
|
||||
});
|
||||
|
||||
test("hides outputs when blocks are invalid (armament without weapons)", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ blocks: { drive: 10, armament: 3, weapons: 0, shields: 5, cargo: 5 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.valuesValid).toBe(false);
|
||||
expect(result.valueReason).toBe("armament_weapons_pair");
|
||||
expect(result.outputs).toBeNull();
|
||||
});
|
||||
|
||||
test("empty load mode yields loaded mass equal to empty mass", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(input({ loadMode: "empty" }), core);
|
||||
expect(result.load).toBe(0);
|
||||
expect(result.outputs?.loadedMass).toBeCloseTo(
|
||||
result.outputs?.emptyMass ?? -1,
|
||||
9,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCalculator goal-seek", () => {
|
||||
test("attack lock back-solves the weapons block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("weapons");
|
||||
// weapons = 30 / weaponsTech(1.5) = 20.
|
||||
expect(result.blocks.weapons).toBeCloseTo(20, 9);
|
||||
expect(result.outputs?.attack).toBeCloseTo(30, 6);
|
||||
});
|
||||
|
||||
test("loaded-speed lock back-solves the drive block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "speedLoaded", value: 5 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("drive");
|
||||
expect(result.outputs?.speedLoaded).toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
test("defence lock back-solves the shields block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "defense", value: 4 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(true);
|
||||
expect(result.computedInput).toBe("shields");
|
||||
expect(result.outputs?.defense).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
test("empty-mass lock back-solves the cargo block", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "emptyMass", value: 25 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.computedInput).toBe("cargo");
|
||||
// cargo = 25 - (drive 10 + shields 5) = 10.
|
||||
expect(result.blocks.cargo).toBeCloseTo(10, 9);
|
||||
expect(result.outputs?.emptyMass).toBeCloseTo(25, 9);
|
||||
});
|
||||
|
||||
test("loaded-mass lock back-solves the cargo load", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
input({ lock: { output: "loadedMass", value: 30 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.computedInput).toBe("load");
|
||||
// load = (30 - emptyMass 20) * cargoTech 1 = 10.
|
||||
expect(result.load).toBeCloseTo(10, 9);
|
||||
expect(result.outputs?.loadedMass).toBeCloseTo(30, 9);
|
||||
});
|
||||
|
||||
test("an unreachable speed marks the lock infeasible", () => {
|
||||
const core = makeFakeCore();
|
||||
const result = computeCalculator(
|
||||
// ceiling is 20 * driveTech = 24; 100 is unreachable.
|
||||
input({ lock: { output: "speedEmpty", value: 100 } }),
|
||||
core,
|
||||
);
|
||||
expect(result.lockFeasible).toBe(false);
|
||||
expect(result.computedInput).toBeNull();
|
||||
// the claimed block keeps its raw value.
|
||||
expect(result.blocks.drive).toBe(10);
|
||||
});
|
||||
|
||||
test("calls the matching solver with the right context", () => {
|
||||
const weaponsForAttack = vi.fn(() => 7);
|
||||
const core = makeFakeCore({ weaponsForAttack });
|
||||
const result = computeCalculator(
|
||||
input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }),
|
||||
core,
|
||||
);
|
||||
expect(weaponsForAttack).toHaveBeenCalledWith({
|
||||
targetAttack: 30,
|
||||
weaponsTech: 1.5,
|
||||
});
|
||||
expect(result.blocks.weapons).toBe(7);
|
||||
expect(result.computedInput).toBe("weapons");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePlanetBuild", () => {
|
||||
test("returns null without a Core", () => {
|
||||
expect(computePlanetBuild({ shipMass: 10, freeIndustry: 100, material: 0, resources: 10 }, null)).toBeNull();
|
||||
});
|
||||
|
||||
test("derives ships-per-turn from the per-turn build loop", () => {
|
||||
const core = makeFakeCore();
|
||||
// shipMass 1, ample material: 100 production / (10 per ship) = 10 ships.
|
||||
const result = computePlanetBuild(
|
||||
{ shipMass: 1, freeIndustry: 100, material: 100, resources: 10 },
|
||||
core,
|
||||
);
|
||||
expect(result?.wholeShips).toBe(10);
|
||||
expect(result?.shipsPerTurn).toBeCloseTo(10, 9);
|
||||
expect(result?.turnsPerShip).toBeCloseTo(0.1, 9);
|
||||
});
|
||||
|
||||
test("reports turns-per-ship when under one ship per turn", () => {
|
||||
const core = makeFakeCore();
|
||||
// shipMass 10, no material, resources 0.5: cost 120, 60 production → 0.5 ship.
|
||||
const result = computePlanetBuild(
|
||||
{ shipMass: 10, freeIndustry: 60, material: 0, resources: 0.5 },
|
||||
core,
|
||||
);
|
||||
expect(result?.wholeShips).toBe(0);
|
||||
expect(result?.shipsPerTurn).toBeCloseTo(0.5, 9);
|
||||
expect(result?.turnsPerShip).toBeCloseTo(2, 9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Component coverage for the Phase 30 ship-class calculator: forward
|
||||
// results, single-target goal-seek wired through a mounted component, the
|
||||
// Create flow against a real OrderDraftStore, and the planet area. The
|
||||
// math itself is covered by `calc-model.test.ts` and the Go parity tests;
|
||||
// here we assert the component renders and orchestrates them.
|
||||
|
||||
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 CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
|
||||
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
SelectionStore,
|
||||
} from "../src/lib/selection.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "../src/lib/rendered-report.svelte";
|
||||
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||
import type { Core } from "../src/platform/core/index";
|
||||
import { makeFakeCore } from "./fake-core";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
const LOCAL_PLANET: ReportPlanet = {
|
||||
number: 17,
|
||||
name: "Castle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
kind: "local",
|
||||
owner: "me",
|
||||
size: 1000,
|
||||
resources: 10,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 100,
|
||||
industry: 1000,
|
||||
population: 1000,
|
||||
colonists: 0,
|
||||
production: "Cruiser",
|
||||
freeIndustry: 1000,
|
||||
};
|
||||
|
||||
function makeReport(over: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
localPlayerDrive: 1.2,
|
||||
localPlayerWeapons: 1.5,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 1,
|
||||
localShipClass: [],
|
||||
planets: [],
|
||||
...over,
|
||||
} as unknown as GameReport;
|
||||
}
|
||||
|
||||
function mount(opts: {
|
||||
core?: Core | null;
|
||||
report?: GameReport;
|
||||
selection?: SelectionStore;
|
||||
} = {}) {
|
||||
const holder = new CoreHolder();
|
||||
holder.set(opts.core === undefined ? makeFakeCore() : opts.core);
|
||||
const selection = opts.selection ?? new SelectionStore();
|
||||
const report = opts.report ?? makeReport();
|
||||
const source: RenderedReportSource = {
|
||||
get report() {
|
||||
return report;
|
||||
},
|
||||
};
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, source],
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[CORE_CONTEXT_KEY, holder],
|
||||
[SELECTION_CONTEXT_KEY, selection],
|
||||
]);
|
||||
return render(CalculatorTab, { context });
|
||||
}
|
||||
|
||||
async function setBlock(
|
||||
ui: { getByTestId(id: string): HTMLElement },
|
||||
key: string,
|
||||
value: number,
|
||||
): Promise<void> {
|
||||
await fireEvent.input(ui.getByTestId(`calculator-block-${key}`), {
|
||||
target: { value: String(value) },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-calculator-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
draft = new OrderDraftStore();
|
||||
await draft.init({ cache: new IDBCache(db), 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculator-tab", () => {
|
||||
test("computes results once the blocks are valid", async () => {
|
||||
const ui = mount();
|
||||
// All-zero blocks are invalid: results read as unavailable.
|
||||
expect(ui.getByTestId("calculator-out-emptyMass")).toHaveTextContent("—");
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
// empty mass = 10 + 5 + 5 = 20.
|
||||
expect(ui.getByTestId("calculator-out-emptyMass")).toHaveTextContent("20");
|
||||
});
|
||||
|
||||
test("locking attack back-solves the weapons block", async () => {
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "armament", 2);
|
||||
await setBlock(ui, "weapons", 5);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
||||
await fireEvent.input(ui.getByTestId("calculator-locked-attack"), {
|
||||
target: { value: "30" },
|
||||
});
|
||||
// weapons = 30 / weaponsTech(1.5) = 20, shown read-only.
|
||||
const weapons = ui.getByTestId("calculator-block-weapons");
|
||||
expect(weapons).toHaveValue(20);
|
||||
expect(weapons).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
test("flags an unreachable speed target as infeasible", async () => {
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty"));
|
||||
// ceiling is 20 * driveTech(1.2) = 24; 100 is unreachable.
|
||||
await fireEvent.input(ui.getByTestId("calculator-locked-speedEmpty"), {
|
||||
target: { value: "100" },
|
||||
});
|
||||
const locked = ui.getByTestId("calculator-locked-speedEmpty");
|
||||
expect(locked).toHaveAttribute("title", expect.stringMatching(/cannot be reached/i));
|
||||
});
|
||||
|
||||
test("create adds a ship-class command once the name is valid", async () => {
|
||||
const ui = mount();
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
const create = ui.getByTestId("calculator-create");
|
||||
expect(create).toBeDisabled();
|
||||
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
||||
target: { value: "Cruiser" },
|
||||
});
|
||||
expect(create).not.toBeDisabled();
|
||||
await fireEvent.click(create);
|
||||
expect(draft.commands).toHaveLength(1);
|
||||
expect(draft.commands[0]).toMatchObject({
|
||||
kind: "createShipClass",
|
||||
name: "Cruiser",
|
||||
drive: 10,
|
||||
shields: 5,
|
||||
cargo: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("planet area prompts for a selection when none is active", () => {
|
||||
const ui = mount();
|
||||
expect(ui.getByTestId("calculator-planet-none")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("planet area shows build stats for a selected own planet", async () => {
|
||||
const selection = new SelectionStore();
|
||||
selection.selectPlanet(17);
|
||||
const ui = mount({
|
||||
report: makeReport({ planets: [LOCAL_PLANET] }),
|
||||
selection,
|
||||
});
|
||||
await setBlock(ui, "drive", 10);
|
||||
await setBlock(ui, "shields", 5);
|
||||
await setBlock(ui, "cargo", 5);
|
||||
expect(ui.getByTestId("calculator-planet-name")).toHaveTextContent("Castle");
|
||||
expect(
|
||||
ui.getByTestId("calculator-ships-per-turn"),
|
||||
).not.toHaveTextContent("—");
|
||||
});
|
||||
});
|
||||
@@ -1,411 +0,0 @@
|
||||
// Vitest coverage for the Phase 17 ship-class 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, ShipClassSummary } 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 {
|
||||
CORE_CONTEXT_KEY,
|
||||
type CoreHandle,
|
||||
} from "../src/lib/core-context.svelte";
|
||||
import { loadWasmCoreForTest } from "./setup-wasm";
|
||||
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";
|
||||
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/ship-class"),
|
||||
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 DesignerShipClass from "../src/lib/active-view/designer-ship-class.svelte";
|
||||
|
||||
let db: IDBPDatabase<GalaxyDB>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
let draft: OrderDraftStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-designer-${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 shipClass(
|
||||
overrides: Partial<ShipClassSummary> & Pick<ShipClassSummary, "name">,
|
||||
): ShipClassSummary {
|
||||
return {
|
||||
drive: 0,
|
||||
armament: 0,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(localShipClass: ShipClassSummary[] = []): 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,
|
||||
};
|
||||
}
|
||||
|
||||
function mountDesigner(opts: {
|
||||
classId?: string;
|
||||
report?: GameReport | null;
|
||||
core?: Core | null;
|
||||
}) {
|
||||
const report = opts.report ?? makeReport();
|
||||
pageMock.params = opts.classId
|
||||
? { id: "g1", classId: opts.classId }
|
||||
: { id: "g1" };
|
||||
const renderedReport = { get report() { return report; } };
|
||||
const coreHandle: CoreHandle = { core: opts.core ?? null };
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||
[CORE_CONTEXT_KEY, coreHandle],
|
||||
]);
|
||||
return render(DesignerShipClass, { context });
|
||||
}
|
||||
|
||||
describe("ship-class designer (new mode)", () => {
|
||||
test("renders the form with a Save button disabled by default", () => {
|
||||
const ui = mountDesigner({});
|
||||
expect(
|
||||
ui.getByTestId("active-view-designer-ship-class"),
|
||||
).toHaveAttribute("data-mode", "new");
|
||||
expect(ui.getByTestId("designer-ship-class-save")).toBeDisabled();
|
||||
expect(ui.getByTestId("designer-ship-class-error")).toHaveTextContent(
|
||||
"name cannot be empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("Save adds a createShipClass to the draft after a valid edit", async () => {
|
||||
const ui = mountDesigner({});
|
||||
const nameInput = ui.getByTestId("designer-ship-class-input-name");
|
||||
await fireEvent.input(nameInput, { target: { value: "Drone" } });
|
||||
const driveInput = ui.getByTestId("designer-ship-class-input-drive");
|
||||
await fireEvent.input(driveInput, { target: { value: "1" } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("designer-ship-class-save")).not.toBeDisabled(),
|
||||
);
|
||||
await fireEvent.click(ui.getByTestId("designer-ship-class-save"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
if (cmd.kind !== "createShipClass") throw new Error("wrong kind");
|
||||
expect(cmd.name).toBe("Drone");
|
||||
expect(cmd.drive).toBe(1);
|
||||
expect(cmd.armament).toBe(0);
|
||||
await waitFor(() =>
|
||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/ship-classes"),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a duplicate name from the overlay before any sync", async () => {
|
||||
const ui = mountDesigner({
|
||||
report: makeReport([
|
||||
shipClass({ name: "Scout", drive: 1 }),
|
||||
]),
|
||||
});
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-name"),
|
||||
{ target: { value: "Scout" } },
|
||||
);
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-drive"),
|
||||
{ target: { value: "1" } },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("designer-ship-class-error")).toHaveTextContent(
|
||||
"already exists",
|
||||
),
|
||||
);
|
||||
expect(ui.getByTestId("designer-ship-class-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("rejects nonzero armament with zero weapons", async () => {
|
||||
const ui = mountDesigner({});
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-name"),
|
||||
{ target: { value: "Bad" } },
|
||||
);
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-armament"),
|
||||
{ target: { value: "1" } },
|
||||
);
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-drive"),
|
||||
{ target: { value: "1" } },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("designer-ship-class-error")).toHaveTextContent(
|
||||
"armament and weapons must be both zero or both nonzero",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Cancel navigates back without mutating the draft", async () => {
|
||||
const ui = mountDesigner({});
|
||||
await fireEvent.click(ui.getByTestId("designer-ship-class-cancel"));
|
||||
expect(draft.commands).toHaveLength(0);
|
||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/ship-classes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ship-class designer (view mode)", () => {
|
||||
test("renders the read-only summary plus Delete + Back affordances", () => {
|
||||
const ui = mountDesigner({
|
||||
classId: "Cruiser",
|
||||
report: makeReport([
|
||||
shipClass({
|
||||
name: "Cruiser",
|
||||
drive: 15,
|
||||
armament: 1,
|
||||
weapons: 15,
|
||||
shields: 15,
|
||||
cargo: 0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
ui.getByTestId("active-view-designer-ship-class"),
|
||||
).toHaveAttribute("data-mode", "view");
|
||||
expect(ui.getByTestId("designer-ship-class-view-name")).toHaveTextContent(
|
||||
"Cruiser",
|
||||
);
|
||||
expect(ui.getByTestId("designer-ship-class-view-drive")).toHaveTextContent(
|
||||
"15",
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-view-armament"),
|
||||
).toHaveTextContent("1");
|
||||
expect(ui.getByTestId("designer-ship-class-delete")).toBeInTheDocument();
|
||||
expect(ui.getByTestId("designer-ship-class-back")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Delete adds a removeShipClass and navigates back", async () => {
|
||||
const ui = mountDesigner({
|
||||
classId: "Cruiser",
|
||||
report: makeReport([shipClass({ name: "Cruiser", drive: 15 })]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("designer-ship-class-delete"));
|
||||
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||
const cmd = draft.commands[0]!;
|
||||
if (cmd.kind !== "removeShipClass") throw new Error("wrong kind");
|
||||
expect(cmd.name).toBe("Cruiser");
|
||||
await waitFor(() =>
|
||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/ship-classes"),
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a not-found message when the class is missing from the overlay", () => {
|
||||
const ui = mountDesigner({
|
||||
classId: "Ghost",
|
||||
report: makeReport([]),
|
||||
});
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-not-found"),
|
||||
).toHaveTextContent("Ghost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ship-class designer preview pane (Phase 18)", () => {
|
||||
test("hides preview while validation fails", () => {
|
||||
const ui = mountDesigner({});
|
||||
expect(
|
||||
ui.queryByTestId("designer-ship-class-preview"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides preview when no Core is provided", async () => {
|
||||
const ui = mountDesigner({});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||
target: { value: "Drone" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(ui.getByTestId("designer-ship-class-save")).not.toBeDisabled(),
|
||||
);
|
||||
expect(
|
||||
ui.queryByTestId("designer-ship-class-preview"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders five rows once form is valid and Core is ready", async () => {
|
||||
const core = await loadWasmCoreForTest();
|
||||
const report: GameReport = {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 1.5,
|
||||
localPlayerWeapons: 1,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 1.2,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
};
|
||||
const ui = mountDesigner({ report, core });
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||
target: { value: "Cruiser" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
|
||||
target: { value: "8" },
|
||||
});
|
||||
await fireEvent.input(
|
||||
ui.getByTestId("designer-ship-class-input-armament"),
|
||||
{ target: { value: "2" } },
|
||||
);
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-weapons"), {
|
||||
target: { value: "5" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-shields"), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
|
||||
target: { value: "4" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Empty mass = drive + shields + cargo + (armament+1)*(weapons/2)
|
||||
// = 8 + 3 + 4 + 3 * 2.5 = 22.5
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-mass"),
|
||||
).toHaveTextContent("22.5");
|
||||
// CargoCapacity = cargoTech * (cargo + cargo²/20)
|
||||
// = 1.2 * (4 + 16/20) = 1.2 * 4.8 = 5.76
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
|
||||
).toHaveTextContent("5.76");
|
||||
// CarryingMass at full = capacity / cargoTech = 5.76 / 1.2 = 4.8
|
||||
// FullLoadMass = 22.5 + 4.8 = 27.3
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-full-load-mass"),
|
||||
).toHaveTextContent("27.3");
|
||||
// DriveEffective = 8 * 1.5 = 12
|
||||
// MaxSpeed = 12 * 20 / 22.5 = 10.666… → "10.67"
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-max-speed"),
|
||||
).toHaveTextContent("10.67");
|
||||
// RangeAtFull = 12 * 20 / 27.3 = 8.791… → "8.79"
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-range"),
|
||||
).toHaveTextContent("8.79");
|
||||
});
|
||||
|
||||
test("preview reacts to subsequent edits", async () => {
|
||||
const core = await loadWasmCoreForTest();
|
||||
const report: GameReport = {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 1,
|
||||
localPlayerWeapons: 1,
|
||||
localPlayerShields: 1,
|
||||
localPlayerCargo: 1,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
};
|
||||
const ui = mountDesigner({ report, core });
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-name"), {
|
||||
target: { value: "Hauler" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-drive"), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
|
||||
target: { value: "5" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
|
||||
).toHaveTextContent("6.25"),
|
||||
);
|
||||
await fireEvent.input(ui.getByTestId("designer-ship-class-input-cargo"), {
|
||||
target: { value: "10" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
ui.getByTestId("designer-ship-class-preview-cargo-capacity"),
|
||||
).toHaveTextContent("15"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -227,3 +227,54 @@ test("clicking a planet on mobile raises the bottom-sheet, close clears it", asy
|
||||
await page.getByTestId("inspector-planet-sheet-close").click();
|
||||
await expect(page.getByTestId("inspector-planet-sheet")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Counts reach-circle primitives off the renderer debug surface. Reach
|
||||
// circles use ids in [REACH_CIRCLE_ID_PREFIX, bombing-marker prefix) —
|
||||
// 0xb0000000..0xc0000000 (see `map/reach-circles.ts`).
|
||||
async function countReachCircles(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const surface = (
|
||||
window as unknown as {
|
||||
__galaxyDebug?: {
|
||||
getMapPrimitives?: () => readonly { id: number; kind: string }[];
|
||||
};
|
||||
}
|
||||
).__galaxyDebug;
|
||||
const prims = surface?.getMapPrimitives?.() ?? [];
|
||||
return prims.filter(
|
||||
(p) => p.kind === "circle" && p.id >= 0xb0000000 && p.id < 0xc0000000,
|
||||
).length;
|
||||
});
|
||||
}
|
||||
|
||||
test("calculator draws reach circles for the selected planet", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"calculator + reach circles are a desktop-sidebar flow",
|
||||
);
|
||||
await setupShell(page);
|
||||
|
||||
// No reach circles before a planet is selected and a design exists.
|
||||
expect(await countReachCircles(page)).toBe(0);
|
||||
|
||||
// Select the planet, then switch the sidebar to the calculator.
|
||||
await clickCanvasCentre(page);
|
||||
await page.getByTestId("sidebar-tab-calculator").click();
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
await expect(calc).toBeVisible();
|
||||
|
||||
// A valid design with a positive drive tech override yields a
|
||||
// positive loaded speed, which the calculator publishes to the map.
|
||||
await calc.getByTestId("calculator-block-drive").fill("10");
|
||||
await calc.getByTestId("calculator-block-shields").fill("5");
|
||||
await calc.getByTestId("calculator-block-cargo").fill("5");
|
||||
await calc.getByTestId("calculator-tech-drive").fill("1.2");
|
||||
|
||||
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
|
||||
|
||||
// Leaving ship mode clears the published reach, so the rings drop.
|
||||
await calc.getByTestId("calculator-mode-modernization").click();
|
||||
await expect.poll(() => countReachCircles(page)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -54,11 +54,6 @@ test("header view-menu navigates to every active view", async ({ page }) => {
|
||||
["view-menu-item-report", "active-view-report", "/report"],
|
||||
["view-menu-item-mail", "active-view-mail", "/mail"],
|
||||
["view-menu-item-battle", "active-view-battle", "/battle"],
|
||||
[
|
||||
"view-menu-item-designer-ship-class",
|
||||
"active-view-designer-ship-class",
|
||||
"/designer/ship-class",
|
||||
],
|
||||
[
|
||||
"view-menu-item-designer-science",
|
||||
"active-view-designer-science",
|
||||
|
||||
@@ -161,7 +161,6 @@ async function readPrimitiveCount(page: Page): Promise<number> {
|
||||
|
||||
const NON_MAP_VIEWS: ReadonlyArray<{ label: string; testid: string }> = [
|
||||
{ label: "report", testid: "view-menu-item-report" },
|
||||
{ label: "designer-ship-class", testid: "view-menu-item-designer-ship-class" },
|
||||
{ label: "designer-science", testid: "view-menu-item-designer-science" },
|
||||
{ label: "mail", testid: "view-menu-item-mail" },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Phase 17 end-to-end coverage for the ship-class CRUD flow. Boots
|
||||
// Phase 30 end-to-end coverage for the ship-class CRUD flow. Boots
|
||||
// an authenticated session, mocks the gateway with a single local
|
||||
// planet plus an empty `localShipClass` projection, navigates to
|
||||
// the ship-classes table, opens the designer, fills the form, and
|
||||
// asserts that:
|
||||
// the ship-classes table, opens the sidebar calculator, fills the
|
||||
// design, and asserts that:
|
||||
//
|
||||
// 1. Save adds a `createShipClass` row to the local order draft,
|
||||
// auto-syncs through `user.games.order`, and the new class
|
||||
@@ -254,12 +254,12 @@ async function bootSession(page: Page): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
test("create / list / delete ship class via the table + designer", async ({
|
||||
test("create / list / delete ship class via the table + calculator", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 17 spec covers desktop layout; mobile inherits the same store",
|
||||
"phase 30 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
const handle = await mockGateway(page, { createOutcome: "applied" });
|
||||
@@ -270,19 +270,18 @@ test("create / list / delete ship class via the table + designer", async ({
|
||||
await expect(tableHost).toBeVisible();
|
||||
await expect(page.getByTestId("ship-classes-empty")).toBeVisible();
|
||||
|
||||
// "New" opens the calculator in the sidebar with a fresh design.
|
||||
await page.getByTestId("ship-classes-new").click();
|
||||
await expect(page.getByTestId("active-view-designer-ship-class")).toHaveAttribute(
|
||||
"data-mode",
|
||||
"new",
|
||||
);
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
await expect(calc).toBeVisible();
|
||||
|
||||
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
|
||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
||||
const save = page.getByTestId("designer-ship-class-save");
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
await calc.getByTestId("calculator-name").fill("Drone");
|
||||
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||
const create = calc.getByTestId("calculator-create");
|
||||
await expect(create).toBeEnabled();
|
||||
await create.click();
|
||||
|
||||
// Returns to the table; the optimistic overlay shows the new class.
|
||||
// The table's optimistic overlay shows the new class.
|
||||
await expect(page.getByTestId("ship-classes-table")).toBeVisible();
|
||||
const row = page.getByTestId("ship-classes-row");
|
||||
await expect(row).toHaveAttribute("data-name", "Drone");
|
||||
@@ -312,38 +311,33 @@ test("create / list / delete ship class via the table + designer", async ({
|
||||
expect(handle.lastRemove?.name).toBe("Drone");
|
||||
});
|
||||
|
||||
test("designer keeps Save disabled while the form is invalid", async ({
|
||||
test("calculator keeps Create disabled while the design is invalid", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 17 spec covers desktop layout; mobile inherits the same store",
|
||||
"phase 30 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
await mockGateway(page, { createOutcome: "applied" });
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
|
||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
||||
await page.getByTestId("ship-classes-new").click();
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
const create = calc.getByTestId("calculator-create");
|
||||
|
||||
const save = page.getByTestId("designer-ship-class-save");
|
||||
await expect(save).toBeDisabled();
|
||||
// Empty name + all-zero blocks: Create is disabled.
|
||||
await expect(create).toBeDisabled();
|
||||
|
||||
// Empty name surfaces the entity-name error.
|
||||
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
|
||||
"name cannot be empty",
|
||||
);
|
||||
// Mismatched armament / weapons keeps it disabled (pair rule).
|
||||
await calc.getByTestId("calculator-name").fill("Bad");
|
||||
await calc.getByTestId("calculator-block-armament").fill("1");
|
||||
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||
await expect(create).toBeDisabled();
|
||||
|
||||
// Mismatched armament / weapons triggers the pair rule.
|
||||
await page.getByTestId("designer-ship-class-input-name").fill("Bad");
|
||||
await page.getByTestId("designer-ship-class-input-armament").fill("1");
|
||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
||||
await expect(page.getByTestId("designer-ship-class-error")).toHaveText(
|
||||
"armament and weapons must be both zero or both nonzero",
|
||||
);
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
// Filling weapons resolves the pair rule.
|
||||
await page.getByTestId("designer-ship-class-input-weapons").fill("1");
|
||||
await expect(save).toBeEnabled();
|
||||
// Filling weapons resolves the pair rule and enables Create.
|
||||
await calc.getByTestId("calculator-block-weapons").fill("1");
|
||||
await expect(create).toBeEnabled();
|
||||
});
|
||||
|
||||
test("rejected createShipClass keeps the table empty and surfaces the failure", async ({
|
||||
@@ -351,23 +345,24 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 17 spec covers desktop layout; mobile inherits the same store",
|
||||
"phase 30 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
await mockGateway(page, { createOutcome: "rejected" });
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/designer/ship-class`);
|
||||
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
|
||||
await page.getByTestId("ship-classes-new").click();
|
||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||
|
||||
await page.getByTestId("designer-ship-class-input-name").fill("Drone");
|
||||
await page.getByTestId("designer-ship-class-input-drive").fill("1");
|
||||
await page.getByTestId("designer-ship-class-save").click();
|
||||
await calc.getByTestId("calculator-name").fill("Drone");
|
||||
await calc.getByTestId("calculator-block-drive").fill("1");
|
||||
await calc.getByTestId("calculator-create").click();
|
||||
|
||||
// Designer's save() calls SvelteKit `goto` to navigate back to
|
||||
// the table. SPA navigation keeps the per-game `OrderDraftStore`
|
||||
// alive so the auto-sync round-trip (which flips the status from
|
||||
// `submitting` to `rejected`) lands while the table is showing.
|
||||
// Order tab carries a `rejected` row; the optimistic overlay
|
||||
// drops the class once the engine answers `cmdApplied=false`.
|
||||
// Create stays in the table active view (the calculator is a
|
||||
// sidebar tool). The per-game OrderDraftStore drives the auto-sync
|
||||
// round-trip, which flips the status to `rejected`; the order tab
|
||||
// carries a `rejected` row and the overlay drops the class once the
|
||||
// engine answers cmdApplied=false.
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// makeFakeCore builds a complete `Core` whose calc methods mirror
|
||||
// `pkg/calc` exactly, for component and unit tests that must not boot the
|
||||
// real WASM module. The committed `core.wasm` is rebuilt out-of-band
|
||||
// (`make wasm`, needs TinyGo), so tests that exercise calculator math
|
||||
// inject this fake instead of depending on a freshly built binary. The
|
||||
// Go parity tests in `ui/core/calc` guarantee the real bridge agrees with
|
||||
// `pkg/calc`, so a fake that also mirrors `pkg/calc` stays faithful.
|
||||
//
|
||||
// Pass `overrides` to replace individual methods — e.g. `vi.fn()` spies
|
||||
// when a test wants to assert how the calc-model orchestrates the bridge.
|
||||
|
||||
import type { Core } from "../src/platform/core/index";
|
||||
|
||||
function weaponsBlockMass(weapons: number, armament: number): number | null {
|
||||
if ((armament === 0 && weapons !== 0) || (armament !== 0 && weapons === 0)) {
|
||||
return null;
|
||||
}
|
||||
return (armament + 1) * (weapons / 2);
|
||||
}
|
||||
|
||||
export function makeFakeCore(overrides: Partial<Core> = {}): Core {
|
||||
const base: Core = {
|
||||
signRequest: () => new Uint8Array(),
|
||||
verifyResponse: () => true,
|
||||
verifyEvent: () => true,
|
||||
verifyPayloadHash: () => true,
|
||||
driveEffective: ({ drive, driveTech }) => drive * driveTech,
|
||||
emptyMass: ({ drive, weapons, armament, shields, cargo }) => {
|
||||
const wb = weaponsBlockMass(weapons, armament);
|
||||
if (wb === null) return null;
|
||||
return drive + shields + cargo + wb;
|
||||
},
|
||||
weaponsBlockMass: ({ weapons, armament }) =>
|
||||
weaponsBlockMass(weapons, armament),
|
||||
fullMass: ({ emptyMass, carryingMass }) => emptyMass + carryingMass,
|
||||
speed: ({ driveEffective, fullMass }) =>
|
||||
fullMass <= 0 ? 0 : (driveEffective * 20) / fullMass,
|
||||
cargoCapacity: ({ cargo, cargoTech }) =>
|
||||
cargoTech * (cargo + (cargo * cargo) / 20),
|
||||
carryingMass: ({ load, cargoTech }) => (load <= 0 ? 0 : load / cargoTech),
|
||||
blockUpgradeCost: ({ blockMass, currentTech, targetTech }) =>
|
||||
blockMass === 0 || targetTech <= currentTech
|
||||
? 0
|
||||
: (1 - currentTech / targetTech) * 10 * blockMass,
|
||||
effectiveAttack: ({ weapons, weaponsTech }) => weapons * weaponsTech,
|
||||
effectiveDefence: ({ shields, shieldsTech, fullMass }) =>
|
||||
fullMass <= 0
|
||||
? 0
|
||||
: ((shields * shieldsTech) / Math.cbrt(fullMass)) * Math.cbrt(30),
|
||||
bombingPower: ({ weapons, weaponsTech, armament, number }) =>
|
||||
(Math.sqrt(weapons * weaponsTech) / 10 + 1) *
|
||||
weapons *
|
||||
weaponsTech *
|
||||
armament *
|
||||
number,
|
||||
shipBuildCost: ({ shipMass, material, resources }) => {
|
||||
const matNeed = Math.max(0, shipMass - material);
|
||||
const matFarm = resources > 0 ? matNeed / resources : 0;
|
||||
return shipMass * 10 + matFarm;
|
||||
},
|
||||
produceShipsInTurn: ({
|
||||
productionAvailable,
|
||||
material,
|
||||
resources,
|
||||
shipMass,
|
||||
}) => {
|
||||
if (productionAvailable <= 0 || shipMass <= 0) {
|
||||
return {
|
||||
ships: 0,
|
||||
materialLeft: material,
|
||||
productionUsed: 0,
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
let pa = productionAvailable;
|
||||
let mat = material;
|
||||
let ships = 0;
|
||||
for (;;) {
|
||||
const matNeed = Math.max(0, shipMass - mat);
|
||||
const cost = shipMass * 10 + (resources > 0 ? matNeed / resources : 0);
|
||||
if (pa < cost) {
|
||||
return {
|
||||
ships,
|
||||
materialLeft: mat,
|
||||
productionUsed: pa,
|
||||
progress: pa / cost,
|
||||
};
|
||||
}
|
||||
pa -= cost;
|
||||
mat = mat - shipMass + matNeed;
|
||||
ships += 1;
|
||||
}
|
||||
},
|
||||
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
|
||||
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
|
||||
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
|
||||
const ceiling = 20 * driveTech;
|
||||
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
|
||||
return null;
|
||||
}
|
||||
return (targetSpeed * restMass) / (ceiling - targetSpeed);
|
||||
},
|
||||
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {
|
||||
if (targetDefence <= 0 || shieldsTech <= 0) return null;
|
||||
const def = (s: number) =>
|
||||
((s * shieldsTech) / Math.cbrt(s + restMass)) * Math.cbrt(30);
|
||||
let lo = 0;
|
||||
let hi = 1;
|
||||
while (def(hi) < targetDefence) {
|
||||
hi *= 2;
|
||||
if (hi > 1e12) return null;
|
||||
}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (def(mid) < targetDefence) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return (lo + hi) / 2;
|
||||
},
|
||||
cargoForEmptyMass: ({ targetEmptyMass, restMass }) =>
|
||||
targetEmptyMass - restMass < 0 ? null : targetEmptyMass - restMass,
|
||||
loadForFullMass: ({ targetFullMass, emptyMass, cargoTech }) =>
|
||||
cargoTech <= 0 || targetFullMass < emptyMass
|
||||
? null
|
||||
: (targetFullMass - emptyMass) * cargoTech,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
RequestSigningFields,
|
||||
ResponseSigningFields,
|
||||
} from "../src/platform/core/index";
|
||||
import { makeFakeCore } from "./fake-core";
|
||||
|
||||
const FIXED_REQUEST_ID = "req-test-1";
|
||||
const FIXED_TIMESTAMP = 1_700_000_000_000n;
|
||||
@@ -204,19 +205,13 @@ function mockCore(opts: MockCoreOptions): Core & {
|
||||
verifyEvent: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
// `GalaxyClient` does not exercise the calc bridge, so the calc
|
||||
// methods come from the shared fake; only the signing/verify
|
||||
// methods need spies for the orchestration-order assertions.
|
||||
...makeFakeCore(),
|
||||
signRequest: vi.fn(opts.signRequestImpl),
|
||||
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
||||
verifyEvent: vi.fn(() => true),
|
||||
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
||||
// `GalaxyClient` does not exercise the Phase 18 calc bridge,
|
||||
// so these stubs only need to satisfy the `Core` interface.
|
||||
driveEffective: () => 0,
|
||||
emptyMass: () => 0,
|
||||
weaponsBlockMass: () => 0,
|
||||
fullMass: () => 0,
|
||||
speed: () => 0,
|
||||
cargoCapacity: () => 0,
|
||||
carryingMass: () => 0,
|
||||
blockUpgradeCost: () => 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,10 +138,6 @@ describe("game-shell header", () => {
|
||||
["view-menu-item-report", "/games/g1/report"],
|
||||
["view-menu-item-battle", "/games/g1/battle"],
|
||||
["view-menu-item-mail", "/games/g1/mail"],
|
||||
[
|
||||
"view-menu-item-designer-ship-class",
|
||||
"/games/g1/designer/ship-class",
|
||||
],
|
||||
[
|
||||
"view-menu-item-designer-science",
|
||||
"/games/g1/designer/science",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// 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; 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
|
||||
// ship-classes table (Phase 30 folded the designer into the sidebar
|
||||
// calculator); Phase 21 lit up the sciences table and the science
|
||||
// designer. Their assertions moved to dedicated suites
|
||||
// (`table-ship-classes.test.ts`, `calculator-tab.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.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} 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 { makeFakeCore } from "./fake-core";
|
||||
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";
|
||||
@@ -107,34 +108,12 @@ function group(
|
||||
};
|
||||
}
|
||||
|
||||
// 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`.
|
||||
// stubCore mirrors `pkg/calc` exactly (via the shared makeFakeCore) so
|
||||
// the preview line shows the same number the WASM bridge would produce.
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
return makeFakeCore();
|
||||
}
|
||||
|
||||
function mount(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
computeReachCircles,
|
||||
reachBound,
|
||||
REACH_CIRCLE_ID_PREFIX,
|
||||
} from "../src/map/reach-circles";
|
||||
|
||||
const CENTER = { x: 500, y: 500 };
|
||||
|
||||
describe("computeReachCircles", () => {
|
||||
test("no circles for a non-positive speed", () => {
|
||||
expect(computeReachCircles(CENTER, 0, 1000, 1000, "torus")).toEqual([]);
|
||||
expect(computeReachCircles(CENTER, -5, 1000, 1000, "torus")).toEqual([]);
|
||||
});
|
||||
|
||||
test("torus: a slow ship shows all three rings", () => {
|
||||
// bound = min(1000,1000)/2 = 500; speed 100 keeps every ring inside.
|
||||
const circles = computeReachCircles(CENTER, 100, 1000, 1000, "torus");
|
||||
expect(circles.map((c) => c.radius)).toEqual([100, 200, 300]);
|
||||
expect(circles[0].id).toBe(REACH_CIRCLE_ID_PREFIX + 1);
|
||||
expect(circles[0].style.strokeColor).toBeDefined();
|
||||
});
|
||||
|
||||
test("torus: a ship reaching the wrap midpoint shows one ring", () => {
|
||||
// speed 500 hits the bound on turn 1, so turn 2 is dropped.
|
||||
const circles = computeReachCircles(CENTER, 500, 1000, 1000, "torus");
|
||||
expect(circles).toHaveLength(1);
|
||||
expect(circles[0].radius).toBe(500);
|
||||
});
|
||||
|
||||
test("torus: a mid-speed ship shows two rings", () => {
|
||||
// speed 300: ring 1 = 300 (< 500), ring 2 = 600; ring 3 dropped
|
||||
// because 2 × 300 = 600 ≥ 500.
|
||||
const circles = computeReachCircles(CENTER, 300, 1000, 1000, "torus");
|
||||
expect(circles.map((c) => c.radius)).toEqual([300, 600]);
|
||||
});
|
||||
|
||||
test("no-wrap: the bound is the farthest corner", () => {
|
||||
// origin at a corner → farthest corner is the diagonal.
|
||||
expect(reachBound({ x: 0, y: 0 }, 1000, 1000, "no-wrap")).toBeCloseTo(
|
||||
Math.hypot(1000, 1000),
|
||||
6,
|
||||
);
|
||||
const circles = computeReachCircles(
|
||||
{ x: 0, y: 0 },
|
||||
500,
|
||||
1000,
|
||||
1000,
|
||||
"no-wrap",
|
||||
);
|
||||
// bound ≈ 1414, so all three rings fit.
|
||||
expect(circles.map((c) => c.radius)).toEqual([500, 1000, 1500]);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { calculatorLoadRequest } from "../src/lib/calculator/load-request.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";
|
||||
@@ -188,12 +189,14 @@ describe("ship-classes table", () => {
|
||||
expect(names).toEqual(["Battleship", "Cruiser", "Drone"]);
|
||||
});
|
||||
|
||||
test("dblclick on a row navigates to the designer for that class", async () => {
|
||||
test("dblclick on a row requests the calculator for that class", async () => {
|
||||
const ui = mountTable(
|
||||
makeReport([shipClass({ name: "Drone", drive: 1 })]),
|
||||
);
|
||||
const before = calculatorLoadRequest.token;
|
||||
await fireEvent.dblClick(ui.getByTestId("ship-classes-row"));
|
||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/ship-class/Drone");
|
||||
expect(calculatorLoadRequest.token).toBe(before + 1);
|
||||
expect(calculatorLoadRequest.name).toBe("Drone");
|
||||
});
|
||||
|
||||
test("delete button adds a removeShipClass to the draft", async () => {
|
||||
@@ -207,9 +210,11 @@ describe("ship-classes table", () => {
|
||||
expect(cmd.name).toBe("Drone");
|
||||
});
|
||||
|
||||
test("new button navigates to the empty designer", async () => {
|
||||
test("new button requests a fresh calculator design", async () => {
|
||||
const ui = mountTable(makeReport([]));
|
||||
const before = calculatorLoadRequest.token;
|
||||
await fireEvent.click(ui.getByTestId("ship-classes-new"));
|
||||
expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/ship-class");
|
||||
expect(calculatorLoadRequest.token).toBe(before + 1);
|
||||
expect(calculatorLoadRequest.name).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Smoke test for the Phase 30 calculator bridge against the real
|
||||
// (TinyGo-built) core.wasm. The calc-model and component suites use a
|
||||
// fake Core; this file boots the actual WASM module to confirm every new
|
||||
// function is registered in `ui/wasm/main.go` and marshals correctly —
|
||||
// including the object return of `produceShipsInTurn` and the `null`
|
||||
// infeasible result of the solvers. Requires `make wasm` to have run.
|
||||
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import type { Core } from "../src/platform/core/index";
|
||||
import { loadWasmCoreForTest } from "./setup-wasm";
|
||||
|
||||
let core: Core;
|
||||
|
||||
beforeAll(async () => {
|
||||
core = await loadWasmCoreForTest();
|
||||
});
|
||||
|
||||
describe("WasmCore calculator bridge (Phase 30)", () => {
|
||||
test("combat results", () => {
|
||||
expect(core.effectiveAttack({ weapons: 15, weaponsTech: 1.5 })).toBeCloseTo(
|
||||
22.5,
|
||||
9,
|
||||
);
|
||||
expect(
|
||||
core.effectiveDefence({ shields: 20, shieldsTech: 1, fullMass: 45 }),
|
||||
).toBeCloseTo((20 / Math.cbrt(45)) * Math.cbrt(30), 6);
|
||||
expect(
|
||||
core.bombingPower({ weapons: 30, weaponsTech: 1, armament: 3, number: 1 }),
|
||||
).toBeCloseTo(139.29503, 3);
|
||||
});
|
||||
|
||||
test("planet build", () => {
|
||||
expect(
|
||||
core.shipBuildCost({ shipMass: 10, material: 3, resources: 0.5 }),
|
||||
).toBeCloseTo(114, 9);
|
||||
const r = core.produceShipsInTurn({
|
||||
productionAvailable: 100,
|
||||
material: 100,
|
||||
resources: 10,
|
||||
shipMass: 1,
|
||||
});
|
||||
expect(r).toEqual({
|
||||
ships: 10,
|
||||
materialLeft: 90,
|
||||
productionUsed: 0,
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("goal-seek solvers, including infeasible", () => {
|
||||
expect(
|
||||
core.weaponsForAttack({ targetAttack: 30, weaponsTech: 1.5 }),
|
||||
).toBeCloseTo(20, 9);
|
||||
expect(
|
||||
core.cargoForEmptyMass({ targetEmptyMass: 42, restMass: 30 }),
|
||||
).toBeCloseTo(12, 9);
|
||||
expect(
|
||||
core.loadForFullMass({ targetFullMass: 65, emptyMass: 45, cargoTech: 1 }),
|
||||
).toBeCloseTo(20, 9);
|
||||
const shields = core.shieldsForDefence({
|
||||
targetDefence: 5,
|
||||
shieldsTech: 1,
|
||||
restMass: 40,
|
||||
});
|
||||
expect(shields).not.toBeNull();
|
||||
expect(shields as number).toBeGreaterThan(0);
|
||||
// Speed at/above the stripped-hull ceiling (20 × driveTech) is
|
||||
// unreachable: the bridge returns null.
|
||||
expect(
|
||||
core.driveForSpeed({ targetSpeed: 100, driveTech: 1.2, restMass: 35 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user