Files
noitu/web/tests/game-board.test.js
T
tiennm99 cf48d270de test(web): mount WordInput and GameBoard under jsdom
WordInput: the turn seed fires once per turn and is gated on the connection
(regression test for C6), a reconnect blip mid-turn does not reseed over
what the player typed, a rejection's suggestion fills the field on click,
and the player's own in-turn composition is left alone.

GameBoard: the chat pill renders only when onchatopen is passed, which is
the actual contract between the bot and online routes — a bot game simply
never passes it.
2026-09-21 16:28:36 +07:00

104 lines
2.9 KiB
JavaScript

// @vitest-environment jsdom
// The chat pill in the board's top row only exists for the online screen —
// a bot game has no chat, so GameBoard simply never receives the handler
// that draws it. This pins that down as a prop contract rather than
// something only the online route happens to exercise.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount, unmount, flushSync } from 'svelte';
import { create } from '@bufbuild/protobuf';
import GameBoard from '../src/lib/components/GameBoard.svelte';
import { ServerMessageSchema } 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';
function startGame() {
game.apply(
create(ServerMessageSchema, {
payload: {
case: 'gameStarted',
value: {
openingWord: 'bình yên',
currentSyllable: 'yên',
myTurn: true,
deadlineUnixMs: 1_700_000_020_000n,
turnSeq: 1,
turnLimitMs: 20_000,
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
turnPlayerId: 'p1'
}
}
})
);
}
/** @param {Partial<ConstructorParameters<typeof GameBoard>[0]['props']>} [extra] */
function renderGameBoard(extra = {}) {
const target = document.createElement('div');
document.body.appendChild(target);
const component = mount(GameBoard, {
target,
props: {
onsubmit: () => true,
onresign: () => {},
onclaimdeadend: () => {},
onreportword: () => {},
...extra
}
});
flushSync();
return { target, component };
}
beforeEach(() => {
game.leave();
connection.status = Status.OPEN;
// jsdom implements neither; ChainHistory (rendered inside the board) uses
// both to keep the newest word in view, which is not what this file
// tests.
Element.prototype.scrollTo = () => {};
globalThis.ResizeObserver ??= class {
observe() {}
disconnect() {}
};
startGame();
});
afterEach(() => {
document.body.innerHTML = '';
connection.status = Status.CLOSED;
});
describe('the chat pill', () => {
it('is absent when the caller passes no onchatopen, as a bot game does', () => {
const { target, component } = renderGameBoard();
expect(target.querySelector('[data-testid="chat-pill"]')).toBeNull();
unmount(component);
});
it('appears once a handler is passed, as the online room does', () => {
const { target, component } = renderGameBoard({ onchatopen: () => {} });
expect(target.querySelector('[data-testid="chat-pill"]')).not.toBeNull();
unmount(component);
});
it('calls the handler on click and shows the unread count', () => {
let opened = 0;
const { target, component } = renderGameBoard({
onchatopen: () => opened++,
chatUnread: 3
});
/** @type {HTMLButtonElement | null} */
const pill = target.querySelector('[data-testid="chat-pill"]');
expect(pill?.textContent).toContain('3');
pill?.click();
expect(opened).toBe(1);
unmount(component);
});
});