// Vitest coverage for the Phase 21 science 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, ScienceSummary } 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 { 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/science"), params: { id: "g1" } as Record, })); const gotoMock = vi.hoisted(() => vi.fn()); vi.mock("$app/state", () => ({ page: pageMock, })); vi.mock("$app/navigation", () => ({ goto: gotoMock, })); import DesignerScience from "../src/lib/active-view/designer-science.svelte"; let db: IDBPDatabase; let dbName: string; let cache: Cache; let draft: OrderDraftStore; beforeEach(async () => { dbName = `galaxy-designer-science-${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((resolve) => { const req = indexedDB.deleteDatabase(dbName); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); }); function science( overrides: Partial & Pick, ): ScienceSummary { return { drive: 0, weapons: 0, shields: 0, cargo: 0, ...overrides, }; } function makeReport(localScience: ScienceSummary[] = []): 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, localScience, }; } function mountDesigner(opts: { scienceId?: string; report?: GameReport | null; }) { const report = opts.report ?? makeReport(); pageMock.params = opts.scienceId ? { id: "g1", scienceId: opts.scienceId } : { id: "g1" }; const renderedReport = { get report() { return report; }, }; const context = new Map([ [ORDER_DRAFT_CONTEXT_KEY, draft], [RENDERED_REPORT_CONTEXT_KEY, renderedReport], ]); return render(DesignerScience, { context }); } describe("science designer (new mode)", () => { test("renders the form with Save disabled by default (empty name + zero sum)", () => { const ui = mountDesigner({}); expect(ui.getByTestId("active-view-designer-science")).toHaveAttribute( "data-mode", "new", ); expect(ui.getByTestId("designer-science-save")).toBeDisabled(); expect(ui.getByTestId("designer-science-error")).toHaveTextContent( "name cannot be empty", ); }); test("Save adds a createScience to the draft after a valid edit", async () => { const ui = mountDesigner({}); await fireEvent.input(ui.getByTestId("designer-science-input-name"), { target: { value: "Even" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-drive"), { target: { value: "25" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-weapons"), { target: { value: "25" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-shields"), { target: { value: "25" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-cargo"), { target: { value: "25" }, }); await waitFor(() => expect(ui.getByTestId("designer-science-save")).not.toBeDisabled(), ); await fireEvent.click(ui.getByTestId("designer-science-save")); await waitFor(() => expect(draft.commands).toHaveLength(1)); const cmd = draft.commands[0]!; if (cmd.kind !== "createScience") throw new Error("wrong kind"); expect(cmd.name).toBe("Even"); expect(cmd.drive).toBeCloseTo(0.25, 12); expect(cmd.weapons).toBeCloseTo(0.25, 12); expect(cmd.shields).toBeCloseTo(0.25, 12); expect(cmd.cargo).toBeCloseTo(0.25, 12); await waitFor(() => expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"), ); }); test("sum readout reflects the live total", async () => { const ui = mountDesigner({}); await fireEvent.input(ui.getByTestId("designer-science-input-drive"), { target: { value: "30" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-weapons"), { target: { value: "20" }, }); await waitFor(() => expect(ui.getByTestId("designer-science-sum")).toHaveTextContent("50"), ); }); test("rejects sum that does not equal 100", async () => { const ui = mountDesigner({}); await fireEvent.input(ui.getByTestId("designer-science-input-name"), { target: { value: "Bad" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-drive"), { target: { value: "50" }, }); await waitFor(() => expect(ui.getByTestId("designer-science-error")).toHaveTextContent( "must sum to exactly 100", ), ); expect(ui.getByTestId("designer-science-save")).toBeDisabled(); }); test("rejects a duplicate name from the overlay before any sync", async () => { const ui = mountDesigner({ report: makeReport([ science({ name: "Even", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25 }), ]), }); await fireEvent.input(ui.getByTestId("designer-science-input-name"), { target: { value: "Even" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-drive"), { target: { value: "25" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-weapons"), { target: { value: "25" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-shields"), { target: { value: "25" }, }); await fireEvent.input(ui.getByTestId("designer-science-input-cargo"), { target: { value: "25" }, }); await waitFor(() => expect(ui.getByTestId("designer-science-error")).toHaveTextContent( "already exists", ), ); expect(ui.getByTestId("designer-science-save")).toBeDisabled(); }); test("Cancel navigates back without mutating the draft", async () => { const ui = mountDesigner({}); await fireEvent.click(ui.getByTestId("designer-science-cancel")); expect(draft.commands).toHaveLength(0); expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"); }); }); describe("science designer (view mode)", () => { test("renders the read-only summary plus Delete + Back affordances", () => { const ui = mountDesigner({ scienceId: "FirstStep", report: makeReport([ science({ name: "FirstStep", drive: 0.222, weapons: 0.111, shields: 0.667, cargo: 0, }), ]), }); expect(ui.getByTestId("active-view-designer-science")).toHaveAttribute( "data-mode", "view", ); expect(ui.getByTestId("designer-science-view-name")).toHaveTextContent( "FirstStep", ); expect(ui.getByTestId("designer-science-view-drive")).toHaveTextContent( "22.2", ); expect(ui.getByTestId("designer-science-view-shields")).toHaveTextContent( "66.7", ); expect(ui.getByTestId("designer-science-delete")).toBeInTheDocument(); expect(ui.getByTestId("designer-science-back")).toBeInTheDocument(); }); test("Delete adds a removeScience and navigates back", async () => { const ui = mountDesigner({ scienceId: "FirstStep", report: makeReport([ science({ name: "FirstStep", drive: 0.5, shields: 0.5 }), ]), }); await fireEvent.click(ui.getByTestId("designer-science-delete")); await waitFor(() => expect(draft.commands).toHaveLength(1)); const cmd = draft.commands[0]!; if (cmd.kind !== "removeScience") throw new Error("wrong kind"); expect(cmd.name).toBe("FirstStep"); await waitFor(() => expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"), ); }); test("renders a not-found message when the science is missing from the overlay", () => { const ui = mountDesigner({ scienceId: "Ghost", report: makeReport([]), }); expect(ui.getByTestId("designer-science-not-found")).toHaveTextContent( "Ghost", ); }); });