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:
@@ -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