Files
Ilia Denisov 2d17760a5e 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>
2026-05-12 00:13:19 +02:00

169 lines
5.8 KiB
TypeScript

// 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();
});
});