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:
+33
-14
@@ -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
|
post-login pages adopt the "nerdy" type stack that the public site
|
||||||
already uses.
|
already uses.
|
||||||
|
|
||||||
The identity strip renders the caller's `display_name` (falling back
|
The identity strip reads the caller's account from
|
||||||
to the immutable `user_name` handle, then to a loading placeholder
|
`lib/account-store.svelte.ts` — a session-wide cache that fetches
|
||||||
while `user.account.get` resolves) as a `data-testid="lobby-account-name"`
|
`user.account.get` once on first access and is written through after
|
||||||
button. Clicking it switches the top-level screen to `profile`
|
every Profile save. Both `lobby-screen.svelte` and
|
||||||
(`appScreen.go("profile")`); the e2e suites use that testid as their
|
`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
|
lobby-loaded signal. The logout button sits next to it
|
||||||
(`session.signOut("user")`).
|
(`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
|
falling back to lobby on Back/Forward (see
|
||||||
[`navigation.md`](navigation.md)).
|
[`navigation.md`](navigation.md)).
|
||||||
|
|
||||||
On mount it issues `user.account.get` through `src/api/account.ts`
|
On mount it reads the caller's account through `account.ensure(...)`
|
||||||
and renders an identity read-out (immutable `user_name`, `email`)
|
(see [Shell](#shell)) — the first visit issues `user.account.get`,
|
||||||
plus a three-field form:
|
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 |
|
| Field | Endpoint | Notes |
|
||||||
| --------------------- | --------------------- | -------------------------------------------------------------- |
|
| --------------------- | --------------------- | -------------------------------------------------------------- |
|
||||||
| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). |
|
| `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. |
|
| `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`
|
Save fires `user.profile.update` and/or `user.settings.update`
|
||||||
conditionally on which fields actually changed, then returns to the
|
conditionally on which fields actually changed, then **stays on the
|
||||||
lobby (`appScreen.go("lobby")`). When the saved
|
profile** and surfaces a transient `profile-saved-notice` line
|
||||||
`preferred_language` is one the UI also ships translations for, the
|
(`data-testid="profile-saved-notice"`). Editing any field clears the
|
||||||
active i18n locale switches in-place so the rest of the session
|
notice. Only the explicit `cancel` button navigates back to the lobby
|
||||||
matches the new preference.
|
(`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
|
`GameSummary` carries a `current_turn` field that the lobby UI does
|
||||||
not display directly — the in-game shell reads it from the same
|
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();
|
||||||
@@ -111,9 +111,10 @@ const en = {
|
|||||||
"profile.field.preferred_language": "preferred language",
|
"profile.field.preferred_language": "preferred language",
|
||||||
"profile.field.time_zone": "time zone",
|
"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.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.save": "save",
|
||||||
"profile.saving": "saving…",
|
"profile.saving": "saving…",
|
||||||
|
"profile.saved": "saved",
|
||||||
"profile.cancel": "cancel",
|
"profile.cancel": "cancel",
|
||||||
"profile.error.language_required": "language must not be empty",
|
"profile.error.language_required": "language must not be empty",
|
||||||
"profile.error.time_zone_required": "time zone must not be empty",
|
"profile.error.time_zone_required": "time zone must not be empty",
|
||||||
|
|||||||
@@ -112,9 +112,10 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"profile.field.preferred_language": "язык интерфейса",
|
"profile.field.preferred_language": "язык интерфейса",
|
||||||
"profile.field.time_zone": "часовой пояс",
|
"profile.field.time_zone": "часовой пояс",
|
||||||
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
|
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
|
||||||
"profile.hint.time_zone": "имя часового пояса IANA (например, Europe/Moscow, America/New_York). В подсказке — текущий пояс твоего браузера.",
|
"profile.hint.time_zone": "пояса IANA, сгруппированные по континентам. Если сохранённого значения нет, форма открывается на поясе, который определил браузер.",
|
||||||
"profile.save": "сохранить",
|
"profile.save": "сохранить",
|
||||||
"profile.saving": "сохраняем…",
|
"profile.saving": "сохраняем…",
|
||||||
|
"profile.saved": "сохранено",
|
||||||
"profile.cancel": "отмена",
|
"profile.cancel": "отмена",
|
||||||
"profile.error.language_required": "язык не должен быть пустым",
|
"profile.error.language_required": "язык не должен быть пустым",
|
||||||
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
|
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
type GameSummary,
|
type GameSummary,
|
||||||
type InviteSummary,
|
type InviteSummary,
|
||||||
} from "../../api/lobby";
|
} 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 { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
import {
|
import {
|
||||||
SyntheticReportError,
|
SyntheticReportError,
|
||||||
@@ -28,8 +28,6 @@
|
|||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import LobbyShell from "./lobby-shell.svelte";
|
import LobbyShell from "./lobby-shell.svelte";
|
||||||
|
|
||||||
let displayName = $state("");
|
|
||||||
let userName = $state("");
|
|
||||||
let configError: string | null = $state(null);
|
let configError: string | null = $state(null);
|
||||||
let listsLoading = $state(true);
|
let listsLoading = $state(true);
|
||||||
let lobbyError: string | null = $state(null);
|
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 {
|
function gotoCreate(): void {
|
||||||
appScreen.go("lobby-create");
|
appScreen.go("lobby-create");
|
||||||
}
|
}
|
||||||
@@ -250,7 +233,11 @@
|
|||||||
deviceSessionId: session.deviceSessionId,
|
deviceSessionId: session.deviceSessionId,
|
||||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
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();
|
await refreshAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lobbyError = describeLobbyError(err);
|
lobbyError = describeLobbyError(err);
|
||||||
@@ -259,7 +246,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LobbyShell activePage="overview" {displayName} {userName}>
|
<LobbyShell activePage="overview">
|
||||||
{#if configError !== null}
|
{#if configError !== null}
|
||||||
<p role="alert" data-testid="account-error">{configError}</p>
|
<p role="alert" data-testid="account-error">{configError}</p>
|
||||||
{:else if lobbyError !== null}
|
{:else if lobbyError !== null}
|
||||||
|
|||||||
@@ -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
|
(mirroring the project site's VitePress layout) plus a top identity
|
||||||
strip ("Player-xxxx" → opens profile, logout). Children fill the
|
strip ("Player-xxxx" → opens profile, logout). Children fill the
|
||||||
right-hand column. Pages mark themselves active via `activePage`.
|
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">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { appScreen } from "$lib/app-nav.svelte";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
|
import { account } from "$lib/account-store.svelte";
|
||||||
|
|
||||||
type Page = "overview" | "profile";
|
type Page = "overview" | "profile";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activePage: Page;
|
activePage: Page;
|
||||||
displayName: string;
|
|
||||||
userName: string;
|
|
||||||
children: Snippet;
|
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" }> = [
|
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
|
||||||
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
|
{ 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(() => {
|
let identityLabel = $derived.by(() => {
|
||||||
const trimmed = displayName.trim();
|
const current = account.current;
|
||||||
|
if (current !== null) {
|
||||||
|
const trimmed = current.displayName.trim();
|
||||||
if (trimmed.length > 0) return trimmed;
|
if (trimmed.length > 0) return trimmed;
|
||||||
if (userName.length > 0) return userName;
|
if (current.userName.length > 0) return current.userName;
|
||||||
|
}
|
||||||
return i18n.t("lobby.account_loading");
|
return i18n.t("lobby.account_loading");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
// `lobby-create`). Loads the caller's account aggregate, lets the
|
// `lobby-create`). Loads the caller's account aggregate, lets the
|
||||||
// user edit `display_name`, `preferred_language`, and `time_zone`,
|
// user edit `display_name`, `preferred_language`, and `time_zone`,
|
||||||
// and posts the changes through `user.profile.update` /
|
// 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 { onMount } from "svelte";
|
||||||
import { appScreen } from "$lib/app-nav.svelte";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
@@ -11,7 +14,6 @@
|
|||||||
import { GalaxyClient } from "../../api/galaxy-client";
|
import { GalaxyClient } from "../../api/galaxy-client";
|
||||||
import {
|
import {
|
||||||
AccountError,
|
AccountError,
|
||||||
getMyAccount,
|
|
||||||
updateMyProfile,
|
updateMyProfile,
|
||||||
updateMySettings,
|
updateMySettings,
|
||||||
type Account,
|
type Account,
|
||||||
@@ -25,6 +27,13 @@
|
|||||||
} from "$lib/i18n/index.svelte";
|
} from "$lib/i18n/index.svelte";
|
||||||
import { loadCore } from "../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { session } from "$lib/session-store.svelte";
|
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";
|
import LobbyShell from "./lobby-shell.svelte";
|
||||||
|
|
||||||
let loaded: Account | null = $state(null);
|
let loaded: Account | null = $state(null);
|
||||||
@@ -36,6 +45,7 @@
|
|||||||
let configError: string | null = $state(null);
|
let configError: string | null = $state(null);
|
||||||
let saveError: string | null = $state(null);
|
let saveError: string | null = $state(null);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let savedNotice = $state(false);
|
||||||
|
|
||||||
let client: GalaxyClient | null = null;
|
let client: GalaxyClient | null = null;
|
||||||
|
|
||||||
@@ -43,6 +53,16 @@
|
|||||||
SUPPORTED_LOCALES.map((entry) => entry.code),
|
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> {
|
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||||
return new Uint8Array(digest);
|
return new Uint8Array(digest);
|
||||||
@@ -58,24 +78,26 @@
|
|||||||
return err instanceof Error ? err.message : "request failed";
|
return err instanceof Error ? err.message : "request failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserTimeZone(): string {
|
function applyAccount(next: Account): void {
|
||||||
try {
|
loaded = next;
|
||||||
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
displayNameInput = next.displayName;
|
||||||
} catch {
|
preferredLanguageInput = next.preferredLanguage;
|
||||||
return "";
|
// 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 {
|
function markDirty(): void {
|
||||||
loaded = account;
|
// Any edit invalidates the "Saved" notice.
|
||||||
displayNameInput = account.displayName;
|
savedNotice = false;
|
||||||
preferredLanguageInput = account.preferredLanguage;
|
saveError = null;
|
||||||
timeZoneInput = account.timeZone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAccount(c: GalaxyClient): Promise<void> {
|
async function loadAccount(c: GalaxyClient): Promise<void> {
|
||||||
try {
|
try {
|
||||||
applyAccount(await getMyAccount(c));
|
applyAccount(await account.ensure(c));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loadError = describe(err);
|
loadError = describe(err);
|
||||||
}
|
}
|
||||||
@@ -97,6 +119,7 @@
|
|||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
|
savedNotice = false;
|
||||||
try {
|
try {
|
||||||
let next: Account = loaded;
|
let next: Account = loaded;
|
||||||
if (trimmedDisplay !== loaded.displayName) {
|
if (trimmedDisplay !== loaded.displayName) {
|
||||||
@@ -109,6 +132,7 @@
|
|||||||
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
|
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
|
||||||
}
|
}
|
||||||
applyAccount(next);
|
applyAccount(next);
|
||||||
|
account.set(next);
|
||||||
// When the user picks a language the UI supports, switch the
|
// When the user picks a language the UI supports, switch the
|
||||||
// active locale immediately so the rest of the session sees
|
// active locale immediately so the rest of the session sees
|
||||||
// the change without a reload. Unsupported BCP 47 codes are
|
// the change without a reload. Unsupported BCP 47 codes are
|
||||||
@@ -116,7 +140,7 @@
|
|||||||
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
|
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
|
||||||
i18n.setLocale(next.preferredLanguage as Locale);
|
i18n.setLocale(next.preferredLanguage as Locale);
|
||||||
}
|
}
|
||||||
appScreen.go("lobby");
|
savedNotice = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
saveError = describe(err);
|
saveError = describe(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,11 +181,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LobbyShell
|
<LobbyShell activePage="profile">
|
||||||
activePage="profile"
|
|
||||||
displayName={loaded?.displayName ?? ""}
|
|
||||||
userName={loaded?.userName ?? ""}
|
|
||||||
>
|
|
||||||
<h1>{i18n.t("profile.title")}</h1>
|
<h1>{i18n.t("profile.title")}</h1>
|
||||||
{#if configError !== null}
|
{#if configError !== null}
|
||||||
<p role="alert" data-testid="profile-config-error">{configError}</p>
|
<p role="alert" data-testid="profile-config-error">{configError}</p>
|
||||||
@@ -183,6 +203,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={displayNameInput}
|
bind:value={displayNameInput}
|
||||||
|
oninput={markDirty}
|
||||||
autocomplete="nickname"
|
autocomplete="nickname"
|
||||||
data-testid="profile-display-name"
|
data-testid="profile-display-name"
|
||||||
/>
|
/>
|
||||||
@@ -193,6 +214,7 @@
|
|||||||
<span>{i18n.t("profile.field.preferred_language")}</span>
|
<span>{i18n.t("profile.field.preferred_language")}</span>
|
||||||
<select
|
<select
|
||||||
bind:value={preferredLanguageInput}
|
bind:value={preferredLanguageInput}
|
||||||
|
onchange={markDirty}
|
||||||
data-testid="profile-preferred-language"
|
data-testid="profile-preferred-language"
|
||||||
>
|
>
|
||||||
{#each SUPPORTED_LOCALES as entry (entry.code)}
|
{#each SUPPORTED_LOCALES as entry (entry.code)}
|
||||||
@@ -212,18 +234,44 @@
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>{i18n.t("profile.field.time_zone")}</span>
|
<span>{i18n.t("profile.field.time_zone")}</span>
|
||||||
|
{#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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={timeZoneInput}
|
bind:value={timeZoneInput}
|
||||||
|
oninput={markDirty}
|
||||||
placeholder={browserTimeZone()}
|
placeholder={browserTimeZone()}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
data-testid="profile-time-zone"
|
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>
|
<small>{i18n.t("profile.hint.time_zone")}</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if saveError !== null}
|
{#if saveError !== null}
|
||||||
<p role="alert" data-testid="profile-save-error">{saveError}</p>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -303,6 +351,12 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-testid="profile-saved-notice"] {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
loadDeviceSession,
|
loadDeviceSession,
|
||||||
setDeviceSessionId,
|
setDeviceSessionId,
|
||||||
} from "../api/session";
|
} from "../api/session";
|
||||||
|
import { account } from "./account-store.svelte";
|
||||||
|
|
||||||
export type SessionStatus =
|
export type SessionStatus =
|
||||||
| "loading"
|
| "loading"
|
||||||
@@ -94,6 +95,10 @@ export class SessionStore {
|
|||||||
this.keypair = fresh.keypair;
|
this.keypair = fresh.keypair;
|
||||||
this.deviceSessionId = null;
|
this.deviceSessionId = null;
|
||||||
this.status = "anonymous";
|
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") {
|
if (reason === "revoked") {
|
||||||
console.info("session store: device session revoked by gateway");
|
console.info("session store: device session revoked by gateway");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the
|
// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the
|
||||||
// lobby boots with an account aggregate, then exercises the sidebar
|
// lobby boots with an account aggregate, then exercises the sidebar
|
||||||
// navigation into the profile, the edit form, and the save round-trip
|
// navigation into the profile, the edit form, the save-stay flow, and
|
||||||
// against the FlatBuffers-decoded `user.profile.update` /
|
// the time-zone dropdown.
|
||||||
// `user.settings.update` payloads.
|
|
||||||
|
|
||||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||||
import { ByteBuffer } from "flatbuffers";
|
import { ByteBuffer } from "flatbuffers";
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
interface ProfileMocks {
|
interface ProfileMocks {
|
||||||
pendingSubscribes: Array<() => void>;
|
pendingSubscribes: Array<() => void>;
|
||||||
account: AccountFixture;
|
account: AccountFixture;
|
||||||
|
accountGetCount: number;
|
||||||
profileUpdates: Array<{ displayName: string }>;
|
profileUpdates: Array<{ displayName: string }>;
|
||||||
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
|
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,7 @@ async function mockGateway(
|
|||||||
const mocks: ProfileMocks = {
|
const mocks: ProfileMocks = {
|
||||||
pendingSubscribes: [],
|
pendingSubscribes: [],
|
||||||
account: { ...initial },
|
account: { ...initial },
|
||||||
|
accountGetCount: 0,
|
||||||
profileUpdates: [],
|
profileUpdates: [],
|
||||||
settingsUpdates: [],
|
settingsUpdates: [],
|
||||||
};
|
};
|
||||||
@@ -68,6 +69,7 @@ async function mockGateway(
|
|||||||
let payload: Uint8Array;
|
let payload: Uint8Array;
|
||||||
switch (req.messageType) {
|
switch (req.messageType) {
|
||||||
case "user.account.get":
|
case "user.account.get":
|
||||||
|
mocks.accountGetCount += 1;
|
||||||
payload = buildAccountResponsePayload(mocks.account);
|
payload = buildAccountResponsePayload(mocks.account);
|
||||||
break;
|
break;
|
||||||
case "user.profile.update": {
|
case "user.profile.update": {
|
||||||
@@ -181,7 +183,7 @@ test.describe("F8-04 — profile screen", () => {
|
|||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGateway(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-display-name").fill("Captain");
|
||||||
await page.getByTestId("profile-save").click();
|
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(
|
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||||
"Captain",
|
"Captain",
|
||||||
);
|
);
|
||||||
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
|
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
|
||||||
expect(mocks.settingsUpdates).toEqual([]);
|
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());
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGateway(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-preferred-language").selectOption("ru");
|
||||||
await page.getByTestId("profile-save").click();
|
await page.getByTestId("profile-save").click();
|
||||||
|
|
||||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
// Profile stays on screen; the Russian dictionary now drives the
|
||||||
// The lobby switches to the Russian dictionary after the save —
|
// form copy. The save button label is the visible signal.
|
||||||
// the "create new game" button label is the visible signal.
|
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||||
await expect(page.getByTestId("lobby-create-button")).toHaveText(
|
await expect(page.getByTestId("profile-save")).toHaveText("сохранить");
|
||||||
"создать новую игру",
|
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
|
||||||
);
|
expect(mocks.settingsUpdates).toHaveLength(1);
|
||||||
expect(mocks.settingsUpdates).toEqual([
|
expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru");
|
||||||
{ preferredLanguage: "ru", timeZone: "UTC" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||||
});
|
});
|
||||||
@@ -257,4 +266,74 @@ test.describe("F8-04 — profile screen", () => {
|
|||||||
|
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
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