mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-12 20:15:50 +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.
262 lines
7.7 KiB
JavaScript
262 lines
7.7 KiB
JavaScript
// @vitest-environment jsdom
|
|
|
|
// The word field's three direct writes — the turn seed, the suggestion fill,
|
|
// and the submit-clear — are the exceptions to an otherwise fully
|
|
// uncontrolled field, so each is worth pinning down: the seed fires once per
|
|
// turn and never during the player's own composition, the suggestion lands
|
|
// on click, and the seed itself is gated on the connection as well as the
|
|
// turn.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { unmount, flushSync } from 'svelte';
|
|
import WordInput from '../src/lib/components/WordInput.svelte';
|
|
import { RejectReason } 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 as startTurn } from './component-support.js';
|
|
|
|
/** @param {string} suggestion */
|
|
function rejectWith(suggestion) {
|
|
receive('moveRejected', {
|
|
reason: RejectReason.NOT_IN_DICTIONARY,
|
|
word: 'binh yen',
|
|
turnSeq: 1,
|
|
suggestion
|
|
});
|
|
}
|
|
|
|
function renderWordInput() {
|
|
const { target, component } = render(WordInput, {
|
|
onsubmit: () => true,
|
|
onreportword: () => {}
|
|
});
|
|
/** @type {HTMLInputElement} */
|
|
const input = target.querySelector('input');
|
|
return { target, component, input };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
game.leave();
|
|
connection.status = Status.OPEN;
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
connection.status = Status.CLOSED;
|
|
});
|
|
|
|
describe('seeding the turn', () => {
|
|
it('fills the field with the current syllable once the turn starts', () => {
|
|
startTurn({ currentSyllable: 'an' });
|
|
const { input, component } = renderWordInput();
|
|
|
|
expect(input.value).toBe('an ');
|
|
unmount(component);
|
|
});
|
|
|
|
it('does not seed, or focus, while the connection is down', () => {
|
|
// Seeding during a reconnect wrote into a field the player could not
|
|
// submit from, and the first composition event then undid it.
|
|
connection.status = Status.CLOSED;
|
|
startTurn({ currentSyllable: 'an' });
|
|
const { input, component } = renderWordInput();
|
|
|
|
expect(input.value).toBe('');
|
|
expect(document.activeElement).not.toBe(input);
|
|
unmount(component);
|
|
});
|
|
|
|
it('does not reseed the same turn once the player has started typing', () => {
|
|
startTurn({ turnSeq: 5, currentSyllable: 'an' });
|
|
const { input, component } = renderWordInput();
|
|
expect(input.value).toBe('an ');
|
|
|
|
input.value = 'an ninh';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
flushSync();
|
|
|
|
// A connection blip and recovery re-evaluates the seeding effect
|
|
// (enabled depends on connection.status) without the turn changing.
|
|
connection.status = Status.CLOSED;
|
|
flushSync();
|
|
connection.status = Status.OPEN;
|
|
flushSync();
|
|
|
|
expect(input.value).toBe('an ninh');
|
|
unmount(component);
|
|
});
|
|
|
|
it('seeds again for a new turn', () => {
|
|
startTurn({ turnSeq: 1, currentSyllable: 'an' });
|
|
const { input, component } = renderWordInput();
|
|
input.value = 'an ninh';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
startTurn({ turnSeq: 2, currentSyllable: 'ninh' });
|
|
flushSync();
|
|
|
|
expect(input.value).toBe('ninh ');
|
|
unmount(component);
|
|
});
|
|
});
|
|
|
|
/** 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'));
|
|
typeInto(input, 'an niệ');
|
|
|
|
expect(input.value).toBe('an niệ');
|
|
input.dispatchEvent(new Event('compositionend'));
|
|
unmount(component);
|
|
});
|
|
});
|
|
|
|
describe('a rejected word\'s suggestion', () => {
|
|
it('fills the field with the suggestion on click', () => {
|
|
startTurn({ currentSyllable: 'an' });
|
|
const { target, input, component } = renderWordInput();
|
|
input.value = 'binh yen';
|
|
rejectWith('bình yên');
|
|
flushSync();
|
|
|
|
/** @type {HTMLButtonElement | null} */
|
|
const suggestion = target.querySelector('.suggestion');
|
|
expect(suggestion).not.toBeNull();
|
|
suggestion?.click();
|
|
flushSync();
|
|
|
|
expect(input.value).toBe('bình yên');
|
|
unmount(component);
|
|
});
|
|
|
|
it('offers no suggestion button when the server sent none', () => {
|
|
startTurn({ currentSyllable: 'an' });
|
|
const { target, component } = renderWordInput();
|
|
rejectWith('');
|
|
flushSync();
|
|
|
|
expect(target.querySelector('.suggestion')).toBeNull();
|
|
unmount(component);
|
|
});
|
|
});
|