Files

173 lines
10 KiB
JavaScript
Raw Permalink 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 checks of the round's result box at settlement (ResultDialog; GitHub #235, PR #112 by @Convey123) on the
// in-match mock harness (public/dev/game-mock.html):
// SP_E2E=1 node --test test/ui/resultbox.e2e.test.js → screenshots in test/e2e/out/resultbox-*.png
//
// The box wears the official dialog's words only (the owner's review of PR #112): title 作战结束, then the LP THIS
// player was charged — 全员无伤! / 生命值减少 −N — after a 联防 the authority's own per-player figure
// (m.public.uniteResult.losses). Asserted: `?phase=SETTLE&variant=unite` pops it for a leaker the helpers saved
// (losses.p1 = 0, nothing got through → 全员无伤!), `unite,through` for the same leaker charged 3 (→ 生命值减少 −3, not
// its own battle's leaks), `unite,through,helper` for a HELPER whose teammate paid (→ the official title alone),
// `unite,dead` for a player not in the round (not in `losses` → no box at all); driving the harness' own COMBAT →
// SETTLE switcher pops the round's own battle's box; the box is big, centred and click-through, closes by itself (and
// leaves the DOM), and no scenario logs a console error. The words are unit-tested in test/ui/gameLogic.test.js, the
// per-player charge server-side in test/match/playtest6-matchflow.test.js (#235).
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('settlement result box in the browser', { 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: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** A mock page; `seen()` tells whether a result box was EVER in the DOM (it closes by itself after ~3 s). */
async function open(query, { w = 1920, h = 1080 } = {}) {
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) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') 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.evaluateOnNewDocument(() => {
globalThis.__rdSeen = 0;
new MutationObserver(() => { if (document.querySelector('.rdialog')) globalThis.__rdSeen++; })
.observe(document, { subtree: true, childList: true });
});
// not networkidle0: the box opens when the screen mounts and is gone ~3 s later, art may still be loading then
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
return { page, problems, seen: () => page.evaluate(() => globalThis.__rdSeen > 0) };
}
/** The box as the player sees it: words + where it sits + how it behaves. */
const boxState = (page) => page.evaluate(() => {
const el = document.querySelector('.rdialog');
if (!el) return null;
const box = el.querySelector('.rdialog__box');
const b = box.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
return {
classes: el.className,
title: el.querySelector('.rdialog__title').textContent,
sub: el.querySelector('.rdialog__sub')?.textContent ?? '',
micro: el.querySelector('.rdialog__micro')?.textContent ?? '',
chevrons: el.querySelectorAll('.rdialog__chev').length,
ticks: el.querySelectorAll('.rdialog__tick').length,
titlePx: parseFloat(getComputedStyle(el.querySelector('.rdialog__title')).fontSize),
pointerEvents: getComputedStyle(el).pointerEvents,
centerX: Math.round(b.left + b.width / 2 - vw / 2),
centerY: Math.round(b.top + b.height / 2 - vh / 2),
visible: b.width > 300 && b.height > 90,
};
});
test('the official dialog: 作战结束 + this player\'s own LP after a 联防 — and none for a player not in the round', async () => {
const { page, problems } = await open('shot=1&phase=SETTLE&variant=unite');
await page.waitForSelector('.rdialog', { timeout: 10000 });
const ok = await boxState(page);
// the mock's player p1 leaked and the helpers stopped every enemy: nothing got through, nobody was charged
assert.equal(ok.title, '作战结束', JSON.stringify(ok));
assert.equal(ok.sub, '全员无伤!', 'the official line for this player\'s own LP, and nothing else');
assert.match(ok.micro, /BATTLE OVER/);
assert.ok(ok.classes.includes('rdialog--mint'), `the held tone: ${ok.classes}`);
assert.equal(ok.chevrons, 2);
assert.equal(ok.ticks, 4);
assert.ok(ok.titlePx >= 40, `a BIG box, not a line of small print (title ${ok.titlePx}px)`);
assert.ok(ok.visible, 'a real plate, not a hairline');
assert.ok(Math.abs(ok.centerX) <= 2 && Math.abs(ok.centerY) <= 2, `centred (off by ${ok.centerX},${ok.centerY})`);
assert.equal(ok.pointerEvents, 'none', 'the settlement stays clickable under it');
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await page.screenshot({ path: path.join(OUT, 'resultbox-unite-held.png') });
assert.deepEqual(problems, []);
await page.close();
// 3 got through and p1 was charged 3: the LP settlement charged (not its own battle's leaks), no verdict line
const t = await open('shot=1&phase=SETTLE&variant=unite,through');
await t.page.waitForSelector('.rdialog', { timeout: 10000 });
const bad = await boxState(t.page);
assert.equal(bad.title, '作战结束');
assert.equal(bad.sub, '生命值减少 −3', 'this player\'s own LP, from the authority');
assert.ok(bad.classes.includes('rdialog--red'), `the loss tone: ${bad.classes}`);
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await t.page.screenshot({ path: path.join(OUT, 'resultbox-unite-through.png') });
assert.deepEqual(t.problems, []);
await t.page.close();
// p1 HELPED: the 联防 leaked and a teammate was charged 4 while p1 paid nothing — 全员无伤! would be false for that
// teammate and the official dialog has no other line, so the box keeps the official title alone
const hp = await open('shot=1&phase=SETTLE&variant=unite,through,helper');
await hp.page.waitForSelector('.rdialog', { timeout: 10000 });
const spared = await boxState(hp.page);
assert.equal(spared.title, '作战结束');
assert.equal(spared.sub, '', 'no 全员无伤! while a teammate was charged');
assert.ok(spared.classes.includes('rdialog--orange'), `spared tone: ${spared.classes}`);
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await hp.page.screenshot({ path: path.join(OUT, 'resultbox-unite-helper-spared.png') });
assert.deepEqual(hp.problems, []);
await hp.page.close();
// p1 was eliminated before the round: not in `losses` → no box (and no sound), as in a round without 联防
const d = await open('shot=1&phase=SETTLE&variant=unite,dead');
await sleep(1500);
assert.equal(await d.seen(), false, 'no result box for a player not in the round');
assert.deepEqual(d.problems, []);
await d.page.close();
});
test('every battle gets one: COMBAT → SETTLE through the harness switcher pops the official dialog', async () => {
const { page, problems } = await open('phase=COMBAT');
await sleep(600);
assert.equal(await page.$('.rdialog'), null, 'no box while the battle runs');
const clicked = await page.evaluate(() => {
const btn = [...document.querySelectorAll('#mockbar button')].find((b) => b.textContent.trim() === 'SETTLE');
if (!btn) return false;
btn.click();
return true;
});
assert.ok(clicked, 'the switcher has a SETTLE button');
await page.waitForSelector('.rdialog', { timeout: 8000 });
const own = await boxState(page);
assert.equal(own.title, '作战结束', `the round's own battle, no 联防: ${JSON.stringify(own)}`);
assert.equal(own.sub, '全员无伤!');
assert.ok(own.classes.includes('rdialog--mint'));
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await page.screenshot({ path: path.join(OUT, 'resultbox-own-battle.png') });
assert.deepEqual(problems, []);
await page.close();
});
test('it closes by itself: leaving, then gone (it never blocks the next phase)', async () => {
const { page, problems } = await open('shot=1&phase=SETTLE&variant=unite');
await page.waitForSelector('.rdialog', { timeout: 10000 });
await page.waitForSelector('.rdialog.is-leaving', { timeout: 6000 });
await page.waitForFunction(() => !document.querySelector('.rdialog'), { timeout: 8000 });
assert.equal(await page.$('.rdialog'), null, 'and it leaves the DOM after ~3 s');
assert.deepEqual(problems, []);
await page.close();
});
});