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.
This commit is contained in:
1 parent
314564c139
commit
f3658a0dd4
104 files changed
+3674
-741
No files matched your search
+143
-1
@@ -367,7 +367,7 @@ describe('operator battle voice', () => {
|
||||
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
|
||||
// the settings store hands the choice over (installAudio and ui/settings.js)
|
||||
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang, s\.voiceOverrides\)/);
|
||||
assert.match(settings, /t\('语音语言'\)/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -870,6 +870,148 @@ describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 司霆惊蛰's S3 attack had no sound: she is 解放者 (she only attacks while a skill runs) and the official client has no
|
||||
// normal-mode bank of hers at all — every attack of hers is a skill mode's (`attacks`, tools/assets/audio.mjs
|
||||
// pickModeAttacks). 赤刃明霄陈's S3 slashes are the other half of the same idea: the mode's IMPACT differs too (`hits`,
|
||||
// pickModeHits) — without it her S3 still landed with p_imp_hljdswd_n, i.e. it sounded like her normal attack. Both
|
||||
// tables play only while that skill is active (the ['skill', id, 1] / 0 events).
|
||||
describe('the running skill\'s own attack / impact sound (sfx.units[id].attacks / hits)', () => {
|
||||
const LEIZI2 = 'char_1043_leizi2';
|
||||
const CHEN3 = 'char_1050_chen3';
|
||||
const enemyId = () => Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack);
|
||||
async function rig(units) {
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.setFieldUnits(units);
|
||||
const settle = () => new Promise((r) => setTimeout(r, 5));
|
||||
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
const clear = (a) => { a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear(); };
|
||||
// …and forget the decoded buffers too: these assertions count REQUESTS, and a sound the manager already holds is
|
||||
// played from the cache without a second fetch (a real battle hears it either way).
|
||||
const fresh = (a) => { clear(a); a.buffers.clear(); };
|
||||
/** One attack plus the impact that belongs to it (the dmg inside IMPACT_WINDOW_MS). */
|
||||
const swingAndHit = (a) => a.handleBattleEvents([['atk', 1, 2, 'none'], ['dmg', 2, 120, 'phys']]);
|
||||
|
||||
|
||||
test('司霆惊蛰 S3: her attack rings the S3 mode\'s file while the skill runs, and only then', async () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.equal(u.attack, undefined, '前提:她没有普通形态的攻击音(这正是那次「没有音效」)');
|
||||
assert.ok(u.attacks['2'] && /_s\.mp3$/.test(u.attacks['2']), '前提:清单带 S3 形态的攻击音');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
// 技能没开(解放者本就不攻击,模拟里也不会发 atk;真发了也仍然是静的)
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 0, '没有技能就没有她的攻击音');
|
||||
// S3 开启 → ['skill', id, 1]
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
await settle();
|
||||
const before = askedCount(urls, u.attacks['2']);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, 'S3 期间她的攻击响 S3 的形态音');
|
||||
assert.ok(!asked(urls, u.skill), '技能发动音是另一条事件(这次没发),别混进攻击音');
|
||||
// 重复的 atk 会被同键冷却挡(既有口径,这里确认它没被新分支绕过)
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '同一单位 160 ms 内只响一次');
|
||||
// S3 结束 → ['skill', id, 0]
|
||||
clear(a);
|
||||
a.handleBattleEvents([['skill', 1, 0]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '技能结束后又静音');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a unit with both banks: the mode file inside the skill, its normal attack outside it', async () => {
|
||||
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].attacks?.['2']);
|
||||
const u = manifest.audio.sfx.units[id];
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 技能外是普通攻击音`);
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 0, `${id}: 技能外不响形态音`);
|
||||
clear(a);
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, `${id}: 技能期间响形态音`);
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 形态音取代、而不是叠加普通攻击音`);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a unit whose manifest has no attacks table for that slot keeps its normal bank (and no crash)', async () => {
|
||||
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && !manifest.audio.sfx.units[k].attacks?.['2']);
|
||||
const u = manifest.audio.sfx.units[id];
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 没有形态音就用普通攻击音`);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('赤刃明霄陈 S3: 挥砍与斩击都用 S3 的 `_s` 对,技能外回到普通 `_n` 对', async () => {
|
||||
const u = manifest.audio.sfx.units[CHEN3];
|
||||
assert.ok(u.attacks['2'] && u.hits['2'] && /_s\.mp3$/.test(u.attacks['2']) && /_s\.mp3$/.test(u.hits['2']), '前提:清单带 S3 的挥砍与斩击');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: CHEN3, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
fresh(a);
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, '技能外:普通挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, '技能外:普通命中');
|
||||
fresh(a);
|
||||
a.handleBattleEvents([['skill', 1, 1]]); // S3 开启
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3:挥砍换成 S3 的');
|
||||
assert.equal(askedCount(urls, u.hits['2']), 1, 'S3:斩击(命中)也换成 S3 的');
|
||||
assert.equal(askedCount(urls, u.attack), 1, 'S3:没有叠加普通挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, 'S3:没有叠加普通命中');
|
||||
fresh(a);
|
||||
a.handleBattleEvents([['skill', 1, 0]]); // S3 结束
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 2, '技能结束:普通挥砍回来了');
|
||||
assert.equal(askedCount(urls, u.hit), 2, '技能结束:普通命中回来了');
|
||||
assert.equal(askedCount(urls, u.hits['2']), 1, '技能结束:不再用 S3 的斩击');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a mode without a hits entry keeps the unit\'s normal impact while the skill runs', async () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.ok(u.attacks['2'] && !u.hits?.['2'], '前提:她的 S3 有挥砍、没有命中 bank(官方就没有)');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3 的挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, '命中仍是普通的那条');
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
|
||||
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
|
||||
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);
|
||||
describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
|
||||
let server, browser, base;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await server?.close(); });
|
||||
async function pageIn(ctx, phone = false) {
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
|
||||
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
|
||||
return page;
|
||||
}
|
||||
test('closed second window resumes the same server player and match through the recovery UI', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const a = await pageIn(ctx), b = await pageIn(ctx);
|
||||
const before = await b.evaluate(async () => {
|
||||
const { net, store } = globalThis.__SP__;
|
||||
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
||||
await net.request('room.start', {});
|
||||
return { id: store.get().me.playerId, code: store.get().room.code };
|
||||
});
|
||||
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
await a.waitForSelector('[data-testid="resume-local-match"]');
|
||||
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
|
||||
assert.equal(refused, false, 'live window is not displaced');
|
||||
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
|
||||
await b.close();
|
||||
await a.click('[data-testid="resume-local-match"]');
|
||||
await a.waitForSelector('.modal .btn--block');
|
||||
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
|
||||
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
|
||||
assert.deepEqual(after, before);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const host = await pageIn(ctx), guest = await pageIn(ctx);
|
||||
const code = await host.evaluate(async () => {
|
||||
const { net, store } = globalThis.__SP__;
|
||||
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
|
||||
return store.get().room.code;
|
||||
});
|
||||
await guest.evaluate(async (code) => {
|
||||
const { net } = globalThis.__SP__;
|
||||
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
|
||||
}, code);
|
||||
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||||
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
|
||||
await host.waitForSelector('.brief-reroll button');
|
||||
await host.bringToFront();
|
||||
await host.click('.brief-reroll button');
|
||||
await guest.waitForSelector('.brief-reroll .btn--primary');
|
||||
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
|
||||
await guest.bringToFront();
|
||||
await guest.click('.brief-reroll .btn--primary');
|
||||
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
|
||||
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
|
||||
assert.equal(state.rerollVote, null);
|
||||
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
|
||||
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
|
||||
// Wait out the documented server anti-burst interval before a second request.
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await host.bringToFront();
|
||||
await host.click('.brief-reroll button');
|
||||
await guest.waitForSelector('.brief-reroll .btn--danger');
|
||||
await guest.bringToFront();
|
||||
await guest.click('.brief-reroll .btn--danger');
|
||||
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
|
||||
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('operator voice selection persists across reload and follows global after reset', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const page = await pageIn(ctx, true);
|
||||
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
|
||||
await open();
|
||||
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
|
||||
await page.select('[data-voice-char] select', 'jp');
|
||||
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
|
||||
await open();
|
||||
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
|
||||
await page.select('[data-voice-char] select', '');
|
||||
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
|
||||
const ctx = browser.defaultBrowserContext();
|
||||
let page;
|
||||
try {
|
||||
page = await pageIn(ctx);
|
||||
const client = await page.createCDPSession();
|
||||
const manifest = await client.send('Page.getAppManifest');
|
||||
assert.deepEqual(manifest.errors, []);
|
||||
const install = await client.send('Page.getInstallabilityErrors');
|
||||
assert.deepEqual(install.installabilityErrors, []);
|
||||
await page.evaluate(() => {
|
||||
const e = new Event('beforeinstallprompt', { cancelable: true });
|
||||
window.promptCalls = 0;
|
||||
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
await page.waitForSelector('[data-testid="pwa-install"]');
|
||||
await page.click('[data-testid="pwa-install"]');
|
||||
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
|
||||
assert.equal(await page.evaluate(() => window.promptCalls), 1);
|
||||
} finally { await page?.close(); }
|
||||
});
|
||||
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const page = await pageIn(ctx, true);
|
||||
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
|
||||
await page.evaluate(async (lang) => {
|
||||
await (await import('/js/ui/lang.js')).switchLang(lang);
|
||||
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
|
||||
}, lang);
|
||||
await page.click('[data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list');
|
||||
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
|
||||
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
|
||||
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
|
||||
}
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
});
|
||||
@@ -723,7 +723,7 @@ describe('keyboard & settings', () => {
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
|
||||
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createPwaInstall } from '../../public/js/pwa.js';
|
||||
function rig(standalone = false) {
|
||||
const win = new EventTarget(), display = new EventTarget();
|
||||
display.matches = standalone;
|
||||
win.matchMedia = () => display;
|
||||
const pwa = createPwaInstall(win);
|
||||
const offer = (outcome = 'dismissed', fail = false) => {
|
||||
let calls = 0;
|
||||
const e = new Event('beforeinstallprompt', { cancelable: true });
|
||||
e.prompt = async () => { calls++; if (fail) throw Error('unavailable'); return { outcome }; };
|
||||
win.dispatchEvent(e);
|
||||
return { calls: () => calls, e };
|
||||
};
|
||||
return { pwa, win, display, offer };
|
||||
}
|
||||
test('install offer is consumed even on dismissal, duplicate clicks, or rejected prompt', async () => {
|
||||
const { pwa, offer } = rig();
|
||||
const states = []; const off = pwa.subscribe((x) => states.push(x));
|
||||
assert.equal(pwa.available(), false);
|
||||
const first = offer();
|
||||
assert.equal(first.e.defaultPrevented, true);
|
||||
assert.equal(pwa.available(), true);
|
||||
const a = pwa.request(), b = pwa.request();
|
||||
assert.equal(await a, false); assert.equal(await b, false); assert.equal(first.calls(), 1);
|
||||
const broken = offer('accepted', true);
|
||||
assert.equal(await pwa.request(), false); assert.equal(await pwa.request(), false); assert.equal(broken.calls(), 1);
|
||||
const next = offer('accepted');
|
||||
assert.equal(await pwa.request(), true); assert.equal(next.calls(), 1);
|
||||
assert.deepEqual(states, [false, true, false, true, false, true, false]);
|
||||
off(); pwa.dispose();
|
||||
});
|
||||
test('installed / standalone hides stale offers; disposing removes listeners', async () => {
|
||||
const { pwa, win, display, offer } = rig();
|
||||
offer(); display.matches = true; display.dispatchEvent(new Event('change'));
|
||||
assert.equal(pwa.available(), false); assert.equal(await pwa.request(), false);
|
||||
display.matches = false; win.dispatchEvent(new Event('appinstalled')); offer();
|
||||
assert.equal(pwa.available(), false);
|
||||
pwa.dispose();
|
||||
assert.equal(offer().e.defaultPrevented, false);
|
||||
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
|
||||
});
|
||||
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
|
||||
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
|
||||
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
|
||||
for (const icon of m.icons) {
|
||||
assert.ok(icon.src.startsWith('/icons/'));
|
||||
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
|
||||
const size = Number(icon.sizes.split('x')[0]);
|
||||
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AudioManager } from '../../public/js/audio.js';
|
||||
import { indexAudio, pickModeAttacks } from '../../tools/assets/audio.mjs';
|
||||
import { buildPlan } from '../../tools/assets/plan.mjs';
|
||||
|
||||
const id = 'char_1043_leizi2';
|
||||
function rig() {
|
||||
const entry = { attack: '/normal_n.mp3', hit: '/normal_hit_n.mp3', attacks: { 2: '/mode_s.mp3' }, hits: { 2: '/impact_s.mp3' }, mix: { attack: { p: 0 }, hit: { vol: 0.1 } }, attackMix: { 2: { vol: 0.6 } } };
|
||||
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { units: { [id]: entry } } } }), random: () => 0.5 });
|
||||
a.ctx = {};
|
||||
const played = [];
|
||||
a._play = (url, opts) => played.push({ url, ...opts });
|
||||
const units = [{ id: 1, spine: id, side: 'ally', kind: 'chess', skillIndex: 2 }, { id: 2, spine: 'enemy_1_test', side: 'enemy' }];
|
||||
a.setFieldUnits(units);
|
||||
return { a, entry, played, units };
|
||||
}
|
||||
|
||||
test('mode uses its own chance and volume, never the normal bank mix', () => {
|
||||
const { a, entry, played } = rig();
|
||||
a.unit(id, 'attack', 1, 2, true);
|
||||
a.unit(id, 'hit', 1, 2, true);
|
||||
assert.deepEqual(played.map((p) => [p.url, p.volume]), [['/mode_s.mp3', 0.33], ['/impact_s.mp3', 0.55]]);
|
||||
entry.attackMix[2].p = 0;
|
||||
a.unit(id, 'attack', 1, 2, true);
|
||||
assert.equal(played.length, 2, 'silent mode does not fall back to normal');
|
||||
});
|
||||
|
||||
test('an in-flight impact remembers the mode at attack time after skill end', () => {
|
||||
const { a, played } = rig();
|
||||
a.handleBattleEvents([['skill', 1, 1], ['atk', 1, 2, 'none'], ['skill', 1, 0], ['dmg', 2, 10, 'phys']]);
|
||||
assert.deepEqual(played.map((p) => p.url), ['/mode_s.mp3', '/impact_s.mp3']);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
});
|
||||
|
||||
test('end before spawn cancels a queued start; field changes discard stale modes', () => {
|
||||
const { a, played, units } = rig();
|
||||
a.setFieldUnits([]);
|
||||
a.handleBattleEvents([['skill', 1, 1], ['skill', 1, 0], ['spawn', units[0]], ['atk', 1, 2, 'none']]);
|
||||
assert.equal(a.pendingSkill.size, 0);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
assert.equal(played.length, 0);
|
||||
a.setFieldUnits([{ ...units[0], skillActive: true }, units[1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
assert.equal(played.at(-1).url, '/mode_s.mp3', 'late join snapshot restores active mode');
|
||||
a.setFieldUnits(units);
|
||||
assert.equal(a.lastAttacker.size, 0);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
});
|
||||
|
||||
test('mixed mode banks are not guessed; the selected official bank retains its mix in the plan', () => {
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', ['/a_s.mp3', '/b_h.mp3']]])), {});
|
||||
const audio = indexAudio({ soundFXBanks: [{ name: `battle.ON_ABILITY_START.${id}.attack.7`, sounds: [
|
||||
{ asset: 'Audio/Sound_Beta_2/Player/p_atk/test_s', weight: 1, minVolume: 0.6, maxVolume: 0.6 },
|
||||
{ asset: '', weight: 1 },
|
||||
] }] });
|
||||
const { template } = buildPlan({ assets07: {}, ops03: {}, extraOperators: { [id]: { skills: [{ index: 2, skillId: 'test' }] } }, enemies05: {}, maps05: {}, audio, modelsData: {} });
|
||||
assert.deepEqual(template.audio.sfx.units[id].attackMix, { 2: { p: 0.5, vol: 0.6 } });
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// 设置 →「文字大小」 (the owner's decision of 2026-10-09): the interface text root `--t` of css/theme.css, stepped by
|
||||
// the settings store (`sp.pref.settings` textSize), applied to <html data-text> by ui/settings.js applyTextSize.
|
||||
// A phone clamps the LAYOUT root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px, while the
|
||||
// browser's own font settings only inflate glyphs inside fixed boxes and page zoom is off (index.html's viewport,
|
||||
// ui/device.js). Only font-size declarations read `--t`: the five modules that turn `1rem` into pixels — ui/fieldHost.js
|
||||
// hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js panelSlots (the detail card's side),
|
||||
// ui/underframe.js (the tile labels' budget) and ui/fallbackField.js (the DOM board) — must never move with it, and the
|
||||
// field itself is sized from the host element's clientWidth (render/app.js).
|
||||
//
|
||||
// What this file guards: the default is the design's own sizes; a bad or missing value falls back to 小; the step lands
|
||||
// on <html data-text>; the layout root's clamp is untouched; and every font-size that stayed on a plain `rem` is one of
|
||||
// the deliberate exclusions (the in-match HUD's fixed boxes, the board-pinned DOM text, geometry-derived sizes, the
|
||||
// ≥16 px iOS-zoom guards) — a new readable size that forgets `--t` fails here, the way `tools/i18n.mjs extract 0` fails
|
||||
// on an unwrapped string. Browser counterpart: test/ui/text-scale.e2e.test.js (SP_E2E=1).
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DEFAULT_SETTINGS, TEXT_SIZES, sanitizeSettings } from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const CSS = 'public/css';
|
||||
|
||||
/** Every stylesheet under public/css (index.html loads all of them, devices.css last), as { rel, src }. */
|
||||
function stylesheets() {
|
||||
const out = [];
|
||||
const visit = (rel) => {
|
||||
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
|
||||
const r = `${rel}/${e.name}`;
|
||||
if (e.isDirectory()) visit(r);
|
||||
else if (r.endsWith('.css')) out.push({ rel: r, src: read(r).replace(/\/\*[\s\S]*?\*\//g, '') });
|
||||
}
|
||||
};
|
||||
visit(CSS);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rules as { selector, body } (flat; @media blocks are walked into, as test/client-static.test.js does). */
|
||||
function cssRules(src) {
|
||||
const out = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(src))) out.push({ selector: m[1].trim(), body: m[2] });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The font sizes that deliberately stay on a plain `rem` (or a bare px): the in-match HUD chrome in fixed boxes, the
|
||||
* DOM text pinned onto board tiles, the geometry-derived sizes and the ≥16 px iOS-zoom guards. Listed by selector, the
|
||||
* same list the codemod used — a rule that stops being an exclusion (or a new one that should have used `--t`) shows up
|
||||
* as a failure with its file and selector.
|
||||
*/
|
||||
const NOT_SCALED = [
|
||||
// pinned onto board tiles by JS-computed pixels (ui/standInTags.js, ui/underframe.js)
|
||||
/\.sitag/, /\.tempnote/, /\.uframe/, /\.ff-/,
|
||||
// in-match HUD chrome whose box the design fixed (a later pass: shop cards, top bar, bond strip, corner buttons)
|
||||
/\.gm__/, /\.roundbox/, /\.capsule/, /\.lp\b/, /\.lp--/, /\.misstag/, /\.bossbar/, /\.readywrap/,
|
||||
/\.bstrip/, /\.bslot/, /\.effect/, /\.efftip/, /\.ebubble/, /\.ticker/, /\.dpbox/, /\.vswitch/,
|
||||
/\.specpill/, /\.otwarn/, /\.team/, /\.enemybtn/, /\.readybtn/, /\.ewheel/, /\.fwheel/, /\.chud/, /\.toolbtn/,
|
||||
/\.shopbar/, /\.scard/, /\.scr\b/,
|
||||
// text inside a fixed-height bar (the HP bar of the detail card, the boss bar's fill)
|
||||
/\.dhp\b/,
|
||||
// the 机变 card: its grid fits 7 lines of the effect text (game-panels.css), so a bigger font would clamp text away
|
||||
/\.spcard/,
|
||||
// decorative wordmarks and boot/rotate overlays whose size is the composition
|
||||
/\.title-cn/, /\.title-en/, /\.title-bg__target/, /\.brief-boss__q/, /\.boot__/, /\.rotate-hint/,
|
||||
// the join code's letter-spaced field, the px-floored 本局信息 dialog, the 16 px diagnostics box (iOS zoom guard)
|
||||
/\.field--code/, /\.minfo-dlg/, /\.set-diag__text/,
|
||||
// the keyboard hints of the 快捷键 list (a shared rule with the HUD's .toolbtn kbd)
|
||||
/\.set-hint kbd/,
|
||||
];
|
||||
|
||||
const notScaled = (selector) => NOT_SCALED.some((re) => re.test(selector));
|
||||
|
||||
/**
|
||||
* Whole stylesheets a later pass owns: the shop bar's fixed cards (their height feeds the prep camera's HUD band,
|
||||
* ui/fieldHost.js HUD_REM), and the 干员调配 / 统计 screens, whose text is already px-floored (`max(.15rem, 11px)`) and
|
||||
* therefore readable on a phone as it is. Scaling them is step 2/3 of the change, not a forgotten line.
|
||||
*/
|
||||
const NOT_SCALED_FILES = [`${CSS}/screens/game-shop.css`, `${CSS}/screens/loadout.css`, `${CSS}/screens/stats.css`];
|
||||
|
||||
/** A font-size value that is neither scaled nor an exclusion: a plain `rem`, or `max(<rem>, …)`. */
|
||||
function plainRem(value) {
|
||||
if (/var\(/.test(value)) return false; // --t, or a geometry-derived size
|
||||
if (/(^|[^r])em\b|%/.test(value.replace(/rem\b/g, ''))) return false;
|
||||
return /^\s*(max\(\s*)?\d*\.?\d+rem\b/.test(value);
|
||||
}
|
||||
|
||||
describe('文字大小: the setting', () => {
|
||||
test('the default is 小 (the design\'s own sizes) and a saved profile without it keeps it', () => {
|
||||
assert.deepEqual([...TEXT_SIZES], ['sm', 'md', 'lg', 'xl'], 'the steps, in the settings order');
|
||||
assert.equal(DEFAULT_SETTINGS.textSize, 'sm');
|
||||
assert.equal(sanitizeSettings(null).textSize, 'sm');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.4 }).textSize, 'sm', 'a profile saved before the setting existed');
|
||||
});
|
||||
|
||||
test('every step survives sanitising; anything else falls back to 小', () => {
|
||||
for (const v of TEXT_SIZES) assert.equal(sanitizeSettings({ textSize: v }).textSize, v);
|
||||
for (const bad of ['huge', 'SM', 'xl2', 'md ', 1, true, null, undefined, {}, []]) {
|
||||
assert.equal(sanitizeSettings({ textSize: bad }).textSize, 'sm', String(bad));
|
||||
}
|
||||
assert.equal(sanitizeSettings({ textSize: 'xl', quality: 'low' }).quality, 'low', 'the other settings are untouched');
|
||||
});
|
||||
|
||||
test('applyTextSize writes the step to <html data-text>, and 小 when it is not a step', async () => {
|
||||
const { applyTextSize } = await import('../../public/js/ui/settings.js');
|
||||
const el = { dataset: {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = { documentElement: el };
|
||||
try {
|
||||
for (const v of TEXT_SIZES) {
|
||||
applyTextSize(v);
|
||||
assert.equal(el.dataset.text, v);
|
||||
}
|
||||
for (const bad of ['', 'big', null, undefined, 42]) {
|
||||
applyTextSize(bad);
|
||||
assert.equal(el.dataset.text, 'sm', String(bad));
|
||||
}
|
||||
applyTextSize(undefined);
|
||||
assert.equal(el.dataset.text, 'sm', 'the attribute is always written, never left stale');
|
||||
} finally {
|
||||
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('the store applies the step on every change, and at boot before the first render', async () => {
|
||||
const { settingsStore, updateSettings } = await import('../../public/js/ui/settings.js');
|
||||
const el = { dataset: {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = { documentElement: el };
|
||||
try {
|
||||
updateSettings({ textSize: 'lg' });
|
||||
assert.equal(el.dataset.text, 'lg', 'picking a step re-renders the text immediately');
|
||||
assert.equal(settingsStore.get().textSize, 'lg', 'and it is saved with the settings (sp.pref.settings)');
|
||||
updateSettings({ textSize: 'sm' });
|
||||
assert.equal(el.dataset.text, 'sm');
|
||||
} finally {
|
||||
updateSettings({ textSize: DEFAULT_SETTINGS.textSize });
|
||||
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('the settings modal offers the four steps (data-testid text-size), below 画面质量', () => {
|
||||
const src = read('public/js/ui/settings.js');
|
||||
assert.match(src, /data-testid="text-size"/);
|
||||
assert.match(src, /const TEXT_SCALES = \[\[?.*'sm'.*'md'.*'lg'.*'xl'/s, 'the steps with their labels');
|
||||
assert.match(src, /applyTextSize\(settingsStore\.get\(\)\.textSize\)/, 'applied at module load (before main.js renders)');
|
||||
assert.ok(src.indexOf("t('文字大小')") > src.indexOf("t('画面质量')"), 'the row is below the quality row');
|
||||
assert.match(read('public/css/screens/game.css'), /\.set-seg\.set-textsize button \{ min-width: \.62rem; \}/,
|
||||
'four steps fit the row on a phone (1rem = 40 px)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文字大小: the CSS text root', () => {
|
||||
const sheets = stylesheets();
|
||||
const theme = sheets.find((s) => s.rel === `${CSS}/theme.css`).src;
|
||||
|
||||
test('theme.css defines --t for 小 and every step, and nothing else defines it', () => {
|
||||
assert.match(theme, /:root \{ --t: 1rem; \}/, '小 = the design\'s own sizes');
|
||||
for (const [attr, value] of [['md', /--t: max\(1rem, 64px\)/], ['lg', /--t: max\(calc\(1rem \* 1\.15\), 76px\)/], ['xl', /--t: max\(calc\(1rem \* 1\.3\), 88px\)/]]) {
|
||||
assert.match(theme, new RegExp(`:root\\[data-text="${attr}"\\] \\{ ${value.source}; \\}`), `the ${attr} step`);
|
||||
}
|
||||
for (const { rel, src } of sheets) {
|
||||
if (rel === `${CSS}/theme.css`) continue;
|
||||
assert.doesNotMatch(src, /--t\s*:/, `${rel}: --t is defined in theme.css only`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the layout root 1rem is untouched — the five rem→px readers must not move', () => {
|
||||
assert.match(theme, /html \{\n {2}font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100vh \/ 10\.8\)\), 240px\);/,
|
||||
'the desktop clamp');
|
||||
assert.match(theme, /font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100svh \/ 10\.8\)\), 240px\);/,
|
||||
'the svh clamp (mobile toolbars)');
|
||||
assert.doesNotMatch(theme, /font-size: clamp\([^)]*var\(--t\)/, 'the root scale never reads the text root');
|
||||
// and the modules that convert it keep their own floor: a text step must not reach them
|
||||
for (const f of ['public/js/ui/fieldHost.js', 'public/js/ui/gameLogic/panel.js', 'public/js/ui/underframe.js', 'public/js/ui/fallbackField.js']) {
|
||||
const src = read(f);
|
||||
assert.doesNotMatch(src, /--t\b|data-text/, `${f}: the layout must not read the text root`);
|
||||
assert.match(src, /getComputedStyle\(document\.documentElement\)\.fontSize|rem\b/, `${f}: still converts 1rem`);
|
||||
}
|
||||
});
|
||||
|
||||
test('readable text reads --t (body, .micro, buttons, modals, panels, settings)', () => {
|
||||
const want = [
|
||||
[theme, /body \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s, 'theme.css body (the 7.2 px text)'],
|
||||
[theme, /\.micro \{[^}]*font-size: calc\(\.11 \* var\(--t\)\)/s, 'theme.css .micro (92 render sites)'],
|
||||
[theme, /:root\[data-text="md"\]/, 'the steps'],
|
||||
];
|
||||
for (const [src, re, what] of want) assert.match(src, re, what);
|
||||
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
|
||||
const samples = [
|
||||
['components.css', /\.btn \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
|
||||
['screens/game.css', /\.set-row__label \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
|
||||
['screens/game-panels.css', /\.dtext \{ font-size: calc\(\.15 \* var\(--t\)\); line-height: 1\.65;/],
|
||||
['screens/guide.css', /\.guide__tips li \{ font-size: calc\(\.18 \* var\(--t\)\)/],
|
||||
['screens/lobby.css', /\.mode-card__desc/, ],
|
||||
];
|
||||
for (const [file, re] of samples) assert.match(byRel.get(`${CSS}/${file}`), re, file);
|
||||
});
|
||||
|
||||
test('a newer reader would be caught: every plain rem font-size is a documented exclusion', () => {
|
||||
const bad = [];
|
||||
let scaled = 0;
|
||||
for (const { rel, src } of sheets) {
|
||||
if (NOT_SCALED_FILES.includes(rel)) continue;
|
||||
for (const { selector, body } of cssRules(src)) {
|
||||
for (const m of body.matchAll(/font-size:([^;}]*)/g)) {
|
||||
const value = m[1];
|
||||
if (/var\(--t\)/.test(value)) { scaled++; continue; }
|
||||
if (!plainRem(value)) continue;
|
||||
if (notScaled(selector)) continue;
|
||||
bad.push(`${rel}: ${selector} { font-size:${value.trim()} }`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], `not on --t and not a documented exclusion (${scaled} declarations do read --t):`);
|
||||
assert.ok(scaled >= 250, `${scaled} declarations read --t — a mass revert would fail here`);
|
||||
});
|
||||
|
||||
test('the in-match HUD, the board-pinned text and the iOS zoom guards stay as they were', () => {
|
||||
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
|
||||
const plain = [
|
||||
['screens/game-panels.css', /\.sitag \{[^}]*font-size: max\(\.12rem, 9px\)/, '.sitag'],
|
||||
['screens/game-panels.css', /\.tempnote__title \{[^}]*font-size: \.15rem/, '.tempnote__title'],
|
||||
['screens/game-panels.css', /\.uframe__label \{[^}]*font-size: \.14rem/, '.uframe__label'],
|
||||
['screens/game-panels.css', /\.spcard__desc \{ flex: none; font-size: \.2rem/, '.spcard__desc (7 clamped lines)'],
|
||||
['screens/game-panels.css', /\.dhp span \{[^}]*font-size: \.12rem/, '.dhp span (a fixed-height HP bar)'],
|
||||
['screens/game-shop.css', /\.scard__name \{[^}]*font-size: \.19rem/, 'the shop card'],
|
||||
['screens/game.css', /\.bslot \.bond__name \{[^}]*font-size: \.14rem/, 'the bond strip'],
|
||||
['screens/game.css', /\.bossbar__txt \{[^}]*font-size: \.14rem/, 'the boss bar'],
|
||||
['screens/game.css', /\.lp--md \{ font-size: \.24rem; \}/, 'the top bar LP'],
|
||||
['screens/game.css', /\.sp-coarse \.set-diag__text \{ font-size: 16px; \}/, 'the 16 px iOS-zoom guard'],
|
||||
['theme.css', /\.rotate-hint \{[\s\S]*?font-size: 16px/, 'the rotate hint'],
|
||||
];
|
||||
for (const [file, re, what] of plain) assert.match(byRel.get(`${CSS}/${file}`), re, what);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sanitizeSettings } from '../../public/js/ui/gameLogic/settings.js';
|
||||
import { sanitizeVoiceOverrides, voiceLangFor } from '../../public/js/voicePrefs.js';
|
||||
import { AudioManager, voiceLine } from '../../public/js/audio.js';
|
||||
const a = 'char_263_skadi', b = 'char_103_angel';
|
||||
test('old settings migrate to inherit, malformed or prototype entries are discarded', () => {
|
||||
assert.deepEqual(sanitizeSettings({ voiceLang: 'jp' }).voiceOverrides, {});
|
||||
assert.deepEqual(sanitizeVoiceOverrides(Object.assign(Object.create({ [b]: 'jp' }), { [a]: 'jp', bad: 'cn', char_1_no: 'en' })), { [a]: 'jp' });
|
||||
for (const raw of [null, [], 1, 'jp']) assert.deepEqual(sanitizeVoiceOverrides(raw), {});
|
||||
});
|
||||
test('per-character override survives persistence; deletion restores the current global setting', () => {
|
||||
const settings = sanitizeSettings(JSON.parse(JSON.stringify({ voiceLang: 'cn', voiceOverrides: { [a]: 'jp', [b]: 'cn' } })));
|
||||
const manager = new AudioManager();
|
||||
manager.setVoiceLang(settings.voiceLang, settings.voiceOverrides);
|
||||
assert.equal(voiceLangFor(a, manager.voiceLang, manager.voiceOverrides), 'jp');
|
||||
manager.setVoiceLang('jp');
|
||||
assert.equal(voiceLangFor(b, manager.voiceLang, manager.voiceOverrides), 'cn');
|
||||
delete settings.voiceOverrides[b];
|
||||
manager.setVoiceLang('jp', settings.voiceOverrides);
|
||||
assert.equal(voiceLangFor(b, manager.voiceLang, manager.voiceOverrides), 'jp');
|
||||
assert.equal(voiceLangFor('unknown', 'cn', manager.voiceOverrides), 'cn');
|
||||
});
|
||||
test('the per-operator JP preference retains the existing per-slot Chinese fallback', () => {
|
||||
const tree = { voice: { [a]: { select: '/cn.mp3' } }, voiceJp: { [a]: { skill1: '/jp.mp3' } } };
|
||||
assert.equal(voiceLine(tree, a, 'select', voiceLangFor(a, 'cn', { [a]: 'jp' })).url, '/cn.mp3');
|
||||
assert.equal(voiceLine(tree, a, 'skill1', voiceLangFor(a, 'cn', { [a]: 'jp' })).url, '/jp.mp3');
|
||||
});
|
||||
Reference in new issue
Block a user