- 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
162 lines
6.4 KiB
TypeScript
162 lines
6.4 KiB
TypeScript
/**
|
||
* 立直麻将 — 联机 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);
|
||
});
|
||
});
|