feat(ui): lobby site-style sidebar + profile screen (#47) #60
+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 (trimmed.length > 0) return trimmed;
|
if (current !== null) {
|
||||||
if (userName.length > 0) return userName;
|
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");
|
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>
|
||||||
<input
|
{#if timeZoneFallbackToText}
|
||||||
type="text"
|
<!--
|
||||||
bind:value={timeZoneInput}
|
Browser lacks `Intl.supportedValuesOf("timeZone")` —
|
||||||
placeholder={browserTimeZone()}
|
fall back to a free-text field so a viable runtime can
|
||||||
autocomplete="off"
|
still save a zone. The backend remains the validator.
|
||||||
data-testid="profile-time-zone"
|
-->
|
||||||
/>
|
<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>
|
<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