4e0058d46c
- 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>
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
// 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. 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";
|
|
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 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();
|
|
const listPublicGamesSpy = vi.fn();
|
|
const listMyInvitesSpy = vi.fn();
|
|
const listMyApplicationsSpy = vi.fn();
|
|
const submitApplicationSpy = vi.fn();
|
|
const redeemInviteSpy = vi.fn();
|
|
const declineInviteSpy = vi.fn();
|
|
|
|
vi.mock("../src/api/lobby", async () => {
|
|
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
|
|
"../src/api/lobby",
|
|
);
|
|
return {
|
|
...actual,
|
|
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
|
|
listPublicGames: (...args: unknown[]) => listPublicGamesSpy(...args),
|
|
listMyInvites: (...args: unknown[]) => listMyInvitesSpy(...args),
|
|
listMyApplications: (...args: unknown[]) => listMyApplicationsSpy(...args),
|
|
submitApplication: (...args: unknown[]) => submitApplicationSpy(...args),
|
|
redeemInvite: (...args: unknown[]) => redeemInviteSpy(...args),
|
|
declineInvite: (...args: unknown[]) => declineInviteSpy(...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");
|
|
|
|
listMyGamesSpy.mockReset();
|
|
listPublicGamesSpy.mockReset();
|
|
listMyInvitesSpy.mockReset();
|
|
listMyApplicationsSpy.mockReset();
|
|
submitApplicationSpy.mockReset();
|
|
redeemInviteSpy.mockReset();
|
|
declineInviteSpy.mockReset();
|
|
appScreenGoSpy.mockReset();
|
|
activeViewResetSpy.mockReset();
|
|
activeViewSelectSpy.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 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");
|
|
|
|
function makeGame(id: string, name: string, status = "draft") {
|
|
return {
|
|
gameId: id,
|
|
gameName: name,
|
|
gameType: "private",
|
|
status,
|
|
ownerUserId: "user-1",
|
|
minPlayers: 2,
|
|
maxPlayers: 8,
|
|
enrollmentEndsAt: baseDate,
|
|
createdAt: baseDate,
|
|
updatedAt: baseDate,
|
|
currentTurn: 0,
|
|
};
|
|
}
|
|
|
|
function makePublicGame(id: string, name: string) {
|
|
return {
|
|
gameId: id,
|
|
gameName: name,
|
|
gameType: "public",
|
|
status: "enrollment_open",
|
|
ownerUserId: "",
|
|
minPlayers: 4,
|
|
maxPlayers: 12,
|
|
enrollmentEndsAt: baseDate,
|
|
createdAt: baseDate,
|
|
updatedAt: baseDate,
|
|
currentTurn: 0,
|
|
};
|
|
}
|
|
|
|
function makeInvite(id: string) {
|
|
return {
|
|
inviteId: id,
|
|
gameId: "private-1",
|
|
inviterUserId: "host",
|
|
invitedUserId: "user-1",
|
|
code: "",
|
|
raceName: "Vegan Federation",
|
|
status: "pending",
|
|
createdAt: baseDate,
|
|
expiresAt: baseDate,
|
|
decidedAt: null,
|
|
};
|
|
}
|
|
|
|
function makeApplication(id: string, status: string) {
|
|
return {
|
|
applicationId: id,
|
|
gameId: "public-1",
|
|
applicantUserId: "user-1",
|
|
raceName: "Vegan Federation",
|
|
status,
|
|
createdAt: baseDate,
|
|
decidedAt: status === "pending" ? null : baseDate,
|
|
};
|
|
}
|
|
|
|
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 });
|
|
listMyInvitesSpy.mockResolvedValue([]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() => {
|
|
expect(ui.getByTestId("lobby-my-games-empty")).toBeInTheDocument();
|
|
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
|
|
expect(ui.getByTestId("lobby-applications-empty")).toBeInTheDocument();
|
|
expect(ui.getByTestId("lobby-public-games-empty")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test("renders my-game cards and public-game cards when items are present", async () => {
|
|
listMyGamesSpy.mockResolvedValue([makeGame("private-1", "First Contact")]);
|
|
listPublicGamesSpy.mockResolvedValue({
|
|
items: [makePublicGame("public-1", "Open Lobby")],
|
|
page: 1,
|
|
pageSize: 50,
|
|
total: 1,
|
|
});
|
|
listMyInvitesSpy.mockResolvedValue([]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() => {
|
|
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(1);
|
|
expect(ui.getByText("First Contact")).toBeInTheDocument();
|
|
expect(ui.getByText("Open Lobby")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test("submitting an application opens the inline form and posts race_name", async () => {
|
|
listMyGamesSpy.mockResolvedValue([]);
|
|
listPublicGamesSpy.mockResolvedValue({
|
|
items: [makePublicGame("public-1", "Open Lobby")],
|
|
page: 1,
|
|
pageSize: 50,
|
|
total: 1,
|
|
});
|
|
listMyInvitesSpy.mockResolvedValue([]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
submitApplicationSpy.mockResolvedValue(makeApplication("app-1", "pending"));
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() => {
|
|
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument();
|
|
});
|
|
|
|
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
|
|
|
|
await waitFor(() => {
|
|
expect(ui.getByTestId("lobby-application-form")).toBeInTheDocument();
|
|
});
|
|
|
|
await fireEvent.input(ui.getByTestId("lobby-application-race-name"), {
|
|
target: { value: "Vegan Federation" },
|
|
});
|
|
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
|
|
|
|
await waitFor(() => {
|
|
expect(submitApplicationSpy).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"public-1",
|
|
"Vegan Federation",
|
|
);
|
|
expect(ui.getByTestId("lobby-application-card")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test("submitting an empty race name surfaces a validation error and does not call the API", async () => {
|
|
listMyGamesSpy.mockResolvedValue([]);
|
|
listPublicGamesSpy.mockResolvedValue({
|
|
items: [makePublicGame("public-1", "Open Lobby")],
|
|
page: 1,
|
|
pageSize: 50,
|
|
total: 1,
|
|
});
|
|
listMyInvitesSpy.mockResolvedValue([]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() =>
|
|
expect(ui.getByTestId("lobby-public-game-apply")).toBeInTheDocument(),
|
|
);
|
|
await fireEvent.click(ui.getByTestId("lobby-public-game-apply"));
|
|
await fireEvent.click(ui.getByTestId("lobby-application-submit"));
|
|
|
|
await waitFor(() => {
|
|
expect(ui.getByTestId("lobby-application-error")).toBeInTheDocument();
|
|
expect(submitApplicationSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
test("accepting an invitation calls redeemInvite and removes the card", async () => {
|
|
listMyGamesSpy.mockResolvedValue([]);
|
|
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
|
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-1")]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
redeemInviteSpy.mockResolvedValue(makeInvite("invite-1"));
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() =>
|
|
expect(ui.getByTestId("lobby-invite-accept")).toBeInTheDocument(),
|
|
);
|
|
|
|
await fireEvent.click(ui.getByTestId("lobby-invite-accept"));
|
|
|
|
await waitFor(() => {
|
|
expect(redeemInviteSpy).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"private-1",
|
|
"invite-1",
|
|
);
|
|
expect(ui.queryByTestId("lobby-invite-accept")).not.toBeInTheDocument();
|
|
expect(ui.getByTestId("lobby-invitations-empty")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test("declining an invitation calls declineInvite and removes the card", async () => {
|
|
listMyGamesSpy.mockResolvedValue([]);
|
|
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
|
listMyInvitesSpy.mockResolvedValue([makeInvite("invite-2")]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
declineInviteSpy.mockResolvedValue({ ...makeInvite("invite-2"), status: "declined" });
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() =>
|
|
expect(ui.getByTestId("lobby-invite-decline")).toBeInTheDocument(),
|
|
);
|
|
|
|
await fireEvent.click(ui.getByTestId("lobby-invite-decline"));
|
|
|
|
await waitFor(() => {
|
|
expect(declineInviteSpy).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"private-1",
|
|
"invite-2",
|
|
);
|
|
expect(ui.queryByTestId("lobby-invite-decline")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test("my-game cards are clickable for running/paused/finished and disabled otherwise", async () => {
|
|
// Cover the live-able statuses (running, paused, finished) and a
|
|
// representative non-playable mix (cancelled is the post-shutdown
|
|
// terminal state developers see most often; draft is the lobby-
|
|
// internal state before any membership exists).
|
|
listMyGamesSpy.mockResolvedValue([
|
|
makeGame("g-running", "Live", "running"),
|
|
makeGame("g-paused", "Paused Run", "paused"),
|
|
makeGame("g-finished", "Closed Run", "finished"),
|
|
makeGame("g-cancelled", "Cancelled Run", "cancelled"),
|
|
makeGame("g-draft", "Draft Run", "draft"),
|
|
]);
|
|
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
|
listMyInvitesSpy.mockResolvedValue([]);
|
|
listMyApplicationsSpy.mockResolvedValue([]);
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() => {
|
|
expect(ui.getAllByTestId("lobby-my-game-card").length).toBe(5);
|
|
});
|
|
const cards = ui.getAllByTestId("lobby-my-game-card");
|
|
const disabledByLabel: Record<string, boolean> = {};
|
|
for (const card of cards) {
|
|
const label = card.querySelector("strong")?.textContent ?? "";
|
|
disabledByLabel[label] = (card as HTMLButtonElement).disabled;
|
|
}
|
|
expect(disabledByLabel["Live"]).toBe(false);
|
|
expect(disabledByLabel["Paused Run"]).toBe(false);
|
|
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 () => {
|
|
listMyGamesSpy.mockResolvedValue([]);
|
|
listPublicGamesSpy.mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
|
listMyInvitesSpy.mockResolvedValue([]);
|
|
listMyApplicationsSpy.mockResolvedValue([
|
|
makeApplication("app-1", "pending"),
|
|
makeApplication("app-2", "approved"),
|
|
]);
|
|
|
|
const Page = (await importLobbyPage()).default;
|
|
const ui = render(Page);
|
|
|
|
await waitFor(() => {
|
|
const cards = ui.getAllByTestId("lobby-application-card");
|
|
expect(cards.length).toBe(2);
|
|
expect(cards[0]!.querySelector(".status")?.textContent?.trim()).toBe("pending");
|
|
expect(cards[1]!.querySelector(".status")?.textContent?.trim()).toBe("approved");
|
|
});
|
|
});
|
|
});
|