// Vitest coverage for the Phase 17 ship-classes table active view. // The component renders against a synthetic `RenderedReportSource` // (so the suite does not need a live `GameStateStore`) and a real // `OrderDraftStore` (so the per-row Delete affordance exercises // the `removeShipClass` add path including persistence). 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/table/ship-classes"), params: { id: "g1" } as Record, })); const gotoMock = vi.hoisted(() => vi.fn()); vi.mock("$app/state", () => ({ page: pageMock, })); vi.mock("$app/navigation", () => ({ goto: gotoMock, })); import TableShipClasses from "../src/lib/active-view/table-ship-classes.svelte"; let db: IDBPDatabase; let dbName: string; let cache: Cache; let draft: OrderDraftStore; beforeEach(async () => { dbName = `galaxy-table-ship-classes-${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 mountTable(report: GameReport | null) { const renderedReport = { get report() { return report; } }; const context = new Map([ [ORDER_DRAFT_CONTEXT_KEY, draft], [RENDERED_REPORT_CONTEXT_KEY, renderedReport], ]); return render(TableShipClasses, { context }); } describe("ship-classes table", () => { test("renders a loading placeholder before the report lands", () => { const ui = mountTable(null); expect(ui.getByTestId("ship-classes-loading")).toBeInTheDocument(); }); test("renders an empty placeholder when no classes are designed", () => { const ui = mountTable(makeReport([])); expect(ui.getByTestId("ship-classes-empty")).toBeInTheDocument(); }); test("renders one row per ship class with full attributes", () => { const ui = mountTable( makeReport([ shipClass({ name: "Cruiser", drive: 15, armament: 1, weapons: 15, shields: 15, cargo: 0, }), ]), ); const rows = ui.getAllByTestId("ship-classes-row"); expect(rows).toHaveLength(1); expect(rows[0]).toHaveAttribute("data-name", "Cruiser"); expect(ui.getByTestId("ship-classes-cell-drive")).toHaveTextContent("15"); expect(ui.getByTestId("ship-classes-cell-armament")).toHaveTextContent("1"); }); test("filters rows by case-insensitive name match", async () => { const ui = mountTable( makeReport([ shipClass({ name: "Drone", drive: 1 }), shipClass({ name: "Cruiser", drive: 15, armament: 1, weapons: 15 }), ]), ); await fireEvent.input(ui.getByTestId("ship-classes-filter"), { target: { value: "cru" }, }); const rows = ui.getAllByTestId("ship-classes-row"); expect(rows).toHaveLength(1); expect(rows[0]).toHaveAttribute("data-name", "Cruiser"); }); test("toggles sort direction when the same column is clicked twice", async () => { const ui = mountTable( makeReport([ shipClass({ name: "Drone", drive: 1 }), shipClass({ name: "Cruiser", drive: 15, armament: 1, weapons: 15, shields: 15, }), shipClass({ name: "Battleship", drive: 25 }), ]), ); const driveHeader = ui.getByTestId("ship-classes-column-drive"); await fireEvent.click(driveHeader); let names = ui .getAllByTestId("ship-classes-row") .map((row) => row.getAttribute("data-name")); expect(names).toEqual(["Drone", "Cruiser", "Battleship"]); await fireEvent.click(driveHeader); names = ui .getAllByTestId("ship-classes-row") .map((row) => row.getAttribute("data-name")); expect(names).toEqual(["Battleship", "Cruiser", "Drone"]); }); test("dblclick on a row navigates to the designer for that class", async () => { const ui = mountTable( makeReport([shipClass({ name: "Drone", drive: 1 })]), ); await fireEvent.dblClick(ui.getByTestId("ship-classes-row")); expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/ship-class/Drone"); }); test("delete button adds a removeShipClass to the draft", async () => { const ui = mountTable( makeReport([shipClass({ name: "Drone", drive: 1 })]), ); await fireEvent.click(ui.getByTestId("ship-classes-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("Drone"); }); test("new button navigates to the empty designer", async () => { const ui = mountTable(makeReport([])); await fireEvent.click(ui.getByTestId("ship-classes-new")); expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/ship-class"); }); });