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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<section
|
||||
id="report-<slug>">` 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 `<section id="report-<slug>">`
|
||||
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.
|
||||
|
||||
@@ -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<GameViewState, "view">;
|
||||
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.
|
||||
|
||||
@@ -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<string, string>,
|
||||
}));
|
||||
// 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<GameViewState, "view">;
|
||||
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);
|
||||
|
||||
@@ -16,7 +16,12 @@ async function bootShell(page: Page): Promise<void> {
|
||||
(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();
|
||||
}
|
||||
|
||||
@@ -145,7 +145,10 @@ async function mockGatewayHappyPath(
|
||||
|
||||
async function completeLogin(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -138,7 +138,12 @@ async function setupShell(page: Page): Promise<void> {
|
||||
},
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/<game-id>/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/<id>/<view>` 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<void> {
|
||||
(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}$`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -235,7 +235,9 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
|
||||
|
||||
async function completeLogin(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -202,7 +202,12 @@ async function bootSession(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function openGame(page: Page): Promise<void> {
|
||||
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",
|
||||
|
||||
@@ -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/<id>/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/<id>/<view>` 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ async function bootSession(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function loadSyntheticGame(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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",
|
||||
|
||||
@@ -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<GameViewState, "view">;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,16 +105,21 @@ async function mockGateway(page: Page): Promise<MockState> {
|
||||
},
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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<unknown, unknown>([[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");
|
||||
|
||||
@@ -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<string, string>,
|
||||
}));
|
||||
|
||||
vi.mock("$app/state", () => ({
|
||||
page: pageMock,
|
||||
}));
|
||||
|
||||
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
|
||||
|
||||
function makePlanet(overrides: Partial<ReportPlanet>): 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();
|
||||
});
|
||||
|
||||
@@ -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<void>>(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<typeof import("../src/routes/lobby/create/+page.svelte")> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import("../src/routes/lobby/+page.svelte")> {
|
||||
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 () => {
|
||||
|
||||
@@ -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<typeof import("../src/routes/login/+page.svelte")> {
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>,
|
||||
}));
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user