ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// `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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user