mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 12:28:54 +00:00
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.
749 lines
28 KiB
JavaScript
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();
|
|
});
|
|
});
|