Files
ark-stronghold-protocol/test/ui/match-info.e2e.test.js
T
sganggs 7188e0af89 feedback1 draft-info: the strategy draft shows the briefing's disabled bonds and banned operators again (GitHub issue #8 item 1)
One component, public/js/ui/matchInfo.js: matchInfoModel (the drawn set D and
the mode's inactive bonds via disabledBondSets, the briefing's bond order,
banned operators by tier, bannedPerBond) and the briefing's bond rows, legend
and banned-operator grid. The briefing renders it; the band draft opens it
read-only in a dialog (查看禁用盟约与干员 under the order list; 关闭, a tap
outside or Esc close it; its footer repeats the turn and the countdown; a turn
change closes it, the end of the draft unmounts it); the in-game 本局信息 tab
reads the same model, so its banned operators are listed by tier too. In the
dialog every size has a px floor, so phones keep readable text and a
scrollable body.

Tests: test/ui/match-info.test.js; opt-in test/ui/match-info.e2e.test.js
(SP_E2E=1: solo 标准 on desktop, co-op 绝境 with a desktop and a phone).
2026-10-03 16:08:32 +08:00

407 lines
27 KiB
JavaScript
Raw 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.
// GitHub issue #8 item 1 (v0.1.1) in the browser: the strategy draft's 本局信息 dialog shows the briefing's disabled bonds
// and banned operators again (ui/matchInfo.js — the briefing's own blocks), against the real server in headless Chrome
// (puppeteer-core + the system Chrome, one Chrome per player). Opt-in:
//
// SP_E2E=1 node --test test/ui/match-info.e2e.test.js
//
// 1. Solo 标准模拟 (the mode that switches 10 bonds off), desktop 1920×1080: the briefing's greyed bonds, badges, legend
// and banned operators are read; in the draft 查看禁用盟约与干员 opens the dialog with exactly the same (and the 本局禁用
// tooltip); Esc, a click outside — right over 确认选择 — and 关闭 close it without touching the highlighted band or
// sending an intent; a strategy is then confirmed; in the first prep the in-game 本局信息 tab lists the same.
// 2. Co-op 绝境 with two humans — desktop (mouse) and a phone (touch, 844×390, then 756×366): both briefings agree with
// the server; the waiting player's dialog shows whose turn it is with the live countdown and closes by itself when the
// picker — whose own dialog counted its seconds down and was closed (Esc / 关闭) — confirms; on the next turn the
// phone's dialog fits both screens, its text stays ≥ 9 px, a swipe scrolls its body to the banned operators and a tap
// outside closes it; the player who already picked keeps its dialog open while the last strategy is confirmed, and
// the end of the draft takes it away.
// Zero console errors / page errors / failed requests. Screenshots: test/e2e/out/match-info-*.png
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--mute-audio'];
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
const PHONE = (width, height) => ({ width, height, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
const OFF_FUNNY = ['lateranoShip', 'egirShip', 'kazimierzShip', 'skillfulShip', 'arcaneShip', 'miraShip', 'investShip', 'raidShip', 'soloShip', 'suntShip'];
describe('GitHub issue #8 item 1: 本局信息 in the strategy draft (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
let srv;
let base;
let puppeteer;
const browsers = [];
before(async () => {
const { startServer } = await import('../../server/index.js');
puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
mkdirSync(OUT, { recursive: true });
});
after(async () => {
for (const b of browsers) {
const proc = b.process();
await b.close().catch(() => {});
// Chrome's helpers (its crash handler, the macOS updater) can inherit its stderr and outlive it: drop our ends of
// its pipes, or this test process never exits
for (const s of [proc?.stdout, proc?.stderr]) s?.destroy();
}
await srv?.close();
});
/** One player: its own Chrome with one always-visible page, past the title screen, in the lobby. */
async function player(name, { phone = null } = {}) {
const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ARGS, protocolTimeout: 90000 });
browsers.push(browser);
const [first] = await browser.pages();
const page = first || await browser.newPage();
if (phone) await page.emulate({ viewport: phone, userAgent: ANDROID_UA });
else await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
const problems = [];
// the optional Google Fonts stylesheet (index.html: never render-blocking, system fallbacks) is the only external request
const external = (url) => /fonts\.(googleapis|gstatic)\.com/.test(url || '');
page.on('console', (m) => {
if (m.type() !== 'error') return;
const url = m.location()?.url || '';
if (/Failed to load resource/.test(m.text()) && external(url)) return;
problems.push(`console: ${m.text()} @ ${url}`);
});
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => {
const err = r.failure()?.errorText || '';
if (external(r.url())) return;
if (err === 'net::ERR_ABORTED' && /\.(mp3|ogg|wav|m4a)(\?|$)/.test(r.url())) return; // media element swaps
problems.push(`requestfailed: ${r.url()} ${err}`);
});
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.evaluateOnNewDocument((n) => { localStorage.setItem('sp.name', n); sessionStorage.setItem('sp.entered', '1'); }, name);
await page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
// record every intent the UI sends (gameActions looks net.request up per call)
await page.evaluate(() => {
const n = globalThis.__SP__.net;
const orig = n.request.bind(n);
globalThis.__req = [];
n.request = (t, f, o) => { globalThis.__req.push(t); return orig(t, f, o); };
});
return { browser, page, problems, phone: !!phone, name };
}
const st = (page) => page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
return {
me: s.me.playerId, room: s.room?.code ?? null, phase: pub?.phase ?? null, turn: pub?.draft?.turn ?? null,
picks: pub?.draft?.picks ?? {}, bandId: s.match.private?.bandId ?? null, modeId: pub?.modeId ?? null,
};
});
async function waitSt(page, pred, what, timeout = 30000) {
const t0 = Date.now();
let last = null;
while (Date.now() - t0 < timeout) { last = await st(page); if (pred(last)) return last; await sleep(150); }
throw new Error(`timed out waiting for ${what}: ${JSON.stringify(last)}`);
}
const shot = (page, name) => page.screenshot({ path: path.join(OUT, `match-info-${name}.png`) });
const intents = (page) => page.evaluate(() => (globalThis.__req || []).slice());
/** Centre of the first visible match (client px), or null. */
const centre = (page, sel, text = null) => page.evaluate((sel, text) => {
for (const el of document.querySelectorAll(sel)) {
if (text && !(el.textContent || '').includes(text)) continue;
const r = el.getBoundingClientRect();
if (r.width > 1 && r.height > 1) return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
return null;
}, sel, text);
/** A click with the mouse, or a tap on a touch phone. */
async function press(p, sel, text = null) {
let pt = null;
for (let i = 0; i < 60 && !pt; i++) { pt = await centre(p.page, sel, text); if (!pt) await sleep(100); }
assert.ok(pt, `${p.name}: ${sel}${text ? ` "${text}"` : ''} on screen`);
if (p.phone) await p.page.touchscreen.tap(pt.x, pt.y); else await p.page.mouse.click(pt.x, pt.y);
await sleep(250);
}
const dialogOpen = (page) => page.evaluate(() => !!document.querySelector('.minfo-dlg'));
/** The bonds, badges, legend and banned operators as drawn under `root` (the briefing's column or the dialog). */
const readInfo = (page, root) => page.evaluate((root) => {
const scope = document.querySelector(root);
if (!scope) return null;
return {
bonds: [...scope.querySelectorAll('.brief-bond')].map((el) => ({
id: el.dataset.bond, name: el.querySelector('.bond__name')?.textContent || '',
off: el.classList.contains('is-off'), incomplete: el.classList.contains('is-incomplete'), partial: el.classList.contains('is-partial'),
x: !!el.querySelector('.bond.is-disabled .bond__ban'), ban: el.querySelector('.brief-bond__ban')?.textContent.trim() || '',
})),
rows: [...scope.querySelectorAll('.brief-bonds .brief-h > span:first-child')].map((el) => el.textContent),
legend: scope.querySelector('.brief-legend')?.textContent.trim() || '',
count: scope.querySelector('.brief-banned__n')?.textContent.trim() || '',
banned: [...scope.querySelectorAll('.brief-banned__grid .uthumb')].map((el) => el.getAttribute('title')),
};
}, root);
/** What the server says: greyed = the drawn set ∪ the mode's inactive bonds; banned names by tier. */
const truth = (page) => page.evaluate(() => {
const { store, data } = globalThis.__SP__;
const pub = store.get().match.public;
const mode = data.get('config')?.modes?.[pub.modeId];
const greyed = [...new Set([...(pub.drawnDisabledBonds || []), ...(mode?.inactiveBondIds || [])])].sort();
const chess = pub.bannedChess.map((id) => data.lookup('chess', id)).filter(Boolean);
return { greyed, off: (mode?.inactiveBondIds || []).slice().sort(), tiers: chess.map((c) => c.tier), names: chess.slice().sort((a, b) => a.tier - b.tier).map((c) => c.name) };
});
const greyedOf = (info) => info.bonds.filter((b) => b.off).map((b) => b.id).sort();
const selBand = (page) => page.evaluate(() => document.querySelector('.dband.is-sel .dband__name')?.textContent || null);
async function openDialog(p) {
await press(p, '[data-testid="match-info-open"]');
await p.page.waitForSelector('.minfo-dlg .brief-banned', { visible: true, timeout: 5000 });
await sleep(350); // the dialog's pop-in
}
test('solo 标准模拟 (desktop): the dialog = the briefing; Esc / outside / 关闭 leave the draft alone; confirm; the in-game tab agrees', { timeout: 240000 }, async () => {
const p = await player('信息测试');
const { page } = p;
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'FUNNY' }));
await waitSt(page, (s) => !!s.room, 'solo room');
await page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
const s0 = await waitSt(page, (s) => s.phase === 'INFO_CHECK', 'briefing');
assert.equal(s0.modeId, 'mode_single_funny');
await page.waitForSelector('.brief__right .brief-banned', { visible: true, timeout: 15000 });
await sleep(600);
const brief = await readInfo(page, '.brief__right');
const want = await truth(page);
assert.deepEqual(want.off, [...OFF_FUNNY].sort(), '标准模拟 switches the official 10 bonds off');
assert.deepEqual(greyedOf(brief), want.greyed, 'the briefing greys D ∪ the mode\'s inactive bonds');
assert.deepEqual(brief.banned, want.names, 'the briefing\'s banned operators, by tier');
assert.equal(brief.count, String(want.names.length));
assert.match(brief.legend, /或本模式禁用/);
await shot(page, 'solo-briefing');
await press(p, '.brief__foot .btn--primary', '准备就绪');
await waitSt(page, (s) => s.phase === 'BAND_DRAFT', 'band draft');
await page.waitForSelector('[data-testid="match-info-open"]', { visible: true, timeout: 10000 });
await shot(page, 'solo-draft');
const sel0 = await selBand(page);
assert.ok(sel0, 'a highlighted band');
const req0 = (await intents(page)).length;
// open: the very same blocks, the same tip; nothing sent
await openDialog(p);
const dlg = await readInfo(page, '.minfo-dlg');
assert.deepEqual(dlg, brief, 'the dialog shows exactly the briefing\'s bonds, badges, legend and banned operators');
assert.deepEqual(dlg.rows, ['核心盟约', '附加盟约']);
assert.ok(dlg.bonds.filter((b) => b.off).every((b) => b.x), 'every greyed disc carries the ✕');
const arcane = await centre(page, '.minfo-dlg .brief-bond[data-bond="arcaneShip"] .bond__disc');
await page.mouse.move(arcane.x, arcane.y);
await page.waitForSelector('.tooltip.is-shown', { visible: true, timeout: 3000 });
assert.equal(await page.$eval('.tooltip.is-shown', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
const status = await page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.equal(status, '轮到你决策', 'solo: untimed — no seconds');
const box = await page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; });
assert.ok(box.top >= 0 && box.bottom <= 1080 && box.left >= 0 && box.right <= 1920, `inside the screen ${JSON.stringify(box)}`);
const fits = await page.$eval('.minfo-dlg .modal__body', (el) => el.scrollHeight <= el.clientHeight + 1);
assert.ok(fits, 'desktop: everything visible without scrolling');
await shot(page, 'solo-dialog');
// Esc
await page.mouse.move(5, 5);
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
// a click outside the box, right over 确认选择: closes, confirms nothing
await openDialog(p);
const confirmPt = await centre(page, '.draft-detail__btns .btn--primary');
const inBox = await page.$eval('.minfo-dlg', (el, pt) => { const r = el.getBoundingClientRect(); return pt.x >= r.left && pt.x <= r.right && pt.y >= r.top && pt.y <= r.bottom; }, confirmPt);
assert.equal(inBox, false, '确认选择 lies outside the dialog box (on its backdrop)');
await page.mouse.click(confirmPt.x, confirmPt.y);
await page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
await sleep(400);
assert.equal((await st(page)).phase, 'BAND_DRAFT', 'the press on the backdrop confirmed nothing');
// 关闭
await openDialog(p);
await press(p, '[data-testid="match-info-close"]', '关闭');
await page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
assert.equal(await selBand(page), sel0, 'the highlighted band is untouched');
assert.deepEqual((await intents(page)).slice(req0), [], 'no intent while looking');
// the draft still works: highlight another strategy, confirm
await press(p, '.dband', '阿米娅');
assert.equal(await selBand(page), '阿米娅');
await press(p, '.draft-detail__btns .btn--primary', '确认选择');
const s1 = await waitSt(page, (s) => s.phase !== 'BAND_DRAFT', 'the draft to end');
assert.equal(s1.bandId, 'band_amiya');
// the in-game 本局信息 tab (left 🔍) lists the same greyed bonds, badges and banned operators (by tier)
await waitSt(page, (s) => s.phase === 'PREP', 'the first prep', 60000);
await page.waitForFunction(() => !document.querySelector('.pbanner'), { timeout: 10000 }); // the 休整期 banner has played
await press(p, '.gtop__iconbtn');
await page.waitForSelector('.edrawer .ibanned', { visible: true, timeout: 5000 });
const tab = await page.evaluate(() => ({
off: [...document.querySelectorAll('.edrawer .ibond.is-off')].map((el) => el.querySelector(':scope > span:not([class])')?.textContent || ''),
ban: Object.fromEntries([...document.querySelectorAll('.edrawer .ibond')].map((el) => [el.querySelector(':scope > span:not([class])')?.textContent || '', el.querySelector('.ibond__ban')?.textContent.trim() || ''])),
banned: [...document.querySelectorAll('.edrawer .ibanned .uthumb')].map((el) => el.getAttribute('title')),
count: document.querySelector('.edrawer .ihead small')?.textContent.trim() || '',
}));
assert.deepEqual(tab.off.sort(), brief.bonds.filter((b) => b.off).map((b) => b.name).sort(), 'the same greyed bonds');
for (const b of brief.bonds) if (b.ban) assert.equal(tab.ban[b.name], b.ban, `${b.name}: the same badge`);
assert.deepEqual(tab.banned, brief.banned, 'the same banned operators, in the same (tier) order');
assert.equal(tab.count, brief.count);
await shot(page, 'solo-ingame-tab');
assert.deepEqual(p.problems, []);
});
test('co-op 绝境, desktop + phone: live countdown in the dialog, a turn change closes it, phones fit and scroll, the draft completes', { timeout: 300000 }, async (t) => {
const a = await player('桌面博士');
const b = await player('手机博士', { phone: PHONE(844, 390) });
await a.page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'coop', difficulty: 'HARD' }));
const code = (await waitSt(a.page, (s) => !!s.room, 'co-op room')).room;
await b.page.evaluate((c) => globalThis.__SP__.net.request('room.join', { code: c }), code);
await waitSt(b.page, (s) => s.room === code, 'joined');
await b.page.evaluate(() => globalThis.__SP__.net.request('room.ready', { ready: true }));
await sleep(300);
await a.page.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [a, b]) {
const s = await waitSt(p.page, (x) => x.phase === 'INFO_CHECK', `${p.name}: briefing`);
assert.equal(s.modeId, 'mode_multi_hard');
await p.page.waitForSelector('.brief__right .brief-banned', { timeout: 15000 });
}
await sleep(500);
const briefs = new Map();
for (const p of [a, b]) briefs.set(p, await readInfo(p.page, '.brief__right'));
const want = await truth(a.page);
assert.deepEqual(want.off, [], '绝境 switches no bond off');
assert.equal(want.greyed.length, 7, '3 core + 4 add-on bonds drawn');
for (const [p, info] of briefs) {
assert.deepEqual(greyedOf(info), want.greyed, `${p.name}: briefing greyed`);
assert.deepEqual(info.banned, want.names, `${p.name}: briefing banned`);
assert.doesNotMatch(info.legend, /本模式禁用/);
}
assert.deepEqual(briefs.get(b), briefs.get(a), 'both players see the same briefing');
await shot(b.page, 'coop-phone-briefing-844');
for (const p of [a, b]) await press(p, '.brief__foot .btn--primary', '准备就绪');
for (const p of [a, b]) await waitSt(p.page, (s) => s.phase === 'BAND_DRAFT' && !!s.turn, `${p.name}: band draft`);
const turn = (await st(a.page)).turn;
const meA = (await st(a.page)).me;
const picker = turn === meA ? a : b;
const waiter = picker === a ? b : a;
t.diagnostic(`first pick: the ${picker.phone ? 'phone' : 'desktop'} player`);
await waiter.page.waitForSelector('[data-testid="match-info-open"]', { visible: true, timeout: 10000 });
if (waiter.phone) await shot(waiter.page, 'coop-phone-draft-844');
// the waiting player looks first: the briefing's blocks, the picker's live countdown
await openDialog(waiter);
assert.deepEqual(await readInfo(waiter.page, '.minfo-dlg'), briefs.get(waiter), `${waiter.name}: the dialog = the briefing`);
const wStatus = await waiter.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.match(wStatus, new RegExp(`^${picker.name} 决策中\\s*\\d+s$`), `whose turn and its seconds: ${wStatus}`);
await shot(waiter.page, waiter.phone ? 'coop-phone-dialog-waiting-844' : 'coop-dialog-waiting');
// the picker: its own dialog says 轮到你决策 with the running clock; Esc; then it confirms with the dialog closed
const reqP = (await intents(picker.page)).length;
const selP = await selBand(picker.page);
await openDialog(picker);
assert.deepEqual(await readInfo(picker.page, '.minfo-dlg'), briefs.get(picker), `${picker.name}: the dialog = the briefing`);
const secsOf = async (p) => Number((await p.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent)).match(/(\d+)s/)?.[1]);
const s1 = await secsOf(picker);
assert.match(await picker.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim()), /^轮到你决策\s*\d+s$/);
await sleep(2200);
const s2 = await secsOf(picker);
assert.ok(s1 > 0 && s2 < s1, `the countdown keeps running in the dialog (${s1} → ${s2})`);
await shot(picker.page, picker.phone ? 'coop-phone-dialog-picker-844' : 'coop-dialog-picker');
if (picker.phone) {
await press(picker, '[data-testid="match-info-close"]', '关闭');
} else {
await picker.page.keyboard.press('Escape');
}
await picker.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
assert.equal(await selBand(picker.page), selP, 'the highlighted band survived the dialog');
assert.deepEqual((await intents(picker.page)).slice(reqP).filter((t) => t !== 'g.bandFocus'), [], 'nothing but the draft\'s own focus report');
assert.equal(await dialogOpen(waiter.page), true, 'the waiting player is still looking');
await press(picker, '.dband', '阿米娅');
await press(picker, '.draft-detail__btns .btn--primary', '确认选择');
await waitSt(picker.page, (s) => !!s.bandId, `${picker.name}: picked`);
// the turn moves on: the waiting player's dialog closes by itself, its draft is in front of it
await waitSt(waiter.page, (s) => s.turn === s.me, `${waiter.name}: its turn`);
await waiter.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 4000 });
const last = waiter;
const done = picker;
// the phone (whichever role it has now: deciding, or waiting after its pick) at 844×390, then 756×366: inside the
// screen, ≥ 9 px text, the countdown and 关闭 in view, the body scrolls to the banned operators; a tap outside closes it
const ph = b;
for (const [w, h] of [[844, 390], [756, 366]]) {
if (w === 756) { await ph.page.setViewport(PHONE(756, 366)); await sleep(500); }
await openDialog(ph);
const lay = await ph.page.evaluate(() => {
const boxEl = document.querySelector('.minfo-dlg');
const r = boxEl.getBoundingClientRect();
const body = boxEl.querySelector('.modal__body');
const px = (sel) => parseFloat(getComputedStyle(boxEl.querySelector(sel)).fontSize);
const vis = (sel) => { const e = boxEl.querySelector(sel).getBoundingClientRect(); return e.top >= r.top - 1 && e.bottom <= r.bottom + 1 && e.left >= r.left - 1 && e.right <= r.right + 1; };
return {
box: [r.left, r.top, r.right, r.bottom], vw: innerWidth, vh: innerHeight, scrolls: body.scrollHeight > body.clientHeight + 4,
overflow: getComputedStyle(body).overflowY, name: px('.bond__name'), legend: px('.brief-legend'), title: px('.modal__title'),
badge: px('.brief-bond__ban'), status: vis('[data-testid="match-info-status"]'), close: vis('[data-testid="match-info-close"]'),
};
});
assert.ok(lay.box[0] >= 0 && lay.box[1] >= 0 && lay.box[2] <= lay.vw && lay.box[3] <= lay.vh, `${w}×${h}: the dialog inside the screen ${JSON.stringify(lay.box)}`);
assert.ok(lay.name >= 9 && lay.legend >= 9 && lay.badge >= 9 && lay.title >= 14, `${w}×${h}: readable text ${JSON.stringify(lay)}`);
assert.ok(lay.status && lay.close, `${w}×${h}: the status line and 关闭 stay in view`);
assert.ok(lay.scrolls && lay.overflow === 'auto', `${w}×${h}: the body scrolls`);
assert.deepEqual(await readInfo(ph.page, '.minfo-dlg'), briefs.get(ph), `${w}×${h}: the dialog = the briefing`);
const phStatus = await ph.page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
if (ph === last) assert.match(phStatus, /^轮到你决策\s*\d+s$/);
else assert.equal(phStatus, '已选择「阿米娅」,等待其他博士');
await shot(ph.page, `coop-phone-dialog-${w}`);
// a finger swipe up the body brings the banned operators into view
const bodyPt = await centre(ph.page, '.minfo-dlg .modal__body');
const top0 = await ph.page.$eval('.minfo-dlg .modal__body', (el) => el.scrollTop);
await ph.page.touchscreen.touchStart(bodyPt.x, bodyPt.y + 60);
for (let i = 1; i <= 8; i++) { await ph.page.touchscreen.touchMove(bodyPt.x, bodyPt.y + 60 - i * 22); await sleep(16); }
await ph.page.touchscreen.touchEnd();
await sleep(500);
const top1 = await ph.page.$eval('.minfo-dlg .modal__body', (el) => el.scrollTop);
assert.ok(top1 > top0, `${w}×${h}: the swipe scrolled the body (${top0} → ${top1})`);
await ph.page.$eval('.minfo-dlg .modal__body', (el) => { el.scrollTop = el.scrollHeight; });
await sleep(200);
const bannedVisible = await ph.page.evaluate(() => {
const body = document.querySelector('.minfo-dlg .modal__body').getBoundingClientRect();
const g = document.querySelector('.minfo-dlg .brief-banned__grid').getBoundingClientRect();
return g.top >= body.top - 1 && g.bottom <= body.bottom + 1;
});
assert.ok(bannedVisible, `${w}×${h}: the banned operators scrolled into view`);
await shot(ph.page, `coop-phone-dialog-${w}-scrolled`);
// a tap outside the box (the order list on the left) closes it and changes nothing
const sel = await selBand(ph.page);
const req = (await intents(ph.page)).length;
const outside = await ph.page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { x: Math.max(4, r.left / 2), y: r.top + r.height / 2 }; });
await ph.page.touchscreen.tap(outside.x, outside.y);
await ph.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 3000 });
assert.equal(await selBand(ph.page), sel, `${w}×${h}: the highlighted band is untouched`);
assert.deepEqual((await intents(ph.page)).slice(req), [], `${w}×${h}: no intent`);
}
await shot(ph.page, 'coop-phone-draft-756');
// the player who already picked keeps its dialog open; the last picker confirms a free strategy (阿米娅 is 队友已选) —
// the end of the draft takes the open dialog away with the screen
await openDialog(done);
await press(last, '.dband', '华法琳');
await press(last, '.draft-detail__btns .btn--primary', '确认选择');
for (const p of [a, b]) {
const s = await waitSt(p.page, (x) => x.phase !== 'BAND_DRAFT', `${p.name}: the draft to end`);
assert.ok(s.bandId, `${p.name} has a strategy`);
}
assert.deepEqual([(await st(done.page)).bandId, (await st(last.page)).bandId], ['band_amiya', 'band_bldsk']);
await done.page.waitForFunction(() => !document.querySelector('.minfo-dlg'), { timeout: 4000 });
assert.equal(await dialogOpen(a.page) || await dialogOpen(b.page), false, 'no dialog outlives the draft');
await sleep(500);
assert.deepEqual([...a.problems, ...b.problems], []);
});
});