/** * 拱猪 — 联机 E2E(真实 UI + server + engine)。 * * 问题1:玩家加入后 UI 应显示真实玩家名(server setName 派发 + 客户端兜底)。 * 问题2:4 人出牌,第 4 张出完后所有客户端都能看到 4 张牌 + 赢家高亮 3 秒,再收牌。 */ import { test, expect, type Browser, type Page, type BrowserContext } from '@playwright/test'; interface Seat { ctx: BrowserContext; page: Page; pid: string; name: string; } async function authUser(suffix: string): Promise<{ token: string; username: string }> { const uname = `gz${suffix}${Math.floor(Math.random() * 1e6)}`.slice(0, 18); const pwd = 'pw123456'; const base = process.env.TTS_API_BASE ?? 'http://localhost:8000'; const reg = await fetch(`${base}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: uname, password: pwd }), }); if (reg.ok) { const j = (await reg.json()) as { token: string; username: string }; return { token: j.token, username: j.username }; } const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: uname, password: pwd }), }); const j = (await login.json()) as { token: string; username: string }; return { token: j.token, username: j.username }; } async function newSeat(browser: Browser, pid: string, auth: { token: string; username: string }): Promise { const ctx = await browser.newContext(); const page = await ctx.newPage(); await page.addInitScript( ([token, username]) => { localStorage.setItem('tts-like.token', token); localStorage.setItem('tts-like.username', username); }, [auth.token, auth.username] as [string, string], ); await page.goto('/'); page.on('console', (m) => console.log(`[browser:${pid}] ${m.text()}`)); await page.getByTestId('game-select').selectOption('pig'); await page.getByTestId('mode-online').click(); await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 }); return { ctx, page, pid, name: auth.username }; } /** 当前可出牌的玩家:优先取「其页面显示『出牌中』且确有合法牌」的座位(避免点到已过期仍能见牌的旧行动者)。 */ async function currentActor(seats: Seat[]): Promise { const infos = await Promise.all(seats.map(async (s) => { const legal = await s.page.locator('[data-testid^="pig-card-"][data-legal="true"]').count(); const turn = await s.page.locator(`[data-testid="pig-seat-${s.pid}"]`).innerText().catch(() => ''); return { s, legal, isTurn: turn.includes('出牌中') }; })); // 先选「轮到你」且「有合法牌」的;其次退化为「有合法牌」的(过渡窗口)。 const turnActor = infos.find((i) => i.isTurn && i.legal > 0); if (turnActor) return turnActor.s; const anyActor = infos.find((i) => i.legal > 0); return anyActor ? anyActor.s : null; } test.describe('拱猪 — 玩家名 + 3 秒延迟收牌', () => { test('4 人:真实玩家名显示 + 满墩 3 秒后收牌', async ({ browser }) => { const auths = [await authUser('a'), await authUser('b'), await authUser('c'), await authUser('d')]; const seats = [ await newSeat(browser, '0', auths[0]!), await newSeat(browser, '1', auths[1]!), await newSeat(browser, '2', auths[2]!), await newSeat(browser, '3', auths[3]!), ]; // P0 创建 4 座房间 await seats[0]!.page.getByTestId('lobby-create-seats').selectOption('4'); await seats[0]!.page.getByTestId('lobby-create-room').click(); await expect(seats[0]!.page.getByTestId('pig-board')).toBeVisible({ timeout: 15_000 }); // 读 P0 matchID,让其他人加入同一房间 const roomText = await seats[0]!.page.getByTestId('lobby-current-room').innerText(); const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? ''; for (const seat of seats.slice(1)) { await expect(async () => { await seat.page.getByTestId('lobby-refresh').click(); const btn = seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`); expect(await btn.count()).toBeGreaterThan(0); }).toPass({ timeout: 15_000 }); await seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`).click(); await expect(seat.page.getByTestId('pig-board')).toBeVisible({ timeout: 15_000 }); } // 房主开始 await expect(async () => { const btn = seats[0]!.page.getByTestId('pig-start-game'); await expect(btn).toBeEnabled({ timeout: 2_000 }); await btn.click(); }).toPass({ timeout: 15_000 }); // 等发牌完成(任一玩家手牌出现) await expect(async () => { expect(await seats[0]!.page.locator('[data-testid^="pig-card-"]').count()).toBeGreaterThan(0); }).toPass({ timeout: 10_000 }); // ===== 问题1:所有座位显示真实玩家名(非 P0/P1/P2/P3) ===== for (const seat of seats) { await expect(async () => { for (const s of seats) { const seatEl = seat.page.getByTestId(`pig-seat-${s.pid}`); const text = await seatEl.innerText(); expect(text).toContain(s.name); } }).toPass({ timeout: 10_000 }); } // ===== 问题2:4 人出完一墩 → 4 张牌都可见 3 秒 → 再收牌 ===== // 依次让当前行动者出第一张合法牌 for (let i = 0; i < 4; i++) { await expect(async () => { const actor = await currentActor(seats); if (!actor) throw new Error('no actor'); const card = actor.page.locator('[data-testid^="pig-card-"][data-legal="true"]').first(); await card.click(); }).toPass({ timeout: 15_000 }); } // 第 4 张出完:trickWinner 高亮 + 4 张牌仍在桌中央(所有客户端可见) for (const seat of seats) { await expect(async () => { // 中央 4 张牌(CardFace 元素) const centerCards = seat.page.locator('[data-testid="pig-board"] > div > div > div > div > div[style*="border-radius: 5px"], [data-testid^="pig-card-"]'); // 更直接:检查"墩满 · 3 秒后收牌"提示 const hint = seat.page.getByTestId('pig-board').locator('text=墩满'); expect(await hint.count()).toBeGreaterThan(0); }).toPass({ timeout: 5_000 }); } // 此时所有客户端都看到赢家高亮 for (const seat of seats) { await expect(seat.page.locator('text=赢得本墩').first()).toBeVisible({ timeout: 2_000 }); } // 等 3 秒收牌(+ 余量) await seats[0]!.page.waitForTimeout(4000); // 收牌后:墩数变 1,桌中央清空,赢家拿到 4 张 await expect(async () => { const text = await seats[0]!.page.getByTestId('pig-board').innerText(); expect(text).toContain('已出 1/13 墩'); }).toPass({ timeout: 5_000 }); console.log('matchID:', mid); }); });