a679d9cdcb
PR-feedback round on #60: - Time-zone field is now a continent-grouped <select> populated from `Intl.supportedValuesOf("timeZone")`, with the browser-detected zone pre-selected when no value is stored. A stored zone the runtime no longer advertises is preserved as an "Other" entry. - Saving the profile no longer kicks the user back to the lobby: the form stays put and shows a transient `saved` notice, cleared on the next edit. Only `cancel` returns to the lobby. - New `lib/account-store.svelte.ts` caches `user.account.get` for the session; lobby + profile share it through `account.ensure()`, so navigating Overview ⇄ Profile no longer flashes the "loading account…" placeholder or fires a second gateway call. Profile save writes through to the store so the shell identity strip picks up the new display name without refetching. Cleared on logout to prevent identity bleed between accounts. - e2e: existing 4 cases adjusted for save-stay; added two new ones for the timezone dropdown and identity-strip stability across navigation. - Docs: `ui/docs/lobby.md` updated to describe the shared cache, the new timezone picker shape, and the save-stay behaviour.
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
// 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, the save-stay flow, and
|
|
// the time-zone dropdown.
|
|
|
|
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;
|
|
accountGetCount: number;
|
|
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 },
|
|
accountGetCount: 0,
|
|
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":
|
|
mocks.accountGetCount += 1;
|
|
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, stays on the form, and refreshes the identity strip", 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();
|
|
|
|
// Form stays on screen; the saved notice surfaces and the
|
|
// shell-level identity strip picks up the new name without a
|
|
// second `user.account.get`.
|
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
|
"Captain",
|
|
);
|
|
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
|
|
expect(mocks.settingsUpdates).toEqual([]);
|
|
|
|
// Editing the form again clears the notice so a follow-up save is
|
|
// unambiguous.
|
|
await page.getByTestId("profile-display-name").fill("Pilot");
|
|
await expect(page.getByTestId("profile-saved-notice")).toHaveCount(0);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("changing the language posts user.settings.update, stays on the form, 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();
|
|
|
|
// Profile stays on screen; the Russian dictionary now drives the
|
|
// form copy. The save button label is the visible signal.
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
await expect(page.getByTestId("profile-save")).toHaveText("сохранить");
|
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
|
expect(mocks.settingsUpdates).toHaveLength(1);
|
|
expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru");
|
|
|
|
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());
|
|
});
|
|
|
|
test("time zone is a continent-grouped <select>; saving an edited zone posts user.settings.update", async ({
|
|
page,
|
|
}) => {
|
|
const mocks = await mockGateway(page, {
|
|
userId: "user-1",
|
|
email: "pilot@example.com",
|
|
userName: "player-abc12345",
|
|
displayName: "Pilot",
|
|
preferredLanguage: "en",
|
|
timeZone: "Europe/London",
|
|
});
|
|
await completeLogin(page);
|
|
await page.getByTestId("lobby-account-name").click();
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
|
|
const select = page.getByTestId("profile-time-zone");
|
|
// The field renders as a <select> with at least the Europe and
|
|
// America optgroups present and the stored zone selected.
|
|
expect(await select.evaluate((el) => el.tagName)).toBe("SELECT");
|
|
const optgroupLabels = await select.evaluate((el) =>
|
|
Array.from((el as HTMLSelectElement).querySelectorAll("optgroup")).map(
|
|
(g) => g.label,
|
|
),
|
|
);
|
|
expect(optgroupLabels).toContain("Europe");
|
|
expect(optgroupLabels).toContain("America");
|
|
await expect(select).toHaveValue("Europe/London");
|
|
|
|
await select.selectOption("America/New_York");
|
|
await page.getByTestId("profile-save").click();
|
|
|
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
|
expect(mocks.settingsUpdates).toEqual([
|
|
{ preferredLanguage: "en", timeZone: "America/New_York" },
|
|
]);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
|
|
test("the identity strip persists across Overview ⇄ Profile without a second user.account.get", 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");
|
|
const firstCount = mocks.accountGetCount;
|
|
expect(firstCount).toBeGreaterThanOrEqual(1);
|
|
|
|
// Navigate Overview → Profile: identity must NOT flash the
|
|
// loading placeholder, and the cache must answer without a
|
|
// second gateway call.
|
|
await page.getByTestId("lobby-nav-profile").click();
|
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
|
|
|
// Navigate back to Overview.
|
|
await page.getByTestId("lobby-nav-overview").click();
|
|
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
|
|
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
|
|
|
expect(mocks.accountGetCount).toBe(firstCount);
|
|
|
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
|
});
|
|
});
|