/** * War — 联机 E2E(2 客户端同步) * * 流程: * 1. Tab 0: 选 War → Online → 创建房间(自动 join P0) * 2. Tab 1: 选 War → Online → 刷新列表 → 加入 P1 * 3. 两 tab 状态同步(flip/collect) */ import { test, expect, Browser } from '@playwright/test'; async function setupOnlineGame(browser: Browser, gameDisplayName: 'war' | 'mill' | 'drag-test') { const ctx = await browser.newContext(); const page = await ctx.newPage(); // 监听 console 和 network 错误 page.on('console', (msg) => console.log(`[browser ${msg.type()}]`, msg.text())); page.on('pageerror', (err) => console.log('[pageerror]', err.message)); page.on('requestfailed', (req) => console.log('[requestfailed]', req.url(), req.failure()?.errorText)); page.on('response', (resp) => { if (resp.url().includes(':8000')) { console.log('[response]', resp.status(), resp.url()); } }); await page.goto('/'); await page.getByTestId('game-select').selectOption(gameDisplayName); await page.getByTestId('mode-online').click(); await expect(page.getByTestId('lobby-room-list')).toBeVisible(); return { ctx, page }; } async function joinAsFirstPlayer(page: any, playerSlot: '0' | '1' = '0') { if (playerSlot === '0') { await page.getByTestId('lobby-create-room').click(); } else { // Tab 1: 等一个"刚被 P0 创建"的房间出现(P1 还能加入的) // 不能简单地取 .first()——InMemory DB 残留旧房间可能 P0/P1 都已满 // 选第一个含"加入 P1"按钮的房间 await expect(async () => { await page.getByTestId('lobby-refresh').click(); const joinableBtns = await page.locator('[data-testid^="lobby-join-1-"]').all(); expect(joinableBtns.length).toBeGreaterThan(0); }).toPass({ timeout: 15_000 }); const firstJoinable = page.locator('[data-testid^="lobby-join-1-"]').first(); const testID = await firstJoinable.getAttribute('data-testid'); // testID 形如 "lobby-join-1-{matchID}" const matchID = testID!.replace(/^lobby-join-1-/, ''); await firstJoinable.click(); } await expect(page.getByTestId('online-need-room')).not.toBeVisible({ timeout: 15_000 }); } test.describe('War — 2-player multiplayer E2E', () => { test('create → join → flip → collect syncs both clients', async ({ browser }) => { test.setTimeout(120_000); const { page: p0 } = await setupOnlineGame(browser, 'war'); const { page: p1 } = await setupOnlineGame(browser, 'war'); // Tab 0 创建并 join P0 await joinAsFirstPlayer(p0, '0'); // Tab 1 刷新后 join P1 await joinAsFirstPlayer(p1, '1'); // 两 tab 都应看到 War 棋盘(4 个 zone: p0-deck / p1-deck / p0-pile / p1-pile) // 注意:Zone 是 absolute 定位 + auto width/height → Playwright 报 hidden(尺寸 0)。 // 用 attached 判断元素存在,再断言 .tts-card 卡片可见。 await expect(p0.getByTestId('zone-p0-deck')).toBeAttached({ timeout: 15_000 }); await expect(p0.getByTestId('zone-p1-deck')).toBeAttached(); await expect(p0.getByTestId('zone-p0-pile')).toBeAttached(); await expect(p0.getByTestId('zone-p1-pile')).toBeAttached(); await expect(p1.getByTestId('zone-p0-deck')).toBeAttached(); // 初始:双方 deck 26 / pile 0 await expect(p0.getByTestId('zone-count-p0-deck')).toHaveText('26'); await expect(p0.getByTestId('zone-count-p1-deck')).toHaveText('26'); await expect(p0.getByTestId('zone-count-p0-pile')).toHaveText('0'); // Tab 0 翻牌 await p0.getByRole('button', { name: '翻牌' }).click(); // Tab 0 端:双方 pile 各 +1 await expect(p0.getByTestId('zone-count-p0-pile')).toHaveText('1', { timeout: 10_000 }); await expect(p0.getByTestId('zone-count-p1-pile')).toHaveText('1'); // 同步:Tab 1 端也应看到 pile 各 1 await expect(p1.getByTestId('zone-count-p0-pile')).toHaveText('1', { timeout: 10_000 }); await expect(p1.getByTestId('zone-count-p1-pile')).toHaveText('1'); // Tab 0 收牌(任意一方都能调) // 注意:War 在 tie 时 collect 会保留 pile 等下一轮 flip。 // 服务器端 deck 洗牌是 Math.random(),不可预测 → 测试要容忍 tie。 // 简化:直接断言 pile 同步(flip 后是 1,collect 后要么 0 要么 1(tie 留着))。 await p0.getByRole('button', { name: '收牌' }).click(); // 等待状态同步:要么收集成功(pile 0),要么 tie 留着(pile 1) await expect(p1.getByTestId('zone-count-p0-pile')).toHaveText(/^[01]$/, { timeout: 10_000 }); await expect(p1.getByTestId('zone-count-p1-pile')).toHaveText(/^[01]$/); }); test('after join, lobby list refreshes so "加入" button disappears (no double-click 409)', async ({ browser }) => { // 回归测试:joinRoom 后必须 refresh,否则重复点会 409 const { page: p0 } = await setupOnlineGame(browser, 'war'); const { page: p1 } = await setupOnlineGame(browser, 'war'); await joinAsFirstPlayer(p0, '0'); // p0 当前所在 room 的 matchID:在 lobby 列表里 data-current="true" 的那个 await p0.getByTestId('lobby-refresh').click(); const currentRoom = p0.locator('[data-testid^="lobby-room-"][data-current="true"]').first(); await expect(currentRoom).toBeAttached({ timeout: 5_000 }); const currentTestID = await currentRoom.getAttribute('data-testid'); const currentMatchID = currentTestID!.replace(/^lobby-room-/, ''); // 新设计:自己所在房间**不显示**任何"加入 P0/P1"按钮(防止自加 409) await expect(p0.getByTestId(`lobby-join-0-${currentMatchID}`)).toHaveCount(0); await expect(p0.getByTestId(`lobby-join-1-${currentMatchID}`)).toHaveCount(0); // p1 加入 P1 await joinAsFirstPlayer(p1, '1'); // 验证:p0 端 refresh 后房间显示"满员"(P1 已被 p1 占) await p0.getByTestId('lobby-refresh').click(); const updatedRoom = p0.locator(`[data-testid="lobby-room-${currentMatchID}"]`); await expect(updatedRoom).toHaveAttribute('data-full', 'true', { timeout: 5_000 }); }); });