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:
@@ -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 () => ({
|
||||
|
||||
Reference in New Issue
Block a user