- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
438 lines
28 KiB
JavaScript
438 lines
28 KiB
JavaScript
// Browser regressions of user playtest #2 (UI items 6–9 + the loadout display of item 1), through the mock harness
|
||
// (public/dev/game-mock.html) in headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
|
||
//
|
||
// SP_E2E=1 node --test test/ui/playtest2.e2e.test.js
|
||
// SP_E2E=1 SP_P2_ONLY=pen|sell|reach|bonds|loadout node --test test/ui/playtest2.e2e.test.js
|
||
//
|
||
// 6 the enemy preview pen is shown only by the pen camera: never with the prep board (after 🔍◀◀ "返回战场" too) nor in
|
||
// any battle camera (normal / 联防 / 最终攻势 / 隐秘核心): no pen figures, no pen rows drawn (2D), no pen area built (3D)
|
||
// 7 the underframe's 出售 / 销毁 buttons are coloured plates (amber / red), not a white block — 3D engine and DOM fallback
|
||
// 8 at 16:9, 16:10, 21:9 (2560×1080 and 3440×1440), 4:3, 1280×720, 1366×768 and iPad landscape every bench / temp /
|
||
// board unit's underframe buttons are the topmost element under their centre (clickable) and the detail card
|
||
// never overlaps them; a real click on 出售 sells
|
||
// 9 the detail card shows the unit's bonds (icon, name, count / threshold, tier pips) in the header's right column
|
||
// (under name / tier, beside the portrait), visible without scrolling; the bond popup lists its facts and the
|
||
// current effect before the long description and keeps clear of the selected unit's underframe
|
||
// §16 the loadout's skill / module: shop card skill badge (mint when not the default; S1–S3 when the manifest has no
|
||
// icon for the chosen skill), detail card skill + 已调配, an elite with its module unequipped shows 未装备模组
|
||
// Screenshots: test/e2e/out/fix-*.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);
|
||
const ONLY = process.env.SP_P2_ONLY || '';
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const skipUnless = (k) => (ONLY && ONLY !== k ? `SP_P2_ONLY=${ONLY}` : false);
|
||
|
||
/** Viewports of item 8 (name, w, h). */
|
||
const SIZES = [
|
||
['16x9', 1920, 1080], ['16x10', 1680, 1050], ['21x9', 2560, 1080], ['uw', 3440, 1440], ['4x3', 1600, 1200],
|
||
['720p', 1280, 720], ['1366', 1366, 768], ['ipad', 1180, 820],
|
||
];
|
||
|
||
describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||
let srv;
|
||
let browser;
|
||
let base;
|
||
|
||
before(async () => {
|
||
const { startServer } = await import('../../server/index.js');
|
||
const 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}`;
|
||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||
mkdirSync(OUT, { recursive: true });
|
||
});
|
||
|
||
after(async () => {
|
||
await browser?.close();
|
||
await srv?.close();
|
||
});
|
||
|
||
async function open(query, { w = 1920, h = 1080, render = 'engine' } = {}) {
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ width: w, height: h });
|
||
const problems = [];
|
||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||
page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
|
||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
|
||
await page.goto(`${base}/dev/game-mock.html?shot=1&render=${render}&${query}`, { waitUntil: 'networkidle0' });
|
||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
|
||
await sleep(render === 'engine' ? 1600 : 700);
|
||
return { page, problems };
|
||
}
|
||
|
||
/** Mean RGB of a client rect of the page (screenshot decoded by the page itself). */
|
||
async function meanColor(page, r) {
|
||
const clip = { x: Math.max(0, r.x), y: Math.max(0, r.y), width: Math.max(1, r.w), height: Math.max(1, r.h) };
|
||
const b64 = await page.screenshot({ clip, encoding: 'base64' });
|
||
return page.evaluate(async (src) => {
|
||
const img = new Image();
|
||
img.src = `data:image/png;base64,${src}`;
|
||
await img.decode();
|
||
const cv = document.createElement('canvas');
|
||
cv.width = img.width; cv.height = img.height;
|
||
const g = cv.getContext('2d');
|
||
g.drawImage(img, 0, 0);
|
||
const d = g.getImageData(0, 0, cv.width, cv.height).data;
|
||
let R = 0, G = 0, B = 0, n = 0;
|
||
for (let i = 0; i < d.length; i += 4) { R += d[i]; G += d[i + 1]; B += d[i + 2]; n++; }
|
||
return { r: R / n, g: G / n, b: B / n };
|
||
}, b64);
|
||
}
|
||
|
||
/** Client rect of a prep piece (plain object: the DOM fallback returns a DOMRect). */
|
||
const pieceRect = (page, uid) => page.evaluate((uid) => {
|
||
const r = globalThis.__SP_VIEW__.pieceScreenRect(uid);
|
||
return r ? { left: r.left, top: r.top, width: r.width, height: r.height } : null;
|
||
}, uid);
|
||
|
||
/**
|
||
* Tap an own prep piece (real mouse) until ITS underframe shows (a unit standing in front can take a low tap: the
|
||
* next try is higher up the sprite). Pieces without an underframe (bench summons) just get one tap.
|
||
*/
|
||
async function tapPiece(page, uid, { underframe = true } = {}) {
|
||
for (const at of [0.72, 0.4, 0.18, 0.88]) {
|
||
const r = await pieceRect(page, uid);
|
||
assert.ok(r && r.width > 0, `piece ${uid} on screen`);
|
||
await page.mouse.click(r.left + r.width / 2, r.top + r.height * at);
|
||
await sleep(350);
|
||
if (!underframe) return true;
|
||
if (await page.$(`.uframe[data-uid="${uid}"]`)) return true;
|
||
await page.keyboard.press('Escape');
|
||
await page.mouse.click(5, Math.round((await page.evaluate(() => innerHeight)) / 2)); // deselect (field press)
|
||
await sleep(150);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Every underframe button: centre, topmost-ness, overlap with the detail card. */
|
||
const underframeState = (page) => page.evaluate(() => {
|
||
const panel = document.querySelector('.dpanel');
|
||
const pr = panel ? panel.getBoundingClientRect() : null;
|
||
const out = [];
|
||
for (const b of document.querySelectorAll('.uframe__btn')) {
|
||
const r = b.getBoundingClientRect();
|
||
const x = r.left + r.width / 2; const y = r.top + r.height * 0.35;
|
||
const top = document.elementFromPoint(x, y);
|
||
const overlap = !!pr && r.left < pr.right && r.right > pr.left && r.top < pr.bottom && r.bottom > pr.top;
|
||
out.push({ cls: b.className, x, y, topmost: !!top && b.contains(top), inView: x > 0 && y > 0 && x < innerWidth && y < innerHeight, overlap });
|
||
}
|
||
return { buttons: out, side: panel?.dataset.side ?? null };
|
||
});
|
||
|
||
// ---- 7 -------------------------------------------------------------------------------------------------------------
|
||
|
||
for (const render of ['engine', 'fallback']) {
|
||
test(`7: 出售 / 销毁 are amber / red plates, not a white block (${render})`, { skip: skipUnless('sell') }, async () => {
|
||
const { page, problems } = await open('phase=PREP', { render });
|
||
const uids = await page.evaluate(() => {
|
||
const p = globalThis.__MOCK__.S().priv;
|
||
return { chess: p.hand.find((x) => x && x.kind === 'chess')?.uid, item: p.hand.find((x) => x && x.kind === 'item')?.uid };
|
||
});
|
||
await tapPiece(page, uids.chess);
|
||
await page.waitForSelector('.uframe__btn--sell .uframe__plate', { timeout: 3000 });
|
||
const plate = await page.$eval('.uframe__btn--sell .uframe__plate', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.2, y: r.top + r.height * 0.2, w: r.width * 0.6, h: r.height * 0.6 }; });
|
||
const c = await meanColor(page, plate);
|
||
assert.ok(!(c.r > 225 && c.g > 225 && c.b > 225), `sell plate is not white (${JSON.stringify(c)})`);
|
||
assert.ok(c.r > c.b + 50 && c.g > c.b + 10, `sell plate reads amber (${JSON.stringify(c)})`);
|
||
await page.screenshot({ path: path.join(OUT, `fix-sell-${render}.png`) });
|
||
await page.keyboard.press('Escape');
|
||
await tapPiece(page, uids.item);
|
||
await page.waitForSelector('.uframe__btn--destroy .uframe__plate', { timeout: 3000 });
|
||
const dp = await page.$eval('.uframe__btn--destroy .uframe__plate', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.2, y: r.top + r.height * 0.2, w: r.width * 0.6, h: r.height * 0.6 }; });
|
||
const d = await meanColor(page, dp);
|
||
assert.ok(!(d.r > 225 && d.g > 225 && d.b > 225), `destroy plate is not white (${JSON.stringify(d)})`);
|
||
assert.ok(d.r > d.g + 60 && d.r > d.b + 60, `destroy plate reads red (${JSON.stringify(d)})`);
|
||
await page.screenshot({ path: path.join(OUT, `fix-destroy-${render}.png`) });
|
||
assert.deepEqual(problems, []);
|
||
await page.close();
|
||
});
|
||
}
|
||
|
||
// ---- 8 -------------------------------------------------------------------------------------------------------------
|
||
|
||
for (const render of ['engine', 'fallback']) {
|
||
test(`8: every bench / temp / board underframe stays clickable at 8 viewports (${render})`, { skip: skipUnless('reach'), timeout: 12 * 60 * 1000 }, async () => {
|
||
for (const [name, w, h] of SIZES) {
|
||
const { page, problems } = await open('phase=PREP', { w, h, render });
|
||
// a full bench and a full temp row (chess + one item each)
|
||
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
|
||
const pool = S.pool;
|
||
let k = 0;
|
||
let uid = 9000;
|
||
const chess = () => { const c = pool[(k += 3) % pool.length]; return { uid: ++uid, kind: 'chess', id: c.chessId, golden: false, tier: c.tier, items: [] }; };
|
||
for (let i = 0; i < S.priv.hand.length; i++) if (!S.priv.hand[i] || S.priv.hand[i].kind === 'token') S.priv.hand[i] = chess();
|
||
for (let i = 0; i < S.priv.temp.length; i++) S.priv.temp[i] = chess();
|
||
S.priv.temp[4] = { uid: ++uid, kind: 'item', id: S.priv.hand.find((x) => x && x.kind === 'item')?.id, golden: false, tier: 1 };
|
||
}));
|
||
await sleep(600);
|
||
const pieces = await page.evaluate(() => {
|
||
const p = globalThis.__MOCK__.S().priv;
|
||
return [
|
||
...p.hand.map((x, i) => x && { uid: x.uid, where: `hand${i}` }),
|
||
...p.temp.map((x, i) => x && { uid: x.uid, where: `temp${i}` }),
|
||
...p.board.map((x) => ({ uid: x.uid, where: `board${x.row},${x.col}` })),
|
||
].filter(Boolean);
|
||
});
|
||
let checked = 0;
|
||
let rightDocked = 0;
|
||
const board = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.map((p) => ({ uid: p.uid, row: p.row, col: p.col })));
|
||
for (const pc of pieces) {
|
||
const tapped = await tapPiece(page, pc.uid);
|
||
// a board unit standing right behind others in its column can be hidden by their sprites (as in the game)
|
||
const b = board.find((x) => x.uid === pc.uid);
|
||
if (!tapped && b && board.some((o) => o.col === b.col && o.row < b.row && b.row - o.row <= 2)) continue;
|
||
assert.ok(tapped, `${name}/${render}: ${pc.where} shows its underframe`);
|
||
const st2 = await underframeState(page);
|
||
assert.ok(st2.buttons.length, `${name}/${render}: ${pc.where} underframe buttons`);
|
||
for (const b of st2.buttons) {
|
||
assert.ok(b.inView, `${name}/${render}: ${pc.where} ${b.cls} inside the viewport`);
|
||
assert.ok(b.topmost, `${name}/${render}: ${pc.where} ${b.cls} is the topmost element (clickable)`);
|
||
assert.ok(!b.overlap, `${name}/${render}: ${pc.where} ${b.cls} not under the detail card (${st2.side})`);
|
||
}
|
||
if (st2.side === 'right') rightDocked++;
|
||
checked++;
|
||
if (pc.where === 'hand0' || pc.where === 'hand1') await page.screenshot({ path: path.join(OUT, `fix-reach-${name}-${render}-${pc.where}.png`) });
|
||
await page.keyboard.press('Escape'); // closes the card (the next tap selects the next unit)
|
||
await sleep(120);
|
||
}
|
||
assert.ok(checked >= pieces.length - 2, `${name}/${render}: checked ${checked}/${pieces.length}`);
|
||
// the leftmost bench unit's card docks right whenever the left card would cover its underframe
|
||
// a real click on 出售 of the leftmost bench unit sells it
|
||
const first = pieces[0];
|
||
await tapPiece(page, first.uid);
|
||
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
|
||
const sell = await page.$eval('.uframe__btn--sell', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height * 0.35 }; });
|
||
await page.mouse.click(sell.x, sell.y);
|
||
await sleep(400);
|
||
const after = await page.evaluate((uid) => ({ funds: globalThis.__MOCK__.S().priv.funds, gone: !globalThis.__MOCK__.S().priv.hand.some((x) => x && x.uid === uid) }), first.uid);
|
||
assert.ok(after.gone && after.funds > funds0, `${name}/${render}: 出售 of ${first.where} sold it (${JSON.stringify(after)})`);
|
||
assert.deepEqual(problems, [], `${name}/${render}`);
|
||
await page.close();
|
||
void rightDocked;
|
||
}
|
||
});
|
||
}
|
||
|
||
// review: the top board row under the bond strip, and a notched phone's safe-area insets
|
||
test('8: a top-row unit under the bond strip is tappable; on a notched phone the underframe stays on its unit', { skip: skipUnless('reach'), timeout: 5 * 60 * 1000 }, async () => {
|
||
for (const [name, w, h] of [['16x9', 1920, 1080], ['21x9', 2560, 1080], ['720p', 1280, 720], ['phone', 844, 390]]) {
|
||
const { page, problems } = await open('phase=PREP', { w, h });
|
||
// the mock's row-12 unit (col 4, behind the row-11 one): its body reaches under the bond strip at these sizes; a
|
||
// press on its tile selects it (picking is by tile, user playtest #4 item 1), so the tile must be free of the strip
|
||
const top = await page.evaluate(() => {
|
||
const b = globalThis.__MOCK__.S().priv.board.filter((p) => p.row === 12).sort((a, c) => a.col - c.col)[0];
|
||
const r = globalThis.__SP_VIEW__.pieceScreenRect(b.uid);
|
||
const t = globalThis.__SP_VIEW__.raw.tileScreen(b.row, b.col);
|
||
const strip = document.querySelector('.gm__bonds').getBoundingClientRect();
|
||
const pts = [-0.3, 0, 0.3].map((dx) => ({ x: t.x + dx * t.s, y: t.y }));
|
||
const hits = pts.map((p) => { const el = document.elementFromPoint(p.x, p.y); return el.tagName === 'CANVAS' ? 'canvas' : el.closest('.bslot') ? 'disc' : `${el.tagName}.${el.className}`; });
|
||
return { uid: b.uid, pts, hits, bodyUnderStrip: r.top < strip.bottom };
|
||
});
|
||
assert.ok(top.bodyUnderStrip, `${name}: the row-12 unit's body reaches under the bond strip (test premise)`);
|
||
for (const hit of top.hits) assert.equal(hit, 'canvas', `${name}: the row-12 unit's tile is free of the bond strip`);
|
||
await page.mouse.click(top.pts[1].x, top.pts[1].y);
|
||
await sleep(350);
|
||
assert.ok(await page.$(`.uframe[data-uid="${top.uid}"]`), `${name}: a tap on the row-12 unit's tile selects it`);
|
||
await page.keyboard.press('Escape');
|
||
await page.mouse.click(5, Math.round(h / 2));
|
||
await sleep(150);
|
||
// notched phone in landscape: the HUD layer sits inside the safe-area insets (css/devices.css)
|
||
await page.evaluate(() => { const s = document.documentElement.style; s.setProperty('--sa-l', '47px'); s.setProperty('--sa-r', '47px'); s.setProperty('--sa-t', '12px'); s.setProperty('--sa-b', '21px'); });
|
||
await sleep(300);
|
||
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.map((x, i) => x && x.kind === 'chess' && { uid: x.uid, i }).filter(Boolean).slice(0, 2));
|
||
for (const pc of hand) {
|
||
assert.ok(await tapPiece(page, pc.uid), `${name} (safe area): hand${pc.i} selected`);
|
||
const st = await page.evaluate((pc) => {
|
||
const uf = document.querySelector(`.uframe[data-uid="${pc.uid}"]`).getBoundingClientRect();
|
||
const t = globalThis.__SP_VIEW__.raw.tileScreen(7, pc.i);
|
||
return { dx: uf.left + uf.width / 2 - t.x, dy: uf.top + uf.height / 2 - t.y };
|
||
}, pc);
|
||
assert.ok(Math.abs(st.dx) < 2 && Math.abs(st.dy) < 2, `${name} (safe area): hand${pc.i} underframe centred on its tile (${JSON.stringify(st)})`);
|
||
const s2 = await underframeState(page);
|
||
for (const b of s2.buttons) {
|
||
assert.ok(b.topmost && b.inView, `${name} (safe area): hand${pc.i} ${b.cls} clickable`);
|
||
assert.ok(!b.overlap, `${name} (safe area): hand${pc.i} ${b.cls} not under the detail card (${s2.side})`);
|
||
}
|
||
await page.keyboard.press('Escape');
|
||
await page.mouse.click(5, Math.round(h / 2)); // deselect (a field press)
|
||
await sleep(150);
|
||
}
|
||
if (name === '16x9') await page.screenshot({ path: path.join(OUT, 'fix-reach-safearea.png') });
|
||
assert.deepEqual(problems, [], name);
|
||
await page.close();
|
||
}
|
||
});
|
||
|
||
// ---- 9 -------------------------------------------------------------------------------------------------------------
|
||
|
||
test('9: the detail card shows the unit\'s bonds under the header, visible without scrolling; the bond popup leads with its facts', { skip: skipUnless('bonds') }, async () => {
|
||
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
|
||
const { page, problems } = await open('phase=PREP', { w, h });
|
||
// a front-row unit (row 9: nothing stands in front of it)
|
||
const uid = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.find((p) => p.row === 9).uid);
|
||
assert.ok(await tapPiece(page, uid), 'unit selected');
|
||
await page.waitForSelector('.dpanel .dbonds--top', { timeout: 3000 });
|
||
const r = await page.evaluate(() => {
|
||
const panel = document.querySelector('.dpanel__scroll');
|
||
const pr = panel.getBoundingClientRect();
|
||
const art = document.querySelector('.dpanel .dhead__art').getBoundingClientRect();
|
||
const name = document.querySelector('.dpanel .dhead__name').getBoundingClientRect();
|
||
const chips = [...document.querySelectorAll('.dpanel .dbonds--top .dbond')];
|
||
const kids = [...panel.children].map((el) => el.className);
|
||
return {
|
||
first: kids[0], inInfo: !!document.querySelector('.dpanel .dhead__info > .dbonds--top'),
|
||
art: { right: art.right, top: art.top, bottom: art.bottom }, nameBottom: name.bottom,
|
||
chips: chips.map((el) => { const r = el.getBoundingClientRect(); return { text: el.textContent.trim(), left: r.left, bottom: r.bottom, top: r.top, icon: !!el.querySelector('.bglyph'), count: el.querySelector('.dbond__count')?.textContent.trim() || '' }; }),
|
||
visibleBottom: pr.bottom, scrollTop: panel.scrollTop,
|
||
bonds: (globalThis.__SP__?.store || globalThis.__MOCK__.store).get().match.private.bonds.length,
|
||
};
|
||
});
|
||
assert.equal(r.first, 'dhead');
|
||
assert.ok(r.inInfo, 'bonds in the header\'s right column (under name / tier)');
|
||
assert.ok(r.chips.length >= 1, 'bond chips');
|
||
for (const c of r.chips) {
|
||
assert.ok(c.icon, `chip ${c.text} has its icon`);
|
||
assert.match(c.count, /^\d+(\/\d+)?$/, `chip ${c.text} shows count/threshold`);
|
||
assert.ok(c.bottom <= r.visibleBottom && r.scrollTop === 0, `chip ${c.text} visible without scrolling`);
|
||
assert.ok(c.left >= r.art.right - 1, `chip ${c.text} right of the portrait (top right)`);
|
||
assert.ok(c.top >= r.nameBottom - 1 && c.top < r.art.bottom, `chip ${c.text} under the name, beside the portrait`);
|
||
}
|
||
await page.screenshot({ path: path.join(OUT, `fix-bonds-${w}.png`) });
|
||
// the chip opens the bond popup; facts + current effect come before the long description
|
||
await page.click('.dpanel .dbonds--top .dbond');
|
||
await page.waitForSelector('.bpop', { timeout: 3000 });
|
||
const pop = await page.evaluate(() => {
|
||
const secs = [...document.querySelectorAll('.bpop .bpop__sec')].map((s) => s.querySelector('h4')?.textContent || '');
|
||
const facts = document.querySelector('.bpop__facts').getBoundingClientRect();
|
||
const box = document.querySelector('.bpop').getBoundingClientRect();
|
||
const btn = [...document.querySelectorAll('.uframe__btn')].map((b) => { const r = b.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height * 0.35); return !!t && b.contains(t); });
|
||
const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||
const card = document.querySelector('.dpanel').getBoundingClientRect();
|
||
const pop = document.querySelector('.bpop');
|
||
return {
|
||
secs, factsIn: facts.bottom <= box.bottom, btnTop: btn, place: pop.dataset.place, over: pop.classList.contains('is-over'),
|
||
coversButtons: [...document.querySelectorAll('.uframe__btn')].some((b) => hit(b.getBoundingClientRect(), box)),
|
||
coversCard: hit(card, box),
|
||
};
|
||
});
|
||
// the playtest's case (a unit left of the board, card docked right) — the popup keeps clear of the underframe
|
||
assert.equal(pop.coversButtons, false, `the bond popup (${pop.place}) keeps clear of the underframe buttons`);
|
||
assert.ok(!pop.coversCard || pop.over, `the popup covers the card only when placed over it (${pop.place})`);
|
||
const iNow = pop.secs.findIndex((t) => t.includes('当前效果'));
|
||
const iDesc = pop.secs.findIndex((t) => t.includes('盟约效果'));
|
||
if (iNow >= 0) assert.ok(iNow < iDesc, `current effect before the description (${pop.secs})`);
|
||
assert.ok(pop.factsIn);
|
||
assert.ok(pop.btnTop.every(Boolean), 'the underframe stays above the bond popup');
|
||
await sleep(450); // the pop-in animation
|
||
await page.screenshot({ path: path.join(OUT, `fix-bondpop-${w}.png`) });
|
||
assert.deepEqual(problems, []);
|
||
await page.close();
|
||
}
|
||
});
|
||
|
||
// ---- §16 loadout ---------------------------------------------------------------------------------------------------
|
||
|
||
test('loadout (DESIGN §16): shop card skill badge, detail card skill + 已调配, elite module 未装备模组', { skip: skipUnless('loadout') }, async () => {
|
||
const { page, problems } = await open('phase=PREP&variant=loadout');
|
||
const lo = await page.evaluate(() => globalThis.__MOCK__.S().priv.loadout);
|
||
assert.ok(lo && Object.keys(lo).length >= 1, 'the mock sends m.private.loadout');
|
||
const badges = await page.$$eval('.scard__skill', (els) => els.map((el) => ({ custom: el.classList.contains('is-custom'), title: el.getAttribute('title') || '' })));
|
||
assert.ok(badges.length >= 3, 'every operator card shows its skill');
|
||
assert.ok(badges.some((b) => b.custom && b.title.includes('已调配')), `a card with a non-default skill (${JSON.stringify(badges)})`);
|
||
assert.ok(badges.some((b) => !b.custom), 'default skills stay plain');
|
||
// a chosen skill is never a blank square: its icon, or its slot letter when the manifest has no icon for it
|
||
const marks = await page.$$eval('.scard__skill.is-custom', (els) => els.map((el) => ({ img: el.querySelector('img')?.getAttribute('src') || null, glyph: el.querySelector('.scard__sglyph')?.textContent || null })));
|
||
for (const g of marks) assert.ok((g.img && !/empty/i.test(g.img)) || /^S[1-3]$/.test(g.glyph || ''), `custom skill badge shows an icon or S1–S3 (${JSON.stringify(g)})`);
|
||
await page.screenshot({ path: path.join(OUT, 'fix-loadout-shop.png') });
|
||
const b0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.board[0].uid);
|
||
await tapPiece(page, b0);
|
||
await page.waitForSelector('.dpanel .dskill', { timeout: 3000 });
|
||
const d = await page.evaluate(() => ({ name: document.querySelector('.dpanel .dskill__name')?.textContent || '', tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout') }));
|
||
assert.ok(d.tag, `detail card marks the loadout skill (${d.name})`);
|
||
const icon = await page.evaluate(() => { const el = document.querySelector('.dpanel .dskill > .dskill__icon'); return { img: el?.tagName === 'IMG' ? el.getAttribute('src') : null, glyph: el?.querySelector('b')?.textContent || null }; });
|
||
assert.ok((icon.img && !/empty/i.test(icon.img)) || /^S[1-3]$/.test(icon.glyph || ''), `detail skill icon or S1–S3 (${JSON.stringify(icon)})`);
|
||
await page.evaluate(() => { const el = document.querySelector('.dpanel .dsec--skill'); el?.scrollIntoView({ block: 'center' }); });
|
||
await sleep(200);
|
||
await page.screenshot({ path: path.join(OUT, 'fix-loadout-detail.png') });
|
||
await page.keyboard.press('Escape');
|
||
const g = await page.evaluate(() => globalThis.__MOCK__.S().priv.board.find((p) => p.golden)?.uid);
|
||
if (g) {
|
||
assert.ok(await tapPiece(page, g), 'the elite is selected');
|
||
await page.waitForSelector('.dpanel .dsec--module', { timeout: 3000 });
|
||
const mod = await page.$eval('.dpanel .dmodule', (el) => ({ none: el.classList.contains('is-none'), text: el.textContent }));
|
||
assert.ok(mod.none && mod.text.includes('未装备模组'), `elite module unequipped (${mod.text})`);
|
||
}
|
||
assert.deepEqual(problems, []);
|
||
await page.close();
|
||
});
|
||
|
||
// ---- 6 -------------------------------------------------------------------------------------------------------------
|
||
|
||
/** Pen / board-area state of the engine view. */
|
||
const penState = (page) => page.evaluate(() => {
|
||
const raw = globalThis.__SP_VIEW__.raw;
|
||
const d = raw.debug;
|
||
const pens = [...d.penViews.values()];
|
||
const area = d.board3d ? d.board3d.area : null;
|
||
return {
|
||
camera: document.querySelector('.gm')?.dataset.camera, kind: d.camKind,
|
||
pen: pens.length, penVisible: pens.filter((v) => v.root && v.root.visible !== false).length,
|
||
band: d.tiles.band, penRowsDrawn: d.tiles.grid.some((row) => row.some((t) => t.r >= 14 && t.drawn)),
|
||
area3d: area ? Math.max(...area.map((a) => a.r1)) : null, board3d: !!d.board3d,
|
||
};
|
||
});
|
||
|
||
test('6: the enemy pen shows only in the pen view — never with the prep board (after 返回战场) or any battle', { skip: skipUnless('pen'), timeout: 5 * 60 * 1000 }, async () => {
|
||
const { page, problems } = await open('phase=PREP', { w: 1920, h: 1080 });
|
||
let s = await penState(page);
|
||
assert.equal(s.camera, 'prep');
|
||
assert.ok(s.pen > 0, 'the pen holds the next round\'s enemies');
|
||
assert.equal(s.penVisible, 0, 'prep board: no pen figure shown');
|
||
assert.equal(s.penRowsDrawn, false, 'prep board: pen rows not drawn');
|
||
if (s.board3d) assert.ok(s.area3d <= 12, `prep board: 3D area without the pen (${s.area3d})`);
|
||
await page.screenshot({ path: path.join(OUT, 'fix-pen-prep.png') });
|
||
await page.click('.enemybtn');
|
||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
|
||
await sleep(700);
|
||
s = await penState(page);
|
||
assert.ok(s.penVisible > 0, 'pen view: figures shown');
|
||
assert.ok(s.penRowsDrawn, 'pen view: pen rows drawn');
|
||
if (s.board3d) assert.ok(s.area3d >= 18, 'pen view: 3D pen area built');
|
||
await page.screenshot({ path: path.join(OUT, 'fix-pen-view.png') });
|
||
// 🔍◀◀ (tooltip 返回战场) back to the board: the pen is gone again
|
||
await page.click('.gtop__iconbtn');
|
||
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
|
||
await sleep(900);
|
||
s = await penState(page);
|
||
assert.equal(s.penVisible, 0, 'back on the board (返回战场): no pen figure');
|
||
assert.equal(s.penRowsDrawn, false, 'back on the board: pen rows not drawn');
|
||
if (s.board3d) assert.ok(s.area3d <= 12, 'back on the board: no pen area');
|
||
await page.screenshot({ path: path.join(OUT, 'fix-pen-back.png') });
|
||
assert.deepEqual(problems, []);
|
||
await page.close();
|
||
|
||
// every battle camera
|
||
for (const phase of ['COMBAT', 'UNITE', 'FINAL_ASSAULT', 'HIDDEN_CORE']) {
|
||
const o = await open(`phase=${phase}`, { w: 1920, h: 1080 });
|
||
await sleep(1200);
|
||
const b = await penState(o.page);
|
||
assert.equal(b.penVisible, 0, `${phase}: no pen figure`);
|
||
assert.equal(b.penRowsDrawn, false, `${phase}: pen rows not drawn`);
|
||
if (b.board3d) assert.ok(b.area3d <= 12, `${phase}: no pen area (${b.area3d})`);
|
||
await o.page.screenshot({ path: path.join(OUT, `fix-pen-${phase.toLowerCase()}.png`) });
|
||
assert.deepEqual(o.problems, [], phase);
|
||
await o.page.close();
|
||
}
|
||
});
|
||
});
|