Files
galaxy-game/ui/frontend/tests/inspector-ship-group-dismantle-confirm.test.ts
T
Ilia Denisov 24c68e9846
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m6s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (pull_request) Successful in 1m51s
Tests · UI / test (pull_request) Successful in 3m53s
feat(model+ui): F8-05 — race on OtherGroup, real attribution + N×M label
Issue #48 п.32 ("Stationed ship groups") shipped with a fragile race
fallback: when a foreign group sat on a non-`other`-kind planet the
inspector printed a generic "foreign" label, which collapsed the
race dropdown to a single uninformative bucket. The engine FBS
contract did not carry per-group race either, so live games hit the
same gap. This patch carries race authoritatively from the engine
through every layer down to the inspector.

Wire format & engine
- `pkg/schema/fbs/report.fbs`: add `race:string` to `OtherGroup` and
  `LocalGroup` (additive — old clients ignore).
- `pkg/schema/fbs/report/`: regenerated Go bindings.
- `ui/frontend/src/proto/galaxy/fbs/report/`: regenerated TS bindings.
- `pkg/model/report.OtherGroup.Race`: new field; carried through
  `LocalGroup` via the embedded `OtherGroup`.
- `pkg/transcoder/report.go`: encode + decode `race` on both
  `LocalGroup` and `OtherGroup`.
- `game/internal/controller/report.go.otherGroup`: set `v.Race`
  from `c.g.Race[c.RaceIndex(sg.OwnerID)].Name` so every emitted
  group — own or foreign — carries the resolved race name.

Legacy parser
- `tools/local-dev/legacy-report/parser.go`: capture the
  `<Race> Groups` header into `pendingOtherGroup.race`, fill local
  group `Race` from `p.rep.Race`, propagate both into the
  `report.OtherGroup` rows.
- Tests + smoke counts updated; regenerated `KNNTS{039,041}.json`
  fixtures so the synthetic loader carries the new field.

UI
- `ui/frontend/src/api/`: `ReportShipGroupBase.race` field;
  synthetic loader + FBS decoder populate it.
- `ui/frontend/src/lib/inspectors/planet/ship-groups.svelte`: the
  stationed-groups inspector picks race directly from
  `group.race` (own falls back to `localRace`, both finally to the
  `race.unknown` placeholder). The planet-owner / "foreign"
  heuristic is gone.
- Row label changes from "N ships mass M" to a compact
  `<class>` | `<N ×>` | `<mass>` three-column layout: the count
  cell is right-aligned tabular, the mass cell is right-aligned
  monospace + tabular, matching the inspector / calculator number
  conventions. Stale i18n keys removed
  (`ship_groups.row.count`, `.row.mass`, `.race.foreign`).
- All affected unit tests (8 files) carry the new `race` field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:23:17 +02:00

203 lines
5.5 KiB
TypeScript

// Vitest coverage for the Phase 20 dismantle confirmation. The
// inspector requires an explicit second click ("colonists die") when
// the player tries to dismantle a colonist-laden group over a
// foreign planet — engine rule reference:
// `controller/ship_group.go.shipGroupDismantle:177-179` (over a
// foreign planet, `UnloadColonists` is not called and the cargo is
// lost).
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 {
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[] = [
{
number: 99,
name: "Outpost",
x: 100,
y: 100,
kind: "other",
owner: "Foreign",
size: 500,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 500,
population: 500,
colonists: 100,
production: "Capital",
freeIndustry: 500,
},
{
number: 17,
name: "Castle",
x: 50,
y: 50,
kind: "local",
owner: null,
size: 1000,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 100,
production: "Capital",
freeIndustry: 1000,
},
];
const SHIP_CLASS_FRONTIER: ShipClassSummary = {
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
};
beforeEach(async () => {
dbName = `galaxy-ship-group-dismantle-${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 group(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
count: 2,
class: "Frontier",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 1 },
cargo: "COL",
load: 1.5,
destination: 99,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
race: "Earthlings",
...overrides,
};
}
function mount(g: ReportLocalShipGroup) {
const selection: ShipGroupSelection = { variant: "local", group: g };
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
]);
return render(ShipGroup, {
props: {
selection,
planets: PLANETS,
localShipClass: [SHIP_CLASS_FRONTIER],
localFleets: [],
otherRaces: ["Aliens"],
mapWidth: 1000,
mapHeight: 1000,
localPlayerDrive: 5,
localPlayerWeapons: 1,
localPlayerShields: 1,
localPlayerCargo: 2,
},
context,
});
}
describe("ship-group inspector — dismantle confirmation", () => {
test("first click on dismantle of foreign-COL group shows the warning and adds nothing", async () => {
const ui = mount(group());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.getByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeInTheDocument();
const confirm = ui.getByTestId(
"inspector-ship-group-form-dismantle-confirm",
);
expect(confirm).toHaveTextContent(/colonists die/i);
await fireEvent.click(confirm);
expect(draft.commands).toHaveLength(0);
});
test("second click on the colonists-die confirm emits dismantleShipGroup", async () => {
const ui = mount(group());
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
const confirm = ui.getByTestId(
"inspector-ship-group-form-dismantle-confirm",
);
await fireEvent.click(confirm);
await fireEvent.click(confirm);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("dismantleShipGroup");
});
test("dismantle over own planet skips the warning even with COL aboard", async () => {
const ui = mount(group({ destination: 17 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
expect(draft.commands[0]!.kind).toBe("dismantleShipGroup");
});
test("dismantle over foreign planet without colonists skips the warning", async () => {
const ui = mount(group({ cargo: "NONE", load: 0 }));
await fireEvent.click(ui.getByTestId("inspector-ship-group-action-dismantle"));
expect(
ui.queryByTestId("inspector-ship-group-form-dismantle-warning"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-ship-group-form-dismantle-confirm"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
});
});