fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m30s
Tests · UI / test (pull_request) Successful in 2m49s

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:
Ilia Denisov
2026-05-26 22:38:14 +02:00
parent 2ecdecad1e
commit a679d9cdcb
10 changed files with 453 additions and 84 deletions
+33 -14
View File
@@ -16,11 +16,23 @@ the right-hand column. The shell uses `var(--font-mono)` so the
post-login pages adopt the "nerdy" type stack that the public site
already uses.
The identity strip renders the caller's `display_name` (falling back
to the immutable `user_name` handle, then to a loading placeholder
while `user.account.get` resolves) as a `data-testid="lobby-account-name"`
button. Clicking it switches the top-level screen to `profile`
(`appScreen.go("profile")`); the e2e suites use that testid as their
The identity strip reads the caller's account from
`lib/account-store.svelte.ts` — a session-wide cache that fetches
`user.account.get` once on first access and is written through after
every Profile save. Both `lobby-screen.svelte` and
`profile-screen.svelte` populate the same cache through
`account.ensure(client)`, so switching Overview ⇄ Profile never
re-issues `user.account.get` and the strip never flashes the
`lobby.account_loading` placeholder mid-navigation. The cache is
cleared by `session.signOut("user")` / `signOut("revoked")` so a
different user signing in on the same browser does not briefly see
the previous identity.
The strip falls back to `display_name` → immutable `user_name`
`lobby.account_loading` while the first `ensure(...)` resolves. It
renders as a `data-testid="lobby-account-name"` button; clicking it
switches the top-level screen to `profile`
(`appScreen.go("profile")`). The e2e suites use that testid as their
lobby-loaded signal. The logout button sits next to it
(`session.signOut("user")`).
@@ -49,22 +61,29 @@ same as the create screen — pushing a fresh history entry on entry,
falling back to lobby on Back/Forward (see
[`navigation.md`](navigation.md)).
On mount it issues `user.account.get` through `src/api/account.ts`
and renders an identity read-out (immutable `user_name`, `email`)
plus a three-field form:
On mount it reads the caller's account through `account.ensure(...)`
(see [Shell](#shell)) — the first visit issues `user.account.get`,
subsequent visits resolve from the session-wide cache without a
gateway round-trip. The form renders an identity read-out (immutable
`user_name`, `email`) plus three editable fields:
| Field | Endpoint | Notes |
| --------------------- | --------------------- | -------------------------------------------------------------- |
| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). |
| `preferred_language` | `user.settings.update`| `<select>` over `SUPPORTED_LOCALES`; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it. |
| `time_zone` | `user.settings.update`| Free-text IANA name. Placeholder shows the browser's current zone; backend validates with `time.LoadLocation`. |
| `time_zone` | `user.settings.update`| `<select>` of every IANA zone the browser knows (`Intl.supportedValuesOf("timeZone")`), grouped by leading slash segment (Africa / America / …; singletons like `UTC` collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to `Intl.DateTimeFormat().resolvedOptions().timeZone`. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack `supportedValuesOf` fall back to a free-text input; the backend validates with `time.LoadLocation` in every shape. |
Save fires `user.profile.update` and/or `user.settings.update`
conditionally on which fields actually changed, then returns to the
lobby (`appScreen.go("lobby")`). When the saved
`preferred_language` is one the UI also ships translations for, the
active i18n locale switches in-place so the rest of the session
matches the new preference.
conditionally on which fields actually changed, then **stays on the
profile** and surfaces a transient `profile-saved-notice` line
(`data-testid="profile-saved-notice"`). Editing any field clears the
notice. Only the explicit `cancel` button navigates back to the lobby
(`appScreen.go("lobby")`). When the saved `preferred_language` is one
the UI also ships translations for, the active i18n locale switches
in-place so the rest of the session matches the new preference. The
write-through is also pushed into the shared `account` store so the
shell identity strip picks up the new `display_name` without a second
`user.account.get`.
`GameSummary` carries a `current_turn` field that the lobby UI does
not display directly — the in-game shell reads it from the same
@@ -0,0 +1,76 @@
// `AccountStore` is the session-wide cache for the caller's
// `user.account.get` aggregate. The lobby shell and every post-login
// screen read the identity (display name, immutable user_name, time
// zone, …) from the same rune, so navigating between Overview and
// Profile does not refetch and does not flash the
// `lobby.account_loading` placeholder.
//
// `ensure(client)` fetches once on first call, dedupes concurrent
// callers onto a single in-flight promise, and resolves immediately
// from the cache thereafter. `set(account)` is the write-through
// path used by Profile after `user.profile.update` /
// `user.settings.update` succeeds — both the shell and the screen
// pick up the change without an extra round-trip. `clear()` resets
// the cache on logout so a different user signing in on the same
// browser does not briefly see the previous identity.
//
// The store is intentionally narrow: it caches one struct, never
// retries on failure (the caller decides), and exposes no error
// state of its own. Callers that need a tighter error surface (the
// Profile form) catch the rejection from `ensure(client)` directly.
import type { GalaxyClient } from "../api/galaxy-client";
import { getMyAccount, type Account } from "../api/account";
class AccountStore {
current: Account | null = $state(null);
#inFlight: Promise<Account> | null = null;
/**
* ensure returns the cached `Account` when present, otherwise issues
* `user.account.get` through the supplied client and caches the
* result. Concurrent callers during the first fetch share the same
* in-flight promise so the gateway only sees one request per
* session.
*/
ensure(client: GalaxyClient): Promise<Account> {
if (this.current !== null) {
return Promise.resolve(this.current);
}
if (this.#inFlight !== null) {
return this.#inFlight;
}
const pending = getMyAccount(client)
.then((account) => {
this.current = account;
return account;
})
.finally(() => {
this.#inFlight = null;
});
this.#inFlight = pending;
return pending;
}
/**
* set replaces the cached `Account` with the supplied value. Used
* by the Profile screen after a successful save so both the form
* and the shell identity strip pick up the new fields without a
* second round-trip.
*/
set(next: Account): void {
this.current = next;
}
/**
* clear resets the cache. Called on logout so a different user
* signing in on the same browser does not briefly see the
* previous identity through the rune.
*/
clear(): void {
this.current = null;
this.#inFlight = null;
}
}
export const account = new AccountStore();
+2 -1
View File
@@ -111,9 +111,10 @@ const en = {
"profile.field.preferred_language": "preferred language",
"profile.field.time_zone": "time zone",
"profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.",
"profile.hint.time_zone": "IANA time-zone name (e.g. Europe/Moscow, America/New_York). The placeholder shows your browser's current zone.",
"profile.hint.time_zone": "IANA zones grouped by continent. The form opens on your browser's current zone when no value is saved.",
"profile.save": "save",
"profile.saving": "saving…",
"profile.saved": "saved",
"profile.cancel": "cancel",
"profile.error.language_required": "language must not be empty",
"profile.error.time_zone_required": "time zone must not be empty",
+2 -1
View File
@@ -112,9 +112,10 @@ const ru: Record<keyof typeof en, string> = {
"profile.field.preferred_language": "язык интерфейса",
"profile.field.time_zone": "часовой пояс",
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
"profile.hint.time_zone": "имя часового пояса IANA (например, Europe/Moscow, America/New_York). В подсказке — текущий пояс твоего браузера.",
"profile.hint.time_zone": "пояса IANA, сгруппированные по континентам. Если сохранённого значения нет, форма открывается на поясе, который определил браузер.",
"profile.save": "сохранить",
"profile.saving": "сохраняем…",
"profile.saved": "сохранено",
"profile.cancel": "отмена",
"profile.error.language_required": "язык не должен быть пустым",
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
@@ -17,7 +17,7 @@
type GameSummary,
type InviteSummary,
} from "../../api/lobby";
import { AccountError, getMyAccount } from "../../api/account";
import { account } from "$lib/account-store.svelte";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
@@ -28,8 +28,6 @@
import { session } from "$lib/session-store.svelte";
import LobbyShell from "./lobby-shell.svelte";
let displayName = $state("");
let userName = $state("");
let configError: string | null = $state(null);
let listsLoading = $state(true);
let lobbyError: string | null = $state(null);
@@ -158,21 +156,6 @@
}
}
async function loadIdentity(c: GalaxyClient): Promise<void> {
try {
const account = await getMyAccount(c);
displayName = account.displayName;
userName = account.userName;
} catch (err) {
if (err instanceof AccountError) {
// Stay quiet: the lobby still works without a name; the
// identity strip falls back to a loading placeholder.
return;
}
throw err;
}
}
function gotoCreate(): void {
appScreen.go("lobby-create");
}
@@ -250,7 +233,11 @@
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
loadIdentity(client).catch(() => {});
// Populate the session-wide identity cache; the shell's
// identity strip reads from there. Swallowed errors leave
// the shell on the `lobby.account_loading` placeholder
// without breaking the rest of the lobby.
account.ensure(client).catch(() => {});
await refreshAll();
} catch (err) {
lobbyError = describeLobbyError(err);
@@ -259,7 +246,7 @@
});
</script>
<LobbyShell activePage="overview" {displayName} {userName}>
<LobbyShell activePage="overview">
{#if configError !== null}
<p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null}
+13 -6
View File
@@ -4,23 +4,27 @@ landing and the editable profile. Renders a left page-list sidebar
(mirroring the project site's VitePress layout) plus a top identity
strip ("Player-xxxx" → opens profile, logout). Children fill the
right-hand column. Pages mark themselves active via `activePage`.
The identity strip reads directly from the session-wide `account`
store so navigating Overview ⇄ Profile never re-renders an empty
placeholder: both screens populate the same cache through
`account.ensure(client)` and the shell renders the latest value.
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
type Page = "overview" | "profile";
interface Props {
activePage: Page;
displayName: string;
userName: string;
children: Snippet;
}
let { activePage, displayName, userName, children }: Props = $props();
let { activePage, children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
@@ -28,9 +32,12 @@ right-hand column. Pages mark themselves active via `activePage`.
];
let identityLabel = $derived.by(() => {
const trimmed = displayName.trim();
if (trimmed.length > 0) return trimmed;
if (userName.length > 0) return userName;
const current = account.current;
if (current !== null) {
const trimmed = current.displayName.trim();
if (trimmed.length > 0) return trimmed;
if (current.userName.length > 0) return current.userName;
}
return i18n.t("lobby.account_loading");
});
@@ -3,7 +3,10 @@
// `lobby-create`). Loads the caller's account aggregate, lets the
// user edit `display_name`, `preferred_language`, and `time_zone`,
// and posts the changes through `user.profile.update` /
// `user.settings.update`. Returns to the lobby on save or cancel.
// `user.settings.update`. The form stays on screen after a
// successful save (the shell-level identity strip picks up the
// new value through the shared `account` store) — only `cancel`
// returns to the lobby.
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
@@ -11,7 +14,6 @@
import { GalaxyClient } from "../../api/galaxy-client";
import {
AccountError,
getMyAccount,
updateMyProfile,
updateMySettings,
type Account,
@@ -25,6 +27,13 @@
} from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
import {
browserTimeZone,
supportedTimeZones,
withPreservedValue,
type TimeZoneGroup,
} from "$lib/time-zones";
import LobbyShell from "./lobby-shell.svelte";
let loaded: Account | null = $state(null);
@@ -36,6 +45,7 @@
let configError: string | null = $state(null);
let saveError: string | null = $state(null);
let saving = $state(false);
let savedNotice = $state(false);
let client: GalaxyClient | null = null;
@@ -43,6 +53,16 @@
SUPPORTED_LOCALES.map((entry) => entry.code),
);
// Built once: the IANA list is static for the page lifetime. The
// stored value is folded in lazily so a zone the runtime no longer
// advertises still renders.
const TIME_ZONE_GROUPS_BASE: readonly TimeZoneGroup[] = supportedTimeZones();
let timeZoneGroups = $derived<readonly TimeZoneGroup[]>(
withPreservedValue(TIME_ZONE_GROUPS_BASE, timeZoneInput),
);
let timeZoneFallbackToText = $derived(TIME_ZONE_GROUPS_BASE.length === 0);
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
@@ -58,24 +78,26 @@
return err instanceof Error ? err.message : "request failed";
}
function browserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
} catch {
return "";
}
function applyAccount(next: Account): void {
loaded = next;
displayNameInput = next.displayName;
preferredLanguageInput = next.preferredLanguage;
// Seed an empty stored zone with the browser's current zone so
// the picker lands on a sensible default rather than the first
// IANA entry. The form treats "no change" as not posting, so
// the seeded value is only persisted on an explicit save.
timeZoneInput = next.timeZone.length > 0 ? next.timeZone : browserTimeZone();
}
function applyAccount(account: Account): void {
loaded = account;
displayNameInput = account.displayName;
preferredLanguageInput = account.preferredLanguage;
timeZoneInput = account.timeZone;
function markDirty(): void {
// Any edit invalidates the "Saved" notice.
savedNotice = false;
saveError = null;
}
async function loadAccount(c: GalaxyClient): Promise<void> {
try {
applyAccount(await getMyAccount(c));
applyAccount(await account.ensure(c));
} catch (err) {
loadError = describe(err);
}
@@ -97,6 +119,7 @@
}
saving = true;
saveError = null;
savedNotice = false;
try {
let next: Account = loaded;
if (trimmedDisplay !== loaded.displayName) {
@@ -109,6 +132,7 @@
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
}
applyAccount(next);
account.set(next);
// When the user picks a language the UI supports, switch the
// active locale immediately so the rest of the session sees
// the change without a reload. Unsupported BCP 47 codes are
@@ -116,7 +140,7 @@
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
i18n.setLocale(next.preferredLanguage as Locale);
}
appScreen.go("lobby");
savedNotice = true;
} catch (err) {
saveError = describe(err);
} finally {
@@ -157,11 +181,7 @@
});
</script>
<LobbyShell
activePage="profile"
displayName={loaded?.displayName ?? ""}
userName={loaded?.userName ?? ""}
>
<LobbyShell activePage="profile">
<h1>{i18n.t("profile.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="profile-config-error">{configError}</p>
@@ -183,6 +203,7 @@
<input
type="text"
bind:value={displayNameInput}
oninput={markDirty}
autocomplete="nickname"
data-testid="profile-display-name"
/>
@@ -193,6 +214,7 @@
<span>{i18n.t("profile.field.preferred_language")}</span>
<select
bind:value={preferredLanguageInput}
onchange={markDirty}
data-testid="profile-preferred-language"
>
{#each SUPPORTED_LOCALES as entry (entry.code)}
@@ -212,18 +234,44 @@
<label>
<span>{i18n.t("profile.field.time_zone")}</span>
<input
type="text"
bind:value={timeZoneInput}
placeholder={browserTimeZone()}
autocomplete="off"
data-testid="profile-time-zone"
/>
{#if timeZoneFallbackToText}
<!--
Browser lacks `Intl.supportedValuesOf("timeZone")` —
fall back to a free-text field so a viable runtime can
still save a zone. The backend remains the validator.
-->
<input
type="text"
bind:value={timeZoneInput}
oninput={markDirty}
placeholder={browserTimeZone()}
autocomplete="off"
data-testid="profile-time-zone"
/>
{:else}
<select
bind:value={timeZoneInput}
onchange={markDirty}
data-testid="profile-time-zone"
>
{#each timeZoneGroups as group (group.label)}
<optgroup label={group.label}>
{#each group.values as zone (zone)}
<option value={zone}>{zone}</option>
{/each}
</optgroup>
{/each}
</select>
{/if}
<small>{i18n.t("profile.hint.time_zone")}</small>
</label>
{#if saveError !== null}
<p role="alert" data-testid="profile-save-error">{saveError}</p>
{:else if savedNotice}
<p role="status" data-testid="profile-saved-notice">
{i18n.t("profile.saved")}
</p>
{/if}
<div class="actions">
@@ -303,6 +351,12 @@
font-size: var(--text-xs);
}
[data-testid="profile-saved-notice"] {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin: 0;
}
.actions {
display: flex;
gap: var(--space-3);
@@ -31,6 +31,7 @@ import {
loadDeviceSession,
setDeviceSessionId,
} from "../api/session";
import { account } from "./account-store.svelte";
export type SessionStatus =
| "loading"
@@ -94,6 +95,10 @@ export class SessionStore {
this.keypair = fresh.keypair;
this.deviceSessionId = null;
this.status = "anonymous";
// Drop the cached identity so a different user signing in on the
// same browser does not briefly see the previous display name
// through the post-login shell.
account.clear();
if (reason === "revoked") {
console.info("session store: device session revoked by gateway");
}
+140
View File
@@ -0,0 +1,140 @@
// Time-zone option helpers for the Profile screen's `<select>`.
//
// The browser ships the full IANA list through
// `Intl.supportedValuesOf("timeZone")` (Chrome 99+, Firefox 93+,
// Safari 15.4+ — all within the PWA target). This module reads that
// list, groups the entries by their first slash-delimited segment
// (`Africa`, `America`, …), sorts both groups and entries within each
// group, and yields a shape that maps 1:1 onto `<optgroup>` /
// `<option>`.
//
// Two corner cases:
// * Singletons like `UTC` / `GMT` / `EST` have no slash, so they
// collapse into a single "Other" bucket at the bottom of the
// dropdown.
// * A stored value that is not in the browser-supplied list (an
// older zone the runtime no longer ships, or a name from a
// freshly-imported account) is appended as a one-entry "Other"
// option through `withPreservedValue`. The Profile form calls
// that helper so saving an unchanged form never silently
// downgrades a stored value to the default.
const OTHER_GROUP = "Other";
export interface TimeZoneGroup {
readonly label: string;
readonly values: readonly string[];
}
/**
* supportedTimeZones returns the browser-supplied IANA list, grouped
* by leading segment and sorted alphabetically. Returns an empty
* array when the runtime does not implement
* `Intl.supportedValuesOf("timeZone")` so callers can fall back to a
* text input.
*/
export function supportedTimeZones(): readonly TimeZoneGroup[] {
const zones = listSupportedZones();
if (zones.length === 0) return [];
return groupZones(zones);
}
/**
* withPreservedValue returns `groups` unchanged when the supplied
* `value` is empty or already appears in one of the groups.
* Otherwise it appends a single-entry "Other" group carrying the
* value so the `<select>` can render it without losing the saved
* zone. The original groups are not mutated.
*/
export function withPreservedValue(
groups: readonly TimeZoneGroup[],
value: string,
): readonly TimeZoneGroup[] {
const trimmed = value.trim();
if (trimmed === "") return groups;
for (const group of groups) {
if (group.values.includes(trimmed)) return groups;
}
const extra: TimeZoneGroup = { label: OTHER_GROUP, values: [trimmed] };
// Merge with an existing "Other" group if one is already present,
// otherwise append a fresh one.
const next: TimeZoneGroup[] = [];
let mergedIntoOther = false;
for (const group of groups) {
if (group.label === OTHER_GROUP) {
mergedIntoOther = true;
next.push({
label: OTHER_GROUP,
values: [...group.values, trimmed].sort((a, b) => a.localeCompare(b)),
});
} else {
next.push(group);
}
}
if (!mergedIntoOther) next.push(extra);
return next;
}
/**
* browserTimeZone returns the time zone the runtime believes the
* user is in. An empty string is returned when `Intl.DateTimeFormat`
* is missing or rejects the resolution.
*/
export function browserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
} catch {
return "";
}
}
interface IntlWithSupportedValues {
supportedValuesOf?: (key: "timeZone") => string[];
}
function listSupportedZones(): string[] {
const intl = Intl as unknown as IntlWithSupportedValues;
if (typeof intl.supportedValuesOf !== "function") return [];
try {
const zones = intl.supportedValuesOf("timeZone");
return Array.isArray(zones) ? zones.slice() : [];
} catch {
return [];
}
}
function groupZones(zones: readonly string[]): readonly TimeZoneGroup[] {
const buckets = new Map<string, string[]>();
const others: string[] = [];
for (const zone of zones) {
const slash = zone.indexOf("/");
if (slash === -1) {
others.push(zone);
continue;
}
const prefix = zone.slice(0, slash);
const bucket = buckets.get(prefix);
if (bucket === undefined) {
buckets.set(prefix, [zone]);
} else {
bucket.push(zone);
}
}
const groups: TimeZoneGroup[] = [];
const sortedPrefixes = Array.from(buckets.keys()).sort((a, b) =>
a.localeCompare(b),
);
for (const prefix of sortedPrefixes) {
const values = (buckets.get(prefix) ?? []).slice().sort((a, b) =>
a.localeCompare(b),
);
groups.push({ label: prefix, values });
}
if (others.length > 0) {
groups.push({
label: OTHER_GROUP,
values: others.slice().sort((a, b) => a.localeCompare(b)),
});
}
return groups;
}
+94 -15
View File
@@ -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());
});
});