Files
galaxy-game/ui/frontend/tests/sync-submit-ship-group.test.ts
Ilia Denisov 3626998a33 ui/phase-20: ship-group inspector actions
Eight ship-group operations land on the inspector behind a single
inline-form panel: split, send, load, unload, modernize, dismantle,
transfer, join fleet. Each action either appends a typed command to
the local order draft or surfaces a tooltip explaining the
disabled state. Partial-ship operations emit an implicit
breakShipGroup command before the targeted action so the engine
sees a clean (Break, Action) pair on the wire.

`pkg/calc.BlockUpgradeCost` migrates from
`game/internal/controller/ship_group_upgrade.go` so the calc
bridge can wrap a pure pkg/calc formula; the controller now
imports it. The bridge surfaces the function as
`core.blockUpgradeCost`, which the inspector calls once per ship
block to render the modernize cost preview.

`GameReport.otherRaces` is decoded from the report's player block
(non-extinct, ≠ self) and feeds the transfer-to-race picker. The
planet inspector's stationed-ship rows become clickable for own
groups so the actions panel is reachable from the standard click
flow (the renderer continues to hide on-planet groups).

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

267 lines
8.4 KiB
TypeScript

// Vitest round-trip coverage for the eight Phase 20 ship-group
// command shapes. The encoder lives in `sync/submit.ts`; the
// decoder lives in `sync/order-load.ts`. We capture the request
// bytes the encoder produces, re-emit them inside a
// `UserGamesOrderGetResponse` envelope, and feed that to
// `fetchOrder`. The decoded command must match the original — any
// drift between encoder and decoder fails here first.
import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test, vi } from "vitest";
import type { GalaxyClient } from "../src/api/galaxy-client";
import { uuidToHiLo } from "../src/api/game-state";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
import { fetchOrder } from "../src/sync/order-load";
import { submitOrder } from "../src/sync/submit";
import type { OrderCommand } from "../src/sync/order-types";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
function mockClient(
executeCommand: (
messageType: string,
payload: Uint8Array,
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
): GalaxyClient {
return { executeCommand } as unknown as GalaxyClient;
}
// captureRequestBytes runs submitOrder against a mock that records
// the outgoing payload, then returns those bytes (which are a valid
// `UserGamesOrder` envelope).
async function captureRequestBytes(cmds: OrderCommand[]): Promise<Uint8Array> {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_msg: string, payload: Uint8Array) => {
captured = payload;
const builder = new Builder(64);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(0));
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return { resultCode: "ok", payloadBytes: builder.asUint8Array() };
});
const result = await submitOrder(mockClient(exec), GAME_ID, cmds);
expect(result.ok).toBe(true);
expect(captured).not.toBeNull();
return captured!;
}
// wrapAsGetResponse rebuilds the captured `UserGamesOrder` inside a
// `UserGamesOrderGetResponse` envelope by walking each
// `CommandItem`, copying its identity fields, and re-packing each
// payload through `unpack().pack(builder)` — the FBS-generated
// helper that round-trips a typed table into a fresh builder.
function wrapAsGetResponse(orderBytes: Uint8Array): Uint8Array {
const order = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(orderBytes),
);
const builder = new Builder(256);
const itemOffsets: number[] = [];
for (let i = 0; i < order.commandsLength(); i++) {
const item = order.commands(i);
if (item === null) continue;
const cmdIdOffset = builder.createString(item.cmdId() ?? "");
const payloadType = item.payloadType();
const payloadOffset = packPayload(builder, item, payloadType);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, payloadOffset);
itemOffsets.push(CommandItem.endCommandItem(builder));
}
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, order.updatedAt());
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const resOffset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(resOffset);
return builder.asUint8Array();
}
function packPayload(
builder: Builder,
item: NonNullable<ReturnType<UserGamesOrder["commands"]>>,
payloadType: CommandPayload,
): number {
switch (payloadType) {
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupSend: {
const inner = new CommandShipGroupSend();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupLoad: {
const inner = new CommandShipGroupLoad();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupUnload: {
const inner = new CommandShipGroupUnload();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupUpgrade: {
const inner = new CommandShipGroupUpgrade();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupDismantle: {
const inner = new CommandShipGroupDismantle();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupTransfer: {
const inner = new CommandShipGroupTransfer();
item.payload(inner);
return inner.unpack().pack(builder);
}
case CommandPayload.CommandShipGroupJoinFleet: {
const inner = new CommandShipGroupJoinFleet();
item.payload(inner);
return inner.unpack().pack(builder);
}
default:
throw new Error(`unsupported payload type ${payloadType}`);
}
}
async function roundTrip(cmd: OrderCommand): Promise<OrderCommand> {
const requestBytes = await captureRequestBytes([cmd]);
const responseBytes = wrapAsGetResponse(requestBytes);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responseBytes,
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 0);
expect(result.commands).toHaveLength(1);
return result.commands[0]!;
}
describe("submit + order-load round-trip — ship-group commands", () => {
test("breakShipGroup", async () => {
const cmd: OrderCommand = {
kind: "breakShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
newGroupId: "11112222-3333-4444-5555-666677778888",
quantity: 3,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("sendShipGroup", async () => {
const cmd: OrderCommand = {
kind: "sendShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
destinationPlanetNumber: 42,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("loadShipGroup", async () => {
const cmd: OrderCommand = {
kind: "loadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
cargo: "MAT",
quantity: 12.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("unloadShipGroup", async () => {
const cmd: OrderCommand = {
kind: "unloadShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
quantity: 6.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("upgradeShipGroup ALL", async () => {
const cmd: OrderCommand = {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "ALL",
level: 0,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("upgradeShipGroup DRIVE level 1.5", async () => {
const cmd: OrderCommand = {
kind: "upgradeShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
tech: "DRIVE",
level: 1.5,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("dismantleShipGroup", async () => {
const cmd: OrderCommand = {
kind: "dismantleShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("transferShipGroup", async () => {
const cmd: OrderCommand = {
kind: "transferShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
acceptor: "Aliens",
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
test("joinFleetShipGroup", async () => {
const cmd: OrderCommand = {
kind: "joinFleetShipGroup",
id: crypto.randomUUID(),
groupId: GROUP_ID,
name: "Vanguard",
};
expect(await roundTrip(cmd)).toEqual(cmd);
});
});