Files
controller-v2/scripts/eq-save-regression.test.mjs
T
2026-09-20 14:06:34 +08:00

166 lines
7.8 KiB
JavaScript

// 运行:node --test scripts/eq-save-regression.test.mjs
// 无硬件测试:执行实际 API 和从 EQPage 提取的提交函数,不复制待测实现。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import ts from 'typescript';
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
const pageText = readFileSync(new URL('../client/src/pages/EQPage.tsx', import.meta.url), 'utf8');
const transpile = text => ts.transpileModule(text, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../client/src/lib/luxsinApi.ts', import.meta.url))],
bundle: true, write: false, format: 'esm', platform: 'node',
define: { 'import.meta.env.DEV': 'false' },
alias: { '@': fileURLToPath(new URL('../client/src', import.meta.url)) },
});
const { LuxsinAPI, encodeCustomBase64, decodeCustomBase64 } = await import(
'data:text/javascript;base64,' + Buffer.from(bundle.outputFiles[0].text).toString('base64')
);
const band = gain => ({ freq: 100, gain, q: 1, type: 0, enabled: true });
const preset = gain => ({ name: 'Copy', preamp: -2, autoPre: 0, canDel: 1,
filters: [{ fc: 100, gain, q: 1, type: 0 }] });
const flushMicrotasks = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
function scheduler() {
const source = ts.createSourceFile('EQPage.tsx', pageText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const component = source.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'EQPage');
const names = ['peqSyncTimerRef', 'pendingPeqSyncRef', 'inFlightPeqSyncRef', 'peqPresetCacheRef',
'cancelPendingPeqSync', 'flushPendingPeqSync', 'rememberPeqPresetCache',
'persistPresetEditsToPeqItem', 'schedulePeqSync'];
const definitions = names.map(name => {
const node = component.body.statements.find(n => ts.isVariableStatement(n)
&& n.declarationList.declarations.some(d => ts.isIdentifier(d.name) && d.name.text === name));
assert.ok(node, `缺少待测定义 ${name}`);
return node.getText(source);
}).join('\n');
const timers = new Map();
let sequence = 0;
const requests = [];
const errors = [];
const window = {
setTimeout: fn => { const id = ++sequence; timers.set(id, fn); return id; },
clearTimeout: id => timers.delete(id),
};
const setup = `
const useRef = current => ({current});
const useCallback = fn => fn;
const api = {}, isDemoMode = false;
let peqItems = [${JSON.stringify(preset(0))}], updates = [];
const setPeqItems = updater => updates.push(updater);
const bandToPeqFilter = b => ({fc: b.freq, gain: b.gain, q: b.q, type: b.type});
const buildPeqPresetBody = (peq, filters) => ({...peq, filters});
const upgradePeqChange = async payload => { requests.push(payload); };
const upgradePeqApply = async payload => { requests.push(payload); };
const toast = {error: message => errors.push(message)};
${definitions}
return {
schedule: schedulePeqSync, flush: flushPendingPeqSync,
cache: peqPresetCacheRef.current,
render: () => { for (const update of updates) peqItems = update(peqItems); updates = []; return peqItems; }
};`;
return { ...new Function('window', 'requests', 'errors', transpile(setup))(window, requests, errors), timers, requests, errors };
}
test('A 显式保存立即发送,不依赖 React updater 求值或定时器', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(3)], 'A', 0);
assert.equal(eq.requests.length, 1);
assert.equal(eq.requests[0].peqChange.filters[0].gain, 3);
assert.equal(eq.timers.size, 0);
assert.equal(eq.cache.Copy.filters[0].gain, 3);
assert.equal(eq.render()[0].filters[0].gain, 3);
});
test('单次编辑在防抖窗口内切换/卸载时补发,且不重复发送', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(4)], 'A');
assert.equal(eq.requests.length, 0);
await eq.flush();
await eq.flush();
assert.equal(eq.requests.length, 1);
assert.equal(eq.requests[0].peqChange.filters[0].gain, 4);
assert.equal(eq.timers.size, 0);
});
test('连续编辑合并为最新快照;元数据也进入载荷', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(1)], 'A');
await eq.schedule({ ...preset(0), preamp: -7, autoPre: 1 }, [band(2)], 'A');
assert.equal(eq.timers.size, 1);
await eq.flush();
assert.equal(eq.requests.length, 1);
assert.deepEqual([eq.requests[0].peqChange.filters[0].gain, eq.requests[0].peqChange.preamp, eq.requests[0].peqChange.autoPre], [2, -7, 1]);
});
test('B 试听不能替换尚未发送的 A 保存', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(1)], 'A');
await eq.schedule({ ...preset(0), preamp: -8 }, [band(5)], 'B', 0);
assert.deepEqual(eq.requests.map(p => Object.keys(p)[0]), ['peqChange', 'peqApply']);
assert.equal(eq.cache.Copy.filters[0].gain, 1);
assert.equal(eq.cache.Copy.preamp, -2);
assert.equal(eq.requests[1].peqApply.preamp, -8);
});
test('B 的待发试听在离开时取消,不写入预设缓存', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(5)], 'B');
await eq.flush();
assert.equal(eq.requests.length, 0);
assert.equal(eq.timers.size, 0);
assert.deepEqual(eq.cache, {});
});
test('切换调度目标时先提交原预设,而不是覆盖它的保存', async () => {
const eq = scheduler();
await eq.schedule(preset(0), [band(3)], 'A');
await eq.schedule({ ...preset(0), name: 'Other' }, [band(4)], 'A', 0);
assert.deepEqual(eq.requests.map(p => p.peqChange.name), ['Copy', 'Other']);
});
test('真实 API 串行执行保存、试听、选择、目录操作及读取', async t => {
const started = [], release = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
assert.equal(new URL(url).hostname, 'eq-test.invalid');
const payload = options?.body ? JSON.parse(decodeCustomBase64(new URLSearchParams(options.body).get('json'))) : null;
started.push(payload ? Object.keys(payload)[0] : new URL(url).searchParams.get('action'));
await new Promise(resolve => release.push(resolve));
return new Response(encodeCustomBase64(JSON.stringify({ peq: [], filters: [] })));
});
const api = new LuxsinAPI('eq-test.invalid');
const calls = [api.upgradePeqChange({ peqChange: preset(1) }), api.upgradePeqApply({ peqApply: preset(2) }),
api.setPeqSelect(1), api.renamePeq('Copy', 'Renamed'), api.sortPeq(['Renamed'], 0), api.removePeq(['Renamed']), api.getPeqState()];
await flushMicrotasks();
assert.deepEqual(started, ['peqChange']);
for (let i = 0; i < calls.length; i++) {
assert.equal(started.length, i + 1, '前一个响应完成之前不应启动下一个请求');
release[i]();
await flushMicrotasks();
}
await Promise.all(calls);
assert.deepEqual(started, ['peqChange', 'peqApply', 'setting', 'peqRename', 'peqSoft', 'peqRemove', 'syncPeq']);
});
test('HTTP 500 必须失败,后续重试不被失败队列阻塞', async t => {
let count = 0;
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: ++count === 1 ? 500 : 200 }));
const api = new LuxsinAPI('eq-test.invalid');
await assert.rejects(api.upgradePeqChange({ peqChange: preset(1) }), /500/);
await api.upgradePeqChange({ peqChange: preset(2) });
assert.equal(count, 2);
});
test('保存失败不会变成静默成功;网络异常之后可重试', async t => {
let count = 0;
t.mock.method(globalThis, 'fetch', async () => {
if (++count === 1) throw new TypeError('mock network failure');
return new Response('');
});
const api = new LuxsinAPI('eq-test.invalid');
await assert.rejects(api.upgradePeqChange({ peqChange: preset(1) }), /network/);
await api.upgradePeqChange({ peqChange: preset(2) });
});