Files
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

173 lines
9.0 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 check of 设置 →「文字大小」 (opt-in, needs Chrome, ~30 s):
// SP_E2E=1 CHROME_PATH=… node --test test/ui/text-scale.e2e.test.js
//
// A 844×390 landscape phone: 1rem is clamped at 40 px, so the .18rem body text is 7.2 CSS px. The setting steps the text
// root `--t` (css/theme.css) instead of the layout root: the readable text grows, while `documentElement`'s font-size,
// the modal's rem-sized box and — through the dev mock's 休整期 screen — the field host, the canvas and the shop bar stay
// exactly where they were. Static counterpart: test/ui/text-scale.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const PHONE = { width: 844, height: 390 };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** The settings the mock harness reads: `sp.pref.settings` with one text step (the argument is passed into the page —
* evaluateOnNewDocument cannot see a closure). */
const seedSettings = (page, textSize) => page.evaluateOnNewDocument((ts) => {
try { localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })); } catch { /* ignore */ }
}, textSize);
describe('文字大小 on a phone (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', '--force-device-scale-factor=1'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** A page watching for console / page errors, with `sp.pref.settings` seeded before the first script runs. */
async function open(url, textSize, { seed = true } = {}) {
const page = await browser.newPage();
await page.setViewport(PHONE);
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
if (seed && textSize) await seedSettings(page, textSize);
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
return { page, problems };
}
/** What the settings modal shows at this step: the roots, a readable label, its Latin micro label, the box. */
const modalMetrics = (page) => page.evaluate(() => {
const px = (sel, prop = 'fontSize') => {
const el = document.querySelector(sel);
return el ? parseFloat(getComputedStyle(el)[prop]) : null;
};
const box = document.querySelector('.modal__box')?.getBoundingClientRect();
const body = document.querySelector('.modal__body');
const on = document.querySelector('[data-testid="text-size"] button.is-on');
// nothing may stick out of the modal's box (the label wraps and the four steps fit its row)
let outRight = 0;
for (const el of document.querySelectorAll('.modal__box *')) {
const r = el.getBoundingClientRect();
if (box && r.width > 0) outRight = Math.max(outRight, r.right - box.right);
}
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
label: px('.set-row__label'),
micro: px('.set-row__label .micro'),
modalWidth: box ? Math.round(box.width) : null,
bodyOverflowX: body ? body.scrollWidth - body.clientWidth : null,
outRight: Math.round(outRight),
step: on?.textContent ?? null,
hint: document.querySelector('.set-textsize-note')?.textContent ?? null,
};
});
test('the steps grow the readable text; the layout root and the modal box do not move', async () => {
const { page, problems } = await open(`${base}/`, null, { seed: false });
await page.waitForSelector('input', { timeout: 15000 });
await page.type('input', '文字大小');
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
await sleep(200);
const small = await modalMetrics(page);
assert.equal(small.step, '小', 'the default is the design\'s own sizes');
assert.equal(small.root, 40, 'a landscape phone clamps the layout root at 40 px');
assert.ok(Math.abs(small.label - 0.18 * 40) < 0.1, `the body text starts at the reported 7.2 px (${small.label})`);
assert.ok(small.hint && /棋盘/.test(small.hint), 'the row says the layout is not touched');
const step = async (name) => {
await page.evaluate((n) => {
const btn = [...document.querySelectorAll('[data-testid="text-size"] button')].find((b) => b.textContent.trim() === n);
btn?.click();
}, name);
await sleep(200);
return modalMetrics(page);
};
const md = await step('中');
const lg = await step('大');
const xl = await step('特大');
assert.equal(md.step, '中');
assert.equal(xl.step, '特大', 'the pick sticks');
assert.ok(md.label > small.label * 1.5 && lg.label > md.label && xl.label > lg.label,
`the readable text grows step by step (${[small.label, md.label, lg.label, xl.label].join(' → ')})`);
assert.ok(Math.abs(xl.label - 0.18 * 88) < 0.2, `特大 reaches the 88 px text root (${xl.label})`);
assert.ok(xl.micro > small.micro * 1.9, `the 4.4 px micro labels follow (${small.micro} → ${xl.micro})`);
for (const m of [md, lg, xl]) {
assert.equal(m.root, 40, 'the layout root never moves: the field camera and the DOM board read 1rem');
assert.equal(m.modalWidth, small.modalWidth, 'the modal\'s box is sized in rem and does not change');
assert.ok(m.bodyOverflowX <= 1, `the rows do not overflow the modal body (${m.bodyOverflowX} px)`);
assert.ok(m.outRight <= 1, `nothing sticks out of the modal's box (${m.outRight} px): the label wraps and the four steps fit`);
}
// saved with the settings and restored on the next visit
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.settings')).textSize), 'xl');
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
const again = await modalMetrics(page);
assert.equal(again.step, '特大', 'the choice survives a reload');
assert.equal(again.root, 40);
assert.deepEqual(problems, []);
await page.close();
});
test('the in-match field, canvas and shop bar do not move; the settings dialog in a match grows', async () => {
const measure = async (textSize) => {
const { page, problems } = await open(`${base}/dev/game-mock.html?shot=1&phase=PREP&variant=settings`, textSize);
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
await sleep(900);
const m = await page.evaluate(() => {
const box = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
};
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
field: box('.gm__field'),
canvas: box('.gm__field canvas'),
shopbar: box('.gm__hud .shopbar'),
label: (() => { const el = document.querySelector('.set-row__label'); return el ? parseFloat(getComputedStyle(el).fontSize) : null; })(),
};
});
return { ...m, problems, page };
};
const small = await measure('sm');
const big = await measure('xl');
assert.ok(small.field && small.shopbar, 'the prep screen with its HUD is on screen');
assert.equal(small.root, 40);
assert.equal(big.root, 40, 'the text step never reaches the root');
assert.deepEqual(big.field, small.field, 'the field host (host.clientWidth / clientHeight) is unchanged');
assert.deepEqual(big.canvas, small.canvas, 'and so is the canvas the renderer resizes to it');
assert.deepEqual(big.shopbar, small.shopbar, 'the shop bar is a fixed HUD box: still the design\'s size');
assert.ok(big.label > small.label * 1.9, `while the dialog's readable text grows (${small.label} → ${big.label})`);
assert.deepEqual(small.problems, []);
assert.deepEqual(big.problems, []);
await small.page.close();
await big.page.close();
});
});