mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 12:28:54 +00:00
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.
402 lines
13 KiB
JavaScript
402 lines
13 KiB
JavaScript
// @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);
|
||
});
|
||
});
|