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