mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
A room used to be a wrapper around one game: joining started it, and the room died with it unless both players accepted a rematch inside thirty seconds. It is now a lobby that outlives its games. Whoever created the room owns it and the other seat is the guest. The guest readies and the owner starts; the owner has no readiness of their own, because starting is the same statement. A finished game returns both to the lobby, where the next one is agreed exactly as the last was — the readiness that started a game is spent with it. A guest takes their readiness back before leaving, which is deliberate friction: a player the owner is waiting on should have to say so before walking away. The owner can free the seat of a guest who is not ready, and not of one who is — readiness is a commitment, not an inconvenience. An owner who leaves hands the room to whoever is left, unreadied, because they are the one who starts now. Something has to bound a room that outlives its games: the last player out closes it, as does ten minutes in a lobby nobody started a game in. A dropped connection is still not a player leaving — the seat is held for the reconnect window in the lobby as well as mid-game, so a refresh no longer costs somebody their room, and a resume lands in the lobby it left. The rematch handshake is retired, and RoomCreated and RoomJoined go with it. All three described part of what RoomState now describes in full, and three messages for one lobby is three ways for a client to hold a view of it the server never had. One snapshot per recipient, broadcast from the one place that knows an input is finished, so no handler can forget to send it.
116 lines
3.6 KiB
JavaScript
116 lines
3.6 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.getByRole('button', { name: 'Gửi', exact: true }),
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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, guest) {
|
|
await guest.getByTestId('ready').click();
|
|
const start = owner.getByTestId('start-game');
|
|
await expect(start).toBeEnabled();
|
|
await start.click();
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|