fix(ui): offline in-game — usable dictionary check + hide (not disable) resign/chat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Two fixes from contour testing of the offline-in-game UX: - The dictionary word check was unusable offline (Check button disabled) when the panel was reached while already offline: CheckScreen fetched the variant + pinned version over the network in onMount, which fails offline, leaving the default variant so input sanitising stripped every letter. Seed both from the cached game (present once the board has opened) so the input and the on-device dawg fallback work without a round-trip; the network refresh still runs when online and on a cold deep-link. - The resign and chat controls were only functionally disabled (chat) or not gated at all (resign) offline, so they still looked active. Hide both while offline, matching the frozen social controls. Tests: the in-game offline e2e now asserts the drawer action icons are hidden (not disabled); a new e2e guards the offline dictionary flow. Docs updated.
This commit is contained in:
@@ -124,8 +124,8 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
switch and no device-local create paths. The machine still tracks reachability there, though: a
|
switch and no device-local create paths. The machine still tracks reachability there, though: a
|
||||||
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
||||||
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
||||||
*connection lost* banner and **freezes** the tray, the move controls, the add-friend/block controls and
|
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||||
the chat/dictionary entry (a started move stays a draft, re-committed by the player on reconnect), and
|
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
|
||||||
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
||||||
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
||||||
look-up hidden.
|
look-up hidden.
|
||||||
|
|||||||
+3
-2
@@ -328,8 +328,9 @@ offline, so you are never interrupted for a hiccup.
|
|||||||
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||||
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||||||
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||||||
board and the play area **freezes**: the rack, the move controls, the add-friend/block controls and the
|
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||||
chat/dictionary entry all disable, while a move you had started stays on the board as a draft. Nothing
|
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||||||
|
board as a draft. Nothing
|
||||||
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||||||
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||||||
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||||||
|
|||||||
@@ -332,8 +332,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||||
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||||
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||||
потеряна*, и игровая область **замораживается**: рэк, ходовые кнопки, кнопки «в друзья»/«заблокировать»
|
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||||
и вход в чат/словарь отключаются, а начатый ход остаётся на доске черновиком. Ничего не отправляется;
|
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||||
|
Ничего не отправляется;
|
||||||
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||||
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||||
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||||
|
|||||||
@@ -64,13 +64,52 @@ test('online game offline: banner shows, the tray and comms entry freeze, and it
|
|||||||
expect(n).toBeGreaterThan(0);
|
expect(n).toBeGreaterThan(0);
|
||||||
for (let i = 0; i < n; i++) await expect(tiles.nth(i)).toBeDisabled();
|
for (let i = 0; i < n; i++) await expect(tiles.nth(i)).toBeDisabled();
|
||||||
|
|
||||||
// The comms entry (💬 — chat + dictionary) is disabled; it lives in the history drawer, so open it.
|
// The comms entry (💬) and the resign control are HIDDEN offline (like the frozen social controls);
|
||||||
|
// both live in the history drawer, so open it and assert no action icons remain in its header.
|
||||||
await page.locator('.scoreboard').click();
|
await page.locator('.scoreboard').click();
|
||||||
await expect(page.locator('button:has(.chat-ico)')).toBeDisabled();
|
await expect(page.locator('.hhead button.hicon')).toHaveCount(0);
|
||||||
|
|
||||||
// Recovery: the network returns, the banner clears and the tray is interactive again.
|
// Recovery: the network returns, the banner clears, the tray is interactive and the comms entry returns.
|
||||||
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
|
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
|
||||||
await expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 });
|
await expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 });
|
||||||
await expect(page.locator('.rack .tile').first()).toBeEnabled();
|
await expect(page.locator('.rack .tile').first()).toBeEnabled();
|
||||||
await expect(page.locator('button:has(.chat-ico)')).toBeEnabled();
|
await expect(page.locator('button:has(.chat-ico)')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online game offline: the dictionary still accepts input and checks (variant seeded from cache)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
// A Russian (erudit_ru — first in the mock's variant preferences) game, so the offline dictionary is
|
||||||
|
// exercised with a non-Latin alphabet that differs from the panel's default 'scrabble_en'. The panel
|
||||||
|
// seeds its variant + pinned version from the cached game; the mock resolves gameState even offline
|
||||||
|
// (no kill switch), so on the real backend — where that fetch fails offline — the seed is what keeps
|
||||||
|
// the input and the dawg fallback working. This guards the offline dictionary flow end-to-end.
|
||||||
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Random player' }).click();
|
||||||
|
await page.locator('.variant').first().click();
|
||||||
|
await page.getByRole('button', { name: /Start game/i }).click();
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||||
|
await expect(page.getByText('Robo')).toBeVisible();
|
||||||
|
|
||||||
|
// Go offline, then open the dictionary directly (its in-game entry is hidden offline) so CheckScreen
|
||||||
|
// mounts with no network and must seed the variant/version from the cached game, not a failed fetch.
|
||||||
|
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||||
|
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const route = location.hash.slice(1); // /game/<id>
|
||||||
|
(window as unknown as { __router: { navigate(p: string): void } }).__router.navigate(route + '/check');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The check input keeps the Cyrillic word (the variant is known) and the Check button is usable —
|
||||||
|
// the outcome the cache seed guarantees on the real backend where the offline gameState fetch fails.
|
||||||
|
const input = page.locator('.check input');
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
await input.fill('СЛОВО');
|
||||||
|
await expect(input).toHaveValue('СЛОВО');
|
||||||
|
await expect(page.locator('.check button')).toBeEnabled();
|
||||||
|
|
||||||
|
// Running the check resolves to a verdict (or the neutral offline note), proving the fallback path
|
||||||
|
// executes rather than hanging.
|
||||||
|
await page.locator('.check button').click();
|
||||||
|
await expect(page.locator('.verdict')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { alphabetLetters } from '../lib/alphabet';
|
import { alphabetLetters } from '../lib/alphabet';
|
||||||
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
|
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
|
||||||
import { onExternalLinkClick } from '../lib/links';
|
import { onExternalLinkClick } from '../lib/links';
|
||||||
|
import { getCachedGame } from '../lib/gamecache';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
|
|
||||||
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
||||||
@@ -25,15 +26,24 @@
|
|||||||
const checked = new Map<string, boolean>();
|
const checked = new Map<string, boolean>();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// Seed the variant + pinned version from the cached game first (present once the board has opened,
|
||||||
|
// and preloaded for ongoing games): an online game's dictionary must keep working while offline,
|
||||||
|
// where the gameState call below cannot run. The variant's alphabet is already cached from opening
|
||||||
|
// the board, so input sanitising and the offline dawg fallback work off the seed alone.
|
||||||
|
const cached = getCachedGame(id)?.view.game;
|
||||||
|
if (cached) {
|
||||||
|
variant = cached.variant;
|
||||||
|
dictVersion = cached.dictVersion;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
|
// Refresh over the network (and cache the alphabet on a cold deep-link that skipped the board).
|
||||||
// through gameSource so an offline (local) game resolves its state from the device, not the
|
|
||||||
// network.
|
|
||||||
const st = await gameSource(id).gameState(id, true);
|
const st = await gameSource(id).gameState(id, true);
|
||||||
variant = st.game.variant;
|
variant = st.game.variant;
|
||||||
dictVersion = st.game.dictVersion;
|
dictVersion = st.game.dictVersion;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
// Offline or a transient failure: the cache seed already set variant + version, so only surface
|
||||||
|
// an error when there was nothing cached to fall back to.
|
||||||
|
if (!cached) handleError(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1607,6 +1607,10 @@
|
|||||||
{:else if view.game.hotseat}
|
{:else if view.game.hotseat}
|
||||||
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||||
<span aria-hidden="true"></span>
|
<span aria-hidden="true"></span>
|
||||||
|
{:else if !netReady}
|
||||||
|
<!-- Offline: resigning needs the server, so hide the control (like the frozen social controls);
|
||||||
|
an empty slot keeps the header's space-between layout. -->
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1615,8 +1619,8 @@
|
|||||||
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||||
— so drop the entry only when a local game is finished; an active local game keeps it for
|
— so drop the entry only when a local game is finished; an active local game keeps it for
|
||||||
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
||||||
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
{#if netReady && !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||||
<button class="hicon" disabled={!netReady} onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user