// 运行: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) }); });