2d17760a5e
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>
266 lines
8.6 KiB
TypeScript
266 lines
8.6 KiB
TypeScript
// 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]);
|
|
});
|