Applied as-is. Captured real battles live in public/dev/perf (including the round-13 联防 on the wide round battlefield), with a browser speed page and a headless benchmark. public/dev stays out of the package. The Chrome benchmark was not run. The numbers in the original pull request are from 0.1.4 and are not quoted here. Tests: node --test test/perfbench.test.js test/render/battle-perf.browser.test.js — 13 pass, 0 fail; the Chrome suite skipped (RENDER_E2E unset).
124 lines
6.6 KiB
JavaScript
124 lines
6.6 KiB
JavaScript
// test/perfbench.test.js — the battle perf tooling: the committed specs of public/dev/perf (tools/capture-specs.mjs) still
|
|
// build and run with the current data and sim, the capture helpers, the perf page's frame figures (public/dev/frame-stats.js),
|
|
// and tools/perfbench.mjs's profile summary and per-platform Chrome flags. The page itself:
|
|
// test/render/battle-perf.browser.test.js (opt-in).
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { getData } from '../server/data.js';
|
|
import { DataSource } from '../server/sim/simdata.js';
|
|
import { createBattleFromSpec } from '../server/sim/spec.js';
|
|
import { specMeta, pickDefaults, cleanStart } from '../tools/capture-specs.mjs';
|
|
import { aggregateProfile, gpuArgs, areaOf } from '../tools/perfbench.mjs';
|
|
import { frameFigures } from '../public/dev/frame-stats.js';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const DIR = path.join(ROOT, 'public/dev/perf');
|
|
const index = JSON.parse(readFileSync(path.join(DIR, 'index.json'), 'utf8'));
|
|
|
|
describe('committed perf specs (public/dev/perf)', () => {
|
|
const ds = new DataSource(getData({ log: { warn() {}, error() {}, info() {} } }), null);
|
|
|
|
test('the index lists one spec of each kind, each file a clean b.start', () => {
|
|
assert.deepEqual(index.map((s) => s.kind), ['normal', 'unite', 'boss', 'hidden']);
|
|
for (const s of index) {
|
|
const f = path.join(DIR, `${s.name}.json`);
|
|
assert.ok(existsSync(f), s.name);
|
|
const msg = JSON.parse(readFileSync(f, 'utf8'));
|
|
assert.equal(msg.t, 'b.start');
|
|
assert.equal(msg.kind, s.kind);
|
|
assert.equal(msg.elapsed, 0);
|
|
assert.equal(msg.authoritative, true);
|
|
assert.equal(msg.spec.stageId, s.stageId);
|
|
assert.equal(msg.spec.players.length, s.players);
|
|
}
|
|
});
|
|
|
|
for (const s of index) {
|
|
test(`${s.name}: builds with the current data and runs 20 game seconds`, () => {
|
|
const msg = JSON.parse(readFileSync(path.join(DIR, `${s.name}.json`), 'utf8'));
|
|
const b = createBattleFromSpec(msg.spec, ds, { recordEvents: false });
|
|
for (let i = 0; i < 600 && !b.finished; i++) b.step();
|
|
assert.ok(b.finished || b.time >= 19.9, `battle time ${b.time}`);
|
|
const snap = b.snapshot();
|
|
assert.ok(Array.isArray(snap.units) && snap.units.length > 0, 'units on the field');
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('tools/capture-specs.mjs helpers', () => {
|
|
const start = (kind, n) => ({ t: 'b.start', kind, spec: { stageId: 'st', players: [{ units: Array(n).fill({}) }, { units: Array(n).fill({}) }] }, elapsed: 3.5, startAt: 9, serverNow: 9, authoritative: false });
|
|
test('specMeta names and counts a b.start', () => {
|
|
assert.deepEqual(specMeta('HARD', start('unite', 8), 9), { name: 'hard-unite-r9', title: '联防 · 第 9 回合', kind: 'unite', round: 9, stageId: 'st', players: 2, units: 16 });
|
|
});
|
|
test('pickDefaults keeps the latest normal / unite / boss / hidden, the last send of a resent battle', () => {
|
|
const resent = start('boss', 8);
|
|
const picked = pickDefaults([
|
|
{ round: 4, msg: start('normal', 1) }, { round: 13, msg: start('normal', 8) }, { round: 7, msg: start('unite', 4) },
|
|
{ round: 9, msg: start('unite', 8) }, { round: 14, msg: start('boss', 8) }, { round: 14, msg: resent },
|
|
]);
|
|
assert.deepEqual(picked.map((c) => `${c.msg.kind}-r${c.round}`), ['normal-r13', 'unite-r9', 'boss-r14']);
|
|
assert.equal(picked[2].msg, resent);
|
|
});
|
|
test('cleanStart: from the start, authoritative, no capture clocks', () => {
|
|
const c = cleanStart(start('normal', 1));
|
|
assert.deepEqual([c.elapsed, c.startAt, c.serverNow, c.authoritative, c.watch, c.done], [0, 0, 0, true, false, false]);
|
|
});
|
|
});
|
|
|
|
describe("the perf page's frame figures (public/dev/frame-stats.js)", () => {
|
|
test('average fps, nearest-rank percentiles and the stutter share of a sample', () => {
|
|
assert.deepEqual(frameFigures([40, 16, 17, 17], 33.4), { fps: 44.4, p50: 17, p95: 40, p99: 40, over33: 25 });
|
|
});
|
|
|
|
test('one frame time is enough; no frame time gives null figures, not NaN / Infinity (the single-frame sample)', () => {
|
|
assert.deepEqual(frameFigures([16.7], 33.4), { fps: 59.9, p50: 16.7, p95: 16.7, p99: 16.7, over33: 0 });
|
|
assert.deepEqual(frameFigures([], 33.4), { fps: null, p50: null, p95: null, p99: null, over33: null });
|
|
assert.equal(frameFigures([0, 0], 33.4).fps, null);
|
|
});
|
|
});
|
|
|
|
describe('tools/perfbench.mjs', () => {
|
|
test('Chrome GPU flags per platform: D3D11 on Windows, Metal on macOS, the default elsewhere', () => {
|
|
assert.ok(gpuArgs('win32').includes('--use-angle=d3d11'));
|
|
assert.ok(gpuArgs('darwin').includes('--use-angle=metal'));
|
|
assert.ok(!gpuArgs('linux').some((a) => a.startsWith('--use-angle')));
|
|
});
|
|
|
|
test('areaOf sorts call frames by source', () => {
|
|
assert.equal(areaOf({ url: 'http://h/vendor/pixi-spine.js', functionName: 'apply' }), 'spine');
|
|
assert.equal(areaOf({ url: 'http://h/vendor/pixi.min.js', functionName: 'render' }), 'pixi');
|
|
assert.equal(areaOf({ url: 'http://h/sim/Battle.js', functionName: 'step' }), 'sim');
|
|
assert.equal(areaOf({ url: '', functionName: '(idle)' }), 'idle');
|
|
});
|
|
|
|
test('aggregateProfile: idle excluded; self time by area / function; stages inclusive, once per stack', () => {
|
|
const cf = (functionName, url) => ({ functionName, url, lineNumber: 0 });
|
|
const profile = {
|
|
nodes: [
|
|
{ id: 1, callFrame: cf('(root)', ''), children: [2, 6, 7] },
|
|
{ id: 2, callFrame: cf('frameBody', 'http://h/js/render/app.js'), children: [3] },
|
|
{ id: 3, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [4] },
|
|
{ id: 4, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [5] }, // recursion: counted once
|
|
{ id: 5, callFrame: cf('apply', 'http://h/vendor/pixi-spine.js'), children: [] },
|
|
{ id: 6, callFrame: cf('(idle)', ''), children: [] },
|
|
{ id: 7, callFrame: cf('(program)', ''), children: [] },
|
|
],
|
|
samples: [5, 5, 3, 6, 6, 7],
|
|
timeDeltas: [1000, 1000, 1000, 5000, 5000, 1000],
|
|
};
|
|
const s = aggregateProfile(profile);
|
|
assert.equal(s.busyMs, 4);
|
|
assert.deepEqual(s.areas.map((a) => [a.area, a.pct]), [['spine', 50], ['render', 25], ['program', 25]]);
|
|
const st = Object.fromEntries(s.stages.map((x) => [x.stage, x.pct]));
|
|
assert.equal(st.spinePose, 75);
|
|
assert.equal(st.frameBody, 75);
|
|
assert.equal(st.program, 25);
|
|
assert.equal(st.sim, 0);
|
|
assert.match(s.top[0].fn, /^apply \/vendor\/pixi-spine\.js:1$/);
|
|
});
|
|
});
|