// 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[]; } interface RecordingHandle { client: GalaxyClient; calls: RecordedCall[]; setOutcome(outcome: "ok" | "rejected"): void; waitForCalls(n: number): Promise; waitForIdle(): Promise; } /** * 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: "ok" | "rejected", options: { delayMs?: number } = {}, ): RecordingHandle { const calls: RecordedCall[] = []; let outcome: "ok" | "rejected" = 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((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 }); if (outcome === "ok") { return { resultCode: "ok", payloadBytes: encodeApplied(gameId, commandIds, true), }; } 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: "ok" | "rejected") { outcome = next; }, async waitForCalls(n: number) { while (calls.length < n) { await new Promise((resolve) => setTimeout(resolve, 5)); } }, async waitForIdle() { if (inFlight === 0) return; await new Promise((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(); }