Files
galaxy-game/ui/frontend/tests/lobby-page.test.ts
Ilia Denisov ce7a66b3e6 ui/phase-11: map wired to live game state
Replaces the Phase 10 map stub with live planet rendering driven by
`user.games.report`, and wires the header turn counter to the same
data. Phase 11's frontend sits on a per-game `GameStateStore` that
lives in `lib/game-state.svelte.ts`: the in-game shell layout
instantiates one per game, exposes it through Svelte context, and
disposes it on remount. The store discovers the game's current turn
through `lobby.my.games.list`, fetches the matching report, and
exposes a TS-friendly snapshot to the header turn counter, the map
view, and the inspector / order / calculator tabs that later phases
will plug onto the same instance.

The pipeline forced one cross-stage decision: the user surface needs
the current turn number to know which report to fetch, but
`GameSummary` did not expose it. Phase 11 extends the lobby
catalogue (FB schema, transcoder, Go model, backend
gameSummaryWire, gateway decoders, openapi, TS bindings,
api/lobby.ts) with `current_turn:int32`. The data was already
tracked in backend's `RuntimeSnapshot.CurrentTurn`; surfacing it is
a wire change only. Two alternatives were rejected: a brand-new
`user.games.state` message (full wire-flow for one field) and
hard-coding `turn=0` (works for the dev sandbox, which never
advances past zero, but renders the initial state for any real
game). The change crosses Phase 8's already-shipped catalogue per
the project's "decisions baked back into the live plan" rule —
existing tests and fixtures are updated in the same patch.

The state binding lives in `map/state-binding.ts::reportToWorld`:
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours,
fill alphas, and point radii so the user can tell them apart at a
glance. The planet engine number is reused as the primitive id so
a hit-test result resolves directly to a planet without an extra
lookup table. Zero-planet reports yield a well-formed empty world;
malformed dimensions fall back to 1×1 so a bad report cannot crash
the renderer.

The map view's mount effect creates the renderer once and skips
re-mount on no-op refreshes (same turn, same wrap mode); a turn
change or wrap-mode flip disposes and recreates it. The renderer's
external API does not yet expose `setWorld`; Phase 24 / 34 will
extract it once high-frequency updates land. The store installs a
`visibilitychange` listener that calls `refresh()` when the tab
regains focus.

Wrap-mode preference uses `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, default `torus`. Phase 11 reads through
`store.wrapMode`; Phase 29 wires the toggle UI on top of
`setWrapMode`.

Tests: Vitest unit coverage for `reportToWorld` (every kind,
ids, styling, empty / zero-dimension edges, priority order) and
for the store lifecycle (init success, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence across
instances, `failBootstrap`). Playwright e2e mocks the gateway for
`lobby.my.games.list` and `user.games.report` and asserts the
live data path: turn counter shows the reported turn,
`active-view-map` flips to `data-status="ready"`, and
`data-planet-count` matches the fixture count. The zero-planet
regression and the missing-membership error path are covered.

Phase 11 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:17:17 +02:00

399 lines
12 KiB
TypeScript

// Component tests for the Phase 8 lobby page. 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.
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";
vi.mock("$app/navigation", () => ({
goto: vi.fn(async () => {}),
}));
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",
GATEWAY_RESPONSE_PUBLIC_KEY: new Uint8Array(32).fill(0x55),
}));
vi.mock("../src/api/connect", () => ({
createEdgeGatewayClient: 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();
});
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/routes/lobby/+page.svelte")> {
return import("../src/routes/lobby/+page.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 page", () => {
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);
});
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");
});
});
});