2ca47eb4df
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>
233 lines
6.4 KiB
TypeScript
233 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]]),
|
|
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);
|
|
});
|
|
});
|