Files
Ilia Denisov 2ca47eb4df 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>
2026-05-11 22:00:16 +02:00

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);
});
});