f57a290432
- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games
list, my-applications/invites lists, game-create, application-submit,
invite-redeem/decline. Mirror the matching transcoder pairs and Go
fixture round-trip tests.
- Wire the seven new lobby message types through
gateway/internal/backendclient/{routes,lobby_commands}.go with
per-command REST helpers, JSON-tolerant decoding of backend wire
shapes, and httptest-based unit coverage for success / 4xx / 5xx /
503 across each command.
- Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a
`make fbs-ts` target driving flatc, and the generated bindings under
ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode
now uses these bindings as well, closing the JSON.parse vs
FlatBuffers gap that would have failed against a real local stack.
- Replace the placeholder lobby with five sections (my games, pending
invitations, my applications, public games, create new game) and the
/lobby/create form. Submit-application uses an inline race-name
form on the public-game card; create-game keeps name / description /
turn_schedule / enrollment_ends_at always visible and the rest under
an Advanced toggle with TS-side defaults.
- Update lobby/+page.svelte to throw LobbyError on non-ok result codes;
GalaxyClient.executeCommand now returns { resultCode, payloadBytes }.
- Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page
+ lobby-create component tests, Playwright lobby-flow.spec covering
create / submit / accept across all four projects. Phase 7 e2e was
migrated to the FlatBuffers fixtures and to click+fill against the
Safari-autofill readonly inputs.
- Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into
Phase 7, append the new lobby commands to gateway/README.md and
docs/ARCHITECTURE.md, add ui/docs/lobby.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
// Component tests for the create-game form. 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).
|
|
|
|
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";
|
|
|
|
const gotoSpy = vi.fn<(url: string) => Promise<void>>(async () => {});
|
|
vi.mock("$app/navigation", () => ({
|
|
goto: (url: string) => gotoSpy(url),
|
|
}));
|
|
|
|
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",
|
|
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");
|
|
createGameSpy.mockReset();
|
|
gotoSpy.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/routes/lobby/create/+page.svelte")> {
|
|
return import("../src/routes/lobby/create/+page.svelte");
|
|
}
|
|
|
|
describe("lobby/create page", () => {
|
|
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(gotoSpy).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 /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(gotoSpy).toHaveBeenCalledWith("/lobby");
|
|
expect(createGameSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|