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 @@
+
+ {fill(t.playerDisconnected, { name: player.name || t.someone })}
{#if seconds > 0}
- {fill(t.playerDisconnectedIn, { name: player.name || t.someone, n: seconds })}
- {:else}
- {fill(t.playerDisconnected, { name: player.name || t.someone })}
+ {fill(t.playerDisconnectedSeconds, { n: seconds })}
{/if}
{/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}
+
+
+ {t.rematch}
+
+{/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/**'],