Files
ark-stronghold-protocol/test/render/elembar.browser.test.js
T
sganggs 7f134737ce Dev: puppeteer-core 25 (browser tests adapted)
- puppeteer-core ^23.11.1 -> ^25.12.0 (dev only); npm audit: 7 high (basic-ftp via
  @puppeteer/browsers / proxy-agent, extract-zip) -> 0; production audit stays clean
- puppeteer-core 25 is ESM-only and needs Node >= 22.12: the render / sim browser suites
  load it with await import() instead of createRequire
- headless: 'new' -> headless: true (same new headless mode; 'new' is no longer a typed value)
- fieldview CPU throttle: page.emulateCPUThrottling() instead of the deprecated page.target() CDP session
- THIRD-PARTY-NOTICES version, DESIGN test note
2026-10-02 17:58:35 +08:00

199 lines
11 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.
// test/render/elembar.browser.test.js — user playtest #6 report 11 in headless Chrome ("干员血条右边的元素损伤条积累速度
// 不对,损伤条远远没扣完但是实际已经爆条"): a real battle (古米 against 底海滑动者) is simulated in Node, its frames are fed
// to the real field view (public/dev/render-demo.html, real Spine models) the way the client runner does (local feed,
// one tick per animation frame), and the drawn element gauge is read back every frame:
// · the white bar is the sim's remaining 元素值 at the frame shown (within 1 %, never below it, never empty before the
// burst), and the burst shows as the bar refilling over the 爆发冷却 in the element's colour (never white);
// · a row of five operators with gauges (desktop and a 844×390 phone at DPR 3): each gauge row lies under its own
// unit's bars and inside their span (the v2.3 ring sat on the next operator's tier chip and bars).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/elembar.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/elembar-*.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';
import { makeBattle } from '../helpers/battleHarness.js';
import { ELEMENT_ORDER } from '../../server/sim/constants.js';
import { ELEMENT_RING } from '../../public/js/render/textures.js';
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.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
const NEURAL_TINT = ELEMENT_RING.neural.tint;
/** Real frames: 古米 blocking 底海滑动者 (15 % ATK 神经 per hit) until past its first burst. */
function recordBattle(seconds = 54) {
const h = makeBattle({ units: [{ chessId: 'chess_char_1_10_a', row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
h.step();
h.spawn('enemy_1148_dssbr', { pos: [10, 5.6], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000 } });
const u = h.allies()[0];
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
h.b.drainEvents();
const frames = [];
for (let i = 0; i < seconds * 30; i++) {
h.step();
if (u.alive) u.hp = u.s.maxHp;
let bv = 0, el = null;
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
const { t, ...rest } = h.b.snapshot();
frames.push({ snap: { ...rest, t: 'b.snap', gt: t }, ev: h.b.drainEvents(), gt: t, el, left: el ? 1 - bv / u.gaugeMax : null, lock: !!u.s.flags.burstLock });
}
return { meta, frames, opId: u.id };
}
/** Page side: the drawn gauge of unit `id` (null: none) — element, the white bar's share, the render clock. */
function readGauge(id) {
const v = window.__demo.view;
const x = v.debug.views.get(id);
const r = x && x._elBar;
if (!r || !r.root.visible) return { el: null, renderT: v.debug.interp.renderT };
return { el: x.el, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, tint: r.fill.tint, renderT: v.debug.interp.renderT };
}
describe('element gauge row in headless Chrome (user playtest #6)', { skip }, () => {
let srv, browser;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const open = async (viewport) => {
const page = await browser.newPage();
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('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport(viewport);
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return { page, problems };
};
test('a real battle: the white bar drawn every frame is the sim\'s remaining 元素值; the burst refills it', async () => {
const { meta, frames, opId } = recordBattle();
const { page, problems } = await open({ width: 1280, height: 720 });
try {
await page.evaluate((meta) => {
const v = window.__demo.view;
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
}, meta);
const byT = new Map(frames.map((f) => [f.gt, f]));
const rows = [];
const CHUNK = 30;
for (let i = 0; i < frames.length; i += CHUNK) {
const got = await page.evaluate(async (chunk, id, readSrc) => {
const read = new Function(`return (${readSrc})`)();
const v = window.__demo.view, out = [];
for (const f of chunk) {
if (f.ev.length) v.pushEvents({ t: 'b.ev', fieldId: f.snap.fieldId, gt: f.gt, ev: f.ev });
v.pushSnapshot(f.snap);
await new Promise((r) => requestAnimationFrame(() => r()));
const g = read(id);
const s = v.debug.interp.snaps;
let shown = s[0]?.t;
for (const x of s) if (x.t <= g.renderT) shown = x.t;
out.push({ ...g, shownT: shown });
}
return out;
}, frames.slice(i, i + CHUNK), opId, readGauge.toString());
rows.push(...got);
}
let checked = 0, refills = 0, prevLock = false, lastBefore = null;
for (const row of rows) {
const f = byT.get(row.shownT);
if (!f) continue;
if (f.lock) {
assert.equal(row.el, 'neural', 'the 爆发冷却 shows');
if (!prevLock) { refills++; assert.ok(row.share < 0.03, `the refill starts empty (${row.share})`); }
assert.equal(row.tint, NEURAL_TINT, 'the refill is drawn in the 神经 colour, not white');
} else if (f.el) {
checked++;
assert.equal(row.el, f.el);
assert.equal(row.tint, 0xffffff, 'the 元素值 left is white');
assert.ok(row.share >= f.left - 1e-6 && row.share - f.left < 0.01 + 1e-6, `gt ${f.gt}: bar ${row.share} vs sim ${f.left}`);
assert.ok(row.share > 0, 'never empty before the burst');
lastBefore = row.share;
} else assert.equal(row.el, null);
prevLock = f.lock;
}
assert.ok(checked > 1000, `${checked} frames checked`);
assert.equal(refills, 1, 'one 神经 burst');
assert.ok(lastBefore <= 0.05, `the burst comes off a sliver (${lastBefore})`);
await page.screenshot({ path: path.join(OUT, 'elembar-cooldown.png') });
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
for (const [name, viewport] of [['desktop', { width: 1280, height: 720 }], ['phone', { width: 844, height: 390, deviceScaleFactor: 3 }]]) {
test(`a row of operators (${name}): every gauge row lies under its own unit's bars, inside their span`, async () => {
const { page, problems } = await open(viewport);
try {
await page.evaluate(() => {
const v = window.__demo.view;
const U = (id, side, spine, x, y) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3 });
const units = [3, 4, 5, 6, 7].map((c, i) => U(i + 1, 'ally', i % 2 ? 'char_1016_agoat2' : 'char_102_texas', c, 10))
.concat([U(6, 'enemy', 'enemy_1000_gopro_2', 3, 11.4), U(7, 'enemy', 'enemy_1000_gopro_2', 6, 11.4)]);
v.enterBattle({ fieldId: 'eb', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
window.__units = units;
});
const feed = (from, to) => page.evaluate(async (from, to) => {
const v = window.__demo.view, units = window.__units;
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) {
v.pushSnapshot({ t: 'b.snap', fieldId: 'eb', gt: +gt.toFixed(4), units: units.map((u) => [u.id, u.x, u.y, u.side === 'enemy' ? 700 : 1000, 1000, 5, 10, 0, 0]), dp: 5, killed: 0, total: 2,
elem: [[1, 'neural', 0.1, 0, 0], [2, 'erosion', 0.5, 0, 0], [3, 'burn', 0.9, 0, 0], [4, 'apoptosis', 0.97, 0, 0], [5, 'neural', 1, 12, 10], [6, 'neural', 0.25, 0, 0], [7, 'erosion', 0.75, 0, 0]] });
await new Promise((r) => requestAnimationFrame(() => r()));
}
}, from, to);
await feed(1, 2.5);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await feed(2.5, 7);
const geo = await page.evaluate(() => {
const out = {};
for (const [id, x] of window.__demo.view.debug.views) {
const r = x._elBar;
if (!r || !r.root.visible) continue;
const x0 = x.hpBg.position.x + 1, bw = x.hpBg.width - 2;
const barsBottom = x.spFill.visible ? x.spFill.position.y + x.spFill.height / 2 : x.hpBg.position.y + x.hpBg.height / 2;
out[id] = { x0, bw, barsBottom, discL: r.disc.position.x - r.disc.width * 0.336, discTop: r.disc.position.y - r.disc.height * 0.336,
barR: r.bg.position.x + r.bg.width, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, el: x.el, tint: r.fill.tint };
}
return out;
});
const want = { 1: 0.9, 2: 0.5, 3: 0.1, 4: 0.03, 6: 0.75, 7: 0.25 };
for (const [id, left] of Object.entries(want)) {
const g = geo[id];
assert.ok(g, `unit ${id} shows its gauge`);
assert.ok(Math.abs(g.share - left) < 1e-6, `unit ${id}: bar ${g.share} = remaining ${left}`);
assert.ok(g.discL >= g.x0 - 1 && g.barR <= g.x0 + g.bw + 1.5, `unit ${id}: inside its bars' span`);
assert.ok(g.discTop >= g.barsBottom, `unit ${id}: under its bars`);
assert.equal(g.tint, 0xffffff, `unit ${id}: 元素值 left is white`);
}
assert.ok(geo[5].share > 0.4 && geo[5].share < 0.6, `the 爆发冷却 half refilled (${geo[5].share})`);
assert.equal(geo[5].tint, NEURAL_TINT, 'the 爆发冷却 refill in the element\'s colour, unlike unit 1\'s white 神经 bar');
await page.screenshot({ path: path.join(OUT, `elembar-row-${name}.png`) });
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
}
});