Files
galaxy-game/ui/frontend/tests/order-queue.test.ts
T
Ilia Denisov 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
fix(order): surface rejection reason, keep sync green, hydrate verdicts
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>
2026-05-29 11:42:27 +02:00

234 lines
6.4 KiB
TypeScript

// `OrderQueue` unit tests under JSDOM. The queue isolates the
// browser online/offline plumbing and the conflict / paused
// classification from the rest of the draft store, so these tests
// drive it directly with injected listeners and synthesised
// `SubmitResult`s.
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { OrderQueue, classifyResult } from "../src/sync/order-queue.svelte";
import type {
SubmitFailure,
SubmitResult,
SubmitSuccess,
} from "../src/sync/submit";
interface FakeBrowser {
online: boolean;
listeners: Map<string, Set<() => void>>;
fireOnline: () => void;
fireOffline: () => void;
}
function makeBrowser(initial: boolean): FakeBrowser {
const listeners = new Map<string, Set<() => void>>();
const browser: FakeBrowser = {
online: initial,
listeners,
fireOnline: () => {
browser.online = true;
const bucket = listeners.get("online");
if (bucket !== undefined) for (const h of [...bucket]) h();
},
fireOffline: () => {
browser.online = false;
const bucket = listeners.get("offline");
if (bucket !== undefined) for (const h of [...bucket]) h();
},
};
return browser;
}
function startQueue(
browser: FakeBrowser,
onOnline: () => void = () => undefined,
): OrderQueue {
const queue = new OrderQueue();
queue.start({
onOnline,
onlineProbe: () => browser.online,
addEventListener: (event, handler) => {
let bucket = browser.listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
browser.listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
const bucket = browser.listeners.get(event);
if (bucket !== undefined) bucket.delete(handler);
},
});
return queue;
}
function success(updatedAt = 1): SubmitSuccess {
return {
ok: true,
results: new Map([["id", "applied"]]),
errorCodes: new Map([["id", null]]),
errorMessages: new Map([["id", null]]),
updatedAt,
};
}
function failure(resultCode: string, code = resultCode, message = ""): SubmitFailure {
return {
ok: false,
resultCode,
code,
message: message || resultCode,
};
}
describe("classifyResult", () => {
test("ok result maps to success", () => {
const out = classifyResult(success());
expect(out.kind).toBe("success");
});
test("turn_already_closed resultCode maps to conflict", () => {
const out = classifyResult(failure("turn_already_closed"));
expect(out.kind).toBe("conflict");
if (out.kind === "conflict") {
expect(out.code).toBe("turn_already_closed");
}
});
test("game_paused resultCode maps to paused", () => {
const out = classifyResult(failure("game_paused", "game_paused", "paused"));
expect(out.kind).toBe("paused");
if (out.kind === "paused") {
expect(out.code).toBe("game_paused");
expect(out.message).toBe("paused");
}
});
test("turn_already_closed via inner code (resultCode opaque) maps to conflict", () => {
// gateway may set resultCode to something opaque while the
// FBS error body carries the actionable code.
const out = classifyResult(failure("conflict", "turn_already_closed"));
expect(out.kind).toBe("conflict");
});
test("unknown failure code stays a rejected outcome", () => {
const out = classifyResult(failure("validation_failed"));
expect(out.kind).toBe("rejected");
});
});
describe("OrderQueue.send", () => {
let browser: FakeBrowser;
let queue: OrderQueue;
beforeEach(() => {
browser = makeBrowser(true);
queue = startQueue(browser);
});
afterEach(() => {
queue.stop();
});
test("offline at call time short-circuits without invoking submit", async () => {
browser.fireOffline();
const submitFn = vi.fn<() => Promise<SubmitResult>>();
const outcome = await queue.send(submitFn);
expect(outcome.kind).toBe("offline");
expect(submitFn).not.toHaveBeenCalled();
});
test("ok result is forwarded as success", async () => {
const outcome = await queue.send(async () => success(42));
expect(outcome.kind).toBe("success");
if (outcome.kind === "success") {
expect(outcome.result.updatedAt).toBe(42);
}
});
test("turn_already_closed reply maps to conflict outcome", async () => {
const outcome = await queue.send(async () =>
failure("turn_already_closed", "turn_already_closed", "turn closed"),
);
expect(outcome.kind).toBe("conflict");
if (outcome.kind === "conflict") {
expect(outcome.message).toBe("turn closed");
}
});
test("game_paused reply maps to paused outcome", async () => {
const outcome = await queue.send(async () =>
failure("game_paused", "game_paused", "paused"),
);
expect(outcome.kind).toBe("paused");
});
test("throw while online maps to failed outcome", async () => {
const outcome = await queue.send(async () => {
throw new Error("boom");
});
expect(outcome.kind).toBe("failed");
if (outcome.kind === "failed") {
expect(outcome.reason).toBe("boom");
}
});
test("throw with offline probe maps to offline outcome", async () => {
const outcome = await queue.send(async () => {
browser.online = false;
throw new Error("network down");
});
expect(outcome.kind).toBe("offline");
expect(queue.online).toBe(false);
});
test("online event triggers the onOnline callback", () => {
const seen: number[] = [];
const newQueue = new OrderQueue();
newQueue.start({
onOnline: () => seen.push(seen.length + 1),
onlineProbe: () => browser.online,
addEventListener: (event, handler) => {
let bucket = browser.listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
browser.listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
const bucket = browser.listeners.get(event);
if (bucket !== undefined) bucket.delete(handler);
},
});
browser.fireOffline();
expect(newQueue.online).toBe(false);
browser.fireOnline();
expect(newQueue.online).toBe(true);
expect(seen).toEqual([1]);
newQueue.stop();
});
test("start is idempotent for the same queue instance", () => {
queue.start({
onOnline: () => undefined,
onlineProbe: () => browser.online,
addEventListener: () => {
throw new Error("must not be called");
},
removeEventListener: () => undefined,
});
expect(queue.online).toBe(true);
});
test("stop unsubscribes from browser events", () => {
queue.stop();
const before = queue.online;
browser.fireOffline();
// After stop, the queue no longer reflects browser flips.
expect(queue.online).toBe(before);
});
});