From 1f2624c0e346767ec898af02759529315e2797ca Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 29 Sep 2026 20:33:16 +0700 Subject: [PATCH] fix(web): clear stale rooms on refused resume, harden reconnect paths A refused resume now leaves the room instead of showing a banner over a dead board, and /play offers a fresh game there. Entering /play forgets any stored token so it no longer races StartBotGame. An eliminated player who reconnects stays a spectator. Held lobby actions wait for the resume's RoomState; leaving while offline is not held. Chat clears its draft only when the line was sent and cannot send while offline. Also: away banners no longer announce every second; game over does not steal focus from the chat field; the clock offset uses the lowest-RTT pong; the history export numbers a resumed chain from its real length; chat replay keeps existing ordinals; one .primary button class replaces five copies; the footer links the data change list; the online heading joins the type ramp. New component, connection and storage-guard tests. --- web/src/app.css | 30 ++ .../lib/components/AttributionFooter.svelte | 7 + web/src/lib/components/ChatPanel.svelte | 31 +- web/src/lib/components/GameOverPanel.svelte | 28 +- web/src/lib/components/PlayerStatus.svelte | 10 +- web/src/lib/components/WordInput.svelte | 38 +- web/src/lib/focus.js | 17 + web/src/lib/history-export.js | 14 +- web/src/lib/i18n/vi.js | 6 +- web/src/lib/stores/game-apply.js | 21 +- web/src/lib/stores/game.svelte.js | 12 +- web/src/lib/ws/client.js | 68 ++- web/src/lib/ws/connection.svelte.js | 20 +- web/src/routes/+page.svelte | 23 +- web/src/routes/online/+page.svelte | 64 ++- web/src/routes/play/+page.svelte | 21 +- web/tests/chat-panel.test.js | 71 +++- web/tests/connection.test.js | 134 ++++++ web/tests/game-store.test.js | 139 ++++++ web/tests/history-export.test.js | 32 ++ web/tests/i18n.test.js | 61 ++- web/tests/reactive-props.svelte.js | 12 + web/tests/status-components.test.js | 401 ++++++++++++++++++ web/tests/word-input.test.js | 127 +++++- web/tests/ws-client.test.js | 224 +++++++++- web/vite.config.js | 2 +- 26 files changed, 1460 insertions(+), 153 deletions(-) create mode 100644 web/src/lib/focus.js create mode 100644 web/tests/connection.test.js create mode 100644 web/tests/reactive-props.svelte.js create mode 100644 web/tests/status-components.test.js diff --git a/web/src/app.css b/web/src/app.css index 35f364a..ef3a991 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -175,6 +175,36 @@ input { line-height: 1; } +/* + * The accent-filled button: colour and state only. Sizing, padding and the + * border width stay with the component, because those differ by where the + * button sits. Not wrapped in :where(): zero specificity would lose to every + * scoped `button` rule that sets a background. + * + * A component that gives all its buttons a background must exclude this class + * from that rule (`:where(:not(.primary))` keeps the old specificity), or its + * rule wins over this one. + */ +.primary { + border-color: transparent; + background: var(--accent); + color: var(--accent-text); + transition: background-color 150ms ease-out; +} + +.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.primary:active:not(:disabled) { + background: var(--accent-pressed); +} + +.primary:disabled { + background: var(--surface-alt); + color: var(--text-muted); +} + /* * A small link to the rules, used wherever a screen has room for one but not * for a button that would compete with the primary action beside it: the diff --git a/web/src/lib/components/AttributionFooter.svelte b/web/src/lib/components/AttributionFooter.svelte index 57dde68..927a371 100644 --- a/web/src/lib/components/AttributionFooter.svelte +++ b/web/src/lib/components/AttributionFooter.svelte @@ -1,5 +1,10 @@ + {/each} diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte index ef75bc5..3b6181f 100644 --- a/web/src/lib/components/WordInput.svelte +++ b/web/src/lib/components/WordInput.svelte @@ -1,4 +1,5 @@ @@ -676,7 +716,7 @@ h1 { margin: 0; - font-size: 1.3rem; + font-size: var(--text-5); } .intro { @@ -701,23 +741,7 @@ padding: var(--space-4); border: 0; border-radius: var(--radius-sm); - background: var(--accent); - color: var(--accent-text); font-weight: 600; - transition: background-color 150ms ease-out; - } - - .primary:hover:not(:disabled) { - background: var(--accent-hover); - } - - .primary:active:not(:disabled) { - background: var(--accent-pressed); - } - - .primary:disabled { - background: var(--surface-alt); - color: var(--text-muted); } .join { diff --git a/web/src/routes/play/+page.svelte b/web/src/routes/play/+page.svelte index 3f52900..07a5669 100644 --- a/web/src/routes/play/+page.svelte +++ b/web/src/routes/play/+page.svelte @@ -73,7 +73,10 @@ session.reset(); isRecord = false; session.request(rung); - connect(); + // A reload is a new game on this rung. A token left by an earlier game + // or by /online would be replayed with the request and resume that seat + // instead. + connect({ freshSession: true }); session.flush(connection.status === Status.OPEN); } @@ -105,3 +108,19 @@ {/snippet} + +{#if game.state.phase === 'idle' && game.state.error} + +

+ +

