// Component tests for the in-game shell header. The header composes // the identity strip (` @ `, 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 in-game ephemeral light/dark theme toggle (F8-05 // replaced the previous account-menu — language picker and logout // now live in the lobby). The tests assert the visible copy, that // every view-menu entry switches the active in-game view via // `activeView.select(...)`, and that the theme toggle flips the // in-memory `theme.override` channel. import "@testing-library/jest-dom/vitest"; import { fireEvent, render } from "@testing-library/svelte"; import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; import { theme } from "../src/lib/theme/theme.svelte"; import Header from "../src/lib/header/header.svelte"; import { GAME_STATE_CONTEXT_KEY, GameStateStore, } from "../src/lib/game-state.svelte"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; function withGameState(opts: { gameName?: string; race?: string; turn?: number; } = {}): Map { const store = new GameStateStore(); store.gameName = opts.gameName ?? ""; if (opts.race !== undefined || opts.turn !== undefined) { store.report = { turn: opts.turn ?? 0, mapWidth: 1000, mapHeight: 1000, planetCount: 0, planets: [], race: opts.race ?? "", localShipClass: [], routes: [], localPlayerDrive: 0, localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, ...EMPTY_SHIP_GROUPS, }; store.currentTurn = opts.turn ?? 0; store.viewedTurn = opts.turn ?? 0; store.status = "ready"; } return new Map([[GAME_STATE_CONTEXT_KEY, store]]); } // The view-menu switches the active in-game view through // `activeView.select(...)`, and the header's return-to-lobby button // leaves the game through `appScreen.go("lobby")`; both internally // call SvelteKit `pushState`. Mock the whole nav module so the spies // capture the transitions and no real history mutation runs in JSDOM. const activeViewSelectSpy = vi.fn(); const appScreenGoSpy = vi.fn(); vi.mock("$lib/app-nav.svelte", () => ({ activeView: { select: (...args: unknown[]) => activeViewSelectSpy(...args) }, appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) }, })); beforeEach(() => { i18n.resetForTests("en"); activeViewSelectSpy.mockReset(); appScreenGoSpy.mockReset(); theme.clearOverride(); }); afterEach(() => { theme.clearOverride(); vi.restoreAllMocks(); }); describe("game-shell header", () => { test("renders fall-back placeholders before the lobby / report data lands", () => { const onToggleSidebar = vi.fn(); const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar }, context: withGameState(), }); 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("game-mode-theme-toggle")).toBeInTheDocument(); }); test("renders the live race / game / turn from GameStateStore", () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, context: withGameState({ gameName: "Phase 14", race: "Federation", turn: 7, }), }); expect(ui.getByTestId("game-shell-identity")).toHaveTextContent( "Federation @ Phase 14", ); expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent( "turn 7", ); }); test("partial data still falls back gracefully (race known, game unknown)", () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, context: withGameState({ race: "Federation", turn: 3 }), }); expect(ui.getByTestId("game-shell-identity")).toHaveTextContent( "Federation @ ?", ); expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent( "turn 3", ); }); test("clicking the sidebar toggle invokes the prop callback", async () => { const onToggleSidebar = vi.fn(); const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar }, }); await fireEvent.click(ui.getByTestId("sidebar-toggle")); expect(onToggleSidebar).toHaveBeenCalledTimes(1); }); test("view-menu switches the active view for every IA destination", async () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); const destinations: Array<[string, string]> = [ ["view-menu-item-map", "map"], ["view-menu-item-report", "report"], ["view-menu-item-mail", "mail"], ["view-menu-item-designer-science", "designer-science"], ]; for (const [testId, view] of destinations) { await fireEvent.click(ui.getByTestId("view-menu-trigger")); await fireEvent.click(ui.getByTestId(testId)); expect(activeViewSelectSpy).toHaveBeenLastCalledWith(view, {}); } }); test("view-menu Tables sub-list switches to every entity", async () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); const tableEntities: Array<[string, string]> = [ ["view-menu-item-table-planets", "planets"], ["view-menu-item-table-ship-classes", "ship-classes"], ["view-menu-item-table-ship-groups", "ship-groups"], ["view-menu-item-table-fleets", "fleets"], ["view-menu-item-table-sciences", "sciences"], ["view-menu-item-table-races", "races"], ["view-menu-item-table-battles", "battles"], ]; for (const [testId, entity] of tableEntities) { await fireEvent.click(ui.getByTestId("view-menu-trigger")); // Open the Tables sub-disclosure each iteration; the menu // closes on every navigation. const summary = ui .getByTestId("view-menu-tables") .querySelector("summary"); if (summary !== null) { await fireEvent.click(summary); } await fireEvent.click(ui.getByTestId(testId)); expect(activeViewSelectSpy).toHaveBeenLastCalledWith("table", { tableEntity: entity, }); } }); test("return-to-lobby button leaves the game for the lobby screen", async () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); await fireEvent.click(ui.getByTestId("return-to-lobby")); expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); }); test("theme toggle flips theme.override between light and dark", async () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); const toggle = ui.getByTestId("game-mode-theme-toggle"); const initialResolved = theme.resolved; const opposite = initialResolved === "light" ? "dark" : "light"; await fireEvent.click(toggle); expect(theme.override).toBe(opposite); expect(theme.resolved).toBe(opposite); await fireEvent.click(toggle); expect(theme.override).toBe(initialResolved); expect(theme.resolved).toBe(initialResolved); }); });