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.
This commit is contained in:
tiennm99 committed 2026-09-29 20:33:16 +07:00
1 parent 81234b5ab9
commit 1f2624c0e3
26 files changed
+1460 -153

No files matched your search

+30
View File
@@ -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
@@ -1,5 +1,10 @@
<script>
import { t } from '$lib/i18n/vi.js';
// The record of what was changed from the source data. Nothing serves it
// over HTTP from the app itself, so it is linked where it lives, in the
// repository the app is built from.
const CHANGES_URL = 'https://github.com/tiennm99/noitu/blob/main/data/ATTRIBUTION.md';
</script>
<!--
@@ -20,6 +25,8 @@
>
{t.attributionLicense}
</a>.
{t.attributionModified}
<a href={CHANGES_URL} target="_blank" rel="noreferrer noopener">{t.attributionChanges}</a>.
</p>
</footer>
+11 -20
View File
@@ -3,6 +3,7 @@
import { fill, t } from '$lib/i18n/vi.js';
import { scrollBehavior } from '$lib/motion.js';
import { game } from '$lib/stores/game.svelte.js';
import { Status, connection } from '$lib/ws/connection.svelte.js';
/**
* The room's conversation. Everything it shows comes from the server: the
@@ -28,7 +29,7 @@
* @type {{
* collapsible?: boolean,
* column?: boolean,
* onsend: (text: string) => void,
* onsend: (text: string) => boolean,
* folded?: boolean,
* unread?: number
* }}
@@ -77,6 +78,10 @@
}
const sendable = $derived(hasContent(draft));
// A line sent into a closed socket goes nowhere, and clearing the field
// over it would lose what the player typed. Offline the button is off, as
// the word field's is.
const offline = $derived(connection.status !== Status.OPEN);
// The field unmounts while the panel is folded and comes back empty, so
// the draft is written back into it on the way in: what was typed survives
@@ -130,7 +135,7 @@
// Enter can commit an accent rather than the form. Sending here would
// post a half-typed word and swallow the keystroke that was choosing
// the diacritic.
if (composing || !sendable || !field) return;
if (composing || !sendable || offline || !field) return;
// Re-tested against the element rather than the mirror: a form value the
// browser restored without firing `input` would otherwise be sent
@@ -138,7 +143,9 @@
const text = field.value;
if (!hasContent(text)) return;
onsend(text);
// Cleared only for a line that went out: a refused send leaves the
// text where the player can send it again.
if (!onsend(text)) return;
draft = '';
field.value = '';
}
@@ -210,7 +217,7 @@
oncompositionstart={() => (composing = true)}
oncompositionend={() => (composing = false)}
/>
<button type="submit" disabled={!sendable} data-testid="chat-send">{t.submit}</button>
<button type="submit" class="primary" disabled={!sendable || offline} data-testid="chat-send">{t.submit}</button>
</form>
{/if}
</section>
@@ -331,22 +338,6 @@
padding: var(--space-3) 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;
}
.row button:hover:not(:disabled) {
background: var(--accent-hover);
}
.row button:active:not(:disabled) {
background: var(--accent-pressed);
}
.row button:disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
</style>
+12 -16
View File
@@ -1,4 +1,5 @@
<script>
import { typingElsewhere } from '$lib/focus.js';
import { chainToText, downloadText, historyFilename } from '$lib/history-export.js';
import { endReasonMessages, fill, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
@@ -30,8 +31,12 @@
// The panel takes focus, not the rematch button. A player who just pressed
// Enter to submit a word may still be holding it, and a focused button
// under that key would start the next game before they had read this one.
//
// Not while the player is typing elsewhere, the chat beside the board in
// the wide layout: a game ending under a half-written message must not
// send the rest of it nowhere.
$effect(() => {
if (game.state.result) panel?.focus();
if (game.state.result && !typingElsewhere()) panel?.focus();
});
/** Hands the finished chain to the player as a text file to keep. */
@@ -306,25 +311,16 @@
min-width: 120px;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-strong);
border-width: 1px;
border-style: solid;
border-radius: var(--radius-sm);
background: var(--surface);
font-weight: 600;
}
.actions .primary {
border-color: transparent;
background: var(--accent);
color: var(--accent-text);
transition: background-color 150ms ease-out;
}
.actions .primary:hover {
background: var(--accent-hover);
}
.actions .primary:active {
background: var(--accent-pressed);
/* The accent button takes its colours from the shared .primary. */
.actions button:where(:not(.primary)) {
border-color: var(--border-strong);
background: var(--surface);
}
/* Keeping the chain is worth offering and not worth pressing first. */
+6 -4
View File
@@ -65,11 +65,13 @@
{#each away as player (player.playerId)}
{@const seconds = secondsLeft(player.playerId)}
<p class="banner" role="status" data-testid={`away-${player.playerId}`}>
<!-- The live region holds the sentence only. The seconds change every
second and sit outside it, so a screen reader hears who dropped once
rather than a fresh announcement per tick. -->
<p class="banner" data-testid={`away-${player.playerId}`}>
<span role="status">{fill(t.playerDisconnected, { name: player.name || t.someone })}</span>
{#if seconds > 0}
{fill(t.playerDisconnectedIn, { name: player.name || t.someone, n: seconds })}
{:else}
{fill(t.playerDisconnected, { name: player.name || t.someone })}
<span aria-hidden="true">{fill(t.playerDisconnectedSeconds, { n: seconds })}</span>
{/if}
</p>
{/each}
+3 -35
View File
@@ -1,4 +1,5 @@
<script>
import { typingElsewhere } from '$lib/focus.js';
import { fill, t } from '$lib/i18n/vi.js';
import { RejectReason } from '$lib/proto/noitu/v1/game_pb.js';
import { game } from '$lib/stores/game.svelte.js';
@@ -41,23 +42,6 @@
/** @type {unknown} */
let seededRejection = null;
/**
* Whether the player is typing somewhere else — the chat, in practice,
* which now sits beside the board rather than folded away under it.
*
* Taking focus off a field somebody is mid-sentence in would drop the rest
* of that sentence into the word field, so a turn arriving is allowed to
* ask for focus only when nothing else holds it.
*/
function typingElsewhere() {
const active = document.activeElement;
if (!active || active === field) return false;
return (
active instanceof HTMLElement &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
);
}
/**
* What the field held when the turn passed, so anything typed into it out
* of turn can be put back. A plain let: nothing renders it, and making it
@@ -112,7 +96,7 @@
// The seeding below happens either way: it writes into the field
// without disturbing wherever the player actually is.
if (!typingElsewhere()) field?.focus();
if (!typingElsewhere(field)) field?.focus();
if (!field || (turn === seededTurn && rejection === seededRejection)) return;
seededTurn = turn;
seededRejection = rejection;
@@ -220,7 +204,7 @@
/>
<!-- Named, because the chat's send button says the same word: both are a
"Gửi", and only a test can tell them apart by where they are. -->
<button type="submit" disabled={!enabled} data-testid="word-submit">{t.submit}</button>
<button type="submit" class="primary" disabled={!enabled} data-testid="word-submit">{t.submit}</button>
</form>
{#if game.state.rejection}
@@ -297,23 +281,7 @@
padding: var(--space-4) var(--space-5);
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
transition: background-color 150ms ease-out;
}
.input-row button:hover:not(:disabled) {
background: var(--accent-hover);
}
.input-row button:active:not(:disabled) {
background: var(--accent-pressed);
}
.input-row button:disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
.rejection {
+17
View File
@@ -0,0 +1,17 @@
/**
* Whether the player is typing in a field other than `own`. Taking focus off
* a field somebody is mid-sentence in would drop the rest of that sentence
* into whatever took it, so anything that grabs focus on its own schedule (a
* turn arriving, a game ending) asks first.
* @param {Element | null | undefined} [own] - the caller's own field, which
* holding focus does not count as typing elsewhere
* @returns {boolean}
*/
export function typingElsewhere(own) {
const active = document.activeElement;
if (!active || active === own) return false;
return (
active instanceof HTMLElement &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
);
}
+13 -1
View File
@@ -40,6 +40,12 @@ export function historyFilename(at = new Date()) {
* `nameOf` resolves a seat id to the name that seat was playing under. It is
* passed in rather than read from the store so the transcript can be built and
* tested without one.
*
* A player who rejoined mid-game only holds the words since they came back,
* because the server's replay carries the opening word and the latest move
* and nothing between. The result's chain length, which counts the opening
* word, is the authority for numbering: words are numbered back from it, and
* a `…` line marks the stretch that is missing.
* @param {object} args
* @param {import('$lib/stores/game.svelte.js').ChainEntry[]} args.chain
* @param {{ iWon: boolean, myScore: number, chainLength: number } | null} [args.result]
@@ -59,10 +65,16 @@ export function chainToText({ chain, result = null, nameOf = () => '', at = new
lines.push('');
const total = Math.max(result?.chainLength ?? 0, chain.length);
const missing = total > chain.length;
chain.forEach((entry, index) => {
const number = `${index + 1}. ${entry.word}`;
// The opening word is the first word of any chain, held in full or not.
const position = entry.opening ? 1 : total - (chain.length - 1 - index);
const number = `${position}. ${entry.word}`;
if (entry.opening) {
lines.push(`${number} (${t.exportOpening})`);
if (missing) lines.push(t.exportGap);
return;
}
// A four-way chain has to say which of the others played a word, not
+5 -1
View File
@@ -104,6 +104,7 @@ export const t = {
noSuggestions: 'Không còn từ nào bắt đầu bằng tiếng “{syllable}”. Ai gặp thế này cũng chịu thôi!',
exportHistory: 'Tải chuỗi từ',
exportOpening: 'từ mở đầu',
exportGap: '…',
bestScore: 'Kỷ lục',
// The chain row's score-breakdown chips, named for a screen reader since
// the chips themselves carry no heading of their own.
@@ -187,7 +188,7 @@ export const t = {
unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.',
playerDisconnected: '{name} mất kết nối…',
playerDisconnectedIn: '{name} mất kết nối… ({n}s)',
playerDisconnectedSeconds: '({n}s)',
// Said once, in the board's own spectating box, alongside whatever the
// position that knocked this player out still had left in it — a second
// banner used to say the same thing 60px above it.
@@ -202,6 +203,8 @@ export const t = {
attributionSource: 'Wiktionary tiếng Việt',
attributionLicense: 'giấy phép CC BY-SA 4.0',
attributionMiddle: 'phát hành theo',
attributionModified: 'Dữ liệu đã được chỉnh sửa:',
attributionChanges: 'xem danh sách thay đổi',
// The /rules route. One page with anchors, not an accordion: the game has
// one set of rules, not a set the reader has to tap open one at a time.
@@ -379,6 +382,7 @@ export const errorMessages = {
too_many_attempts: 'Bạn thử vào phòng quá nhiều lần. Hãy đợi một lát.',
too_many_rooms: 'Bạn tạo phòng quá nhanh. Hãy đợi một lát.',
unknown_difficulty: 'Độ khó không hợp lệ.',
unknown_message: 'Máy chủ không hiểu yêu cầu này. Hãy tải lại trang.',
word_report_limit: 'Bạn đã báo quá nhiều từ trong phiên này.',
word_report_refused: 'Từ này phải có ít nhất 2 tiếng mới báo được.'
};
+18 -3
View File
@@ -22,7 +22,7 @@ export const CHAT_WINDOW = 20;
* Error codes that also end this connection's membership of the room, so the
* model has to stop describing one.
*/
const LEAVES_ROOM = new Set(['kicked', 'room_idle_closed']);
const LEAVES_ROOM = new Set(['kicked', 'room_idle_closed', 'session_not_resumable']);
/**
* A false dead-end claim, and a resign or claim that raced the turn moving
@@ -241,13 +241,28 @@ export function applyTo(state, msg, { reset, leave }) {
}
break;
case 'chatHistory':
case 'chatHistory': {
// A snapshot replaces; it never merges. It is also what a
// client arriving in a new room is given, so a conversation
// cannot outlive the room it was had in.
state.chat = payload.value.messages.map(toChatLine);
//
// A line already on screen keeps its ordinal, though. The
// replay after every reconnect resends the whole window, and
// renumbering it re-keyed every row inside a live region, which
// a screen reader took for twenty new messages.
/** @type {ChatLine[]} */
const unclaimed = [...state.chat];
state.chat = payload.value.messages.map((m) => {
const line = toChatLine(m);
const at = unclaimed.findIndex(
(old) => old.atMs === line.atMs && old.playerId === line.playerId && old.text === line.text
);
if (at >= 0) line.n = unclaimed.splice(at, 1)[0].n;
return line;
});
state.chatCount = state.chat.length;
break;
}
case 'error': {
const code = payload.value.code;
+10 -2
View File
@@ -113,9 +113,17 @@ export function createGameStore() {
get freeSeats() {
return Math.max(0, state.maxPlayers - state.roomPlayers.length);
},
/** Whether this player has been knocked out of the game still running. */
/**
* Whether this player has been knocked out of the game still running.
* The player's own row is checked as well as the elimination frame: a
* player who reloads after being knocked out is replayed a fresh
* GameStarted, which carries the row but not the frame.
*/
get iAmOut() {
return state.phase === 'playing' && state.elimination !== null;
return (
state.phase === 'playing' &&
(state.elimination !== null || state.gamePlayers.some((p) => p.isMe && p.eliminated))
);
},
/** This player's score in the game on screen, finished or not. */
get myScore() {
+59 -9
View File
@@ -48,6 +48,13 @@ const TERMINAL_ERROR_CODES = new Set(['protocol_version_mismatch']);
const RESUME_KEY = 'noitu.resumeToken';
/**
* How many recent clock probes are kept. The offset comes from the one with
* the shortest round trip among them, because its one-way delay estimate
* (half the round trip) can be wrong by the least.
*/
const CLOCK_SAMPLES = 5;
/**
* Resolves the socket URL from the page's own origin.
*
@@ -94,6 +101,21 @@ export function hasStoredSession() {
}
}
/**
* Drops this tab's resume token so the next Hello starts a fresh session.
*
* Module-level rather than a client method because a screen that wants a
* fresh game has to forget a token left by an earlier tab or game before it
* has a client to ask.
*/
export function forgetStoredSession() {
try {
safeSessionStorage()?.removeItem(RESUME_KEY);
} catch {
// Same as storeToken: nothing to recover.
}
}
/**
* Creates the socket client.
*
@@ -104,6 +126,9 @@ export function hasStoredSession() {
* changed between attempts is the one the server is told about
* @param {(msg: ServerMessage) => void} options.onMessage
* @param {(status: string) => void} [options.onStatus]
* @param {() => void} [options.onResumeRefused] - the server answered a Hello
* that carried a resume token with an error instead of restoring the seat,
* so whatever room the UI still shows is gone
* @param {string} [options.url]
* @param {(url: string) => WebSocket} [options.socketFactory]
* @param {() => number} [options.now]
@@ -115,6 +140,7 @@ export function createClient({
nickname,
onMessage,
onStatus = () => {},
onResumeRefused = () => {},
url = socketUrl(globalThis.location ?? { protocol: 'http:', host: 'localhost' }),
socketFactory = (u) => new WebSocket(u),
now = () => Date.now(),
@@ -133,6 +159,15 @@ export function createClient({
/** @type {ReturnType<typeof setTimeout> | null} */
let pingTimer = null;
let clockOffsetMs = 0;
/** @type {{ rtt: number, offset: number }[]} */
let clockSamples = [];
// True from a Welcome that answered a token-carrying Hello until the next
// frame that is not a Pong. A resume is answered with the Welcome and then
// either the restored room or an error, in that order, so an error in this
// window is the refusal.
let awaitingResume = false;
// Whether the Hello sent on the current socket carried a resume token.
let helloResumed = false;
let status = Status.CLOSED;
// When a frame last arrived. Any frame counts: the point is whether the
// socket still carries traffic, not which message proved it.
@@ -225,7 +260,10 @@ export function createClient({
// Hello goes out before the status is announced. The server refuses
// every other message until the handshake lands, and a listener
// reacting to "open" by sending something would otherwise race it.
send(hello({ nickname: nickname(), resumeToken: storedToken() }));
const token = storedToken();
helloResumed = token !== '';
awaitingResume = false;
send(hello({ nickname: nickname(), resumeToken: token }));
// Probe the clock immediately rather than waiting out the first
// interval. GameStarted arrives about one round trip after Hello, so
// a deferred first probe would leave the opening turn counting down
@@ -275,12 +313,23 @@ export function createClient({
*/
function intercept(msg) {
const payload = msg.payload;
if (awaitingResume && payload.case !== 'pong') {
awaitingResume = false;
if (payload.case === 'error') {
// The token is spent, and the room the UI still shows is not
// this connection's any more. Dropping the token keeps the next
// reconnect from asking for the same refusal again.
forgetStoredSession();
onResumeRefused();
}
}
if (payload.case === 'welcome') {
// The backoff resets here, not when the socket opens. A server that
// accepts the connection and then rejects the handshake would
// otherwise look like a success to the schedule, and every retry
// would start again from the shortest delay.
attempt = 0;
awaitingResume = helloResumed;
if (payload.value.resumeToken) storeToken(payload.value.resumeToken);
return;
}
@@ -295,7 +344,14 @@ export function createClient({
// Half the round trip is the best estimate of the one-way delay, so
// the server's timestamp is compared against the midpoint of the
// exchange rather than against either end of it.
clockOffsetMs = serverTime + (received - sent) / 2 - received;
const rtt = received - sent;
clockSamples.push({ rtt, offset: serverTime + rtt / 2 - received });
if (clockSamples.length > CLOCK_SAMPLES) clockSamples.shift();
// A probe that crossed a congested moment overstates the delay by
// an unknown, lopsided amount. The quickest recent probe is the
// least contaminated, and using it keeps the countdown from
// jumping by the jitter of whichever pong happened to land last.
clockOffsetMs = clockSamples.reduce((best, s) => (s.rtt < best.rtt ? s : best)).offset;
}
}
@@ -348,12 +404,6 @@ export function createClient({
setStatus(Status.CLOSED);
},
/** Forgets the resume token so the next Hello starts a fresh session. */
forgetSession() {
try {
safeSessionStorage()?.removeItem(RESUME_KEY);
} catch {
// Same as storeToken: nothing to recover.
}
}
forgetSession: forgetStoredSession
};
}
+15 -5
View File
@@ -1,4 +1,4 @@
import { Status, createClient, hasStoredSession } from './client.js';
import { Status, createClient, forgetStoredSession, hasStoredSession } from './client.js';
import { claimDeadEnd, reportWord, resign, submitWord } from './messages.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
@@ -17,12 +17,22 @@ const state = $state({ status: Status.CLOSED });
/** @type {ReturnType<typeof createClient> | null} */
let client = null;
/** Opens the socket if it is not already open. Safe to call from any route. */
export function connect() {
/**
* Opens the socket if it is not already open. Safe to call from any route.
* @param {{ freshSession?: boolean }} [options] - `freshSession` forgets any
* resume token this tab holds before a new socket is opened, for a screen
* that is about to ask for a game of its own. It does nothing when a socket
* already exists: that socket's token belongs to the game it is in.
*/
export function connect({ freshSession = false } = {}) {
if (client) return;
if (freshSession) forgetStoredSession();
client = createClient({
nickname: () => settings.state.nickname,
onMessage: (msg) => game.apply(msg),
// Runs before the refusing error itself is applied, so the board is
// cleared first and the error is still shown afterwards.
onResumeRefused: () => game.leave(),
onStatus: (status) => {
state.status = status;
}
@@ -71,7 +81,7 @@ export function serverNow() {
* and the player may well want to start something new on it.
*/
export function forgetSession() {
client?.forgetSession();
forgetStoredSession();
}
/**
@@ -83,7 +93,7 @@ export function forgetSession() {
* abandoned seat back to a connection that no longer wants it.
*/
export function disconnect() {
client?.forgetSession();
forgetStoredSession();
client?.close();
client = null;
state.status = Status.CLOSED;
+7 -16
View File
@@ -72,28 +72,19 @@
.actions > * {
min-height: 44px;
padding: var(--space-4);
border: 1px solid var(--border-strong);
border-width: 1px;
border-style: solid;
border-radius: var(--radius-sm);
background: var(--surface);
color: inherit;
font-weight: 600;
text-align: center;
text-decoration: none;
}
.actions .primary {
border-color: transparent;
background: var(--accent);
color: var(--accent-text);
transition: background-color 150ms ease-out;
}
.actions .primary:hover {
background: var(--accent-hover);
}
.actions .primary:active {
background: var(--accent-pressed);
/* The accent button takes its colours from the shared .primary. */
.actions > :where(:not(.primary)) {
border-color: var(--border-strong);
background: var(--surface);
color: inherit;
}
/* Its own line under the two big taps, not one more of them: this is a
+44 -20
View File
@@ -218,10 +218,42 @@
// Held requests go out once the handshake has landed — both the one that
// opens or joins a room, and any lobby action the socket refused while it
// was down.
//
// A lobby action is held back further while the seat is being reclaimed.
// After a drop the socket is open as soon as Hello is out, but the server
// re-attaches the seat afterwards, so an action sent in between is
// answered "not in a room" and lost. The RoomState the resume sends is the
// proof the seat is back, and it replaces the roster, so a new roster
// object is what releases the action.
//
// Plain lets, not state: they only steer this effect, which already
// re-runs on the two things it reads.
let seatLost = false;
/** @type {unknown} */
let staleRoster = null;
$effect(() => {
const open = connection.status === Status.OPEN;
const roster = game.state.roomPlayers;
untrack(() => {
flush(open);
if (!open) {
if (inRoom) seatLost = true;
return;
}
if (seatLost) {
if (!inRoom) {
// The resume was refused and the room is gone: an action
// aimed at it has nothing left to act on.
seatLost = false;
session.clearAction();
return;
}
if (staleRoster === null) staleRoster = roster;
if (roster === staleRoster) return;
seatLost = false;
staleRoster = null;
}
session.flushAction(open, dispatchAction);
});
});
@@ -404,18 +436,26 @@
* enabled when this client already knows the rule allows it.
*/
function leave() {
act({ kind: 'leaveRoom' });
// Sent if the socket can carry it and otherwise dropped, never held: the
// token is forgotten below, so a held LeaveRoom would be flushed on a
// fresh session and answered "not in a room". The seat is freed by the
// reconnect window running out either way.
dispatchAction({ kind: 'leaveRoom' });
game.leave();
session.clearPending();
session.clearAction();
// Matches the page-teardown path: leaving deliberately must not leave
// a token behind for the next load of /online to resume with — the
// player just walked out of this room on purpose.
forgetSession();
}
/** @param {string} text */
/**
* @param {string} text
* @returns {boolean} whether the line reached the server
*/
function say(text) {
send(sendChat(text));
return send(sendChat(text));
}
</script>
@@ -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 {
+20 -1
View File
@@ -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 @@
<GameOverPanel {isRecord} onrematch={rematch} onhome={goHome} />
{/snippet}
</GameBoard>
{#if game.state.phase === 'idle' && game.state.error}
<!-- A resume the server refused leaves no game to return to: the board is
empty under the banner, and the way forward is a fresh game on the same
rung. -->
<p class="restart">
<button class="primary" type="button" onclick={rematch}>{t.rematch}</button>
</p>
{/if}
<style>
.restart {
margin: var(--space-4) 0 0;
text-align: center;
}
</style>
+61 -10
View File
@@ -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"]');
+134
View File
@@ -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.');
});
});
+139
View File
@@ -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);
});
});
+32
View File
@@ -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 });
+60 -1
View File
@@ -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<string, string>} */ (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);
});
});
+12
View File
@@ -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<string, unknown>} T
* @param {T} initial
* @returns {T}
*/
export function reactiveProps(initial) {
const props = $state(initial);
return props;
}
+401
View File
@@ -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<string>}
*/
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);
});
});
+120 -7
View File
@@ -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);
+223 -1
View File
@@ -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);
}
});
});
+1 -1
View File
@@ -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/**'],