// 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 account-menu. The tests assert the visible // copy, that every view-menu entry switches the active in-game view // via `activeView.select(...)` (the single-URL app-shell has no // per-view routes), 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"; import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; import { session } from "../src/lib/session-store.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(); vi.spyOn(session, "signOut").mockResolvedValue(undefined); }); afterEach(() => { 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("account-menu-trigger")).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-battle", "battle"], ["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"], ]; 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("account-menu Logout triggers session.signOut('user')", async () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); await fireEvent.click(ui.getByTestId("account-menu-trigger")); await fireEvent.click(ui.getByTestId("account-menu-logout")); expect(session.signOut).toHaveBeenCalledWith("user"); }); test("account-menu language picker switches the i18n locale", async () => { const ui = render(Header, { props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); await fireEvent.click(ui.getByTestId("account-menu-trigger")); const select = ui.getByTestId("account-menu-language-select"); await fireEvent.change(select, { target: { value: "ru" } }); expect(i18n.locale).toBe("ru"); }); });