feat(ui): lobby site-style sidebar + profile screen (#47)
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome: page-list sidebar (Overview/Profile) and a top "Player-xxxx" identity strip mirroring the project site's monospace look. - Strip the legacy `lobby.title`, device-session-id `<code>`, and `lobby.greeting` paragraph; the identity strip both names the user and opens the profile editor. - Add a top-level `profile` AppScreen with a three-field form (`display_name`, `preferred_language`, `time_zone`) backed by a new `src/api/account.ts` wrapper around `user.account.get`, `user.profile.update`, and `user.settings.update`. Saving switches the active i18n locale in-place when the new preferred language is one the UI ships translations for. - Update e2e fixture + auth-flow / lobby-flow specs to use the new `lobby-account-name` testid and wait for the loaded identity before releasing pending `SubscribeEvents` (webkit revocation race). New `profile-screen.spec.ts` covers navigation, edit-save, and cancel. - Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new layout. Closes #47
This commit is contained in:
@@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
|
||||
await page.getByTestId("login-code-input").click();
|
||||
await page.getByTestId("login-code-input").fill("123456");
|
||||
await page.getByTestId("login-code-submit").click();
|
||||
// Sign-in switches the in-memory screen to the lobby; the device
|
||||
// session id surfaces only on the lobby screen.
|
||||
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
||||
// Sign-in switches the in-memory screen to the lobby; the identity
|
||||
// strip rendered by `lobby-shell.svelte` is the lobby-loaded signal.
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Phase 7 — auth flow", () => {
|
||||
@@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => {
|
||||
}) => {
|
||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||
"dev-test-1",
|
||||
);
|
||||
await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
@@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => {
|
||||
}) => {
|
||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
// The restored session re-renders the lobby screen directly (no
|
||||
// `/lobby` route to land on).
|
||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||
"dev-test-1",
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||
"Pilot",
|
||||
);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
@@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => {
|
||||
}) => {
|
||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||
// `lobby-account-name` becomes visible on lobby mount with the
|
||||
// "loading account…" placeholder before the gateway responds.
|
||||
// Wait for the loaded name to settle so the event-stream effect
|
||||
// has had a chance to issue its `SubscribeEvents` request — the
|
||||
// release below targets that pending stream, and an empty
|
||||
// `pendingSubscribes` list defeats the test.
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||
"Pilot",
|
||||
);
|
||||
|
||||
// Fire all pending SubscribeEvents requests with an empty 200
|
||||
// response. Connect-Web's server-streaming reader sees no frames
|
||||
|
||||
@@ -215,6 +215,9 @@ export interface AccountFixture {
|
||||
email: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
preferredLanguage?: string;
|
||||
timeZone?: string;
|
||||
declaredCountry?: string;
|
||||
}
|
||||
|
||||
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
|
||||
@@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array
|
||||
const email = builder.createString(account.email);
|
||||
const userName = builder.createString(account.userName);
|
||||
const displayName = builder.createString(account.displayName);
|
||||
const preferredLanguage = builder.createString("en");
|
||||
const timeZone = builder.createString("UTC");
|
||||
const declaredCountry = builder.createString("");
|
||||
const preferredLanguage = builder.createString(account.preferredLanguage ?? "en");
|
||||
const timeZone = builder.createString(account.timeZone ?? "UTC");
|
||||
const declaredCountry = builder.createString(account.declaredCountry ?? "");
|
||||
AccountView.startAccountView(builder);
|
||||
AccountView.addUserId(builder, userId);
|
||||
AccountView.addEmail(builder, email);
|
||||
|
||||
@@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise<void> {
|
||||
await page.getByTestId("login-code-input").fill("123456");
|
||||
await page.getByTestId("login-code-submit").click();
|
||||
// Sign-in switches the in-memory screen to the lobby.
|
||||
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Phase 8 — lobby flow", () => {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the
|
||||
// lobby boots with an account aggregate, then exercises the sidebar
|
||||
// navigation into the profile, the edit form, and the save round-trip
|
||||
// against the FlatBuffers-decoded `user.profile.update` /
|
||||
// `user.settings.update` payloads.
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
|
||||
import {
|
||||
UpdateMyProfileRequest,
|
||||
UpdateMySettingsRequest,
|
||||
} from "../../src/proto/galaxy/fbs/user";
|
||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||
import {
|
||||
buildAccountResponsePayload,
|
||||
buildMyApplicationsListPayload,
|
||||
buildMyGamesListPayload,
|
||||
buildMyInvitesListPayload,
|
||||
buildPublicGamesListPayload,
|
||||
type AccountFixture,
|
||||
} from "./fixtures/lobby-fbs";
|
||||
|
||||
interface ProfileMocks {
|
||||
pendingSubscribes: Array<() => void>;
|
||||
account: AccountFixture;
|
||||
profileUpdates: Array<{ displayName: string }>;
|
||||
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
|
||||
}
|
||||
|
||||
async function mockGateway(
|
||||
page: Page,
|
||||
initial: AccountFixture,
|
||||
): Promise<ProfileMocks> {
|
||||
const mocks: ProfileMocks = {
|
||||
pendingSubscribes: [],
|
||||
account: { ...initial },
|
||||
profileUpdates: [],
|
||||
settingsUpdates: [],
|
||||
};
|
||||
|
||||
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ challenge_id: "ch-test-1" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ device_session_id: "dev-test-1" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
|
||||
const reqText = route.request().postData();
|
||||
if (reqText === null) {
|
||||
await route.fulfill({ status: 400 });
|
||||
return;
|
||||
}
|
||||
const req = fromJson(
|
||||
ExecuteCommandRequestSchema,
|
||||
JSON.parse(reqText) as JsonValue,
|
||||
);
|
||||
let payload: Uint8Array;
|
||||
switch (req.messageType) {
|
||||
case "user.account.get":
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
case "user.profile.update": {
|
||||
const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
const next = decoded.displayName() ?? "";
|
||||
mocks.profileUpdates.push({ displayName: next });
|
||||
mocks.account = { ...mocks.account, displayName: next };
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
}
|
||||
case "user.settings.update": {
|
||||
const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
const preferredLanguage = decoded.preferredLanguage() ?? "";
|
||||
const timeZone = decoded.timeZone() ?? "";
|
||||
mocks.settingsUpdates.push({ preferredLanguage, timeZone });
|
||||
mocks.account = { ...mocks.account, preferredLanguage, timeZone };
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
}
|
||||
case "lobby.my.games.list":
|
||||
payload = buildMyGamesListPayload([]);
|
||||
break;
|
||||
case "lobby.public.games.list":
|
||||
payload = buildPublicGamesListPayload([]);
|
||||
break;
|
||||
case "lobby.my.invites.list":
|
||||
payload = buildMyInvitesListPayload([]);
|
||||
break;
|
||||
case "lobby.my.applications.list":
|
||||
payload = buildMyApplicationsListPayload([]);
|
||||
break;
|
||||
default:
|
||||
payload = new Uint8Array();
|
||||
}
|
||||
const responseJson = await forgeExecuteCommandResponseJson({
|
||||
requestId: req.requestId,
|
||||
timestampMs: BigInt(Date.now()),
|
||||
resultCode: "ok",
|
||||
payloadBytes: payload,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: responseJson,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
|
||||
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
|
||||
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
|
||||
});
|
||||
if (action === "abort") {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
const body = new TextEncoder().encode("{}");
|
||||
const frame = new Uint8Array(5 + body.length);
|
||||
frame[0] = 0x02;
|
||||
new DataView(frame.buffer).setUint32(1, body.length, false);
|
||||
frame.set(body, 5);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/connect+json",
|
||||
body: Buffer.from(frame),
|
||||
});
|
||||
});
|
||||
|
||||
return mocks;
|
||||
}
|
||||
|
||||
async function completeLogin(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("login-email-input")).toBeVisible();
|
||||
await page.getByTestId("login-email-input").click();
|
||||
await page.getByTestId("login-email-input").fill("pilot@example.com");
|
||||
await page.getByTestId("login-email-submit").click();
|
||||
await expect(page.getByTestId("login-code-input")).toBeVisible();
|
||||
await page.getByTestId("login-code-input").click();
|
||||
await page.getByTestId("login-code-input").fill("123456");
|
||||
await page.getByTestId("login-code-submit").click();
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("F8-04 — profile screen", () => {
|
||||
test("clicking the identity strip opens the profile and renders the form", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
||||
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot");
|
||||
await expect(page.getByTestId("profile-identity")).toContainText(
|
||||
"player-abc12345",
|
||||
);
|
||||
await expect(page.getByTestId("profile-identity")).toContainText(
|
||||
"pilot@example.com",
|
||||
);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("saving an edited display name posts user.profile.update and returns to lobby", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
|
||||
await page.getByTestId("profile-display-name").fill("Captain");
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||
"Captain",
|
||||
);
|
||||
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
|
||||
expect(mocks.settingsUpdates).toEqual([]);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("changing the language posts user.settings.update and switches the active locale", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
|
||||
await page.getByTestId("profile-preferred-language").selectOption("ru");
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
// The lobby switches to the Russian dictionary after the save —
|
||||
// the "create new game" button label is the visible signal.
|
||||
await expect(page.getByTestId("lobby-create-button")).toHaveText(
|
||||
"создать новую игру",
|
||||
);
|
||||
expect(mocks.settingsUpdates).toEqual([
|
||||
{ preferredLanguage: "ru", timeZone: "UTC" },
|
||||
]);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("cancel returns to the lobby without posting anything", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
|
||||
await page.getByTestId("profile-display-name").fill("ignored");
|
||||
await page.getByTestId("profile-cancel").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
|
||||
expect(mocks.profileUpdates).toEqual([]);
|
||||
expect(mocks.settingsUpdates).toEqual([]);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user