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>
234 lines
6.4 KiB
TypeScript
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);
|
|
});
|
|
});
|