+{/if} + + diff --git a/web/tests/chat-panel.test.js b/web/tests/chat-panel.test.js index 93ba90d..0a7bf74 100644 --- a/web/tests/chat-panel.test.js +++ b/web/tests/chat-panel.test.js @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { unmount, flushSync } from 'svelte'; import ChatPanel from '../src/lib/components/ChatPanel.svelte'; import { game } from '../src/lib/stores/game.svelte.js'; +import { Status, connection } from '../src/lib/ws/connection.svelte.js'; import { receive, render } from './component-support.js'; /** @param {{ author?: string, text?: string, fromMe?: boolean }} [fields] */ @@ -22,6 +23,7 @@ function renderChatPanel(props) { beforeEach(() => { game.clearChat(); + connection.status = Status.OPEN; // jsdom does not implement scrollTo; the panel calls it to keep the // newest line in view, which is not what this file is testing. Element.prototype.scrollTo = () => {}; @@ -29,11 +31,12 @@ beforeEach(() => { afterEach(() => { document.body.innerHTML = ''; + connection.status = Status.CLOSED; }); describe('folding', () => { it('starts folded when collapsible, with no log or input on screen', () => { - const { target } = renderChatPanel({ collapsible: true, onsend: () => {} }); + const { target } = renderChatPanel({ collapsible: true, onsend: () => true }); expect(target.querySelector('[data-testid="chat-toggle"]')).not.toBeNull(); expect(target.querySelector('[data-testid="chat-log"]')).toBeNull(); @@ -42,7 +45,7 @@ describe('folding', () => { it('opens on a toggle press and shows the log', () => { receiveLine(); - const { target } = renderChatPanel({ collapsible: true, onsend: () => {} }); + const { target } = renderChatPanel({ collapsible: true, onsend: () => true }); /** @type {HTMLButtonElement | null} */ const toggle = target.querySelector('[data-testid="chat-toggle"]'); @@ -54,7 +57,7 @@ describe('folding', () => { }); it('never folds when not collapsible, regardless of the toggle', () => { - const { target } = renderChatPanel({ collapsible: false, onsend: () => {} }); + const { target } = renderChatPanel({ collapsible: false, onsend: () => true }); expect(target.querySelector('[data-testid="chat-toggle"]')).toBeNull(); expect(target.querySelector('[data-testid="chat-input"]')).not.toBeNull(); @@ -63,7 +66,7 @@ describe('folding', () => { describe('unread count', () => { it('counts a line that arrives while folded', () => { - const { target } = renderChatPanel({ collapsible: true, onsend: () => {} }); + const { target } = renderChatPanel({ collapsible: true, onsend: () => true }); receiveLine({ text: 'một' }); flushSync(); @@ -73,7 +76,7 @@ describe('unread count', () => { it('clears to zero once the panel is opened', () => { receiveLine({ text: 'một' }); - const { target } = renderChatPanel({ collapsible: true, onsend: () => {} }); + const { target } = renderChatPanel({ collapsible: true, onsend: () => true }); flushSync(); /** @type {HTMLButtonElement | null} */ @@ -85,7 +88,7 @@ describe('unread count', () => { }); it('does not count anything while the panel is already open', () => { - const { target } = renderChatPanel({ collapsible: false, onsend: () => {} }); + const { target } = renderChatPanel({ collapsible: false, onsend: () => true }); receiveLine({ text: 'một' }); flushSync(); @@ -94,7 +97,7 @@ describe('unread count', () => { }); it('resumes counting once folded again after having been read', () => { - const { target, component } = renderChatPanel({ collapsible: true, onsend: () => {} }); + const { target, component } = renderChatPanel({ collapsible: true, onsend: () => true }); /** @type {HTMLButtonElement | null} */ const toggle = target.querySelector('[data-testid="chat-toggle"]'); @@ -115,7 +118,7 @@ describe('sending', () => { it('reports the typed text and clears the field on submit', () => { /** @type {string[]} */ const sent = []; - const { target } = renderChatPanel({ collapsible: false, onsend: (text) => sent.push(text) }); + const { target } = renderChatPanel({ collapsible: false, onsend: (text) => !!sent.push(text) }); /** @type {HTMLInputElement | null} */ const input = target.querySelector('[data-testid="chat-input"]'); @@ -135,10 +138,58 @@ describe('sending', () => { expect(input?.value).toBe(''); }); + /** + * Types into the mounted panel's field. + * @param {HTMLElement} target + * @param {string} text + */ + function type(target, text) { + /** @type {HTMLInputElement | null} */ + const input = target.querySelector('[data-testid="chat-input"]'); + if (input) { + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + flushSync(); + return input; + } + + it('keeps the text in the field when the send was refused', () => { + const { target } = renderChatPanel({ collapsible: false, onsend: () => false }); + const input = type(target, 'xin chào'); + + target.querySelector('form')?.requestSubmit(); + flushSync(); + + expect(input?.value).toBe('xin chào'); + expect( + /** @type {HTMLButtonElement | null} */ (target.querySelector('[data-testid="chat-send"]')) + ?.disabled + ).toBe(false); + }); + + it('offers no send while the socket is down, and sends nothing if forced', () => { + /** @type {string[]} */ + const sent = []; + const { target } = renderChatPanel({ collapsible: false, onsend: (text) => !!sent.push(text) }); + type(target, 'xin chào'); + connection.status = Status.RECONNECTING; + flushSync(); + + expect( + /** @type {HTMLButtonElement | null} */ (target.querySelector('[data-testid="chat-send"]')) + ?.disabled + ).toBe(true); + target.querySelector('form')?.requestSubmit(); + flushSync(); + + expect(sent).toEqual([]); + }); + it('refuses a message that is only whitespace', () => { /** @type {string[]} */ const sent = []; - const { target } = renderChatPanel({ collapsible: false, onsend: (text) => sent.push(text) }); + const { target } = renderChatPanel({ collapsible: false, onsend: (text) => !!sent.push(text) }); /** @type {HTMLInputElement | null} */ const input = target.querySelector('[data-testid="chat-input"]'); @@ -162,7 +213,7 @@ describe('a draft across a fold', () => { const { target, component } = renderChatPanel({ collapsible: true, folded: false, - onsend: (text) => sent.push(text) + onsend: (text) => !!sent.push(text) }); /** @type {HTMLButtonElement | null} */ const toggle = target.querySelector('[data-testid="chat-toggle"]'); diff --git a/web/tests/connection.test.js b/web/tests/connection.test.js new file mode 100644 index 0000000..2b09e5a --- /dev/null +++ b/web/tests/connection.test.js @@ -0,0 +1,134 @@ +// @vitest-environment jsdom + +// The module-level connection is what a screen actually calls. These tests +// drive it through a stubbed WebSocket, because what they pin is which resume +// token a screen's socket carries and what happens to the board when the +// server refuses one. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { + ClientMessageSchema, + ServerMessageSchema +} from '../src/lib/proto/noitu/v1/game_pb.js'; +import { game } from '../src/lib/stores/game.svelte.js'; +import { connect, disconnect } from '../src/lib/ws/connection.svelte.js'; +import { receive } from './component-support.js'; + +const RESUME_KEY = 'noitu.resumeToken'; + +/** @type {FakeWebSocket[]} */ +let sockets = []; + +class FakeWebSocket { + /** @param {string} url */ + constructor(url) { + this.url = url; + this.readyState = 0; + this.binaryType = ''; + /** @type {Uint8Array[]} */ + this.sent = []; + this.onopen = () => {}; + this.onmessage = (/** @type {{ data: ArrayBuffer }} */ _event) => {}; + this.onclose = () => {}; + this.onerror = () => {}; + sockets.push(this); + } + + send(/** @type {Uint8Array} */ bytes) { + this.sent.push(bytes); + } + + close() {} + + open() { + this.readyState = 1; + this.onopen(); + } + + /** + * @param {string} kind + * @param {object} value + */ + deliver(kind, value) { + const msg = create(ServerMessageSchema, { payload: { case: kind, value } }); + this.onmessage({ data: toBinary(ServerMessageSchema, msg).buffer }); + } + + helloToken() { + const [first] = this.sent.map((b) => fromBinary(ClientMessageSchema, new Uint8Array(b))); + return first.payload.case === 'hello' ? first.payload.value.resumeToken : null; + } +} + +const greeting = { sessionId: 's1', resumeToken: 'fresh-token', protocolVersion: 1, acceptedNickname: 'Minh' }; + +beforeEach(() => { + sockets = []; + sessionStorage.clear(); + vi.stubGlobal('WebSocket', FakeWebSocket); + game.leave(); +}); + +afterEach(() => { + disconnect(); + vi.unstubAllGlobals(); +}); + +describe('opening a socket for a game of its own', () => { + it('does not replay a token left by an earlier game or tab', () => { + sessionStorage.setItem(RESUME_KEY, 'left-behind'); + + connect({ freshSession: true }); + sockets[0].open(); + + expect(sockets[0].helloToken()).toBe(''); + }); + + it('replays the stored token when nothing asked for a fresh session', () => { + sessionStorage.setItem(RESUME_KEY, 'left-behind'); + + connect(); + sockets[0].open(); + + expect(sockets[0].helloToken()).toBe('left-behind'); + }); + + it('keeps the token this game was given, so a dropped socket still resumes', () => { + sessionStorage.setItem(RESUME_KEY, 'left-behind'); + connect({ freshSession: true }); + sockets[0].open(); + sockets[0].deliver('welcome', greeting); + + // A rematch asks again on the socket that already exists. That must + // not throw away the token the game in progress is resumable by. + connect({ freshSession: true }); + + expect(sessionStorage.getItem(RESUME_KEY)).toBe('fresh-token'); + expect(sockets).toHaveLength(1); + }); +}); + +describe('a resume the server refuses', () => { + it('clears the room the board still shows, and still says why', () => { + receive('roomState', { + roomCode: 'ABCD', + canStart: false, + maxPlayers: 4, + minPlayers: 2, + graceMs: 30_000, + players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }] + }); + expect(game.state.phase).toBe('lobby'); + sessionStorage.setItem(RESUME_KEY, 'old-token'); + + connect(); + sockets[0].open(); + sockets[0].deliver('welcome', greeting); + sockets[0].deliver('error', { code: 'game_already_over', message: '' }); + + expect(game.state.phase).toBe('idle'); + expect(game.state.roomCode).toBe(''); + expect(game.state.error).toBe('Ván đấu đã kết thúc.'); + }); +}); diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index d34b0b3..eaa05c3 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -981,3 +981,142 @@ describe('a resign or claim that raced the turn moving on', () => { expect(store.state.error).toBeNull(); }); }); + + +describe('resuming after the game ended while away', () => { + // What the server replays to a seat that dropped mid-game and came back + // once the room was already back in the lobby: the room, the chat, and then + // that seat's own GameOver. + function resumeIntoFinishedGame() { + const store = createGameStore(); + store.apply(pair()); + store.apply(started({ turnPlayerId: 'p2' })); + expect(store.state.phase).toBe('playing'); + + store.apply(pair({ canStart: true })); + store.apply(msg('chatHistory', { messages: [] })); + store.apply( + msg('gameOver', { + iWon: false, + reason: GameEndReason.OPPONENT_LEFT, + chainLength: 5, + standings: [ + { playerId: 'p1', name: 'Chủ', isMe: false, score: 9, rank: 1, connected: true }, + { playerId: 'p2', name: 'Lan', isMe: true, score: 4, rank: 2, connected: true } + ] + }) + ); + return store; + } + + it('lands on the game-over view with the standings', () => { + const store = resumeIntoFinishedGame(); + + expect(store.state.phase).toBe('over'); + expect(store.state.myTurn).toBe(false); + expect(store.state.result).toMatchObject({ iWon: false, chainLength: 5, myScore: 4 }); + expect(store.state.standings.map((p) => p.playerId)).toEqual(['p1', 'p2']); + }); + + it('keeps the lobby the room snapshot describes, so Ready and Leave are there', () => { + const store = resumeIntoFinishedGame(); + + expect(store.state.roomCode).toBe('ABCD'); + expect(store.state.canStart).toBe(true); + expect(store.me?.playerId).toBe('p2'); + expect(store.iAmOut).toBe(false); + }); + + it('does not end the game on the room snapshot alone', () => { + // A RoomState mid-game is ordinary: somebody said they were ready, or + // dropped. Only a GameOver moves the phase. + const store = createGameStore(); + store.apply(pair()); + store.apply(started({ turnPlayerId: 'p2' })); + store.apply(pair({ canStart: true })); + + expect(store.state.phase).toBe('playing'); + expect(store.state.myTurn).toBe(true); + }); +}); + +describe('a refused resume', () => { + it('leaves the room when the session cannot be resumed', () => { + const store = createGameStore(); + store.apply(pair()); + store.apply(started()); + + store.apply(msg('error', { code: 'session_not_resumable', message: '' })); + + expect(store.state.phase).toBe('idle'); + expect(store.state.roomCode).toBe(''); + expect(store.state.roomPlayers).toEqual([]); + expect(store.state.chain).toEqual([]); + expect(store.state.error).toBe('Không khôi phục được ván đấu trước.'); + }); +}); + +describe('being knocked out across a reload', () => { + it('counts as out from the replayed row alone, without the elimination frame', () => { + const store = createGameStore(); + store.apply( + started({ + myTurn: false, + players: [ + { playerId: 'p1', name: 'Minh', isMe: true, score: 3, connected: true, eliminated: true }, + { playerId: 'p2', name: 'Lan', isMe: false, score: 5, connected: true }, + { playerId: 'p3', name: 'Hà', isMe: false, score: 2, connected: true } + ], + turnPlayerId: 'p2' + }) + ); + + expect(store.state.elimination).toBeNull(); + expect(store.iAmOut).toBe(true); + }); + + it('does not count somebody else being out', () => { + const store = createGameStore(); + store.apply( + started({ + players: [ + { playerId: 'p1', name: 'Minh', isMe: true, score: 3, connected: true }, + { playerId: 'p2', name: 'Lan', isMe: false, score: 5, connected: true, eliminated: true } + ] + }) + ); + + expect(store.iAmOut).toBe(false); + }); +}); + +describe('a chat history replayed after a reconnect', () => { + const wire = (/** @type {string} */ text, /** @type {bigint} */ at) => ({ + fromMe: false, + playerId: 'p2', + author: 'Lan', + text, + sentUnixMs: at + }); + + it('keeps the ordinal of a line already on screen, so its row is not re-inserted', () => { + const store = createGameStore(); + store.apply(msg('chatHistory', { messages: [wire('một', 1n), wire('hai', 2n)] })); + const before = store.state.chat.map((m) => m.n); + + store.apply(msg('chatHistory', { messages: [wire('một', 1n), wire('hai', 2n), wire('ba', 3n)] })); + + expect(store.state.chat.map((m) => m.n).slice(0, 2)).toEqual(before); + expect(store.state.chat[2].n).not.toBe(before[0]); + expect(store.state.chat[2].n).not.toBe(before[1]); + }); + + it('gives two identical lines their own ordinals', () => { + const store = createGameStore(); + store.apply(msg('chatHistory', { messages: [wire('hi', 1n), wire('hi', 1n)] })); + store.apply(msg('chatHistory', { messages: [wire('hi', 1n), wire('hi', 1n)] })); + + const [a, b] = store.state.chat; + expect(a.n).not.toBe(b.n); + }); +}); diff --git a/web/tests/history-export.test.js b/web/tests/history-export.test.js index fdecefa..12d0c09 100644 --- a/web/tests/history-export.test.js +++ b/web/tests/history-export.test.js @@ -79,6 +79,38 @@ describe('chainToText', () => { expect(text).toContain('Số từ trong chuỗi: 3'); }); + it('numbers a chain held only in part from the server\'s length, and marks the gap', () => { + // A player who rejoined mid-game holds the opening word and the latest + // move, and nothing between. + const partial = [ + link({ word: 'học sinh', opening: true, syllables: 0 }), + link({ word: 'viên chức', playerId: 'p2', points: 2 }) + ]; + const lines = chainToText({ + chain: partial, + at, + nameOf, + result: { iWon: true, myScore: 4, chainLength: 30 } + }) + .trim() + .split('\n'); + const body = lines.slice(lines.indexOf('1. học sinh (từ mở đầu)')); + + expect(body).toEqual(['1. học sinh (từ mở đầu)', '…', '30. viên chức — Minh +2']); + }); + + it('numbers a whole chain from one, with no gap marker', () => { + const text = chainToText({ + chain, + at, + result: { iWon: true, myScore: 4, chainLength: 3 } + }); + + expect(text).toContain('1. học sinh'); + expect(text).toContain('3. viên chức'); + expect(text).not.toContain('…'); + }); + it('writes a chain with no result at all', () => { const text = chainToText({ chain: [], at }); diff --git a/web/tests/i18n.test.js b/web/tests/i18n.test.js index 2a1d133..d853521 100644 --- a/web/tests/i18n.test.js +++ b/web/tests/i18n.test.js @@ -2,6 +2,9 @@ // two ways it can silently fall out of step with the wire contract: a new enum // value with no message, and a placeholder no caller fills. +import { readdirSync, readFileSync } from 'node:fs'; +import { join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { Difficulty, @@ -22,7 +25,8 @@ import { fill, pointKindLabels, rejectMessage, - rejectMessages + rejectMessages, + t } from '../src/lib/i18n/vi.js'; /** @@ -135,3 +139,58 @@ describe('fill', () => { expect(fill('{a} và {a}', { a: 'x' })).toBe('x và x'); }); }); + +describe('fill call sites', () => { + const src = fileURLToPath(new URL('../src', import.meta.url)); + + /** Every source file a `fill(t.key, { ... })` call can live in. */ + const files = readdirSync(src, { recursive: true, encoding: 'utf8' }) + .filter((f) => /\.(svelte|js)$/.test(f) && !f.split(sep).includes('proto')) + .map((f) => join(src, f)); + + /** + * Splits an object literal's body on its top-level commas, so a value that + * is itself a call with arguments stays whole. + * @param {string} body + */ + function topLevel(body) { + /** @type {string[]} */ + const parts = []; + let depth = 0; + let from = 0; + for (let i = 0; i < body.length; i++) { + if ('([{'.includes(body[i])) depth++; + else if (')]}'.includes(body[i])) depth--; + else if (body[i] === ',' && depth === 0) { + parts.push(body.slice(from, i)); + from = i + 1; + } + } + parts.push(body.slice(from)); + return parts.map((p) => p.trim()).filter(Boolean); + } + + // Matches the direct form, `fill(t.key, { ... })`. A call whose template is + // chosen by an expression is not visible to a regex and is not covered. + const call = /\bfill\(\s*t\.(\w+)\s*,\s*\{((?:[^{}]|\{[^{}]*\})*)\}/g; + + it('supplies exactly the placeholders each template asks for', () => { + let seen = 0; + for (const file of files) { + const text = readFileSync(file, 'utf8'); + for (const [, key, body] of text.matchAll(call)) { + seen++; + const template = /** @type {Record} */ (t)[key]; + expect(template, `${file}: t.${key} does not exist`).toBeTypeOf('string'); + + const asked = [...template.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort(); + const given = topLevel(body) + .map((entry) => entry.match(/^(\w+)\s*(?::|$)/)?.[1] ?? '?') + .sort(); + expect(given, `${file}: fill(t.${key}) placeholders`).toEqual(asked); + } + } + // A scan that matched nothing would pass for the wrong reason. + expect(seen).toBeGreaterThan(10); + }); +}); diff --git a/web/tests/reactive-props.svelte.js b/web/tests/reactive-props.svelte.js new file mode 100644 index 0000000..f56a962 --- /dev/null +++ b/web/tests/reactive-props.svelte.js @@ -0,0 +1,12 @@ +// Not a test file: a place for the `$state` rune, which only compiles in a +// `.svelte.js` module, so a test can change a mounted component's props. + +/** + * @template {Record} T + * @param {T} initial + * @returns {T} + */ +export function reactiveProps(initial) { + const props = $state(initial); + return props; +} diff --git a/web/tests/status-components.test.js b/web/tests/status-components.test.js new file mode 100644 index 0000000..81f7eb7 --- /dev/null +++ b/web/tests/status-components.test.js @@ -0,0 +1,401 @@ +// @vitest-environment jsdom + +// The small display components whose logic is worth pinning: what the clock +// shows and when it turns urgent, what the away banner announces, what the +// game-over panel shows and whether it may take focus, and the two-press +// button. Mounted directly under jsdom, as the other component suites are. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import ArmedButton from '../src/lib/components/ArmedButton.svelte'; +import CountdownRing from '../src/lib/components/CountdownRing.svelte'; +import GameOverPanel from '../src/lib/components/GameOverPanel.svelte'; +import PlayerStatus from '../src/lib/components/PlayerStatus.svelte'; +import { GameEndReason } from '../src/lib/proto/noitu/v1/game_pb.js'; +import { game } from '../src/lib/stores/game.svelte.js'; +import { Status, connection } from '../src/lib/ws/connection.svelte.js'; +import { receive, render, startGame } from './component-support.js'; +import { reactiveProps } from './reactive-props.svelte.js'; + +beforeEach(() => { + game.leave(); + connection.status = Status.OPEN; +}); + +afterEach(() => { + document.body.innerHTML = ''; + connection.status = Status.CLOSED; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** + * Starts a game whose turn ends `ms` from the frozen clock. + * @param {{ ms: number, myTurn: boolean }} args + */ +function turnEndingIn({ ms, myTurn }) { + receive('gameStarted', { + openingWord: 'bình yên', + currentSyllable: 'yên', + myTurn, + // The ring stops the clock 300ms short of the server's deadline. + deadlineUnixMs: BigInt(Date.now() + ms + 300), + turnSeq: 1, + turnLimitMs: 20_000, + players: [ + { playerId: 'p1', name: 'Minh', isMe: true, connected: true }, + { playerId: 'p2', name: 'Lan', isMe: false, connected: true } + ], + turnPlayerId: myTurn ? 'p1' : 'p2' + }); +} + +describe('CountdownRing', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'], now: 1_700_000_000_000 }); + // jsdom's frame loop would run against the real clock; the value is + // read once at mount, which is what these assertions need. + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows a dash and no urgency between games', () => { + const { target, component } = render(CountdownRing, {}); + const ring = target.querySelector('[role="timer"]'); + + expect(ring?.classList.contains('idle')).toBe(true); + expect(target.querySelector('.value')?.textContent).toBe('–'); + unmount(component); + }); + + it('shows whole seconds, rounded up, and labels them for assistive tech', () => { + turnEndingIn({ ms: 12_400, myTurn: true }); + const { target, component } = render(CountdownRing, {}); + + expect(target.querySelector('.value')?.textContent).toBe('13'); + expect(target.querySelector('[role="timer"]')?.getAttribute('aria-label')).toBe('13 giây'); + unmount(component); + }); + + it('turns urgent in the last five seconds of the player\'s own turn only', () => { + turnEndingIn({ ms: 4_000, myTurn: true }); + const mine = render(CountdownRing, {}); + expect(mine.target.querySelector('[role="timer"]')?.classList.contains('urgent')).toBe(true); + unmount(mine.component); + + turnEndingIn({ ms: 4_000, myTurn: false }); + const theirs = render(CountdownRing, {}); + const ring = theirs.target.querySelector('[role="timer"]'); + expect(ring?.classList.contains('urgent')).toBe(false); + expect(ring?.classList.contains('mine')).toBe(false); + unmount(theirs.component); + }); + + it('says it is stalled, rather than urgent, while the socket is down', () => { + turnEndingIn({ ms: 4_000, myTurn: true }); + connection.status = Status.RECONNECTING; + const { target, component } = render(CountdownRing, {}); + const ring = target.querySelector('[role="timer"]'); + + expect(ring?.classList.contains('stalled')).toBe(true); + expect(ring?.classList.contains('urgent')).toBe(false); + unmount(component); + }); + + it('speaks the ten and five second marks on the player\'s own turn, and never otherwise', () => { + turnEndingIn({ ms: 9_500, myTurn: true }); + const mine = render(CountdownRing, {}); + expect(mine.target.querySelector('[role="status"]')?.textContent?.trim()).toBe( + 'Còn 10 giây cho lượt của bạn' + ); + unmount(mine.component); + + turnEndingIn({ ms: 9_500, myTurn: false }); + const theirs = render(CountdownRing, {}); + expect(theirs.target.querySelector('[role="status"]')?.textContent?.trim()).toBe(''); + unmount(theirs.component); + }); +}); + +describe('PlayerStatus', () => { + /** @param {boolean} connected */ + function roomWithLan(connected) { + receive('roomState', { + roomCode: 'ABCD', + canStart: false, + maxPlayers: 4, + minPlayers: 2, + graceMs: 30_000, + players: [ + { playerId: 'p1', name: 'Minh', isMe: true, connected: true }, + { playerId: 'p2', name: 'Lan', isMe: false, connected } + ] + }); + flushSync(); + } + + beforeEach(() => { + vi.useFakeTimers({ now: 1_700_000_000_000 }); + }); + + it('announces who dropped in a live region that does not hold the countdown', () => { + roomWithLan(false); + const { target, component } = render(PlayerStatus, {}); + flushSync(); + + const banner = target.querySelector('[data-testid="away-p2"]'); + const live = banner?.querySelector('[role="status"]'); + expect(live?.textContent).toBe('Lan mất kết nối…'); + expect(live?.textContent).not.toMatch(/\d/); + + const counter = banner?.querySelector('[aria-hidden="true"]'); + expect(counter?.textContent).toBe('(30s)'); + unmount(component); + }); + + it('counts the grace window down, and drops the number at zero', () => { + roomWithLan(false); + const { target, component } = render(PlayerStatus, {}); + flushSync(); + + vi.advanceTimersByTime(10_000); + flushSync(); + expect(target.querySelector('[data-testid="away-p2"] [aria-hidden="true"]')?.textContent).toBe( + '(20s)' + ); + + vi.advanceTimersByTime(25_000); + flushSync(); + expect(target.querySelector('[data-testid="away-p2"] [aria-hidden="true"]')).toBeNull(); + expect(target.querySelector('[data-testid="away-p2"]')?.textContent).toContain('Lan mất kết nối'); + unmount(component); + }); + + it('takes the banner away when the player is back', () => { + roomWithLan(false); + const { target, component } = render(PlayerStatus, {}); + flushSync(); + expect(target.querySelector('[data-testid="away-p2"]')).not.toBeNull(); + + roomWithLan(true); + flushSync(); + + expect(target.querySelector('[data-testid="away-p2"]')).toBeNull(); + unmount(component); + }); +}); + +/** + * jsdom's Blob has no `text()`. + * @param {Blob} blob + * @returns {Promise} + */ +function readText(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); +} + +describe('GameOverPanel', () => { + /** @param {object} [fields] */ + function finish(fields = {}) { + receive('gameOver', { + iWon: true, + reason: GameEndReason.TIMEOUT, + chainLength: 6, + standings: [ + { playerId: 'p2', name: 'Lan', isMe: false, score: 9, rank: 2 }, + { playerId: 'p1', name: 'Minh', isMe: true, score: 14, rank: 1 } + ], + ...fields + }); + flushSync(); + } + + beforeEach(() => { + startGame(); + }); + + it('renders nothing until there is a result', () => { + const { target, component } = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + + expect(target.querySelector('[role="group"]')).toBeNull(); + unmount(component); + }); + + it('lists the standings by rank with the winner marked', () => { + finish(); + const { target, component } = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + + const rows = [...target.querySelectorAll('[data-testid="standings"] li')]; + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.querySelector('.rank')?.textContent)).toEqual(['2', '1']); + expect(rows[1].classList.contains('winner')).toBe(true); + expect(rows[1].classList.contains('me')).toBe(true); + expect(rows[0].classList.contains('winner')).toBe(false); + unmount(component); + }); + + it('shows no table for a one-row result', () => { + finish({ standings: [{ playerId: 'p1', name: 'Minh', isMe: true, score: 3, rank: 1 }] }); + const { target, component } = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + + expect(target.querySelector('[data-testid="standings"]')).toBeNull(); + unmount(component); + }); + + it('takes focus when the game ends', () => { + const { target, component } = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + + finish(); + + expect(document.activeElement).toBe(target.querySelector('[role="group"]')); + unmount(component); + }); + + it('leaves focus alone when the player is typing in another field', () => { + const chat = document.createElement('input'); + document.body.appendChild(chat); + chat.focus(); + const { component } = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + + finish(); + + expect(document.activeElement).toBe(chat); + unmount(component); + }); + + it('offers a rematch only when the screen supplies one', () => { + finish(); + const without = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + expect(without.target.querySelector('button.primary')).toBeNull(); + unmount(without.component); + + const onrematch = vi.fn(); + const withIt = render(GameOverPanel, { isRecord: false, onhome: () => {}, onrematch }); + withIt.target.querySelector('button.primary')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onrematch).toHaveBeenCalledTimes(1); + unmount(withIt.component); + }); + + it('hands the chain over as a text file when export is pressed', async () => { + finish(); + /** @type {Blob | null} */ + let saved = null; + URL.createObjectURL = (blob) => { + saved = /** @type {Blob} */ (blob); + return 'blob:test'; + }; + URL.revokeObjectURL = () => {}; + /** @type {string[]} */ + const names = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () { + names.push(/** @type {HTMLAnchorElement} */ (this).download); + }); + const { target, component } = render(GameOverPanel, { isRecord: false, onhome: () => {} }); + + target.querySelector('button.export')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(names).toHaveLength(1); + expect(names[0]).toMatch(/^noi-tu-\d{4}-\d{2}-\d{2}-\d{4}\.txt$/); + expect(await readText(/** @type {Blob} */ (saved))).toContain('bình yên'); + unmount(component); + }); +}); + +describe('ArmedButton', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + /** @param {object} [extra] */ + function armed(extra = {}) { + const onconfirm = vi.fn(); + const { target, component } = render(ArmedButton, { + label: 'Đầu hàng', + confirmLabel: 'Bấm lại để đầu hàng', + onconfirm, + ...extra + }); + /** @type {HTMLButtonElement} */ + const button = target.querySelector('button'); + const press = () => { + button.click(); + flushSync(); + }; + return { button, press, onconfirm, component }; + } + + it('arms on the first press without confirming, and says so', () => { + const { button, press, onconfirm, component } = armed(); + + press(); + + expect(onconfirm).not.toHaveBeenCalled(); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(button.getAttribute('aria-label')).toBe('Bấm lại để đầu hàng'); + unmount(component); + }); + + it('confirms on the second press and disarms', () => { + const { button, press, onconfirm, component } = armed(); + + press(); + press(); + + expect(onconfirm).toHaveBeenCalledTimes(1); + expect(button.getAttribute('aria-pressed')).toBe('false'); + unmount(component); + }); + + it('disarms by itself after the arm window, so a stray press is not a confirmation', () => { + const { button, press, onconfirm, component } = armed({ armMs: 1000 }); + + press(); + vi.advanceTimersByTime(1000); + flushSync(); + expect(button.getAttribute('aria-pressed')).toBe('false'); + + press(); + expect(onconfirm).not.toHaveBeenCalled(); + unmount(component); + }); + + it('disarms the moment it is disabled, and needs two presses again afterwards', () => { + const target = document.createElement('div'); + document.body.appendChild(target); + const onconfirm = vi.fn(); + const props = reactiveProps({ + label: 'Mời ra', + confirmLabel: 'Bấm lại để mời ra', + onconfirm, + disabled: false + }); + const component = mount(ArmedButton, { target, props }); + flushSync(); + /** @type {HTMLButtonElement} */ + const button = target.querySelector('button'); + + button.click(); + flushSync(); + expect(button.getAttribute('aria-pressed')).toBe('true'); + + props.disabled = true; + flushSync(); + expect(button.getAttribute('aria-pressed')).toBe('false'); + + props.disabled = false; + flushSync(); + button.click(); + flushSync(); + expect(onconfirm).not.toHaveBeenCalled(); + unmount(component); + }); +}); diff --git a/web/tests/word-input.test.js b/web/tests/word-input.test.js index 579844c..a4af322 100644 --- a/web/tests/word-input.test.js +++ b/web/tests/word-input.test.js @@ -100,18 +100,131 @@ describe('seeding the turn', () => { }); }); -describe('composing on the player\'s own turn', () => { - it('leaves an in-progress composition alone', () => { +/** Passes the turn to the other player, as the server's next update would. */ +function passTurn() { + receive('turnUpdate', { + currentSyllable: 'ninh', + myTurn: false, + deadlineUnixMs: 1_700_000_040_000n, + turnSeq: 2, + chainLength: 1, + players: [ + { playerId: 'p1', name: 'Minh', isMe: true, connected: true }, + { playerId: 'p2', name: 'Lan', isMe: false, connected: true } + ], + turnPlayerId: 'p2' + }); + flushSync(); +} + +/** @param {HTMLInputElement} input */ +function typeInto(input, text) { + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + flushSync(); +} + +describe('submitting', () => { + /** @param {boolean} accepted */ + function submitWith(accepted) { + startTurn({ currentSyllable: 'an' }); + /** @type {string[]} */ + const sent = []; + const { target, component } = render(WordInput, { + onsubmit: (/** @type {string} */ word) => { + sent.push(word); + return accepted; + }, + onreportword: () => {} + }); + /** @type {HTMLInputElement} */ + const input = target.querySelector('input'); + typeInto(input, 'an ninh'); + target.querySelector('form')?.requestSubmit(); + flushSync(); + return { input, sent, component }; + } + + it('clears the field once the word went out', () => { + const { input, sent, component } = submitWith(true); + + expect(sent).toEqual(['an ninh']); + expect(input.value).toBe(''); + unmount(component); + }); + + it('keeps the word when the socket refused it, so it can be sent again', () => { + const { input, sent, component } = submitWith(false); + + expect(sent).toEqual(['an ninh']); + expect(input.value).toBe('an ninh'); + unmount(component); + }); + + it('refuses to submit while an accent is still being composed', () => { + startTurn({ currentSyllable: 'an' }); + /** @type {string[]} */ + const sent = []; + const { target, component } = render(WordInput, { + onsubmit: (/** @type {string} */ word) => !!sent.push(word), + onreportword: () => {} + }); + /** @type {HTMLInputElement} */ + const input = target.querySelector('input'); + typeInto(input, 'an niệ'); + + input.dispatchEvent(new Event('compositionstart')); + target.querySelector('form')?.requestSubmit(); + flushSync(); + expect(sent).toEqual([]); + expect(input.value).toBe('an niệ'); + + input.dispatchEvent(new Event('compositionend')); + target.querySelector('form')?.requestSubmit(); + flushSync(); + expect(sent).toEqual(['an niệ']); + unmount(component); + }); +}); + +describe('out of turn', () => { + it('cancels typed text before it lands, and allows it on the player\'s own turn', () => { + startTurn({ currentSyllable: 'an' }); + const { input, component } = renderWordInput(); + + const own = new Event('beforeinput', { cancelable: true, bubbles: true }); + input.dispatchEvent(own); + expect(own.defaultPrevented).toBe(false); + + passTurn(); + const other = new Event('beforeinput', { cancelable: true, bubbles: true }); + input.dispatchEvent(other); + expect(other.defaultPrevented).toBe(true); + unmount(component); + }); + + it('puts back what a composition wrote past the guard', () => { + // beforeinput cannot be cancelled for a composition, so the text + // lands and has to be taken straight back out. + startTurn({ currentSyllable: 'an' }); + const { input, component } = renderWordInput(); + expect(input.value).toBe('an '); + + passTurn(); + input.dispatchEvent(new Event('compositionstart')); + typeInto(input, 'an niệ'); + + expect(input.value).toBe('an '); + unmount(component); + }); + + it('leaves the player\'s own text alone while it is their turn', () => { startTurn({ currentSyllable: 'an' }); const { input, component } = renderWordInput(); input.dispatchEvent(new Event('compositionstart')); - input.value = 'an niệ'; - input.dispatchEvent(new Event('input', { bubbles: true })); - flushSync(); + typeInto(input, 'an niệ'); - // undoInput only reverts out of turn; on the player's own turn it is - // a no-op, so a composed character in flight is never taken back. expect(input.value).toBe('an niệ'); input.dispatchEvent(new Event('compositionend')); unmount(component); diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js index 22ea461..5bd4a7e 100644 --- a/web/tests/ws-client.test.js +++ b/web/tests/ws-client.test.js @@ -5,7 +5,7 @@ // drawn from. All three are driven here through injected fakes, so the // assertions are about the client rather than about a real network. -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { create, toBinary } from '@bufbuild/protobuf'; import { PROTOCOL_VERSION, cancelQuickMatch, quickMatch } from '../src/lib/ws/messages.js'; import { @@ -18,6 +18,8 @@ import { PING_INTERVAL_MS, Status, createClient, + forgetStoredSession, + hasStoredSession, socketUrl } from '../src/lib/ws/client.js'; @@ -108,6 +110,8 @@ function setup(options = {}) { const received = []; /** @type {string[]} */ const statuses = []; + /** @type {true[]} */ + const refusals = []; const timers = fakeTimers(); let clock = 1_000; @@ -115,6 +119,7 @@ function setup(options = {}) { nickname: () => options.nickname ?? 'Minh', onMessage: (m) => received.push(m), onStatus: (s) => statuses.push(s), + onResumeRefused: () => refusals.push(true), url: 'ws://localhost/ws', socketFactory: (url) => { const s = new FakeSocket(url); @@ -132,6 +137,7 @@ function setup(options = {}) { sockets, received, statuses, + refusals, timers, // The ping interval uses the same timer double, so the reconnect // schedule has to be read out from under it. @@ -628,3 +634,219 @@ describe('a socket that dies without closing', () => { expect(socket.closed).toBe(false); }); }); + + +const welcome = () => + serverMsg('welcome', { + sessionId: 's1', + resumeToken: 'token-1', + protocolVersion: 1, + acceptedNickname: 'Minh' + }); + +describe('a resume the server refuses', () => { + /** Opens a socket whose Hello carries a stored token, and greets it. */ + function resumeAttempt() { + sessionStorage.setItem('noitu.resumeToken', 'old-token'); + const h = setup(); + h.client.connect(); + h.last().open(); + h.last().deliver(welcome()); + return h; + } + + it('reports a refusal that is the first frame after the Welcome', () => { + const h = resumeAttempt(); + + h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' })); + + expect(h.refusals).toHaveLength(1); + }); + + it('reports a game_already_over refusal the same way', () => { + const h = resumeAttempt(); + + h.last().deliver(serverMsg('error', { code: 'game_already_over', message: '' })); + + expect(h.refusals).toHaveLength(1); + }); + + it('spends the token, so the next reconnect does not ask for the same refusal', async () => { + const h = resumeAttempt(); + h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' })); + + h.last().drop(); + h.timers.flush(); + h.last().open(); + + const [hello] = await sentMessages(h.last()); + expect(hello.payload.value.resumeToken).toBe(''); + }); + + it('still forwards the error, after reporting the refusal', () => { + const h = resumeAttempt(); + h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' })); + + expect(h.received.map((m) => m.payload.case)).toEqual(['welcome', 'error']); + }); + + it('looks past a Pong that lands between the Welcome and the refusal', () => { + const h = resumeAttempt(); + h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 1000n })); + + h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' })); + + expect(h.refusals).toHaveLength(1); + }); + + it('does not report an error that follows the restored room', () => { + const h = resumeAttempt(); + h.last().deliver(serverMsg('roomState', { roomCode: 'ABCD', players: [] })); + + h.last().deliver(serverMsg('error', { code: 'not_your_turn', message: '' })); + + expect(h.refusals).toEqual([]); + }); + + it('does not report an error on a Hello that carried no token', () => { + const h = setup(); + h.client.connect(); + h.last().open(); + h.last().deliver(welcome()); + + h.last().deliver(serverMsg('error', { code: 'room_not_found', message: '' })); + + expect(h.refusals).toEqual([]); + }); + + it('watches each connection separately: a fresh Hello after a refusal is not a resume', () => { + const h = resumeAttempt(); + h.last().deliver(serverMsg('error', { code: 'session_not_resumable', message: '' })); + h.last().drop(); + h.timers.flush(); + h.last().open(); + h.last().deliver(welcome()); + + h.last().deliver(serverMsg('error', { code: 'room_not_found', message: '' })); + + expect(h.refusals).toHaveLength(1); + }); +}); + +describe('clock offset filtering', () => { + it('keeps the sample with the shortest round trip, so a slow pong does not move it', () => { + const h = setup(); + h.client.connect(); + h.last().open(); + + // Quick exchange: sent 1000, read at 1040, server stamped 5020. The + // server clock is 5020 + 20 - 1040 = 4000 ahead. + h.setClock(1040); + h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5020n })); + expect(h.client.clockOffset()).toBe(4000); + + // A congested one, whose midpoint estimate is 350ms out. + h.setClock(3000); + h.last().deliver(serverMsg('pong', { clientTimeMs: 2000n, serverTimeMs: 6850n })); + + expect(h.client.clockOffset()).toBe(4000); + }); + + it('follows a faster sample when one comes', () => { + const h = setup(); + h.client.connect(); + h.last().open(); + h.setClock(1200); + h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5150n })); + + h.setClock(2020); + h.last().deliver(serverMsg('pong', { clientTimeMs: 2000n, serverTimeMs: 6010n })); + + // rtt 20: 6010 + 10 - 2020 = 4000 + expect(h.client.clockOffset()).toBe(4000); + }); + + it('forgets a fast sample once enough newer ones have pushed it out', () => { + const h = setup(); + h.client.connect(); + h.last().open(); + h.setClock(1010); + h.last().deliver(serverMsg('pong', { clientTimeMs: 1000n, serverTimeMs: 5005n })); + expect(h.client.clockOffset()).toBe(4000); + + // The device clock drifts against the server's, and every later probe + // says so. Five of them replace the old best. + for (let i = 0; i < 5; i++) { + const sent = 2000 + i * 1000; + h.setClock(sent + 100); + h.last().deliver(serverMsg('pong', { clientTimeMs: BigInt(sent), serverTimeMs: BigInt(sent + 4150) })); + } + + expect(h.client.clockOffset()).toBe(4000 + 100); + }); +}); + +describe('storage that refuses', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reads as no session when the read throws', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied'); + }); + const h = setup(); + + expect(hasStoredSession()).toBe(false); + h.client.connect(); + h.last().open(); + + const [hello] = await sentMessages(h.last()); + expect(hello.payload.value.resumeToken).toBe(''); + }); + + it('keeps the session going when saving the token throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + const h = setup(); + h.client.connect(); + h.last().open(); + + expect(() => h.last().deliver(welcome())).not.toThrow(); + expect(h.received.map((m) => m.payload.case)).toEqual(['welcome']); + }); + + it('forgets quietly when removing the token throws', () => { + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('denied'); + }); + const h = setup(); + + expect(() => h.client.forgetSession()).not.toThrow(); + expect(() => forgetStoredSession()).not.toThrow(); + }); + + it('works with no storage at all, as a private-mode browser can leave it', async () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + get() { + throw new Error('SecurityError'); + } + }); + try { + const h = setup(); + expect(hasStoredSession()).toBe(false); + h.client.connect(); + h.last().open(); + expect(() => h.last().deliver(welcome())).not.toThrow(); + expect(() => h.client.forgetSession()).not.toThrow(); + + const [hello] = await sentMessages(h.last()); + expect(hello.payload.value.resumeToken).toBe(''); + } finally { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + } + }); +}); diff --git a/web/vite.config.js b/web/vite.config.js index bcb58a2..a8d4ca5 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -24,7 +24,7 @@ export default defineConfig({ test: { // The default node environment: the cross-language fixture test reads // files through import.meta.url, which jsdom rewrites to an http URL. - // The two suites that need a DOM opt in with a per-file docblock. + // The suites that need a DOM opt in with a per-file docblock. // e2e/ is Playwright's; Vitest must not try to run browser specs. include: ['tests/**/*.test.js'], exclude: ['e2e/**', 'node_modules/**', 'build/**'],