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:
Ilia Denisov
2026-05-11 22:00:16 +02:00
parent bbdcc36e05
commit 2ca47eb4df
35 changed files with 2539 additions and 143 deletions
+340
View File
@@ -0,0 +1,340 @@
// Phase 25 end-to-end coverage for the sync protocol additions on
// the order tab: the offline / online flip, the
// `turn_already_closed` conflict banner, and the `game.paused` push
// frame. Each test boots an authenticated session, mocks the lobby
// + report + order routes, drives an order mutation through the
// inspector, and asserts the matching banner / sync-status DOM.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
UserGamesOrder,
UserGamesOrderGet,
} from "../../src/proto/galaxy/fbs/order";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import { forgeGatewayEventFrame } from "./fixtures/sign-event";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
import {
buildOrderGetResponsePayload,
buildOrderResponsePayload,
type CommandResultFixture,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-25-order-sync-session";
const GAME_ID = "25252525-2525-2525-2525-252525252525";
const WORLD = 4000;
const CENTRE = WORLD / 2;
const TURN = 4;
type SubmitVerdict = "applied" | "rejected" | "turn_already_closed" | "game_paused";
interface MockOpts {
/** Initial server-side order returned by `user.games.order.get`. */
storedOrder?: CommandResultFixture[];
/** How the first `user.games.order` submit replies. */
initialSubmitVerdict: SubmitVerdict;
/**
* If set, the SubscribeEvents stream emits this frame instead of
* holding the connection open. Used by the paused-banner test.
*/
subscribeFrame?: { eventType: string; payload: Uint8Array };
}
interface MockHandle {
/** Setter the test uses to flip the verdict mid-run. */
setSubmitVerdict(next: SubmitVerdict): void;
/** Read-only counter for assertion. */
get submitCallCount(): number;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 25 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: TURN,
};
let storedOrder = (opts.storedOrder ?? []).slice();
let submitVerdict: SubmitVerdict = opts.initialSubmitVerdict;
let submitCalls = 0;
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
let bodyOverride: string | null = null;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: TURN,
mapWidth: WORLD,
mapHeight: WORLD,
localPlanets: [
{
number: 17,
name: "Earth",
x: CENTRE,
y: CENTRE,
size: 1000,
resources: 10,
capital: 0,
material: 0,
population: 850,
colonists: 25,
industry: 700,
production: "drive",
freeIndustry: 175,
},
],
});
break;
}
case "user.games.order": {
submitCalls += 1;
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(req.payloadBytes),
);
const length = decoded.commandsLength();
const fixtures: CommandResultFixture[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const cmdId = item.cmdId() ?? "";
const inner = new (await import(
"../../src/proto/galaxy/fbs/order"
)).CommandPlanetRename();
item.payload(inner);
const submittedName = inner.name() ?? "";
const applied = submitVerdict === "applied";
fixtures.push({
kind: "planetRename",
cmdId,
planetNumber: Number(inner.number()),
name: submittedName,
applied,
errorCode: applied ? null : 1,
});
}
if (submitVerdict === "turn_already_closed") {
resultCode = "turn_already_closed";
bodyOverride = JSON.stringify({
code: "turn_already_closed",
message: "turn closed before submit",
});
} else if (submitVerdict === "game_paused") {
resultCode = "game_paused";
bodyOverride = JSON.stringify({
code: "game_paused",
message: "game is paused",
});
}
if (submitVerdict === "applied") {
storedOrder = fixtures;
}
payload =
bodyOverride !== null
? new TextEncoder().encode(bodyOverride)
: buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
}
case "user.games.order.get": {
UserGamesOrderGet.getRootAsUserGamesOrderGet(
new ByteBuffer(req.payloadBytes),
);
payload = buildOrderGetResponsePayload(
GAME_ID,
storedOrder,
Date.now(),
storedOrder.length > 0,
);
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
let subscribeServed = false;
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async (route) => {
if (opts.subscribeFrame !== undefined && !subscribeServed) {
subscribeServed = true;
const frame = await forgeGatewayEventFrame({
eventType: opts.subscribeFrame.eventType,
eventId: "evt-phase25-1",
timestampMs: BigInt(Date.now()),
requestId: "req-phase25-1",
traceId: "trace-phase25-1",
payloadBytes: opts.subscribeFrame.payload,
});
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
return;
}
await new Promise<void>(() => {});
},
);
return {
setSubmitVerdict(next) {
submitVerdict = next;
},
get submitCallCount() {
return submitCalls;
},
};
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.evaluate(
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
GAME_ID,
);
}
async function clickPlanetCentre(page: Page): Promise<void> {
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) throw new Error("canvas has no bounding box");
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
async function startRename(page: Page, newName: string): Promise<void> {
await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click();
const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill(newName);
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
}
test("turn_already_closed surfaces the conflict banner on the order tab", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 25 spec covers desktop layout; mobile inherits the same store",
);
await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await startRename(page, "Conflict-Earth");
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-conflict-banner")).toBeVisible({
timeout: 5_000,
});
await expect(orderTool.getByTestId("order-conflict-banner")).toContainText(
"Edit and resubmit",
);
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"conflict",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"conflict",
);
});
test("game.paused push frame surfaces the paused banner", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 25 spec covers desktop layout; mobile inherits the same store",
);
const payload = new TextEncoder().encode(
JSON.stringify({
game_id: GAME_ID,
turn: TURN,
reason: "generation_failed",
}),
);
await mockGateway(page, {
initialSubmitVerdict: "applied",
subscribeFrame: { eventType: "game.paused", payload },
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-paused-banner")).toBeVisible({
timeout: 5_000,
});
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"paused",
);
});
+30
View File
@@ -291,6 +291,36 @@ describe("EventStream", () => {
eventStream.stop();
});
test("game.paused events dispatch to the matching handler (Phase 25)", async () => {
const handler = vi.fn();
eventStream.on("game.paused", handler);
const payload = new TextEncoder().encode(
JSON.stringify({
game_id: "11111111-2222-3333-4444-555555555555",
turn: 7,
reason: "generation_failed",
}),
);
const event = buildEvent("game.paused", payload);
const client = makeRouter(async function* () {
yield event;
});
eventStream.start({
core: mockCore(),
keypair: mockKeypair(),
deviceSessionId: "device-1",
gatewayResponsePublicKey: new Uint8Array(32),
client,
sleep: async () => {},
random: () => 0,
});
await vi.waitFor(() => {
expect(handler).toHaveBeenCalled();
});
expect(handler.mock.calls[0]?.[0].eventType).toBe("game.paused");
eventStream.stop();
});
test("connectionStatus transitions through connecting → connected → idle", async () => {
expect(eventStream.connectionStatus).toBe("idle");
const event = buildEvent(
+57 -18
View File
@@ -33,10 +33,25 @@ interface RecordedCall {
commandIds: string[];
}
/**
* RecordingOutcome enumerates the synthetic server reactions a test
* can drive through `recordingClient.setOutcome`. Phase 25 adds the
* `turn_already_closed` and `game_paused` codes (the order-queue
* classifies them into `conflict` / `paused` outcomes) and `throw`
* which lets the test exercise the network-error branch of
* `OrderQueue.send`.
*/
export type RecordingOutcome =
| "ok"
| "rejected"
| "turn_already_closed"
| "game_paused"
| "throw";
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void;
setOutcome(outcome: RecordingOutcome): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
@@ -51,11 +66,11 @@ interface RecordingHandle {
*/
export function recordingClient(
gameId: string,
initialOutcome: "ok" | "rejected",
initialOutcome: RecordingOutcome,
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome;
let outcome: RecordingOutcome = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
@@ -81,21 +96,45 @@ export function recordingClient(
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
if (outcome === "ok") {
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
switch (outcome) {
case "ok":
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
case "turn_already_closed":
return {
resultCode: "turn_already_closed",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "turn_already_closed",
message: "turn closed before submit",
}),
),
};
case "game_paused":
return {
resultCode: "game_paused",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "game_paused",
message: "game is paused",
}),
),
};
case "throw":
throw new Error("network down");
default:
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
@@ -113,7 +152,7 @@ export function recordingClient(
return {
client,
calls,
setOutcome(next: "ok" | "rejected") {
setOutcome(next: RecordingOutcome) {
outcome = next;
},
async waitForCalls(n: number) {
+186
View File
@@ -623,3 +623,189 @@ describe("OrderDraftStore auto-sync", () => {
store.dispose();
});
});
describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
test("turn_already_closed marks the in-flight commands as conflict", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client, { getCurrentTurn: () => 7 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
expect(store.statuses["id-1"]).toBe("conflict");
expect(store.conflictBanner).not.toBeNull();
expect(store.conflictBanner?.turn).toBe(7);
expect(store.conflictBanner?.code).toBe("turn_already_closed");
store.dispose();
});
test("mutating after a conflict clears the banner and revalidates", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client, { getCurrentTurn: () => 3 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
// Adding a second command must wipe the conflict banner and
// re-validate the prior conflict-marked entry. Auto-sync
// re-fires (still seeing turn_already_closed) and the
// store ends up back in conflict for the new attempt.
handle.setOutcome("ok");
await store.add({
kind: "planetRename",
id: "id-2",
planetNumber: 2,
name: "Mars",
});
await handle.waitForCalls(2);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.statuses["id-2"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
expect(store.conflictBanner).toBeNull();
store.dispose();
});
test("game_paused outcome surfaces the pause banner and locks sync", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "game_paused");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
expect(store.syncStatus).toBe("paused");
expect(store.pausedBanner).not.toBeNull();
expect(store.statuses["id-1"]).toBe("valid"); // reverted, not in flight
// While paused, additional mutations should not trigger another
// submit — the queue would just hit the same wall.
const before = handle.calls.length;
store.forceSync();
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(handle.calls.length).toBe(before);
store.dispose();
});
test("markPaused projects a push event into the store", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.markPaused({ reason: "generation_failed" });
expect(store.syncStatus).toBe("paused");
expect(store.pausedBanner?.reason).toBe("generation_failed");
store.dispose();
});
test("resetForNewTurn clears the conflict banner and rehydrates", async () => {
const { fakeFetchClient, recordingClient } = await import(
"./helpers/fake-order-client"
);
const recHandle = recordingClient(GAME_ID, "turn_already_closed");
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(recHandle.client, { getCurrentTurn: () => 5 });
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await recHandle.waitForCalls(1);
expect(store.syncStatus).toBe("conflict");
const { client: fetchClient } = fakeFetchClient(GAME_ID, [
{
kind: "planetRename",
id: "fresh-1",
planetNumber: 9,
name: "Hydrated",
},
], 11);
await store.resetForNewTurn({ client: fetchClient, turn: 6 });
expect(store.conflictBanner).toBeNull();
expect(store.syncStatus).toBe("synced");
expect(store.commands.map((c) => c.id)).toEqual(["fresh-1"]);
expect(store.updatedAt).toBe(11);
store.dispose();
});
test("offline outcome holds the submit until online flips", async () => {
const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "ok");
const store = new OrderDraftStore();
// Stub the browser event surface so we can flip online/offline
// deterministically and assert the queue's reaction.
const listeners = new Map<string, Set<() => void>>();
let online = false;
await store.init({
cache,
gameId: GAME_ID,
queue: {
onlineProbe: () => online,
addEventListener: (event, handler) => {
let bucket = listeners.get(event);
if (bucket === undefined) {
bucket = new Set();
listeners.set(event, bucket);
}
bucket.add(handler);
},
removeEventListener: (event, handler) => {
listeners.get(event)?.delete(handler);
},
onOnline: () => undefined,
},
});
store.bindClient(handle.client);
await store.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
// Submission must NOT have left the queue while offline.
await new Promise<void>((resolve) => setTimeout(resolve, 20));
expect(handle.calls).toHaveLength(0);
expect(store.syncStatus).toBe("offline");
expect(store.statuses["id-1"]).toBe("valid");
// Flip online and fire the browser `online` event; the queue's
// onOnline callback (set inside OrderDraftStore) schedules a
// fresh sync.
online = true;
const onlineBucket = listeners.get("online");
onlineBucket?.forEach((h) => h());
await handle.waitForCalls(1);
expect(store.statuses["id-1"]).toBe("applied");
expect(store.syncStatus).toBe("synced");
store.dispose();
});
});
+232
View File
@@ -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);
});
});
+54
View File
@@ -175,4 +175,58 @@ describe("order-tab", () => {
});
draft.dispose();
});
test("turn_already_closed surfaces the conflict banner with the turn", async () => {
const handle = recordingClient(GAME_ID, "turn_already_closed");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client, { getCurrentTurn: () => 12 });
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
const banner = ui.getByTestId("order-conflict-banner");
expect(banner).toBeVisible();
expect(banner).toHaveTextContent("Turn 12");
expect(banner).toHaveAttribute("data-conflict-turn", "12");
});
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("conflict");
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"conflict",
);
draft.dispose();
});
test("game_paused surfaces the paused banner and blocks retry", async () => {
const handle = recordingClient(GAME_ID, "game_paused");
const { draft, context } = await makeDraft([]);
draft.bindClient(handle.client);
const ui = render(OrderTab, { context });
await draft.add({
kind: "planetRename",
id: "id-1",
planetNumber: 1,
name: "Earth",
});
await handle.waitForCalls(1);
await waitFor(() => {
expect(ui.getByTestId("order-paused-banner")).toBeVisible();
});
expect(ui.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status",
"paused",
);
// No retry button is shown for paused state.
expect(ui.queryByTestId("order-sync-retry")).toBeNull();
draft.dispose();
});
});