Files
galaxy-game/ui/frontend/tests/lobby-api.test.ts
T
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

338 lines
13 KiB
TypeScript

// Unit tests for the typed lobby.ts wrappers. They invoke the
// wrappers against a minimal stub of `GalaxyClient.executeCommand`
// that captures the message type and FlatBuffers request payload,
// then returns a forged FlatBuffers response payload built with the
// generated TS bindings. No network, no signing — the test confirms
// the encoder/decoder shape matches the gateway contract and that
// non-`ok` result codes are surfaced as a `LobbyError`.
import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test, vi } from "vitest";
import {
LobbyError,
createGame,
declineInvite,
listMyApplications,
listMyGames,
listMyInvites,
listPublicGames,
redeemInvite,
submitApplication,
} from "../src/api/lobby";
import {
ApplicationSubmitResponse,
ApplicationSummary,
ErrorBody,
ErrorResponse,
GameCreateResponse,
GameSummary,
InviteDeclineResponse,
InviteRedeemResponse,
InviteSummary,
MyApplicationsListResponse,
MyGamesListResponse,
MyInvitesListResponse,
PublicGamesListResponse,
} from "../src/proto/galaxy/fbs/lobby";
import type { GalaxyClient } from "../src/api/galaxy-client";
import {
GameCreateRequest,
PublicGamesListRequest,
ApplicationSubmitRequest,
InviteRedeemRequest,
InviteDeclineRequest,
} from "../src/proto/galaxy/fbs/lobby";
interface Captured {
messageType: string;
payload: Uint8Array;
}
function makeStub(
respondWith: (c: Captured) => { resultCode?: string; payloadBytes: Uint8Array },
): {
client: GalaxyClient;
captured: Captured[];
} {
const captured: Captured[] = [];
const stub = {
executeCommand: vi.fn(async (messageType: string, payload: Uint8Array) => {
const c = { messageType, payload };
captured.push(c);
const result = respondWith(c);
return {
resultCode: result.resultCode ?? "ok",
payloadBytes: result.payloadBytes,
};
}),
} as unknown as GalaxyClient;
return { client: stub, captured };
}
function encodeGameSummary(builder: Builder, currentTurn: number = 0): number {
const gameId = builder.createString("g-1");
const gameName = builder.createString("Test Game");
const gameType = builder.createString("private");
const status = builder.createString("draft");
const ownerUserId = builder.createString("user-1");
GameSummary.startGameSummary(builder);
GameSummary.addGameId(builder, gameId);
GameSummary.addGameName(builder, gameName);
GameSummary.addGameType(builder, gameType);
GameSummary.addStatus(builder, status);
GameSummary.addOwnerUserId(builder, ownerUserId);
GameSummary.addMinPlayers(builder, 2);
GameSummary.addMaxPlayers(builder, 8);
GameSummary.addEnrollmentEndsAtMs(builder, 1_780_000_000_000n);
GameSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
GameSummary.addUpdatedAtMs(builder, 1_770_000_000_000n);
GameSummary.addCurrentTurn(builder, currentTurn);
return GameSummary.endGameSummary(builder);
}
function encodeApplicationSummary(builder: Builder, status: string): number {
const applicationId = builder.createString("app-1");
const gameId = builder.createString("g-1");
const applicantUserId = builder.createString("user-1");
const raceName = builder.createString("Vegan Federation");
const statusOff = builder.createString(status);
ApplicationSummary.startApplicationSummary(builder);
ApplicationSummary.addApplicationId(builder, applicationId);
ApplicationSummary.addGameId(builder, gameId);
ApplicationSummary.addApplicantUserId(builder, applicantUserId);
ApplicationSummary.addRaceName(builder, raceName);
ApplicationSummary.addStatus(builder, statusOff);
ApplicationSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
ApplicationSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n);
return ApplicationSummary.endApplicationSummary(builder);
}
function encodeInviteSummary(builder: Builder, status: string): number {
const inviteId = builder.createString("invite-1");
const gameId = builder.createString("g-1");
const inviter = builder.createString("user-host");
const invited = builder.createString("user-1");
const code = builder.createString("");
const race = builder.createString("Vegan Federation");
const statusOff = builder.createString(status);
InviteSummary.startInviteSummary(builder);
InviteSummary.addInviteId(builder, inviteId);
InviteSummary.addGameId(builder, gameId);
InviteSummary.addInviterUserId(builder, inviter);
InviteSummary.addInvitedUserId(builder, invited);
InviteSummary.addCode(builder, code);
InviteSummary.addRaceName(builder, race);
InviteSummary.addStatus(builder, statusOff);
InviteSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
InviteSummary.addExpiresAtMs(builder, 1_780_000_000_000n);
InviteSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n);
return InviteSummary.endInviteSummary(builder);
}
describe("lobby.ts wrappers", () => {
test("listMyGames decodes the response and reports the message type", async () => {
const { client, captured } = makeStub(() => {
const builder = new Builder(256);
const item = encodeGameSummary(builder, 5);
const items = MyGamesListResponse.createItemsVector(builder, [item]);
MyGamesListResponse.startMyGamesListResponse(builder);
MyGamesListResponse.addItems(builder, items);
builder.finish(MyGamesListResponse.endMyGamesListResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const games = await listMyGames(client);
expect(captured[0]!.messageType).toBe("lobby.my.games.list");
expect(games.length).toBe(1);
expect(games[0]!.gameId).toBe("g-1");
expect(games[0]!.minPlayers).toBe(2);
expect(games[0]!.currentTurn).toBe(5);
});
test("listPublicGames passes pagination and decodes pageSize/total", async () => {
const { client, captured } = makeStub(() => {
const builder = new Builder(256);
const item = encodeGameSummary(builder);
const items = PublicGamesListResponse.createItemsVector(builder, [item]);
PublicGamesListResponse.startPublicGamesListResponse(builder);
PublicGamesListResponse.addItems(builder, items);
PublicGamesListResponse.addPage(builder, 2);
PublicGamesListResponse.addPageSize(builder, 25);
PublicGamesListResponse.addTotal(builder, 51);
builder.finish(PublicGamesListResponse.endPublicGamesListResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const page = await listPublicGames(client, { page: 2, pageSize: 25 });
expect(captured[0]!.messageType).toBe("lobby.public.games.list");
const decodedRequest = PublicGamesListRequest.getRootAsPublicGamesListRequest(
new ByteBuffer(captured[0]!.payload),
);
expect(decodedRequest.page()).toBe(2);
expect(decodedRequest.pageSize()).toBe(25);
expect(page.items.length).toBe(1);
expect(page.page).toBe(2);
expect(page.pageSize).toBe(25);
expect(page.total).toBe(51);
});
test("listMyApplications decodes pending and decided records", async () => {
const { client } = makeStub(() => {
const builder = new Builder(256);
const pending = encodeApplicationSummary(builder, "pending");
const approved = encodeApplicationSummary(builder, "approved");
const items = MyApplicationsListResponse.createItemsVector(builder, [pending, approved]);
MyApplicationsListResponse.startMyApplicationsListResponse(builder);
MyApplicationsListResponse.addItems(builder, items);
builder.finish(MyApplicationsListResponse.endMyApplicationsListResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const applications = await listMyApplications(client);
expect(applications.length).toBe(2);
expect(applications[0]!.status).toBe("pending");
expect(applications[0]!.decidedAt).toBeNull();
expect(applications[1]!.status).toBe("approved");
expect(applications[1]!.decidedAt).not.toBeNull();
});
test("listMyInvites decodes user-bound invites", async () => {
const { client } = makeStub(() => {
const builder = new Builder(256);
const invite = encodeInviteSummary(builder, "pending");
const items = MyInvitesListResponse.createItemsVector(builder, [invite]);
MyInvitesListResponse.startMyInvitesListResponse(builder);
MyInvitesListResponse.addItems(builder, items);
builder.finish(MyInvitesListResponse.endMyInvitesListResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const invites = await listMyInvites(client);
expect(invites.length).toBe(1);
expect(invites[0]!.invitedUserId).toBe("user-1");
expect(invites[0]!.status).toBe("pending");
expect(invites[0]!.decidedAt).toBeNull();
});
test("createGame encodes every field and decodes the returned summary", async () => {
const { client, captured } = makeStub(() => {
const builder = new Builder(256);
const game = encodeGameSummary(builder);
GameCreateResponse.startGameCreateResponse(builder);
GameCreateResponse.addGame(builder, game);
builder.finish(GameCreateResponse.endGameCreateResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const enrollment = new Date(1_780_000_000_000);
const result = await createGame(client, {
gameName: "First Contact",
description: "",
minPlayers: 2,
maxPlayers: 8,
startGapHours: 24,
startGapPlayers: 2,
enrollmentEndsAt: enrollment,
turnSchedule: "0 0 * * *",
targetEngineVersion: "v1",
});
expect(captured[0]!.messageType).toBe("lobby.game.create");
const request = GameCreateRequest.getRootAsGameCreateRequest(
new ByteBuffer(captured[0]!.payload),
);
expect(request.gameName()).toBe("First Contact");
expect(request.turnSchedule()).toBe("0 0 * * *");
expect(request.targetEngineVersion()).toBe("v1");
expect(request.minPlayers()).toBe(2);
expect(request.maxPlayers()).toBe(8);
expect(request.enrollmentEndsAtMs()).toBe(BigInt(enrollment.getTime()));
expect(result.gameId).toBe("g-1");
});
test("submitApplication encodes game_id and race_name", async () => {
const { client, captured } = makeStub(() => {
const builder = new Builder(128);
const app = encodeApplicationSummary(builder, "pending");
ApplicationSubmitResponse.startApplicationSubmitResponse(builder);
ApplicationSubmitResponse.addApplication(builder, app);
builder.finish(ApplicationSubmitResponse.endApplicationSubmitResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const submitted = await submitApplication(client, "public-1", "Vegan Federation");
expect(captured[0]!.messageType).toBe("lobby.application.submit");
const decoded = ApplicationSubmitRequest.getRootAsApplicationSubmitRequest(
new ByteBuffer(captured[0]!.payload),
);
expect(decoded.gameId()).toBe("public-1");
expect(decoded.raceName()).toBe("Vegan Federation");
expect(submitted.applicationId).toBe("app-1");
});
test("redeemInvite and declineInvite hit their respective message types", async () => {
const stubRedeem = makeStub(() => {
const builder = new Builder(128);
const invite = encodeInviteSummary(builder, "accepted");
InviteRedeemResponse.startInviteRedeemResponse(builder);
InviteRedeemResponse.addInvite(builder, invite);
builder.finish(InviteRedeemResponse.endInviteRedeemResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const redeemed = await redeemInvite(stubRedeem.client, "private-1", "invite-1");
expect(stubRedeem.captured[0]!.messageType).toBe("lobby.invite.redeem");
const redeemReq = InviteRedeemRequest.getRootAsInviteRedeemRequest(
new ByteBuffer(stubRedeem.captured[0]!.payload),
);
expect(redeemReq.gameId()).toBe("private-1");
expect(redeemReq.inviteId()).toBe("invite-1");
expect(redeemed.status).toBe("accepted");
const stubDecline = makeStub(() => {
const builder = new Builder(128);
const invite = encodeInviteSummary(builder, "declined");
InviteDeclineResponse.startInviteDeclineResponse(builder);
InviteDeclineResponse.addInvite(builder, invite);
builder.finish(InviteDeclineResponse.endInviteDeclineResponse(builder));
return { payloadBytes: builder.asUint8Array() };
});
const declined = await declineInvite(stubDecline.client, "private-1", "invite-1");
expect(stubDecline.captured[0]!.messageType).toBe("lobby.invite.decline");
const declineReq = InviteDeclineRequest.getRootAsInviteDeclineRequest(
new ByteBuffer(stubDecline.captured[0]!.payload),
);
expect(declineReq.gameId()).toBe("private-1");
expect(declineReq.inviteId()).toBe("invite-1");
expect(declined.status).toBe("declined");
});
test("non-ok result codes are surfaced as a LobbyError with code and message", async () => {
const { client } = makeStub(() => {
const builder = new Builder(128);
const code = builder.createString("conflict");
const message = builder.createString("game is not in enrollment_open");
ErrorBody.startErrorBody(builder);
ErrorBody.addCode(builder, code);
ErrorBody.addMessage(builder, message);
const errorOff = ErrorBody.endErrorBody(builder);
ErrorResponse.startErrorResponse(builder);
ErrorResponse.addError(builder, errorOff);
builder.finish(ErrorResponse.endErrorResponse(builder));
return { resultCode: "conflict", payloadBytes: builder.asUint8Array() };
});
await expect(submitApplication(client, "public-1", "race")).rejects.toThrow(LobbyError);
try {
await submitApplication(client, "public-1", "race");
} catch (err) {
const lobbyError = err as LobbyError;
expect(lobbyError.code).toBe("conflict");
expect(lobbyError.message).toBe("game is not in enrollment_open");
expect(lobbyError.resultCode).toBe("conflict");
}
});
});