Files
noitu/web/tests/status-components.test.js
tiennm99 1f2624c0e3 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.
2026-09-29 20:33:16 +07:00

402 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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);
});
});