Files
noitu/web/e2e/helpers.js
T
tiennm99 e19b083dc0 feat(online): seat the chat beside the game and score the series
A room screen wide enough for it is two columns now: the game or the
lobby on one side and the room's conversation on the other, so neither
has to be scrolled past to reach the other. A phone keeps one column,
the game first, with the chat folded behind an unread count while a game
is on. One chat panel spans both phases by staying in the same place in
the markup — a remounted panel reopened having read nothing, and handed
the reader the lobby's conversation back as unread mail.

The room also keeps the score of the series. PlayerSlot carries the
games a seat has won since the room opened, credited before the lobby is
broadcast so the players see it the moment a game ends, and shown in the
lobby and beside each player's score on the board. It belongs to the
seat rather than to the room, because vacating one is exactly when the
name on it stops meaning the same person.

The conversation reads as a log — one line per message, "name: text",
each seat in its own colour — rather than as a stack of bubbles, which
stops being legible once four people are talking. ChatMessage carries
the seat that spoke, so a line is coloured by what the server said
rather than by matching display names, and a line whose seat has been
vacated is in nobody's colour.

The board's word button gets a test id of its own: it and the chat's
send button both read "Gửi", and the two are now on screen together.
2026-09-08 11:28:19 +07:00

156 lines
4.5 KiB
JavaScript

import { expect } from '@playwright/test';
import { continuation } from './fixture-dictionary.js';
/**
* Shared page driving for the end-to-end specs.
*
* Everything here waits on a state the server produced — the turn indicator,
* the syllable, a word appearing in the chain — rather than on a fixed delay.
* A sleep would pass or fail depending on how loaded the machine is, which is
* the usual reason a browser suite goes intermittently red.
*/
/** @param {import('@playwright/test').Page} page */
export function board(page) {
return {
syllable: page.getByTestId('current-syllable'),
turn: page.getByTestId('turn-indicator'),
input: page.getByRole('textbox', { name: 'Nhập từ của bạn' }),
submit: page.getByTestId('word-submit'),
chain: page.getByRole('list').first(),
rejection: page.getByRole('alert')
};
}
/**
* Waits until it is this player's turn to move.
*
* @param {import('@playwright/test').Page} page
*/
export async function waitForMyTurn(page) {
await expect(board(page).turn).toHaveText('Đến lượt bạn', { timeout: 30_000 });
}
/**
* Types a word and sends it. The field is uncontrolled on purpose, so this
* fills and submits exactly as a player would.
*
* @param {import('@playwright/test').Page} page
* @param {string} word
*/
export async function submitWord(page, word) {
const b = board(page);
await b.input.fill(word);
await b.submit.click();
}
/**
* Plays one legal move for whatever syllable is currently required, and returns
* the word it played.
*
* @param {import('@playwright/test').Page} page
* @param {Set<string>} used
* @returns {Promise<string>}
*/
export async function playLegalMove(page, used) {
await waitForMyTurn(page);
const syllable = (await board(page).syllable.textContent())?.trim() ?? '';
const word = continuation(syllable, used);
expect(word, `the fixture has no unused continuation of "${syllable}"`).toBeTruthy();
used.add(/** @type {string} */ (word));
await submitWord(page, /** @type {string} */ (word));
await expect(page.getByText(/** @type {string} */ (word), { exact: true }).first()).toBeVisible();
return /** @type {string} */ (word);
}
/**
* The chat panel's parts, for the tests that drive a conversation.
*
* @param {import('@playwright/test').Page} page
*/
export function chat(page) {
return {
log: page.getByTestId('chat-log'),
input: page.getByTestId('chat-input'),
send: page.getByTestId('chat-send'),
unread: page.getByTestId('chat-unread'),
error: page.getByTestId('chat-error')
};
}
/**
* Types a line and sends it, the way a player does.
*
* @param {import('@playwright/test').Page} page
* @param {string} text
*/
export async function say(page, text) {
const c = chat(page);
await c.input.fill(text);
await c.send.click();
}
/**
* Takes a seated pair from their lobby into a game: the guest readies, the
* owner starts. Nothing begins on its own now, so every online test that is
* about a game goes through here.
*
* @param {import('@playwright/test').Page} owner
* @param {import('@playwright/test').Page} guest
*/
export async function readyAndStart(owner, ...guests) {
for (const guest of guests) {
await guest.getByTestId('ready').click();
}
const start = owner.getByTestId('start-game');
await expect(start).toBeEnabled();
await start.click();
}
/**
* The seat rows in the lobby, one per player who is actually in the room. The
* free seats are drawn too, so counting rows would count the room's size
* rather than its occupants.
*
* @param {import('@playwright/test').Page} page
*/
export function seats(page) {
return page.locator('.seat:not(.empty)');
}
/**
* Picks a difficulty by clicking its card.
*
* The radio itself is visually hidden so the platform supplies arrow-key
* behaviour, so the card is what a player actually clicks.
*
* @param {import('@playwright/test').Page} page
* @param {string | RegExp} label
*/
export async function chooseDifficulty(page, label) {
await page.locator('label', { has: page.getByRole('radio', { name: label }) }).click();
await expect(page.getByRole('radio', { name: label })).toBeChecked();
}
/**
* Sets the nickname on the home screen.
*
* @param {import('@playwright/test').Page} page
* @param {string} name
*/
export async function setNickname(page, name) {
await page.getByLabel('Tên của bạn').fill(name);
}
/**
* Reads the words currently in the chain, opening word included.
*
* @param {import('@playwright/test').Page} page
* @returns {Promise<string[]>}
*/
export async function chainWords(page) {
return page.locator('ol li .word').allTextContents();
}