Files
galaxy-game/ui/frontend/tests/helpers/fake-order-client.ts
T
Ilia Denisov 2ca47eb4df ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.

UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:00:16 +02:00

280 lines
8.4 KiB
TypeScript

// Test helpers that fabricate `GalaxyClient` stand-ins for the
// auto-sync pipeline. Two flavours:
//
// - `recordingClient` — captures every `submitOrder` call and lets
// the test assert on the order of in-flight payloads. The
// outcome (`ok` / `rejected`) is settable per call so tests can
// simulate retry loops.
// - `fakeFetchClient` — wires a synthetic `user.games.order.get`
// response so `OrderDraftStore.hydrateFromServer` exercises the
// decoder against a populated FBS envelope.
//
// Both helpers live under `tests/helpers/` so they can be reused
// across `order-draft.test.ts`, `inspector-overlay.test.ts`, and
// future Phase 14+ specs.
import { Builder } from "flatbuffers";
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,
CommandPlanetRename,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../../src/proto/galaxy/fbs/order";
import type { OrderCommand } from "../../src/sync/order-types";
interface RecordedCall {
messageType: string;
commandIds: string[];
}
/**
* RecordingOutcome enumerates the synthetic server reactions a test
* can drive through `recordingClient.setOutcome`. Phase 25 adds the
* `turn_already_closed` and `game_paused` codes (the order-queue
* classifies them into `conflict` / `paused` outcomes) and `throw`
* which lets the test exercise the network-error branch of
* `OrderQueue.send`.
*/
export type RecordingOutcome =
| "ok"
| "rejected"
| "turn_already_closed"
| "game_paused"
| "throw";
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: RecordingOutcome): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
/**
* recordingClient returns a fake GalaxyClient whose `executeCommand`
* decodes the in-flight UserGamesOrder, records the cmd_ids, and
* answers with a synthesised UserGamesOrderResponse where every
* cmdApplied is true (when outcome="ok") or false (when outcome=
* "rejected"). An optional `delayMs` simulates network latency so
* tests can exercise the coalescing path.
*/
export function recordingClient(
gameId: string,
initialOutcome: RecordingOutcome,
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: RecordingOutcome = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
const client: GalaxyClient = {
async executeCommand(messageType: string, payload: Uint8Array) {
inFlight += 1;
try {
if (options.delayMs !== undefined) {
await new Promise<void>((resolve) =>
setTimeout(resolve, options.delayMs),
);
}
if (messageType === "user.games.order") {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(payload),
);
const length = decoded.commandsLength();
const commandIds: string[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const id = item.cmdId();
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
switch (outcome) {
case "ok":
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
case "turn_already_closed":
return {
resultCode: "turn_already_closed",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "turn_already_closed",
message: "turn closed before submit",
}),
),
};
case "game_paused":
return {
resultCode: "game_paused",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "game_paused",
message: "game is paused",
}),
),
};
case "throw":
throw new Error("network down");
default:
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
inFlight -= 1;
if (inFlight === 0) {
while (waiters.length > 0) {
const wake = waiters.shift();
wake?.();
}
}
}
},
} as unknown as GalaxyClient;
return {
client,
calls,
setOutcome(next: RecordingOutcome) {
outcome = next;
},
async waitForCalls(n: number) {
while (calls.length < n) {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
},
async waitForIdle() {
if (inFlight === 0) return;
await new Promise<void>((resolve) => waiters.push(resolve));
},
};
}
/**
* fakeFetchClient returns a GalaxyClient stand-in whose
* `executeCommand` answers a single hard-coded
* UserGamesOrderGetResponse — enough for `hydrateFromServer` to
* decode a realistic payload without standing up a full mock
* gateway.
*/
export function fakeFetchClient(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found = true,
): { client: GalaxyClient } {
const client: GalaxyClient = {
async executeCommand(messageType: string) {
if (messageType !== "user.games.order.get") {
throw new Error(`unexpected messageType ${messageType}`);
}
return {
resultCode: "ok",
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
};
},
} as unknown as GalaxyClient;
return { client };
}
function encodeApplied(
gameId: string,
cmdIds: string[],
applied: boolean,
): Uint8Array {
const builder = new Builder(256);
const itemOffsets = cmdIds.map((id) => {
const cmdIdOffset = builder.createString(id);
const nameOffset = builder.createString("ignored");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, applied);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder,
itemOffsets,
);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(Date.now()));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
function encodeOrderGet(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found: boolean,
): Uint8Array {
const builder = new Builder(256);
let orderOffset = 0;
if (found) {
const itemOffsets = commands.map((cmd) => {
if (cmd.kind !== "planetRename") {
throw new Error(`unsupported command kind ${cmd.kind}`);
}
const cmdIdOffset = builder.createString(cmd.id);
const nameOffset = builder.createString(cmd.name);
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(cmd.planetNumber),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
UserGamesOrder.addCommands(builder, commandsVec);
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
}
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, found);
if (orderOffset !== 0) {
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
}
const offset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}