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