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
+123 -22
View File
@@ -1381,9 +1381,13 @@ Decisions taken with the project owner during implementation:
6. **`historyMode` as a prop, not a module.** Layout passes
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and
`BottomTabs`; both forward to their tab-bar children which omit
the order entry when the flag is true. Phase 26 introduces the
real `lib/history-mode.ts` module and replaces the constant in
one place.
the order entry when the flag is true. Phase 26 superseded the
"introduce `lib/history-mode.ts`" half of this decision: the
single derivation `historyMode = $derived(gameState.historyMode)`
lives directly in `+layout.svelte`, the rune split between
`currentTurn` and `viewedTurn` lives in `GameStateStore`, and
no separate module is introduced. See Phase 26 decisions for
the rationale.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
`coming soon` placeholder text is replaced; per-row delete
button reads `delete` / `удалить`.
@@ -2805,24 +2809,116 @@ Targeted tests:
## Phase 26. History Mode
Status: pending.
Status: pending (awaiting local-ci verification).
Goal: let the user navigate to past turns and view all data as it was,
with no order composition allowed.
Artifacts:
Decisions baked in during implementation:
- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn
counter expansion: popover (desktop) / bottom-sheet (mobile) listing
recent turns and a search field for jumping to a turn number
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every
view's data source: when active, all `state-binding`, table, report,
inspector, and map sources read from the historical snapshot for the
selected turn
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner
reading `Viewing turn N · read-only` with a `Return to current turn`
action
- order tab hidden in history mode (already prepared in Phase 12)
1. **History state lives in `GameStateStore`, no separate module.**
The Phase 12 plan-line "introduce `lib/history-mode.ts`" is
superseded: the only consumer needs a one-line derivation
(`historyMode = $derived(gameState.historyMode)`), and the
project's compactness rule rejects an abstraction with no second
caller. The store ships two distinct turn runes — `currentTurn`
(server's authoritative latest, set by `setGame` /
`advanceToPending`) and `viewedTurn` (what the UI displays, set
by `viewTurn` / `returnToCurrent`) — plus the derived
`historyMode` rune that flips when `viewedTurn < currentTurn`.
2. **`OrderDraftStore` gates mutations at one chokepoint.**
`bindClient` gains an optional `getHistoryMode: () => boolean`
alongside the existing `getCurrentTurn`; `add` / `remove` /
`move` return early when it reports `true`. Every Phase 1422
inspector that calls `orderDraft.add(...)` becomes inert in
history mode without per-component edits.
3. **Turn navigator UX.** Header replaces the static `turn N` text
with `← turn N →`: arrows step ±1 (disabled at `0` and
`currentTurn`), the middle button opens a dropdown of every
turn `Turn #0`…`Turn #currentTurn` with the current row carrying
a badge. No free-text input. Desktop uses an absolute popover
under the header; mobile reuses `view-menu.svelte`'s fixed-
drawer pattern (no new primitive). Selecting the current row
routes through `returnToCurrent()` so the "leave history" path
has one canonical entry.
4. **History is ephemeral across reloads.** `last-viewed-turn` is
written only when `viewedTurn === currentTurn`; historical
excursions never advance the resume bookmark. Page reload exits
history mode. The visibility-refresh listener is a no-op while
`historyMode` is true so a tab-focus event cannot silently kick
the user back onto the live turn. Push events (Phase 24) continue
to deliver new-turn notifications, so the pending-turn toast
still appears.
5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}`
namespace stores past-turn reports; `viewTurn(N)` reads cache
first and falls back to the network on miss. Past turns are
immutable so the cache has no TTL and no eviction. The current
turn deliberately skips the cache (it is mutable until the next
tick).
6. **Order overlay short-circuits in history mode.**
`RenderedReportSource.report` returns the raw server snapshot
instead of running `applyOrderOverlay`: the draft is composed
against the current turn, projecting it onto a past report would
render fictional intent.
7. **`game.shell.headline` removed.** The Phase 11 i18n key that
formatted `{race} @ {game}, turn {turn}` is deleted; the header
composes `race @ game` in plain text and delegates `turn N` to
`turn-navigator.svelte`. The existing `game-shell-headline`
testid moves to the `.left` wrapper so e2e specs that match
`toContainText("turn N")` continue to find the substring inside
the navigator's button.
Artifacts (delivered):
- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune,
derived `historyMode` rune, `viewTurn(turn)` /
`returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })`
refactor that gates `last-viewed-turn` writes, `readReport` cache
layer over the `game-history` namespace, visibility-refresh
short-circuit in history mode, `initSynthetic` keeps
`currentTurn === viewedTurn`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts
`getHistoryMode`, `add` / `remove` / `move` no-op when active.
- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short-
circuit when `gameState.historyMode === true`.
- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header
triplet `← turn N →` + dropdown popover / drawer, reuses
`view-menu.svelte`'s outside-click / Escape pattern.
- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky
read-only banner under the header with a `Return to current turn`
action.
- `ui/frontend/src/lib/header/header.svelte` — embeds
`<TurnNavigator />` next to the race-and-game identity span;
drops the static turn portion.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
`historyMode` derived rune, `getHistoryMode` passed to
`orderDraft.bindClient`, `<HistoryBanner />` mounted between
header and body.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.shell.history.*` and `game.shell.turn.*` keys; the now-
unused `game.shell.headline` entry is removed.
- `ui/docs/storage.md` — `game-history` namespace row; also adds
the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc
gap).
- `ui/docs/game-state.md` — current/viewed-turn rune table, the
new History mode section.
- `ui/docs/navigation.md` — describes the navigator, the read-only
banner, and the `historyMode` derivation wiring.
- `ui/docs/order-composer.md` — notes the mutation gate, the
overlay short-circuit, and the cross-doc references.
- Vitest: `tests/game-state.test.ts` extended with `viewTurn` /
`returnToCurrent` / `historyMode` derivation / cache hit /
visibility-refresh short-circuit / resume-from-stale-bookmark
flips; `tests/order-draft.test.ts` extended with the history-
mode gate cases; `tests/turn-navigator.test.ts` and
`tests/history-banner.test.ts` (new) cover the components in
isolation.
- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the
full chrome flow against `/games/<id>/table/planets`. The map
view is deliberately avoided because the Pixi renderer can
monopolise the headless Chromium main thread long enough to let
the `toContainText` poll find stale "turn ?" content; the table
view exercises the same wiring without that rendering tail.
Dependencies: Phases 11, 12, 23.
@@ -2834,15 +2930,20 @@ Acceptance criteria:
available;
- returning to the current turn restores live data and re-shows the
order tab with the prior draft intact (state preservation);
- all UI views (map, tables, report, battle, mail) work in history
mode.
- battle / mail stub views still render correctly while the
read-only banner is visible (Phases 27 / 28 will replace the
stubs with real implementations; the wiring is sufficient
today).
Targeted tests:
- Vitest unit tests for `history-mode` toggle and per-view source
selection;
- Playwright e2e: enter history mode, navigate three views, return,
confirm the order draft survived.
- Vitest unit tests for current/viewed turn rune split, view-turn
cache behaviour, visibility-refresh short-circuit, order-draft
history-mode gate, turn-navigator interactions, history-banner
rendering / action;
- Playwright e2e: enter history mode via arrow, navigate via
dropdown, return via banner action, confirm the order draft
survives the round-trip.
## Phase 27. Battle Viewer
+64 -11
View File
@@ -37,6 +37,10 @@ The store exposes:
| `gameId` | `string` | active game id |
| `status` | `idle / loading / ready / error` | current lifecycle state |
| `report` | `GameReport \| null` | latest decoded report, `null` until first fetch |
| `currentTurn` | `number` | server's authoritative current turn (live snapshot) |
| `viewedTurn` | `number` | turn whose snapshot is in `report`; equals `currentTurn` in live mode |
| `historyMode` | `boolean` (derived) | true while `status === "ready"` and `viewedTurn < currentTurn` |
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
| `error` | `string \| null` | localised error message when `status === "error"` |
@@ -45,8 +49,15 @@ The store exposes:
- Phase 11 surfaces only the planet subset of the report. Later
phases extend `GameReport` and `decodeReport` as their slice of
the wire lands (ships, fleets, sciences, routes, battles, mail).
- Phase 26 wires history mode through `setTurn(turn)`. The store
already supports it; the navigator UI is what is missing.
- Phase 26 splits `currentTurn` from the turn whose snapshot is
displayed (`viewedTurn`) and adds `viewTurn(turn)` /
`returnToCurrent()` for history navigation. The derived
`historyMode` rune flips automatically when `viewedTurn <
currentTurn`; the layout passes it to Phase 12's sidebar /
bottom-tabs wiring (which hides the order tab) and to
`OrderDraftStore.bindClient` (which gates `add` / `remove` /
`move`). See "History mode" below for the cache and refresh
rules.
- Phase 24 replaces the tab-focus refresh with push-event-driven
refreshes; the visibility listener stays as a fallback for
background tabs that miss a push.
@@ -88,17 +99,59 @@ result can resolve back to a planet without an extra lookup table.
## Refresh discipline
`refresh()` re-fetches the same turn snapshot. It is called by the
`visibilitychange` handler when `document.visibilityState ===
"visible"` and the store is already in `ready` state. The map view's
mount effect skips a re-render when the new snapshot's turn matches
the previously-mounted turn (and the wrap mode is unchanged), so a
no-op refresh does not flicker the canvas.
`refresh()` re-fetches the current-turn snapshot. It is called by
the `visibilitychange` handler when `document.visibilityState ===
"visible"` and the store is already in `ready` state. The map
view's mount effect skips a re-render when the new snapshot's turn
matches the previously-mounted turn (and the wrap mode is
unchanged), so a no-op refresh does not flicker the canvas.
`setTurn(turn)` is the entry point for Phase 26 history mode:
calling it on a different turn loads that snapshot and the same
mount effect re-creates the renderer with the new world.
In history mode `refresh()` is a no-op — forcing a reload would
silently bump the user back onto the current turn while they are
intentionally viewing a past one. Push events (Phase 24) still
deliver new-turn notifications asynchronously while the user
explores history, so the pending-turn toast continues to work.
`setWrapMode(mode)` writes to `Cache` and updates the rune; the
map view's effect picks the change up and re-mounts the renderer
with the new mode.
## History mode
Phase 26 lets the user step backward through the report timeline
without losing the live snapshot. The store keeps two turn runes:
- `currentTurn` — the server's authoritative latest. Only
`setGame` and `advanceToPending` write to it.
- `viewedTurn` — the turn currently rendered. `viewTurn(N)` flips
this rune and the underlying `report` to `N` without touching
`currentTurn`. `returnToCurrent()` is a one-line wrapper that
navigates back to live.
The derived `historyMode` rune (`status === "ready" && viewedTurn
< currentTurn`) drives every history-aware consumer:
- the layout passes it to `Sidebar` / `BottomTabs` so the order
tab vanishes (Phase 12 prop wiring);
- the layout passes a `getHistoryMode` getter to
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
no-ops while the user is looking at a past turn;
- `RenderedReportSource` returns the raw report (no order overlay)
because the draft is composed against the current turn;
- the new `HistoryBanner` component renders the sticky "Viewing
turn N · read-only" strip when the flag is true.
`last-viewed-turn` semantics keep their Phase 11 meaning: "the
latest turn the user was caught up on". `loadTurn` only writes the
cache row when called with `isCurrent === true` (i.e. when the
load matches `currentTurn`). Historical excursions are therefore
ephemeral: closing the tab and reopening the game resumes on the
last caught-up turn, not on the last clicked one.
Past-turn reports are cached in the `game-history` namespace
(`{gameId}/turn/{N}``GameReport`). The cache is written by
`loadTurn` on every successful historical fetch and read first by
`viewTurn(N)` before falling back to the network. Past turns are
immutable, so the cache has no TTL and no eviction in Phase 26.
The current-turn snapshot is deliberately *not* cached — it is
mutable until the next engine tick.
+34 -5
View File
@@ -75,17 +75,46 @@ end-to-end command flow) can set it on navigation.
The Order entry is hidden when the layout's `historyMode` flag is
true. Phase 12 plumbs the flag end-to-end as a prop —
`+layout.svelte` passes a constant `false` to `Sidebar`, which
`+layout.svelte` forwards a derived value to `Sidebar`, which
forwards `hideOrder` to its `TabBar`; the same flag goes to
`BottomTabs` so the mobile `Order` button is also suppressed. A
`?sidebar=order` URL seed that arrives while the flag is true falls
back to `inspector`, and an `$effect` on the sidebar resets
`activeTab` away from `order` if the flag flips on mid-session.
Phase 26 introduces `lib/history-mode.ts` and replaces the constant
with the live signal; the order draft survives the toggle because
Phase 26 wires the flag to the live history signal owned by
`GameStateStore`. The derivation lives directly in `+layout.svelte`
(`const historyMode = $derived(gameState.historyMode)`) — no
separate `lib/history-mode.ts` module ships, because the layout is
the single consumer and the project's compactness rule rejects a
one-line indirection. The order draft survives the toggle because
`OrderDraftStore` lives one level above the sidebar in the layout
hierarchy. See [`order-composer.md`](order-composer.md) for the
draft-store side of the flow.
hierarchy; the same `historyMode` derivation is also fed into
`OrderDraftStore.bindClient` so inspector-driven mutations
(`add` / `remove` / `move`) become no-ops while the user is
viewing a past turn. See [`order-composer.md`](order-composer.md)
for the draft-store side of the flow and
[`game-state.md`](game-state.md) for the rune split between
`currentTurn` and `viewedTurn`.
## Header turn navigator and history banner
The header replaces the Phase 11 inline `turn N` text with a
`← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The
arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and
`currentTurn`); clicking the middle button opens an absolute
popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98
px) listing every turn from `currentTurn` down to `0`. Selecting
the current-turn row routes through `gameState.returnToCurrent()`;
any other row calls `gameState.viewTurn(N)`. The popover reuses
`view-menu.svelte`'s outside-click / Escape pattern.
`lib/header/history-banner.svelte` renders directly under the
header whenever `gameState.historyMode === true`. It shows
"Viewing turn {N} · read-only" with a "Return to current turn"
button that delegates back to `gameState.returnToCurrent()`. Both
the navigator and the banner read `gameState` through context, so
the layout is the only place where the wiring lives.
## Layout breakpoints
+30 -8
View File
@@ -300,13 +300,14 @@ order composer uses the namespace.
## History mode wiring
Phase 26 introduces a global history-mode flag. The IA section
specifies that the Order tab is hidden when history mode is active —
the player is browsing a past turn snapshot, and composing commands
against an immutable snapshot would be confusing.
Phase 26 implements history mode: the user can step back through
past turns and see the report as it was. The IA section specifies
that the Order tab is hidden when history mode is active — the
player is browsing an immutable snapshot, and composing commands
against it would be confusing.
Phase 12 wires the flag end-to-end as a prop. The layout owns the
flag (a constant `false` until Phase 26) and passes it to:
flag and passes it to:
- `Sidebar` as `historyMode`. The sidebar forwards it to its
`TabBar` as `hideOrder`. The Order entry is filtered out of the
@@ -317,10 +318,31 @@ flag (a constant `false` until Phase 26) and passes it to:
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
button is suppressed when true.
Phase 26 turns the constant into a derived value driven by
`GameStateStore.historyMode` (`viewedTurn < currentTurn` while
`status === "ready"`). The same getter is also passed into
`OrderDraftStore.bindClient` as `getHistoryMode`, which short-
circuits the `add` / `remove` / `move` mutations to a no-op while
the flag is true. This makes every Phase 1422 inspector affordance
that calls `orderDraft.add(...)` inert in history mode without
per-component edits — the gate lives in the one chokepoint that
all callers go through. The conflict / paused banners and the
in-flight sync pipeline are untouched: they describe state that
exists independently of the user's current view.
The store itself stays alive across history-mode round-trips so
the draft survives. Phase 26 will replace the constant with the
real signal from `lib/history-mode.ts` and exercise the toggle in
its own test suite.
the draft survives the toggle. The `RenderedReportSource` overlay
(`lib/rendered-report.svelte.ts`) additionally short-circuits in
history mode: when `gameState.historyMode === true` it returns
the raw report so the map / inspector do not project pending
renames composed for the *current* turn onto a *past* report.
See [`game-state.md`](game-state.md) for the `viewTurn` /
`returnToCurrent` API, the cache namespace
(`game-history/{gameId}/turn/{N}`), and the visibility-refresh
short-circuit; see [`navigation.md`](navigation.md) for the turn
navigator and the read-only banner that surface history mode in
the chrome.
## Testing
+3 -1
View File
@@ -113,10 +113,12 @@ wipes every namespace.
Namespaces in current use:
| Namespace | Key | Value type | Owner |
|-----------------|---------------------|------------------|-----------------------------|
|-----------------|--------------------------------|------------------|------------------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
Later phases will add more per-feature namespaces (fixtures, lobby
snapshot, etc.). The contract is namespace-strings stay scoped to
+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();
});
});