Files
huajishe-tts/apps/web/e2e/mahjong-multiplayer.spec.ts
e2hang ffd36cfee1 feat(mahjong): 修复 P2(拔北限制/抢杠赤牌/顺位马/听牌振听提示/立直棒)+ 联机 E2E
- R-01 三麻:碰/明杠后未打牌前不可拔北(justCalledBy)
- R-02 加杠被抢撤销为碰时扣回第 4 张牌的赤宝牌计数
- R-05 终局顺位马 + 头名赏(四麻 +15/+5/-5/-15 赏+20;三麻 +15/0/-15 赏+15),结算面板展示
- R-06 playerView 暴露 myWaits/myFuriten,UI 显示听牌与振听
- D9/N8 立直棒可视化(顶栏按供托数画棍)+ 自己立直提示
- R-07 新增 4 端联机 E2E(建房→开局→隐藏信息→轮转打牌),Playwright 通过
- 保留合理简化:R-03 开牌/宝牌物理位置、C6 自摸牌显示、E15 杠宝时机、F14 人和(有意)、O5 客户端驱动超时
- 测试:engine 329 通过(新增 P2 用例 5 条);engine/web tsc 干净;清单更新至 v3
2026-10-03 15:59:28 +08:00

162 lines
6.4 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.
/**
* 立直麻将 — 联机 E2E(真实 UI + server + engine,4 端)。
*
* 覆盖:
* 1. 4 人注册 → 建房(4 座)→ 加入 → 房主开始对局。
* 2. 进入 playing:每人只见己方手牌;对手座位只显示牌背(无真实牌泄漏)。
* 3. 当前行动者打出第一张可点牌,轮转推进;牌河累积。
* 4. 鸣牌窗口出现时自动跳过。
*
* 参考:apps/web/e2e/bridge-multiplayer.spec.ts。
*/
import { test, expect, type Browser, type Page, type BrowserContext } from '@playwright/test';
interface Seat {
ctx: BrowserContext;
page: Page;
pid: string;
name: string;
}
async function authUser(suffix: string): Promise<{ token: string; username: string }> {
const uname = `mj${suffix}${Math.floor(Math.random() * 1e6)}`.slice(0, 18);
const pwd = 'pw123456';
const base = process.env.TTS_API_BASE ?? 'http://localhost:8000';
const reg = await fetch(`${base}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: uname, password: pwd }),
});
if (reg.ok) {
const j = (await reg.json()) as { token: string; username: string };
return { token: j.token, username: j.username };
}
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: uname, password: pwd }),
});
const j = (await login.json()) as { token: string; username: string };
return { token: j.token, username: j.username };
}
async function newSeat(browser: Browser, pid: string, auth: { token: string; username: string }): Promise<Seat> {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.addInitScript(
([token, username]) => {
localStorage.setItem('tts-like.token', token);
localStorage.setItem('tts-like.username', username);
},
[auth.token, auth.username] as [string, string],
);
await page.goto('/');
page.on('console', (m) => console.log(`[browser:${pid}] ${m.text()}`));
await page.getByTestId('game-select').selectOption('mahjong');
await page.getByTestId('mode-online').click();
await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 });
return { ctx, page, pid, name: auth.username };
}
/** 当前可出牌的 seat(本人回合且手牌可点)。 */
async function discardActor(seats: Seat[]): Promise<Seat | null> {
for (const s of seats) {
const n = await s.page.locator('[data-testid=mj-myhand] [data-playable="true"]').count().catch(() => 0);
if (n > 0) return s;
}
return null;
}
/** 任意 seat 出现鸣牌窗口就跳过。 */
async function passClaims(seats: Seat[]): Promise<void> {
for (const s of seats) {
const btn = s.page.getByTestId('mj-claim-pass');
if ((await btn.count()) > 0 && (await btn.isVisible().catch(() => false))) {
await btn.click().catch(() => {});
}
}
}
test.describe('立直麻将 — 4 人联机 端到端', () => {
test('建房 → 开局 → 隐藏信息 → 轮转打牌', async ({ browser }) => {
const auths = [
await authUser('a'),
await authUser('b'),
await authUser('c'),
await authUser('d'),
];
const seats = [
await newSeat(browser, '0', auths[0]!),
await newSeat(browser, '1', auths[1]!),
await newSeat(browser, '2', auths[2]!),
await newSeat(browser, '3', auths[3]!),
];
// ===== 1. P0 建房(mahjong 可变人数,显式选 4 座) =====
await expect(async () => {
const btn = seats[0]!.page.getByTestId('lobby-create-room');
await expect(btn).toBeEnabled({ timeout: 2_000 });
const seatsSel = seats[0]!.page.getByTestId('lobby-create-seats');
if ((await seatsSel.count()) > 0) await seatsSel.selectOption('4').catch(() => {});
await btn.click();
}).toPass({ timeout: 15_000 });
await expect(seats[0]!.page.getByTestId('mj-waiting')).toBeVisible({ timeout: 15_000 });
const roomText = await seats[0]!.page.getByTestId('lobby-current-room').innerText();
const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? '';
// ===== 2. 其余 3 家加入 =====
for (const seat of seats.slice(1)) {
await expect(async () => {
await seat.page.getByTestId('lobby-refresh').click();
const btn = seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`);
expect(await btn.count()).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
await seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`).click();
await expect(seat.page.getByTestId('mj-waiting')).toBeVisible({ timeout: 15_000 });
}
// ===== 3. 房主开局 =====
await expect(async () => {
const btn = seats[0]!.page.getByTestId('mj-start');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('mj-myhand')).toBeVisible({ timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// ===== 4. 隐藏信息:各见己方手牌;对手座位无真实牌泄漏 =====
for (const seat of seats) {
const selfTiles = await seat.page.locator('[data-testid=mj-myhand] [data-testid^=mj-tile-]').count();
expect(selfTiles).toBeGreaterThanOrEqual(13);
for (const other of seats.filter((s) => s.pid !== seat.pid)) {
const leaked = await seat.page.locator(`[data-testid=mj-seat-${other.pid}] [data-testid^=mj-tile-]`).count();
expect(leaked).toBe(0);
}
}
// ===== 5. 连续打牌若干轮(当前行动者打出第一张可点牌;有鸣牌窗口则跳过) =====
for (let i = 0; i < 6; i++) {
await passClaims(seats);
await expect(async () => {
const actor = await discardActor(seats);
expect(actor).not.toBeNull();
}).toPass({ timeout: 15_000 });
const actor = await discardActor(seats);
if (!actor) throw new Error(`第 ${i + 1} 手:没有可出牌的玩家`);
await actor.page.locator('[data-testid=mj-myhand] [data-playable="true"]').first().click();
await seats[0]!.page.waitForTimeout(500);
}
// 牌河累积(被合理的鸣牌消耗后仍应 >= 3)
const totalRiver = await seats[0]!.page.locator('[data-testid^=mj-river-] [data-testid^=mj-tile-]').count();
expect(totalRiver).toBeGreaterThanOrEqual(3);
console.log('mahjong matchID:', mid);
});
});