fc371c7fe1
Wraps every in-game route under `/games/:id/*` in a responsive shell
with a header (race / turn placeholders, view-menu dropdown or mobile
hamburger, account menu), a three-tab sidebar (Calculator, Inspector,
Order), an active-view slot, and a mobile-only bottom-tabs row
`[Map, Calc, Order, More]`. Every view in the IA section
(`map`, `table/:entity`, `report`, `battle/:battleId?`, `mail`,
`designer/{ship-class,science}/:id?`) ships as a thin SvelteKit route
that mounts a `lib/active-view/<name>.svelte` stub rendering a
localised `coming soon` body. The lobby's `gotoGame` path now actually
lands on a rendered shell instead of a 404.
The "view router" mentioned in the plan is implemented as the file
system plus two-line route wrappers — no separate dispatch component.
Sidebar tab state lives as a `$state` rune inside `sidebar.svelte`,
which sits in the layout that SvelteKit keeps mounted across child
route swaps, so tab choice survives every active-view navigation for
free. A `?sidebar=calc|inspector|order` URL param seeds the initial
tab on first mount; the mobile bottom-tabs use a layout-owned
`mobileTool` rune with a URL-gated `effectiveTool` derivation so the
Calc / Order tool overlay only applies on `/map` and naturally drops
when the user navigates elsewhere.
Tablet ships with a click-toggle drawer for the sidebar rather than
the IA section's swipe-from-right gesture; the structural breakpoint
satisfies Phase 10's acceptance criterion and Phase 35 polish lands
the swipe. The mobile More drawer mirrors the header view-menu
content; the IA's narrower More list (Mail, Battle, Tables, History,
Settings, Logout) is also a Phase 35 polish target once History
exists.
Topic doc `ui/docs/navigation.md` captures the active-view model, the
sidebar state-preservation rule, the `?sidebar=` and `mobileTool`
conventions, and the transient map-overlay back-stack concept (with
the implementation deferred to Phase 34 alongside its first user).
i18n catalogues for `en` and `ru` add the full `game.shell.*`,
`game.view.*`, `game.sidebar.*`, `game.bottom_tabs.*` namespaces.
Tests: Vitest covers the header view-menu (every IA destination
including the Tables sub-list), the account-menu Logout / Language
wiring, the sidebar default tab / switching / `?sidebar=` seed /
close button, and every active-view stub. Playwright e2e boots an
authenticated session via `__galaxyDebug.setDeviceSessionId` (no
gateway calls — the shell makes none in Phase 10), exercises every
view through both the desktop dropdown and the mobile More drawer,
verifies sidebar tab survival across navigation, and uses
`setViewportSize` to validate the breakpoint switches at 768 px and
1024 px.
Phase 10 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
141 lines
4.6 KiB
TypeScript
141 lines
4.6 KiB
TypeScript
// Component tests for the Phase 10 in-game shell header. The header
|
|
// composes the static `race ?` placeholder, the placeholder
|
|
// turn-counter (Phase 11 wires the live source), the view-menu, and
|
|
// the account-menu. The tests assert the placeholder copy, that
|
|
// every view-menu entry dispatches `goto` with the right URL, 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";
|
|
|
|
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
|
|
vi.mock("$app/navigation", () => ({
|
|
goto: (...args: unknown[]) => gotoSpy(...args),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
i18n.resetForTests("en");
|
|
gotoSpy.mockReset();
|
|
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("game-shell header", () => {
|
|
test("renders the static race / turn placeholders and toggles", () => {
|
|
const onToggleSidebar = vi.fn();
|
|
const ui = render(Header, {
|
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
|
});
|
|
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
|
|
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
|
|
/turn\s+\?/,
|
|
);
|
|
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
|
|
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
|
|
});
|
|
|
|
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
|
const onToggleSidebar = vi.fn();
|
|
const ui = render(Header, {
|
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
|
});
|
|
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
|
|
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("view-menu navigates to every IA destination", async () => {
|
|
const ui = render(Header, {
|
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
|
});
|
|
|
|
const destinations: Array<[string, string]> = [
|
|
["view-menu-item-map", "/games/g1/map"],
|
|
["view-menu-item-report", "/games/g1/report"],
|
|
["view-menu-item-battle", "/games/g1/battle"],
|
|
["view-menu-item-mail", "/games/g1/mail"],
|
|
[
|
|
"view-menu-item-designer-ship-class",
|
|
"/games/g1/designer/ship-class",
|
|
],
|
|
[
|
|
"view-menu-item-designer-science",
|
|
"/games/g1/designer/science",
|
|
],
|
|
];
|
|
|
|
for (const [testId, href] of destinations) {
|
|
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
|
await fireEvent.click(ui.getByTestId(testId));
|
|
expect(gotoSpy).toHaveBeenLastCalledWith(href);
|
|
}
|
|
});
|
|
|
|
test("view-menu Tables sub-list navigates to every entity", async () => {
|
|
const ui = render(Header, {
|
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
|
});
|
|
const tableEntities: Array<[string, string]> = [
|
|
["view-menu-item-table-planets", "/games/g1/table/planets"],
|
|
[
|
|
"view-menu-item-table-ship-classes",
|
|
"/games/g1/table/ship-classes",
|
|
],
|
|
[
|
|
"view-menu-item-table-ship-groups",
|
|
"/games/g1/table/ship-groups",
|
|
],
|
|
["view-menu-item-table-fleets", "/games/g1/table/fleets"],
|
|
["view-menu-item-table-sciences", "/games/g1/table/sciences"],
|
|
["view-menu-item-table-races", "/games/g1/table/races"],
|
|
];
|
|
for (const [testId, href] 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(gotoSpy).toHaveBeenLastCalledWith(href);
|
|
}
|
|
});
|
|
|
|
test("account-menu Logout triggers session.signOut('user')", async () => {
|
|
const ui = render(Header, {
|
|
props: { gameId: "g1", 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: { gameId: "g1", 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");
|
|
});
|
|
});
|