Files
huajishe-tts/apps/web/e2e/war-multiplayer.spec.ts
e2hang 948b45b20a fix(ui): lobby UX — remove p0/p1 manual entry, add leave room, conn status
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.
2026-08-23 21:33:03 +08:00

127 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* War — 联机 E2E2 客户端同步)
*
* 流程:
* 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 后是 1collect 后要么 0 要么 1tie 留着))。
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 });
});
});