2d17760a5e
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>
169 lines
5.8 KiB
TypeScript
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();
|
|
});
|
|
});
|