// 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; 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 { 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([ [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 { 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((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("—"); }); test("zero cargo disables the load toggle", async () => { const ui = mount(); await setBlock(ui, "drive", 10); await setBlock(ui, "shields", 5); await setBlock(ui, "cargo", 0); expect(ui.getByTestId("calculator-load-full")).toBeDisabled(); expect(ui.getByTestId("calculator-load-custom")).toBeDisabled(); }); test("full load shows the cargo capacity", async () => { const ui = mount(); await setBlock(ui, "drive", 10); await setBlock(ui, "shields", 5); await setBlock(ui, "cargo", 5); // A fresh design starts with cargo 0, which pins load to empty; // pick full now that there is a hold. await fireEvent.click(ui.getByTestId("calculator-load-full")); // capacity = cargoTech(1) * (5 + 25/20) = 6.25. expect(ui.getByTestId("calculator-full-capacity")).toHaveTextContent("6.25"); }); test("flags a custom load above cargo capacity", 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-load-custom")); await fireEvent.input(ui.getByTestId("calculator-custom-load"), { target: { value: "100" }, }); expect(ui.getByTestId("calculator-custom-load")).toHaveAttribute( "aria-invalid", "true", ); }); test("marks an invalid block value with aria-invalid", async () => { const ui = mount(); // 0.5 is neither 0 nor ≥ 1. await setBlock(ui, "drive", 0.5); expect(ui.getByTestId("calculator-block-drive")).toHaveAttribute( "aria-invalid", "true", ); }); test("disables the speed lock when drive is zero", async () => { const ui = mount(); await setBlock(ui, "drive", 0); await setBlock(ui, "shields", 5); await setBlock(ui, "cargo", 5); expect(ui.getByTestId("calculator-lock-speedEmpty")).toBeDisabled(); }); test("displays computed values rounded up to three decimals", async () => { const ui = mount(); await setBlock(ui, "drive", 7); await setBlock(ui, "shields", 3); await setBlock(ui, "cargo", 1); // empty mass = 11; max speed = 11 * driveTech... use a value that is // not already 3-decimal: speedEmpty = 20*7*1.2 / 11 = 15.2727… // ceil to 3 → 15.273. expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent( "15.273", ); }); test("tech defaults render as a number with an open-lock affordance", () => { const ui = mount(); // Default state: no override → number + open lock, no input. expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent( "1.2", ); expect( ui.getByTestId("calculator-tech-override-drive"), ).toBeInTheDocument(); expect(ui.queryByTestId("calculator-tech-drive")).toBeNull(); }); test("clicking the open tech lock reveals the input + closed lock", async () => { const ui = mount(); await fireEvent.click(ui.getByTestId("calculator-tech-override-drive")); // Now an input is rendered and the lock turned closed (reset). expect(ui.getByTestId("calculator-tech-drive")).toHaveValue(1.2); expect(ui.getByTestId("calculator-tech-reset-drive")).toBeInTheDocument(); expect(ui.queryByTestId("calculator-tech-value-drive")).toBeNull(); }); test("flags a tech override below the player's current tech", async () => { const ui = mount(); await fireEvent.click(ui.getByTestId("calculator-tech-override-drive")); // Player drive is 1.2; setting 0.5 is below the floor. await fireEvent.input(ui.getByTestId("calculator-tech-drive"), { target: { value: "0.5" }, }); expect(ui.getByTestId("calculator-tech-drive")).toHaveAttribute( "aria-invalid", "true", ); }); test("smart step jumps from 0 to 1 on ArrowUp for ship blocks", async () => { const ui = mount(); const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement; drive.focus(); await fireEvent.keyDown(drive, { key: "ArrowUp" }); expect(drive).toHaveValue(1); await fireEvent.keyDown(drive, { key: "ArrowUp" }); expect(drive).toHaveValue(1.1); await fireEvent.keyDown(drive, { key: "ArrowDown" }); expect(drive).toHaveValue(1); await fireEvent.keyDown(drive, { key: "ArrowDown" }); expect(drive).toHaveValue(0); }); test("regression: speed lock works at the ceiling with all-zero non-drive blocks", async () => { const ui = mount(); await setBlock(ui, "drive", 1); // Override drive tech to 1 so the ceiling math is plain. await fireEvent.click(ui.getByTestId("calculator-tech-override-drive")); await fireEvent.input(ui.getByTestId("calculator-tech-drive"), { target: { value: "1" }, }); // With D=1, W=A=S=C=0 the only achievable speed is 20*driveTech=20. expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent("20"); await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty")); const locked = ui.getByTestId("calculator-locked-speedEmpty"); expect(locked).toHaveValue(20); // The lock is feasible — no infeasible title and no red error class. expect(locked).not.toHaveAttribute( "title", expect.stringMatching(/cannot be reached/i), ); }); test("planet MAT defaults to a value + open lock and opens an input on click", async () => { const selection = new SelectionStore(); selection.selectPlanet(17); const ui = mount({ report: makeReport({ planets: [LOCAL_PLANET] }), selection, }); // Initial state: the MAT shows the planet's value via the number cell // and an open lock; no input until the override is activated. expect( ui.getByTestId("calculator-planet-mat-value"), ).toHaveTextContent("100"); expect( ui.getByTestId("calculator-mat-override"), ).toBeInTheDocument(); expect(ui.queryByTestId("calculator-planet-mat")).toBeNull(); await fireEvent.click(ui.getByTestId("calculator-mat-override")); expect(ui.getByTestId("calculator-planet-mat")).toHaveValue(100); expect(ui.getByTestId("calculator-mat-reset")).toBeInTheDocument(); }); test("flags a modernization target below the player's current tech", async () => { const ui = mount(); await fireEvent.click(ui.getByTestId("calculator-mode-modernization")); // Player drive is 1.2; the target is seeded with the same value. await fireEvent.input(ui.getByTestId("calculator-target-drive"), { target: { value: "0.5" }, }); expect(ui.getByTestId("calculator-target-drive")).toHaveAttribute( "aria-invalid", "true", ); }); test("armament Arrow keys step the integer block by ±1 (clamped at 0)", async () => { const ui = mount(); const armament = ui.getByTestId( "calculator-block-armament", ) as HTMLInputElement; armament.focus(); await fireEvent.keyDown(armament, { key: "ArrowUp" }); expect(armament).toHaveValue(1); await fireEvent.keyDown(armament, { key: "ArrowUp" }); expect(armament).toHaveValue(2); await fireEvent.keyDown(armament, { key: "ArrowDown" }); expect(armament).toHaveValue(1); await fireEvent.keyDown(armament, { key: "ArrowDown" }); expect(armament).toHaveValue(0); // Clamped at zero — another ArrowDown is a no-op. await fireEvent.keyDown(armament, { key: "ArrowDown" }); expect(armament).toHaveValue(0); }); test("renders unoverridden tech as a 3-decimal label (matches the report)", () => { // Player drive tech 1.2 → "1.200" via the shared ceil3 formatter, // always padded to three decimals (calculator labels are column- // aligned with the report). const ui = mount(); const tech = ui.getByTestId("calculator-tech-value-drive"); expect((tech.textContent ?? "").trim()).toBe("1.200"); }); test("planet MAT label renders through the 3-decimal formatter", () => { const selection = new SelectionStore(); selection.selectPlanet(17); const ui = mount({ report: makeReport({ planets: [LOCAL_PLANET] }), selection, }); // Planet MAT is 100 → "100.000" through the shared formatter; the // label is monospaced + right-aligned via the existing `.mat-val` // rule. Integer MAT pads to three decimals like every other label. const mat = ui.getByTestId("calculator-planet-mat-value"); expect((mat.textContent ?? "").trim()).toBe("100.000"); }); test("derived results pad to three decimals (integer empty mass)", async () => { // Integer-valued outputs read with the same trailing zeros as // fractional ones — column-aligned tabular display. const ui = mount(); await setBlock(ui, "drive", 10); await setBlock(ui, "shields", 5); await setBlock(ui, "cargo", 5); const mass = ui.getByTestId("calculator-out-emptyMass"); expect((mass.textContent ?? "").trim()).toBe("20.000"); }); test("number inputs refuse a fourth decimal as the user types", async () => { const selection = new SelectionStore(); selection.selectPlanet(17); const ui = mount({ report: makeReport({ planets: [LOCAL_PLANET] }), selection, }); // MAT input: typed "12.3456" must clamp to "12.345" on input. await fireEvent.click(ui.getByTestId("calculator-mat-override")); const mat = ui.getByTestId("calculator-planet-mat") as HTMLInputElement; await fireEvent.input(mat, { target: { value: "12.3456" } }); expect(mat.value).toBe("12.345"); expect(mat.valueAsNumber).toBeCloseTo(12.345, 9); // Custom-load input on a ship with a non-zero cargo: typed // "1.2345" must clamp to "1.234". await setBlock(ui, "drive", 10); await setBlock(ui, "shields", 5); await setBlock(ui, "cargo", 5); await fireEvent.click(ui.getByTestId("calculator-load-custom")); const load = ui.getByTestId("calculator-custom-load") as HTMLInputElement; await fireEvent.input(load, { target: { value: "1.2345" } }); expect(load.value).toBe("1.234"); }); test("tech and target-tech inputs cap at three decimals", async () => { const ui = mount(); // Tech override input. await fireEvent.click(ui.getByTestId("calculator-tech-override-drive")); const tech = ui.getByTestId("calculator-tech-drive") as HTMLInputElement; await fireEvent.input(tech, { target: { value: "2.9999" } }); expect(tech.value).toBe("2.999"); // Modernization target tech input. await fireEvent.click(ui.getByTestId("calculator-mode-modernization")); const target = ui.getByTestId( "calculator-target-drive", ) as HTMLInputElement; await fireEvent.input(target, { target: { value: "3.1416" } }); expect(target.value).toBe("3.141"); }); test("lock value input caps at three decimals", 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-attack")); const lock = ui.getByTestId( "calculator-locked-attack", ) as HTMLInputElement; await fireEvent.input(lock, { target: { value: "0.1234" } }); expect(lock.value).toBe("0.123"); }); test("ship-block input caps at three decimals", async () => { const ui = mount(); const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement; await fireEvent.input(drive, { target: { value: "1.2345" } }); expect(drive.value).toBe("1.234"); }); test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", 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-attack")); const locked = ui.getByTestId( "calculator-locked-attack", ) as HTMLInputElement; // Lock value is seeded from outputs.attack (0 with no weapons). const start = Number(locked.value); locked.focus(); await fireEvent.keyDown(locked, { key: "ArrowUp" }); expect(Number(locked.value)).toBeCloseTo(start + 0.001, 9); await fireEvent.keyDown(locked, { key: "ArrowDown" }); expect(Number(locked.value)).toBeCloseTo(start, 9); }); test("flags the lock as infeasible when the back-solved block falls in (0, 1)", async () => { // attack lock → weapons = targetAttack / weaponsTech. weaponsTech // is 1.5; a target of 0.5 would force weapons = 0.333… which // fails the DWSC rule (must be 0 or ≥ 1). 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: "0.5" }, }); const locked = ui.getByTestId("calculator-locked-attack"); expect(locked).toHaveAttribute( "title", expect.stringMatching(/cannot be reached/i), ); // The claimed block is not back-solved into the invalid (0, 1) // range — the weapons input keeps the user's typed value (5). expect(ui.getByTestId("calculator-block-weapons")).toHaveValue(5); }); test("dropdown selection loads the class immediately (no blur needed)", async () => { const ui = mount({ report: makeReport({ localShipClass: [ { name: "Scout", drive: 3, armament: 0, weapons: 0, shields: 2, cargo: 1, }, ], } as unknown as GameReport), }); // A datalist option click sets the whole value at once — Firefox // reports no `inputType`, Chromium reports "insertReplacementText". // Simulate the latter; the calculator should load before any // `change` event. await fireEvent.input(ui.getByTestId("calculator-name"), { target: { value: "Scout" }, inputType: "insertReplacementText", }); expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3); expect(ui.getByTestId("calculator-block-shields")).toHaveValue(2); }); test("dropdown selection asks before discarding manual edits", async () => { const ui = mount({ report: makeReport({ localShipClass: [ { name: "Scout", drive: 3, armament: 0, weapons: 0, shields: 2, cargo: 1, }, ], } as unknown as GameReport), }); // The user has hand-edited the design. await setBlock(ui, "drive", 7); const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); await fireEvent.input(ui.getByTestId("calculator-name"), { target: { value: "Scout" }, inputType: "insertReplacementText", }); expect(confirm).toHaveBeenCalledTimes(1); // The user said no — the manual edits stay. expect(ui.getByTestId("calculator-block-drive")).toHaveValue(7); // The name field is reverted to the previously loaded class (or // empty), so the field does not pretend the load happened. expect(ui.getByTestId("calculator-name")).toHaveValue(""); confirm.mockReturnValue(true); await fireEvent.input(ui.getByTestId("calculator-name"), { target: { value: "Scout" }, inputType: "insertReplacementText", }); // Confirmed — the class is now loaded. expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3); confirm.mockRestore(); }); test("dropdown selection loads silently when the design is clean", async () => { const ui = mount({ report: makeReport({ localShipClass: [ { name: "Scout", drive: 3, armament: 0, weapons: 0, shields: 2, cargo: 1, }, ], } as unknown as GameReport), }); const confirm = vi.spyOn(window, "confirm"); await fireEvent.input(ui.getByTestId("calculator-name"), { target: { value: "Scout" }, inputType: "insertReplacementText", }); expect(confirm).not.toHaveBeenCalled(); expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3); confirm.mockRestore(); }); });