mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 22:16:20 +00:00
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.
509 lines
19 KiB
JavaScript
509 lines
19 KiB
JavaScript
import { expect, test } from '@playwright/test';
|
|
import {
|
|
board,
|
|
chainWords,
|
|
chat,
|
|
playLegalMove,
|
|
readyAndStart,
|
|
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();
|
|
}
|
|
|
|
/** Seats a pair and plays them into a game, which is where most tests start. */
|
|
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);
|
|
await waitForMyTurn(pair.host);
|
|
return { ...pair, code };
|
|
}
|
|
|
|
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.
|
|
await waitForMyTurn(host);
|
|
// Whose turn it is, by name: with four seats "the opponent" would stop
|
|
// naming anybody.
|
|
await expect(board(guest).turn).toHaveText('Đến lượt Minh…');
|
|
|
|
// 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(host));
|
|
const hostWord = await playLegalMove(host, used);
|
|
|
|
// The move crosses to the other browser, and the turn goes with it.
|
|
await expect(guest.getByText(hostWord, { exact: true }).first()).toBeVisible();
|
|
await waitForMyTurn(guest);
|
|
|
|
const guestWord = await playLegalMove(guest, used);
|
|
await expect(host.getByText(guestWord, { exact: true }).first()).toBeVisible();
|
|
await waitForMyTurn(host);
|
|
|
|
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');
|
|
|
|
// 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 waitForMyTurn(host);
|
|
await expect(board(guest).syllable).toBeVisible();
|
|
|
|
await close();
|
|
});
|
|
|
|
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
|
|
const { host, guest, close } = await playingPair(browser);
|
|
|
|
host.on('dialog', (dialog) => dialog.accept());
|
|
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
|
|
|
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);
|
|
|
|
host.on('dialog', (dialog) => dialog.accept());
|
|
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
|
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 waitForMyTurn(host);
|
|
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();
|
|
host.on('dialog', (dialog) => dialog.accept());
|
|
await kick.click();
|
|
|
|
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 joinRoom(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 waitForMyTurn(host);
|
|
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 joinRoom(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 waitForMyTurn(host);
|
|
|
|
// 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 message is rendered as text, never as markup', async ({ browser }) => {
|
|
const { host, guest, close } = await twoPlayers(browser);
|
|
|
|
const code = await createRoom(host, 'Minh');
|
|
await joinRoom(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 visible in the lobby, which shows no errors of its own', 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, and the panel is the only surface
|
|
// in this phase that can say so.
|
|
for (let i = 0; i < 8; i++) {
|
|
await say(host, `tin ${i}`);
|
|
}
|
|
|
|
// containText, not haveText: the box carries its own dismiss button.
|
|
await expect(chat(host).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 joinRoom(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.
|
|
await waitForMyTurn(host);
|
|
for (const page of [guest, ...players]) {
|
|
await expect(board(page).turn).toHaveText('Đến lượt Minh…');
|
|
}
|
|
|
|
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);
|
|
await waitForMyTurn(host);
|
|
|
|
host.on('dialog', (dialog) => dialog.accept());
|
|
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
|
|
|
// Out, but still in the room: no input, no result screen, and the game
|
|
// carrying on in front of them.
|
|
await expect(host.getByTestId('eliminated')).toBeVisible();
|
|
await expect(host.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0);
|
|
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0);
|
|
await expect(host.getByTestId('turn-indicator')).toHaveText('Đến lượt Lan…');
|
|
|
|
// The others are told who went out, and one of them is now on turn.
|
|
await expect(guest.getByTestId('player-out')).toContainText('Minh');
|
|
await waitForMyTurn(guest);
|
|
|
|
// The last two settle it, and everybody sees the same table.
|
|
guest.on('dialog', (dialog) => dialog.accept());
|
|
await guest.getByRole('button', { name: 'Đầu hàng' }).click();
|
|
|
|
await expect(third.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
|
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
|
await expect(host.getByTestId('standings').locator('li')).toHaveCount(3);
|
|
|
|
await thirdContext.close();
|
|
await close();
|
|
});
|
|
});
|