Files
galaxy-game/ui/frontend/tests/calculator-tab.test.ts
T
Ilia Denisov 3ea29cf8b5
Tests · UI / test (push) Successful in 1m59s
fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.

While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:29:08 +02:00

215 lines
6.8 KiB
TypeScript

// 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, vi } from "vitest";
// The calculator reads `page.params.id` to scope its long-lived state to
// the active game; stub a stable id so the component test has a router.
vi.mock("$app/state", () => ({ page: { params: { id: "calc-test-game" } } }));
import { i18n } from "../src/lib/i18n/index.svelte";
import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
import { calculatorState } from "../src/lib/calculator/calc-state.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");
// The calculator state is a module singleton shared across cases.
calculatorState.reset();
});
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("—");
});
});