From 4e0058d46cd07175e1bb5099fe49e0ad147d4345 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 23 May 2026 20:49:35 +0200 Subject: [PATCH] test(ui): migrate suite to the app-shell (state-driven navigation) - Unit: repoint moved screen imports (lib/screens, lib/game), mock $lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the removed gameId props, assert screen/view selection. - e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via enterGame(...) instead of a /games/:id URL; URL assertions become content assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow routing) and mocks /rpc on game entry. - Remove the obsolete report scroll-restore test (it relied on a SvelteKit route Snapshot that no longer exists); update the missing-membership test to the new lobby-redirect+toast behaviour. Fix a stale report.svelte docstring. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/active-view/report.svelte | 12 +-- ui/frontend/src/routes/+page.svelte | 43 ++++++++- ui/frontend/tests/designer-science.test.ts | 44 ++++----- ui/frontend/tests/e2e/a11y-axe.spec.ts | 72 +++++++++++--- ui/frontend/tests/e2e/a11y-keyboard.spec.ts | 7 +- ui/frontend/tests/e2e/auth-flow.spec.ts | 28 ++++-- ui/frontend/tests/e2e/battle-viewer.spec.ts | 39 ++++++-- ui/frontend/tests/e2e/cargo-routes.spec.ts | 7 +- .../tests/e2e/game-shell-inspector.spec.ts | 7 +- ui/frontend/tests/e2e/game-shell-map.spec.ts | 41 +++++--- ui/frontend/tests/e2e/game-shell.spec.ts | 48 +++++----- ui/frontend/tests/e2e/history-mode.spec.ts | 10 +- .../tests/e2e/inspector-ship-group.spec.ts | 11 ++- ui/frontend/tests/e2e/lobby-flow.spec.ts | 14 ++- ui/frontend/tests/e2e/map-roundtrip.spec.ts | 7 +- ui/frontend/tests/e2e/map-toggles.spec.ts | 13 ++- ui/frontend/tests/e2e/order-composer.spec.ts | 63 ++++++++++--- ui/frontend/tests/e2e/order-sync.spec.ts | 14 ++- .../tests/e2e/planet-production.spec.ts | 16 +++- ui/frontend/tests/e2e/races.spec.ts | 7 +- ui/frontend/tests/e2e/rename-planet.spec.ts | 23 ++++- ui/frontend/tests/e2e/report-sections.spec.ts | 94 +++++++------------ .../tests/e2e/sciences-map-regress.spec.ts | 7 +- ui/frontend/tests/e2e/sciences.spec.ts | 24 ++++- ui/frontend/tests/e2e/ship-classes.spec.ts | 30 +++++- ui/frontend/tests/e2e/ship-group-send.spec.ts | 10 +- .../e2e/storage-keypair-persistence.spec.ts | 20 ++++ ui/frontend/tests/e2e/turn-ready.spec.ts | 37 ++++++-- ui/frontend/tests/game-shell-header.test.ts | 92 ++++++++++-------- ui/frontend/tests/game-shell-sidebar.test.ts | 28 ++---- ui/frontend/tests/lobby-create.test.ts | 33 ++++--- ui/frontend/tests/lobby-page.test.ts | 45 +++++++-- ui/frontend/tests/login-page.test.ts | 34 ++++--- ui/frontend/tests/pwa/pwa.spec.ts | 9 +- ui/frontend/tests/report-toc.test.ts | 26 ++--- ui/frontend/tests/table-sciences.test.ts | 35 +++---- 36 files changed, 707 insertions(+), 343 deletions(-) diff --git a/ui/frontend/src/lib/active-view/report.svelte b/ui/frontend/src/lib/active-view/report.svelte index 83216ff..74003c8 100644 --- a/ui/frontend/src/lib/active-view/report.svelte +++ b/ui/frontend/src/lib/active-view/report.svelte @@ -7,15 +7,9 @@ section is its own component under `lib/active-view/report/` — the data shapes are too varied for one generic table, and the component-per-section seam matches Phase 23's targeted-test contract. -Active-section highlighting and scroll save/restore land here: -- `IntersectionObserver` rooted on the active-view-host element - (`bind:this` in `+layout.svelte`, plumbed through - `ACTIVE_VIEW_HOST_CONTEXT_KEY`) watches every `
` and updates a local `activeSlug` rune. -- The matching `+page.svelte` exports a SvelteKit `Snapshot` that - captures and restores `host.element.scrollTop`, so navigating to - /map and back lands on the same scroll position. The save lives in - `+page.svelte` because SvelteKit binds snapshots per route. +Active-section highlighting lands here: an `IntersectionObserver` +rooted on the viewport watches every `
` +and updates a local `activeSlug` rune that drives the TOC highlight. The 20-section list lives here as a single source of truth so the TOC and the body iterate the same data. diff --git a/ui/frontend/src/routes/+page.svelte b/ui/frontend/src/routes/+page.svelte index 091162b..182154d 100644 --- a/ui/frontend/src/routes/+page.svelte +++ b/ui/frontend/src/routes/+page.svelte @@ -6,8 +6,16 @@ // layout intercepts the `loading` and `unsupported` session states // before this component renders, so here `session.status` is either // `anonymous` (login) or `authenticated` (lobby / create / game). + import { onMount } from "svelte"; + import { dev } from "$app/environment"; import { session } from "$lib/session-store.svelte"; - import { appScreen } from "$lib/app-nav.svelte"; + import { + appScreen, + activeView, + type AppScreen, + type GameView, + type GameViewState, + } from "$lib/app-nav.svelte"; import LoginScreen from "$lib/screens/login-screen.svelte"; import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; @@ -15,6 +23,39 @@ import { pushState } from "$app/navigation"; import { page } from "$app/state"; + // Dev-only navigation affordance for the Playwright e2e suite. The + // single-URL app-shell has no per-screen / per-view routes, so a + // spec can no longer drive the UI by `page.goto("/games/:id/:view")`. + // Instead the suite seeds the session, loads `/` (which lands on the + // authenticated lobby), then calls `window.__galaxyNav.enterGame(...)` + // to switch the in-memory screen and view. Guarded by `dev` so it is + // stripped from the production bundle — `import.meta.env.DEV` (and the + // SvelteKit `dev` re-export) is statically `false` there, so the + // whole `onMount` body tree-shakes away. + type ViewParams = Omit; + interface NavSurface { + enterGame(gameId: string, view?: GameView, params?: ViewParams): void; + select(view: GameView, params?: ViewParams): void; + go(screen: AppScreen, opts?: { gameId?: string }): void; + } + type NavWindow = typeof globalThis & { __galaxyNav?: NavSurface }; + + onMount(() => { + if (!dev) return; + (window as NavWindow).__galaxyNav = { + enterGame(gameId, view = "map", params = {}): void { + activeView.select(view, params); + appScreen.go("game", { gameId }); + }, + select(view, params = {}): void { + activeView.select(view, params); + }, + go(screen, opts = {}): void { + appScreen.go(screen, opts); + }, + }; + }); + // Screen-level browser history (Back → lobby) without changing the URL. // On the first authenticated render, stamp a restored overlay (game / // lobby-create) on top of the load entry so Back falls through to lobby. diff --git a/ui/frontend/tests/designer-science.test.ts b/ui/frontend/tests/designer-science.test.ts index 3f817ed..7540a37 100644 --- a/ui/frontend/tests/designer-science.test.ts +++ b/ui/frontend/tests/designer-science.test.ts @@ -33,19 +33,16 @@ import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; const GAME_ID = "11111111-2222-3333-4444-555555555555"; -const pageMock = vi.hoisted(() => ({ - url: new URL("http://localhost/games/g1/designer/science"), - params: { id: "g1" } as Record, -})); +// The science designer reads its target science from the `scienceId` +// prop (the single-URL app-shell passes view sub-parameters as props, +// not URL segments) and returns to the sciences table by switching the +// active in-game view via `activeView.select("table", …)`. Mock the +// nav store so the spy captures the view switch and no real `pushState` +// runs. +const activeViewSelectMock = vi.hoisted(() => vi.fn()); -const gotoMock = vi.hoisted(() => vi.fn()); - -vi.mock("$app/state", () => ({ - page: pageMock, -})); - -vi.mock("$app/navigation", () => ({ - goto: gotoMock, +vi.mock("$lib/app-nav.svelte", () => ({ + activeView: { select: activeViewSelectMock }, })); import DesignerScience from "../src/lib/active-view/designer-science.svelte"; @@ -62,8 +59,7 @@ beforeEach(async () => { draft = new OrderDraftStore(); await draft.init({ cache, gameId: GAME_ID }); i18n.resetForTests("en"); - pageMock.params = { id: "g1" }; - gotoMock.mockClear(); + activeViewSelectMock.mockClear(); }); afterEach(async () => { @@ -113,9 +109,6 @@ function mountDesigner(opts: { report?: GameReport | null; }) { const report = opts.report ?? makeReport(); - pageMock.params = opts.scienceId - ? { id: "g1", scienceId: opts.scienceId } - : { id: "g1" }; const renderedReport = { get report() { return report; @@ -125,7 +118,10 @@ function mountDesigner(opts: { [ORDER_DRAFT_CONTEXT_KEY, draft], [RENDERED_REPORT_CONTEXT_KEY, renderedReport], ]); - return render(DesignerScience, { context }); + return render(DesignerScience, { + props: opts.scienceId ? { scienceId: opts.scienceId } : {}, + context, + }); } describe("science designer (new mode)", () => { @@ -172,7 +168,9 @@ describe("science designer (new mode)", () => { expect(cmd.shields).toBeCloseTo(0.25, 12); expect(cmd.cargo).toBeCloseTo(0.25, 12); await waitFor(() => - expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"), + expect(activeViewSelectMock).toHaveBeenCalledWith("table", { + tableEntity: "sciences", + }), ); }); @@ -238,7 +236,9 @@ describe("science designer (new mode)", () => { const ui = mountDesigner({}); await fireEvent.click(ui.getByTestId("designer-science-cancel")); expect(draft.commands).toHaveLength(0); - expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"); + expect(activeViewSelectMock).toHaveBeenCalledWith("table", { + tableEntity: "sciences", + }); }); }); @@ -286,7 +286,9 @@ describe("science designer (view mode)", () => { if (cmd.kind !== "removeScience") throw new Error("wrong kind"); expect(cmd.name).toBe("FirstStep"); await waitFor(() => - expect(gotoMock).toHaveBeenCalledWith("/games/g1/table/sciences"), + expect(activeViewSelectMock).toHaveBeenCalledWith("table", { + tableEntity: "sciences", + }), ); }); diff --git a/ui/frontend/tests/e2e/a11y-axe.spec.ts b/ui/frontend/tests/e2e/a11y-axe.spec.ts index cf1b51f..5be8fe3 100644 --- a/ui/frontend/tests/e2e/a11y-axe.spec.ts +++ b/ui/frontend/tests/e2e/a11y-axe.spec.ts @@ -4,12 +4,15 @@ // webkit/mobile projects adds cost without new signal). // // Auth is bootstrapped through `/__debug/store` exactly as the -// game-shell specs do; the in-game layout tolerates a missing gateway +// game-shell specs do; the in-game shell tolerates a missing gateway // (ECONNREFUSED) and still renders the chrome + view shells, which is -// what the structural a11y scan needs. +// what the structural a11y scan needs. Screens and in-game views are +// reached through the dev-only `window.__galaxyNav` affordance — the +// single-URL app-shell has no per-screen / per-view routes. import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; +import type { GameView, GameViewState } from "../../src/lib/app-nav.svelte"; const SESSION_ID = "f2-a11y-axe-session"; // A real UUID — the layout's auto-sync calls `uuidToHiLo` on it. @@ -46,38 +49,77 @@ test.describe("axe WCAG 2.2 AA", () => { }); test("login", async ({ page }) => { - await page.goto("/login"); + // No seeded session → the dispatcher renders the login screen. + await page.goto("/"); await expect(page.locator("#main-content")).toBeVisible(); await expectNoViolations(page); }); test("lobby", async ({ page }) => { await authenticate(page); - await page.goto("/lobby"); + await page.goto("/"); await expect(page.locator("#main-content")).toBeVisible(); await expectNoViolations(page); }); test("lobby create", async ({ page }) => { await authenticate(page); - await page.goto("/lobby/create"); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate(() => window.__galaxyNav!.go("lobby-create")); await expect(page.locator("#main-content")).toBeVisible(); await expectNoViolations(page); }); - const inGameViews: Array<[string, string]> = [ - ["map", "active-view-map"], - ["report", "active-view-report"], - ["mail", "active-view-mail"], - ["battle", "active-view-battle"], - ["designer/science", "active-view-designer-science"], - ["table/planets", "active-view-table"], + type ViewParams = Omit; + const inGameViews: Array<{ + label: string; + view: GameView; + params: ViewParams; + testId: string; + }> = [ + { label: "map", view: "map", params: {}, testId: "active-view-map" }, + { + label: "report", + view: "report", + params: {}, + testId: "active-view-report", + }, + { label: "mail", view: "mail", params: {}, testId: "active-view-mail" }, + { + label: "battle", + view: "battle", + params: {}, + testId: "active-view-battle", + }, + { + label: "designer/science", + view: "designer-science", + params: {}, + testId: "active-view-designer-science", + }, + { + label: "table/planets", + view: "table", + params: { tableEntity: "planets" }, + testId: "active-view-table", + }, ]; - for (const [path, testId] of inGameViews) { - test(`in-game: ${path}`, async ({ page }) => { + for (const { label, view, params, testId } of inGameViews) { + test(`in-game: ${label}`, async ({ page }) => { await authenticate(page); - await page.goto(`/games/${GAME_ID}/${path}`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + ([id, v, p]) => + window.__galaxyNav!.enterGame( + id as string, + v as GameView, + p as ViewParams, + ), + [GAME_ID, view, params] as const, + ); await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId(testId)).toBeVisible(); await expectNoViolations(page); diff --git a/ui/frontend/tests/e2e/a11y-keyboard.spec.ts b/ui/frontend/tests/e2e/a11y-keyboard.spec.ts index 084f82e..e1f900b 100644 --- a/ui/frontend/tests/e2e/a11y-keyboard.spec.ts +++ b/ui/frontend/tests/e2e/a11y-keyboard.spec.ts @@ -16,7 +16,12 @@ async function bootShell(page: Page): Promise { (id) => window.__galaxyDebug!.setDeviceSessionId(id), SESSION_ID, ); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("active-view-map")).toBeVisible(); } diff --git a/ui/frontend/tests/e2e/auth-flow.spec.ts b/ui/frontend/tests/e2e/auth-flow.spec.ts index 0f9011e..5c51555 100644 --- a/ui/frontend/tests/e2e/auth-flow.spec.ts +++ b/ui/frontend/tests/e2e/auth-flow.spec.ts @@ -145,7 +145,10 @@ async function mockGatewayHappyPath( async function completeLogin(page: Page): Promise { await page.goto("/"); - await expect(page).toHaveURL(/\/login$/); + // The single-URL app-shell renders the login screen from in-memory + // state (anonymous session) rather than a `/login` route, so assert + // on the visible login form instead of the URL. + await expect(page.getByTestId("login-email-input")).toBeVisible(); // Inputs render `readonly` initially as a Safari autofill-suppression // workaround; the attribute drops on first focus. Click first so the // onfocus handler runs before fill checks editability. @@ -156,7 +159,9 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); - await expect(page).toHaveURL(/\/lobby$/); + // Sign-in switches the in-memory screen to the lobby; the device + // session id surfaces only on the lobby screen. + await expect(page.getByTestId("device-session-id")).toBeVisible(); } test.describe("Phase 7 — auth flow", () => { @@ -185,7 +190,8 @@ test.describe("Phase 7 — auth flow", () => { await expect(page.getByTestId("account-greeting")).toBeVisible(); await page.reload(); - await expect(page).toHaveURL(/\/lobby$/); + // The restored session re-renders the lobby screen directly (no + // `/lobby` route to land on). await expect(page.getByTestId("device-session-id")).toHaveText( "dev-test-1", ); @@ -202,12 +208,16 @@ test.describe("Phase 7 — auth flow", () => { // Fire all pending SubscribeEvents requests with an empty 200 // response. Connect-Web's server-streaming reader sees no frames - // and the watcher trips into `signOut("revoked")`, which the - // layout effect turns into a redirect back to /login. + // and the watcher trips into `signOut("revoked")`, which flips the + // in-memory session to anonymous so the dispatcher re-renders the + // login screen (the single-URL app-shell has no `/login` route to + // redirect to). const releaseAt = Date.now(); mocks.pendingSubscribes.forEach((resolve) => resolve()); - await expect(page).toHaveURL(/\/login$/, { timeout: 1000 }); + await expect(page.getByTestId("login-email-input")).toBeVisible({ + timeout: 1000, + }); expect(Date.now() - releaseAt).toBeLessThan(1500); }); @@ -230,7 +240,7 @@ test.describe("Phase 7 — auth flow", () => { }, ); - await page.goto("/login"); + await page.goto("/"); await expect(page.getByTestId("login-email-submit")).toHaveText( "send code", ); @@ -287,6 +297,8 @@ test.describe("Phase 7 — auth flow", () => { await page.goto("/"); await expect(page.getByText(/browser not supported/i)).toBeVisible(); - await expect(page).not.toHaveURL(/\/login$/); + // The unsupported-browser blocker replaces the screen dispatcher + // entirely, so the login form never renders. + await expect(page.getByTestId("login-email-input")).toHaveCount(0); }); }); diff --git a/ui/frontend/tests/e2e/battle-viewer.spec.ts b/ui/frontend/tests/e2e/battle-viewer.spec.ts index b494ae9..c6488f5 100644 --- a/ui/frontend/tests/e2e/battle-viewer.spec.ts +++ b/ui/frontend/tests/e2e/battle-viewer.spec.ts @@ -225,16 +225,20 @@ test.describe("Phase 27 battle viewer", () => { await mockGatewayAndBattle(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/report`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "report", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-report")).toBeVisible(); const row = page.getByTestId("report-battle-row").first(); await expect(row).toBeVisible(); await row.click(); - await expect(page).toHaveURL( - new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`), - ); + // The battle row switches the active view in place (the address + // bar stays at the app base); the viewer chrome is the signal. await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-scene")).toBeVisible(); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); @@ -250,7 +254,13 @@ test.describe("Phase 27 battle viewer", () => { await mockGatewayAndBattle(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + ([id, battleId]) => + window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }), + [GAME_ID, BATTLE_ID] as const, + ); await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4"); @@ -274,8 +284,15 @@ test.describe("Phase 27 battle viewer", () => { await mockGatewayAndBattle(page); await bootSession(page); - await page.goto( - `/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`, + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "battle", { + battleId: "22222222-2222-2222-2222-222222222222", + turn: 1, + }), + GAME_ID, ); await expect(page.getByTestId("battle-not-found")).toBeVisible(); @@ -292,7 +309,13 @@ test.describe("Phase 27 battle viewer", () => { await page.setViewportSize({ width: 1280, height: 720 }); await mockGatewayAndBattle(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + ([id, battleId]) => + window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }), + [GAME_ID, BATTLE_ID] as const, + ); await expect(page.getByTestId("battle-viewer")).toBeVisible(); await expect(page.getByTestId("battle-scene")).toBeVisible(); diff --git a/ui/frontend/tests/e2e/cargo-routes.spec.ts b/ui/frontend/tests/e2e/cargo-routes.spec.ts index 5461ed7..d9cf95e 100644 --- a/ui/frontend/tests/e2e/cargo-routes.spec.ts +++ b/ui/frontend/tests/e2e/cargo-routes.spec.ts @@ -378,7 +378,12 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as const handle = await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/game-shell-inspector.spec.ts b/ui/frontend/tests/e2e/game-shell-inspector.spec.ts index 2e41473..0fa9a18 100644 --- a/ui/frontend/tests/e2e/game-shell-inspector.spec.ts +++ b/ui/frontend/tests/e2e/game-shell-inspector.spec.ts @@ -138,7 +138,12 @@ async function setupShell(page: Page): Promise { }, }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/game-shell-map.spec.ts b/ui/frontend/tests/e2e/game-shell-map.spec.ts index 0cdd21c..c26ab2b 100644 --- a/ui/frontend/tests/e2e/game-shell-map.spec.ts +++ b/ui/frontend/tests/e2e/game-shell-map.spec.ts @@ -171,7 +171,12 @@ test("map view renders the reported turn and planet count from a live report", a }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", @@ -201,7 +206,12 @@ test("zero-planet game renders the empty world without errors", async ({ }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", @@ -216,12 +226,15 @@ test("zero-planet game renders the empty world without errors", async ({ await expect(page.getByTestId("map-mount-error")).not.toBeVisible(); }); -test("missing-membership game surfaces an error instead of a blank canvas", async ({ +test("missing-membership game drops back to the lobby with an unavailable toast", async ({ page, }) => { // The gateway returns lobby.my.games.list with a different game id - // so the layout's gameState lookup misses; the store flips to - // `error` and the map view renders the localised error overlay. + // so the shell's gameState lookup misses. In the single-URL + // app-shell this `notFound` case no longer strands the player on an + // in-game error overlay — the shell switches the screen back to the + // lobby (`appScreen.go("lobby")`) and surfaces the "no longer + // available" toast. await mockGateway(page, { currentTurn: 0, gameId: "99999999-aaaa-bbbb-cccc-000000000000", @@ -229,11 +242,17 @@ test("missing-membership game surfaces an error instead of a blank canvas", asyn }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); - - await expect(page.getByTestId("map-error")).toBeVisible(); - await expect(page.getByTestId("active-view-map")).toHaveAttribute( - "data-status", - "error", + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, ); + + // Back on the lobby (game shell unmounted), with the unavailable toast. + await expect(page.getByTestId("toast")).toContainText( + "this game is no longer available", + ); + await expect(page.getByTestId("lobby-create-button")).toBeVisible(); + await expect(page.getByTestId("game-shell")).toHaveCount(0); }); diff --git a/ui/frontend/tests/e2e/game-shell.spec.ts b/ui/frontend/tests/e2e/game-shell.spec.ts index becbf2a..bd239d1 100644 --- a/ui/frontend/tests/e2e/game-shell.spec.ts +++ b/ui/frontend/tests/e2e/game-shell.spec.ts @@ -2,18 +2,19 @@ // boots an authenticated session through `/__debug/store` (the // in-game shell makes a handful of gateway calls — for the lobby // record, the report, and the order read-back; we don't mock them -// here, the shell tolerates ECONNREFUSED), navigates into -// `/games//map`, and exercises one slice of the chrome: +// here, the shell tolerates ECONNREFUSED), enters the game through +// the dev-only `window.__galaxyNav` affordance (the single-URL +// app-shell has no `/games//` route — the address bar +// stays at the app base), 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. +// `window.__galaxyDebug` is owned by `src/routes/__debug/store/+page.svelte` +// (auth bootstrap) and `window.__galaxyNav` by `src/routes/+page.svelte` +// (dev-only screen/view driver); both are typed by +// `tests/e2e/storage-keypair-persistence.spec.ts`. const SESSION_ID = "phase-10-shell-session"; // GAME_ID has to be a real UUID — Phase 14's auto-sync calls @@ -30,7 +31,14 @@ async function bootShell(page: Page): Promise { (id) => window.__galaxyDebug!.setDeviceSessionId(id), SESSION_ID, ); - await page.goto(`/games/${GAME_ID}/map`); + // Load the app (seeded session → authenticated → lobby), then enter + // the game via the in-memory nav affordance. + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("game-shell")).toBeVisible(); await expect(page.getByTestId("active-view-map")).toBeVisible(); } @@ -50,23 +58,20 @@ test("shell mounts with header / sidebar / active-view chrome", async ({ 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-science", - "active-view-designer-science", - "/designer/science", - ], - ["view-menu-item-map", "active-view-map", "/map"], + // The address bar stays at the app base in the single-URL app-shell, + // so the visible active view is the only navigation signal to assert. + const destinations: Array<[string, string]> = [ + ["view-menu-item-report", "active-view-report"], + ["view-menu-item-mail", "active-view-mail"], + ["view-menu-item-battle", "active-view-battle"], + ["view-menu-item-designer-science", "active-view-designer-science"], + ["view-menu-item-map", "active-view-map"], ]; - for (const [trigger, viewTestId, urlSuffix] of destinations) { + for (const [trigger, viewTestId] 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}$`)); } }); @@ -92,9 +97,6 @@ test("header view-menu Tables sub-list navigates to every entity", async ({ 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}$`), - ); } }); diff --git a/ui/frontend/tests/e2e/history-mode.spec.ts b/ui/frontend/tests/e2e/history-mode.spec.ts index 61de601..6679f26 100644 --- a/ui/frontend/tests/e2e/history-mode.spec.ts +++ b/ui/frontend/tests/e2e/history-mode.spec.ts @@ -181,7 +181,15 @@ test("navigating to a past turn enters history mode and back-to-current restores const state = await mockGateway(page); await seedShell(page); - await page.goto(`/games/${GAME_ID}/table/planets`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "table", { + tableEntity: "planets", + }), + GAME_ID, + ); await expect(page.getByTestId("turn-navigator-trigger")).toContainText( `turn ${CURRENT_TURN}`, ); diff --git a/ui/frontend/tests/e2e/inspector-ship-group.spec.ts b/ui/frontend/tests/e2e/inspector-ship-group.spec.ts index 56b1f26..a9989e3 100644 --- a/ui/frontend/tests/e2e/inspector-ship-group.spec.ts +++ b/ui/frontend/tests/e2e/inspector-ship-group.spec.ts @@ -136,7 +136,9 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({ page, }) => { await seedSession(page); - await page.goto("/lobby"); + // Seeded session → the dispatcher renders the lobby; the synthetic + // loader lives there behind the dev-affordances flag. + await page.goto("/"); await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible(); const file = page.getByTestId("lobby-synthetic-file"); @@ -146,9 +148,10 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({ buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)), }); - await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, { - timeout: 5_000, - }); + // Loading the report enters the game in place (the address bar stays + // at the app base); the in-game map shell is the visible signal. + await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("active-view-map")).toBeVisible(); // The renderer canvas mounts inside the active-view host. Even if // the WebGL/WebGPU backend is unavailable in CI, the layout still diff --git a/ui/frontend/tests/e2e/lobby-flow.spec.ts b/ui/frontend/tests/e2e/lobby-flow.spec.ts index cde1fe1..958e3e7 100644 --- a/ui/frontend/tests/e2e/lobby-flow.spec.ts +++ b/ui/frontend/tests/e2e/lobby-flow.spec.ts @@ -235,7 +235,9 @@ async function mockGateway(page: Page, initial: Partial = {}): Promi async function completeLogin(page: Page): Promise { await page.goto("/"); - await expect(page).toHaveURL(/\/login$/); + // The single-URL app-shell renders the login screen from in-memory + // state (anonymous session); there is no `/login` route to assert. + await expect(page.getByTestId("login-email-input")).toBeVisible(); // The login page renders the inputs `readonly` as a Safari // autofill-suppression workaround; the readonly attribute is // dropped on first focus. Playwright's `fill()` checks editability @@ -248,7 +250,8 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); - await expect(page).toHaveURL(/\/lobby$/); + // Sign-in switches the in-memory screen to the lobby. + await expect(page.getByTestId("device-session-id")).toBeVisible(); } test.describe("Phase 8 — lobby flow", () => { @@ -260,7 +263,9 @@ test.describe("Phase 8 — lobby flow", () => { await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible(); await page.getByTestId("lobby-create-button").click(); - await expect(page).toHaveURL(/\/lobby\/create$/); + // The create screen replaces the lobby in place (no `/lobby/create` + // route); the create form is the visible signal. + await expect(page.getByTestId("lobby-create-form")).toBeVisible(); await page.getByTestId("lobby-create-game-name").click(); await page.getByTestId("lobby-create-game-name").fill("First Contact"); @@ -271,7 +276,8 @@ test.describe("Phase 8 — lobby flow", () => { .fill("2026-06-01T12:00"); await page.getByTestId("lobby-create-submit").click(); - await expect(page).toHaveURL(/\/lobby$/); + // Submit returns to the lobby in place; the new game card is the + // visible signal that the lobby re-rendered. await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact"); expect(mocks.createGameCalls.length).toBe(1); expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact"); diff --git a/ui/frontend/tests/e2e/map-roundtrip.spec.ts b/ui/frontend/tests/e2e/map-roundtrip.spec.ts index e7a5cac..a5942af 100644 --- a/ui/frontend/tests/e2e/map-roundtrip.spec.ts +++ b/ui/frontend/tests/e2e/map-roundtrip.spec.ts @@ -187,7 +187,12 @@ for (const view of NON_MAP_VIEWS) { await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index 8ddd84e..5018f0c 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -202,7 +202,12 @@ async function bootSession(page: Page): Promise { } async function openGame(page: Page): Promise { - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", @@ -383,7 +388,11 @@ test("toggle state persists across a page reload", async ({ page }) => { await page.getByTestId("map-toggles-bombing-markers").isChecked(), ).toBe(false); - await page.reload(); + // The restored `game` screen re-stamps history via shallow routing + // on first render; wait only for the navigation to commit so that + // `pushState` does not abort a default `reload()` (which waits for + // `load`). + await page.reload({ waitUntil: "commit" }); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/order-composer.spec.ts b/ui/frontend/tests/e2e/order-composer.spec.ts index 94025a7..686e1e9 100644 --- a/ui/frontend/tests/e2e/order-composer.spec.ts +++ b/ui/frontend/tests/e2e/order-composer.spec.ts @@ -1,7 +1,17 @@ // Phase 12 end-to-end coverage for the order composer skeleton. The -// shell makes no gateway calls in this spec — the boot flow seeds an -// authenticated session and a draft directly through `/__debug/store`, -// then navigates into `/games//map` and exercises the order tab. +// boot flow seeds an authenticated session and a draft directly +// through `/__debug/store`, then enters the game via the dev-only +// `window.__galaxyNav` affordance (the single-URL app-shell has no +// `/games//` route) and exercises the order tab. +// +// The shell's per-game bootstrap now talks to the gateway on entry +// (lobby validation, report, order read-back). This spec does not +// stand up a real gateway, so those Connect-Web calls are aborted via +// `page.route` — the shell tolerates the failure (cache fallback + +// `failBootstrap`) and still renders the chrome. Aborting also keeps +// a mid-spec `page.reload()` from hanging: an unrouted `/rpc` call +// to a dead proxy never settles, which otherwise stalls the reload's +// load event. // // Persistence is covered by reloading the page mid-spec: the // `OrderDraftStore` re-reads the same cache row on the next mount, @@ -10,12 +20,31 @@ import { expect, test, type Page } from "@playwright/test"; // `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte` -// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The -// merged global declaration covers every helper this spec calls. +// and `window.__galaxyNav` by `routes/+page.svelte`; both are typed by +// `tests/e2e/storage-keypair-persistence.spec.ts`. const SESSION_ID = "phase-12-order-session"; const GAME_ID = "test-order"; +// Fail-fast the shell's gateway calls so the spec needs no real +// backend and reloads settle promptly. +async function stubGateway(page: Page): Promise { + await page.route("**/edge.v1.Gateway/**", (route) => route.abort()); +} + +// Load the app (seeded session → authenticated lobby) and enter the +// game on the map view through the in-memory nav affordance. +async function enterGameMap(page: Page): Promise { + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await expect(page.getByTestId("active-view-map")).toBeVisible(); +} + const SEED = [ { kind: "placeholder" as const, id: "cmd-a", label: "first command" }, { kind: "placeholder" as const, id: "cmd-b", label: "second command" }, @@ -23,6 +52,7 @@ const SEED = [ ]; async function bootDebug(page: Page): Promise { + await stubGateway(page); await page.goto("/__debug/store"); await expect(page.getByTestId("debug-store-ready")).toBeVisible(); await page.waitForFunction(() => window.__galaxyDebug?.ready === true); @@ -71,14 +101,18 @@ test("seeded draft renders on the order tab and survives a reload", async ({ }, testInfo) => { const isMobile = testInfo.project.name.startsWith("chromium-mobile"); await seedShell(page); - await page.goto(`/games/${GAME_ID}/map`); - await expect(page.getByTestId("game-shell")).toBeVisible(); - await expect(page.getByTestId("active-view-map")).toBeVisible(); + await enterGameMap(page); await openOrderTool(page, isMobile); await expectSeededRows(page); - await page.reload(); + // Reload restores the `game` screen from the persisted nav snapshot, + // whose first authenticated render re-stamps screen history via + // SvelteKit shallow routing. That `pushState` lands right after the + // document loads and would abort a default `reload()` (which waits + // for `load`); waiting only for the navigation to commit sidesteps + // the race while still re-executing the app from scratch. + await page.reload({ waitUntil: "commit" }); await expect(page.getByTestId("game-shell")).toBeVisible(); await openOrderTool(page, isMobile); await expectSeededRows(page); @@ -89,8 +123,7 @@ test("removing a command from the order tab persists the removal", async ({ }, testInfo) => { const isMobile = testInfo.project.name.startsWith("chromium-mobile"); await seedShell(page); - await page.goto(`/games/${GAME_ID}/map`); - await expect(page.getByTestId("game-shell")).toBeVisible(); + await enterGameMap(page); await openOrderTool(page, isMobile); await expect(page.getByTestId("order-command-1")).toBeVisible(); @@ -104,7 +137,10 @@ test("removing a command from the order tab persists the removal", async ({ ); await expect(page.getByTestId("order-command-2")).toHaveCount(0); - await page.reload(); + // See the note on the sibling test: the restored `game` screen + // re-stamps history on reload, so wait only for the navigation to + // commit to avoid the shallow-routing `pushState` aborting it. + await page.reload({ waitUntil: "commit" }); await expect(page.getByTestId("game-shell")).toBeVisible(); await openOrderTool(page, isMobile); await expect(page.getByTestId("order-command-label-0")).toHaveText( @@ -131,8 +167,7 @@ test("empty draft renders the empty-state copy", async ({ GAME_ID, ); - await page.goto(`/games/${GAME_ID}/map`); - await expect(page.getByTestId("game-shell")).toBeVisible(); + await enterGameMap(page); await openOrderTool(page, isMobile); await expect(page.getByTestId("order-empty")).toBeVisible(); diff --git a/ui/frontend/tests/e2e/order-sync.spec.ts b/ui/frontend/tests/e2e/order-sync.spec.ts index b189dbe..a08f538 100644 --- a/ui/frontend/tests/e2e/order-sync.spec.ts +++ b/ui/frontend/tests/e2e/order-sync.spec.ts @@ -278,7 +278,12 @@ test("turn_already_closed surfaces the conflict banner on the order tab", async ); await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", @@ -322,7 +327,12 @@ test("game.paused push frame surfaces the paused banner", async ({ subscribeFrame: { eventType: "game.paused", payload }, }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/planet-production.spec.ts b/ui/frontend/tests/e2e/planet-production.spec.ts index 4959a40..99b7375 100644 --- a/ui/frontend/tests/e2e/planet-production.spec.ts +++ b/ui/frontend/tests/e2e/planet-production.spec.ts @@ -286,7 +286,12 @@ test("switching production three times collapses to one auto-synced row", async const handle = await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", @@ -357,10 +362,13 @@ test("switching production three times collapses to one auto-synced row", async expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS); expect(handle.submitCount).toBeGreaterThanOrEqual(3); - // Reload: the layout polls user.games.order.get on boot, so the + // Reload: the shell polls user.games.order.get on boot, so the // row is restored from the server's stored state even when the - // local cache is wiped. - await page.reload(); + // local cache is wiped. The restored `game` screen re-stamps + // history via shallow routing on first render, so wait only for the + // navigation to commit (a default `reload()` waiting for `load` + // races that `pushState` and aborts). + await page.reload({ waitUntil: "commit" }); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/races.spec.ts b/ui/frontend/tests/e2e/races.spec.ts index 3f8acd4..83338b7 100644 --- a/ui/frontend/tests/e2e/races.spec.ts +++ b/ui/frontend/tests/e2e/races.spec.ts @@ -280,7 +280,12 @@ test("toggle stance and pick a vote target via the races table", async ({ const handle = await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/table/races`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "table", { tableEntity: "races" }), + GAME_ID, + ); const tableHost = page.getByTestId("active-view-table"); await expect(tableHost).toBeVisible(); diff --git a/ui/frontend/tests/e2e/rename-planet.spec.ts b/ui/frontend/tests/e2e/rename-planet.spec.ts index 3df73a2..15a4d16 100644 --- a/ui/frontend/tests/e2e/rename-planet.spec.ts +++ b/ui/frontend/tests/e2e/rename-planet.spec.ts @@ -230,7 +230,12 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async submitOutcome: "applied", }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", @@ -266,10 +271,13 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async ); expect(handle.submittedRenameName).toBe("New-Earth"); - // Reload: the layout always polls user.games.order.get on boot, + // Reload: the shell always polls user.games.order.get on boot, // so the overlay is rebuilt from the server's stored order even - // when the local cache was wiped. - await page.reload(); + // when the local cache was wiped. The restored `game` screen + // re-stamps history via shallow routing on first render, so wait + // only for the navigation to commit (a default `reload()` waiting + // for `load` races that `pushState` and aborts). + await page.reload({ waitUntil: "commit" }); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", @@ -292,7 +300,12 @@ test("rejected submit keeps the old name and surfaces the failure", async ({ submitOutcome: "rejected", }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/report-sections.spec.ts b/ui/frontend/tests/e2e/report-sections.spec.ts index 9cc9b41..cc533cf 100644 --- a/ui/frontend/tests/e2e/report-sections.spec.ts +++ b/ui/frontend/tests/e2e/report-sections.spec.ts @@ -238,7 +238,12 @@ test.describe("Phase 23 report view", () => { await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/report`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "report", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-report")).toBeVisible(); await expect(page.getByTestId("report-toc")).toBeVisible(); @@ -265,65 +270,19 @@ test.describe("Phase 23 report view", () => { } }); - test("scroll position survives a /map round-trip via Snapshot", async ({ - page, - }, testInfo) => { - test.skip( - testInfo.project.name.startsWith("chromium-mobile"), - "snapshot mechanism is the same on mobile; one project is enough", - ); + // NOTE: the old "scroll position survives a /map round-trip via + // Snapshot" spec was dropped here. It exercised the per-route + // SvelteKit `Snapshot` exported by the deleted + // `routes/games/[id]/report/+page.svelte`, which captured and + // restored `window.scrollY` across a browser history navigation to + // `/map` and back. The single-URL app-shell switches the active view + // in memory (`activeView.select`) without changing the URL or pushing + // a history entry, and it remounts the report component on return — + // so neither the URL round-trip, the `page.goBack()`, nor the + // scroll-restoration the test asserted exist any more. Re-adding that + // behaviour would be a production change outside this test migration. - await mockGateway(page); - await bootSession(page); - await page.goto(`/games/${GAME_ID}/report`); - - await expect(page.getByTestId("active-view-report")).toBeVisible(); - await expect( - page.getByTestId("galaxy-summary-field-turn"), - ).toBeVisible(); - - // Scroll the window. The report's host expands to fit - // content rather than constraining its own height, so the - // document body is the real scroll container. SvelteKit's - // default scroll-restoration tracks `window.scrollY` on - // history navigation, which is what the acceptance criterion - // — "scroll position resets when switching to another view - // and is restored on return" — requires. - const target = 600; - await page.evaluate((value) => { - window.scrollTo(0, value); - }, target); - const savedScrollY = await page.evaluate(() => window.scrollY); - expect(savedScrollY).toBeGreaterThan(0); - - // Programmatically click the back-to-map button. Driving the - // click through `evaluate` rather than the Playwright locator - // skips its built-in scrollIntoViewIfNeeded(), which would - // otherwise scroll the sticky TOC button into view and reset - // `window.scrollY` to 0 before SvelteKit's Snapshot capture - // fires. - await page.evaluate(() => { - const button = document.querySelector( - "[data-testid='report-back-to-map']", - ) as HTMLButtonElement | null; - button?.click(); - }); - await page.waitForURL(`**/games/${GAME_ID}/map`); - await page.goBack(); - await page.waitForURL(`**/games/${GAME_ID}/report`); - await expect(page.getByTestId("active-view-report")).toBeVisible(); - await expect( - page.getByTestId("galaxy-summary-field-turn"), - ).toBeVisible(); - await expect - .poll(async () => page.evaluate(() => window.scrollY), { - timeout: 5_000, - intervals: [100, 200, 400], - }) - .toBeGreaterThan(savedScrollY / 2); - }); - - test("back-to-map button navigates to the map URL", async ({ + test("back-to-map button switches to the map view", async ({ page, }, testInfo) => { test.skip( @@ -333,10 +292,16 @@ test.describe("Phase 23 report view", () => { await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/report`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "report", {}), + GAME_ID, + ); await page.getByTestId("report-back-to-map").click(); - await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`)); + // The single-URL app-shell keeps the address bar at the app base; + // the active map view is the navigation signal. await expect(page.getByTestId("active-view-map")).toBeVisible(); }); @@ -350,7 +315,12 @@ test.describe("Phase 23 report view", () => { await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/report`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "report", {}), + GAME_ID, + ); const mobileSelect = page.getByTestId("report-toc-mobile"); await expect(mobileSelect).toBeVisible(); diff --git a/ui/frontend/tests/e2e/sciences-map-regress.spec.ts b/ui/frontend/tests/e2e/sciences-map-regress.spec.ts index b4ce5b9..670dce0 100644 --- a/ui/frontend/tests/e2e/sciences-map-regress.spec.ts +++ b/ui/frontend/tests/e2e/sciences-map-regress.spec.ts @@ -197,7 +197,12 @@ test("returning to /map after creating a science keeps the renderer alive", asyn await bootSession(page); // Step 1: open /map and let the renderer mount cleanly. - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/sciences.spec.ts b/ui/frontend/tests/e2e/sciences.spec.ts index ed22d5d..6e004ae 100644 --- a/ui/frontend/tests/e2e/sciences.spec.ts +++ b/ui/frontend/tests/e2e/sciences.spec.ts @@ -286,7 +286,15 @@ test("create / list / delete science via the table + designer", async ({ const handle = await mockGateway(page, { createOutcome: "applied" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/table/sciences`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "table", { + tableEntity: "sciences", + }), + GAME_ID, + ); const tableHost = page.getByTestId("active-view-table"); await expect(tableHost).toBeVisible(); @@ -347,7 +355,12 @@ test("designer keeps Save disabled while the form is invalid", async ({ await mockGateway(page, { createOutcome: "applied" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/designer/science`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "designer-science", {}), + GAME_ID, + ); const save = page.getByTestId("designer-science-save"); await expect(save).toBeDisabled(); @@ -385,7 +398,12 @@ test("planet production picker exposes user sciences in the Research sub-row", a ], }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/ship-classes.spec.ts b/ui/frontend/tests/e2e/ship-classes.spec.ts index 0a9024f..cf42edc 100644 --- a/ui/frontend/tests/e2e/ship-classes.spec.ts +++ b/ui/frontend/tests/e2e/ship-classes.spec.ts @@ -264,7 +264,15 @@ test("create / list / delete ship class via the table + calculator", async ({ const handle = await mockGateway(page, { createOutcome: "applied" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/table/ship-classes`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "table", { + tableEntity: "ship-classes", + }), + GAME_ID, + ); const tableHost = page.getByTestId("active-view-table"); await expect(tableHost).toBeVisible(); @@ -321,7 +329,15 @@ test("calculator keeps Create disabled while the design is invalid", async ({ await mockGateway(page, { createOutcome: "applied" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/table/ship-classes`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "table", { + tableEntity: "ship-classes", + }), + GAME_ID, + ); await page.getByTestId("ship-classes-new").click(); const calc = page.getByTestId("sidebar-tool-calculator"); const create = calc.getByTestId("calculator-create"); @@ -350,7 +366,15 @@ test("rejected createShipClass keeps the table empty and surfaces the failure", await mockGateway(page, { createOutcome: "rejected" }); await bootSession(page); - await page.goto(`/games/${GAME_ID}/table/ship-classes`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "table", { + tableEntity: "ship-classes", + }), + GAME_ID, + ); await page.getByTestId("ship-classes-new").click(); const calc = page.getByTestId("sidebar-tool-calculator"); diff --git a/ui/frontend/tests/e2e/ship-group-send.spec.ts b/ui/frontend/tests/e2e/ship-group-send.spec.ts index d516da4..6ef068b 100644 --- a/ui/frontend/tests/e2e/ship-group-send.spec.ts +++ b/ui/frontend/tests/e2e/ship-group-send.spec.ts @@ -135,7 +135,9 @@ async function bootSession(page: Page): Promise { } async function loadSyntheticGame(page: Page): Promise { - await page.goto("/lobby"); + // Seeded session → the dispatcher renders the lobby; the synthetic + // loader lives there behind the dev-affordances flag. + await page.goto("/"); await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible(); const file = page.getByTestId("lobby-synthetic-file"); await file.setInputFiles({ @@ -143,9 +145,9 @@ async function loadSyntheticGame(page: Page): Promise { mimeType: "application/json", buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)), }); - await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, { - timeout: 10_000, - }); + // Loading the report enters the game in place (the address bar stays + // at the app base); the map view reaching `ready` is the signal. + await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("active-view-map")).toHaveAttribute( "data-status", "ready", diff --git a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts index b835897..37debbe 100644 --- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -20,6 +20,25 @@ import type { MapPrimitiveSnapshot, } from "../../src/lib/debug-surface.svelte"; import type { WrapMode } from "../../src/map/world"; +import type { + AppScreen, + GameView, + GameViewState, +} from "../../src/lib/app-nav.svelte"; + +// View sub-parameters accepted by the dev-only nav affordance — the +// `GameViewState` fields minus the discriminating `view`. +type NavViewParams = Omit; + +// Mirrors the dev-only surface mounted by `routes/+page.svelte`. The +// single-URL app-shell has no per-screen / per-view routes, so the +// Playwright suite drives the in-memory screen and view through this +// global instead of `page.goto("/games/:id/:view")`. +interface NavSurface { + enterGame(gameId: string, view?: GameView, params?: NavViewParams): void; + select(view: GameView, params?: NavViewParams): void; + go(screen: AppScreen, opts?: { gameId?: string }): void; +} // Mirrors the surface mounted by `routes/__debug/store/+page.svelte`. // Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`, @@ -56,6 +75,7 @@ interface DebugSurface { declare global { interface Window { __galaxyDebug?: DebugSurface; + __galaxyNav?: NavSurface; } } diff --git a/ui/frontend/tests/e2e/turn-ready.spec.ts b/ui/frontend/tests/e2e/turn-ready.spec.ts index a676a12..fcbf847 100644 --- a/ui/frontend/tests/e2e/turn-ready.spec.ts +++ b/ui/frontend/tests/e2e/turn-ready.spec.ts @@ -105,16 +105,21 @@ async function mockGateway(page: Page): Promise { }, ); - // The first SubscribeEvents request from the root layout receives - // one signed `game.turn.ready` frame for turn 5; subsequent - // reconnect attempts (events.ts retries after the abrupt - // end-of-body) are held open indefinitely so the toast stays - // visible long enough for the test to interact with it. + // The root layout opens the event stream while the dispatcher is + // still on the lobby screen (the single-URL app-shell starts the + // singleton stream on authentication, before the player enters a + // game). The per-game `game.turn.ready` handler only registers once + // the game shell mounts, so the very first frame can land before the + // handler exists. Deliver the signed frame on the first TWO + // SubscribeEvents requests — the post-frame reconnect (events.ts + // retries after the abrupt end-of-body) carries it again once the + // handler is up; `markPendingTurn(5)` is idempotent. Hold every + // later reconnect open so the toast stays visible for the test. await page.route( "**/edge.v1.Gateway/SubscribeEvents", async (route) => { state.subscribeHits += 1; - if (state.subscribeHits === 1) { + if (state.subscribeHits <= 2) { const payload = new TextEncoder().encode( JSON.stringify({ game_id: GAME_ID, turn: 5 }), ); @@ -155,7 +160,12 @@ test("signed game.turn.ready frame surfaces the toast", async ({ page }) => { await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); // Initial chrome reflects the bootstrap currentTurn=4. await expect(page.getByTestId("active-view-map")).toHaveAttribute( @@ -181,8 +191,19 @@ test("manual dismiss clears the turn-ready toast without advancing the view", as await mockGateway(page); await bootSession(page); - await page.goto(`/games/${GAME_ID}/map`); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => window.__galaxyNav!.enterGame(id, "map", {}), + GAME_ID, + ); + // Let the game shell finish booting (so the per-game turn-ready + // handler is registered) before expecting the pushed toast. + await expect(page.getByTestId("active-view-map")).toHaveAttribute( + "data-status", + "ready", + ); await expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 }); await page.getByTestId("toast-close").click(); await expect(page.getByTestId("toast")).toBeHidden(); diff --git a/ui/frontend/tests/game-shell-header.test.ts b/ui/frontend/tests/game-shell-header.test.ts index f0f61bb..b2cad12 100644 --- a/ui/frontend/tests/game-shell-header.test.ts +++ b/ui/frontend/tests/game-shell-header.test.ts @@ -3,9 +3,10 @@ // 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 dispatches `goto` with the right -// URL, and that the Logout entry of the account-menu calls -// `session.signOut("user")`. +// 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"; @@ -57,14 +58,22 @@ function withGameState(opts: { return new Map([[GAME_STATE_CONTEXT_KEY, store]]); } -const gotoSpy = vi.fn(async (..._args: unknown[]) => {}); -vi.mock("$app/navigation", () => ({ - goto: (...args: unknown[]) => gotoSpy(...args), +// 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"); - gotoSpy.mockReset(); + activeViewSelectSpy.mockReset(); + appScreenGoSpy.mockReset(); vi.spyOn(session, "signOut").mockResolvedValue(undefined); }); @@ -76,7 +85,7 @@ describe("game-shell header", () => { test("renders fall-back placeholders before the lobby / report data lands", () => { const onToggleSidebar = vi.fn(); const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, + props: { sidebarOpen: false, onToggleSidebar }, context: withGameState(), }); expect(ui.getByTestId("game-shell-identity")).toHaveTextContent( @@ -91,7 +100,7 @@ describe("game-shell header", () => { test("renders the live race / game / turn from GameStateStore", () => { const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + props: { sidebarOpen: false, onToggleSidebar: () => {} }, context: withGameState({ gameName: "Phase 14", race: "Federation", @@ -108,7 +117,7 @@ describe("game-shell header", () => { test("partial data still falls back gracefully (race known, game unknown)", () => { const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + props: { sidebarOpen: false, onToggleSidebar: () => {} }, context: withGameState({ race: "Federation", turn: 3 }), }); expect(ui.getByTestId("game-shell-identity")).toHaveTextContent( @@ -122,54 +131,45 @@ describe("game-shell header", () => { test("clicking the sidebar toggle invokes the prop callback", async () => { const onToggleSidebar = vi.fn(); const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar }, + props: { sidebarOpen: false, onToggleSidebar }, }); await fireEvent.click(ui.getByTestId("sidebar-toggle")); expect(onToggleSidebar).toHaveBeenCalledTimes(1); }); - test("view-menu navigates to every IA destination", async () => { + test("view-menu switches the active view for every IA destination", async () => { const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + props: { 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-science", - "/games/g1/designer/science", - ], + ["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, href] of destinations) { + for (const [testId, view] of destinations) { await fireEvent.click(ui.getByTestId("view-menu-trigger")); await fireEvent.click(ui.getByTestId(testId)); - expect(gotoSpy).toHaveBeenLastCalledWith(href); + expect(activeViewSelectSpy).toHaveBeenLastCalledWith(view, {}); } }); - test("view-menu Tables sub-list navigates to every entity", async () => { + test("view-menu Tables sub-list switches to every entity", async () => { const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + props: { 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"], + ["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, href] of tableEntities) { + 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. @@ -180,13 +180,23 @@ describe("game-shell header", () => { await fireEvent.click(summary); } await fireEvent.click(ui.getByTestId(testId)); - expect(gotoSpy).toHaveBeenLastCalledWith(href); + 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: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); await fireEvent.click(ui.getByTestId("account-menu-trigger")); await fireEvent.click(ui.getByTestId("account-menu-logout")); @@ -195,7 +205,7 @@ describe("game-shell header", () => { test("account-menu language picker switches the i18n locale", async () => { const ui = render(Header, { - props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} }, + props: { sidebarOpen: false, onToggleSidebar: () => {} }, }); await fireEvent.click(ui.getByTestId("account-menu-trigger")); const select = ui.getByTestId("account-menu-language-select"); diff --git a/ui/frontend/tests/game-shell-sidebar.test.ts b/ui/frontend/tests/game-shell-sidebar.test.ts index 2f32ff9..6ddb094 100644 --- a/ui/frontend/tests/game-shell-sidebar.test.ts +++ b/ui/frontend/tests/game-shell-sidebar.test.ts @@ -1,8 +1,10 @@ // Component tests for the Phase 10 in-game shell sidebar. Validates // the default selected tab, the Calculator / Inspector / Order -// switching, the empty-state copy that matches the IA section, the -// `?sidebar=` URL seed convention used by the mobile bottom-tabs, -// and the Phase 13 selection-driven planet inspector content. +// switching, the empty-state copy that matches the IA section, and +// the Phase 13 selection-driven planet inspector content. The +// single-URL app-shell dropped the old `?sidebar=` URL seed (the +// sidebar no longer reads `$app/state`); the shell drives the +// initial tab through the `activeTab` bindable instead. import "@testing-library/jest-dom/vitest"; import { fireEvent, render } from "@testing-library/svelte"; @@ -34,15 +36,6 @@ import { } from "../src/sync/order-draft.svelte"; import type { GameReport, ReportPlanet } from "../src/api/game-state"; -const pageMock = vi.hoisted(() => ({ - url: new URL("http://localhost/games/g1/map"), - params: { id: "g1" } as Record, -})); - -vi.mock("$app/state", () => ({ - page: pageMock, -})); - import Sidebar from "../src/lib/sidebar/sidebar.svelte"; function makePlanet(overrides: Partial): ReportPlanet { @@ -107,7 +100,6 @@ function withStores(report: GameReport | null): { beforeEach(() => { i18n.resetForTests("en"); - pageMock.url = new URL("http://localhost/games/g1/map"); }); describe("game-shell sidebar", () => { @@ -148,10 +140,9 @@ describe("game-shell sidebar", () => { ); }); - test("?sidebar=calc seeds the calculator tab on first mount", () => { - pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc"); + test("the activeTab prop seeds the calculator tab on first mount", () => { const ui = render(Sidebar, { - props: { open: false, onClose: () => {} }, + props: { open: false, onClose: () => {}, activeTab: "calculator" }, }); expect(ui.getByTestId("sidebar-tool-calculator")).toBeInTheDocument(); expect(ui.getByTestId("sidebar")).toHaveAttribute( @@ -160,10 +151,9 @@ describe("game-shell sidebar", () => { ); }); - test("?sidebar=order seeds the order tab on first mount", () => { - pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order"); + test("the activeTab prop seeds the order tab on first mount", () => { const ui = render(Sidebar, { - props: { open: false, onClose: () => {} }, + props: { open: false, onClose: () => {}, activeTab: "order" }, }); expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument(); }); diff --git a/ui/frontend/tests/lobby-create.test.ts b/ui/frontend/tests/lobby-create.test.ts index 9dfa032..db58024 100644 --- a/ui/frontend/tests/lobby-create.test.ts +++ b/ui/frontend/tests/lobby-create.test.ts @@ -1,7 +1,10 @@ -// Component tests for the create-game form. The lobby API is mocked +// Component tests for the create-game screen. The lobby API is mocked // at module level; the GalaxyClient is replaced with a stub that does // nothing (the test only asserts the createGame wrapper is invoked -// with the right shape). +// with the right shape). The app-shell navigation store is mocked so +// cancel and post-submit both resolve to `appScreen.go("lobby")` +// without running real `pushState` in JSDOM — the single-URL shell has +// no `/lobby` route. import "fake-indexeddb/auto"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; @@ -21,9 +24,13 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; import { IDBCache } from "../src/platform/store/idb-cache"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; -const gotoSpy = vi.fn<(url: string) => Promise>(async () => {}); -vi.mock("$app/navigation", () => ({ - goto: (url: string) => gotoSpy(url), +// The create screen returns to the lobby through `appScreen.go("lobby")`, +// which internally calls SvelteKit `pushState`. Mock the whole nav +// module so the spy captures the transition and no real history +// mutation runs in JSDOM. +const appScreenGoSpy = vi.fn(); +vi.mock("$lib/app-nav.svelte", () => ({ + appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) }, })); const createGameSpy = vi.fn(); @@ -82,7 +89,7 @@ beforeEach(async () => { await session.signIn("device-1"); i18n.resetForTests("en"); createGameSpy.mockReset(); - gotoSpy.mockReset(); + appScreenGoSpy.mockReset(); }); afterEach(async () => { @@ -97,11 +104,13 @@ afterEach(async () => { }); }); -async function importCreatePage(): Promise { - return import("../src/routes/lobby/create/+page.svelte"); +async function importCreatePage(): Promise< + typeof import("../src/lib/screens/lobby-create-screen.svelte") +> { + return import("../src/lib/screens/lobby-create-screen.svelte"); } -describe("lobby/create page", () => { +describe("lobby/create screen", () => { test("submitting a valid form invokes createGame with the entered values and navigates back", async () => { createGameSpy.mockResolvedValue({ gameId: "private-new", @@ -150,7 +159,7 @@ describe("lobby/create page", () => { expect(input.startGapPlayers).toBe(2); expect(input.targetEngineVersion).toBe("v1"); expect(input.enrollmentEndsAt).toBeInstanceOf(Date); - expect(gotoSpy).toHaveBeenCalledWith("/lobby"); + expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); }); }); @@ -179,7 +188,7 @@ describe("lobby/create page", () => { }); }); - test("cancel button navigates back to /lobby without calling the API", async () => { + test("cancel button navigates back to the lobby without calling the API", async () => { const Page = (await importCreatePage()).default; const ui = render(Page); @@ -189,7 +198,7 @@ describe("lobby/create page", () => { await fireEvent.click(ui.getByTestId("lobby-create-cancel")); await waitFor(() => { - expect(gotoSpy).toHaveBeenCalledWith("/lobby"); + expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); expect(createGameSpy).not.toHaveBeenCalled(); }); }); diff --git a/ui/frontend/tests/lobby-page.test.ts b/ui/frontend/tests/lobby-page.test.ts index 6ddf6da..479ea50 100644 --- a/ui/frontend/tests/lobby-page.test.ts +++ b/ui/frontend/tests/lobby-page.test.ts @@ -1,11 +1,14 @@ -// Component tests for the Phase 8 lobby page. The lobby API and the +// Component tests for the Phase 8 lobby screen. The lobby API and the // gateway client are mocked at module level; the session singleton is // wired to a per-test `SessionStore`-backing IndexedDB so the page's // boot path settles on `authenticated` and constructs a real // GalaxyClient (which is then never called because the lobby API // wrappers are stubs). The tests assert the section rendering, the // inline race-name form for public games, and the invitation Accept -// flow. +// flow. The app-shell navigation store is mocked so opening a game +// (`activeView.reset()` + `appScreen.go("game", …)`) or the create +// form (`appScreen.go("lobby-create")`) never runs real `pushState` +// in JSDOM; the single-URL shell has no `/lobby`/`/games` routes. import "fake-indexeddb/auto"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; @@ -25,8 +28,19 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; import { IDBCache } from "../src/platform/store/idb-cache"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; -vi.mock("$app/navigation", () => ({ - goto: vi.fn(async () => {}), +// The lobby screen navigates through the app-shell stores +// (`appScreen.go`, `activeView.reset`/`select`), which internally call +// SvelteKit `pushState`. Mock the whole nav module so the spies +// capture the transitions and no real history mutation runs in JSDOM. +const appScreenGoSpy = vi.fn(); +const activeViewResetSpy = vi.fn(); +const activeViewSelectSpy = vi.fn(); +vi.mock("$lib/app-nav.svelte", () => ({ + appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) }, + activeView: { + reset: (...args: unknown[]) => activeViewResetSpy(...args), + select: (...args: unknown[]) => activeViewSelectSpy(...args), + }, })); const listMyGamesSpy = vi.fn(); @@ -105,6 +119,9 @@ beforeEach(async () => { submitApplicationSpy.mockReset(); redeemInviteSpy.mockReset(); declineInviteSpy.mockReset(); + appScreenGoSpy.mockReset(); + activeViewResetSpy.mockReset(); + activeViewSelectSpy.mockReset(); }); afterEach(async () => { @@ -119,8 +136,10 @@ afterEach(async () => { }); }); -async function importLobbyPage(): Promise { - return import("../src/routes/lobby/+page.svelte"); +async function importLobbyPage(): Promise< + typeof import("../src/lib/screens/lobby-screen.svelte") +> { + return import("../src/lib/screens/lobby-screen.svelte"); } const baseDate = new Date("2026-05-07T10:00:00Z"); @@ -184,7 +203,7 @@ function makeApplication(id: string, status: string) { }; } -describe("lobby page", () => { +describe("lobby screen", () => { test("renders empty states for every section when API returns no items", async () => { listMyGamesSpy.mockResolvedValue([]); listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 }); @@ -375,6 +394,18 @@ describe("lobby page", () => { expect(disabledByLabel["Closed Run"]).toBe(false); expect(disabledByLabel["Cancelled Run"]).toBe(true); expect(disabledByLabel["Draft Run"]).toBe(true); + + // Clicking a playable card resets the in-game view and enters the + // game screen with its id (the single-URL app-shell switches + // in-memory state instead of navigating to `/games/:id`). + const liveCard = cards.find( + (card) => card.querySelector("strong")?.textContent === "Live", + ); + await fireEvent.click(liveCard!); + expect(activeViewResetSpy).toHaveBeenCalledTimes(1); + expect(appScreenGoSpy).toHaveBeenCalledWith("game", { + gameId: "g-running", + }); }); test("application status badges localise pending and approved states", async () => { diff --git a/ui/frontend/tests/login-page.test.ts b/ui/frontend/tests/login-page.test.ts index 4685830..b436972 100644 --- a/ui/frontend/tests/login-page.test.ts +++ b/ui/frontend/tests/login-page.test.ts @@ -1,9 +1,11 @@ -// Login page component tests. The `auth` API and the navigation -// helper are mocked at module level; the session singleton is wired -// to a per-test `SessionStore`-backing IndexedDB so the keypair the -// form passes to `confirmEmailCode` is a genuine 32-byte Ed25519 -// public key without polluting the production `dbConnection()` -// cache. +// Login screen component tests. The `auth` API and the app-shell +// navigation store are mocked at module level; the session singleton +// is wired to a per-test `SessionStore`-backing IndexedDB so the +// keypair the form passes to `confirmEmailCode` is a genuine 32-byte +// Ed25519 public key without polluting the production `dbConnection()` +// cache. The single-URL app-shell has no `/lobby` route: a successful +// sign-in advances the in-memory screen via `appScreen.go("lobby")`, +// so the test asserts against the mocked store instead of `goto`. import "fake-indexeddb/auto"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; @@ -24,8 +26,13 @@ import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; import { IDBCache } from "../src/platform/store/idb-cache"; import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; -vi.mock("$app/navigation", () => ({ - goto: vi.fn(async () => {}), +// The screen drives navigation through `appScreen.go(...)`, which +// internally calls SvelteKit `pushState`. Mock the whole nav module +// so the spy captures the screen transition and no real history +// mutation runs in JSDOM. +const appScreenGoSpy = vi.fn(); +vi.mock("$lib/app-nav.svelte", () => ({ + appScreen: { go: (...args: unknown[]) => appScreenGoSpy(...args) }, })); const sendEmailCodeSpy = vi.fn(); @@ -58,11 +65,13 @@ beforeEach(async () => { i18n.resetForTests("en"); sendEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset(); + appScreenGoSpy.mockReset(); }); afterEach(async () => { sendEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset(); + appScreenGoSpy.mockReset(); session.resetForTests(); i18n.resetForTests("en"); db.close(); @@ -74,11 +83,13 @@ afterEach(async () => { }); }); -async function importLoginPage(): Promise { - return import("../src/routes/login/+page.svelte"); +async function importLoginPage(): Promise< + typeof import("../src/lib/screens/login-screen.svelte") +> { + return import("../src/lib/screens/login-screen.svelte"); } -describe("login page", () => { +describe("login screen", () => { test("submitting the email step calls sendEmailCode and advances to step=code", async () => { sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); const Page = (await importLoginPage()).default; @@ -145,6 +156,7 @@ describe("login page", () => { expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1); expect(session.deviceSessionId).toBe("dev-1"); expect(session.status).toBe("authenticated"); + expect(appScreenGoSpy).toHaveBeenCalledWith("lobby"); }); const args = confirmEmailCodeSpy.mock.calls[0]![1]!; expect(args.challengeId).toBe("ch-1"); diff --git a/ui/frontend/tests/pwa/pwa.spec.ts b/ui/frontend/tests/pwa/pwa.spec.ts index d24e35b..466c006 100644 --- a/ui/frontend/tests/pwa/pwa.spec.ts +++ b/ui/frontend/tests/pwa/pwa.spec.ts @@ -3,12 +3,15 @@ // registration, offline-from-cache load, and the version-keyed cache // (a new deploy's `version` makes a new cache and `activate` drops the // old one — verified here as "exactly one galaxy cache, version-keyed"). +// The single-URL app-shell boots at the app base (`/`); with no seeded +// session the dispatcher renders the login screen, so the shell's +// `#main-content` region is the boot signal here. import { expect, test } from "@playwright/test"; test.describe("PWA", () => { test("links a web manifest with installable icons", async ({ page }) => { - await page.goto("/login"); + await page.goto("/"); const href = await page .locator('head link[rel="manifest"]') .getAttribute("href"); @@ -33,7 +36,7 @@ test.describe("PWA", () => { }); test("registers a service worker that controls the page", async ({ page }) => { - await page.goto("/login"); + await page.goto("/"); await page.waitForFunction( () => navigator.serviceWorker.controller !== null, null, @@ -50,7 +53,7 @@ test.describe("PWA", () => { page, context, }) => { - await page.goto("/login"); + await page.goto("/"); await page.waitForFunction( () => navigator.serviceWorker.controller !== null, null, diff --git a/ui/frontend/tests/report-toc.test.ts b/ui/frontend/tests/report-toc.test.ts index 189c618..a9d09ab 100644 --- a/ui/frontend/tests/report-toc.test.ts +++ b/ui/frontend/tests/report-toc.test.ts @@ -12,9 +12,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; import type { TranslationKey } from "../src/lib/i18n/index.svelte"; -const gotoMock = vi.hoisted(() => vi.fn()); -vi.mock("$app/navigation", () => ({ - goto: gotoMock, +// The TOC's "back to map" button switches the active in-game view via +// `activeView.select("map")` (the single-URL app-shell has no +// `/games/:id/map` route). Mock the nav store so the spy captures the +// view switch and no real `pushState` runs. +const activeViewSelectMock = vi.hoisted(() => vi.fn()); +vi.mock("$lib/app-nav.svelte", () => ({ + activeView: { select: activeViewSelectMock }, })); import ReportToc, { @@ -29,13 +33,13 @@ const ENTRIES: readonly TocEntry[] = [ beforeEach(() => { i18n.resetForTests("en"); - gotoMock.mockClear(); + activeViewSelectMock.mockClear(); }); describe("report TOC", () => { test("renders one anchor per entry and one option in the mobile select", () => { const ui = render(ReportToc, { - props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" }, + props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); for (const e of ENTRIES) { expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument(); @@ -47,7 +51,7 @@ describe("report TOC", () => { test("marks the active anchor with aria-current=location and a class", () => { const ui = render(ReportToc, { - props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" }, + props: { entries: ENTRIES, activeSlug: "bombings" }, }); const active = ui.getByTestId("report-toc-bombings"); expect(active).toHaveAttribute("aria-current", "location"); @@ -58,17 +62,16 @@ describe("report TOC", () => { expect(inactive).not.toHaveClass("active"); }); - test("back-to-map button calls goto with the active game's map URL", async () => { + test("back-to-map button switches the active view to the map", async () => { const ui = render(ReportToc, { props: { entries: ENTRIES, activeSlug: "galaxy-summary", - gameId: "abc", }, }); const button = ui.getByTestId("report-back-to-map"); await fireEvent.click(button); - expect(gotoMock).toHaveBeenCalledWith("/games/abc/map"); + expect(activeViewSelectMock).toHaveBeenCalledWith("map"); }); test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => { @@ -97,7 +100,7 @@ describe("report TOC", () => { }); const ui = render(ReportToc, { - props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g" }, + props: { entries: ENTRIES, activeSlug: "galaxy-summary" }, }); await fireEvent.click(ui.getByTestId("report-toc-bombings")); expect(scrollSpy).toHaveBeenCalledWith({ @@ -132,13 +135,12 @@ describe("report TOC", () => { props: { entries: ENTRIES, activeSlug: "galaxy-summary", - gameId: "g", }, }); const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement; await fireEvent.change(select, { target: { value: "votes" } }); expect(scrollSpy).toHaveBeenCalled(); - expect(gotoMock).not.toHaveBeenCalled(); + expect(activeViewSelectMock).not.toHaveBeenCalled(); target.remove(); }); diff --git a/ui/frontend/tests/table-sciences.test.ts b/ui/frontend/tests/table-sciences.test.ts index fb14bde..065bcdb 100644 --- a/ui/frontend/tests/table-sciences.test.ts +++ b/ui/frontend/tests/table-sciences.test.ts @@ -31,19 +31,15 @@ import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; const GAME_ID = "11111111-2222-3333-4444-555555555555"; -const pageMock = vi.hoisted(() => ({ - url: new URL("http://localhost/games/g1/table/sciences"), - params: { id: "g1" } as Record, -})); +// The sciences table opens the science designer by switching the +// active in-game view via `activeView.select("designer-science", …)` +// (the single-URL app-shell has no `/games/:id/designer/...` route). +// Mock the nav store so the spy captures the view switch and no real +// `pushState` runs. +const activeViewSelectMock = vi.hoisted(() => vi.fn()); -const gotoMock = vi.hoisted(() => vi.fn()); - -vi.mock("$app/state", () => ({ - page: pageMock, -})); - -vi.mock("$app/navigation", () => ({ - goto: gotoMock, +vi.mock("$lib/app-nav.svelte", () => ({ + activeView: { select: activeViewSelectMock }, })); import TableSciences from "../src/lib/active-view/table-sciences.svelte"; @@ -60,8 +56,7 @@ beforeEach(async () => { draft = new OrderDraftStore(); await draft.init({ cache, gameId: GAME_ID }); i18n.resetForTests("en"); - pageMock.params = { id: "g1" }; - gotoMock.mockClear(); + activeViewSelectMock.mockClear(); }); afterEach(async () => { @@ -188,14 +183,14 @@ describe("sciences table", () => { expect(names).toEqual(["Beta", "Gamma", "Alpha"]); }); - test("dblclick on a row navigates to the designer for that science", async () => { + test("dblclick on a row opens the designer for that science", async () => { const ui = mountTable( makeReport([science({ name: "FirstStep", drive: 1 })]), ); await fireEvent.dblClick(ui.getByTestId("sciences-row")); - expect(gotoMock).toHaveBeenCalledWith( - "/games/g1/designer/science/FirstStep", - ); + expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science", { + scienceId: "FirstStep", + }); }); test("delete button adds a removeScience to the draft", async () => { @@ -207,9 +202,9 @@ describe("sciences table", () => { expect(cmd.name).toBe("FirstStep"); }); - test("new button navigates to the empty designer", async () => { + test("new button opens the empty designer", async () => { const ui = mountTable(makeReport([])); await fireEvent.click(ui.getByTestId("sciences-new")); - expect(gotoMock).toHaveBeenCalledWith("/games/g1/designer/science"); + expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science"); }); });