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

203 lines
12 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 regressions of user playtest #5, UI items 8 / 9, through the mock harness (public/dev/game-mock.html) in
// headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/playtest5-ui.e2e.test.js
//
// 9 during prep no bench (row 7) / temp (row 8) tile is covered by DOM overlays (the shop bar, the corner buttons, the
// tools) and the field's back row is free of the bond strip — own board and Final Assault half, at the phone
// landscape viewports (844×390 and 932×430 with and without the iPhone insets, 915×412, 800×360, 914×411,
// 960×411, 924×424, and the user's Android: 756×366 — its 2772×1272 px screenshot shows the page right of a
// 141 px black cutout band, 2631×1272 px at DPR ≈ 3.48, the exit and 准备就绪 buttons matching to ±1 px — and
// 798×366 with a 41 px left inset had the page covered the cutout) and at desktop 16:9 / 16:10 / 3:2 / 21:9, where
// the official shop camera is unchanged (122 px per bench tile at 1920×1080). Each tile top's projected quad is
// sampled on a 24 × 24 grid with elementFromPoint (every HUD element hit-testable); a sample counts as covered
// when the element there paints (background, border, text, image) — v2.3 left 71 % of bench pads 4–9 free at
// 844×390, 49 % at 800×360 and 54 % at 756×366 (the user's screenshot: pads 3–9 about half covered).
// 9 an armed shop card (first tap of the two-tap buy) at 756×366 rises ≤ 5 px above the bar and covers ≤ 4 px of
// the bench pads' near corners (accepted, ui/fieldHost.js hudBands)
// 8 the corner's ⚙ is the regular gear glyph (ui/gameComponents.js gearPath), square, as large as 📖 / ⛶ (half the
// grown button on phones)
// Screenshots: test/e2e/out/p5-*.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';
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));
const IPH = (s) => ({ l: s, r: s, t: 0, b: 21 }); // iPhone landscape safe-area insets (px)
/** name, width, height, touch, insets */
const VIEWS = [
['844x390', 844, 390, true, null], ['844x390-notch', 844, 390, true, IPH(47)], ['932x430', 932, 430, true, null],
['932x430-notch', 932, 430, true, IPH(59)], ['915x412', 915, 412, true, null], ['800x360', 800, 360, true, null],
['914x411', 914, 411, true, null], ['960x411', 960, 411, true, null], ['924x424', 924, 424, true, null],
['756x366', 756, 366, true, null], ['798x366-inset41', 798, 366, true, { l: 41, r: 0, t: 0, b: 0 }],
['1920x1080', 1920, 1080, false, null], ['1680x1050', 1680, 1050, false, null], ['1560x1040', 1560, 1040, false, null],
['2560x1080', 2560, 1080, false, null],
];
const view = (name) => VIEWS.find((v) => v[0] === name);
describe('user playtest #5 — UI items 8 / 9 (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: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w, h, touch, insets }) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
if (insets) {
await page.evaluate((i) => {
const s = document.documentElement.style;
s.setProperty('--sa-l', `${i.l}px`); s.setProperty('--sa-r', `${i.r}px`); s.setProperty('--sa-t', `${i.t}px`); s.setProperty('--sa-b', `${i.b}px`);
}, insets);
await page.evaluate(() => globalThis.__SP_VIEW__.raw.resize());
}
await sleep(1800);
return { page, problems };
}
/** Free fraction of each tile top of the listed board tiles (board coordinates; the boss prep maps them itself). */
const freeFractions = (page, tiles) => page.evaluate((tiles) => {
const view = globalThis.__SP_VIEW__.raw;
const st = document.createElement('style');
st.textContent = '.gm__hud, .gm__hud * { pointer-events: auto !important; }';
document.head.appendChild(st);
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c);
const paints = (el) => {
if (!el || el.tagName === 'CANVAS' || el.closest('.gm__field')) return false;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
if (['IMG', 'svg', 'path', 'B', 'SPAN', 'KBD', 'I', 'P', 'LABEL'].includes(el.tagName)) return true;
if (!clear(cs.backgroundColor) || cs.backgroundImage !== 'none') return true;
if (['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && !clear(cs[`border${s}Color`]))) return true;
return [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
};
const inside = (poly, x, y) => {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
}
return c;
};
const out = tiles.map(([r, c]) => {
const t = view.tileScreen(r, c);
if (!t) return { r, c, free: -1 };
const xs = t.poly.map((p) => p[0]), ys = t.poly.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
let n = 0, free = 0;
const N = 24;
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
const x = x0 + (x1 - x0) * (i + 0.5) / N, y = y0 + (y1 - y0) * (j + 0.5) / N;
if (!inside(t.poly, x, y)) continue;
n++;
if (x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && !paints(document.elementFromPoint(x, y))) free++;
}
return { r, c, free: n ? free / n : 0, s: t.s };
});
st.remove();
return out;
}, tiles);
const BENCH = Array.from({ length: 10 }, (_, c) => [7, c]);
const TEMP = [4, 5, 6, 7, 8].map((c) => [8, c]);
const BACK = [4, 5, 6, 7, 8, 9, 10].map((c) => [12, c]); // the playable back row of 战场#01 (cols 0–3: forbidden)
test('9: bench, temp and the back row are free of the HUD in prep and in the Final Assault prep', { timeout: 10 * 60 * 1000 }, async () => {
const report = [];
for (const [name, w, h, touch, insets] of VIEWS) {
for (const variant of ['', 'boss']) {
const { page, problems } = await open(`phase=PREP${variant ? `&variant=${variant}` : ''}`, { w, h, touch, insets });
const tag = `${name}${variant ? ' (Final Assault prep)' : ''}`;
const bench = await freeFractions(page, BENCH);
const temp = await freeFractions(page, TEMP);
const back = await freeFractions(page, BACK);
for (const t of [...bench, ...temp]) assert.ok(t.free >= 0.99, `${tag}: ${t.r === 7 ? 'bench' : 'temp'} ${t.c} ${Math.round(t.free * 100)} % free`);
for (const t of back) assert.ok(t.free >= 0.99, `${tag}: back row ${t.c} ${Math.round(t.free * 100)} % free`);
// desktop 16:9 keeps the official shop camera
if (name === '1920x1080' && !variant) assert.ok(Math.abs(bench[5].s - 122) < 1, `1920×1080 px per bench tile ${bench[5].s}`);
report.push(`${tag}: bench ${Math.round(Math.min(...bench.map((t) => t.free)) * 100)} %, ${Math.round(bench[5].s)} px/tile`);
if (['844x390', '756x366', '800x360', '1920x1080'].includes(name)) await page.screenshot({ path: path.join(OUT, `p5-bench-${name}${variant ? '-boss' : ''}.png`) });
assert.deepEqual(problems, [], tag);
await page.close();
}
}
console.log(report.join('\n'));
});
test('9: an armed shop card (two-tap buy) barely reaches the bench on the user\'s phone', async () => {
// accepted overlap (ui/fieldHost.js hudBands): the armed card rises 4 px above the bar at 1rem = 40 px and covers
// the pads' near corners by ≈ 3 px — less than under the official camera at 1920×1080 (13 px / ≈ 11 px)
const [, w, h, touch] = view('756x366');
const { page, problems } = await open('phase=PREP', { w, h, touch });
const cards = await page.$$('.scard:not(.scard--sold)');
assert.ok(cards.length > 0, 'a shop card to arm');
await cards[0].tap();
await sleep(400);
const r = await page.evaluate(() => {
const a = document.querySelector('.scard.is-armed');
if (!a) return null;
const b = a.getBoundingClientRect(), row = document.querySelector('.shopbar__row').getBoundingClientRect();
const view = globalThis.__SP_VIEW__.raw;
let over = 0;
for (let c = 0; c < 10; c++) {
const t = view.tileScreen(7, c);
const xs = t.poly.map((p) => p[0]), y1 = Math.max(...t.poly.map((p) => p[1]));
if (Math.max(...xs) > b.left && Math.min(...xs) < b.right) over = Math.max(over, y1 - b.top);
}
return { lift: row.top - b.top, over };
});
assert.ok(r, 'the first tap arms the card');
assert.ok(r.lift <= 5, `armed card ${r.lift} px above the bar`);
assert.ok(r.over <= 4, `armed card over the bench pads by ${r.over} px`);
assert.deepEqual(problems, []);
await page.close();
});
test('8: the corner gear is the regular glyph, square and as large as its neighbours', async () => {
const { GLYPHS } = await import('../../public/js/ui/gameComponents.js');
for (const [name, w, h, touch] of [view('844x390'), view('756x366'), view('1920x1080')]) {
const { page, problems } = await open('phase=PREP', { w, h, touch });
const r = await page.evaluate(() => [...document.querySelectorAll('.gm__corner .gm__gear')].map((b) => {
const svg = b.querySelector('svg.icon');
const s = svg.getBoundingClientRect(), br = b.getBoundingClientRect();
return { cls: b.className, w: s.width, h: s.height, bw: br.width, d: svg.querySelector('path')?.getAttribute('d') };
}));
assert.equal(r.length, 3, `${name}: ⚙ 📖 ⛶`);
assert.equal(r[0].d, GLYPHS.gear, `${name}: the gear glyph`);
for (const b of r) {
assert.ok(Math.abs(b.w - b.h) < 0.01, `${name}: ${b.cls} square (${b.w}×${b.h})`);
assert.ok(b.w >= 0.45 * b.bw && b.w <= 0.55 * b.bw, `${name}: ${b.cls} icon ${b.w} in a ${b.bw} button`);
}
await page.screenshot({ path: path.join(OUT, `p5-corner-${name}.png`), clip: { x: 0, y: h - 90, width: 420, height: 90 } });
assert.deepEqual(problems, [], name);
await page.close();
}
});
});