Files
noitu/web/e2e/pvp-game.spec.js
tiennm99 d94c2a105c feat(web): apply the whole-game UX review's copy proposals
Rewrites the strings the review flagged as unclear or misleading: the
not_your_turn and not_a_dead_end messages now describe the race or the
position they actually answer, need_more_players drops a server constant
that had leaked into prose, the per-game score and the room's series score
get separate labels instead of sharing "Tỉ số", the opponent-turn fallback
reads neutral rather than assuming one opponent, a chat author who left
reads as a name rather than a sentence, too_fast drops its odd-one-out
"nhé", and the meta description stops promising 1v1 for a 2-4 person game.
2026-09-21 16:58:50 +07:00

749 lines
28 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');
// Seated, not merely joined: readyAndStart clicks the guest's own ready
// row next, which does not exist until the seat does.
await joinRoomSeated(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 };
}
/**
* Hands the turn to the room's owner, which the resignation rule needs: only
* the player to act may give up, and who leads is drawn at the start.
*
* The fixture graph is a forced path, so the other player has exactly one
* legal word and playing it passes the turn.
*/
async function hostOnTurn({ host, lead }) {
if (lead === host) return;
await playLegalMove(lead, new Set(await chainWords(lead)));
await waitForMyTurn(host);
}
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('the waiting player cannot type a word into the field', async ({ browser }) => {
const { second, close } = await playingPair(browser);
// The field is never disabled — that is what keeps a phone's keyboard
// open across the handover — so out of turn it is the text it refuses,
// and nobody spends somebody else's turn writing a word that was never
// going to be sent.
//
// Focused and typed into rather than clicked: Playwright refuses to
// click a field that says aria-disabled, which is what this one tells
// a screen reader, and a real player's keystrokes never go through
// that check.
const field = board(second).input;
await field.focus();
await second.keyboard.type('sinh viên');
await expect(field).toHaveValue('');
await expect(board(second).submit).toBeDisabled();
await close();
});
test('giving up is offered on your turn and no other', async ({ browser }) => {
const { lead, second, close } = await playingPair(browser);
// Still on screen for the player waiting — the way out of a game must
// not appear and disappear under their thumb every handover — but not
// theirs to press until the turn is.
const give = (page) => page.getByRole('button', { name: 'Đầu hàng' });
await expect(give(second)).toBeDisabled();
await expect(give(lead)).toBeEnabled();
// It follows the turn: the leader plays, and the two swap.
await playLegalMove(lead, new Set(await chainWords(lead)));
await waitForMyTurn(second);
await expect(give(second)).toBeEnabled();
await expect(give(lead)).toBeDisabled();
await close();
});
test('going back to the homepage leaves the room, and the others are told', async ({
browser
}) => {
const { host, guest, close } = await twoPlayers(browser);
// Reached by link rather than by URL, so going back is the in-app
// navigation a player actually makes. A reload is the other thing
// entirely: that resumes the game, and has its own test.
await host.goto('/');
await setNickname(host, 'Minh');
await host.getByRole('link', { name: 'Đấu trực tuyến' }).click();
await host.getByRole('button', { name: 'Tạo phòng' }).click();
const code = ((await host.getByTestId('room-code').textContent()) ?? '').replace(/\s+/g, '');
await joinRoom(guest, 'Lan', code);
await readyAndStart(host, guest);
await awaitTurn(host, guest);
await host.goBack();
await expect(host.getByRole('link', { name: 'Đấu trực tuyến' })).toBeVisible();
// Left the room, not lost the connection: the game is over for the
// player who stayed, and it says somebody left rather than counting
// down a seat nobody is coming back to.
await expect(guest.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
await expect(guest.getByTestId('player-count')).toHaveText('1/4 người chơi');
// And the player who left is a stranger again, free to open a room of
// their own rather than being resumed into the one they walked out of.
await host.getByRole('link', { name: 'Đấu trực tuyến' }).click();
await host.getByRole('button', { name: 'Tạo phòng' }).click();
const next = ((await host.getByTestId('room-code').textContent()) ?? '').replace(/\s+/g, '');
expect(next).not.toBe(code);
await close();
});
test('a ready player who goes back to the homepage gives up the seat', async ({ browser }) => {
const { host, guest, close } = await twoPlayers(browser);
const code = await createRoom(host, 'Minh');
// The guest arrives by link, so going back is the in-app navigation a
// player makes rather than a fresh document load.
await guest.goto('/');
await setNickname(guest, 'Lan');
await guest.getByRole('link', { name: 'Đấu trực tuyến' }).click();
await guest.getByLabel('Mã phòng').fill(code);
await guest.getByRole('button', { name: 'Vào phòng' }).click();
await guest.getByTestId('ready').click();
await expect(host.getByTestId('start-game')).toBeEnabled();
// The room refuses a ready player's own leave button on purpose, and
// going home is not that button: the seat has to go, or it sits in the
// lobby ready and empty until the reconnect window runs out.
await guest.goBack();
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
await expect(host.getByTestId('start-game')).toBeDisabled();
await close();
});
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
const pair = await playingPair(browser);
const { host, guest, close } = pair;
await hostOnTurn(pair);
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 pair = await playingPair(browser);
const { host, guest, code, close } = pair;
await hostOnTurn(pair);
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('Ván thắng 1');
await expect(host.getByTestId('wins-p1')).toContainText('Ván thắng 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('Thắng 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 chat(guest).toggle.click();
await say(guest, 'ba');
await expect(chat(host).unread).toHaveText('1 tin mới');
await chat(host).toggle.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('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();
});
});