Files
ark-stronghold-protocol/test/ui/mock.e2e.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

720 lines
43 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 E2E for the in-match UI via the mock harness (public/dev/game-mock.html) in headless Chrome
// (puppeteer-core + system Chrome). Opt-in: runs only with SP_E2E=1 (it needs Chrome and takes ~1 min).
//
// SP_E2E=1 node --test test/ui/mock.e2e.test.js
// SP_E2E=1 SP_RENDER=fallback node --test test/ui/mock.e2e.test.js # screenshots with the DOM fallback view
//
// 1. Screenshots of every phase / variant at 1920×1080 and 1280×720 → test/e2e/out/ui-<name>-<w>.png,
// asserting zero console errors / page errors / failed requests.
// 2. Interactions against the harness's mock server: two-tap buy, keyboard shortcuts (R/F/D/Space/Esc), drag a hand
// unit onto the board + choose a direction on the wheel, tap a unit → underframe 出售 (no drag-to-sell), drag an
// item onto a unit (equip), 机变 pick, band pick, briefing ready, emote, exit → 暂离 → 返回.
// 3. Regressions: tooltip after a click, toasts / reconnect banner placement, refused watch targets, battle unit
// panel closing at the next prep, no Hidden Core medal before R15, equipment dropped on an operator's upper body
// (engine), illegal drops toasting their reason.
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 RENDER = process.env.SP_RENDER === 'fallback' ? 'fallback' : 'engine';
const SHOTS = [
['briefing', 'phase=INFO_CHECK'], ['draft', 'phase=BAND_DRAFT'], ['draft-solo', 'phase=BAND_DRAFT&variant=solo'],
['boot', 'phase=BATTLE_CHECK'], ['prep', 'phase=PREP'], ['prep-reward-temp', 'phase=PREP&variant=reward,temp'],
['prep-frozen', 'phase=PREP&variant=frozen'], ['prep-dead', 'phase=PREP&variant=dead'], ['prep-collapsed', 'phase=PREP&variant=collapsed'],
['prep-detail', 'phase=PREP&variant=detailpiece'], ['prep-shopdetail', 'phase=PREP&variant=detail'], ['prep-bond', 'phase=PREP&variant=bond'],
['prep-enemies', 'phase=PREP&variant=drawer'], ['prep-pen', 'phase=PREP&variant=pen'], ['prep-info', 'phase=PREP&variant=info'], ['prep-emote-ticker', 'phase=PREP&variant=emote,ticker'],
['prep-settings', 'phase=PREP&variant=settings'], ['sp-bounty', 'phase=SP_DRAFT&variant=bounty'], ['sp-supply', 'phase=SP_DRAFT&variant=supply'],
['sp-tactic', 'phase=SP_DRAFT&variant=tactic'], ['sp-solo', 'phase=SP_DRAFT&variant=solo'], ['combat', 'phase=COMBAT'],
['combat-done', 'phase=COMBAT&variant=done'], ['unite', 'phase=UNITE'], ['settle', 'phase=SETTLE'], ['final-assault', 'phase=FINAL_ASSAULT'],
['hidden-core', 'phase=HIDDEN_CORE'], ['result-win', 'phase=RESULT'], ['result-lose', 'phase=RESULT&variant=defeat'],
];
describe('in-match UI (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'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w = 1920, h = 1080, render = RENDER } = {}) {
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)'), { timeout: 15000 });
await new Promise((r) => setTimeout(r, 900));
return { page, problems };
}
const mockState = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Swipe the open direction wheel from its centre towards `dir` and release (research 09 §1.2). */
const swipeWheel = async (page, dir = 'RIGHT') => {
await page.waitForSelector('.fwheel__dia', { timeout: 4000 });
const w = await page.$eval('.fwheel__dia', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.width / 2 }; });
const v = { UP: [0, -1], RIGHT: [1, 0], DOWN: [0, 1], LEFT: [-1, 0] }[dir];
await page.mouse.move(w.x, w.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++) { await page.mouse.move(w.x + (v[0] * w.h * 0.7 * i) / 8 + 1, w.y + (v[1] * w.h * 0.7 * i) / 8 + 1); await sleep(16); }
await page.mouse.up();
await sleep(400);
};
for (const [w, h] of [[1920, 1080], [1280, 720]]) {
test(`screenshots of every phase at ${w}×${h} with zero console errors`, async () => {
for (const [name, query] of SHOTS) {
const { page, problems } = await open(query, { w, h });
await sleep(RENDER === 'engine' ? 1500 : 700);
await page.screenshot({ path: path.join(OUT, `ui-${name}-${w}.png`) });
assert.deepEqual(problems, [], `${name} @${w}`);
await page.close();
}
});
}
test('prep: buy, shortcuts, ready, Esc', async () => {
const { page, problems } = await open('phase=PREP');
let s = await mockState(page);
const funds0 = s.funds;
const empty0 = s.hand.filter((x) => !x).length;
await page.click('.shopbar__cards .scard:not(.scard--sold)');
await sleep(300);
assert.equal((await mockState(page)).funds, funds0, 'the first tap only selects (确认购买)');
assert.ok(await page.$('.scard.is-armed .scard__confirm'));
await page.click('.scard.is-armed');
await sleep(300);
s = await mockState(page);
assert.ok(s.funds < funds0, 'bought');
assert.equal(s.hand.filter((x) => !x).length, empty0 - 1);
const f1 = s.funds;
await page.keyboard.press('KeyR');
await sleep(300);
s = await mockState(page);
assert.equal(s.funds, f1 - 1, 'R refreshes');
await page.keyboard.press('KeyF');
await sleep(300);
assert.equal((await mockState(page)).shop.frozen, true, 'F freezes');
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 40; }));
await sleep(100);
const lv = (await mockState(page)).shop.level;
await page.keyboard.press('KeyD');
await sleep(300);
assert.equal((await mockState(page)).shop.level, lv + 1, 'D levels up');
await page.keyboard.press('Space');
await sleep(300);
assert.equal((await mockState(page)).ready, true, 'Space readies');
assert.ok(await page.$('.readybtn.is-on'));
await page.keyboard.press('KeyR');
await sleep(200);
assert.equal((await mockState(page)).shop.frozen, true, 'no actions while ready');
await page.keyboard.press('Space');
await sleep(300);
assert.equal((await mockState(page)).ready, false);
// detail + Esc
await page.click('.scard .scard__detail');
await page.waitForSelector('.dpanel');
await page.keyboard.press('Escape');
await sleep(200);
assert.equal(await page.$('.dpanel'), null, 'Esc closes the detail panel');
assert.deepEqual(problems, []);
await page.close();
});
test('prep: drag & drop — tap-sell (no sell zone), hand → board + wheel, item → unit (equip)', async () => {
// DOM drag targets only exist in the fallback view (the Pixi engine's own drag is tested by the render module)
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
const center = async (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const drag = async (from, to) => {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8);
await sleep(80);
await page.mouse.up();
await sleep(400);
};
let s = await mockState(page);
// sell one board unit first so the deploy cap has room: dropping it on the shop bar does nothing (no drag-to-sell),
// tapping it opens its underframe → 出售
const boardUnit = s.board.find((p) => p.kind === 'chess' && !p.golden);
const cap0 = s.board.length;
const funds0 = s.funds;
await drag(await center(`.ff-piece[data-uid="${boardUnit.uid}"]`), await center('.shopbar__cards'));
assert.equal((await mockState(page)).board.length, cap0, 'the shop bar is not a sell zone');
await page.click(`.ff-piece[data-uid="${boardUnit.uid}"]`);
await page.waitForSelector('.uframe__btn--sell', { timeout: 3000 });
assert.ok(await page.$('.uframe__btn--retreat'), '撤退 too');
await page.click('.uframe__btn--sell');
await sleep(400);
s = await mockState(page);
assert.equal(s.board.length, cap0 - 1, '出售 ⇒ sold');
assert.equal(s.funds, funds0 + 1);
// hand chess → an empty melee tile, then the direction wheel
const handChess = s.hand.find((p) => p && p.kind === 'chess');
const handLen = s.hand.filter(Boolean).length;
const occupied = new Set(s.board.map((p) => `${p.row},${p.col}`));
const freeTile = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7]].find(([r, c]) => !occupied.has(`${r},${c}`));
await drag(await center(`.ff-piece[data-uid="${handChess.uid}"]`), await center(`.ff-tile[data-row="${freeTile[0]}"][data-col="${freeTile[1]}"]`));
await swipeWheel(page, 'UP');
s = await mockState(page);
assert.ok(s.board.some((p) => p.uid === handChess.uid && p.row === freeTile[0] && p.col === freeTile[1]), 'moved to the board');
assert.equal(s.hand.filter(Boolean).length, handLen - 1);
// item → a unit without items
const itemPiece = s.hand.find((p) => p && p.kind === 'item');
const target = s.board.find((p) => p.kind === 'chess' && (!p.items || p.items.length === 0));
await drag(await center(`.ff-piece[data-uid="${itemPiece.uid}"]`), await center(`.ff-piece[data-uid="${target.uid}"]`));
s = await mockState(page);
assert.ok(s.board.find((p) => p.uid === target.uid).items.some((i) => i.uid === itemPiece.uid), 'equipped');
// illegal: melee piece onto high ground does nothing
assert.deepEqual(problems, []);
await page.close();
});
test('engine drag & drop: tap-sell, hand unit → board unit (swap) + wheel', async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP');
await sleep(1500); // spine models
const rect = (uid) => page.evaluate((u) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * 0.65 }; }, uid);
const drag = async (from, to) => {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(100);
await page.mouse.up();
await sleep(600);
};
let s = await mockState(page);
const unit = s.board.find((x) => x.kind === 'chess' && !x.golden && !(x.items || []).length);
const bar = await page.$eval('.shopbar__cards', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
await drag(await rect(unit.uid), bar);
assert.equal((await mockState(page)).board.length, s.board.length, 'no drag-to-sell');
const pt = await rect(unit.uid);
await page.mouse.click(pt.x, pt.y);
await page.waitForSelector('.uframe__btn--sell', { timeout: 3000 });
await page.click('.uframe__btn--sell');
await sleep(400);
const s2 = await mockState(page);
assert.equal(s2.board.length, s.board.length - 1, 'sold with the underframe 出售');
assert.equal(s2.funds, s.funds + 1);
const hc = s2.hand.find((x) => x && x.kind === 'chess' && !x.golden);
const target = s2.board.find((x) => x.kind === 'chess' && x.row === 9);
await drag(await rect(hc.uid), await rect(target.uid));
await swipeWheel(page, 'LEFT');
s = await mockState(page);
assert.ok(s.board.some((x) => x.uid === hc.uid), 'hand unit deployed');
assert.ok(s.hand.some((x) => x && x.uid === target.uid), 'board unit swapped into the hand');
assert.equal(s.board.find((x) => x.uid === hc.uid).dir, 'LEFT', 'the mock server stores the wheel direction');
assert.equal(s.hand.find((x) => x && x.uid === target.uid).dir, undefined, 'bench pieces carry no direction');
// re-orient in place: drop the unit on its own tile → wheel → UP (no duplicate piece)
const here = await rect(hc.uid);
await page.mouse.move(here.x, here.y);
await page.mouse.down();
for (let i = 1; i <= 8; i++) { await page.mouse.move(here.x + i * 12, here.y - i * 6); await sleep(16); } // off the tile…
for (let i = 7; i >= 0; i--) { await page.mouse.move(here.x + i * 12, here.y - i * 6); await sleep(16); } // …and back onto it
await sleep(100);
await page.mouse.up();
await sleep(600);
await swipeWheel(page, 'UP');
const s3 = await mockState(page);
assert.equal(s3.board.length, s.board.length, 'still one piece');
assert.equal(s3.board.find((x) => x.uid === hc.uid).dir, 'UP', 're-oriented in place');
assert.equal(await page.evaluate((u) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get('p:' + u)?.dir ?? null, hc.uid), 'UP', 'the model shows it');
assert.deepEqual(problems, []);
await page.close();
});
test('机变 pick, band pick, briefing ready', async () => {
let { page, problems } = await open('phase=SP_DRAFT&variant=bounty');
await page.click('.spcard.is-pickable');
await sleep(300);
assert.ok(await page.$('.spcard.is-mine'), 'picked card marked');
assert.deepEqual(problems, []);
await page.close();
({ page, problems } = await open('phase=BAND_DRAFT'));
const bands = await page.$$('.dband');
await bands[5].click();
await page.click('.draft-detail__btns .btn--primary');
await sleep(300);
assert.ok(await page.$('.dband.is-mine'), 'band picked');
assert.deepEqual(problems, []);
await page.close();
({ page, problems } = await open('phase=INFO_CHECK'));
await page.click('.brief__foot .btn--primary');
await sleep(300);
const txt = await page.$eval('.brief-ready__txt', (el) => el.textContent);
assert.match(txt, /3/);
assert.deepEqual(problems, []);
await page.close();
});
// ---- round-1 UI fixes ----------------------------------------------------------------------------------------
const rectOf = (page, sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; });
const overlaps = (a, b) => !!a && !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
test('tooltip: after clicking 本局信息 it does not come back with stale text over the dialog', async () => {
const { page, problems } = await open('phase=PREP');
const btn = await rectOf(page, '.gtop__iconbtn');
await page.mouse.move((btn.left + btn.right) / 2, (btn.top + btn.bottom) / 2);
await sleep(600);
await page.mouse.down();
await page.mouse.up();
await sleep(800);
await page.waitForSelector('.edrawer');
const tip = await page.$eval('.tooltip', (el) => ({ shown: el.classList.contains('is-shown'), text: el.textContent })).catch(() => null);
assert.ok(!tip || !tip.shown, `no tooltip after the click (got ${JSON.stringify(tip)})`);
// leaving and coming back shows the current text
await page.mouse.move(5, 500);
await sleep(200);
await page.mouse.move((btn.left + btn.right) / 2, (btn.top + btn.bottom) / 2);
await sleep(600);
assert.match(await page.$eval('.tooltip.is-shown', (el) => el.textContent), /本局信息/);
assert.deepEqual(problems, []);
await page.close();
});
// ---- enemy preview pen (research 09 §2 / §6.2 item 3) ------------------------------------------------------------
const camera = (page) => page.$eval('.gm', (el) => el.dataset.camera);
const sprite = (page, sel) => page.$eval(sel, (el) => ({ sprite: el.dataset.sprite, label: el.getAttribute('aria-label'), disabled: el.getAttribute('aria-disabled') }));
for (const render of ['engine', 'fallback']) {
test(`pen (${render}): 🔍▶▶ pans to the pen, 🔍◀◀ / grey 🔍 / Esc return; the shop folds away and comes back`, async (t) => {
if (render === 'engine' && RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP', { render });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
assert.equal(await camera(page), 'prep');
assert.deepEqual(await sprite(page, '.enemybtn'), { sprite: 'btn_check_enemy', label: '敌方情报', disabled: 'false' });
assert.equal((await sprite(page, '.gtop__iconbtn')).sprite, 'btn_check_player_normal');
assert.ok(await page.$('.shopbar:not(.is-collapsed), .shopbar'), 'shop bar shown');
const cards0 = await page.$$eval('.shopbar__cards .scard', (els) => els.length);
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
assert.ok(await page.$('.gm.is-pen'));
assert.equal((await sprite(page, '.gtop__iconbtn')).sprite, 'btn_check_player_back', 'left button: 🔍◀◀');
assert.equal((await sprite(page, '.enemybtn')).sprite, 'btn_check_enemy_unfold', 'right button greys out');
assert.equal(await page.$$eval('.shopbar__cards .scard', (els) => els.length), 0, 'the shop bar folded away');
assert.equal(await page.$('.edrawer'), null, 'no DOM list: the pen is the preview');
await sleep(400);
await page.screenshot({ path: path.join(OUT, `ui-pen-${render}.png`) });
// the left 🔍◀◀ returns
await page.click('.gtop__iconbtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
assert.equal(await page.$$eval('.shopbar__cards .scard', (els) => els.length), cards0, 'the shop bar is back');
assert.equal(await page.$('.edrawer'), null, 'returning does not open the info dialog');
// the grey right button returns too; Esc returns
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep');
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep');
assert.deepEqual(problems, []);
await page.close();
});
}
test('pen (fallback view): the enemies of m.private.nextEnemies stand in their gate zones; a tap opens the detail card', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.click('.enemybtn');
await page.waitForSelector('.ff-pen__enemy', { timeout: 3000 });
const s = await mockState(page);
const total = s.nextEnemies.reduce((n, e) => n + e.count, 0);
const shown = await page.$$eval('.ff-pen__enemy', (els) => els.map((el) => el.dataset.enemy));
assert.equal(shown.length, Math.min(50, total), 'one figure per enemy (≤ 50)');
assert.deepEqual([...new Set(shown)].sort(), [...new Set(s.nextEnemies.map((e) => e.enemyKey))].sort());
await page.click('.ff-pen__enemy');
await page.waitForSelector('.dpanel', { timeout: 3000 });
assert.equal(await camera(page), 'pen', 'the detail card opens over the pen');
await page.keyboard.press('Escape');
await sleep(200);
assert.equal(await page.$('.dpanel'), null, 'Esc closes the card first');
assert.equal(await camera(page), 'pen');
await page.keyboard.press('Escape');
await sleep(200);
assert.equal(await camera(page), 'prep', 'then leaves the pen');
assert.deepEqual(problems, []);
await page.close();
});
test('pen: only in 休整期 — in combat the right 🔍 is grey and inert; the left 🔍 opens 本局信息 with the enemy list as its 2nd tab', async () => {
const { page, problems } = await open('phase=COMBAT');
const b = await sprite(page, '.enemybtn');
assert.equal(b.sprite, 'btn_check_enemy_unfold');
assert.equal(b.disabled, 'true');
const cam0 = await camera(page);
await page.click('.enemybtn');
await sleep(400);
assert.equal(await camera(page), cam0, 'no pen outside prep');
await page.close();
const p2 = await open('phase=PREP');
await p2.page.click('.gtop__iconbtn');
await p2.page.waitForSelector('.edrawer');
const tabs = await p2.page.$$eval('.edrawer .tabs__tab', (els) => els.map((el) => el.textContent.trim()));
assert.deepEqual(tabs, ['本局信息', '敌方情报']);
assert.equal(await p2.page.$eval('.edrawer .tabs__tab.is-active', (el) => el.textContent.trim()), '本局信息', 'info first');
await p2.page.click('.edrawer .tabs__tab:nth-child(2)');
await p2.page.waitForSelector('.edrawer .erow');
await p2.page.click('.gtop__iconbtn');
await sleep(200);
assert.equal(await p2.page.$('.edrawer'), null, 'the left 🔍 closes the dialog again');
assert.deepEqual([...problems, ...p2.problems], []);
await p2.page.close();
});
// ---- review regressions (ui-compat): FA prep on the own half, teammate pen, pen state machine ------------------------
for (const side of ['L', 'R']) {
test(`Final Assault prep (${side}): the own half of the boss field; a screen swipe → the board direction${side === 'R' ? ' (mirrored)' : ''}`, async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open(`phase=PREP&variant=${side === 'R' ? 'bossR' : 'boss'}`, { w: 1600, h: 900 });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.raw?.prepField, { timeout: 15000 });
await sleep(900);
assert.equal(await camera(page), 'bossPrep');
assert.deepEqual(await page.evaluate(() => globalThis.__SP_VIEW__.raw.prepField()), { kind: 'bossPrep', side, mirror: side === 'R' });
const plan = await page.evaluate(async () => {
const v = globalThis.__SP_VIEW__.raw;
const S = globalThis.__MOCK__.S();
const piece = S.priv.hand.find((x) => x && x.kind === 'chess');
const stage = (await import('/js/data.js')).data.lookup('stages', S.pub.stageId);
const taken = new Set(S.priv.board.map((b) => `${b.row},${b.col}`));
const [row, col] = (stage.deployTiles.normal.melee || []).find(([r, c]) => !taken.has(`${r},${c}`));
const r = v.pieceScreenRect(piece.uid);
const tt = v.tileScreen(row, col);
return { uid: piece.uid, row, col, from: { x: r.left + r.width / 2, y: r.top + r.height * 0.6 }, to: { x: tt.x, y: tt.y } };
});
const m = page.mouse;
await m.move(plan.from.x, plan.from.y); await m.down();
for (let i = 1; i <= 14; i++) { await m.move(plan.from.x + ((plan.to.x - plan.from.x) * i) / 14, plan.from.y + ((plan.to.y - plan.from.y) * i) / 14); await sleep(16); }
await sleep(120); await m.up();
await swipeWheel(page, 'RIGHT');
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === uid), { timeout: 4000 }, plan.uid);
const sent = await page.evaluate(() => globalThis.__MOCK__.S().requests.filter((r) => r[0] === 'g.move').pop()[1]);
assert.deepEqual(sent.to, { area: 'board', row: plan.row, col: plan.col, dir: side === 'R' ? 'LEFT' : 'RIGHT' }, 'board coordinates and the board direction');
const shown = await page.evaluate((uid) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + uid); return { x: v.x, y: v.y, dir: v.dir }; }, plan.uid);
assert.deepEqual(shown, { x: side === 'R' ? 20 - plan.col : plan.col, y: plan.row - 7, dir: 'RIGHT' }, 'drawn where it was dropped, facing the swipe');
// the pen is a detour of this camera: the way back is the boss-field half again
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
await page.click('.gtop__iconbtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep');
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.prepField().side), side);
await page.screenshot({ path: path.join(OUT, `ui-fa-prep-${side}.png`) });
assert.deepEqual(problems, []);
await page.close();
});
}
for (const render of ['engine', 'fallback']) {
test(`pen (${render}): scouting a teammate in prep shows THEIR pen; the way back is their board, then home shows mine`, async (t) => {
if (render === 'engine' && RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP', { render });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
const penKeys = () => page.evaluate(() => {
const v = globalThis.__SP_VIEW__;
if (v.kind === 'engine') return [...v.raw.debug.penViews.values()].map((x) => [x.info.enemyKey, Math.round(x.info.y)]);
return [...document.querySelectorAll('.ff-pen__enemy')].map((el) => [el.dataset.enemy, null]);
});
const rows = await page.$$('.team__btn');
await rows[2].click();
await sleep(250);
if (await page.$('.team__ob')) await page.click('.team__ob');
await page.waitForSelector('.gm__watching', { timeout: 4000 });
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
await sleep(300);
const theirs = await page.evaluate(() => globalThis.__MOCK__.store.get().match.field.nextEnemies);
const shown = await penKeys();
assert.equal(shown.length, theirs.reduce((n, e) => n + e.count, 0), 'one figure per enemy of THEIR round');
assert.deepEqual([...new Set(shown.map((x) => x[0]))].sort(), [...new Set(theirs.map((e) => e.enemyKey))].sort());
if (render === 'engine') assert.ok(shown.every((x) => x[1] >= 17), 'their upper-gate enemies stand in rows 17–18');
await page.click('.gtop__iconbtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'normal', { timeout: 3000 });
assert.ok(await page.$('.gm__watching'), 'still scouting the teammate');
await page.click('.gm__watching button');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
await sleep(300);
const mine = await page.evaluate(() => globalThis.__MOCK__.S().priv.nextEnemies);
assert.equal((await penKeys()).length, Math.min(50, mine.reduce((n, e) => n + e.count, 0)), 'home: my own pen again');
assert.deepEqual(problems, []);
await page.close();
});
}
test('pen: leaving prep while the pen is shown returns the camera and unfolds the shop; a folded shop stays folded', async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP');
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
await page.evaluate(() => globalThis.__MOCK__.setPhase('COMBAT'));
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'normal', { timeout: 4000 });
assert.equal(await page.$('.gm.is-pen'), null);
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.camKind), 'normal', 'the view left the pen too');
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.stats().pen), 0, 'a real battle empties the pen');
await page.evaluate(() => globalThis.__MOCK__.setPhase('PREP'));
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 4000 });
assert.equal(await page.$('.gm.is-collapsed'), null, 'the shop is back');
await page.click('.funds__collapse');
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen');
for (let i = 0; i < 9; i++) await page.click(i % 2 ? '.enemybtn' : '.gtop__iconbtn'); // fast back-and-forth
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'prep', { timeout: 3000 });
await sleep(400);
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.camKind), 'prep', 'view and HUD agree');
assert.ok(await page.$('.gm.is-collapsed'), 'the shop folded before the pen stays folded');
assert.deepEqual(problems, []);
await page.close();
});
for (const [w, h] of [[1920, 1080], [1366, 768]]) {
test(`toasts clear the top bar capsule; the reconnect banner clears the view switcher (${w}×${h})`, async () => {
let { page, problems } = await open('phase=PREP', { w, h });
await page.evaluate(() => import('/js/ui/toasts.js').then((m) => m.toast('整备区已满', 'error')));
await sleep(500);
const toastR = await rectOf(page, '.toast');
const cap = await rectOf(page, '.gtop__center');
assert.ok(!overlaps(toastR, cap), `toast ${JSON.stringify(toastR)} over the capsule ${JSON.stringify(cap)}`);
assert.deepEqual(problems, []);
await page.close();
({ page, problems } = await open('phase=COMBAT', { w, h }));
await page.evaluate(() => globalThis.__MOCK__.store.set({ connection: { status: 'reconnecting', ping: 0, attempt: 2, retryAt: Date.now() + 5000, lastError: null, everOnline: true } }));
await page.waitForSelector('.conn-banner');
await sleep(900);
const banner = await rectOf(page, '.conn-banner');
const vs = await rectOf(page, '.vswitch');
assert.ok(!overlaps(banner, vs), `banner ${JSON.stringify(banner)} over the switcher ${JSON.stringify(vs)}`);
assert.ok(!overlaps(banner, await rectOf(page, '.gtop__center')), 'banner clear of the capsule');
const hit = await page.evaluate((x, y) => !!document.elementFromPoint(x, y)?.closest('.vswitch'), (vs.left + vs.right) / 2, (vs.top + vs.bottom) / 2);
assert.ok(hit, 'the switcher stays clickable');
await page.evaluate(() => import('/js/ui/toasts.js').then((m) => m.toast('连接中断', 'error')));
await sleep(500);
assert.ok(!overlaps(await rectOf(page, '.toast'), banner), 'toasts stack below the banner');
assert.deepEqual(problems, []);
await page.close();
});
}
test('watch: the other pair of the Final Assault is refused and the eye stays put', async () => {
const { page, problems } = await open('phase=FINAL_ASSAULT');
const rows = await page.$$('.team__row');
await (await rows[2].$('.team__btn')).click(); // P3: the other pair (field b2)
await sleep(500);
const watched = await page.$$eval('.team__row.is-watched .team__name', (els) => els.map((e) => e.textContent));
assert.ok(!watched.includes('Doctor·B') && !watched.includes('灰烬'), `other pair not marked watched (${watched})`);
assert.match(await page.$eval('.toast', (el) => el.textContent), /另一组/);
assert.equal(await page.$eval('.vswitch__label', (el) => el.textContent), '全景');
assert.deepEqual(problems, []);
await page.close();
});
test('a battle unit panel opened in combat closes when the next prep starts', async () => {
const { page, problems } = await open('phase=COMBAT', { render: 'fallback' });
await page.waitForSelector('.ff-unit.is-ally');
await page.$eval('.ff-unit.is-ally', (el) => el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 })));
await page.waitForSelector('.dpanel');
await page.evaluate(() => globalThis.__MOCK__.setPhase('SETTLE'));
await sleep(300);
assert.ok(await page.$('.dpanel'), 'still open while the battle is being settled');
await page.evaluate(() => globalThis.__MOCK__.setPhase('PREP'));
await sleep(400);
assert.equal(await page.$('.dpanel'), null, 'closed in the next prep');
assert.deepEqual(problems, []);
await page.close();
});
test('R / F / D / Space do nothing behind the 本局信息 / 敌方情报 drawer; Esc closes it', async () => {
const { page, problems } = await open('phase=PREP');
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 40; }));
await sleep(100);
const before = await mockState(page);
await page.click('.gtop__iconbtn[aria-label="本局信息"]');
await page.waitForSelector('.edrawer');
for (const k of ['KeyR', 'KeyF', 'KeyD', 'Space']) { await page.keyboard.press(k); await sleep(150); }
const s = await mockState(page);
assert.deepEqual([s.funds, s.shop.frozen, s.shop.level, s.ready], [before.funds, before.shop.frozen, before.shop.level, before.ready], 'nothing acted behind the drawer');
await page.keyboard.press('Escape');
await sleep(200);
assert.equal(await page.$('.edrawer'), null, 'Esc closes the drawer');
await page.keyboard.press('KeyF');
await sleep(300);
assert.equal((await mockState(page)).shop.frozen, !before.shop.frozen, 'the shortcuts work again');
assert.deepEqual(problems, []);
await page.close();
});
for (const render of ['fallback', 'engine']) {
test(`a detail card opened by right-click closes when the field is pressed (${render})`, async (t) => {
if (render === 'engine' && RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP', { render });
await page.waitForFunction((k) => globalThis.__SP_VIEW__?.kind === k, { timeout: 15000 }, render);
if (render === 'engine') await sleep(1500); // spine models
const s = await mockState(page);
const unit = s.board.find((p) => p.kind === 'chess');
const occupied = new Set(s.board.map((p) => `${p.row},${p.col}`));
const [er, ec] = [[9, 9], [9, 8], [11, 7], [10, 7], [9, 7], [12, 6]].find(([r, c]) => !occupied.has(`${r},${c}`));
const pts = await page.evaluate((uid, row, col) => {
const v = globalThis.__SP_VIEW__;
const r = v.pieceScreenRect(uid);
const tile = document.querySelector(`.ff-tile[data-row="${row}"][data-col="${col}"]`)?.getBoundingClientRect();
const ts = tile ? { x: tile.left + tile.width / 2, y: tile.top + tile.height / 2 } : v.raw.tileScreen(row, col);
return { piece: { x: r.left + r.width / 2, y: r.top + r.height * 0.65 }, tile: { x: ts.x, y: ts.y } };
}, unit.uid, er, ec);
await page.mouse.click(pts.piece.x, pts.piece.y, { button: 'right' });
await page.waitForSelector('.dpanel', { timeout: 3000 });
await sleep(300);
assert.equal(await page.$('.uframe'), null, 'right-click only opens the card (no selection)');
await page.mouse.click(pts.tile.x, pts.tile.y);
await sleep(400);
assert.equal(await page.$('.dpanel'), null, `${render}: a press on an empty tile closes the card`);
assert.deepEqual(problems, []);
await page.close();
});
}
test('an eliminated player sees one elimination banner during SETTLE', async () => {
for (const phase of ['SETTLE', 'PREP']) {
const { page, problems } = await open(`phase=${phase}&variant=dead`, { render: 'fallback' });
await sleep(300);
const banners = await page.$$eval('.gm__dead, .chud__msg--dead', (els) => els.map((el) => el.className));
assert.equal(banners.length, 1, `${phase}: ${JSON.stringify(banners)}`);
assert.deepEqual(problems, []);
await page.close();
}
});
test('leaving the match screen clears the __SP_VIEW__ dev hook (no detached screen kept alive)', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__, { timeout: 15000 });
await page.evaluate(() => globalThis.__MOCK__.setPhase('RESULT'));
await page.waitForFunction(() => !document.querySelector('.gm'), { timeout: 5000 });
await sleep(200);
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__), null);
assert.deepEqual(problems, []);
await page.close();
});
test('result: no Hidden Core medal when R15 was never reached', async () => {
const { page, problems } = await open('phase=RESULT&variant=defeat');
assert.equal(await page.$('.medal--hidden'), null);
assert.ok(await page.$('.medal'), 'the leader medal is still shown');
assert.deepEqual(problems, []);
await page.close();
});
test('engine: equipment released over an operator\'s upper body equips it', async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP');
await sleep(1500); // spine models
const at = (uid, fy) => page.evaluate((u, f) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(u); return r && { x: r.left + r.width / 2, y: r.top + r.height * f }; }, uid, fy);
const drag = async (from, to) => {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(120);
await page.mouse.up();
await sleep(600);
};
let s = await mockState(page);
const targets = s.board.filter((x) => x.kind === 'chess' && !(x.items || []).length).sort((a, b) => a.row - b.row);
const items = s.hand.filter((x) => x && x.kind === 'item');
assert.ok(targets.length && items.length, 'mock board has an unequipped operator and a hand item');
const unit = targets[0];
await drag(await at(items[0].uid, 0.5), await at(unit.uid, 0.25));
s = await mockState(page);
assert.ok(s.board.find((x) => x.uid === unit.uid).items.some((i) => i.uid === items[0].uid), 'equipped from the head');
assert.deepEqual(problems, []);
await page.close();
});
test('an illegal drop says why (DOM fallback: pieceDrop, engine: cancelled drag)', async () => {
for (const render of RENDER === 'engine' ? ['fallback', 'engine'] : ['fallback']) {
const { page, problems } = await open('phase=PREP', { render });
if (render === 'engine') await sleep(1500);
// deploy cap reached: a hand operator dropped on any empty board tile is refused
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.deployCap = S.priv.board.filter((x) => x.kind === 'chess').length; S.priv.deployCount = S.priv.deployCap; }));
await sleep(200);
const s = await mockState(page);
const hc = s.hand.find((x) => x && x.kind === 'chess' && !x.golden);
const occupied = new Set(s.board.map((x) => `${x.row},${x.col}`));
let from;
let to = null;
if (render === 'fallback') {
from = await page.$eval(`.ff-piece[data-uid="${hc.uid}"]`, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const free = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7], [10, 4], [11, 4]].find(([r, c]) => !occupied.has(`${r},${c}`));
to = await page.$eval(`.ff-tile[data-row="${free[0]}"][data-col="${free[1]}"]`, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
} else {
from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height * 0.65 }; }, hc.uid);
// find the screen point of an empty board tile through the view's tileHover events
await page.evaluate(() => { globalThis.__TH__ = null; globalThis.__SP_VIEW__.on('tileHover', (t) => { globalThis.__TH__ = t; }); });
const box = await page.evaluate((uids) => {
const rs = uids.map((u) => globalThis.__SP_VIEW__.pieceScreenRect(u)).filter(Boolean);
return { l: Math.min(...rs.map((r) => r.left)) - 150, r: Math.max(...rs.map((r) => r.right)) + 150, t: Math.min(...rs.map((r) => r.top)), b: Math.max(...rs.map((r) => r.bottom)) + 40 };
}, s.board.map((x) => x.uid));
for (let y = box.t; y <= box.b && !to; y += 18) {
for (let x = box.l; x <= box.r && !to; x += 18) {
await page.mouse.move(x, y);
const t = await page.evaluate(() => globalThis.__TH__);
if (t && t.area === 'board' && !occupied.has(`${t.row},${t.col}`)) to = { x, y };
}
}
assert.ok(to, 'found an empty board tile on screen');
}
await page.mouse.move(from.x, from.y);
await page.mouse.down();
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(120);
await page.mouse.up();
await sleep(500);
const toastText = await page.$eval('.toast', (el) => el.textContent).catch(() => null);
assert.match(String(toastText), /已达到部署上限|无法部署在该位置|近战单位只能部署在地面/, `${render}: toast explains the refusal`);
assert.ok((await mockState(page)).hand.some((x) => x && x.uid === hc.uid), `${render}: the operator stays in the hand`);
assert.deepEqual(problems, []);
await page.close();
}
});
test('emote, exit → 暂离 → 返回', async () => {
const { page, problems } = await open('phase=PREP');
await page.click('.ewheel__btn');
await page.click('.ewheel__item');
await page.waitForSelector('.team__bubble');
await page.click('.gtop__exit');
await page.waitForSelector('.modal');
const buttons = await page.$$('.modal__actions .btn');
await buttons[1].click(); // 暂离
await page.waitForSelector('.awayov');
await page.click('.awayov .btn');
await sleep(300);
assert.equal(await page.$('.awayov'), null);
assert.deepEqual(problems, []);
await page.close();
});
});