Addresses dev-log 11 user feedback: - 删除 OnlineConfigBar 里的 Player/Secret 手动输入框(旧 workaround, 和 LobbyRoomList 的"加入 P0/P1"按钮重复,让用户困惑 p0/p1 是什么)。 现在只留 Server URL 覆盖入口;matchID/playerID/credentials 全部由 点按钮自动填写。 - LobbyRoomList 强化: - 创建房间按钮文案改为"创建房间(自动成为 P0)" - 自己所在房间不显示"加入 P0/P1"按钮(防止自加 409) - 顶部新增"在房间 X 作为 P0 + 退出房间"指示条 - leaveCurrentRoom 调 LobbyClient.leaveMatch 后清空 OnlineConfig - CardOnlineView 显示 socket.io 连接状态: - 自定义 loading 组件(替代默认 "connecting...") - board 顶部加 connection-status 条(已连接/未连接 + gameover) - 未连接时隐藏操作按钮(防止 move 发到失效连接) - War E2E 回归测试改为断言"自己房间无加入按钮 + 满员 data-full=true" Tests: pnpm ts 6/6, engine 34 + server 9, E2E 4/4.
127 lines
6.0 KiB
TypeScript
127 lines
6.0 KiB
TypeScript
/**
|
||
* 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 });
|
||
});
|
||
}); |