/** * 桥牌 — 联机 E2E(真实 UI + server + engine)。 * * 覆盖: * 1. 4 人加入 fixedSeats=4 的房间 → 房主点「发牌开始」→ 4 家各得 13 张。 * 2. 叫牌阶段:当前行动者按钮可用,非行动者禁用;依次 1♠ + 3 家 Pass → 进入 play。 * 3. play 阶段:明手牌对所有人公开;庄家可控制自己 + 明手。 * 4. 满墩 3 秒后 collectTrick:trickCount 推进。 * 5. 13 墩打完 → 进入 score 阶段 → 房主可发下一副。 * * 参考:apps/web/e2e/pig-names-and-trick-delay.spec.ts(4 座房 + 联机流程模式)。 */ 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 = `br${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('bridge'); await page.getByTestId('mode-online').click(); await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 }); return { ctx, page, pid, name: auth.username }; } /** 当前叫牌者 + 当前 seat 的「合法按钮」列表(用于校验非法按钮已禁用)。 */ async function bidActor(seats: Seat[]): Promise { for (const s of seats) { const text = await s.page.getByTestId(`bridge-seat-${s.pid}`).innerText().catch(() => ''); if (text.includes('叫牌中')) return s; } return null; } /** 抓取「叫牌中」玩家的所有叫品按钮 + data-legal 标记。 * 返回 { callButtons: { id, label, legal }[] } —— 用于断言「非法叫品已禁用」。 */ async function bidButtonStates(actor: Seat): Promise> { return await actor.page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('[data-testid^="call-"]')) as HTMLButtonElement[]; return buttons .filter((b) => b.tagName === 'BUTTON') .map((b) => ({ id: b.getAttribute('data-testid') ?? '', label: b.textContent?.trim() ?? '', legal: b.getAttribute('data-legal') === 'true', })); }); } /** 当前可出牌的玩家:找有「可点牌」(data-playable=true)的 seat。 * - 防守方:自己 seat 上有可点牌 → 自己出。 * - 庄家:自己 seat 上有可点牌(轮到自己时) OR 明手 seat 上有可点牌(轮到明手时)。 * - 明手本人:永远没有可点牌(declarer 控)。 */ async function playActor(seats: Seat[]): Promise { for (const s of seats) { const playable = await s.page.locator('[data-playable="true"]').count(); if (playable > 0) return s; } return null; } test.describe('桥牌 — 4 人联机 端到端', () => { test('完整流程:发牌 → 叫牌 → 打牌 → 收牌 → 计分', 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 创建桥牌房间(bridge 在 GAMES 中 fixedSeats=4,自动 4 座) await expect(async () => { const btn = seats[0]!.page.getByTestId('lobby-create-room'); await expect(btn).toBeEnabled({ timeout: 2_000 }); await btn.click(); }).toPass({ timeout: 15_000 }); await expect(seats[0]!.page.getByTestId('bridge-waiting')).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('bridge-waiting')).toBeVisible({ timeout: 15_000 }); } // 房主开始发牌 await expect(async () => { const btn = seats[0]!.page.getByTestId('bridge-start'); await expect(btn).toBeEnabled({ timeout: 2_000 }); await btn.click(); }).toPass({ timeout: 15_000 }); // ===== 1. 发牌完成 → 进入 bidding 阶段 ===== await expect(async () => { for (const seat of seats) { await expect(seat.page.getByTestId('bridge-bidding')).toBeVisible({ timeout: 5_000 }); } }).toPass({ timeout: 15_000 }); // 每人手牌 13 张 for (const seat of seats) { await expect(async () => { const cardCount = await seat.page.locator('[data-testid^="card-"]').count(); expect(cardCount).toBeGreaterThanOrEqual(13); }).toPass({ timeout: 5_000 }); } // ===== 2. 叫牌:P0 叫 1♠,其余 3 家 Pass ===== // 找出当前 bidTurn 玩家,逐家出牌 for (let i = 0; i < 4; i++) { await expect(async () => { const actor = await bidActor(seats); expect(actor).not.toBeNull(); if (!actor) return; if (i === 0) { // P0 (NS) 叫 1♠。第一次开叫:X/XX 应禁用(没有敌方叫品可加倍) const states = await bidButtonStates(actor); const xBtn = states.find((s) => s.id === 'call-double'); const xxBtn = states.find((s) => s.id === 'call-redouble'); expect(xBtn?.legal).toBe(false); expect(xxBtn?.legal).toBe(false); // 1♠ 应合法(第一家开叫无约束) const bid1S = states.find((s) => s.id === 'call-bid-1♠'); expect(bid1S?.legal).toBe(true); await actor.page.getByTestId('call-bid-1♠').click(); } else if (i === 1) { // P1 (EW):上一叫品是 1♠。1♣..1♥ 应禁用;1♠ / Pass 合法;X 合法(敌方叫品) const states = await bidButtonStates(actor); expect(states.find((s) => s.id === 'call-bid-1♣')?.legal).toBe(false); expect(states.find((s) => s.id === 'call-bid-1♦')?.legal).toBe(false); expect(states.find((s) => s.id === 'call-bid-1♥')?.legal).toBe(false); expect(states.find((s) => s.id === 'call-bid-1♠')?.legal).toBe(false); // 等于上一叫品 = 非法 expect(states.find((s) => s.id === 'call-bid-1NT')?.legal).toBe(true); expect(states.find((s) => s.id === 'call-bid-2♣')?.legal).toBe(true); expect(states.find((s) => s.id === 'call-double')?.legal).toBe(true); // X 合法 expect(states.find((s) => s.id === 'call-redouble')?.legal).toBe(false); // 没被加倍 → 非法 await actor.page.getByTestId('call-pass').click(); } else if (i === 2) { // P2 (NS):1♠ 之后 Pass。P2 是 dummy;叫牌 +1♠ 不可能(已叫过);1NT / 2♣ 合法 const states = await bidButtonStates(actor); expect(states.find((s) => s.id === 'call-bid-1♠')?.legal).toBe(false); // 同阶同花色 expect(states.find((s) => s.id === 'call-bid-1NT')?.legal).toBe(true); await actor.page.getByTestId('call-pass').click(); } else { // P3 (EW):Pass await actor.page.getByTestId('call-pass').click(); } }).toPass({ timeout: 10_000 }); } // ===== 3. 进入 play 阶段:contract 1♠,declarer=0, dummy=2, openingLeader=1 ===== await expect(async () => { for (const seat of seats) { await expect(seat.page.getByTestId('bridge-play')).toBeVisible({ timeout: 5_000 }); // contract display 应为「契约 1♠」 await expect(seat.page.getByTestId('contract-display')).toContainText('1♠', { timeout: 5_000 }); } }).toPass({ timeout: 15_000 }); // 明手牌对所有人公开(P2 是 dummy)。等待进入 play 阶段且明手亮出。 await expect(async () => { for (const seat of seats) { await expect(seat.page.getByTestId('bridge-play')).toBeVisible({ timeout: 5_000 }); const handArea = seat.page.getByTestId('bridge-hand-area'); await expect(handArea).toContainText('明手', { timeout: 5_000 }); } }).toPass({ timeout: 15_000 }); // ===== 2.5 明手规则验证 ===== // P0 (declarer) 应能控制明手牌 — 明手牌区显示「你控制」 + dummy 牌 13 张可点(轮到 dummy 时) const declarerPage = seats[0]!.page; await expect(declarerPage.getByTestId('bridge-hand-area')).toContainText('明手', { timeout: 3_000 }); await expect(declarerPage.getByTestId('bridge-hand-area')).toContainText('你控制', { timeout: 3_000 }); // 明手牌 13 张可见 const dummyCardsCount = await declarerPage.locator('[data-testid^="card-"]').count(); expect(dummyCardsCount).toBeGreaterThanOrEqual(26); // 13 自己 + 13 明手 // P2 (dummy) 自己看自己的牌:明手本人不能自己出牌(不可点) const dummySeat = seats[2]!; // 明手本人页面:手牌区应明示「由庄家 XXX 控制(你不可出牌)」 await expect(dummySeat.page.getByTestId('bridge-hand-area')).toContainText('由庄家', { timeout: 3_000 }); await expect(dummySeat.page.getByTestId('bridge-hand-area')).toContainText('你不可出牌', { timeout: 3_000 }); // 自己的牌 13 张可见但都不可点 const dummySelfCardCount = await dummySeat.page.locator('[data-testid^="card-"]').count(); expect(dummySelfCardCount).toBeGreaterThanOrEqual(13); const dummySelfPlayable = await dummySeat.page.locator('[data-playable="true"]').count(); expect(dummySelfPlayable).toBe(0); // 明手牌无 data-playable(不能自己出) // 防守方 (P1) 也能看到明手牌(playerView 公开) const defenderPage = seats[1]!.page; await expect(defenderPage.getByTestId('bridge-hand-area')).toContainText('明手', { timeout: 3_000 }); // ===== 4. 出第一墩(4 人各出一张合法牌) ===== for (let i = 0; i < 4; i++) { // 等到至少有 1 个 seat 有可点的牌(确保状态稳定) await expect(async () => { const actor = await playActor(seats); expect(actor).not.toBeNull(); }).toPass({ timeout: 10_000 }); // 然后点击 const actor = await playActor(seats); if (!actor) throw new Error(`第 ${i + 1} 张牌:没有 actor`); const card = actor.page.locator('[data-playable="true"]').first(); await expect(card).toBeVisible({ timeout: 3_000 }); await card.click(); // 等一下让状态传播 await seats[0]!.page.waitForTimeout(500); } // ===== 5. 满墩:所有客户端看到「上一墩 · XXX 赢(3 秒后收)」提示 ===== for (const seat of seats) { await expect(async () => { await expect(seat.page.getByTestId('last-trick')).toBeVisible({ timeout: 5_000 }); }).toPass({ timeout: 10_000 }); } // ===== 6. 等 3 秒后收牌:trickCount 应推进到 1 ===== await seats[0]!.page.waitForTimeout(4_000); await expect(async () => { for (const seat of seats) { const boardText = await seat.page.getByTestId('bridge-play').innerText(); expect(boardText).toMatch(/第\s*2\s*\/\s*13\s*墩/); } }).toPass({ timeout: 10_000 }); console.log('bridge matchID:', mid); }); });