import { expect, test } from 'playwright/test' test('enter room, ready, start game, ding que, and discard tile', async ({ page }) => { let createdRoom: Record | 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 = (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) => 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 } 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, ) }, 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 | 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) })