// `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 void>>; fireOnline: () => void; fireOffline: () => void; } function makeBrowser(initial: boolean): FakeBrowser { const listeners = new Map 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>(); 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); }); });