Files
noitu/web/tests/game-store.test.js
T
tiennm99 4fe7ef4f11 feat(web): slim the board header and stop the claim/resign row from churning
At 360px the header used to open on two rows of chrome: the connection badge
said "Đã kết nối" in full while it was true, "Luật chơi" was a full-width
text link, and the room code sat crowded in between. The badge now shows
only its dot once open, the rules link is a 44px icon with an aria-label,
and the room code moves under the turn prompt it used to sit beside. "Bí từ"
used to mount and unmount every handover, shifting the input under a
player's thumb each turn; it now sits in one persistent row with "Đầu hàng",
both built on ArmedButton and disabled off-turn rather than unmounted.

A resign or claim raced by the turn moving on now answers beside the button
that sent it instead of the top banner, matching how a false dead-end claim
was already handled. Knockout stops saying "you're out" twice — once in a
banner above the scoreboard, once in the spectating box below it — and the
elimination suggestions a spectator used to wait for the game-over screen to
see now show up the moment they're eliminated, keyed to the syllable they
were actually stuck on rather than whatever the game has moved on to since.
2026-09-21 16:59:13 +07:00

984 lines
30 KiB
JavaScript
Raw 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.
// The store is a projection of server messages, so these tests feed it real
// decoded messages and assert the rendered model — not the reducer's internals.
import { describe, expect, it } from 'vitest';
import { create } from '@bufbuild/protobuf';
import {
GameEndReason,
PointKind,
RejectReason,
ServerMessageSchema
} from '../src/lib/proto/noitu/v1/game_pb.js';
import { CHAT_WINDOW, createGameStore } from '../src/lib/stores/game.svelte.js';
/**
* @param {string} kind
* @param {object} value
*/
function msg(kind, value) {
return create(ServerMessageSchema, { payload: { case: kind, value } });
}
/** The two players every in-game fixture below is scored for. */
function table(mine = 0, theirs = 0) {
return [
{ playerId: 'p1', name: 'Minh', isMe: true, score: mine, connected: true },
{ playerId: 'p2', name: 'Lan', isMe: false, score: theirs, connected: true }
];
}
function started(overrides = {}) {
return msg('gameStarted', {
openingWord: 'học sinh',
currentSyllable: 'sinh',
myTurn: true,
deadlineUnixMs: 1_700_000_020_000n,
turnSeq: 1,
turnLimitMs: 20_000,
players: table(),
turnPlayerId: 'p1',
...overrides
});
}
describe('welcome', () => {
it('displays the name the server accepted, not the one requested', () => {
const store = createGameStore();
store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's1', resumeToken: 'r1' }));
expect(store.state.nickname).toBe('Minh');
});
});
describe('gameStarted', () => {
it('seeds the chain with the opening word', () => {
const store = createGameStore();
store.apply(started());
expect(store.state.phase).toBe('playing');
expect(store.state.chain).toHaveLength(1);
expect(store.state.chain[0]).toMatchObject({ word: 'học sinh', opening: true, byMe: false });
expect(store.state.currentSyllable).toBe('sinh');
expect(store.state.myTurn).toBe(true);
expect(store.state.turnLimitMs).toBe(20_000);
});
it('counts the opening word in the chain, matching the server', () => {
const store = createGameStore();
store.apply(started());
expect(store.state.chainLength).toBe(1);
});
it('converts the int64 deadline out of bigint so the countdown can do arithmetic', () => {
const store = createGameStore();
store.apply(started());
expect(store.state.deadlineMs).toBe(1_700_000_020_000);
expect(typeof store.state.deadlineMs).toBe('number');
});
it('clears a previous result so a rematch does not start on the old screen', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.TIMEOUT }));
expect(store.state.phase).toBe('over');
store.apply(started());
expect(store.state.phase).toBe('playing');
expect(store.state.result).toBeNull();
});
});
describe('turnUpdate', () => {
it('appends the played word and adopts the server scores', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('turnUpdate', {
played: {
word: 'sinh viên',
byMe: true,
points: 12,
syllables: 2,
typed: 'sinh vien',
playerId: 'p1'
},
currentSyllable: 'viên',
myTurn: false,
deadlineUnixMs: 1_700_000_040_000n,
turnSeq: 2,
chainLength: 2,
players: table(12, 0),
turnPlayerId: 'p2'
})
);
expect(store.state.chain).toHaveLength(2);
expect(store.state.chain[1]).toMatchObject({
word: 'sinh viên',
typed: 'sinh vien',
byMe: true,
points: 12,
syllables: 2
});
expect(store.state.currentSyllable).toBe('viên');
expect(store.state.myTurn).toBe(false);
expect(store.myScore).toBe(12);
expect(store.state.turnPlayerId).toBe('p2');
expect(store.state.chainLength).toBe(2);
expect(store.state.turnSeq).toBe(2);
});
it('moves the turn on without a word when an elimination did it', () => {
// The syllable and the chain survive the player who could not answer
// them, so there is nothing to append — but the deadline and the player
// to act are both new.
const store = createGameStore();
store.apply(started());
store.apply(
msg('turnUpdate', {
currentSyllable: 'sinh',
myTurn: true,
deadlineUnixMs: 1_700_000_050_000n,
turnSeq: 3,
chainLength: 1,
players: [
{ playerId: 'p1', name: 'Minh', isMe: true, score: 0, connected: true },
{ playerId: 'p2', name: 'Lan', score: 0, eliminated: true, connected: true }
],
turnPlayerId: 'p1'
})
);
expect(store.state.chain).toHaveLength(1);
expect(store.state.turnSeq).toBe(3);
expect(store.state.deadlineMs).toBe(1_700_000_050_000);
expect(store.state.gamePlayers[1].eliminated).toBe(true);
});
it('clears the standing rejection, because an accepted move answers it', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
expect(store.state.rejection).not.toBeNull();
store.apply(
msg('turnUpdate', {
played: { word: 'sinh viên', byMe: true, points: 1, syllables: 2, typed: '' },
currentSyllable: 'viên',
myTurn: false,
turnSeq: 2,
chainLength: 2
})
);
expect(store.state.rejection).toBeNull();
});
it('keeps the rejection when a wordless update arrives', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
// A wordless update: somebody else went out, which answers nothing
// about the word this player was refused.
store.apply(
msg('turnUpdate', {
currentSyllable: 'sinh',
myTurn: true,
deadlineUnixMs: 1_700_000_020_000n,
turnSeq: 1,
chainLength: 1,
players: table(),
turnPlayerId: 'p1'
})
);
expect(store.state.rejection).toMatchObject({ word: 'xyz' });
});
});
describe('meanings', () => {
/** A move by p1 with optional senses, seq counting from 2. */
function move(word, seq, meanings = []) {
return msg('turnUpdate', {
played: { word, byMe: true, points: 2, syllables: 2, typed: word, playerId: 'p1', meanings },
currentSyllable: word.split(' ').at(-1),
myTurn: false,
deadlineUnixMs: 1_700_000_040_000n,
turnSeq: seq,
chainLength: seq,
players: table(),
turnPlayerId: 'p2'
});
}
it('carries the senses off the wire, label and gloss apart', () => {
const store = createGameStore();
store.apply(
started({ openingMeanings: [{ pos: 'danh từ', gloss: 'Người học ở trường phổ thông.' }] })
);
store.apply(move('sinh viên', 2, [{ pos: '', gloss: 'Người học đại học.' }]));
expect(store.state.chain[0].meanings).toEqual([
{ pos: 'danh từ', gloss: 'Người học ở trường phổ thông.' }
]);
expect(store.state.chain[1].meanings).toEqual([{ pos: '', gloss: 'Người học đại học.' }]);
});
it('opens the opening word when the game starts', () => {
const store = createGameStore();
store.apply(started());
expect(store.state.expanded).toEqual(['học sinh']);
expect(store.isExpanded('học sinh')).toBe(true);
});
it('opens the new word and closes the previous newest', () => {
const store = createGameStore();
store.apply(started());
store.apply(move('sinh viên', 2));
expect(store.state.expanded).toEqual(['sinh viên']);
store.apply(move('viên chức', 3));
expect(store.state.expanded).toEqual(['viên chức']);
});
it('opens a word without a definition the same way', () => {
const store = createGameStore();
store.apply(started());
store.apply(move('sinh viên', 2, []));
expect(store.state.chain[1].meanings).toEqual([]);
expect(store.isExpanded('sinh viên')).toBe(true);
});
it('leaves the open set alone when an elimination moves the turn', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('turnUpdate', {
currentSyllable: 'sinh',
myTurn: true,
deadlineUnixMs: 1_700_000_040_000n,
turnSeq: 2,
chainLength: 1,
players: table(),
turnPlayerId: 'p1'
})
);
expect(store.state.expanded).toEqual(['học sinh']);
});
it('toggles any word, and two can be open together', () => {
const store = createGameStore();
store.apply(started());
store.apply(move('sinh viên', 2));
store.toggleMeaning('học sinh');
expect(store.state.expanded.sort()).toEqual(['học sinh', 'sinh viên']);
store.toggleMeaning('sinh viên');
expect(store.state.expanded).toEqual(['học sinh']);
store.toggleMeaning('học sinh');
expect(store.state.expanded).toEqual([]);
});
it('is cleared by reset, and a resumed session ends with one word open', () => {
const store = createGameStore();
store.apply(started());
store.toggleMeaning('sinh viên');
store.reset();
expect(store.state.expanded).toEqual([]);
// A resume replays the opening and then the last move.
store.apply(started());
store.apply(move('sinh viên', 2));
expect(store.state.expanded).toEqual(['sinh viên']);
});
});
describe('moveRejected', () => {
it('renders the specific Vietnamese reason', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('moveRejected', { reason: RejectReason.ALREADY_USED, word: 'học sinh' }));
expect(store.state.rejection).toEqual({
word: 'học sinh',
message: 'Từ này đã được dùng rồi.',
reason: RejectReason.ALREADY_USED,
suggestion: ''
});
});
it('names the required syllable in the wrong-link message', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('moveRejected', { reason: RejectReason.WRONG_LINK, word: 'bàn ghế' }));
expect(store.state.rejection?.message).toBe('Từ phải bắt đầu bằng tiếng “sinh”.');
});
it('carries a near-miss suggestion for a diacritic typo', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('moveRejected', {
reason: RejectReason.NOT_IN_DICTIONARY,
word: 'sinh vien',
suggestion: 'sinh viên'
})
);
expect(store.state.rejection?.suggestion).toBe('sinh viên');
});
it('reports no suggestion when the server sent none', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
expect(store.state.rejection?.suggestion).toBe('');
});
});
describe('gameOver', () => {
it('records the result and takes the turn away', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('gameOver', {
iWon: true,
reason: GameEndReason.NO_LEGAL_MOVE,
chainLength: 7,
standings: [
{ playerId: 'p1', name: 'Minh', isMe: true, score: 42, connected: true, rank: 1 },
{ playerId: 'p2', name: 'Lan', score: 8, eliminated: true, connected: true, rank: 2 }
]
})
);
expect(store.state.phase).toBe('over');
expect(store.state.myTurn).toBe(false);
expect(store.state.result).toEqual({
iWon: true,
reason: GameEndReason.NO_LEGAL_MOVE,
myScore: 42,
chainLength: 7
});
});
it('keeps the final table in the order the server ranked it', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('gameOver', {
iWon: false,
reason: GameEndReason.TIMEOUT,
chainLength: 9,
standings: [
{ playerId: 'p3', name: 'Hà', score: 60, connected: true, rank: 1 },
{ playerId: 'p1', name: 'Minh', isMe: true, score: 40, eliminated: true, rank: 2 },
{ playerId: 'p2', name: 'Lan', score: 55, eliminated: true, rank: 3 }
]
})
);
expect(store.state.standings.map((p) => p.playerId)).toEqual(['p3', 'p1', 'p2']);
// Rank is finishing order, not score order: Lan outscored Minh and
// still placed below him, because he outlasted her.
expect(store.state.standings[2].score).toBeGreaterThan(store.state.standings[1].score);
expect(store.state.result?.myScore).toBe(40);
});
});
describe('playerEliminated', () => {
it('keeps the words this player could have played', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('playerEliminated', {
playerId: 'p1',
name: 'Minh',
isMe: true,
reason: GameEndReason.TIMEOUT,
suggestions: ['sinh viên', 'sinh sôi']
})
);
expect(store.state.elimination?.suggestions).toEqual(['sinh viên', 'sinh sôi']);
expect(store.iAmOut).toBe(true);
expect(store.state.myTurn).toBe(false);
});
it('captures the syllable this player was stuck on, not whatever the game moves on to', () => {
// currentSyllable keeps changing for the players still in the game;
// the elimination has to freeze its own copy at the moment it happens
// or a later reader (the game-over screen, in a four-seat room) would
// name somebody else's syllable.
const store = createGameStore();
store.apply(started());
store.apply(
msg('playerEliminated', {
playerId: 'p1',
isMe: true,
reason: GameEndReason.NO_LEGAL_MOVE
})
);
expect(store.state.elimination?.syllable).toBe(store.state.currentSyllable);
const stuckOn = store.state.currentSyllable;
store.apply(
msg('turnUpdate', {
currentSyllable: 'khác',
myTurn: false,
turnSeq: 2,
chainLength: 1,
players: table(),
turnPlayerId: 'p2'
})
);
expect(store.state.currentSyllable).toBe('khác');
expect(store.state.elimination?.syllable).toBe(stuckOn);
});
it('reads an absent list as a position that had nothing left', () => {
// A dead end arrives as an empty list, which means "no words to offer"
// rather than undefined.
const store = createGameStore();
store.apply(started());
store.apply(
msg('playerEliminated', { playerId: 'p1', isMe: true, reason: GameEndReason.NO_LEGAL_MOVE })
);
expect(store.state.elimination?.suggestions).toEqual([]);
});
it('records somebody else going out without claiming this player did', () => {
const store = createGameStore();
store.apply(started());
store.apply(
msg('playerEliminated', {
playerId: 'p2',
name: 'Lan',
isMe: false,
reason: GameEndReason.RESIGNED,
suggestions: []
})
);
expect(store.state.lastOut?.name).toBe('Lan');
expect(store.state.elimination).toBeNull();
expect(store.iAmOut).toBe(false);
});
});
describe('error', () => {
it('translates the UI key rather than showing it', () => {
const store = createGameStore();
store.apply(msg('error', { code: 'room_not_found', message: '' }));
expect(store.state.error).toBe('Không tìm thấy phòng với mã này.');
});
it('falls back to a generic message for an unknown code', () => {
const store = createGameStore();
store.apply(msg('error', { code: 'something_new', message: '' }));
expect(store.state.error).toBe('Đã có lỗi xảy ra. Hãy thử lại.');
expect(store.state.error).not.toContain('something_new');
});
});
/** One seat, with the fields a test does not care about defaulted. */
function seat(fields = {}) {
return {
playerId: 'p1',
name: 'Minh',
isMe: false,
isOwner: false,
ready: false,
connected: true,
wins: 0,
...fields
};
}
/** One lobby snapshot. `players` is the whole room, the recipient included. */
function lobby(fields = {}) {
return msg('roomState', {
roomCode: 'ABCD',
canStart: false,
maxPlayers: 4,
minPlayers: 2,
graceMs: 30_000,
players: [seat({ isMe: true })],
...fields
});
}
/** A two-player room, seen by the guest. */
function pair(fields = {}) {
return lobby({
players: [
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
seat({ playerId: 'p2', name: 'Lan', isMe: true })
],
...fields
});
}
describe('room messages', () => {
it('keeps the room code and the sanitized names', () => {
const store = createGameStore();
store.apply(pair());
expect(store.state.roomCode).toBe('ABCD');
expect(store.state.roomPlayers.map((p) => p.name)).toEqual(['Chủ', 'Lan']);
expect(store.me?.playerId).toBe('p2');
});
it('survives a reset, because identity outlives one game', () => {
const store = createGameStore();
store.apply(msg('welcome', { acceptedNickname: 'Minh', sessionId: 's', resumeToken: 't' }));
store.apply(pair());
store.reset();
expect(store.state.nickname).toBe('Minh');
expect(store.state.roomCode).toBe('ABCD');
expect(store.state.roomPlayers).toHaveLength(2);
expect(store.state.chain).toEqual([]);
});
it('counts the seats nobody is in yet', () => {
const store = createGameStore();
store.apply(pair());
expect(store.freeSeats).toBe(2);
});
it('reads the series score off the room, not off the game', () => {
const store = createGameStore();
store.apply(
pair({
players: [
seat({ playerId: 'p1', name: 'Chủ', isOwner: true, wins: 2 }),
seat({ playerId: 'p2', name: 'Lan', isMe: true, wins: 1 })
]
})
);
expect(store.winsOf('p1')).toBe(2);
expect(store.winsOf('p2')).toBe(1);
// A game starting clears the board and keeps the room, tally included.
store.apply(started());
expect(store.winsOf('p1')).toBe(2);
// Nobody the room does not seat has a tally.
expect(store.winsOf('bot')).toBe(0);
});
it('numbers the seats, which is what a chat line is coloured by', () => {
const store = createGameStore();
store.apply(pair());
expect(store.seatIndexOf('p1')).toBe(1);
expect(store.seatIndexOf('p2')).toBe(2);
// A line whose seat the server cleared belongs to nobody.
expect(store.seatIndexOf('')).toBe(0);
expect(store.seatIndexOf('p9')).toBe(0);
});
});
describe('pong', () => {
it('changes nothing in the game model', () => {
const store = createGameStore();
store.apply(started());
const before = JSON.stringify(store.state);
store.apply(msg('pong', { clientTimeMs: 1n, serverTimeMs: 2n }));
expect(JSON.stringify(store.state)).toBe(before);
});
});
describe('quickMatchStatus', () => {
it('flags the wait while queued', () => {
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
expect(store.state.queued).toBe(true);
});
it('ends the wait once the queue answers false', () => {
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
store.apply(msg('quickMatchStatus', { queued: false }));
expect(store.state.queued).toBe(false);
});
it('ends the wait when the server could not open the room', () => {
// The server dequeues both sides of a match it cannot seat, and this
// refusal is the only frame that says so.
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
store.apply(msg('error', { code: 'server_full', message: 'server_full' }));
expect(store.state.queued).toBe(false);
expect(store.state.error).toBeTruthy();
});
it('ends the wait the moment a room seats this connection, even with no prior status', () => {
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
store.apply(pair());
expect(store.state.queued).toBe(false);
});
});
describe('chat', () => {
/** @param {object} fields */
function line(fields) {
return msg('chatMessage', {
fromMe: false,
playerId: 'p2',
author: 'Lan',
text: 'chào',
sentUnixMs: 1756998000123n,
...fields
});
}
it('keys two lines sent in the same millisecond apart', () => {
// The server stamps at millisecond resolution and one seat may send a
// burst, so the timestamp cannot be the list key.
const store = createGameStore();
store.apply(line({ text: 'một' }));
store.apply(line({ text: 'hai' }));
const [a, b] = store.state.chat;
expect(a.atMs).toBe(b.atMs);
expect(a.n).not.toBe(b.n);
store.apply(msg('chatHistory', { messages: [line({ text: 'ba' }).payload.value] }));
expect(store.state.chat[0].n).not.toBe(b.n);
});
it('appends in arrival order and converts the time out of bigint', () => {
const store = createGameStore();
store.apply(line({ text: 'một' }));
store.apply(line({ text: 'hai', fromMe: true }));
expect(store.state.chat.map((m) => m.text)).toEqual(['một', 'hai']);
expect(typeof store.state.chat[0].atMs).toBe('number');
expect(store.state.chat[0].atMs).toBe(1756998000123);
});
it('keeps the seat a line came from, which is what colours it', () => {
const store = createGameStore();
store.apply(line({ text: 'của tôi', fromMe: true, playerId: 'p1' }));
// An author the server cleared: the seat goes with the name.
store.apply(line({ text: 'của ai', playerId: '', author: '' }));
expect(store.state.chat.map((m) => m.playerId)).toEqual(['p1', '']);
});
it('stops at the window the server keeps, so the two cannot disagree', () => {
const store = createGameStore();
for (let i = 0; i < CHAT_WINDOW + 5; i++) {
store.apply(line({ text: `tin ${i}` }));
}
expect(store.state.chat).toHaveLength(CHAT_WINDOW);
expect(store.state.chat[0].text).toBe('tin 5');
});
it('replaces the conversation wholesale on a history, never merging', () => {
const store = createGameStore();
store.apply(line({ text: 'cũ' }));
const history = msg('chatHistory', {
messages: [
{ fromMe: true, author: 'Minh', text: 'a', sentUnixMs: 1n },
{ fromMe: false, author: 'Lan', text: 'b', sentUnixMs: 2n }
]
});
store.apply(history);
store.apply(history);
expect(store.state.chat.map((m) => m.text)).toEqual(['a', 'b']);
});
it('survives a game starting: the conversation belongs to the room', () => {
const store = createGameStore();
store.apply(line({ text: 'trước ván' }));
store.apply(started());
expect(store.state.chat).toHaveLength(1);
});
it('counts arrivals past the window, which the list length cannot', () => {
// The badge is built on this: once the list is capped its length stops
// rising, so anything derived from it stops counting.
const store = createGameStore();
for (let i = 0; i < CHAT_WINDOW + 5; i++) {
store.apply(line({ text: `tin ${i}` }));
}
expect(store.state.chat).toHaveLength(CHAT_WINDOW);
expect(store.state.chatCount).toBe(CHAT_WINDOW + 5);
});
it('counts a resynchronising history as what it holds, not as nothing', () => {
// A reconnect or an opponent leaving replaces the panel. Zeroing the
// counter there would leave a folded panel's "seen" mark stranded
// above it, and the badge would go quiet for as many messages as had
// been read before — which is a bug two reviewers found by reading.
const store = createGameStore();
store.apply(line({}));
store.apply(line({}));
expect(store.state.chatCount).toBe(2);
store.apply(
msg('chatHistory', {
messages: [
{ fromMe: true, author: 'Minh', text: 'a', sentUnixMs: 1n },
{ fromMe: false, author: 'Lan', text: 'b', sentUnixMs: 2n }
]
})
);
expect(store.state.chatCount).toBe(2);
store.apply(msg('chatHistory', { messages: [] }));
expect(store.state.chatCount).toBe(0);
});
it('is dropped when this player is no longer in the room', () => {
const store = createGameStore();
store.apply(line({}));
store.clearChat();
expect(store.state.chat).toEqual([]);
store.apply(line({}));
store.leave();
expect(store.state.chat).toEqual([]);
expect(store.state.chatCount).toBe(0);
});
});
describe('the lobby', () => {
it('opens the lobby when the room appears', () => {
const store = createGameStore();
store.apply(lobby({ roomCode: 'K7M2QP', players: [seat({ isMe: true, isOwner: true })] }));
expect(store.state.phase).toBe('lobby');
expect(store.state.roomCode).toBe('K7M2QP');
expect(store.isOwner).toBe(true);
});
it("reads this player’s role and readiness off their own row", () => {
// There is one encoding of each, so a client cannot end up believing a
// role the list it is rendering disagrees with.
const store = createGameStore();
store.apply(
pair({
canStart: true,
players: [
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
seat({ playerId: 'p2', name: 'Lan', isMe: true, ready: true })
]
})
);
expect(store.isOwner).toBe(false);
expect(store.isReady).toBe(true);
expect(store.state.canStart).toBe(true);
expect(store.state.maxPlayers).toBe(4);
expect(store.state.minPlayers).toBe(2);
});
it('lists everybody whose reconnect window is running, and nobody else', () => {
const store = createGameStore();
store.apply(
lobby({
players: [
seat({ playerId: 'p1', name: 'Chủ', isOwner: true }),
seat({ playerId: 'p2', name: 'Lan', isMe: true }),
seat({ playerId: 'p3', name: 'Hà', connected: false })
]
})
);
expect(store.awayPlayers.map((p) => p.name)).toEqual(['Hà']);
expect(store.state.graceMs).toBe(30_000);
});
it('does not drop a live game back into the lobby', () => {
// A room state arriving mid-game is a presence change, not a phase.
// Acting on it would replace the board with the lobby mid-turn.
const store = createGameStore();
store.apply(started());
store.apply(pair());
expect(store.state.phase).toBe('playing');
});
it('keeps the result on screen when a finished game returns to the lobby', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
store.apply(pair());
expect(store.state.phase).toBe('over');
expect(store.state.result).not.toBeNull();
});
it('stops waiting for a player who is connected again', () => {
const store = createGameStore();
store.apply(started());
store.apply(
pair({
players: [
seat({ playerId: 'p1', name: 'Chủ', isOwner: true, connected: false }),
seat({ playerId: 'p2', name: 'Lan', isMe: true })
]
})
);
expect(store.awayPlayers).toHaveLength(1);
store.apply(pair());
expect(store.awayPlayers).toHaveLength(0);
});
it('forgets the room when this player is kicked out of it', () => {
const store = createGameStore();
store.apply(pair());
store.apply(msg('error', { code: 'kicked', message: '' }));
expect(store.state.phase).toBe('idle');
expect(store.state.roomCode).toBe('');
expect(store.state.error).toBe('Bạn đã bị mời ra khỏi phòng.');
});
it('forgets a room that closed for sitting idle', () => {
const store = createGameStore();
store.apply(lobby());
store.apply(msg('error', { code: 'room_idle_closed', message: '' }));
expect(store.state.phase).toBe('idle');
expect(store.state.roomCode).toBe('');
expect(store.state.error).not.toBeNull();
});
});
describe('score breakdown', () => {
it('carries the parts a played word scored, opening word excluded', () => {
const store = createGameStore();
store.apply(started());
expect(store.state.chain[0].parts).toEqual([]);
store.apply(
msg('turnUpdate', {
played: {
word: 'sinh viên',
byMe: true,
points: 25,
syllables: 2,
typed: 'sinh viên',
playerId: 'p1',
parts: [
{ kind: PointKind.BASE, value: 10 },
{ kind: PointKind.SPEED, value: 15 }
]
},
currentSyllable: 'viên',
myTurn: false,
turnSeq: 2,
chainLength: 2,
players: table(25, 0),
turnPlayerId: 'p2'
})
);
expect(store.state.chain[1].parts).toEqual([
{ kind: PointKind.BASE, value: 10 },
{ kind: PointKind.SPEED, value: 15 }
]);
});
});
describe('word report', () => {
it('shows the confirmation once the server acknowledges a report', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('wordReported', { word: 'bình tâm' }));
expect(store.state.reportConfirmation).toBe('Đã ghi nhận “bình tâm”. Cảm ơn bạn!');
});
it('clears a stale confirmation when a new rejection arrives', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('wordReported', { word: 'bình tâm' }));
store.apply(msg('moveRejected', { reason: RejectReason.NOT_IN_DICTIONARY, word: 'xyz' }));
expect(store.state.reportConfirmation).toBeNull();
});
it('clears the confirmation once an accepted move answers the rejection it was about', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('wordReported', { word: 'bình tâm' }));
store.apply(
msg('turnUpdate', {
played: { word: 'sinh viên', byMe: true, points: 1, syllables: 2, typed: '' },
currentSyllable: 'viên',
myTurn: false,
turnSeq: 2,
chainLength: 2
})
);
expect(store.state.reportConfirmation).toBeNull();
});
});
describe('dead-end claim', () => {
it('answers a false claim inline rather than in the general error banner', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
expect(store.state.claimError).not.toBeNull();
expect(store.state.error).toBeNull();
});
it('clears the claim error once the position moves on', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
store.apply(
msg('turnUpdate', {
currentSyllable: 'sinh',
myTurn: true,
turnSeq: 2,
chainLength: 1,
players: table(),
turnPlayerId: 'p1'
})
);
expect(store.state.claimError).toBeNull();
});
it('is dismissed the same way a general error is', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_a_dead_end', message: '' }));
store.clearClaimError();
expect(store.state.claimError).toBeNull();
});
});
describe('a resign or claim that raced the turn moving on', () => {
// The server can only answer resign or claim-dead-end with not_your_turn
// when the turn already left before the request arrived — both are
// gated on the player's own turn client-side. That is the same kind of
// news as a false dead-end claim: about the move just attempted, not the
// room, so it answers beside the button rather than in the top banner.
it('answers inline rather than in the general error banner', () => {
const store = createGameStore();
store.apply(started());
store.apply(msg('error', { code: 'not_your_turn', message: '' }));
expect(store.state.claimError).not.toBeNull();
expect(store.state.error).toBeNull();
});
});