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