Files
galaxy-game/ui/frontend/tests/lobby-create.test.ts
T
Ilia Denisov 4e0058d46c 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>
2026-05-23 20:49:35 +02:00

206 lines
6.2 KiB
TypeScript

// 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). 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";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import type { IDBPDatabase } from "idb";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
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";
// 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();
vi.mock("../src/api/lobby", async () => {
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
"../src/api/lobby",
);
return {
...actual,
createGame: (...args: unknown[]) => createGameSpy(...args),
};
});
vi.mock("../src/lib/env", () => ({
GATEWAY_BASE_URL: "http://gateway.test",
gatewayRpcBaseUrl: () => "http://gateway.test/rpc",
GATEWAY_RESPONSE_PUBLIC_KEY: new Uint8Array(32).fill(0x55),
}));
vi.mock("../src/api/connect", () => ({
createGatewayClient: vi.fn(() => ({})),
}));
vi.mock("../src/api/galaxy-client", () => {
class FakeGalaxyClient {
executeCommand = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: new Uint8Array(),
}));
}
return { GalaxyClient: FakeGalaxyClient };
});
vi.mock("../src/platform/core/index", () => ({
loadCore: async () => ({
signRequest: () => new Uint8Array(),
verifyResponse: () => true,
verifyEvent: () => true,
verifyPayloadHash: () => true,
}),
}));
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
beforeEach(async () => {
dbName = `galaxy-ui-test-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
const store = {
keyStore: new WebCryptoKeyStore(db),
cache: new IDBCache(db),
};
session.resetForTests();
session.setStoreLoaderForTests(async () => store);
await session.init();
await session.signIn("device-1");
i18n.resetForTests("en");
createGameSpy.mockReset();
appScreenGoSpy.mockReset();
});
afterEach(async () => {
session.resetForTests();
i18n.resetForTests("en");
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
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 screen", () => {
test("submitting a valid form invokes createGame with the entered values and navigates back", async () => {
createGameSpy.mockResolvedValue({
gameId: "private-new",
gameName: "First Contact",
gameType: "private",
status: "draft",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const Page = (await importCreatePage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-create-form")).toBeInTheDocument(),
);
await fireEvent.input(ui.getByTestId("lobby-create-game-name"), {
target: { value: "First Contact" },
});
await fireEvent.input(ui.getByTestId("lobby-create-description"), {
target: { value: "" },
});
await fireEvent.input(ui.getByTestId("lobby-create-turn-schedule"), {
target: { value: "0 0 * * *" },
});
await fireEvent.input(ui.getByTestId("lobby-create-enrollment-ends-at"), {
target: { value: "2026-06-01T12:00" },
});
await fireEvent.click(ui.getByTestId("lobby-create-submit"));
await waitFor(() => {
expect(createGameSpy).toHaveBeenCalledTimes(1);
const call = createGameSpy.mock.calls[0]!;
const input = call[1] as Record<string, unknown>;
expect(input.gameName).toBe("First Contact");
expect(input.turnSchedule).toBe("0 0 * * *");
expect(input.minPlayers).toBe(2);
expect(input.maxPlayers).toBe(8);
expect(input.startGapHours).toBe(24);
expect(input.startGapPlayers).toBe(2);
expect(input.targetEngineVersion).toBe("v1");
expect(input.enrollmentEndsAt).toBeInstanceOf(Date);
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
});
});
test("submitting with an empty game name surfaces a validation error and does not call the API", async () => {
const Page = (await importCreatePage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-create-form")).toBeInTheDocument(),
);
// turn_schedule starts populated with the default; clear game_name to trigger the error
await fireEvent.input(ui.getByTestId("lobby-create-game-name"), {
target: { value: " " },
});
await fireEvent.input(ui.getByTestId("lobby-create-enrollment-ends-at"), {
target: { value: "2026-06-01T12:00" },
});
await fireEvent.click(ui.getByTestId("lobby-create-submit"));
await waitFor(() => {
expect(ui.getByTestId("lobby-create-error")).toHaveTextContent(
"game name must not be empty",
);
expect(createGameSpy).not.toHaveBeenCalled();
});
});
test("cancel button navigates back to the lobby without calling the API", async () => {
const Page = (await importCreatePage()).default;
const ui = render(Page);
await waitFor(() =>
expect(ui.getByTestId("lobby-create-cancel")).toBeInTheDocument(),
);
await fireEvent.click(ui.getByTestId("lobby-create-cancel"));
await waitFor(() => {
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
expect(createGameSpy).not.toHaveBeenCalled();
});
});
});