feat(game): 添加摸牌和碰杠胡操作功能
- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌 - 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态 - 添加 claimActionPending 状态防止重复提交操作 - 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性 - 添加 submitClaim 方法处理碰/杠/胡/过操作 - 实现 normalizePendingClaim 函数解析服务端推送的声明状态 - 在底部手牌区域将牌图片改为按钮以便点击弃牌 - 添加摸牌按钮和声明操作栏界面元素 - 更新房间创建表单添加局数选择选项 - 添加 E2E 测试文件验证多人房间流程 - 为登录页面输入框和按钮添加 testid 属性便于测试 - 修复 test-results 文件中的失败测试记录
This commit is contained in:
292
tests/e2e/room-flow.live.spec.ts
Normal file
292
tests/e2e/room-flow.live.spec.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { expect, request, test, type Browser, type BrowserContext, type Page } from 'playwright/test'
|
||||
|
||||
const liveEnabled = process.env.PLAYWRIGHT_LIVE === '1'
|
||||
const apiBaseURL = process.env.E2E_LIVE_API_BASE_URL ?? 'http://127.0.0.1:19000'
|
||||
const password = process.env.E2E_LIVE_PASSWORD ?? 'Passw0rd!'
|
||||
const configuredUsers = (process.env.E2E_LIVE_USERS ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
type PlayerPage = {
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
test.skip(!liveEnabled, 'set PLAYWRIGHT_LIVE=1 to run live integration flow')
|
||||
|
||||
test('live room flow: create, join, ready, start, ding que, multi-turn discard', async ({ browser }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const api = await request.newContext({
|
||||
baseURL: apiBaseURL,
|
||||
extraHTTPHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const players: PlayerPage[] = []
|
||||
|
||||
try {
|
||||
await expectLiveStackReady(api)
|
||||
|
||||
if (configuredUsers.length > 0) {
|
||||
for (const username of configuredUsers) {
|
||||
players.push(
|
||||
await openPlayerPage(browser, {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const sessions = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, index) => createLiveUserSession(api, index)),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
players.push(await openPlayerPage(browser, session))
|
||||
}
|
||||
}
|
||||
|
||||
const [owner, guest2, guest3, guest4] = players
|
||||
|
||||
await owner.page.goto('/hall')
|
||||
await expect(owner.page.getByTestId('open-create-room')).toBeVisible()
|
||||
await owner.page.getByTestId('open-create-room').click()
|
||||
await owner.page.getByTestId('create-room-name').fill(`live-room-${Date.now()}`)
|
||||
const createRoomResponsePromise = owner.page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/game/mahjong/room/create') && response.request().method() === 'POST'
|
||||
})
|
||||
await owner.page.getByTestId('submit-create-room').click()
|
||||
|
||||
const createRoomResponse = await createRoomResponsePromise
|
||||
if (!createRoomResponse.ok()) {
|
||||
throw new Error(
|
||||
`create room failed: status=${createRoomResponse.status()} body=${await createRoomResponse.text()} request=${createRoomResponse.request().postData() ?? ''}`,
|
||||
)
|
||||
}
|
||||
const createRoomPayload = (await createRoomResponse.json()) as { data?: { room_id?: string; name?: string } }
|
||||
const roomID = createRoomPayload.data?.room_id
|
||||
if (!roomID) {
|
||||
throw new Error('live room id not found after creating room')
|
||||
}
|
||||
const roomName = createRoomPayload.data?.name ?? ''
|
||||
|
||||
const enterCreatedRoomButton = owner.page.getByTestId('enter-created-room')
|
||||
if (await enterCreatedRoomButton.isVisible().catch(() => false)) {
|
||||
await enterCreatedRoomButton.click()
|
||||
} else {
|
||||
await owner.page.goto(`/game/chengdu/${roomID}${roomName ? `?roomName=${encodeURIComponent(roomName)}` : ''}`)
|
||||
}
|
||||
|
||||
await expect(owner.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}`))
|
||||
|
||||
for (const guest of [guest2, guest3, guest4]) {
|
||||
await guest.page.goto('/hall')
|
||||
await expect(guest.page.getByTestId('quick-join-room-id')).toBeVisible()
|
||||
await guest.page.getByTestId('quick-join-room-id').fill(roomID)
|
||||
await guest.page.getByTestId('quick-join-submit').click()
|
||||
await expect(guest.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}(\\?.*)?$`))
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
await expect(player.page.getByTestId('ready-toggle')).toBeVisible()
|
||||
await player.page.getByTestId('ready-toggle').click()
|
||||
}
|
||||
|
||||
await expect(owner.page.getByTestId('start-game')).toBeEnabled({ timeout: 20_000 })
|
||||
await owner.page.getByTestId('start-game').click()
|
||||
|
||||
const dingQueChoices: Array<'w' | 't' | 'b'> = ['w', 't', 'b', 'w']
|
||||
for (const [index, player] of players.entries()) {
|
||||
const suit = dingQueChoices[index]
|
||||
const dingQueButton = player.page.getByTestId(`ding-que-${suit}`)
|
||||
await expect(dingQueButton).toBeVisible({ timeout: 20_000 })
|
||||
await expect(dingQueButton).toBeEnabled()
|
||||
await dingQueButton.click()
|
||||
}
|
||||
|
||||
const discardActors: string[] = []
|
||||
let previousActor: PlayerPage | null = null
|
||||
|
||||
for (let turn = 0; turn < 4; turn += 1) {
|
||||
const actor = await findDiscardActor(players, 30_000, previousActor)
|
||||
const actorTiles = actor.page.locator('[data-testid^="hand-tile-"]')
|
||||
expect(await actorTiles.count()).toBeGreaterThan(0)
|
||||
expect(await countEnabledTiles(actor.page)).toBeGreaterThan(0)
|
||||
|
||||
await actorTiles.first().click()
|
||||
await expect
|
||||
.poll(() => countEnabledTiles(actor.page), { timeout: 20_000 })
|
||||
.toBe(0)
|
||||
|
||||
await resolvePendingClaims(players, 10_000)
|
||||
await drawIfNeeded(players, 10_000)
|
||||
|
||||
discardActors.push(actor.username)
|
||||
previousActor = actor
|
||||
}
|
||||
|
||||
expect(new Set(discardActors).size).toBeGreaterThan(1)
|
||||
} finally {
|
||||
await api.dispose()
|
||||
await Promise.all(players.map(async ({ context }) => context.close()))
|
||||
}
|
||||
})
|
||||
|
||||
async function expectLiveStackReady(api: Awaited<ReturnType<typeof request.newContext>>): Promise<void> {
|
||||
const response = await api.get('/healthz')
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
async function createLiveUserSession(
|
||||
api: Awaited<ReturnType<typeof request.newContext>>,
|
||||
index: number,
|
||||
): Promise<{ username: string; password: string }> {
|
||||
const seed = `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const username = `pwlive_${seed}`
|
||||
const timestampDigits = String(Date.now()).slice(-8)
|
||||
const randomDigit = Math.floor(Math.random() * 10)
|
||||
const phone = `13${timestampDigits}${index}${randomDigit}`
|
||||
const email = `${username}@example.com`
|
||||
|
||||
const registerResponse = await api.post('/api/v1/auth/register', {
|
||||
data: {
|
||||
username,
|
||||
phone,
|
||||
email,
|
||||
password,
|
||||
},
|
||||
})
|
||||
expect(registerResponse.ok(), await registerResponse.text()).toBeTruthy()
|
||||
|
||||
const loginResponse = await api.post('/api/v1/auth/login', {
|
||||
data: {
|
||||
login_id: username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
expect(loginResponse.ok(), await loginResponse.text()).toBeTruthy()
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPlayerPage(
|
||||
browser: Browser,
|
||||
session: { username: string; password: string },
|
||||
): Promise<PlayerPage> {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await page.goto('/login')
|
||||
await page.getByTestId('login-id').fill(session.username)
|
||||
await page.getByTestId('login-password').fill(session.password)
|
||||
const loginResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
|
||||
})
|
||||
await page.getByTestId('login-submit').click()
|
||||
const loginResponse = await loginResponsePromise
|
||||
if (!loginResponse.ok()) {
|
||||
throw new Error(
|
||||
`browser login failed: status=${loginResponse.status()} body=${await loginResponse.text()} request=${loginResponse.request().postData() ?? ''}`,
|
||||
)
|
||||
}
|
||||
await expect(page).toHaveURL(/\/hall$/)
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
}
|
||||
}
|
||||
|
||||
async function findDiscardActor(
|
||||
players: PlayerPage[],
|
||||
timeoutMs: number,
|
||||
previousActor?: PlayerPage | null,
|
||||
): Promise<PlayerPage> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const player of players) {
|
||||
if (previousActor && player.username === previousActor.username) {
|
||||
continue
|
||||
}
|
||||
const firstTile = player.page.locator('[data-testid^="hand-tile-"]').first()
|
||||
const tileCount = await player.page.locator('[data-testid^="hand-tile-"]').count()
|
||||
if (tileCount === 0) {
|
||||
continue
|
||||
}
|
||||
if (await firstTile.isEnabled().catch(() => false)) {
|
||||
return player
|
||||
}
|
||||
}
|
||||
await players[0]?.page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
const diagnostics = await Promise.all(
|
||||
players.map(async (player) => {
|
||||
const logs = await player.page.locator('.sidebar-line').allTextContents().catch(() => [])
|
||||
const claimBarVisible = await player.page.getByTestId('claim-action-bar').isVisible().catch(() => false)
|
||||
const passVisible = await player.page.getByTestId('claim-pass').isVisible().catch(() => false)
|
||||
const enabledTiles = await countEnabledTiles(player.page)
|
||||
return [
|
||||
`player=${player.username}`,
|
||||
`enabledTiles=${enabledTiles}`,
|
||||
`claimBar=${claimBarVisible}`,
|
||||
`claimPass=${passVisible}`,
|
||||
`logs=${logs.slice(0, 4).join(' || ')}`,
|
||||
].join(' ')
|
||||
}),
|
||||
)
|
||||
|
||||
throw new Error(`no player reached enabled discard state within timeout\n${diagnostics.join('\n')}`)
|
||||
}
|
||||
|
||||
async function countEnabledTiles(page: Page): Promise<number> {
|
||||
return page.locator('[data-testid^="hand-tile-"]:enabled').count()
|
||||
}
|
||||
|
||||
async function resolvePendingClaims(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
let handled = false
|
||||
|
||||
for (const player of players) {
|
||||
const passButton = player.page.getByTestId('claim-pass')
|
||||
if (await passButton.isVisible().catch(() => false)) {
|
||||
await expect(passButton).toBeEnabled({ timeout: 5_000 })
|
||||
await passButton.click()
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
return
|
||||
}
|
||||
|
||||
await players[0]?.page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
async function drawIfNeeded(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const player of players) {
|
||||
const drawButton = player.page.getByTestId('draw-tile')
|
||||
if (await drawButton.isVisible().catch(() => false)) {
|
||||
await expect(drawButton).toBeEnabled({ timeout: 5_000 })
|
||||
await drawButton.click()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await players[0]?.page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user