/** * 立直麻将 — 联机 E2E(真实 UI + server + engine,4 端)。 * * 覆盖: * 1. 4 人注册 → 建房(4 座)→ 加入 → 房主开始对局。 * 2. 进入 playing:每人只见己方手牌;对手座位只显示牌背(无真实牌泄漏)。 * 3. 当前行动者打出第一张可点牌,轮转推进;牌河累积。 * 4. 鸣牌窗口出现时自动跳过。 * * 参考:apps/web/e2e/bridge-multiplayer.spec.ts。 */ 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 = `mj${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('mahjong'); 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 discardActor(seats: Seat[]): Promise { for (const s of seats) { const n = await s.page.locator('[data-testid=mj-myhand] [data-playable="true"]').count().catch(() => 0); if (n > 0) return s; } return null; } /** 任意 seat 出现鸣牌窗口就跳过。 */ async function passClaims(seats: Seat[]): Promise { for (const s of seats) { const btn = s.page.getByTestId('mj-claim-pass'); if ((await btn.count()) > 0 && (await btn.isVisible().catch(() => false))) { await btn.click().catch(() => {}); } } } 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]!), ]; // ===== 1. P0 建房(mahjong 可变人数,显式选 4 座) ===== await expect(async () => { const btn = seats[0]!.page.getByTestId('lobby-create-room'); await expect(btn).toBeEnabled({ timeout: 2_000 }); const seatsSel = seats[0]!.page.getByTestId('lobby-create-seats'); if ((await seatsSel.count()) > 0) await seatsSel.selectOption('4').catch(() => {}); await btn.click(); }).toPass({ timeout: 15_000 }); await expect(seats[0]!.page.getByTestId('mj-waiting')).toBeVisible({ timeout: 15_000 }); const roomText = await seats[0]!.page.getByTestId('lobby-current-room').innerText(); const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? ''; // ===== 2. 其余 3 家加入 ===== 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('mj-waiting')).toBeVisible({ timeout: 15_000 }); } // ===== 3. 房主开局 ===== await expect(async () => { const btn = seats[0]!.page.getByTestId('mj-start'); await expect(btn).toBeEnabled({ timeout: 2_000 }); await btn.click(); }).toPass({ timeout: 15_000 }); await expect(async () => { for (const seat of seats) { await expect(seat.page.getByTestId('mj-myhand')).toBeVisible({ timeout: 5_000 }); } }).toPass({ timeout: 15_000 }); // ===== 4. 隐藏信息:各见己方手牌;对手座位无真实牌泄漏 ===== for (const seat of seats) { const selfTiles = await seat.page.locator('[data-testid=mj-myhand] [data-testid^=mj-tile-]').count(); expect(selfTiles).toBeGreaterThanOrEqual(13); for (const other of seats.filter((s) => s.pid !== seat.pid)) { const leaked = await seat.page.locator(`[data-testid=mj-seat-${other.pid}] [data-testid^=mj-tile-]`).count(); expect(leaked).toBe(0); } } // ===== 5. 连续打牌若干轮(当前行动者打出第一张可点牌;有鸣牌窗口则跳过) ===== for (let i = 0; i < 6; i++) { await passClaims(seats); await expect(async () => { const actor = await discardActor(seats); expect(actor).not.toBeNull(); }).toPass({ timeout: 15_000 }); const actor = await discardActor(seats); if (!actor) throw new Error(`第 ${i + 1} 手:没有可出牌的玩家`); await actor.page.locator('[data-testid=mj-myhand] [data-playable="true"]').first().click(); await seats[0]!.page.waitForTimeout(500); } // 牌河累积(被合理的鸣牌消耗后仍应 >= 3) const totalRiver = await seats[0]!.page.locator('[data-testid^=mj-river-] [data-testid^=mj-tile-]').count(); expect(totalRiver).toBeGreaterThanOrEqual(3); console.log('mahjong matchID:', mid); }); });