mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-20 02:18:08 +00:00
Applies the findings of the three UI/UX reviews. The two that cost a player something every single turn: - The countdown reddened during the opponent's turn. It read the deadline, which the server rewrites on every handover, and never asked whose turn it was — a panic spike, every turn, about nothing the player could act on. It now mutes when the clock is not theirs, and signals urgency by thickening as well as reddening, because green to red is the pair a red-green colour blindness cannot separate. - On a phone the keyboard closed on every turn and could not be reopened. `disabled` on the focused word field blurs it, and the recovering focus() ran on a WebSocket message, which iOS will not open a keyboard for. The field stays editable and the submit button carries the turn instead; focus is asked for inside the gesture that submitted. A draft aimed at a syllable the game has moved past is now corrected rather than kept. The board was also not holding together as the game grew. `min-height` on the shell left nothing with a definite height to shrink against, so the chain kept its full height and the page got taller every turn, walking the word field and the chat off the bottom of the screen. The shell now has a real height, the chain gives up its space first with a floor so it cannot vanish, and `main` scrolls only as a last resort. Online play had one true dead end: nothing on the pre-room screen showed the connection at all, so a server that was down left an enabled button and a screen that never changed — while each impatient tap sent a real CreateRoom until the rate limiter answered. The badge is there now, requests disable their button, a stall says so, and the lobby draws the room's refusals beside the button that produced them instead of in a chat panel below the fold. A dropped player gets a retry rather than waiting out the backoff. An invite link asks a first-time guest for a name instead of seating them as "Người chơi" with no way to fix it. Accessibility and palette: - The turn change is announced. It is the state the whole game hangs on and it reached nobody who was not looking at that line. - --accent darkened to #12692f: accent text on its own tint measured 4.29:1. Every existing pair improves, white-on-accent included. - --border-strong added for the boundary of anything you type into or press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark. - One focus ring, one 44px icon-button recipe, spacing and type scales, safe-area gutters, per-route titles, a heading per screen, a skip link, and reduced motion that stops the connection dot pulsing rather than flickering it a hundred thousand times a frame. Tests follow the two interactions that changed shape: resign and kick confirm inline with a second press, because confirm() blocks the frame loop the countdown runs on and hesitating could cost the turn it was protecting. joinRoomSeated waits for the seat before anybody speaks — the server replays a seat only the conversation from when it joined, so the old helper was racing the handshake. Verified: 189 unit tests, 40 end-to-end, no type errors, and the palette and layout measured at 360/400/768/1280 in both themes.
627 lines
23 KiB
JavaScript
627 lines
23 KiB
JavaScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
awaitTurn,
|
|
board,
|
|
chainWords,
|
|
chat,
|
|
confirmKick,
|
|
openMeanings,
|
|
playLegalMove,
|
|
readyAndStart,
|
|
resign,
|
|
say,
|
|
seats,
|
|
setNickname,
|
|
waitForMyTurn
|
|
} from './helpers.js';
|
|
|
|
/**
|
|
* Two browser contexts, so each player has their own storage, nickname and
|
|
* socket. One context with two tabs would share a session and prove nothing
|
|
* about two people playing.
|
|
*/
|
|
async function twoPlayers(browser, options = {}) {
|
|
// newContext does not inherit the project's `use` options, so a test about
|
|
// a phone-sized screen has to pass the viewport in here.
|
|
const hostContext = await browser.newContext(options);
|
|
const guestContext = await browser.newContext(options);
|
|
return {
|
|
host: await hostContext.newPage(),
|
|
guest: await guestContext.newPage(),
|
|
async close() {
|
|
await hostContext.close();
|
|
await guestContext.close();
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Creates a room as the host and returns its code. */
|
|
async function createRoom(page, nickname) {
|
|
await page.goto('/online');
|
|
await setNickname(page, nickname);
|
|
await page.getByRole('button', { name: 'Tạo phòng' }).click();
|
|
|
|
const code = await page.getByTestId('room-code').textContent();
|
|
return (code ?? '').replace(/\s+/g, '');
|
|
}
|
|
|
|
async function joinRoom(page, nickname, code) {
|
|
await page.goto('/online');
|
|
await setNickname(page, nickname);
|
|
await page.getByLabel('Mã phòng').fill(code);
|
|
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
|
}
|
|
|
|
/**
|
|
* Joins, and waits until the seat actually exists.
|
|
*
|
|
* A seat is replayed only the conversation from the moment it joined — that is
|
|
* the server's rule, not an accident — so a test where somebody speaks has to
|
|
* know the listener is in the room first. Without this the assertion races the
|
|
* handshake and passes or fails on how quickly the page hydrates.
|
|
*/
|
|
async function joinRoomSeated(page, nickname, code) {
|
|
await joinRoom(page, nickname, code);
|
|
await expect(page.getByTestId('ready')).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Seats a pair and plays them into a game, which is where most tests start.
|
|
*
|
|
* Also handed back as lead and second: the first turn is drawn, so a test that
|
|
* plays a move takes the player who may play it rather than the owner.
|
|
*/
|
|
async function playingPair(browser) {
|
|
const pair = await twoPlayers(browser);
|
|
const code = await createRoom(pair.host, 'Minh');
|
|
await joinRoom(pair.guest, 'Lan', code);
|
|
await readyAndStart(pair.host, pair.guest);
|
|
const {
|
|
lead,
|
|
waits: [second]
|
|
} = await awaitTurn(pair.host, pair.guest);
|
|
return { ...pair, code, lead, second };
|
|
}
|
|
|
|
test.describe('playing a stranger', () => {
|
|
test('two players join by code and alternate turns', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
expect(code).toMatch(/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/);
|
|
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
|
|
|
await joinRoom(guest, 'Lan', code);
|
|
await readyAndStart(host, guest);
|
|
|
|
// Both boards come up, and exactly one player is on turn — which of
|
|
// them is drawn when the game starts.
|
|
const {
|
|
lead,
|
|
waits: [waiting]
|
|
} = await awaitTurn(host, guest);
|
|
// Whose turn it is, by name: with four seats "the opponent" would stop
|
|
// naming anybody.
|
|
await expect(board(waiting).turn).toHaveText(`Đến lượt ${lead === host ? 'Minh' : 'Lan'}…`);
|
|
|
|
// Each side is shown the other's server-sanitized name. Scoped to the
|
|
// scoreboard: the turn indicator names a player too, so an unscoped
|
|
// match is ambiguous for whoever is not on turn.
|
|
await expect(host.getByTestId('scoreboard').getByText('Lan')).toBeVisible();
|
|
await expect(guest.getByTestId('scoreboard').getByText('Minh')).toBeVisible();
|
|
|
|
const used = new Set(await chainWords(lead));
|
|
const opening = await playLegalMove(lead, used);
|
|
|
|
// The move crosses to the other browser, and the turn goes with it.
|
|
await expect(waiting.getByText(opening, { exact: true }).first()).toBeVisible();
|
|
await waitForMyTurn(waiting);
|
|
|
|
const reply = await playLegalMove(waiting, used);
|
|
await expect(lead.getByText(reply, { exact: true }).first()).toBeVisible();
|
|
await waitForMyTurn(lead);
|
|
|
|
await close();
|
|
});
|
|
|
|
test('an invite link opens straight into the room', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
|
|
// A name is all the link needs; it is remembered from the last visit,
|
|
// which is what makes the link one tap for anybody who has played
|
|
// before.
|
|
await guest.goto('/online');
|
|
await setNickname(guest, 'Lan');
|
|
|
|
// No code typed, no button pressed beyond opening the link.
|
|
await guest.goto(`/online?code=${code}`);
|
|
|
|
// The link seats them; the lobby is still where the game is agreed.
|
|
await expect(guest.getByTestId('ready')).toBeVisible();
|
|
await readyAndStart(host, guest);
|
|
|
|
await awaitTurn(host, guest);
|
|
await expect(board(guest).syllable).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('an invite link asks a nameless guest for a name before seating them', async ({
|
|
browser
|
|
}) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
|
|
// A first-time guest: nothing stored, so the link cannot introduce them.
|
|
// Seating them anyway made them "Người chơi" on the scoreboard, in the
|
|
// chat and in the standings, with no way to fix it from inside the room.
|
|
await guest.goto(`/online?code=${code}`);
|
|
|
|
await expect(guest.getByTestId('name-needed')).toBeVisible();
|
|
await expect(guest.getByTestId('ready')).toHaveCount(0);
|
|
// Still only the host in the room.
|
|
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
|
|
|
// The code came with the link, so giving a name is the only step left.
|
|
await setNickname(guest, 'Lan');
|
|
await guest.getByRole('button', { name: 'Vào phòng' }).click();
|
|
|
|
await expect(guest.getByTestId('ready')).toBeVisible();
|
|
await expect(host.getByTestId('player-count')).toHaveText('2/4 người chơi');
|
|
await expect(host.getByTestId('scoreboard').getByText('Lan')).toHaveCount(0);
|
|
await expect(host.locator('.seat').getByText('Lan')).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
|
|
const { host, guest, close } = await playingPair(browser);
|
|
|
|
await resign(host);
|
|
|
|
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
|
await expect(guest.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('a second game is agreed in the lobby the first one ends in', async ({ browser }) => {
|
|
const { host, guest, code, close } = await playingPair(browser);
|
|
|
|
await resign(host);
|
|
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
|
|
|
// The readiness that started the first game is spent, so the owner
|
|
// cannot simply start another.
|
|
await expect(host.getByTestId('start-game')).toBeDisabled();
|
|
|
|
// The series score of the room, which the finished game has just moved:
|
|
// the guest took it, so the owner's lobby shows 1 against their seat.
|
|
await expect(host.getByTestId('wins-p2')).toContainText('Tỉ số 1');
|
|
await expect(host.getByTestId('wins-p1')).toContainText('Tỉ số 0');
|
|
|
|
await readyAndStart(host, guest);
|
|
|
|
// A new game in the same room: the code is unchanged and the board is
|
|
// back to a single opening word.
|
|
await awaitTurn(host, guest);
|
|
await expect(host.getByText(code)).toBeVisible();
|
|
expect(await chainWords(host)).toHaveLength(1);
|
|
|
|
// And the tally is carried into it, where the board shows it beside
|
|
// each player's score for this game.
|
|
await expect(host.getByTestId('series-p2')).toContainText('Tỉ số 1');
|
|
|
|
await close();
|
|
});
|
|
|
|
test('the guest readies, and cannot leave without taking it back', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoom(guest, 'Lan', code);
|
|
|
|
// Nothing starts until the owner says so.
|
|
await expect(host.getByTestId('start-game')).toBeDisabled();
|
|
await guest.getByTestId('ready').click();
|
|
|
|
await expect(guest.getByTestId('my-ready')).toHaveText('Đã sẵn sàng');
|
|
await expect(host.getByTestId('ready-p2')).toHaveText('Đã sẵn sàng');
|
|
await expect(host.getByTestId('start-game')).toBeEnabled();
|
|
|
|
// Ready is a commitment: the way out is to take it back first.
|
|
await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeDisabled();
|
|
await guest.getByTestId('ready').click();
|
|
await expect(guest.getByRole('button', { name: 'Rời phòng' })).toBeEnabled();
|
|
|
|
await guest.getByRole('button', { name: 'Rời phòng' }).click();
|
|
|
|
// The room survives with its owner in it, and the rest going spare.
|
|
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
|
await expect(host.getByTestId('start-game')).toBeDisabled();
|
|
// And the player who left is back on the join screen.
|
|
await expect(guest.getByRole('button', { name: 'Tạo phòng' })).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('the owner can put an unready guest out of the room', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoom(guest, 'Lan', code);
|
|
|
|
const kick = host.getByRole('button', { name: 'Mời ra khỏi phòng' });
|
|
await expect(kick).toBeEnabled();
|
|
|
|
// A guest who is ready is waiting on the owner, not in the way.
|
|
await guest.getByTestId('ready').click();
|
|
await expect(kick).toBeDisabled();
|
|
|
|
await guest.getByTestId('ready').click();
|
|
await expect(kick).toBeEnabled();
|
|
await confirmKick(kick);
|
|
|
|
await expect(guest.getByTestId('join-error')).toHaveText('Bạn đã bị mời ra khỏi phòng.');
|
|
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
|
|
|
await close();
|
|
});
|
|
|
|
test('the room outlives its owner, who hands it to whoever is left', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoom(guest, 'Lan', code);
|
|
await expect(guest.locator('.role', { hasText: 'Chủ phòng' })).toBeVisible();
|
|
|
|
await host.getByRole('button', { name: 'Rời phòng' }).click();
|
|
|
|
// The guest is the owner now, which is visible in what they are
|
|
// offered rather than only in a label.
|
|
await expect(guest.getByTestId('start-game')).toBeVisible();
|
|
await expect(guest.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
|
|
|
// And the promotion is real: the next person in can be played against.
|
|
const thirdContext = await browser.newContext();
|
|
const third = await thirdContext.newPage();
|
|
await joinRoom(third, 'Nam', code);
|
|
await readyAndStart(guest, third);
|
|
|
|
// Both boards come up. Which of them moves first is the engine's
|
|
// business, and asserting it here would test seat order instead of the
|
|
// promotion.
|
|
await expect(board(guest).syllable).toBeVisible();
|
|
await expect(board(third).syllable).toBeVisible();
|
|
|
|
await thirdContext.close();
|
|
await close();
|
|
});
|
|
|
|
test('the two players can talk, in the lobby and in the game', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoomSeated(guest, 'Lan', code);
|
|
|
|
await say(host, 'chào bạn');
|
|
// Each side is shown its own words and the other's, with a name on the
|
|
// half that is not theirs.
|
|
await expect(chat(guest).log).toContainText('chào bạn');
|
|
await expect(chat(guest).log).toContainText('Minh');
|
|
await expect(chat(host).log).toContainText('chào bạn');
|
|
|
|
await say(guest, 'chào!');
|
|
await expect(chat(host).log).toContainText('chào!');
|
|
|
|
// The log is a list of lines, each naming its author and written in
|
|
// that seat's own colour, so four people talking stay tellable apart.
|
|
const lines = chat(host).log.getByRole('listitem');
|
|
await expect(lines.first()).toContainText('Minh:');
|
|
await expect(lines.nth(1)).toContainText('Lan:');
|
|
const [first, second] = await Promise.all([
|
|
lines.first().evaluate((el) => getComputedStyle(el).color),
|
|
lines.nth(1).evaluate((el) => getComputedStyle(el).color)
|
|
]);
|
|
expect(first).not.toBe(second);
|
|
|
|
// And the conversation follows them into the game, where a screen this
|
|
// wide keeps it beside the board rather than folding it away.
|
|
await readyAndStart(host, guest);
|
|
await awaitTurn(host, guest);
|
|
await expect(chat(host).log).toContainText('chào bạn');
|
|
|
|
await close();
|
|
});
|
|
|
|
test('the board panel opens with nothing unread from the lobby', async ({ browser }) => {
|
|
// A phone: one column, so the board's chat folds behind an unread count
|
|
// instead of sitting beside the game. Nothing folds on a wide screen,
|
|
// and a badge for a panel that is always open would be a lie.
|
|
const { host, guest, close } = await twoPlayers(browser, {
|
|
viewport: { width: 420, height: 900 }
|
|
});
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoomSeated(guest, 'Lan', code);
|
|
|
|
// A conversation both players have already read.
|
|
await say(host, 'một');
|
|
await expect(chat(guest).log).toContainText('một');
|
|
await say(guest, 'hai');
|
|
await expect(chat(host).log).toContainText('hai');
|
|
|
|
await readyAndStart(host, guest);
|
|
await awaitTurn(host, guest);
|
|
|
|
// The panel that folds on the way into the game is the one that was
|
|
// open in the lobby. What it had already shown is not new mail.
|
|
await expect(chat(host).unread).toHaveCount(0);
|
|
|
|
// And it does start counting what actually arrives while folded. The
|
|
// guest's panel is folded too, so it has to be opened before there is
|
|
// anything to type into.
|
|
await guest.getByRole('button', { name: 'Trò chuyện' }).click();
|
|
await say(guest, 'ba');
|
|
await expect(chat(host).unread).toHaveText('1 tin mới');
|
|
|
|
await host.getByRole('button', { name: 'Trò chuyện' }).click();
|
|
await expect(chat(host).unread).toHaveCount(0);
|
|
|
|
await close();
|
|
});
|
|
|
|
test('a turn arriving does not take the chat field away', async ({ browser }) => {
|
|
const { lead, second, close } = await playingPair(browser);
|
|
|
|
// The player waiting starts typing while the other is still on turn.
|
|
// The word field wants focus the moment a turn lands, and taking it
|
|
// here would drop the rest of the sentence into the game.
|
|
await chat(second).input.click();
|
|
await chat(second).input.fill('đang gõ dở');
|
|
|
|
await playLegalMove(lead, new Set(await chainWords(lead)));
|
|
await waitForMyTurn(second);
|
|
|
|
await expect(chat(second).input).toBeFocused();
|
|
await expect(chat(second).input).toHaveValue('đang gõ dở');
|
|
|
|
await close();
|
|
});
|
|
|
|
test('a message is rendered as text, never as markup', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoomSeated(guest, 'Lan', code);
|
|
|
|
await say(host, '<b>đậm</b>');
|
|
await expect(chat(guest).log).toContainText('<b>đậm</b>');
|
|
await expect(chat(guest).log.locator('b')).toHaveCount(0);
|
|
|
|
await close();
|
|
});
|
|
|
|
test('sending is refused until there is something to send', async ({ page }) => {
|
|
await createRoom(page, 'Minh');
|
|
const host = page;
|
|
|
|
await expect(chat(host).send).toBeDisabled();
|
|
// Whitespace and an invisible character are both nothing once the
|
|
// server has sanitized them, so neither may be sent.
|
|
await chat(host).input.fill(' ');
|
|
await expect(chat(host).send).toBeDisabled();
|
|
await chat(host).input.fill('\u200b');
|
|
await expect(chat(host).send).toBeDisabled();
|
|
await chat(host).input.fill('có chữ');
|
|
await expect(chat(host).send).toBeEnabled();
|
|
});
|
|
|
|
test('a refusal is shown in the lobby, beside the buttons that produce it', async ({
|
|
browser
|
|
}) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoom(guest, 'Lan', code);
|
|
|
|
// Past the burst the server refuses.
|
|
for (let i = 0; i < 8; i++) {
|
|
await say(host, `tin ${i}`);
|
|
}
|
|
|
|
// In the lobby itself. It used to land in the chat panel below it,
|
|
// which on a phone is under a four-seat list and off the screen — so
|
|
// "Bắt đầu" being refused looked like "Bắt đầu" being broken.
|
|
// containText, not haveText: the box carries its own dismiss button.
|
|
await expect(host.getByTestId('lobby-error')).toContainText('Bạn thao tác quá nhanh');
|
|
|
|
await close();
|
|
});
|
|
|
|
test('a reload brings the conversation back, and a new room does not', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoomSeated(guest, 'Lan', code);
|
|
await say(host, 'nhớ nhé');
|
|
await expect(chat(guest).log).toContainText('nhớ nhé');
|
|
|
|
await guest.reload();
|
|
await expect(chat(guest).log).toContainText('nhớ nhé');
|
|
|
|
// A different room is a different conversation, even in the same tab.
|
|
// Left properly rather than navigated away from: an open tab that walks
|
|
// off is resumed back into the room it was in, which is its own
|
|
// feature.
|
|
await guest.getByRole('button', { name: 'Rời phòng' }).click();
|
|
await guest.getByRole('button', { name: 'Tạo phòng' }).click();
|
|
await expect(guest.getByTestId('room-code')).toBeVisible();
|
|
await expect(guest.getByText('Chưa có tin nhắn nào.')).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('an unknown room code is refused in Vietnamese', async ({ page }) => {
|
|
await page.goto('/online');
|
|
await setNickname(page, 'Minh');
|
|
await page.getByLabel('Mã phòng').fill('ZZZZZZ');
|
|
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
|
|
|
await expect(page.getByTestId('join-error')).toHaveText(
|
|
'Không tìm thấy phòng với mã này.'
|
|
);
|
|
});
|
|
|
|
test('a malformed code is caught before it reaches the server', async ({ page }) => {
|
|
await page.goto('/online');
|
|
await page.getByLabel('Mã phòng').fill('ABC');
|
|
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
|
|
|
await expect(page.getByText('Mã phòng gồm sáu ký tự.')).toBeVisible();
|
|
await expect(page.getByTestId('join-error')).toHaveCount(0);
|
|
});
|
|
|
|
test('a game in progress turns a latecomer away', async ({ browser }) => {
|
|
// The room has seats going spare; arriving in the middle of a game is
|
|
// what is refused, not the room being full.
|
|
const { host, code, close } = await playingPair(browser);
|
|
void host;
|
|
|
|
const thirdContext = await browser.newContext();
|
|
const third = await thirdContext.newPage();
|
|
await joinRoom(third, 'Nam', code);
|
|
|
|
await expect(third.getByTestId('join-error')).toHaveText('Ván đấu đang diễn ra.');
|
|
|
|
await thirdContext.close();
|
|
await close();
|
|
});
|
|
|
|
test('four players fill the room, and a fifth is turned away', async ({ browser }) => {
|
|
const { host, guest, code, close } = await twoPlayers(browser).then(async (pair) => ({
|
|
...pair,
|
|
code: await createRoom(pair.host, 'Minh')
|
|
}));
|
|
await joinRoom(guest, 'Lan', code);
|
|
|
|
/** @type {import('@playwright/test').BrowserContext[]} */
|
|
const extra = [];
|
|
/** @type {import('@playwright/test').Page[]} */
|
|
const players = [];
|
|
for (const name of ['Nam', 'Hà']) {
|
|
const context = await browser.newContext();
|
|
extra.push(context);
|
|
const page = await context.newPage();
|
|
players.push(page);
|
|
await joinRoom(page, name, code);
|
|
}
|
|
|
|
await expect(host.getByTestId('player-count')).toHaveText('4/4 người chơi');
|
|
await expect(seats(host)).toHaveCount(4);
|
|
|
|
const fifthContext = await browser.newContext();
|
|
const fifth = await fifthContext.newPage();
|
|
await joinRoom(fifth, 'Bình', code);
|
|
await expect(fifth.getByTestId('join-error')).toHaveText('Phòng đã đủ người.');
|
|
|
|
// Every guest has to say yes, not just the first one.
|
|
await guest.getByTestId('ready').click();
|
|
await expect(host.getByTestId('start-game')).toBeDisabled();
|
|
await readyAndStart(host, ...players);
|
|
|
|
// Four boards, and exactly one player on turn — whoever the draw dealt
|
|
// it to. The other three are all told the same name.
|
|
const names = new Map([
|
|
[host, 'Minh'],
|
|
[guest, 'Lan'],
|
|
[players[0], 'Nam'],
|
|
[players[1], 'Hà']
|
|
]);
|
|
const { lead, waits } = await awaitTurn(host, guest, ...players);
|
|
for (const page of waits) {
|
|
await expect(board(page).turn).toHaveText(`Đến lượt ${names.get(lead)}…`);
|
|
}
|
|
|
|
await fifthContext.close();
|
|
for (const context of extra) await context.close();
|
|
await close();
|
|
});
|
|
|
|
test('a player who goes out is a spectator, and the rest play on', async ({ browser }) => {
|
|
// Three players, so the game outlives the first knockout. Resignation
|
|
// rather than the clock: the turn limit on the test server is two
|
|
// minutes, and both go through the same elimination.
|
|
const { host, guest, code, close } = await twoPlayers(browser).then(async (pair) => ({
|
|
...pair,
|
|
code: await createRoom(pair.host, 'Minh')
|
|
}));
|
|
await joinRoom(guest, 'Lan', code);
|
|
|
|
const thirdContext = await browser.newContext();
|
|
const third = await thirdContext.newPage();
|
|
await joinRoom(third, 'Nam', code);
|
|
|
|
await readyAndStart(host, guest, third);
|
|
|
|
// The player who drew the first turn is the one who gives it up, so
|
|
// the position is inherited rather than merely passed over.
|
|
const names = new Map([
|
|
[host, 'Minh'],
|
|
[guest, 'Lan'],
|
|
[third, 'Nam']
|
|
]);
|
|
const { lead, waits } = await awaitTurn(host, guest, third);
|
|
|
|
await resign(lead);
|
|
|
|
// Out, but still in the room: no input, no result screen, and the game
|
|
// carrying on in front of them.
|
|
await expect(lead.getByTestId('eliminated')).toBeVisible();
|
|
await expect(lead.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0);
|
|
await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0);
|
|
|
|
// The others are told who went out, and the next of them is on turn —
|
|
// which the player who left is shown by name.
|
|
const {
|
|
lead: next,
|
|
waits: [last]
|
|
} = await awaitTurn(...waits);
|
|
await expect(lead.getByTestId('turn-indicator')).toHaveText(`Đến lượt ${names.get(next)}…`);
|
|
await expect(next.getByTestId('player-out')).toContainText(names.get(lead));
|
|
|
|
// The last two settle it, and everybody sees the same table.
|
|
await resign(next);
|
|
|
|
await expect(last.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
|
await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
|
await expect(lead.getByTestId('standings').locator('li')).toHaveCount(3);
|
|
|
|
await thirdContext.close();
|
|
await close();
|
|
});
|
|
});
|
|
|
|
test.describe('word meanings', () => {
|
|
test('both players see the newest word open', async ({ browser }) => {
|
|
const { lead, second, close } = await playingPair(browser);
|
|
|
|
const [opening] = await chainWords(lead);
|
|
expect(await openMeanings(lead)).toEqual([opening]);
|
|
expect(await openMeanings(second)).toEqual([opening]);
|
|
|
|
const played = await playLegalMove(lead, new Set([opening]));
|
|
await expect(second.locator('ol li .word').first()).toHaveText(played);
|
|
|
|
// The same word is open on both sides: the rule lives in the client
|
|
// store and both clients received the same move.
|
|
expect(await openMeanings(lead)).toEqual([played]);
|
|
expect(await openMeanings(second)).toEqual([played]);
|
|
|
|
await close();
|
|
});
|
|
});
|