Files
galaxy-game/ui/frontend/tests/turn-navigator.test.ts
T
Ilia Denisov e82c9f8bbd
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 3m35s
fix(ui): no-op when re-selecting the turn already on screen
Clicking the current-turn row in the header turn navigator while
already viewing it routed through returnToCurrent() →
viewTurn(currentTurn), which re-fetches the live report and flips the
view through `loading`. At turn 0 the only row is the live turn, so
the dropdown always fired a pointless backend round-trip and redraw.

Guard goToTurn() against re-selecting the on-screen turn
(turn === viewedTurn): just close the popover and stop. Leaving
history is unaffected — there the viewed turn differs from the target.

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:18:30 +02:00

205 lines
7.2 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();
});
test("selecting the live row at turn 0 is a no-op (no fetch, no redraw)", async () => {
const store = buildStore({ currentTurn: 0, viewedTurn: 0 });
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-0"));
expect(viewTurn).not.toHaveBeenCalled();
expect(returnToCurrent).not.toHaveBeenCalled();
// The click still dismisses the popover.
expect(ui.queryByTestId("turn-navigator-list")).not.toBeInTheDocument();
});
test("re-selecting the row already viewed in history is a no-op", async () => {
const store = buildStore({ currentTurn: 3, viewedTurn: 2 });
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-2"));
expect(viewTurn).not.toHaveBeenCalled();
expect(returnToCurrent).not.toHaveBeenCalled();
});
});