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

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:
Ilia Denisov
2026-07-14 09:38:42 +02:00
parent 564abdcf88
commit 471fadc6a4
6 changed files with 71 additions and 16 deletions
+43 -4
View File
@@ -64,13 +64,52 @@ test('online game offline: banner shows, the tray and comms entry freeze, and it
expect(n).toBeGreaterThan(0);
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 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 expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 });
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();
});