// 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 { 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"; 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, })); 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; 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((resolve) => { const req = indexedDB.deleteDatabase(dbName); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); }); function shipClass( overrides: Partial & Pick, ): 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, }; } function mountDesigner(opts: { classId?: string; report?: GameReport | null; }) { const report = opts.report ?? makeReport(); pageMock.params = opts.classId ? { id: "g1", classId: opts.classId } : { id: "g1" }; const renderedReport = { get report() { return report; } }; const context = new Map([ [ORDER_DRAFT_CONTEXT_KEY, draft], [RENDERED_REPORT_CONTEXT_KEY, renderedReport], ]); 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"); }); });