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,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",
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user