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
|
data shapes are too varied for one generic table, and the
|
||||||
component-per-section seam matches Phase 23's targeted-test contract.
|
component-per-section seam matches Phase 23's targeted-test contract.
|
||||||
|
|
||||||
Active-section highlighting and scroll save/restore land here:
|
Active-section highlighting lands here: an `IntersectionObserver`
|
||||||
- `IntersectionObserver` rooted on the active-view-host element
|
rooted on the viewport watches every `<section id="report-<slug>">`
|
||||||
(`bind:this` in `+layout.svelte`, plumbed through
|
and updates a local `activeSlug` rune that drives the TOC highlight.
|
||||||
`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.
|
|
||||||
|
|
||||||
The 20-section list lives here as a single source of truth so the
|
The 20-section list lives here as a single source of truth so the
|
||||||
TOC and the body iterate the same data.
|
TOC and the body iterate the same data.
|
||||||
|
|||||||
@@ -6,8 +6,16 @@
|
|||||||
// layout intercepts the `loading` and `unsupported` session states
|
// layout intercepts the `loading` and `unsupported` session states
|
||||||
// before this component renders, so here `session.status` is either
|
// before this component renders, so here `session.status` is either
|
||||||
// `anonymous` (login) or `authenticated` (lobby / create / game).
|
// `anonymous` (login) or `authenticated` (lobby / create / game).
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { dev } from "$app/environment";
|
||||||
import { session } from "$lib/session-store.svelte";
|
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 LoginScreen from "$lib/screens/login-screen.svelte";
|
||||||
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
||||||
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
|
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
|
||||||
@@ -15,6 +23,39 @@
|
|||||||
import { pushState } from "$app/navigation";
|
import { pushState } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
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.
|
// Screen-level browser history (Back → lobby) without changing the URL.
|
||||||
// On the first authenticated render, stamp a restored overlay (game /
|
// On the first authenticated render, stamp a restored overlay (game /
|
||||||
// lobby-create) on top of the load entry so Back falls through to lobby.
|
// 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 GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
// The science designer reads its target science from the `scienceId`
|
||||||
url: new URL("http://localhost/games/g1/designer/science"),
|
// prop (the single-URL app-shell passes view sub-parameters as props,
|
||||||
params: { id: "g1" } as Record<string, string>,
|
// 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("$lib/app-nav.svelte", () => ({
|
||||||
|
activeView: { select: activeViewSelectMock },
|
||||||
vi.mock("$app/state", () => ({
|
|
||||||
page: pageMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
|
||||||
goto: gotoMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
|
import DesignerScience from "../src/lib/active-view/designer-science.svelte";
|
||||||
@@ -62,8 +59,7 @@ beforeEach(async () => {
|
|||||||
draft = new OrderDraftStore();
|
draft = new OrderDraftStore();
|
||||||
await draft.init({ cache, gameId: GAME_ID });
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
pageMock.params = { id: "g1" };
|
activeViewSelectMock.mockClear();
|
||||||
gotoMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -113,9 +109,6 @@ function mountDesigner(opts: {
|
|||||||
report?: GameReport | null;
|
report?: GameReport | null;
|
||||||
}) {
|
}) {
|
||||||
const report = opts.report ?? makeReport();
|
const report = opts.report ?? makeReport();
|
||||||
pageMock.params = opts.scienceId
|
|
||||||
? { id: "g1", scienceId: opts.scienceId }
|
|
||||||
: { id: "g1" };
|
|
||||||
const renderedReport = {
|
const renderedReport = {
|
||||||
get report() {
|
get report() {
|
||||||
return report;
|
return report;
|
||||||
@@ -125,7 +118,10 @@ function mountDesigner(opts: {
|
|||||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||||
]);
|
]);
|
||||||
return render(DesignerScience, { context });
|
return render(DesignerScience, {
|
||||||
|
props: opts.scienceId ? { scienceId: opts.scienceId } : {},
|
||||||
|
context,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("science designer (new mode)", () => {
|
describe("science designer (new mode)", () => {
|
||||||
@@ -172,7 +168,9 @@ describe("science designer (new mode)", () => {
|
|||||||
expect(cmd.shields).toBeCloseTo(0.25, 12);
|
expect(cmd.shields).toBeCloseTo(0.25, 12);
|
||||||
expect(cmd.cargo).toBeCloseTo(0.25, 12);
|
expect(cmd.cargo).toBeCloseTo(0.25, 12);
|
||||||
await waitFor(() =>
|
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({});
|
const ui = mountDesigner({});
|
||||||
await fireEvent.click(ui.getByTestId("designer-science-cancel"));
|
await fireEvent.click(ui.getByTestId("designer-science-cancel"));
|
||||||
expect(draft.commands).toHaveLength(0);
|
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");
|
if (cmd.kind !== "removeScience") throw new Error("wrong kind");
|
||||||
expect(cmd.name).toBe("FirstStep");
|
expect(cmd.name).toBe("FirstStep");
|
||||||
await waitFor(() =>
|
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).
|
// webkit/mobile projects adds cost without new signal).
|
||||||
//
|
//
|
||||||
// Auth is bootstrapped through `/__debug/store` exactly as the
|
// 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
|
// (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 AxeBuilder from "@axe-core/playwright";
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
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";
|
const SESSION_ID = "f2-a11y-axe-session";
|
||||||
// A real UUID — the layout's auto-sync calls `uuidToHiLo` on it.
|
// 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 }) => {
|
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 expect(page.locator("#main-content")).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lobby", async ({ page }) => {
|
test("lobby", async ({ page }) => {
|
||||||
await authenticate(page);
|
await authenticate(page);
|
||||||
await page.goto("/lobby");
|
await page.goto("/");
|
||||||
await expect(page.locator("#main-content")).toBeVisible();
|
await expect(page.locator("#main-content")).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lobby create", async ({ page }) => {
|
test("lobby create", async ({ page }) => {
|
||||||
await authenticate(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 expect(page.locator("#main-content")).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
const inGameViews: Array<[string, string]> = [
|
type ViewParams = Omit<GameViewState, "view">;
|
||||||
["map", "active-view-map"],
|
const inGameViews: Array<{
|
||||||
["report", "active-view-report"],
|
label: string;
|
||||||
["mail", "active-view-mail"],
|
view: GameView;
|
||||||
["battle", "active-view-battle"],
|
params: ViewParams;
|
||||||
["designer/science", "active-view-designer-science"],
|
testId: string;
|
||||||
["table/planets", "active-view-table"],
|
}> = [
|
||||||
|
{ 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) {
|
for (const { label, view, params, testId } of inGameViews) {
|
||||||
test(`in-game: ${path}`, async ({ page }) => {
|
test(`in-game: ${label}`, async ({ page }) => {
|
||||||
await authenticate(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("game-shell")).toBeVisible();
|
||||||
await expect(page.getByTestId(testId)).toBeVisible();
|
await expect(page.getByTestId(testId)).toBeVisible();
|
||||||
await expectNoViolations(page);
|
await expectNoViolations(page);
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ async function bootShell(page: Page): Promise<void> {
|
|||||||
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||||
SESSION_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("game-shell")).toBeVisible();
|
||||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ async function mockGatewayHappyPath(
|
|||||||
|
|
||||||
async function completeLogin(page: Page): Promise<void> {
|
async function completeLogin(page: Page): Promise<void> {
|
||||||
await page.goto("/");
|
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
|
// Inputs render `readonly` initially as a Safari autofill-suppression
|
||||||
// workaround; the attribute drops on first focus. Click first so the
|
// workaround; the attribute drops on first focus. Click first so the
|
||||||
// onfocus handler runs before fill checks editability.
|
// 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").click();
|
||||||
await page.getByTestId("login-code-input").fill("123456");
|
await page.getByTestId("login-code-input").fill("123456");
|
||||||
await page.getByTestId("login-code-submit").click();
|
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", () => {
|
test.describe("Phase 7 — auth flow", () => {
|
||||||
@@ -185,7 +190,8 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||||
|
|
||||||
await page.reload();
|
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(
|
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||||
"dev-test-1",
|
"dev-test-1",
|
||||||
);
|
);
|
||||||
@@ -202,12 +208,16 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
|
|
||||||
// Fire all pending SubscribeEvents requests with an empty 200
|
// Fire all pending SubscribeEvents requests with an empty 200
|
||||||
// response. Connect-Web's server-streaming reader sees no frames
|
// response. Connect-Web's server-streaming reader sees no frames
|
||||||
// and the watcher trips into `signOut("revoked")`, which the
|
// and the watcher trips into `signOut("revoked")`, which flips the
|
||||||
// layout effect turns into a redirect back to /login.
|
// 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();
|
const releaseAt = Date.now();
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
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);
|
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(
|
await expect(page.getByTestId("login-email-submit")).toHaveText(
|
||||||
"send code",
|
"send code",
|
||||||
);
|
);
|
||||||
@@ -287,6 +297,8 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByText(/browser not supported/i)).toBeVisible();
|
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 mockGatewayAndBattle(page);
|
||||||
await bootSession(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("active-view-report")).toBeVisible();
|
||||||
const row = page.getByTestId("report-battle-row").first();
|
const row = page.getByTestId("report-battle-row").first();
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await row.click();
|
await row.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
// The battle row switches the active view in place (the address
|
||||||
new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`),
|
// bar stays at the app base); the viewer chrome is the signal.
|
||||||
);
|
|
||||||
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
await expect(page.getByTestId("battle-viewer")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
await expect(page.getByTestId("battle-scene")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
||||||
@@ -250,7 +254,13 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(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-viewer")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
|
||||||
@@ -274,8 +284,15 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(
|
await page.goto("/");
|
||||||
`/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`,
|
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();
|
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 page.setViewportSize({ width: 1280, height: 720 });
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(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-viewer")).toBeVisible();
|
||||||
await expect(page.getByTestId("battle-scene")).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);
|
const handle = await mockGateway(page);
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -138,7 +138,12 @@ async function setupShell(page: Page): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -171,7 +171,12 @@ test("map view renders the reported turn and planet count from a live report", a
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
@@ -201,7 +206,12 @@ test("zero-planet game renders the empty world without errors", async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"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();
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// The gateway returns lobby.my.games.list with a different game id
|
// The gateway returns lobby.my.games.list with a different game id
|
||||||
// so the layout's gameState lookup misses; the store flips to
|
// so the shell's gameState lookup misses. In the single-URL
|
||||||
// `error` and the map view renders the localised error overlay.
|
// 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, {
|
await mockGateway(page, {
|
||||||
currentTurn: 0,
|
currentTurn: 0,
|
||||||
gameId: "99999999-aaaa-bbbb-cccc-000000000000",
|
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 bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await page.goto("/");
|
||||||
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
||||||
await expect(page.getByTestId("map-error")).toBeVisible();
|
await page.evaluate(
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
|
||||||
"data-status",
|
GAME_ID,
|
||||||
"error",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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
|
// boots an authenticated session through `/__debug/store` (the
|
||||||
// in-game shell makes a handful of gateway calls — for the lobby
|
// 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
|
// record, the report, and the order read-back; we don't mock them
|
||||||
// here, the shell tolerates ECONNREFUSED), navigates into
|
// here, the shell tolerates ECONNREFUSED), enters the game through
|
||||||
// `/games/<game-id>/map`, and exercises one slice of the chrome:
|
// 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,
|
// header navigation, sidebar tab preservation, mobile bottom-tabs,
|
||||||
// and the breakpoint switches at 768 / 1024 px.
|
// and the breakpoint switches at 768 / 1024 px.
|
||||||
|
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
// The `window.__galaxyDebug` surface is owned by
|
// `window.__galaxyDebug` is owned by `src/routes/__debug/store/+page.svelte`
|
||||||
// `src/routes/__debug/store/+page.svelte` and typed by
|
// (auth bootstrap) and `window.__galaxyNav` by `src/routes/+page.svelte`
|
||||||
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only
|
// (dev-only screen/view driver); both are typed by
|
||||||
// needs the auth-bootstrap subset (`clearSession`,
|
// `tests/e2e/storage-keypair-persistence.spec.ts`.
|
||||||
// `setDeviceSessionId`); the merged global declaration covers both.
|
|
||||||
|
|
||||||
const SESSION_ID = "phase-10-shell-session";
|
const SESSION_ID = "phase-10-shell-session";
|
||||||
// GAME_ID has to be a real UUID — Phase 14's auto-sync calls
|
// 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),
|
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||||
SESSION_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("game-shell")).toBeVisible();
|
||||||
await expect(page.getByTestId("active-view-map")).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 }) => {
|
test("header view-menu navigates to every active view", async ({ page }) => {
|
||||||
await bootShell(page);
|
await bootShell(page);
|
||||||
|
|
||||||
const destinations: Array<[string, string, string]> = [
|
// The address bar stays at the app base in the single-URL app-shell,
|
||||||
["view-menu-item-report", "active-view-report", "/report"],
|
// so the visible active view is the only navigation signal to assert.
|
||||||
["view-menu-item-mail", "active-view-mail", "/mail"],
|
const destinations: Array<[string, string]> = [
|
||||||
["view-menu-item-battle", "active-view-battle", "/battle"],
|
["view-menu-item-report", "active-view-report"],
|
||||||
[
|
["view-menu-item-mail", "active-view-mail"],
|
||||||
"view-menu-item-designer-science",
|
["view-menu-item-battle", "active-view-battle"],
|
||||||
"active-view-designer-science",
|
["view-menu-item-designer-science", "active-view-designer-science"],
|
||||||
"/designer/science",
|
["view-menu-item-map", "active-view-map"],
|
||||||
],
|
|
||||||
["view-menu-item-map", "active-view-map", "/map"],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [trigger, viewTestId, urlSuffix] of destinations) {
|
for (const [trigger, viewTestId] of destinations) {
|
||||||
await page.getByTestId("view-menu-trigger").click();
|
await page.getByTestId("view-menu-trigger").click();
|
||||||
await page.getByTestId(trigger).click();
|
await page.getByTestId(trigger).click();
|
||||||
await expect(page.getByTestId(viewTestId)).toBeVisible();
|
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");
|
const view = page.getByTestId("active-view-table");
|
||||||
await expect(view).toBeVisible();
|
await expect(view).toBeVisible();
|
||||||
await expect(view).toHaveAttribute("data-entity", entity);
|
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);
|
const state = await mockGateway(page);
|
||||||
await seedShell(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(
|
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
|
||||||
`turn ${CURRENT_TURN}`,
|
`turn ${CURRENT_TURN}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await seedSession(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();
|
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
||||||
|
|
||||||
const file = page.getByTestId("lobby-synthetic-file");
|
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)),
|
buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)),
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
|
// Loading the report enters the game in place (the address bar stays
|
||||||
timeout: 5_000,
|
// 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 renderer canvas mounts inside the active-view host. Even if
|
||||||
// the WebGL/WebGPU backend is unavailable in CI, the layout still
|
// 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> {
|
async function completeLogin(page: Page): Promise<void> {
|
||||||
await page.goto("/");
|
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
|
// The login page renders the inputs `readonly` as a Safari
|
||||||
// autofill-suppression workaround; the readonly attribute is
|
// autofill-suppression workaround; the readonly attribute is
|
||||||
// dropped on first focus. Playwright's `fill()` checks editability
|
// 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").click();
|
||||||
await page.getByTestId("login-code-input").fill("123456");
|
await page.getByTestId("login-code-input").fill("123456");
|
||||||
await page.getByTestId("login-code-submit").click();
|
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", () => {
|
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 expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("lobby-create-button").click();
|
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").click();
|
||||||
await page.getByTestId("lobby-create-game-name").fill("First Contact");
|
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");
|
.fill("2026-06-01T12:00");
|
||||||
await page.getByTestId("lobby-create-submit").click();
|
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");
|
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
|
||||||
expect(mocks.createGameCalls.length).toBe(1);
|
expect(mocks.createGameCalls.length).toBe(1);
|
||||||
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
|
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
|
||||||
|
|||||||
@@ -187,7 +187,12 @@ for (const view of NON_MAP_VIEWS) {
|
|||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -202,7 +202,12 @@ async function bootSession(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openGame(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -383,7 +388,11 @@ test("toggle state persists across a page reload", async ({ page }) => {
|
|||||||
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
||||||
).toBe(false);
|
).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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
// Phase 12 end-to-end coverage for the order composer skeleton. The
|
// 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
|
// boot flow seeds an authenticated session and a draft directly
|
||||||
// authenticated session and a draft directly through `/__debug/store`,
|
// through `/__debug/store`, then enters the game via the dev-only
|
||||||
// then navigates into `/games/<id>/map` and exercises the order tab.
|
// `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
|
// Persistence is covered by reloading the page mid-spec: the
|
||||||
// `OrderDraftStore` re-reads the same cache row on the next mount,
|
// `OrderDraftStore` re-reads the same cache row on the next mount,
|
||||||
@@ -10,12 +20,31 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
|
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
|
||||||
// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The
|
// and `window.__galaxyNav` by `routes/+page.svelte`; both are typed by
|
||||||
// merged global declaration covers every helper this spec calls.
|
// `tests/e2e/storage-keypair-persistence.spec.ts`.
|
||||||
|
|
||||||
const SESSION_ID = "phase-12-order-session";
|
const SESSION_ID = "phase-12-order-session";
|
||||||
const GAME_ID = "test-order";
|
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 = [
|
const SEED = [
|
||||||
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" },
|
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" },
|
||||||
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" },
|
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" },
|
||||||
@@ -23,6 +52,7 @@ const SEED = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function bootDebug(page: Page): Promise<void> {
|
async function bootDebug(page: Page): Promise<void> {
|
||||||
|
await stubGateway(page);
|
||||||
await page.goto("/__debug/store");
|
await page.goto("/__debug/store");
|
||||||
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
||||||
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
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) => {
|
}, testInfo) => {
|
||||||
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
||||||
await seedShell(page);
|
await seedShell(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await enterGameMap(page);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
|
||||||
|
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
await expectSeededRows(page);
|
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 expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
await expectSeededRows(page);
|
await expectSeededRows(page);
|
||||||
@@ -89,8 +123,7 @@ test("removing a command from the order tab persists the removal", async ({
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
|
||||||
await seedShell(page);
|
await seedShell(page);
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await enterGameMap(page);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
|
|
||||||
await expect(page.getByTestId("order-command-1")).toBeVisible();
|
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 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 expect(page.getByTestId("game-shell")).toBeVisible();
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
await expect(page.getByTestId("order-command-label-0")).toHaveText(
|
await expect(page.getByTestId("order-command-label-0")).toHaveText(
|
||||||
@@ -131,8 +167,7 @@ test("empty draft renders the empty-state copy", async ({
|
|||||||
GAME_ID,
|
GAME_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(`/games/${GAME_ID}/map`);
|
await enterGameMap(page);
|
||||||
await expect(page.getByTestId("game-shell")).toBeVisible();
|
|
||||||
await openOrderTool(page, isMobile);
|
await openOrderTool(page, isMobile);
|
||||||
|
|
||||||
await expect(page.getByTestId("order-empty")).toBeVisible();
|
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 mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -322,7 +327,12 @@ test("game.paused push frame surfaces the paused banner", async ({
|
|||||||
subscribeFrame: { eventType: "game.paused", payload },
|
subscribeFrame: { eventType: "game.paused", payload },
|
||||||
});
|
});
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -286,7 +286,12 @@ test("switching production three times collapses to one auto-synced row", async
|
|||||||
|
|
||||||
const handle = await mockGateway(page);
|
const handle = await mockGateway(page);
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"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.lastSubmitted!.subject).toBe(SHIP_CLASS);
|
||||||
expect(handle.submitCount).toBeGreaterThanOrEqual(3);
|
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
|
// row is restored from the server's stored state even when the
|
||||||
// local cache is wiped.
|
// local cache is wiped. The restored `game` screen re-stamps
|
||||||
await page.reload();
|
// 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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -280,7 +280,12 @@ test("toggle stance and pick a vote target via the races table", async ({
|
|||||||
|
|
||||||
const handle = await mockGateway(page);
|
const handle = await mockGateway(page);
|
||||||
await bootSession(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");
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
await expect(tableHost).toBeVisible();
|
await expect(tableHost).toBeVisible();
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
|
|||||||
submitOutcome: "applied",
|
submitOutcome: "applied",
|
||||||
});
|
});
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -266,10 +271,13 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
|
|||||||
);
|
);
|
||||||
expect(handle.submittedRenameName).toBe("New-Earth");
|
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
|
// so the overlay is rebuilt from the server's stored order even
|
||||||
// when the local cache was wiped.
|
// when the local cache was wiped. The restored `game` screen
|
||||||
await page.reload();
|
// 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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -292,7 +300,12 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
|
|||||||
submitOutcome: "rejected",
|
submitOutcome: "rejected",
|
||||||
});
|
});
|
||||||
await bootSession(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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -238,7 +238,12 @@ test.describe("Phase 23 report view", () => {
|
|||||||
|
|
||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(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("active-view-report")).toBeVisible();
|
||||||
await expect(page.getByTestId("report-toc")).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 ({
|
// NOTE: the old "scroll position survives a /map round-trip via
|
||||||
page,
|
// Snapshot" spec was dropped here. It exercised the per-route
|
||||||
}, testInfo) => {
|
// SvelteKit `Snapshot` exported by the deleted
|
||||||
test.skip(
|
// `routes/games/[id]/report/+page.svelte`, which captured and
|
||||||
testInfo.project.name.startsWith("chromium-mobile"),
|
// restored `window.scrollY` across a browser history navigation to
|
||||||
"snapshot mechanism is the same on mobile; one project is enough",
|
// `/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);
|
test("back-to-map button switches to the map view", async ({
|
||||||
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 ({
|
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
@@ -333,10 +292,16 @@ test.describe("Phase 23 report view", () => {
|
|||||||
|
|
||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(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 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();
|
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +315,12 @@ test.describe("Phase 23 report view", () => {
|
|||||||
|
|
||||||
await mockGateway(page);
|
await mockGateway(page);
|
||||||
await bootSession(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");
|
const mobileSelect = page.getByTestId("report-toc-mobile");
|
||||||
await expect(mobileSelect).toBeVisible();
|
await expect(mobileSelect).toBeVisible();
|
||||||
|
|||||||
@@ -197,7 +197,12 @@ test("returning to /map after creating a science keeps the renderer alive", asyn
|
|||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
|
|
||||||
// Step 1: open /map and let the renderer mount cleanly.
|
// 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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -286,7 +286,15 @@ test("create / list / delete science via the table + designer", async ({
|
|||||||
|
|
||||||
const handle = await mockGateway(page, { createOutcome: "applied" });
|
const handle = await mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
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");
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
await expect(tableHost).toBeVisible();
|
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 mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
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");
|
const save = page.getByTestId("designer-science-save");
|
||||||
await expect(save).toBeDisabled();
|
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 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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -264,7 +264,15 @@ test("create / list / delete ship class via the table + calculator", async ({
|
|||||||
|
|
||||||
const handle = await mockGateway(page, { createOutcome: "applied" });
|
const handle = await mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
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");
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
await expect(tableHost).toBeVisible();
|
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 mockGateway(page, { createOutcome: "applied" });
|
||||||
await bootSession(page);
|
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();
|
await page.getByTestId("ship-classes-new").click();
|
||||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
const calc = page.getByTestId("sidebar-tool-calculator");
|
||||||
const create = calc.getByTestId("calculator-create");
|
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 mockGateway(page, { createOutcome: "rejected" });
|
||||||
await bootSession(page);
|
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();
|
await page.getByTestId("ship-classes-new").click();
|
||||||
const calc = page.getByTestId("sidebar-tool-calculator");
|
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> {
|
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();
|
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
|
||||||
const file = page.getByTestId("lobby-synthetic-file");
|
const file = page.getByTestId("lobby-synthetic-file");
|
||||||
await file.setInputFiles({
|
await file.setInputFiles({
|
||||||
@@ -143,9 +145,9 @@ async function loadSyntheticGame(page: Page): Promise<void> {
|
|||||||
mimeType: "application/json",
|
mimeType: "application/json",
|
||||||
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
|
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
|
||||||
});
|
});
|
||||||
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
|
// Loading the report enters the game in place (the address bar stays
|
||||||
timeout: 10_000,
|
// 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(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ import type {
|
|||||||
MapPrimitiveSnapshot,
|
MapPrimitiveSnapshot,
|
||||||
} from "../../src/lib/debug-surface.svelte";
|
} from "../../src/lib/debug-surface.svelte";
|
||||||
import type { WrapMode } from "../../src/map/world";
|
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`.
|
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
|
||||||
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
||||||
@@ -56,6 +75,7 @@ interface DebugSurface {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__galaxyDebug?: DebugSurface;
|
__galaxyDebug?: DebugSurface;
|
||||||
|
__galaxyNav?: NavSurface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,16 +105,21 @@ async function mockGateway(page: Page): Promise<MockState> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// The first SubscribeEvents request from the root layout receives
|
// The root layout opens the event stream while the dispatcher is
|
||||||
// one signed `game.turn.ready` frame for turn 5; subsequent
|
// still on the lobby screen (the single-URL app-shell starts the
|
||||||
// reconnect attempts (events.ts retries after the abrupt
|
// singleton stream on authentication, before the player enters a
|
||||||
// end-of-body) are held open indefinitely so the toast stays
|
// game). The per-game `game.turn.ready` handler only registers once
|
||||||
// visible long enough for the test to interact with it.
|
// 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(
|
await page.route(
|
||||||
"**/edge.v1.Gateway/SubscribeEvents",
|
"**/edge.v1.Gateway/SubscribeEvents",
|
||||||
async (route) => {
|
async (route) => {
|
||||||
state.subscribeHits += 1;
|
state.subscribeHits += 1;
|
||||||
if (state.subscribeHits === 1) {
|
if (state.subscribeHits <= 2) {
|
||||||
const payload = new TextEncoder().encode(
|
const payload = new TextEncoder().encode(
|
||||||
JSON.stringify({ game_id: GAME_ID, turn: 5 }),
|
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 mockGateway(page);
|
||||||
|
|
||||||
await bootSession(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.
|
// Initial chrome reflects the bootstrap currentTurn=4.
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
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 mockGateway(page);
|
||||||
|
|
||||||
await bootSession(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 expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 });
|
||||||
await page.getByTestId("toast-close").click();
|
await page.getByTestId("toast-close").click();
|
||||||
await expect(page.getByTestId("toast")).toBeHidden();
|
await expect(page.getByTestId("toast")).toBeHidden();
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
// the lobby / report calls are in flight), the Phase 26 turn
|
// the lobby / report calls are in flight), the Phase 26 turn
|
||||||
// navigator (`← turn N →` with a popover of every turn), the
|
// navigator (`← turn N →` with a popover of every turn), the
|
||||||
// view-menu, and the account-menu. The tests assert the visible
|
// view-menu, and the account-menu. The tests assert the visible
|
||||||
// copy, that every view-menu entry dispatches `goto` with the right
|
// copy, that every view-menu entry switches the active in-game view
|
||||||
// URL, and that the Logout entry of the account-menu calls
|
// via `activeView.select(...)` (the single-URL app-shell has no
|
||||||
// `session.signOut("user")`.
|
// per-view routes), and that the Logout entry of the account-menu
|
||||||
|
// calls `session.signOut("user")`.
|
||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { fireEvent, render } from "@testing-library/svelte";
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
@@ -57,14 +58,22 @@ function withGameState(opts: {
|
|||||||
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
|
// The view-menu switches the active in-game view through
|
||||||
vi.mock("$app/navigation", () => ({
|
// `activeView.select(...)`, and the header's return-to-lobby button
|
||||||
goto: (...args: unknown[]) => gotoSpy(...args),
|
// 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(() => {
|
beforeEach(() => {
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
gotoSpy.mockReset();
|
activeViewSelectSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
|
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", () => {
|
test("renders fall-back placeholders before the lobby / report data lands", () => {
|
||||||
const onToggleSidebar = vi.fn();
|
const onToggleSidebar = vi.fn();
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
props: { sidebarOpen: false, onToggleSidebar },
|
||||||
context: withGameState(),
|
context: withGameState(),
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
||||||
@@ -91,7 +100,7 @@ describe("game-shell header", () => {
|
|||||||
|
|
||||||
test("renders the live race / game / turn from GameStateStore", () => {
|
test("renders the live race / game / turn from GameStateStore", () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
context: withGameState({
|
context: withGameState({
|
||||||
gameName: "Phase 14",
|
gameName: "Phase 14",
|
||||||
race: "Federation",
|
race: "Federation",
|
||||||
@@ -108,7 +117,7 @@ describe("game-shell header", () => {
|
|||||||
|
|
||||||
test("partial data still falls back gracefully (race known, game unknown)", () => {
|
test("partial data still falls back gracefully (race known, game unknown)", () => {
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
context: withGameState({ race: "Federation", turn: 3 }),
|
context: withGameState({ race: "Federation", turn: 3 }),
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
|
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 () => {
|
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
||||||
const onToggleSidebar = vi.fn();
|
const onToggleSidebar = vi.fn();
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
props: { sidebarOpen: false, onToggleSidebar },
|
||||||
});
|
});
|
||||||
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
|
await fireEvent.click(ui.getByTestId("sidebar-toggle"));
|
||||||
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
|
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, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
});
|
});
|
||||||
|
|
||||||
const destinations: Array<[string, string]> = [
|
const destinations: Array<[string, string]> = [
|
||||||
["view-menu-item-map", "/games/g1/map"],
|
["view-menu-item-map", "map"],
|
||||||
["view-menu-item-report", "/games/g1/report"],
|
["view-menu-item-report", "report"],
|
||||||
["view-menu-item-battle", "/games/g1/battle"],
|
["view-menu-item-battle", "battle"],
|
||||||
["view-menu-item-mail", "/games/g1/mail"],
|
["view-menu-item-mail", "mail"],
|
||||||
[
|
["view-menu-item-designer-science", "designer-science"],
|
||||||
"view-menu-item-designer-science",
|
|
||||||
"/games/g1/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("view-menu-trigger"));
|
||||||
await fireEvent.click(ui.getByTestId(testId));
|
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, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
props: { sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
});
|
});
|
||||||
const tableEntities: Array<[string, string]> = [
|
const tableEntities: Array<[string, string]> = [
|
||||||
["view-menu-item-table-planets", "/games/g1/table/planets"],
|
["view-menu-item-table-planets", "planets"],
|
||||||
[
|
["view-menu-item-table-ship-classes", "ship-classes"],
|
||||||
"view-menu-item-table-ship-classes",
|
["view-menu-item-table-ship-groups", "ship-groups"],
|
||||||
"/games/g1/table/ship-classes",
|
["view-menu-item-table-fleets", "fleets"],
|
||||||
],
|
["view-menu-item-table-sciences", "sciences"],
|
||||||
[
|
["view-menu-item-table-races", "races"],
|
||||||
"view-menu-item-table-ship-groups",
|
|
||||||
"/games/g1/table/ship-groups",
|
|
||||||
],
|
|
||||||
["view-menu-item-table-fleets", "/games/g1/table/fleets"],
|
|
||||||
["view-menu-item-table-sciences", "/games/g1/table/sciences"],
|
|
||||||
["view-menu-item-table-races", "/games/g1/table/races"],
|
|
||||||
];
|
];
|
||||||
for (const [testId, href] of tableEntities) {
|
for (const [testId, entity] of tableEntities) {
|
||||||
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
||||||
// Open the Tables sub-disclosure each iteration; the menu
|
// Open the Tables sub-disclosure each iteration; the menu
|
||||||
// closes on every navigation.
|
// closes on every navigation.
|
||||||
@@ -180,13 +180,23 @@ describe("game-shell header", () => {
|
|||||||
await fireEvent.click(summary);
|
await fireEvent.click(summary);
|
||||||
}
|
}
|
||||||
await fireEvent.click(ui.getByTestId(testId));
|
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 () => {
|
test("account-menu Logout triggers session.signOut('user')", async () => {
|
||||||
const ui = render(Header, {
|
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-trigger"));
|
||||||
await fireEvent.click(ui.getByTestId("account-menu-logout"));
|
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 () => {
|
test("account-menu language picker switches the i18n locale", async () => {
|
||||||
const ui = render(Header, {
|
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-trigger"));
|
||||||
const select = ui.getByTestId("account-menu-language-select");
|
const select = ui.getByTestId("account-menu-language-select");
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Component tests for the Phase 10 in-game shell sidebar. Validates
|
// Component tests for the Phase 10 in-game shell sidebar. Validates
|
||||||
// the default selected tab, the Calculator / Inspector / Order
|
// the default selected tab, the Calculator / Inspector / Order
|
||||||
// switching, the empty-state copy that matches the IA section, the
|
// switching, the empty-state copy that matches the IA section, and
|
||||||
// `?sidebar=` URL seed convention used by the mobile bottom-tabs,
|
// the Phase 13 selection-driven planet inspector content. The
|
||||||
// and the Phase 13 selection-driven planet inspector content.
|
// 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 "@testing-library/jest-dom/vitest";
|
||||||
import { fireEvent, render } from "@testing-library/svelte";
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
@@ -34,15 +36,6 @@ import {
|
|||||||
} from "../src/sync/order-draft.svelte";
|
} from "../src/sync/order-draft.svelte";
|
||||||
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
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";
|
import Sidebar from "../src/lib/sidebar/sidebar.svelte";
|
||||||
|
|
||||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||||
@@ -107,7 +100,6 @@ function withStores(report: GameReport | null): {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
pageMock.url = new URL("http://localhost/games/g1/map");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("game-shell sidebar", () => {
|
describe("game-shell sidebar", () => {
|
||||||
@@ -148,10 +140,9 @@ describe("game-shell sidebar", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("?sidebar=calc seeds the calculator tab on first mount", () => {
|
test("the activeTab prop seeds the calculator tab on first mount", () => {
|
||||||
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=calc");
|
|
||||||
const ui = render(Sidebar, {
|
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-tool-calculator")).toBeInTheDocument();
|
||||||
expect(ui.getByTestId("sidebar")).toHaveAttribute(
|
expect(ui.getByTestId("sidebar")).toHaveAttribute(
|
||||||
@@ -160,10 +151,9 @@ describe("game-shell sidebar", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("?sidebar=order seeds the order tab on first mount", () => {
|
test("the activeTab prop seeds the order tab on first mount", () => {
|
||||||
pageMock.url = new URL("http://localhost/games/g1/map?sidebar=order");
|
|
||||||
const ui = render(Sidebar, {
|
const ui = render(Sidebar, {
|
||||||
props: { open: false, onClose: () => {} },
|
props: { open: false, onClose: () => {}, activeTab: "order" },
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("sidebar-tool-order")).toBeInTheDocument();
|
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
|
// at module level; the GalaxyClient is replaced with a stub that does
|
||||||
// nothing (the test only asserts the createGame wrapper is invoked
|
// 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 "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
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 { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||||
|
|
||||||
const gotoSpy = vi.fn<(url: string) => Promise<void>>(async () => {});
|
// The create screen returns to the lobby through `appScreen.go("lobby")`,
|
||||||
vi.mock("$app/navigation", () => ({
|
// which internally calls SvelteKit `pushState`. Mock the whole nav
|
||||||
goto: (url: string) => gotoSpy(url),
|
// 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();
|
const createGameSpy = vi.fn();
|
||||||
@@ -82,7 +89,7 @@ beforeEach(async () => {
|
|||||||
await session.signIn("device-1");
|
await session.signIn("device-1");
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
createGameSpy.mockReset();
|
createGameSpy.mockReset();
|
||||||
gotoSpy.mockReset();
|
appScreenGoSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -97,11 +104,13 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importCreatePage(): Promise<typeof import("../src/routes/lobby/create/+page.svelte")> {
|
async function importCreatePage(): Promise<
|
||||||
return import("../src/routes/lobby/create/+page.svelte");
|
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 () => {
|
test("submitting a valid form invokes createGame with the entered values and navigates back", async () => {
|
||||||
createGameSpy.mockResolvedValue({
|
createGameSpy.mockResolvedValue({
|
||||||
gameId: "private-new",
|
gameId: "private-new",
|
||||||
@@ -150,7 +159,7 @@ describe("lobby/create page", () => {
|
|||||||
expect(input.startGapPlayers).toBe(2);
|
expect(input.startGapPlayers).toBe(2);
|
||||||
expect(input.targetEngineVersion).toBe("v1");
|
expect(input.targetEngineVersion).toBe("v1");
|
||||||
expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
|
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 Page = (await importCreatePage()).default;
|
||||||
const ui = render(Page);
|
const ui = render(Page);
|
||||||
|
|
||||||
@@ -189,7 +198,7 @@ describe("lobby/create page", () => {
|
|||||||
await fireEvent.click(ui.getByTestId("lobby-create-cancel"));
|
await fireEvent.click(ui.getByTestId("lobby-create-cancel"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(gotoSpy).toHaveBeenCalledWith("/lobby");
|
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||||
expect(createGameSpy).not.toHaveBeenCalled();
|
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
|
// gateway client are mocked at module level; the session singleton is
|
||||||
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
|
// wired to a per-test `SessionStore`-backing IndexedDB so the page's
|
||||||
// boot path settles on `authenticated` and constructs a real
|
// boot path settles on `authenticated` and constructs a real
|
||||||
// GalaxyClient (which is then never called because the lobby API
|
// GalaxyClient (which is then never called because the lobby API
|
||||||
// wrappers are stubs). The tests assert the section rendering, the
|
// wrappers are stubs). The tests assert the section rendering, the
|
||||||
// inline race-name form for public games, and the invitation Accept
|
// 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 "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
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 { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
// The lobby screen navigates through the app-shell stores
|
||||||
goto: vi.fn(async () => {}),
|
// (`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();
|
const listMyGamesSpy = vi.fn();
|
||||||
@@ -105,6 +119,9 @@ beforeEach(async () => {
|
|||||||
submitApplicationSpy.mockReset();
|
submitApplicationSpy.mockReset();
|
||||||
redeemInviteSpy.mockReset();
|
redeemInviteSpy.mockReset();
|
||||||
declineInviteSpy.mockReset();
|
declineInviteSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
|
activeViewResetSpy.mockReset();
|
||||||
|
activeViewSelectSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -119,8 +136,10 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importLobbyPage(): Promise<typeof import("../src/routes/lobby/+page.svelte")> {
|
async function importLobbyPage(): Promise<
|
||||||
return import("../src/routes/lobby/+page.svelte");
|
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");
|
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 () => {
|
test("renders empty states for every section when API returns no items", async () => {
|
||||||
listMyGamesSpy.mockResolvedValue([]);
|
listMyGamesSpy.mockResolvedValue([]);
|
||||||
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
@@ -375,6 +394,18 @@ describe("lobby page", () => {
|
|||||||
expect(disabledByLabel["Closed Run"]).toBe(false);
|
expect(disabledByLabel["Closed Run"]).toBe(false);
|
||||||
expect(disabledByLabel["Cancelled Run"]).toBe(true);
|
expect(disabledByLabel["Cancelled Run"]).toBe(true);
|
||||||
expect(disabledByLabel["Draft 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 () => {
|
test("application status badges localise pending and approved states", async () => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Login page component tests. The `auth` API and the navigation
|
// Login screen component tests. The `auth` API and the app-shell
|
||||||
// helper are mocked at module level; the session singleton is wired
|
// navigation store are mocked at module level; the session singleton
|
||||||
// to a per-test `SessionStore`-backing IndexedDB so the keypair the
|
// is wired to a per-test `SessionStore`-backing IndexedDB so the
|
||||||
// form passes to `confirmEmailCode` is a genuine 32-byte Ed25519
|
// keypair the form passes to `confirmEmailCode` is a genuine 32-byte
|
||||||
// public key without polluting the production `dbConnection()`
|
// Ed25519 public key without polluting the production `dbConnection()`
|
||||||
// cache.
|
// 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 "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
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 { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore";
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
// The screen drives navigation through `appScreen.go(...)`, which
|
||||||
goto: vi.fn(async () => {}),
|
// 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();
|
const sendEmailCodeSpy = vi.fn();
|
||||||
@@ -58,11 +65,13 @@ beforeEach(async () => {
|
|||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
sendEmailCodeSpy.mockReset();
|
sendEmailCodeSpy.mockReset();
|
||||||
confirmEmailCodeSpy.mockReset();
|
confirmEmailCodeSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
sendEmailCodeSpy.mockReset();
|
sendEmailCodeSpy.mockReset();
|
||||||
confirmEmailCodeSpy.mockReset();
|
confirmEmailCodeSpy.mockReset();
|
||||||
|
appScreenGoSpy.mockReset();
|
||||||
session.resetForTests();
|
session.resetForTests();
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
db.close();
|
db.close();
|
||||||
@@ -74,11 +83,13 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importLoginPage(): Promise<typeof import("../src/routes/login/+page.svelte")> {
|
async function importLoginPage(): Promise<
|
||||||
return import("../src/routes/login/+page.svelte");
|
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 () => {
|
test("submitting the email step calls sendEmailCode and advances to step=code", async () => {
|
||||||
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
|
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
|
||||||
const Page = (await importLoginPage()).default;
|
const Page = (await importLoginPage()).default;
|
||||||
@@ -145,6 +156,7 @@ describe("login page", () => {
|
|||||||
expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1);
|
expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(session.deviceSessionId).toBe("dev-1");
|
expect(session.deviceSessionId).toBe("dev-1");
|
||||||
expect(session.status).toBe("authenticated");
|
expect(session.status).toBe("authenticated");
|
||||||
|
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
|
||||||
});
|
});
|
||||||
const args = confirmEmailCodeSpy.mock.calls[0]![1]!;
|
const args = confirmEmailCodeSpy.mock.calls[0]![1]!;
|
||||||
expect(args.challengeId).toBe("ch-1");
|
expect(args.challengeId).toBe("ch-1");
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
// registration, offline-from-cache load, and the version-keyed cache
|
// registration, offline-from-cache load, and the version-keyed cache
|
||||||
// (a new deploy's `version` makes a new cache and `activate` drops the
|
// (a new deploy's `version` makes a new cache and `activate` drops the
|
||||||
// old one — verified here as "exactly one galaxy cache, version-keyed").
|
// 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";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("PWA", () => {
|
test.describe("PWA", () => {
|
||||||
test("links a web manifest with installable icons", async ({ page }) => {
|
test("links a web manifest with installable icons", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
const href = await page
|
const href = await page
|
||||||
.locator('head link[rel="manifest"]')
|
.locator('head link[rel="manifest"]')
|
||||||
.getAttribute("href");
|
.getAttribute("href");
|
||||||
@@ -33,7 +36,7 @@ test.describe("PWA", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("registers a service worker that controls the page", async ({ page }) => {
|
test("registers a service worker that controls the page", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => navigator.serviceWorker.controller !== null,
|
() => navigator.serviceWorker.controller !== null,
|
||||||
null,
|
null,
|
||||||
@@ -50,7 +53,7 @@ test.describe("PWA", () => {
|
|||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/login");
|
await page.goto("/");
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => navigator.serviceWorker.controller !== null,
|
() => navigator.serviceWorker.controller !== null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
|
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
|
||||||
|
|
||||||
const gotoMock = vi.hoisted(() => vi.fn());
|
// The TOC's "back to map" button switches the active in-game view via
|
||||||
vi.mock("$app/navigation", () => ({
|
// `activeView.select("map")` (the single-URL app-shell has no
|
||||||
goto: gotoMock,
|
// `/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, {
|
import ReportToc, {
|
||||||
@@ -29,13 +33,13 @@ const ENTRIES: readonly TocEntry[] = [
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
gotoMock.mockClear();
|
activeViewSelectMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("report TOC", () => {
|
describe("report TOC", () => {
|
||||||
test("renders one anchor per entry and one option in the mobile select", () => {
|
test("renders one anchor per entry and one option in the mobile select", () => {
|
||||||
const ui = render(ReportToc, {
|
const ui = render(ReportToc, {
|
||||||
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" },
|
props: { entries: ENTRIES, activeSlug: "galaxy-summary" },
|
||||||
});
|
});
|
||||||
for (const e of ENTRIES) {
|
for (const e of ENTRIES) {
|
||||||
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
|
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", () => {
|
test("marks the active anchor with aria-current=location and a class", () => {
|
||||||
const ui = render(ReportToc, {
|
const ui = render(ReportToc, {
|
||||||
props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" },
|
props: { entries: ENTRIES, activeSlug: "bombings" },
|
||||||
});
|
});
|
||||||
const active = ui.getByTestId("report-toc-bombings");
|
const active = ui.getByTestId("report-toc-bombings");
|
||||||
expect(active).toHaveAttribute("aria-current", "location");
|
expect(active).toHaveAttribute("aria-current", "location");
|
||||||
@@ -58,17 +62,16 @@ describe("report TOC", () => {
|
|||||||
expect(inactive).not.toHaveClass("active");
|
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, {
|
const ui = render(ReportToc, {
|
||||||
props: {
|
props: {
|
||||||
entries: ENTRIES,
|
entries: ENTRIES,
|
||||||
activeSlug: "galaxy-summary",
|
activeSlug: "galaxy-summary",
|
||||||
gameId: "abc",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const button = ui.getByTestId("report-back-to-map");
|
const button = ui.getByTestId("report-back-to-map");
|
||||||
await fireEvent.click(button);
|
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 () => {
|
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, {
|
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"));
|
await fireEvent.click(ui.getByTestId("report-toc-bombings"));
|
||||||
expect(scrollSpy).toHaveBeenCalledWith({
|
expect(scrollSpy).toHaveBeenCalledWith({
|
||||||
@@ -132,13 +135,12 @@ describe("report TOC", () => {
|
|||||||
props: {
|
props: {
|
||||||
entries: ENTRIES,
|
entries: ENTRIES,
|
||||||
activeSlug: "galaxy-summary",
|
activeSlug: "galaxy-summary",
|
||||||
gameId: "g",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
||||||
await fireEvent.change(select, { target: { value: "votes" } });
|
await fireEvent.change(select, { target: { value: "votes" } });
|
||||||
expect(scrollSpy).toHaveBeenCalled();
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
expect(gotoMock).not.toHaveBeenCalled();
|
expect(activeViewSelectMock).not.toHaveBeenCalled();
|
||||||
target.remove();
|
target.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,19 +31,15 @@ import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
|||||||
|
|
||||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
const pageMock = vi.hoisted(() => ({
|
// The sciences table opens the science designer by switching the
|
||||||
url: new URL("http://localhost/games/g1/table/sciences"),
|
// active in-game view via `activeView.select("designer-science", …)`
|
||||||
params: { id: "g1" } as Record<string, string>,
|
// (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("$lib/app-nav.svelte", () => ({
|
||||||
|
activeView: { select: activeViewSelectMock },
|
||||||
vi.mock("$app/state", () => ({
|
|
||||||
page: pageMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$app/navigation", () => ({
|
|
||||||
goto: gotoMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import TableSciences from "../src/lib/active-view/table-sciences.svelte";
|
import TableSciences from "../src/lib/active-view/table-sciences.svelte";
|
||||||
@@ -60,8 +56,7 @@ beforeEach(async () => {
|
|||||||
draft = new OrderDraftStore();
|
draft = new OrderDraftStore();
|
||||||
await draft.init({ cache, gameId: GAME_ID });
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
pageMock.params = { id: "g1" };
|
activeViewSelectMock.mockClear();
|
||||||
gotoMock.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -188,14 +183,14 @@ describe("sciences table", () => {
|
|||||||
expect(names).toEqual(["Beta", "Gamma", "Alpha"]);
|
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(
|
const ui = mountTable(
|
||||||
makeReport([science({ name: "FirstStep", drive: 1 })]),
|
makeReport([science({ name: "FirstStep", drive: 1 })]),
|
||||||
);
|
);
|
||||||
await fireEvent.dblClick(ui.getByTestId("sciences-row"));
|
await fireEvent.dblClick(ui.getByTestId("sciences-row"));
|
||||||
expect(gotoMock).toHaveBeenCalledWith(
|
expect(activeViewSelectMock).toHaveBeenCalledWith("designer-science", {
|
||||||
"/games/g1/designer/science/FirstStep",
|
scienceId: "FirstStep",
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete button adds a removeScience to the draft", async () => {
|
test("delete button adds a removeScience to the draft", async () => {
|
||||||
@@ -207,9 +202,9 @@ describe("sciences table", () => {
|
|||||||
expect(cmd.name).toBe("FirstStep");
|
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([]));
|
const ui = mountTable(makeReport([]));
|
||||||
await fireEvent.click(ui.getByTestId("sciences-new"));
|
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