Files
ark-stronghold-protocol/test/render/browser.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

419 lines
23 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/browser.test.js — headless Chrome checks of the battlefield renderer through the dev demo
// (public/dev/render-demo.html): every scene renders without console errors, Spine models load, drag & drop
// round-trips, camera transitions work, per-frame JS cost stays bounded. Screenshots → test/e2e/out/render-*.png.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/browser.test.js
// Chrome path: $CHROME_PATH or the macOS default.
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 { createRequire } from 'node:module';
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)';
describe('render engine in headless Chrome', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
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: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, w = 1920, h = 1080) {
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({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?${query}`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const err = await page.evaluate(() => window.__demo.error || null);
assert.equal(err, null, 'demo boot');
return { page, problems };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const SCENES = [
['prep', 'scene=prep', 1920, 1080],
['prep-720', 'scene=prep', 1280, 720],
['combat', 'scene=normal-m01&t=18', 1920, 1080],
['combat-720', 'scene=normal-m03&t=30', 1280, 720],
['unite', 'scene=unite-m01&t=25', 1920, 1080],
['boss', 'scene=boss-m02&t=30', 1920, 1080],
['boss-720', 'scene=boss-m02&t=12', 1280, 720],
];
for (const [name, query, w, h] of SCENES) {
test(`${name} ${w}×${h} renders cleanly`, async () => {
const { page, problems } = await open(query + '&panel=0', w, h);
await wait(3500);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, `render-${name}.png`) });
await page.close();
assert.deepEqual(problems, []);
assert.ok(st.units > 0, 'units on screen');
assert.equal(st.spine.failed, 0, 'no Spine load failures');
assert.ok(st.fps > 20, `fps ${st.fps}`);
if (name.startsWith('combat') || name.startsWith('unite') || name.startsWith('boss')) {
assert.equal(st.mode, 'battle');
assert.ok(Math.abs(st.rate - 2) < 0.3, `2× clock (${st.rate})`);
}
});
}
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s)
const kinds = await page.evaluate(async () => (await import('/js/render/fx.js')).FX_KINDS).then((k) => Object.keys(k).length);
await wait(Math.min(45000, kinds * 260 + 1500));
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-fx.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(st.mode, 'battle');
assert.ok(st.units >= 10, `units ${st.units}`);
if (existsSync(path.join(ROOT, 'public/assets/local/map/autochess/tiles.json'))) assert.equal(st.boardArt, true, 'real board art composed');
});
test('stress: 120 units + FX keep the per-frame JS cost bounded', async () => {
const { page, problems } = await open('scene=stress&panel=0');
await wait(6000);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-stress.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(st.units, 120);
assert.ok(st.impostor >= 2, 'crowded field switches to impostor rendering');
assert.ok(st.cpuMs + st.renderMs < 16, `JS per frame ${st.cpuMs} + ${st.renderMs} ms`);
});
test('drag a hand piece onto a legal board tile; right-click opens detail', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
const info = await page.evaluate(() => {
const v = window.__demo.view, st = window.__demo.scene.state;
const uid = st.hand[0].uid;
const r = v.pieceScreenRect(uid);
const t = v.debug.cam.project(5, 10, 0);
return { uid, r, target: { x: t.x, y: t.y } };
});
assert.ok(info.r && info.r.width > 10, 'pieceScreenRect');
const cx = info.r.left + info.r.width / 2, cy = info.r.top + info.r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(cx + (info.target.x - cx) * i / 10, cy + (info.target.y - cy) * i / 10);
await page.screenshot({ path: path.join(OUT, 'render-drag.png') });
await page.mouse.up();
await wait(700);
const placed = await page.evaluate((uid) => window.__demo.scene.state.board.find((p) => p.uid === uid) || null, info.uid);
assert.ok(placed, 'piece moved to the board');
assert.deepEqual([placed.row, placed.col], [10, 5]);
const r2 = await page.evaluate(() => window.__demo.view.pieceScreenRect(window.__demo.scene.state.board[1].uid));
await page.mouse.click(r2.left + r2.width / 2, r2.top + r2.height / 2, { button: 'right' });
await wait(150);
const log = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log, /pieceClick .*"detail":true/);
await page.close();
assert.deepEqual(problems, []);
});
test('API survives junk input, calls in any order, and double destroy', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1000);
const res = await page.evaluate(async () => {
const { createFieldView } = await import('/js/render/app.js');
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:0;top:0;width:640px;height:360px;z-index:9';
document.body.appendChild(host);
const v = await createFieldView(host, { data: null, assets: null });
const out = [];
const tryit = (name, fn) => { try { out.push([name, fn()]); } catch (e) { out.push([name, 'THROW ' + e.message]); } };
tryit('pushSnapshot before battle', () => v.pushSnapshot({ t: 1, units: [[1, 2, 3, 4, 5, 6, 7, 8, 9]] }));
tryit('setStage junk', () => v.setStage({ rows: 5 }));
tryit('setStage null', () => v.setStage(null));
tryit('setCamera junk', () => v.setCamera(null, null));
tryit('setCamera weird', () => v.setCamera('zzz', { rect: { r0: 'a' } }));
tryit('setPrep null', () => v.setPrep(null));
tryit('setPrep junk', () => v.setPrep({ hand: 'x', temp: [null, 5, { uid: 'q' }], board: [{ uid: 1, row: 99, col: 1, kind: 'chess', id: 'nope' }, { uid: 2, row: 10, col: 4, kind: 'chess', id: 'nope' }] }, { editable: true, canPlace: () => { throw new Error('x'); } }));
tryit('highlight junk', () => v.highlightTiles('x', 'y'));
tryit('highlight mixed', () => v.highlightTiles([[1, 2], { row: 3, col: 4 }, null, [99, 99], ['a']], { color: 0xff0000 }));
tryit('highlight clear', () => v.highlightTiles(null));
tryit('enterBattle junk', () => v.enterBattle({ units: [null, { id: {} }, { id: 5, x: 'q' }] }));
tryit('pushSnapshot junk', () => [v.pushSnapshot(null), v.pushSnapshot({ t: NaN }), v.pushSnapshot({ t: 1, units: [[5, 1, 1, 10, 10, 0, 0, 0, 0], 'x', [7]] })]);
tryit('pushEvents junk', () => [v.pushEvents(null), v.pushEvents({ ev: 'x' }), v.pushEvents([['atk', 5, 999, 'arrow'], ['dmg', 5, 'x', 'phys'], ['heal'], ['status', 5], ['fx', 'burst', 'a', null, null], ['layer', 1, 'yanShip', 3], ['bounty', 1, 2], ['leak', 5], ['nope'], 7])]);
await new Promise((r) => setTimeout(r, 400));
tryit('snapshot after', () => v.pushSnapshot({ t: 1.2, units: [[5, 1.5, 1, 10, 10, 0, 0, 16, 2]] }));
await new Promise((r) => setTimeout(r, 300));
tryit('pieceScreenRect', () => [v.pieceScreenRect(-1), v.pieceScreenRect(undefined)]);
tryit('settings', () => { v.setSettings(null); v.setSettings({ quality: 'low', damageNumbers: false }); v.setSettings({ quality: 'nope' }); return true; });
tryit('on junk', () => typeof v.on('x', null));
tryit('stats', () => v.stats().mode);
tryit('destroy', () => { v.destroy(); v.destroy(); return true; });
tryit('after destroy', () => [v.setPrep({}), v.pushSnapshot({ t: 9, units: [] }), v.enterBattle({})]);
host.remove();
return out;
});
await wait(300);
await page.close();
const thrown = res.filter(([, r]) => typeof r === 'string' && r.startsWith('THROW'));
assert.deepEqual(thrown, []);
assert.deepEqual(problems.filter((p) => !/\[render\]/.test(p)), []);
});
test('raised blocks share the unit layer and sort around units; operators on walls stand on top', async () => {
// the 2D atlas board's block rows (the 3D board, default when the local art is installed, has none: board=2d)
const { page, problems } = await open('scene=normal-m01&t=4&panel=0&board=2d', 1600, 900);
await wait(2500);
const r = await page.evaluate(() => {
const { tiles, views } = window.__demo.view.debug;
const out = { rows: [...tiles.rowMeshes.keys()], shared: true, order: [], walls: [] };
for (const [id, v] of views) {
if (!v.root || v.destroyed || !v.alive) continue;
const row = Math.round(v.y);
if (Math.round(v.x) === 4 && row >= 10 && row <= 12) {
out.walls.push({ id, z: v.z, shadowOnSurface: v.shadow?.parent === tiles.rowSurfaces.get(row) });
}
for (const [br, { mesh: m }] of tiles.rowMeshes) {
if (m.parent !== v.root.parent) out.shared = false;
const kids = m.parent.children;
// after a render the layer is sorted: the unit is behind the row's blocks iff it stands further back
out.order.push({ id, y: v.y, br, before: kids.indexOf(v.root) < kids.indexOf(m) });
}
}
return out;
});
await page.screenshot({ path: path.join(OUT, 'render-walls.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.rows.length > 0, 'raised rows have block meshes');
assert.ok(r.shared, 'block rows live in the unit layer');
const wrong = r.order.filter((o) => o.before !== (o.y > o.br + 0.5));
assert.deepEqual(wrong, [], 'units behind a block row are drawn before it, others after');
assert.ok(r.walls.length >= 2, `operators on the col-4 walls (${r.walls.length})`);
for (const w of r.walls) {
assert.ok(w.z > 0.12, `unit ${w.id} stands on the wall (z ${w.z})`);
assert.ok(w.shadowOnSurface, `unit ${w.id} shadow lies on the wall top`);
}
});
test('attack wind-up starts before the atk event reaches the render clock', async () => {
const { page, problems } = await open('scene=normal-m01&t=6&panel=0', 1280, 720);
await wait(800);
const log = await page.evaluate(async () => {
const { views, interp } = window.__demo.view.debug;
const log = [];
const hook = () => {
for (const [id, v] of views) {
if (v._hooked || !v.windUp) continue;
v._hooked = true;
const w = v.windUp.bind(v), a = v.onAttack.bind(v);
v.windUp = (lead) => { const ok = w(lead); if (ok) log.push(['wind', id, interp.renderT, lead]); return ok; };
v.onAttack = (tg, now) => { log.push(['atk', id, interp.renderT]); return a(tg, now); };
}
};
for (let i = 0; i < 80; i++) { hook(); await new Promise((r) => setTimeout(r, 50)); }
return log;
});
await page.close();
assert.deepEqual(problems, []);
const winds = log.filter((e) => e[0] === 'wind');
assert.ok(winds.length >= 3, `wind-ups started (${winds.length})`);
let paired = 0;
for (const [, id, t, lead] of winds) {
assert.ok(lead > 0 && lead <= 1.5, `lead ${lead}`);
const hit = log.find((e) => e[0] === 'atk' && e[1] === id && e[2] >= t);
if (hit && hit[2] - t > 0.02) paired++;
}
assert.ok(paired >= 2, `wind-ups followed by their attack (${paired})`);
});
test('a leaked enemy fades once and is not recreated from the trailing snapshots', async () => {
const { page, problems } = await open('scene=normal-m02&t=33.5&panel=0', 1280, 720);
const seen = await page.evaluate(async () => {
const { views, interp } = window.__demo.view.debug;
const seen = [];
for (let i = 0; i < 120; i++) {
seen.push([interp.renderT, views.has(18)]);
await new Promise((r) => setTimeout(r, 25));
}
return seen;
});
await page.close();
assert.deepEqual(problems, []);
const first = seen.findIndex(([, has]) => has);
assert.ok(first >= 0, 'enemy 18 shown before its leak');
const gone = seen.findIndex(([, has], i) => i > first && !has);
assert.ok(gone > first && seen[gone][0] > 35, `removed after the leak at 35 (at ${seen[gone]?.[0]})`);
const back = seen.slice(gone).filter(([, has]) => has);
assert.deepEqual(back, [], 'not resurrected by the snapshots that still list it');
});
test('prep scouting board (prep:true + one gt:0 snapshot) stays visible and static', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1200);
const r = await page.evaluate(async () => {
const rec = await fetch('/dev/recordings/normal-m01.json').then((x) => x.json());
const v = window.__demo.view;
const snap = rec.frames[0].snap;
v.enterBattle({ fieldId: 'scout', kind: 'normal', rect: rec.field.rect, units: rec.field.units, prep: true });
v.pushSnapshot({ t: 'b.snap', fieldId: 'scout', gt: 0, units: snap.units });
const pos = () => [...v.debug.views].filter(([, u]) => u.root && !u.destroyed).map(([id, u]) => [id, +u.x.toFixed(3), +u.y.toFixed(3), u.root.visible && u.root.alpha > 0.5]);
await new Promise((res) => setTimeout(res, 1500));
const a = pos();
await new Promise((res) => setTimeout(res, 1500));
return { a, b: pos(), n: rec.field.units.length, mode: v.stats().mode };
});
await page.screenshot({ path: path.join(OUT, 'render-scout.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.a.length, r.n, 'every unit has a view');
assert.ok(r.a.every((u) => u[3]), 'all visible');
assert.deepEqual(r.b, r.a, 'nothing moved or vanished');
});
test('facing hooks: tileScreen / holdPiece / setPieceDir; board pieces and battle allies are built with their dir', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, st = window.__demo.scene.state;
const out = {};
// tileScreen = the camera projection of the tile-top centre, in client px (the wheel's geometry)
const ts = v.tileScreen(10, 5);
const cr = v.debug.app.view.getBoundingClientRect();
const c = v.debug.cam.project(5, 10, v.debug.tiles.heightAt(10, 5));
out.ts = ts && [Math.abs(ts.x - (cr.left + c.x)) < 0.01, Math.abs(ts.y - (cr.top + c.y)) < 0.01, ts.poly.length, ts.s > 0];
out.tsJunk = [v.tileScreen('a', 1), v.tileScreen(1.5, 2)];
// holdPiece pins a bench piece on a board tile across setPrep calls until released
const uid = st.hand[0].uid;
const pv = () => v.debug.views.get('p:' + uid);
v.holdPiece(uid, { row: 10, col: 5 });
await new Promise((res) => setTimeout(res, 450));
out.held = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
v.setPrep(JSON.parse(JSON.stringify(st)), { editable: true });
await new Promise((res) => setTimeout(res, 1600)); // longer than the drop-pending grace
out.heldAfterPrep = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
v.holdPiece(uid, null);
v.setPrep(JSON.parse(JSON.stringify(st)), { editable: true });
await new Promise((res) => setTimeout(res, 450));
out.released = [+pv().x.toFixed(2), +pv().y.toFixed(2)];
// setPieceDir re-orients a prep view
out.setDir = [v.setPieceDir(uid, 'UP'), pv().dir, v.setPieceDir(-5, 'UP'), v.setPieceDir(uid, 7)];
v.setPieceDir(uid, 'RIGHT');
// a board piece carrying dir is built facing it (after the round's rebuild: enterPrepMode clears every view)
const b0 = st.board[0];
v.enterBattle({ fieldId: 'x', kind: 'normal', units: [] });
const next = JSON.parse(JSON.stringify(st));
next.board[0].dir = 'LEFT';
if (next.board[1]) next.board[1].dir = 'UP';
v.setPrep(next, { editable: true });
const bv = (p) => v.debug.views.get('p:' + p.uid);
out.built = [bv(b0).dir, bv(b0).facing, next.board[1] ? bv(next.board[1]).dir : 'UP'];
// battle / scouting allies keep UnitInfo.dir (model + ground wedge); a unit without it derives RIGHT, no wedge
v.enterBattle({ fieldId: 'dirs', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, prep: true, units: [
{ id: 1, kind: 'op', side: 'ally', defId: b0.id, x: 3, y: 10, facing: 1, dir: 'UP', maxHp: 100 },
{ id: 2, kind: 'op', side: 'ally', defId: b0.id, x: 4, y: 10, facing: -1, dir: 'LEFT', maxHp: 100 },
{ id: 3, kind: 'op', side: 'ally', defId: b0.id, x: 5, y: 10, facing: 1, maxHp: 100 },
] });
v.pushSnapshot({ fieldId: 'dirs', gt: 0, units: [[1, 3, 10, 100, 100, 0, 0, 0, 0], [2, 4, 10, 100, 100, 0, 0, 0, 0], [3, 5, 10, 100, 100, 0, 0, 0, 0]] });
await new Promise((res) => setTimeout(res, 500));
const u = (id) => v.debug.views.get(id);
out.battle = [1, 2, 3].map((id) => u(id) && [u(id).dir, u(id).hasDir, u(id).facing]);
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(r.ts, [true, true, 4, true], 'tileScreen = projected tile centre + 4 corners');
assert.deepEqual(r.tsJunk, [null, null]);
assert.deepEqual(r.held, [5, 10], 'held on the tile');
assert.deepEqual(r.heldAfterPrep, [5, 10], 'setPrep and the drop grace keep a held piece on its tile');
assert.notDeepEqual(r.released, [5, 10], 'released: back on its bench slot');
assert.deepEqual(r.setDir, [true, 'UP', false, false]);
assert.deepEqual(r.built, ['LEFT', -1, 'UP'], 'stored dir applied at build time');
assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]);
});
test('drops outside the stage report client coords; a refused drop tweens back home', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000);
const info = await page.evaluate(() => {
window.__demo.refuseDrops = true;
const v = window.__demo.view, st = window.__demo.scene.state;
const uid = st.hand[0].uid;
const pv = v.debug.views.get('p:' + uid);
const t = v.debug.cam.project(5, 10, 0);
return { uid, r: v.pieceScreenRect(uid), home: [pv.x, pv.y], target: { x: t.x, y: t.y } };
});
const drag = async (tx, ty) => {
const cx = info.r.left + info.r.width / 2, cy = info.r.top + info.r.height * 0.6;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(cx + (tx - cx) * i / 10, cy + (ty - cy) * i / 10);
await page.mouse.up();
};
await drag(8, 8);
await wait(500);
const log1 = await page.evaluate(() => window.__demo.log.join('\n'));
assert.match(log1, /pieceDrop .*"area":"outside","clientX":8,"clientY":8/);
await drag(info.target.x, info.target.y);
await wait(250);
const mid = await page.evaluate((uid) => { const pv = window.__demo.view.debug.views.get('p:' + uid); return [pv.x, pv.y]; }, info.uid);
await wait(1800);
const end = await page.evaluate((uid) => { const pv = window.__demo.view.debug.views.get('p:' + uid); return [pv.x, pv.y]; }, info.uid);
const onBoard = await page.evaluate((uid) => window.__demo.scene.state.board.some((p) => p.uid === uid), info.uid);
await page.close();
assert.deepEqual(problems, []);
assert.equal(onBoard, false, 'demo refused the move');
assert.ok(Math.hypot(mid[0] - 5, mid[1] - 10) < 0.3, `held at the drop tile while pending (${mid})`);
assert.ok(Math.hypot(end[0] - info.home[0], end[1] - info.home[1]) < 0.05, `back home (${end} vs ${info.home})`);
});
test('DPR change and resize re-fit the canvas', async () => {
const { page, problems } = await open('scene=normal-m02&t=10&panel=0', 1280, 720);
await wait(1000);
const size = () => page.evaluate(() => { const c = window.__demo.view.debug.app.view; return [c.width, c.height]; });
const a = await size();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
await wait(700);
const b = await size();
await page.setViewport({ width: 900, height: 700, deviceScaleFactor: 1 });
await wait(700);
const c = await size();
await page.screenshot({ path: path.join(OUT, 'render-resize.png') });
await page.close();
assert.deepEqual(problems, []);
assert.ok(b[0] > a[0] * 1.3, `DPR 2 raises the backing resolution (${a} → ${b})`);
assert.ok(c[0] < b[0] && Math.abs(c[0] / c[1] - 900 / 700) < 0.05, `resized to 900×700 aspect (${c})`);
});
test('camera flies prep → boss → unite without errors', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1280, 720);
await wait(1500);
await page.evaluate(() => window.__demo.view.setCamera('boss'));
await wait(350);
await page.screenshot({ path: path.join(OUT, 'render-camera-mid.png') });
await wait(700);
const cam1 = await page.evaluate(() => window.__demo.view.stats().camera);
await page.evaluate(() => window.__demo.view.setCamera('unite'));
await wait(1000);
const cam2 = await page.evaluate(() => window.__demo.view.stats().camera);
await page.close();
assert.deepEqual(problems, []);
assert.ok(cam1.ty < 5, 'boss camera looks at rows 0–5');
assert.ok(cam2.tx > 9 && cam2.ty > 9, 'unite camera centred on the wide field');
});
});