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:
Ilia Denisov
2026-05-12 00:13:19 +02:00
parent 070fdc0ee5
commit 2d17760a5e
20 changed files with 1572 additions and 118 deletions
+112 -19
View File
@@ -27,6 +27,10 @@ const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
`${gameId}/last-viewed-turn`;
const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
`${gameId}/turn/${turn}`;
/**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `GameStateStore` instance to descendants.
@@ -55,9 +59,30 @@ export class GameStateStore {
* game (lifted from the lobby record on `setGame`). Phase 14
* exposes it so the layout can pass it to
* `OrderDraftStore.hydrateFromServer` after both stores boot;
* later phases (history mode, calc) will read it directly.
* Phase 26 keeps the "authoritative server-side turn" meaning —
* only `setGame`, `advanceToPending`, and the visibility-listener
* lobby re-query update it. History navigation (`viewTurn`) leaves
* it alone so the "Return to current turn" affordance keeps a
* reliable target.
*/
currentTurn = $state(0);
/**
* viewedTurn is the turn whose snapshot is currently displayed.
* In live mode it equals `currentTurn`. Phase 26 history mode
* decouples the two: `viewTurn(N)` flips this rune (and `report`)
* to N without touching `currentTurn` or `last-viewed-turn`.
*/
viewedTurn = $state(0);
/**
* historyMode is the derived "user is viewing a past turn" rune
* consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
* history banner, the rendered-report overlay short-circuit, and
* the order-draft mutation gate. It depends only on the rune state
* above, so every consumer reacts to a single source of truth.
*/
historyMode = $derived(
this.status === "ready" && this.viewedTurn < this.currentTurn,
);
/**
* synthetic is set by `initSynthetic` for DEV-only sessions backed
* by a hand-loaded report (lobby's "Load synthetic report"
@@ -140,16 +165,18 @@ export class GameStateStore {
// server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the
// shell can render a "new turn available" affordance instead
// of silently auto-advancing.
// of silently auto-advancing. After Phase 26 the same gap
// also flips `historyMode` to true (viewedTurn < currentTurn),
// so the read-only banner appears alongside the toast.
if (
lastViewed !== null &&
lastViewed >= 0 &&
lastViewed < summary.currentTurn
) {
this.pendingTurn = summary.currentTurn;
await this.loadTurn(lastViewed);
await this.loadTurn(lastViewed, { isCurrent: false });
} else {
await this.loadTurn(summary.currentTurn);
await this.loadTurn(summary.currentTurn, { isCurrent: true });
}
} catch (err) {
if (this.destroyed) return;
@@ -196,7 +223,7 @@ export class GameStateStore {
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn);
await this.loadTurn(summary.currentTurn, { isCurrent: true });
this.pendingTurn = null;
} catch (err) {
if (this.destroyed) return;
@@ -206,29 +233,57 @@ export class GameStateStore {
}
/**
* setTurn loads a different turn snapshot — used by Phase 26 history
* mode. The current turn stays at whatever `setGame` discovered;
* calling without an argument refetches the same turn.
* viewTurn loads the historical snapshot for `turn` and switches the
* UI into history mode (Phase 26). The current turn is untouched —
* `historyMode` flips on automatically through the derived rune, and
* the `last-viewed-turn` cache is only refreshed when the caller
* happens to ask for the currentTurn (e.g. `returnToCurrent`). A
* cache hit on `game-history/{gameId}/turn/{N}` skips the network;
* past turns are immutable so the cache never goes stale.
*/
async setTurn(turn: number): Promise<void> {
async viewTurn(turn: number): Promise<void> {
if (this.client === null) return;
if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
return;
}
this.status = "loading";
this.error = null;
try {
await this.loadTurn(turn);
await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = describe(err);
}
}
/**
* refresh re-fetches the report at the current turn. Called on
* window `visibilitychange` so the map and the turn counter stay
* fresh after the user returns to the tab.
* returnToCurrent jumps back to the server's current turn after a
* history excursion. Thin wrapper around `viewTurn(currentTurn)` so
* the banner / popover share the same call site.
*/
refresh(): Promise<void> {
return this.setTurn(this.currentTurn);
returnToCurrent(): Promise<void> {
return this.viewTurn(this.currentTurn);
}
/**
* refresh is fired from the `visibilitychange` listener. In live
* mode it re-fetches the report at the current turn so the map and
* the counter catch up after the user returns to the tab. In
* history mode it is a no-op: the user is intentionally viewing a
* past turn, push events (Phase 24) deliver new-turn notifications
* asynchronously, and forcing a reload would silently bump the
* user out of history mode.
*/
async refresh(): Promise<void> {
if (this.client === null) return;
if (this.historyMode) return;
try {
await this.loadTurn(this.currentTurn, { isCurrent: true });
} catch (err) {
if (this.destroyed) return;
console.warn("game-state: refresh failed", err);
}
}
/**
@@ -276,6 +331,7 @@ export class GameStateStore {
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
this.report = opts.report;
this.currentTurn = opts.report.turn;
this.viewedTurn = opts.report.turn;
this.status = "ready";
}
@@ -295,20 +351,57 @@ export class GameStateStore {
return games.find((g) => g.gameId === gameId) ?? null;
}
private async loadTurn(turn: number): Promise<void> {
private async loadTurn(
turn: number,
opts: { isCurrent: boolean },
): Promise<void> {
if (this.client === null) return;
const report = await fetchGameReport(this.client, this.gameId, turn);
const report = await this.readReport(turn, opts.isCurrent);
if (this.destroyed) return;
this.report = report;
this.currentTurn = turn;
this.viewedTurn = turn;
this.status = "ready";
if (this.cache !== null) {
if (this.cache === null) return;
if (opts.isCurrent) {
// Persist last-viewed-turn only when the user is caught up
// on the live snapshot. Historical excursions are ephemeral
// (Phase 26 decision): the resume-on-open affordance from
// Phase 11 must keep meaning "the latest turn this player
// was caught up on", not "wherever they last clicked".
await this.cache.put(
PREF_NAMESPACE,
PREF_KEY_LAST_VIEWED_TURN(this.gameId),
turn,
);
return;
}
// Past turns are immutable, so the snapshot is safe to cache
// for fast re-entry. The current-turn snapshot deliberately
// skips the cache — it is mutable until the next tick.
await this.cache.put(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
report,
);
}
private async readReport(
turn: number,
isCurrent: boolean,
): Promise<GameReport> {
if (this.client === null) {
throw new Error("game-state: readReport called without client");
}
if (!isCurrent && this.cache !== null) {
const cached = await this.cache.get<GameReport>(
HISTORY_NAMESPACE,
HISTORY_KEY_TURN(this.gameId, turn),
);
if (cached !== undefined && cached.turn === turn) {
return cached;
}
}
return await fetchGameReport(this.client, this.gameId, turn);
}
private installVisibilityListener(): void {
+11 -21
View File
@@ -1,8 +1,10 @@
<!--
Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the
account menu. The sidebar-toggle slot to its left appears only on
tablet viewports (7681024 px) and is wired by `+layout.svelte`.
(race name @ game name) followed by the Phase 26 turn navigator (a
`← Turn N →` triplet with a popover of every turn), the view
dropdown / hamburger, and the account menu. The sidebar-toggle slot
to its left appears only on tablet viewports (7681024 px) and is
wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece
@@ -22,6 +24,7 @@ absent until Phase 24 wires push-event state.
} from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
import TurnNavigator from "./turn-navigator.svelte";
type Props = {
gameId: string;
@@ -44,27 +47,14 @@ absent until Phase 24 wires push-event state.
const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name;
});
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script>
<header class="game-shell-header" data-testid="game-shell-header">
<div class="left">
<span class="headline" data-testid="game-shell-headline">
{headline}
<div class="left" data-testid="game-shell-headline">
<span class="identity" data-testid="game-shell-identity">
{raceName} @ {gameName}
</span>
<TurnNavigator />
</div>
<div class="right">
<button
@@ -106,7 +96,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem;
min-width: 0;
}
.headline {
.identity {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -0,0 +1,80 @@
<!--
Phase 26 read-only banner. Renders directly under the shell header
whenever the user is viewing a past turn (`gameState.historyMode`).
Carries the turn number and a "Return to current turn" action that
delegates to `gameState.returnToCurrent()`. The banner is invisible
in live mode so the active-view chrome keeps its full vertical
budget.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
const visible = $derived(gameState?.historyMode === true);
function onReturn(): void {
void gameState?.returnToCurrent();
}
</script>
{#if visible}
<aside class="history-banner" data-testid="history-banner" role="status">
<span class="message">
{i18n.t("game.shell.history.viewing", { turn: String(viewedTurn) })}
</span>
<button
type="button"
class="return"
data-testid="history-banner-return"
onclick={onReturn}
>
{i18n.t("game.shell.history.return_to_current")}
</button>
</aside>
{/if}
<style>
.history-banner {
position: sticky;
top: 3rem;
z-index: 35;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: #2a2438;
color: #efe9c8;
border-bottom: 1px solid #45375a;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
}
.message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.return {
font: inherit;
padding: 0.25rem 0.65rem;
background: transparent;
color: inherit;
border: 1px solid #6c5a8a;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.return:hover {
background: #3a3050;
}
</style>
@@ -0,0 +1,263 @@
<!--
Phase 26 header turn navigator. Replaces the Phase 11 inline turn
number with a `← Turn N →` triplet. The arrows step ±1 (disabled at
boundaries `0` and `currentTurn`), the middle button opens a popover
listing every turn `Turn #0`…`Turn #currentTurn` with the current row
tagged. No free-text input — every reachable turn is in the list, so
there is nothing to validate.
Desktop and mobile share the same component: an absolute-positioned
popover anchored to the trigger on desktop becomes a fixed full-width
drawer below the 768 px breakpoint, mirroring `view-menu.svelte`.
Selecting a row calls `gameState.viewTurn(N)`; the row that matches
`currentTurn` delegates to `gameState.returnToCurrent()` so the
"leave history" path always flows through one method.
-->
<script lang="ts">
import { getContext, onMount } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const currentTurn = $derived(gameState?.currentTurn ?? 0);
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
const ready = $derived(gameState?.status === "ready");
const canPrev = $derived(ready && viewedTurn > 0);
const canNext = $derived(ready && viewedTurn < currentTurn);
// Until the boot completes the store has no report, so the
// counter falls back to the same `?` placeholder Phase 11 used in
// the static headline. Avoids briefly flashing `turn 0` while the
// lobby / report calls are in flight.
const turnText = $derived(
ready ? String(viewedTurn) : i18n.t("game.shell.unknown"),
);
// Descending list newest → oldest. The popover stays compact for
// short games and scrolls for long ones; the current-turn row is
// pinned at the top with an explicit badge so the affordance to
// jump back is always reachable without scrolling.
const turns = $derived.by(() => {
if (!ready) return [] as number[];
const out: number[] = [];
for (let i = currentTurn; i >= 0; i--) {
out.push(i);
}
return out;
});
function toggleOpen(): void {
if (!ready) return;
open = !open;
}
async function goToTurn(turn: number): Promise<void> {
open = false;
if (gameState === undefined) return;
if (turn === gameState.currentTurn) {
await gameState.returnToCurrent();
return;
}
await gameState.viewTurn(turn);
}
async function step(delta: number): Promise<void> {
if (gameState === undefined) return;
const next = viewedTurn + delta;
if (next < 0 || next > currentTurn) return;
if (next === gameState.currentTurn) {
await gameState.returnToCurrent();
return;
}
await gameState.viewTurn(next);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="turn-navigator" bind:this={rootEl} data-testid="turn-navigator">
<button
type="button"
class="step"
data-testid="turn-navigator-prev"
aria-label={i18n.t("game.shell.turn.prev")}
disabled={!canPrev}
onclick={() => void step(-1)}
>
</button>
<button
type="button"
class="trigger"
data-testid="turn-navigator-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.turn.close_navigator")
: i18n.t("game.shell.turn.open_navigator")}
disabled={!ready}
onclick={toggleOpen}
>
{i18n.t("game.shell.turn.label", { turn: turnText })}
</button>
<button
type="button"
class="step"
data-testid="turn-navigator-next"
aria-label={i18n.t("game.shell.turn.next")}
disabled={!canNext}
onclick={() => void step(1)}
>
</button>
{#if open}
<div class="surface" role="menu" data-testid="turn-navigator-list">
{#each turns as turn (turn)}
<button
type="button"
role="menuitem"
class="row"
class:viewed={turn === viewedTurn}
data-testid={`turn-navigator-item-${turn}`}
onclick={() => void goToTurn(turn)}
>
<span class="label">
{i18n.t("game.shell.turn.list_item", { turn: String(turn) })}
</span>
{#if turn === currentTurn}
<span class="badge" data-testid="turn-navigator-current-badge">
{i18n.t("game.shell.history.current_badge")}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.turn-navigator {
position: relative;
display: inline-flex;
align-items: stretch;
gap: 0.25rem;
}
.step,
.trigger {
font: inherit;
font-size: 1rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
line-height: 1;
}
.step:hover:not(:disabled),
.trigger:hover:not(:disabled) {
background: #1c2238;
}
.step:disabled,
.trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.trigger {
min-width: 5rem;
font-weight: 500;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
min-width: 10rem;
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
text-align: left;
font: inherit;
padding: 0.4rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.row:hover {
background: #1c2238;
}
.row.viewed {
font-weight: 600;
background: #1a2040;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex: 0 0 auto;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.05rem 0.4rem;
background: #2a3150;
color: #d8def0;
border-radius: 999px;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
max-height: calc(100vh - 3rem);
border-radius: 0;
border-left: 0;
border-right: 0;
}
}
</style>
+9 -1
View File
@@ -89,7 +89,6 @@ const en = {
"lobby.error.unknown": "{message}",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, turn {turn}",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
"game.shell.connection.offline": "offline",
@@ -104,6 +103,15 @@ const en = {
"game.shell.menu.language": "language",
"game.shell.menu.logout": "logout",
"game.shell.coming_soon": "coming soon",
"game.shell.turn.label": "turn {turn}",
"game.shell.turn.list_item": "turn #{turn}",
"game.shell.turn.prev": "previous turn",
"game.shell.turn.next": "next turn",
"game.shell.turn.open_navigator": "open turn list",
"game.shell.turn.close_navigator": "close turn list",
"game.shell.history.viewing": "Viewing turn {turn} · read-only",
"game.shell.history.return_to_current": "Return to current turn",
"game.shell.history.current_badge": "current",
"game.view.map": "map",
"game.view.table": "table",
"game.view.table.planets": "planets",
+9 -1
View File
@@ -90,7 +90,6 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.unknown": "{message}",
"game.shell.unknown": "?",
"game.shell.headline": "{race} @ {game}, ход {turn}",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
"game.shell.connection.offline": "офлайн",
@@ -105,6 +104,15 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.menu.language": "язык",
"game.shell.menu.logout": "выйти",
"game.shell.coming_soon": "скоро будет",
"game.shell.turn.label": "ход {turn}",
"game.shell.turn.list_item": "ход #{turn}",
"game.shell.turn.prev": "предыдущий ход",
"game.shell.turn.next": "следующий ход",
"game.shell.turn.open_navigator": "открыть список ходов",
"game.shell.turn.close_navigator": "закрыть список ходов",
"game.shell.history.viewing": "Просмотр хода {turn} · только чтение",
"game.shell.history.return_to_current": "Вернуться к текущему ходу",
"game.shell.history.current_badge": "текущий",
"game.view.map": "карта",
"game.view.table": "таблица",
"game.view.table.planets": "планеты",
@@ -37,6 +37,12 @@ export interface RenderedReportSource {
* underlying `$state` accesses inside `applyOrderOverlay`, so any
* change to the report or the draft re-runs every dependent
* `$derived` block.
*
* Phase 26: the order draft is composed against the *current* turn,
* so projecting it onto a historical snapshot would render fictional
* intent on a past report. In history mode the getter returns the
* raw server snapshot untouched — the order tab is hidden anyway and
* mutations are gated at the store, so nothing else needs to know.
*/
export function createRenderedReportSource(
gameState: GameStateStore,
@@ -46,6 +52,7 @@ export function createRenderedReportSource(
get report(): GameReport | null {
const raw = gameState.report;
if (raw === null) return null;
if (gameState.historyMode) return raw;
return applyOrderOverlay(raw, orderDraft.commands, orderDraft.statuses);
},
};
@@ -47,6 +47,7 @@ fresh.
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte";
@@ -101,9 +102,6 @@ fresh.
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
@@ -115,6 +113,13 @@ fresh.
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
// when the player is viewing a past turn. The flag is owned by
// `GameStateStore` (single source of truth for "what turn are we
// looking at") and surfaced here so the Phase 12 sidebar wiring,
// the new `HistoryBanner`, and `orderDraft.bindClient` all read
// from the same derivation.
const historyMode = $derived(gameState.historyMode);
const selection = new SelectionStore();
setContext(SELECTION_CONTEXT_KEY, selection);
const renderedReport = createRenderedReportSource(gameState, orderDraft);
@@ -398,6 +403,7 @@ fresh.
galaxyClient.set(client);
orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn,
getHistoryMode: () => gameState.historyMode,
});
// The server is always polled at game boot — its
// stored order may be fresher than the local cache
@@ -441,6 +447,7 @@ fresh.
{sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
<HistoryBanner />
<div class="body">
<main class="active-view-host" data-testid="active-view-host">
{#if effectiveTool === "calc"}
+21 -1
View File
@@ -148,6 +148,7 @@ export class OrderDraftStore {
private queue = new OrderQueue();
private queueStarted = false;
private getCurrentTurn: (() => number) | null = null;
private getHistoryMode: (() => boolean) | null = null;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
@@ -195,13 +196,24 @@ export class OrderDraftStore {
* interpolate the turn number the player was composing for. The
* layout passes `() => gameState.currentTurn`; tests may omit it,
* in which case the banner falls back to a turn-less template.
*
* Phase 26: `opts.getHistoryMode` lets `add` / `remove` / `move`
* short-circuit while the user is viewing a past turn. Without
* the gate, inspector affordances built in Phases 1422 would
* happily push commands into the draft even though the order tab
* is hidden and the read-only banner is visible. Tests may omit
* it; the default is "never in history mode".
*/
bindClient(
client: GalaxyClient,
opts: { getCurrentTurn?: () => number } = {},
opts: {
getCurrentTurn?: () => number;
getHistoryMode?: () => boolean;
} = {},
): void {
this.client = client;
this.getCurrentTurn = opts.getCurrentTurn ?? null;
this.getHistoryMode = opts.getHistoryMode ?? null;
}
/**
@@ -305,6 +317,11 @@ export class OrderDraftStore {
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
// Phase 26: history mode hides the order tab and treats every
// view as read-only. The inspector affordances are not aware of
// the mode, so the gate lives here — one chokepoint protects
// every Phase 1422 caller without per-component edits.
if (this.getHistoryMode?.() === true) return;
this.clearConflictForMutation();
const removed: string[] = [];
let nextCommands: OrderCommand[];
@@ -385,6 +402,7 @@ export class OrderDraftStore {
*/
async remove(id: string): Promise<void> {
if (this.status !== "ready") return;
if (this.getHistoryMode?.() === true) return;
const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return;
this.clearConflictForMutation();
@@ -406,6 +424,7 @@ export class OrderDraftStore {
*/
async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return;
if (this.getHistoryMode?.() === true) return;
const length = this.commands.length;
if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return;
@@ -479,6 +498,7 @@ export class OrderDraftStore {
this.cache = null;
this.client = null;
this.getCurrentTurn = null;
this.getHistoryMode = null;
if (this.queueStarted) {
this.queue.stop();
this.queueStarted = false;
+265
View File
@@ -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]);
});
+24 -11
View File
@@ -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",
);
});
+173 -10
View File
@@ -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 () => ({
+63
View File
@@ -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);
});
});
+99
View File
@@ -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();
});
});
+168
View File
@@ -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();
});
});