// Vitest coverage for Phase 20's ship-group action panel. Exercises // the disabled-with-tooltip rules per action, the implicit-split // pattern (an action targeting fewer ships than the group holds // emits a `breakShipGroup` command before the action), and the // happy-path commits of every variant. The dismantle confirmation // for foreign-COL groups lives in its own file // (`inspector-ship-group-dismantle-confirm.test.ts`); the modernize // cost preview lives in `inspector-ship-group-modernize-cost.test.ts`. import "@testing-library/jest-dom/vitest"; import "fake-indexeddb/auto"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; import type { ReportLocalFleet, ReportLocalShipGroup, ReportPlanet, ShipClassSummary, } from "../src/api/game-state"; import ShipGroup, { type ShipGroupSelection, } from "../src/lib/inspectors/ship-group.svelte"; import { ORDER_DRAFT_CONTEXT_KEY, OrderDraftStore, } from "../src/sync/order-draft.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"; let db: IDBPDatabase; let dbName: string; let cache: Cache; let draft: OrderDraftStore; const PLANETS: ReportPlanet[] = [ planet({ number: 17, name: "Castle", x: 100, y: 100, kind: "local" }), planet({ number: 99, name: "Outpost", x: 110, y: 110, kind: "other", owner: "Foreign" }), planet({ number: 33, name: "Reach", x: 150, y: 150, kind: "uninhabited" }), ]; const SHIP_CLASS_FRONTIER: ShipClassSummary = { name: "Frontier", drive: 5, armament: 0, weapons: 0, shields: 0, cargo: 1, }; beforeEach(async () => { dbName = `galaxy-ship-group-actions-${crypto.randomUUID()}`; db = await openGalaxyDB(dbName); cache = new IDBCache(db); draft = new OrderDraftStore(); await draft.init({ cache, gameId: GAME_ID }); i18n.resetForTests("en"); }); 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 planet( overrides: Partial & Pick, ): ReportPlanet { return { owner: null, size: 1000, resources: 5, industryStockpile: 100, materialsStockpile: 100, industry: 100, population: 100, colonists: 100, production: null, freeIndustry: 100, ...overrides, }; } function localGroup( overrides: Partial = {}, ): ReportLocalShipGroup { return { id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", count: 3, class: "Frontier", tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 }, cargo: "NONE", load: 0, destination: 17, origin: null, range: null, speed: 0, mass: 12, state: "In_Orbit", fleet: null, ...overrides, }; } function mount( group: ReportLocalShipGroup, options: { otherRaces?: string[]; localFleets?: ReportLocalFleet[]; localPlayerDrive?: number; } = {}, ) { const selection: ShipGroupSelection = { variant: "local", group }; const context = new Map([ [ORDER_DRAFT_CONTEXT_KEY, draft], ]); return render(ShipGroup, { props: { selection, planets: PLANETS, localShipClass: [SHIP_CLASS_FRONTIER], localFleets: options.localFleets ?? [], otherRaces: options.otherRaces ?? ["Aliens"], mapWidth: 1000, mapHeight: 1000, localPlayerDrive: options.localPlayerDrive ?? 5, localPlayerWeapons: 1, localPlayerShields: 1, localPlayerCargo: 2, }, context, }); } describe("ship-group inspector — action enablement", () => { test("non-orbit groups disable every action with the busy tooltip", () => { const ui = mount(localGroup({ state: "In_Space" })); for (const id of [ "inspector-ship-group-action-split", "inspector-ship-group-action-send", "inspector-ship-group-action-load", "inspector-ship-group-action-unload", "inspector-ship-group-action-modernize", "inspector-ship-group-action-dismantle", "inspector-ship-group-action-transfer", "inspector-ship-group-action-join-fleet", ]) { const button = ui.getByTestId(id); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/ships are busy/i); } }); test("send is disabled when no planet is in drive range", () => { const ui = mount(localGroup({ destination: 17 }), { localPlayerDrive: 0 }); const button = ui.getByTestId("inspector-ship-group-action-send"); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/no planets are within drive range/i); }); test("transfer is disabled when there are no other races", () => { const ui = mount(localGroup(), { otherRaces: [] }); const button = ui.getByTestId("inspector-ship-group-action-transfer"); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/no other non-extinct races/i); }); test("unload is disabled when the group carries no cargo", () => { const ui = mount(localGroup({ cargo: "NONE", load: 0 })); const button = ui.getByTestId("inspector-ship-group-action-unload"); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/empty/i); }); test("unload of colonists is blocked over a foreign planet", () => { const ui = mount(localGroup({ destination: 99, cargo: "COL", load: 1.5 })); const button = ui.getByTestId("inspector-ship-group-action-unload"); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/colonists cannot be unloaded over a foreign planet/i); }); test("load is blocked over a foreign planet", () => { const ui = mount(localGroup({ destination: 99 })); const button = ui.getByTestId("inspector-ship-group-action-load"); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/own or unowned planets/i); }); }); describe("ship-group inspector — implicit split + action", () => { test("split with K=1 of 3 emits a single breakShipGroup", async () => { const ui = mount(localGroup({ count: 3 })); await fireEvent.click(ui.getByTestId("inspector-ship-group-action-split")); const input = ui.getByTestId("inspector-ship-group-form-split-ships") as HTMLInputElement; await fireEvent.input(input, { target: { value: "1" } }); await fireEvent.click(ui.getByTestId("inspector-ship-group-form-split-confirm")); await waitFor(() => expect(draft.commands).toHaveLength(1)); const cmd = draft.commands[0]!; expect(cmd.kind).toBe("breakShipGroup"); if (cmd.kind !== "breakShipGroup") return; expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); expect(cmd.quantity).toBe(1); }); test("dismantle on the whole group emits a single dismantleShipGroup", async () => { const ui = mount(localGroup({ count: 2 })); await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle")); await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm")); await waitFor(() => expect(draft.commands).toHaveLength(1)); const cmd = draft.commands[0]!; expect(cmd.kind).toBe("dismantleShipGroup"); if (cmd.kind !== "dismantleShipGroup") return; expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); }); test("dismantle on a subset emits implicit Break + Dismantle on the new group", async () => { const ui = mount(localGroup({ count: 3 })); await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle")); const input = ui.getByTestId("inspector-ship-group-form-dismantle-ships") as HTMLInputElement; await fireEvent.input(input, { target: { value: "2" } }); await fireEvent.click(ui.getByTestId("inspector-ship-group-form-dismantle-confirm")); await waitFor(() => expect(draft.commands).toHaveLength(2)); const [breakCmd, action] = draft.commands; if (breakCmd?.kind !== "breakShipGroup") throw new Error("expected break first"); if (action?.kind !== "dismantleShipGroup") throw new Error("expected dismantle second"); expect(breakCmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); expect(breakCmd.quantity).toBe(2); expect(action.groupId).toBe(breakCmd.newGroupId); }); test("transfer to the only available race emits a transferShipGroup", async () => { const ui = mount(localGroup(), { otherRaces: ["Aliens"] }); await fireEvent.click(ui.getByTestId("inspector-ship-group-action-transfer")); await fireEvent.click(ui.getByTestId("inspector-ship-group-form-transfer-confirm")); await waitFor(() => expect(draft.commands).toHaveLength(1)); const cmd = draft.commands[0]!; if (cmd.kind !== "transferShipGroup") throw new Error("wrong kind"); expect(cmd.acceptor).toBe("Aliens"); expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); }); test("join fleet with a fresh name emits joinFleetShipGroup", async () => { const ui = mount(localGroup()); await fireEvent.click(ui.getByTestId("inspector-ship-group-action-join-fleet")); const input = ui.getByTestId("inspector-ship-group-form-join-fleet-new") as HTMLInputElement; await fireEvent.input(input, { target: { value: "Vanguard" } }); await fireEvent.click(ui.getByTestId("inspector-ship-group-form-join-fleet-confirm")); await waitFor(() => expect(draft.commands).toHaveLength(1)); const cmd = draft.commands[0]!; if (cmd.kind !== "joinFleetShipGroup") throw new Error("wrong kind"); expect(cmd.name).toBe("Vanguard"); expect(cmd.groupId).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); }); }); describe("ship-group inspector — destructive command lock", () => { const ALL_ACTION_TESTIDS = [ "inspector-ship-group-action-split", "inspector-ship-group-action-send", "inspector-ship-group-action-load", "inspector-ship-group-action-unload", "inspector-ship-group-action-modernize", "inspector-ship-group-action-dismantle", "inspector-ship-group-action-transfer", "inspector-ship-group-action-join-fleet", ]; test("a queued dismantleShipGroup disables every action with the lock tooltip", async () => { const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; await draft.add({ kind: "dismantleShipGroup", id: crypto.randomUUID(), groupId, }); const ui = mount(localGroup({ id: groupId, count: 3, cargo: "MAT", load: 0.5 })); const banner = ui.getByTestId("inspector-ship-group-actions-locked"); expect(banner).toHaveTextContent(/dismantle/i); for (const id of ALL_ACTION_TESTIDS) { const button = ui.getByTestId(id); expect(button).toBeDisabled(); expect(button.getAttribute("title")).toMatch(/order is already queued/i); } }); test("a queued upgradeShipGroup locks the inspector and reports modernize as the kind", async () => { const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; await draft.add({ kind: "upgradeShipGroup", id: crypto.randomUUID(), groupId, tech: "ALL", level: 0, }); const ui = mount(localGroup({ id: groupId, count: 2 })); expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent( /modernize/i, ); }); test("a queued transferShipGroup locks the inspector and reports transfer as the kind", async () => { const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; await draft.add({ kind: "transferShipGroup", id: crypto.randomUUID(), groupId, acceptor: "Aliens", }); const ui = mount(localGroup({ id: groupId })); expect(ui.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent( /transfer/i, ); }); test("a queued sendShipGroup does NOT lock the group", async () => { const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; await draft.add({ kind: "sendShipGroup", id: crypto.randomUUID(), groupId, destinationPlanetNumber: 99, }); const ui = mount(localGroup({ id: groupId, count: 3 })); expect( ui.queryByTestId("inspector-ship-group-actions-locked"), ).toBeNull(); expect(ui.getByTestId("inspector-ship-group-action-split")).not.toBeDisabled(); }); test("a destructive command targeting a different group does not lock this one", async () => { await draft.add({ kind: "dismantleShipGroup", id: crypto.randomUUID(), groupId: "ffffffff-ffff-ffff-ffff-ffffffffffff", }); const ui = mount(localGroup({ count: 3 })); expect( ui.queryByTestId("inspector-ship-group-actions-locked"), ).toBeNull(); }); test("removing the destructive command from the draft releases the lock", async () => { const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; const cmdId = crypto.randomUUID(); await draft.add({ kind: "dismantleShipGroup", id: cmdId, groupId, }); const ui = mount(localGroup({ id: groupId, count: 3 })); expect(ui.getByTestId("inspector-ship-group-actions-locked")).toBeInTheDocument(); await draft.remove(cmdId); await waitFor(() => { expect( ui.queryByTestId("inspector-ship-group-actions-locked"), ).toBeNull(); }); expect(ui.getByTestId("inspector-ship-group-action-split")).not.toBeDisabled(); }); });