54733bfb14
Send joins Modernize / Dismantle / Transfer as a lockable command: once any of the four lands in the draft for a group, every action button on its inspector is disabled with a "command pending" tooltip and the banner names the queued kind. Load / Unload / Split / Join Fleet stay non-locking — they stack legitimately on the engine side. Two dashed overlays now run alongside the cargo-route arrows: - Yellow dashed track for own in-space groups, drawn from the origin planet to the destination (matches the in-space point colour so eye reads both as one entity). - Green dashed track for every wire-valid sendShipGroup command in the order draft, drawn from the source group's orbit planet to the chosen destination. Disappears when the command is removed from the order tab, when the engine rejects it, or when the group has left orbit (in-space track replaces it). Both tracks are wrap-aware via torusShortestDelta and never participate in hit-test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
// 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<GalaxyDB>;
|
|
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<void>((resolve) => {
|
|
const req = indexedDB.deleteDatabase(dbName);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => resolve();
|
|
req.onblocked = () => resolve();
|
|
});
|
|
});
|
|
|
|
function planet(
|
|
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "name" | "x" | "y" | "kind">,
|
|
): 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> = {},
|
|
): 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<unknown, unknown>([
|
|
[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 locks the inspector and reports send as the kind", 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.getByTestId("inspector-ship-group-actions-locked")).toHaveTextContent(
|
|
/send/i,
|
|
);
|
|
expect(ui.getByTestId("inspector-ship-group-action-split")).toBeDisabled();
|
|
});
|
|
|
|
test("a queued loadShipGroup does NOT lock the group", async () => {
|
|
const groupId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
|
await draft.add({
|
|
kind: "loadShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
cargo: "MAT",
|
|
quantity: 1,
|
|
});
|
|
const ui = mount(localGroup({ id: groupId, count: 3, cargo: "MAT", load: 0.5 }));
|
|
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();
|
|
});
|
|
});
|