- 添加discardPending状态控制丢弃牌操作 - 实现canDiscardTiles计算属性判断是否可以丢弃牌 - 新增handleRoomStateResponse函数处理房间状态响应 - 实现discardTile函数发送丢弃牌消息 - 在游戏页面添加手牌操作栏显示可丢弃的牌 - 为定缺按钮和准备按钮添加data-testid标识 - 在大厅页面为房间操作元素添加data-testid标识 - 添加手牌操作相关的CSS样式 - 配置Playwright E2E测试框架 - 创建房间流程到打牌的完整E2E测试用例
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { expect, test } from 'playwright/test'
|
|
|
|
test('enter room, ready, start game, ding que, and discard tile', async ({ page }) => {
|
|
let createdRoom: Record<string, unknown> | null = null
|
|
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem(
|
|
'mahjong_auth',
|
|
JSON.stringify({
|
|
token: 'mock-access-token',
|
|
tokenType: 'Bearer',
|
|
refreshToken: 'mock-refresh-token',
|
|
user: {
|
|
id: 'u-e2e-1',
|
|
username: '测试玩家',
|
|
},
|
|
}),
|
|
)
|
|
|
|
type Tile = { id: number; suit: 'W' | 'T' | 'B'; value: number }
|
|
type Player = {
|
|
index: number
|
|
player_id: string
|
|
player_name: string
|
|
ready: boolean
|
|
missing_suit?: string | null
|
|
}
|
|
|
|
const state: {
|
|
roomId: string
|
|
status: 'waiting' | 'playing'
|
|
dealerIndex: number
|
|
currentTurn: number
|
|
selfId: string
|
|
players: Player[]
|
|
hand: Tile[]
|
|
missingSuit: string | null
|
|
} = {
|
|
roomId: 'room-e2e-001',
|
|
status: 'waiting',
|
|
dealerIndex: 0,
|
|
currentTurn: 0,
|
|
selfId: 'u-e2e-1',
|
|
players: [
|
|
{ index: 0, player_id: 'u-e2e-1', player_name: '测试玩家', ready: false, missing_suit: null },
|
|
{ index: 1, player_id: 'bot-2', player_name: '机器人二号', ready: true, missing_suit: null },
|
|
{ index: 2, player_id: 'bot-3', player_name: '机器人三号', ready: true, missing_suit: null },
|
|
{ index: 3, player_id: 'bot-4', player_name: '机器人四号', ready: true, missing_suit: null },
|
|
],
|
|
hand: [
|
|
{ id: 11, suit: 'W', value: 1 },
|
|
{ id: 12, suit: 'W', value: 2 },
|
|
{ id: 13, suit: 'W', value: 3 },
|
|
{ id: 21, suit: 'T', value: 4 },
|
|
{ id: 22, suit: 'T', value: 5 },
|
|
{ id: 23, suit: 'T', value: 6 },
|
|
{ id: 31, suit: 'B', value: 1 },
|
|
{ id: 32, suit: 'B', value: 2 },
|
|
{ id: 33, suit: 'B', value: 3 },
|
|
{ id: 34, suit: 'B', value: 4 },
|
|
{ id: 35, suit: 'B', value: 5 },
|
|
{ id: 36, suit: 'B', value: 6 },
|
|
{ id: 37, suit: 'B', value: 7 },
|
|
],
|
|
missingSuit: null,
|
|
}
|
|
|
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|
|
|
const buildRoomPayload = () => ({
|
|
room: {
|
|
room_id: state.roomId,
|
|
name: 'E2E 测试房间',
|
|
game_type: 'chengdu',
|
|
owner_id: state.selfId,
|
|
max_players: 4,
|
|
player_count: state.players.length,
|
|
players: clone(state.players),
|
|
status: state.status,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
},
|
|
game_state:
|
|
state.status === 'playing'
|
|
? {
|
|
room_id: state.roomId,
|
|
phase: 'playing',
|
|
status: 'playing',
|
|
wall_count: 55,
|
|
current_turn_player: state.players.find((player) => player.index === state.currentTurn)?.player_id ?? '',
|
|
players: state.players.map((player) => ({
|
|
player_id: player.player_id,
|
|
ding_que: player.missing_suit ?? '',
|
|
ding_que_done: Boolean(player.missing_suit),
|
|
hand_count: player.player_id === state.selfId ? state.hand.length : 13,
|
|
melds: [],
|
|
out_tiles: [],
|
|
has_hu: false,
|
|
})),
|
|
scores: {},
|
|
winners: [],
|
|
}
|
|
: null,
|
|
player_view:
|
|
state.status === 'playing'
|
|
? {
|
|
room_id: state.roomId,
|
|
ding_que: state.missingSuit ?? '',
|
|
hand: clone(state.hand),
|
|
}
|
|
: {
|
|
room_id: state.roomId,
|
|
hand: [],
|
|
},
|
|
})
|
|
|
|
class MockWebSocket {
|
|
url: string
|
|
readyState = 0
|
|
onopen: ((event: Event) => void) | null = null
|
|
onmessage: ((event: MessageEvent<string>) => void) | null = null
|
|
onerror: ((event: Event) => void) | null = null
|
|
onclose: ((event: CloseEvent) => void) | null = null
|
|
|
|
constructor(url: string) {
|
|
this.url = url
|
|
window.setTimeout(() => {
|
|
this.readyState = 1
|
|
this.onopen?.(new Event('open'))
|
|
}, 0)
|
|
}
|
|
|
|
send(raw: string) {
|
|
const message = JSON.parse(raw) as {
|
|
type?: string
|
|
payload?: Record<string, unknown>
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'get_room_info':
|
|
this.emit({
|
|
type: 'room_info',
|
|
roomId: state.roomId,
|
|
payload: buildRoomPayload(),
|
|
})
|
|
break
|
|
case 'set_ready': {
|
|
const nextReady = Boolean(message.payload?.ready)
|
|
state.players = state.players.map((player) =>
|
|
player.player_id === state.selfId ? { ...player, ready: nextReady } : player,
|
|
)
|
|
this.emit({
|
|
type: 'set_ready',
|
|
roomId: state.roomId,
|
|
payload: {
|
|
room_id: state.roomId,
|
|
user_id: state.selfId,
|
|
ready: nextReady,
|
|
},
|
|
})
|
|
this.emit({
|
|
type: 'room_player_update',
|
|
roomId: state.roomId,
|
|
payload: {
|
|
room_id: state.roomId,
|
|
status: 'waiting',
|
|
player_ids: state.players.map((player) => player.player_id),
|
|
players: clone(state.players),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'start_game':
|
|
state.status = 'playing'
|
|
this.emit({
|
|
type: 'room_state',
|
|
roomId: state.roomId,
|
|
payload: buildRoomPayload().game_state,
|
|
})
|
|
this.emit({
|
|
type: 'player_hand',
|
|
roomId: state.roomId,
|
|
payload: {
|
|
room_id: state.roomId,
|
|
hand: clone(state.hand),
|
|
},
|
|
})
|
|
break
|
|
case 'ding_que': {
|
|
const suit = typeof message.payload?.suit === 'string' ? message.payload.suit : ''
|
|
state.missingSuit = suit || null
|
|
state.players = state.players.map((player) =>
|
|
player.player_id === state.selfId ? { ...player, missing_suit: state.missingSuit } : player,
|
|
)
|
|
this.emit({
|
|
type: 'player_ding_que',
|
|
roomId: state.roomId,
|
|
payload: {
|
|
room_id: state.roomId,
|
|
player_id: state.selfId,
|
|
suit,
|
|
},
|
|
})
|
|
this.emit({
|
|
type: 'room_state',
|
|
roomId: state.roomId,
|
|
payload: buildRoomPayload().game_state,
|
|
})
|
|
this.emit({
|
|
type: 'player_hand',
|
|
roomId: state.roomId,
|
|
payload: {
|
|
room_id: state.roomId,
|
|
ding_que: state.missingSuit ?? '',
|
|
hand: clone(state.hand),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
case 'discard': {
|
|
const tile = message.payload?.tile as Tile | undefined
|
|
if (!tile) {
|
|
break
|
|
}
|
|
state.hand = state.hand.filter((item) => item.id !== tile.id)
|
|
state.currentTurn = 1
|
|
this.emit({
|
|
type: 'room_state',
|
|
roomId: state.roomId,
|
|
payload: buildRoomPayload().game_state,
|
|
})
|
|
this.emit({
|
|
type: 'player_hand',
|
|
roomId: state.roomId,
|
|
payload: {
|
|
room_id: state.roomId,
|
|
hand: clone(state.hand),
|
|
},
|
|
})
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.readyState = 3
|
|
this.onclose?.(new CloseEvent('close'))
|
|
}
|
|
|
|
private emit(payload: unknown) {
|
|
window.setTimeout(() => {
|
|
this.onmessage?.(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify(payload),
|
|
}) as MessageEvent<string>,
|
|
)
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, 'WebSocket', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: MockWebSocket,
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/v1/user/info', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
code: 0,
|
|
msg: 'ok',
|
|
data: {
|
|
userID: 'u-e2e-1',
|
|
username: '测试玩家',
|
|
nickname: '测试玩家',
|
|
},
|
|
}),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/v1/game/mahjong/room/list', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
code: 0,
|
|
msg: 'ok',
|
|
data: {
|
|
items: createdRoom ? [createdRoom] : [],
|
|
page: 1,
|
|
size: 20,
|
|
total: createdRoom ? 1 : 0,
|
|
},
|
|
}),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/v1/game/mahjong/room/create', async (route) => {
|
|
const body = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {}
|
|
createdRoom = {
|
|
room_id: 'room-e2e-001',
|
|
name: typeof body.name === 'string' ? body.name : 'E2E 测试房间',
|
|
game_type: typeof body.game_type === 'string' ? body.game_type : 'chengdu',
|
|
owner_id: 'u-e2e-1',
|
|
max_players: typeof body.max_players === 'number' ? body.max_players : 4,
|
|
player_count: 1,
|
|
players: [
|
|
{
|
|
index: 0,
|
|
player_id: 'u-e2e-1',
|
|
player_name: '测试玩家',
|
|
ready: false,
|
|
},
|
|
],
|
|
status: 'waiting',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
code: 0,
|
|
msg: 'ok',
|
|
data: createdRoom,
|
|
}),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/v1/game/mahjong/room/join', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
code: 0,
|
|
msg: 'ok',
|
|
data: createdRoom ?? {
|
|
room_id: 'room-e2e-001',
|
|
name: 'E2E 测试房间',
|
|
game_type: 'chengdu',
|
|
owner_id: 'u-e2e-1',
|
|
max_players: 4,
|
|
player_count: 1,
|
|
status: 'waiting',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
},
|
|
}),
|
|
})
|
|
})
|
|
|
|
await page.goto('/hall')
|
|
|
|
await expect(page.getByTestId('open-create-room')).toBeVisible()
|
|
await page.getByTestId('open-create-room').click()
|
|
await page.getByTestId('create-room-name').fill('E2E 测试房间')
|
|
await page.getByTestId('submit-create-room').click()
|
|
await expect(page.getByTestId('enter-created-room')).toBeVisible()
|
|
await page.getByTestId('enter-created-room').click()
|
|
|
|
await expect(page).toHaveURL(/\/game\/chengdu\/room-e2e-001/)
|
|
await expect(page.getByTestId('ready-toggle')).toBeVisible()
|
|
await page.getByTestId('ready-toggle').click()
|
|
|
|
await expect(page.getByTestId('start-game')).toBeVisible()
|
|
await page.getByTestId('start-game').click()
|
|
|
|
await expect(page.getByTestId('ding-que-w')).toBeVisible()
|
|
await page.getByTestId('ding-que-w').click()
|
|
|
|
const handBar = page.getByTestId('hand-action-bar')
|
|
await expect(handBar).toBeVisible()
|
|
const tiles = handBar.locator('[data-testid^="hand-tile-"]')
|
|
await expect(tiles).toHaveCount(13)
|
|
await tiles.first().click()
|
|
await expect(tiles).toHaveCount(12)
|
|
})
|