Files
ark-stronghold-protocol/test/ui/playtest2.e2e.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #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)
2026-09-30 05:35:45 +08:00

438 lines
28 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.
// 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();
}
});
});