723885e74e
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
Three issues surfaced once the per-command rejection from the previous commit actually reached the UI: 1. Sync banner falsely red. `OrderDraftStore.runSync` flipped `syncStatus = "error"` whenever any command was rejected and advertised a Retry button. A per-command rejection is a player-correctable state — the round trip succeeded, the engine just refused that command — so the retry can't help. Keep `syncStatus = "synced"` on `success`; the red row highlight is the visible cue. 2. Rejection reason missing. Add `cmd_error_message: string` to `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to preserve existing slot offsets) and regenerate the Go + TS stubs for that one type. Plumb the message through `CommandMeta`, `Controller.applyCommand`'s `m.Result(code, message)` call, the Go transcoder, the UI decoders in `submit.ts` / `order-load.ts`, and the `OrderDraftStore.errorMessages` map. `order-tab.svelte` renders it as an italic danger-coloured line under rejected commands, with new CSS for `.error-reason`. 3. Verdict lost on navigation. `order-load.ts.decodeCommand` never read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell back to a blanket "applied" status — a previously-rejected command came back green after a lobby → game round trip. Extend the fetch decoder to populate `statuses`/`errorCodes`/ `errorMessages` maps and have `hydrateFromServer` use them. Engine-side persistence already records the verdict on disk — verified against the live `0000/order/<id>.json`. `flatbuffers@25` elides default-int8/int64 fields on write; the Go transcoder force-slots `cmd_applied=false` / `cmd_error_code=0` already, the new test fixtures flip `builder.forceDefaults(true)` to mirror that behaviour so the round trip survives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
319 lines
9.9 KiB
TypeScript
319 lines
9.9 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.
|
|
*/
|
|
/**
|
|
* FakeFetchStatus carries the per-command result fields the engine
|
|
* would echo on `user.games.order.get` (cmdApplied / cmdErrorCode /
|
|
* cmdErrorMessage). The helper omits each set only when the
|
|
* corresponding pointer is `undefined`, mimicking the engine's
|
|
* `omitempty` semantics.
|
|
*/
|
|
export interface FakeFetchStatus {
|
|
applied?: boolean;
|
|
errorCode?: number;
|
|
errorMessage?: string;
|
|
}
|
|
|
|
export function fakeFetchClient(
|
|
gameId: string,
|
|
commands: OrderCommand[],
|
|
updatedAt: number,
|
|
found = true,
|
|
statuses?: Record<string, FakeFetchStatus>,
|
|
): { 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, statuses ?? {}),
|
|
};
|
|
},
|
|
} as unknown as GalaxyClient;
|
|
return { client };
|
|
}
|
|
|
|
function encodeApplied(
|
|
gameId: string,
|
|
cmdIds: string[],
|
|
applied: boolean,
|
|
): Uint8Array {
|
|
const builder = new Builder(256);
|
|
// See `encodeOrderGet` — flatbuffers@25 elides `cmd_applied=false`
|
|
// against its int8 default; mirror the Go transcoder's force-slot
|
|
// behaviour so the boolean survives the round trip in tests.
|
|
builder.forceDefaults(true);
|
|
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,
|
|
statuses: Record<string, FakeFetchStatus>,
|
|
): Uint8Array {
|
|
const builder = new Builder(256);
|
|
// flatbuffers@25 elides fields equal to their generated default; the
|
|
// Go transcoder works around this via explicit `Slot()` calls.
|
|
// Mirror that behaviour here so `cmd_applied=false` and
|
|
// `cmd_error_code=0` round-trip through the helper instead of being
|
|
// silently dropped.
|
|
builder.forceDefaults(true);
|
|
|
|
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 status = statuses[cmd.id] ?? {};
|
|
const errMsgOffset =
|
|
status.errorMessage !== undefined
|
|
? builder.createString(status.errorMessage)
|
|
: 0;
|
|
const inner = CommandPlanetRename.createCommandPlanetRename(
|
|
builder,
|
|
BigInt(cmd.planetNumber),
|
|
nameOffset,
|
|
);
|
|
CommandItem.startCommandItem(builder);
|
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
|
if (status.applied !== undefined) {
|
|
CommandItem.addCmdApplied(builder, status.applied);
|
|
}
|
|
if (status.errorCode !== undefined) {
|
|
CommandItem.addCmdErrorCode(builder, BigInt(status.errorCode));
|
|
}
|
|
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
|
CommandItem.addPayload(builder, inner);
|
|
if (errMsgOffset !== 0) {
|
|
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
|
|
}
|
|
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();
|
|
}
|