fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
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.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
// 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.
|
||||
// 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";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
interface ProfileMocks {
|
||||
pendingSubscribes: Array<() => void>;
|
||||
account: AccountFixture;
|
||||
accountGetCount: number;
|
||||
profileUpdates: Array<{ displayName: string }>;
|
||||
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ async function mockGateway(
|
||||
const mocks: ProfileMocks = {
|
||||
pendingSubscribes: [],
|
||||
account: { ...initial },
|
||||
accountGetCount: 0,
|
||||
profileUpdates: [],
|
||||
settingsUpdates: [],
|
||||
};
|
||||
@@ -68,6 +69,7 @@ async function mockGateway(
|
||||
let payload: Uint8Array;
|
||||
switch (req.messageType) {
|
||||
case "user.account.get":
|
||||
mocks.accountGetCount += 1;
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
case "user.profile.update": {
|
||||
@@ -181,7 +183,7 @@ test.describe("F8-04 — profile screen", () => {
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("saving an edited display name posts user.profile.update and returns to lobby", async ({
|
||||
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, {
|
||||
@@ -197,17 +199,26 @@ test.describe("F8-04 — profile screen", () => {
|
||||
await page.getByTestId("profile-display-name").fill("Captain");
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
|
||||
// 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 and switches the active locale", async ({
|
||||
test("changing the language posts user.settings.update, stays on the form, and switches the active locale", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
@@ -222,15 +233,13 @@ test.describe("F8-04 — profile screen", () => {
|
||||
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" },
|
||||
]);
|
||||
// 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());
|
||||
});
|
||||
@@ -257,4 +266,74 @@ test.describe("F8-04 — profile screen", () => {
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user