ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn (displayed snapshot) so history excursions don't corrupt the resume bookmark or the live-turn bound. Add viewTurn / returnToCurrent / historyMode rune, plus a game-history cache namespace that stores past-turn reports for fast re-entry. OrderDraftStore.bindClient takes a getHistoryMode getter and short-circuits add / remove / move while the user is viewing a past turn; RenderedReportSource skips the order overlay in the same case. Header replaces the static "turn N" with a clickable triplet (TurnNavigator), the layout mounts HistoryBanner under the header, and visibility-refresh is a no-op while history is active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
// Phase 26 end-to-end coverage for history mode. The spec boots an
|
||||
// authenticated session, mocks the gateway calls the in-game shell
|
||||
// makes (`lobby.my.games.list`, `user.games.report`), pre-seeds a
|
||||
// local order draft, and drives the new turn navigator + history
|
||||
// banner.
|
||||
//
|
||||
// The active view is `/table/planets` rather than `/map`: the Pixi
|
||||
// renderer can monopolise the headless Chromium main thread for
|
||||
// hundreds of ms after a snapshot change, which lets the navigator
|
||||
// click win the race against Svelte's reactive flush and the
|
||||
// `toContainText` poll find the old "turn ?" state for the entire
|
||||
// 5 s polling window. The table view exercises the same `GameReport`
|
||||
// data pipeline and the same banner / sidebar wiring without that
|
||||
// rendering tail, so the assertions stay deterministic.
|
||||
//
|
||||
// Gateway mock design notes:
|
||||
// - `user.games.order.get` always replies with a non-ok status so
|
||||
// `OrderDraftStore.hydrateFromServer` short-circuits into its
|
||||
// `syncStatus = "error"` branch without overwriting the local
|
||||
// cache. This keeps the pre-seeded draft in memory across the
|
||||
// boot path, which is what we need to assert "draft survives a
|
||||
// history round-trip".
|
||||
// - `user.games.report` answers any requested turn with a turn
|
||||
// stamp in the local-planet names so a future diagnostic can
|
||||
// prove the rendered snapshot matches the requested turn.
|
||||
// - `SubscribeEvents` is held open so the revocation watcher does
|
||||
// not bounce the test back to `/login`.
|
||||
|
||||
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 { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||
import {
|
||||
buildMyGamesListPayload,
|
||||
type GameFixture,
|
||||
} from "./fixtures/lobby-fbs";
|
||||
import { buildReportPayload } from "./fixtures/report-fbs";
|
||||
|
||||
const SESSION_ID = "phase-26-history-session";
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
const CURRENT_TURN = 5;
|
||||
|
||||
const SEED_DRAFT = [
|
||||
{ kind: "placeholder" as const, id: "cmd-a", label: "first" },
|
||||
{ kind: "placeholder" as const, id: "cmd-b", label: "second" },
|
||||
];
|
||||
|
||||
interface MockState {
|
||||
reportRequests: number[];
|
||||
}
|
||||
|
||||
async function mockGateway(page: Page): Promise<MockState> {
|
||||
const state: MockState = { reportRequests: [] };
|
||||
|
||||
const baseGame = (): GameFixture => ({
|
||||
gameId: GAME_ID,
|
||||
gameName: "Phase 26 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: CURRENT_TURN,
|
||||
});
|
||||
|
||||
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 = new Uint8Array(new ArrayBuffer(0));
|
||||
|
||||
const errorPayload = (message: string): Uint8Array => {
|
||||
const text = new TextEncoder().encode(
|
||||
JSON.stringify({ code: "internal_error", message }),
|
||||
);
|
||||
const buf = new ArrayBuffer(text.byteLength);
|
||||
new Uint8Array(buf).set(text);
|
||||
return new Uint8Array(buf);
|
||||
};
|
||||
|
||||
switch (req.messageType) {
|
||||
case "lobby.my.games.list":
|
||||
payload = buildMyGamesListPayload([baseGame()]);
|
||||
break;
|
||||
case "user.games.report": {
|
||||
const decoded = GameReportRequest.getRootAsGameReportRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
const turn = decoded.turn();
|
||||
state.reportRequests.push(turn);
|
||||
const localPlanets = [
|
||||
{
|
||||
number: 1,
|
||||
name: `Home-${turn}`,
|
||||
x: 1000,
|
||||
y: 1000,
|
||||
},
|
||||
];
|
||||
payload = buildReportPayload({
|
||||
turn,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
localPlanets,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "user.games.order.get": {
|
||||
// Force `hydrateFromServer` into its catch branch so
|
||||
// the seeded local draft survives the boot path.
|
||||
resultCode = "internal_error";
|
||||
payload = errorPayload("test stub");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
resultCode = "internal_error";
|
||||
payload = errorPayload(`unstubbed ${req.messageType}`);
|
||||
}
|
||||
|
||||
const body = await forgeExecuteCommandResponseJson({
|
||||
requestId: req.requestId,
|
||||
timestampMs: BigInt(Date.now()),
|
||||
resultCode,
|
||||
payloadBytes: payload,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
|
||||
async () => {
|
||||
await new Promise<void>(() => {});
|
||||
},
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function seedShell(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, commands }) =>
|
||||
window.__galaxyDebug!.clearOrderDraft(gameId).then(() =>
|
||||
window.__galaxyDebug!.seedOrderDraft(gameId, commands),
|
||||
),
|
||||
{ gameId: GAME_ID, commands: SEED_DRAFT },
|
||||
);
|
||||
}
|
||||
|
||||
test("navigating to a past turn enters history mode and back-to-current restores the draft", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
const state = await mockGateway(page);
|
||||
await seedShell(page);
|
||||
|
||||
await page.goto(`/games/${GAME_ID}/table/planets`);
|
||||
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
||||
`turn ${CURRENT_TURN}`,
|
||||
);
|
||||
|
||||
// Live mode: banner hidden, order tab reachable.
|
||||
await expect(page.getByTestId("history-banner")).toHaveCount(0);
|
||||
|
||||
// Order tab is visible. We expect both Sidebar (desktop / tablet)
|
||||
// and BottomTabs (mobile) wirings — the Phase 12 prop pair flips
|
||||
// off together when historyMode goes true.
|
||||
if (isMobile) {
|
||||
await expect(page.getByTestId("bottom-tab-order")).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByTestId("sidebar-tab-order")).toBeVisible();
|
||||
}
|
||||
|
||||
// Step back one turn with the prev arrow.
|
||||
await page.getByTestId("turn-navigator-prev").click();
|
||||
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
||||
`turn ${CURRENT_TURN - 1}`,
|
||||
);
|
||||
await expect(page.getByTestId("history-banner")).toBeVisible();
|
||||
await expect(page.getByTestId("history-banner")).toContainText(
|
||||
`Viewing turn ${CURRENT_TURN - 1}`,
|
||||
);
|
||||
|
||||
// Order tab vanishes from both wirings in history mode.
|
||||
if (isMobile) {
|
||||
await expect(page.getByTestId("bottom-tab-order")).toHaveCount(0);
|
||||
} else {
|
||||
await expect(page.getByTestId("sidebar-tab-order")).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Open the navigator popover and jump to turn 2 directly.
|
||||
await page.getByTestId("turn-navigator-trigger").click();
|
||||
const list = page.getByTestId("turn-navigator-list");
|
||||
await expect(list).toBeVisible();
|
||||
await expect(
|
||||
list.getByTestId("turn-navigator-item-0"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
list.getByTestId("turn-navigator-item-5"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
list.getByTestId("turn-navigator-current-badge"),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("turn-navigator-item-2").click();
|
||||
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
||||
"turn 2",
|
||||
);
|
||||
await expect(page.getByTestId("history-banner")).toContainText(
|
||||
"Viewing turn 2",
|
||||
);
|
||||
|
||||
// Click the banner action; live mode resumes.
|
||||
await page.getByTestId("history-banner-return").click();
|
||||
await expect(page.getByTestId("history-banner")).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
||||
`turn ${CURRENT_TURN}`,
|
||||
);
|
||||
|
||||
// Order tab is back and the seeded draft survives the round-trip.
|
||||
if (isMobile) {
|
||||
await page.getByTestId("bottom-tab-order").click();
|
||||
} else {
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-tool-order")).toBeVisible();
|
||||
const list2 = page.getByTestId("order-list");
|
||||
await expect(list2).toBeVisible();
|
||||
for (let i = 0; i < SEED_DRAFT.length; i++) {
|
||||
await expect(page.getByTestId(`order-command-${i}`)).toBeVisible();
|
||||
}
|
||||
|
||||
// The mock served every requested turn (5 on boot, 4 via arrow,
|
||||
// 2 via dropdown, 5 again on return). The exact sequence proves
|
||||
// `viewTurn` does not bypass the network for live turns and
|
||||
// historical fetches hit the gateway when no cache row is present.
|
||||
expect(state.reportRequests).toEqual([5, 4, 2, 5]);
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
// Component tests for the in-game shell header. The header composes
|
||||
// the headline strip (`<race> @ <game>, turn N`, falling back to `?`
|
||||
// while the lobby / report calls are in flight), the view-menu, and
|
||||
// the account-menu. The tests assert the headline copy, that every
|
||||
// view-menu entry dispatches `goto` with the right URL, and that the
|
||||
// Logout entry of the account-menu calls `session.signOut("user")`.
|
||||
// the identity strip (`<race> @ <game>`, falling back to `?` while
|
||||
// the lobby / report calls are in flight), the Phase 26 turn
|
||||
// navigator (`← turn N →` with a popover of every turn), the
|
||||
// view-menu, and the account-menu. The tests assert the visible
|
||||
// copy, that every view-menu entry dispatches `goto` with the right
|
||||
// URL, and that the Logout entry of the account-menu calls
|
||||
// `session.signOut("user")`.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
@@ -48,6 +50,8 @@ function withGameState(opts: {
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
};
|
||||
store.currentTurn = opts.turn ?? 0;
|
||||
store.viewedTurn = opts.turn ?? 0;
|
||||
store.status = "ready";
|
||||
}
|
||||
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
||||
@@ -75,8 +79,11 @@ describe("game-shell header", () => {
|
||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
||||
context: withGameState(),
|
||||
});
|
||||
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||
"? @ ?, turn ?",
|
||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
||||
"? @ ?",
|
||||
);
|
||||
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
|
||||
"turn ?",
|
||||
);
|
||||
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
|
||||
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
|
||||
@@ -91,8 +98,11 @@ describe("game-shell header", () => {
|
||||
turn: 7,
|
||||
}),
|
||||
});
|
||||
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||
"Federation @ Phase 14, turn 7",
|
||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
||||
"Federation @ Phase 14",
|
||||
);
|
||||
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
|
||||
"turn 7",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -101,8 +111,11 @@ describe("game-shell header", () => {
|
||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
||||
context: withGameState({ race: "Federation", turn: 3 }),
|
||||
});
|
||||
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||
"Federation @ ?, turn 3",
|
||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
||||
"Federation @ ?",
|
||||
);
|
||||
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
|
||||
"turn 3",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Vitest coverage for the per-game runes store
|
||||
// (`lib/game-state.svelte.ts`). The test stubs `lobby.my.games.list`
|
||||
// and `user.games.report` at module level and drives the store
|
||||
// through its lifecycle: init → ready → error → setTurn → wrap-mode
|
||||
// persistence.
|
||||
// through its lifecycle: init → ready → error → viewTurn → wrap-mode
|
||||
// persistence. Phase 26 adds coverage for history-mode (current vs.
|
||||
// viewed turn split, cache-backed re-entry, visibility-refresh
|
||||
// short-circuit, resume-from-stale-bookmark flips historyMode on).
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
@@ -250,12 +252,12 @@ describe("GameStateStore", () => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("setTurn loads a different turn snapshot", async () => {
|
||||
test("viewTurn loads a historical snapshot without touching currentTurn", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
|
||||
const turns: number[] = [];
|
||||
const client = makeFakeClient(async () => {
|
||||
const turn = turns.length === 0 ? 3 : 1;
|
||||
turns.push(turn);
|
||||
const requestedTurns: number[] = [];
|
||||
const client = makeFakeClient(async (_messageType, payload) => {
|
||||
const turn = decodeRequestedTurn(payload);
|
||||
requestedTurns.push(turn);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn }),
|
||||
@@ -265,10 +267,104 @@ describe("GameStateStore", () => {
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
expect(store.report?.turn).toBe(3);
|
||||
expect(store.currentTurn).toBe(3);
|
||||
expect(store.viewedTurn).toBe(3);
|
||||
expect(store.historyMode).toBe(false);
|
||||
|
||||
await store.setTurn(1);
|
||||
await store.viewTurn(1);
|
||||
expect(store.status).toBe("ready");
|
||||
expect(store.report?.turn).toBe(1);
|
||||
expect(store.viewedTurn).toBe(1);
|
||||
expect(store.currentTurn).toBe(3);
|
||||
expect(store.historyMode).toBe(true);
|
||||
|
||||
// Phase 26: historical snapshots do not move the
|
||||
// last-viewed-turn cache forward — that resumes-on-open
|
||||
// bookmark must keep meaning "last current turn caught up on",
|
||||
// not "last clicked".
|
||||
const lastViewed = await cache.get<number>(
|
||||
"game-prefs",
|
||||
`${GAME_ID}/last-viewed-turn`,
|
||||
);
|
||||
expect(lastViewed).toBe(3);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("returnToCurrent restores the live snapshot and clears historyMode", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(4)]);
|
||||
const client = makeFakeClient(async (_messageType, payload) => {
|
||||
const turn = decodeRequestedTurn(payload);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn }),
|
||||
};
|
||||
});
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
await store.viewTurn(2);
|
||||
expect(store.historyMode).toBe(true);
|
||||
|
||||
await store.returnToCurrent();
|
||||
expect(store.viewedTurn).toBe(4);
|
||||
expect(store.currentTurn).toBe(4);
|
||||
expect(store.historyMode).toBe(false);
|
||||
expect(store.report?.turn).toBe(4);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("viewTurn rejects out-of-range turns without touching state", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(2)]);
|
||||
const client = makeFakeClient(async (_messageType, payload) => {
|
||||
const turn = decodeRequestedTurn(payload);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn }),
|
||||
};
|
||||
});
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
expect(store.viewedTurn).toBe(2);
|
||||
|
||||
await store.viewTurn(-1);
|
||||
expect(store.viewedTurn).toBe(2);
|
||||
await store.viewTurn(99);
|
||||
expect(store.viewedTurn).toBe(2);
|
||||
await store.viewTurn(Number.NaN);
|
||||
expect(store.viewedTurn).toBe(2);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("viewTurn serves repeated historical reads from the game-history cache", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
|
||||
let calls = 0;
|
||||
const client = makeFakeClient(async (_messageType, payload) => {
|
||||
calls += 1;
|
||||
const turn = decodeRequestedTurn(payload);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn }),
|
||||
};
|
||||
});
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
expect(calls).toBe(1); // boot fetch at turn 5
|
||||
|
||||
await store.viewTurn(1);
|
||||
expect(calls).toBe(2);
|
||||
await store.viewTurn(5);
|
||||
// Returning to the live turn always hits the network — the
|
||||
// current snapshot is mutable until the next tick.
|
||||
expect(calls).toBe(3);
|
||||
await store.viewTurn(1);
|
||||
// Second visit to turn 1 reads from `game-history` cache —
|
||||
// past turns are immutable.
|
||||
expect(calls).toBe(3);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
@@ -318,7 +414,15 @@ describe("GameStateStore", () => {
|
||||
|
||||
expect(requestedTurns).toEqual([4]);
|
||||
expect(store.report?.turn).toBe(4);
|
||||
expect(store.currentTurn).toBe(4);
|
||||
// Phase 26 splits the runes: `currentTurn` mirrors the lobby's
|
||||
// authoritative `current_turn` (7), `viewedTurn` is the
|
||||
// snapshot actually loaded (4, the last-viewed bookmark from
|
||||
// the previous session). The gap also flips `historyMode` on
|
||||
// so the read-only banner appears alongside the pending-turn
|
||||
// toast.
|
||||
expect(store.currentTurn).toBe(7);
|
||||
expect(store.viewedTurn).toBe(4);
|
||||
expect(store.historyMode).toBe(true);
|
||||
expect(store.pendingTurn).toBe(7);
|
||||
store.dispose();
|
||||
});
|
||||
@@ -374,17 +478,76 @@ describe("GameStateStore", () => {
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
expect(store.currentTurn).toBe(2);
|
||||
// `currentTurn` is the server's view (5); the user is held on
|
||||
// the bookmarked turn 2 with the pending-turn affordance.
|
||||
expect(store.currentTurn).toBe(5);
|
||||
expect(store.viewedTurn).toBe(2);
|
||||
expect(store.pendingTurn).toBe(5);
|
||||
|
||||
await store.advanceToPending();
|
||||
expect(store.currentTurn).toBe(5);
|
||||
expect(store.viewedTurn).toBe(5);
|
||||
expect(store.historyMode).toBe(false);
|
||||
expect(store.pendingTurn).toBeNull();
|
||||
expect(requestedTurns).toEqual([2, 5]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("refresh in history mode does not touch report or viewedTurn", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
|
||||
let calls = 0;
|
||||
const client = makeFakeClient(async (_messageType, payload) => {
|
||||
calls += 1;
|
||||
const turn = decodeRequestedTurn(payload);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn }),
|
||||
};
|
||||
});
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
await store.viewTurn(2);
|
||||
expect(store.historyMode).toBe(true);
|
||||
const callsBefore = calls;
|
||||
|
||||
await store.refresh();
|
||||
// History mode keeps the displayed report frozen — push events
|
||||
// (Phase 24) carry new-turn notifications asynchronously; the
|
||||
// visibility-driven refresh would otherwise silently kick the
|
||||
// user out of history.
|
||||
expect(calls).toBe(callsBefore);
|
||||
expect(store.viewedTurn).toBe(2);
|
||||
expect(store.currentTurn).toBe(5);
|
||||
expect(store.historyMode).toBe(true);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("refresh in live mode refetches the current turn", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
|
||||
let calls = 0;
|
||||
const client = makeFakeClient(async (_messageType, payload) => {
|
||||
calls += 1;
|
||||
const turn = decodeRequestedTurn(payload);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn }),
|
||||
};
|
||||
});
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
const callsBefore = calls;
|
||||
await store.refresh();
|
||||
expect(calls).toBe(callsBefore + 1);
|
||||
expect(store.viewedTurn).toBe(3);
|
||||
expect(store.currentTurn).toBe(3);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("decodeReport surfaces the localShipClass projection with full attributes", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(1)]);
|
||||
const client = makeFakeClient(async () => ({
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Phase 26 history-banner component tests. The banner is mounted by
|
||||
// the in-game shell layout directly under the header; it renders
|
||||
// only when `gameState.historyMode === true` and carries a return
|
||||
// action delegating to `gameState.returnToCurrent()`.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import HistoryBanner from "../src/lib/header/history-banner.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
GameStateStore,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
|
||||
function buildStore(opts: {
|
||||
currentTurn: number;
|
||||
viewedTurn: number;
|
||||
}): GameStateStore {
|
||||
const store = new GameStateStore();
|
||||
store.currentTurn = opts.currentTurn;
|
||||
store.viewedTurn = opts.viewedTurn;
|
||||
store.status = "ready";
|
||||
return store;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
describe("HistoryBanner", () => {
|
||||
test("is hidden in live mode", () => {
|
||||
const store = buildStore({ currentTurn: 5, viewedTurn: 5 });
|
||||
const ui = render(HistoryBanner, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
expect(ui.queryByTestId("history-banner")).toBeNull();
|
||||
});
|
||||
|
||||
test("is visible in history mode with the viewed turn interpolated", () => {
|
||||
const store = buildStore({ currentTurn: 5, viewedTurn: 2 });
|
||||
const ui = render(HistoryBanner, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
const banner = ui.getByTestId("history-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveTextContent("Viewing turn 2");
|
||||
expect(banner).toHaveTextContent("read-only");
|
||||
});
|
||||
|
||||
test("return action delegates to gameState.returnToCurrent", async () => {
|
||||
const store = buildStore({ currentTurn: 5, viewedTurn: 2 });
|
||||
const returnToCurrent = vi
|
||||
.spyOn(store, "returnToCurrent")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(HistoryBanner, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("history-banner-return"));
|
||||
expect(returnToCurrent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -809,3 +809,102 @@ describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrderDraftStore Phase 26 history-mode gate", () => {
|
||||
test("add is a no-op while getHistoryMode returns true", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
|
||||
|
||||
let history = false;
|
||||
// The store would short-circuit even without bindClient (the
|
||||
// gate runs before any sync logic). Binding a fake client
|
||||
// here mirrors the real layout where `bindClient` is the path
|
||||
// that wires `getHistoryMode` in.
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
history = true;
|
||||
await store.add(placeholder("c2", "second"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
|
||||
|
||||
history = false;
|
||||
await store.add(placeholder("c3", "third"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c3"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("remove is a no-op while getHistoryMode returns true", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
await store.add(placeholder("c2", "second"));
|
||||
|
||||
let history = true;
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
await store.remove("c1");
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||
|
||||
history = false;
|
||||
await store.remove("c1");
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c2"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("move is a no-op while getHistoryMode returns true", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
await store.add(placeholder("c2", "second"));
|
||||
await store.add(placeholder("c3", "third"));
|
||||
|
||||
let history = true;
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
await store.move(0, 2);
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c3"]);
|
||||
|
||||
history = false;
|
||||
await store.move(0, 2);
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("draft survives entering and leaving history mode untouched", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
await store.add(placeholder("c2", "second"));
|
||||
|
||||
let history = false;
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
history = true;
|
||||
// Inspector affordances try to push commands, gate refuses.
|
||||
await store.add(placeholder("c3", "history attempt"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||
|
||||
history = false;
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||
await store.add(placeholder("c4", "back live"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c4"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Phase 26 turn-navigator component tests. The navigator owns three
|
||||
// affordances: arrows that step ±1 through history, a clickable
|
||||
// `turn N` button that opens the full popover, and the popover rows
|
||||
// themselves. The store under test is a real `GameStateStore`
|
||||
// instance seeded into Svelte context — the navigator never calls
|
||||
// the network in tests because we override `viewTurn` /
|
||||
// `returnToCurrent` with `vi.fn` spies.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import TurnNavigator from "../src/lib/header/turn-navigator.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
GameStateStore,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
|
||||
function buildStore(opts: {
|
||||
currentTurn: number;
|
||||
viewedTurn: number;
|
||||
ready?: boolean;
|
||||
}): GameStateStore {
|
||||
const store = new GameStateStore();
|
||||
store.currentTurn = opts.currentTurn;
|
||||
store.viewedTurn = opts.viewedTurn;
|
||||
store.status = opts.ready === false ? "loading" : "ready";
|
||||
return store;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
describe("TurnNavigator", () => {
|
||||
test("renders `turn ?` when the store is not ready yet", () => {
|
||||
const store = buildStore({ currentTurn: 0, viewedTurn: 0, ready: false });
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
|
||||
"turn ?",
|
||||
);
|
||||
expect(ui.getByTestId("turn-navigator-trigger")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("prev arrow disabled at viewedTurn = 0", () => {
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([
|
||||
[
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
buildStore({ currentTurn: 4, viewedTurn: 0 }),
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(ui.getByTestId("turn-navigator-prev")).toBeDisabled();
|
||||
expect(ui.getByTestId("turn-navigator-next")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("next arrow disabled at viewedTurn = currentTurn", () => {
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([
|
||||
[
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
buildStore({ currentTurn: 4, viewedTurn: 4 }),
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(ui.getByTestId("turn-navigator-prev")).not.toBeDisabled();
|
||||
expect(ui.getByTestId("turn-navigator-next")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("prev arrow steps to viewedTurn - 1 via viewTurn", async () => {
|
||||
const store = buildStore({ currentTurn: 4, viewedTurn: 4 });
|
||||
const viewTurn = vi
|
||||
.spyOn(store, "viewTurn")
|
||||
.mockResolvedValue(undefined);
|
||||
const returnToCurrent = vi
|
||||
.spyOn(store, "returnToCurrent")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-prev"));
|
||||
expect(viewTurn).toHaveBeenCalledWith(3);
|
||||
expect(returnToCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("next arrow at one-step-from-current routes through returnToCurrent", async () => {
|
||||
const store = buildStore({ currentTurn: 4, viewedTurn: 3 });
|
||||
const viewTurn = vi
|
||||
.spyOn(store, "viewTurn")
|
||||
.mockResolvedValue(undefined);
|
||||
const returnToCurrent = vi
|
||||
.spyOn(store, "returnToCurrent")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-next"));
|
||||
expect(returnToCurrent).toHaveBeenCalledTimes(1);
|
||||
expect(viewTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("trigger opens the popover with every turn in descending order", async () => {
|
||||
const store = buildStore({ currentTurn: 3, viewedTurn: 1 });
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-trigger"));
|
||||
const list = ui.getByTestId("turn-navigator-list");
|
||||
expect(list).toBeInTheDocument();
|
||||
|
||||
const rows = list.querySelectorAll("button[role='menuitem']");
|
||||
expect(rows.length).toBe(4);
|
||||
expect(rows[0]).toHaveAttribute(
|
||||
"data-testid",
|
||||
"turn-navigator-item-3",
|
||||
);
|
||||
expect(rows[3]).toHaveAttribute(
|
||||
"data-testid",
|
||||
"turn-navigator-item-0",
|
||||
);
|
||||
// Current-turn row carries the badge.
|
||||
const currentRow = ui.getByTestId("turn-navigator-item-3");
|
||||
expect(currentRow.querySelector("[data-testid='turn-navigator-current-badge']"))
|
||||
.not.toBeNull();
|
||||
// Other rows do not carry a badge.
|
||||
const otherRow = ui.getByTestId("turn-navigator-item-2");
|
||||
expect(otherRow.querySelector("[data-testid='turn-navigator-current-badge']"))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("selecting a past row delegates to viewTurn(N)", async () => {
|
||||
const store = buildStore({ currentTurn: 3, viewedTurn: 3 });
|
||||
const viewTurn = vi
|
||||
.spyOn(store, "viewTurn")
|
||||
.mockResolvedValue(undefined);
|
||||
const returnToCurrent = vi
|
||||
.spyOn(store, "returnToCurrent")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-item-1"));
|
||||
expect(viewTurn).toHaveBeenCalledWith(1);
|
||||
expect(returnToCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("selecting the current row delegates to returnToCurrent", async () => {
|
||||
const store = buildStore({ currentTurn: 3, viewedTurn: 1 });
|
||||
const viewTurn = vi
|
||||
.spyOn(store, "viewTurn")
|
||||
.mockResolvedValue(undefined);
|
||||
const returnToCurrent = vi
|
||||
.spyOn(store, "returnToCurrent")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(TurnNavigator, {
|
||||
context: new Map([[GAME_STATE_CONTEXT_KEY, store]]),
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("turn-navigator-item-3"));
|
||||
expect(returnToCurrent).toHaveBeenCalledTimes(1);
|
||||
expect(viewTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user