Files
galaxy-game/ui/frontend/tests/e2e/game-shell.spec.ts
T
Ilia Denisov fc371c7fe1 ui/phase-10: in-game shell with view-replacement skeleton
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>
2026-05-08 20:15:49 +02:00

220 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 10 end-to-end coverage for the in-game shell. Every spec
// boots an authenticated session through `/__debug/store` (no
// gateway calls — the shell makes none in Phase 10), navigates into
// `/games/test-shell/map`, and exercises one slice of the chrome:
// header navigation, sidebar tab preservation, mobile bottom-tabs,
// and the breakpoint switches at 768 / 1024 px.
import { expect, test, type Page } from "@playwright/test";
// The `window.__galaxyDebug` surface is owned by
// `src/routes/__debug/store/+page.svelte` and typed by
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only
// needs the auth-bootstrap subset (`clearSession`,
// `setDeviceSessionId`); the merged global declaration covers both.
const SESSION_ID = "phase-10-shell-session";
const GAME_ID = "test-shell";
async function bootShell(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
test("shell mounts with header / sidebar / active-view chrome", async ({
page,
}) => {
await bootShell(page);
await expect(page.getByTestId("game-shell-header")).toBeVisible();
await expect(page.getByTestId("race-name")).toContainText("race ?");
await expect(page.getByTestId("turn-counter")).toContainText("turn");
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
});
test("header view-menu navigates to every active view", async ({ page }) => {
await bootShell(page);
const destinations: Array<[string, string, string]> = [
["view-menu-item-report", "active-view-report", "/report"],
["view-menu-item-mail", "active-view-mail", "/mail"],
["view-menu-item-battle", "active-view-battle", "/battle"],
[
"view-menu-item-designer-ship-class",
"active-view-designer-ship-class",
"/designer/ship-class",
],
[
"view-menu-item-designer-science",
"active-view-designer-science",
"/designer/science",
],
["view-menu-item-map", "active-view-map", "/map"],
];
for (const [trigger, viewTestId, urlSuffix] of destinations) {
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId(trigger).click();
await expect(page.getByTestId(viewTestId)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`));
}
});
test("header view-menu Tables sub-list navigates to every entity", async ({
page,
}) => {
await bootShell(page);
const entities = [
"planets",
"ship-classes",
"ship-groups",
"fleets",
"sciences",
"races",
];
for (const entity of entities) {
await page.getByTestId("view-menu-trigger").click();
await page
.getByTestId("view-menu-tables")
.locator("summary")
.click();
await page.getByTestId(`view-menu-item-table-${entity}`).click();
const view = page.getByTestId("active-view-table");
await expect(view).toBeVisible();
await expect(view).toHaveAttribute("data-entity", entity);
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/table/${entity}$`),
);
}
});
test("sidebar tab choice survives navigation between active views", async ({
page,
browserName,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile") ||
testInfo.project.name === "webkit-desktop"
? false
: false,
"sidebar test runs on every project",
);
await bootShell(page);
// Skip on viewports below 1024 — sidebar is hidden by CSS there.
const viewport = page.viewportSize();
if (viewport === null || viewport.width < 1024) {
test.skip();
return;
}
void browserName;
await page.getByTestId("sidebar-tab-calculator").click();
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-report").click();
await expect(page.getByTestId("active-view-report")).toBeVisible();
// Sidebar still rendered; the calculator tool remains selected.
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveAttribute(
"data-active-tab",
"calculator",
);
});
test("mobile bottom-tabs show on small viewports and toggle the tool overlay", async ({
page,
}, testInfo) => {
if (!testInfo.project.name.startsWith("chromium-mobile")) {
test.skip();
return;
}
await bootShell(page);
await expect(page.getByTestId("bottom-tabs")).toBeVisible();
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await page.getByTestId("bottom-tab-calc").click();
await expect(page.getByTestId("sidebar-tool-calculator")).toBeVisible();
await page.getByTestId("bottom-tab-order").click();
await expect(page.getByTestId("sidebar-tool-order")).toBeVisible();
await page.getByTestId("bottom-tab-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
});
test("mobile More drawer navigates to every destination", async ({
page,
}, testInfo) => {
if (!testInfo.project.name.startsWith("chromium-mobile")) {
test.skip();
return;
}
await bootShell(page);
await page.getByTestId("bottom-tab-more").click();
await expect(page.getByTestId("bottom-tabs-more-drawer")).toBeVisible();
await page.getByTestId("bottom-tabs-more-mail").click();
await expect(page.getByTestId("active-view-mail")).toBeVisible();
await page.getByTestId("bottom-tab-more").click();
await page.getByTestId("bottom-tabs-more-report").click();
await expect(page.getByTestId("active-view-report")).toBeVisible();
});
test("breakpoint switches between desktop / tablet / mobile", async ({
page,
}, testInfo) => {
// Use a single chromium-desktop run to drive all three viewports in
// the same browser. Other projects skip — the viewport diff is the
// goal here, not browser-specific behaviour.
if (testInfo.project.name !== "chromium-desktop") {
test.skip();
return;
}
await bootShell(page);
// Desktop ≥ 1024: sidebar visible, bottom-tabs hidden, sidebar
// toggle hidden.
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.getByTestId("sidebar")).toBeVisible();
await expect(page.getByTestId("bottom-tabs")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible();
// Tablet 7681024: sidebar hidden by default, sidebar toggle
// visible, bottom-tabs hidden. Click the toggle and the sidebar
// becomes visible again.
await page.setViewportSize({ width: 900, height: 800 });
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).toBeVisible();
await expect(page.getByTestId("bottom-tabs")).not.toBeVisible();
await page.getByTestId("sidebar-toggle").click();
await expect(page.getByTestId("sidebar")).toBeVisible();
// Mobile < 768: sidebar hidden entirely, bottom-tabs visible,
// sidebar toggle hidden again.
await page.setViewportSize({ width: 390, height: 800 });
await expect(page.getByTestId("bottom-tabs")).toBeVisible();
await expect(page.getByTestId("sidebar")).not.toBeVisible();
await expect(page.getByTestId("sidebar-toggle")).not.toBeVisible();
});