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

234 lines
15 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 regressions of user playtest #6, UI items 6 / 10, 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/playtest6-ui.e2e.test.js
//
// 6 every 机变 card shows its whole effect text inside the card: all 51 shop items (道具补给 / 机密商店), all 129
// bounty and all 43 tactic cards, at the phone landscape viewports (the smallest supported 640×360, 667×375,
// 720×360, the user's Android 756×366, 800×360, 844×390, 932×430) and at 1280×720 / 1920×1080 — the text is not
// clamped, ends inside the card's padding, and is at least 7.5 px (phones) / .19rem (16:9); every card is measured
// once taken (the taker's round avatar at its top-right) and once not, and a taken card's name and tags never run
// under the badge — measured with the page's web font Noto Sans SC when Google Fonts can be reached (QA: with it
// 海沟实验体 took 8 lines at 640×360). v2.4.1 at 756×366: 0.2 lines of it were visible (screenshot 2).
// 10 a tap anywhere on the armed shop card (operator and item) buys, the 确认购买 strip included, on touch at
// 640×360 / 756×366 / 800×360 / 844×390 / 932×430 and with the mouse at 1920×1080: every point of a 12 × 12 grid over the
// armed card hit-tests to the card, and real taps on the strip's centre / right end and the card's bottom-right
// buy. v2.4.1: 120 of 144 points (the ⓘ corner's hidden 44 px touch area over the bottom-right quarter) — the strip's
// centre did not buy; at 640×360 the left-docked detail card opened by the first tap also covered the top quarter of
// the first cards (84 / 96 of 144).
// Screenshots: test/e2e/out/p6-*.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));
/** name, width, height, touch */
const PHONES = [['756x366', 756, 366, true], ['800x360', 800, 360, true], ['844x390', 844, 390, true], ['932x430', 932, 430, true]];
/** narrower phones (text only): the smallest supported phone (DESIGN §10, devices.e2e) and the iPhone SE / 8 */
const NARROW = [['640x360', 640, 360, true], ['667x375', 667, 375, true], ['720x360', 720, 360, true]];
/** two pick maps over the 4 mock players (p1 = me): every one of 6 cards is taken in one pass and free in the other */
const TAKEN = [{ p4: 0, p3: 1, ai_2: 2, p1: 3 }, { p4: 4, p3: 5 }];
const DESKTOP = [['1280x720', 1280, 720, false], ['1920x1080', 1920, 1080, false]];
/** public/index.html's Google Fonts stylesheet */
const WEB_FONTS = 'https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Oxanium:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap';
describe('user playtest #6 — UI items 6 / 10 (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, webFont = false }) {
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)'), { timeout: 20000 });
// the real page's web font (index.html loads it from Google Fonts; Android's CJK font has its metrics): the mock has
// no <link> for it, and the system fallback wraps differently (QA: 海沟实验体 took 8 lines at 640×360 with it) —
// measured with it whenever it can be fetched
const noto = webFont && await Promise.race([
page.addStyleTag({ url: WEB_FONTS }).then(() => page.evaluate(async () => {
await Promise.all(['400', '700', '900'].map((wt) => document.fonts.load(`${wt} 12px "Noto Sans SC"`, '获得伤害减免')));
return document.fonts.check('12px "Noto Sans SC"', '获得');
})).catch(() => false),
sleep(8000).then(() => false),
]);
await page.evaluate(() => document.fonts.ready);
await sleep(900);
return { page, problems, noto };
}
/** Each 机变 card's text: clamped?, bottom beyond the card's padding (px), font size; taken?, name / tags under the badge? */
const cardTexts = (page) => page.evaluate(() => [...document.querySelectorAll('.spcard')].map((c) => {
const r = c.getBoundingClientRect();
const d = c.querySelector('.spcard__desc');
if (!d) return { name: c.querySelector('.spcard__name')?.textContent, missing: true };
const dr = d.getBoundingClientRect();
const cs = getComputedStyle(d);
const badge = c.querySelector('.spcard__taker')?.getBoundingClientRect();
let under = false;
for (const el of badge ? c.querySelectorAll('.spcard__name, .spcard__tag') : []) {
const range = document.createRange();
range.selectNodeContents(el);
for (const a of [...range.getClientRects(), el.getBoundingClientRect()]) {
if (a.right > badge.left + 0.5 && a.left < badge.right - 0.5 && a.bottom > badge.top + 0.5 && a.top < badge.bottom - 0.5) under = true;
}
}
return { name: c.querySelector('.spcard__name')?.textContent, text: d.textContent.length, clamped: d.scrollHeight > d.clientHeight + 1,
beyond: dr.bottom - (r.bottom - parseFloat(getComputedStyle(c).paddingBottom)), font: parseFloat(cs.fontSize), rem: parseFloat(getComputedStyle(document.documentElement).fontSize),
taken: !!badge, under };
}));
test('6: every 机变 card shows its whole effect text inside the card, taken or not, on phones and on desktop', { timeout: 15 * 60 * 1000 }, async () => {
const report = [];
for (const [name, w, h, touch] of [...NARROW, ...PHONES, ...DESKTOP]) {
const { page, problems, noto } = await open('phase=SP_DRAFT&variant=supply', { w, h, touch, webFont: true });
if (name === '756x366' || name === '640x360') await page.screenshot({ path: path.join(OUT, `p6-supply-${name}.png`) });
const sets = await page.evaluate(async () => {
const { data } = await import('/js/data.js');
const ch = data.get('choices');
return {
supply: data.list('items').filter((i) => !i.isGolden && i.itemType === 'EQUIP' && !i.shopExcluded).map((i) => ({ itemId: i.id })),
bounty: ch.cards.bounty.map((b) => ({ effectId: b.effectId })),
tactic: ch.cards.tactic.map((t) => ({ effectId: t.effectId })),
};
});
for (const [family, list] of Object.entries(sets)) {
let n = 0;
let taken = 0;
for (let i = 0; i < list.length; i += 6) {
for (const picks of TAKEN) {
await page.evaluate((cards, family, picks) => {
const S = globalThis.__MOCK__.S();
S.pub.sp = { ...S.pub.sp, family, cards, picks };
globalThis.__MOCK__.pushPublic();
}, list.slice(i, i + 6), family, picks);
await sleep(120);
for (const t of await cardTexts(page)) {
n++;
if (t.taken) taken++;
const tag = `${name} ${family} ${t.name}${t.taken ? ' (taken)' : ''}`;
assert.ok(!t.missing && t.text > 0, `${tag}: an effect text`);
assert.ok(!t.clamped, `${tag}: the whole text (not clamped)`);
assert.ok(t.beyond <= 0.5, `${tag}: inside the card (${t.beyond.toFixed(1)} px beyond its padding)`);
assert.ok(touch ? t.font >= 7.5 : t.font >= 0.19 * t.rem - 0.01, `${tag}: ${t.font} px`);
assert.ok(!t.under, `${tag}: the name and tags keep clear of the taker's badge`);
}
}
}
assert.equal(n, list.length * TAKEN.length, `${name} ${family}: every card rendered`);
assert.equal(taken, list.length, `${name} ${family}: every card measured taken once`);
}
report.push(`${name}: ${Object.values(sets).reduce((a, l) => a + l.length, 0)} texts in full, taken or not (${noto ? 'Noto Sans SC' : 'system font — Google Fonts unreachable'})`);
assert.deepEqual(noto ? problems : problems.filter((x) => !/Failed to load resource/.test(x)), [], name);
await page.close();
}
console.log(report.join('\n'));
});
test('6: the taken-by badge and the two-tap strip stay on the new card (756×366)', async () => {
const { page, problems } = await open('phase=SP_DRAFT&variant=supply', { w: 756, h: 366, touch: true });
assert.ok(await page.$('.spcard.is-taken .spcard__taker'), 'the taker\'s badge');
const tap = async (sel) => {
const r = await page.$eval(sel, (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.bottom - 4 }; });
await page.touchscreen.tap(r.x, r.y);
await sleep(350);
};
await tap('.spcard.is-pickable');
assert.ok(await page.$('.spcard.is-armed .spcard__confirm'), 'first tap: selected, 确认选择 strip');
await page.screenshot({ path: path.join(OUT, 'p6-supply-armed-756x366.png') });
await tap('.spcard.is-armed'); // on the strip
await page.waitForSelector('.spcard.is-mine', { timeout: 3000 });
assert.deepEqual(problems, []);
await page.close();
});
/** Arm the first buyable card matching `sel`, then hit-test a 12 × 12 grid over it: a point counts when the tap target
* there (the nearest button / role=button) is the armed card itself — not an inner control such as the old ⓘ, and not
* the detail card its first tap opened. */
async function armAndProbe(page, sel, touch) {
const tapAt = async (x, y) => { if (touch) await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); await sleep(400); };
const b = await page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; });
await tapAt(b.x + b.w * 0.85, b.y + b.h * 0.85); // the old ⓘ corner: arms like the rest of the card
return page.evaluate(() => {
const a = document.querySelector('.scard.is-armed');
if (!a) return null;
const r = a.getBoundingClientRect();
const N = 12;
let hit = 0;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const el = document.elementFromPoint(r.left + (i + 0.5) * r.width / N, r.top + (j + 0.5) * r.height / N);
if (el && el.closest('button, [role="button"], a, input') === a) hit++;
}
const s = a.querySelector('.scard__confirm')?.getBoundingClientRect();
return { hit, n: N * N, box: { x: r.left, y: r.top, w: r.width, h: r.height }, strip: s && { x: s.left, y: s.top, w: s.width, h: s.height } };
});
}
test('10: a tap anywhere on the armed shop card buys — the 确认购买 strip included (touch on phones, mouse on desktop)', { timeout: 10 * 60 * 1000 }, async () => {
const report = [];
const SPOTS = ['strip centre', 'strip right', 'card bottom-right'];
for (const [name, w, h, touch] of [NARROW[0], ...PHONES, DESKTOP[1]]) {
const { page, problems } = await open('phase=PREP', { w, h, touch });
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; S.priv.hand = S.priv.hand.map(() => null); }));
await sleep(200);
for (const [kind, sel] of [['operator', '.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)'], ['item', '.shopbar__item .scard:not(.scard--sold):not(.is-disabled)']]) {
for (const spot of SPOTS) {
// a fresh card for every spot: the next operator card; the item slot comes back with a refresh (R)
if (!(await page.$(sel))) { await page.keyboard.press('KeyR'); await sleep(350); }
const slotIdx = await page.evaluate((s) => {
const el = document.querySelector(s);
const S = globalThis.__MOCK__.S();
const isItem = !!el.closest('.shopbar__item');
const cards = [...document.querySelectorAll(isItem ? '.shopbar__item .scard' : '.shopbar__cards .scard')];
const slots = S.priv.shop.slots.map((x, i) => ({ x, i })).filter(({ x }) => (isItem ? x && x.kind === 'item' : !x || x.kind !== 'item'));
return slots[cards.indexOf(el)]?.i ?? null;
}, sel);
assert.ok(Number.isInteger(slotIdx), `${name} ${kind}: a card to buy`);
const probe = await armAndProbe(page, sel, touch);
assert.ok(probe, `${name} ${kind}: the card is armed by a tap on its bottom-right`);
assert.equal(probe.hit, probe.n, `${name} ${kind}: ${probe.hit}/${probe.n} points of the armed card are the card`);
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
const { box, strip } = probe;
const at = spot === 'strip centre' ? [strip.x + strip.w / 2, strip.y + strip.h / 2]
: spot === 'strip right' ? [strip.x + strip.w * 0.9, strip.y + strip.h * 0.6] : [box.x + box.w * 0.94, box.y + box.h * 0.94];
if (touch) await page.touchscreen.tap(at[0], at[1]); else await page.mouse.click(at[0], at[1]);
await sleep(450);
const after = await page.evaluate((i) => ({ funds: globalThis.__MOCK__.S().priv.funds, sold: !!globalThis.__MOCK__.S().priv.shop.slots[i]?.sold }), slotIdx);
assert.ok(after.sold && after.funds < funds0, `${name} ${kind}: a ${touch ? 'tap' : 'click'} on the ${spot} buys`);
if (spot === 'strip centre' && kind === 'operator') report.push(`${name}: ${probe.hit}/${probe.n}`);
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; S.priv.hand = S.priv.hand.map(() => null); }));
await sleep(150);
}
}
assert.deepEqual(problems, [], name);
await page.close();
}
console.log(report.join('\n'));
});
});