import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; const loadModule = async (sandbox = {}) => { const service = await readFile(new URL('../src/services/speech-audio-source.ts', import.meta.url), 'utf8'); const compiled = ts.transpileModule(service, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }); const runtimeModule = { exports: {} }; vm.runInNewContext(compiled.outputText, { exports: runtimeModule.exports, module: runtimeModule, atob: (value) => Buffer.from(value, 'base64').toString('binary'), Blob, URL, Date, ...sandbox }); return runtimeModule.exports; }; // 'ID3' 是 mp3 文件头标识,base64 为 SUQz const MP3_DATA_URI = `data:audio/mp3;base64,${Buffer.from('ID3\x04\x00\x00').toString('base64')}`; test('识别音频 data: URI,拒绝 http URL 与非音频 data URI', async () => { const runtime = await loadModule(); assert.equal(runtime.isDataAudioUri(MP3_DATA_URI), true); assert.equal(runtime.isDataAudioUri('data:audio/mpeg;base64,SUQz'), true); assert.equal(runtime.isDataAudioUri('https://example.com/a.mp3'), false); assert.equal(runtime.isDataAudioUri('data:image/png;base64,SUQz'), false); assert.equal(runtime.isDataAudioUri(''), false); }); test('解码 data: URI 为字节,保留 mime 类型', async () => { const runtime = await loadModule(); const decoded = runtime.decodeDataAudioUri(MP3_DATA_URI); assert.equal(decoded.mime, 'audio/mp3'); assert.deepEqual(Array.from(decoded.bytes.slice(0, 3)), [0x49, 0x44, 0x33]); assert.equal(runtime.decodeDataAudioUri('data:audio/mp3;base64,!!!'), null); assert.equal(runtime.decodeDataAudioUri('not-a-data-uri'), null); }); test('H5 环境转换为 Blob URL 并可回收', async () => { const created = []; const fakeURL = { createObjectURL: (blob) => { created.push(blob); return 'blob:mock-1'; } }; const runtime = await loadModule({ URL: fakeURL }); const blobUrl = runtime.dataUriToBlobUrl(MP3_DATA_URI); assert.equal(blobUrl, 'blob:mock-1'); assert.equal(created.length, 1); assert.equal(created[0].type, 'audio/mp3'); assert.equal(created[0].size, 6); }); // plus.io 的 FileWriter 只接受字符串。此前 mock 收 Blob 并断言 blob.type, // 把错误契约固化成"通过":真机 writeAsBinary 从未被调用,落地 0 字节而单测全绿。 // 因此这里断言写入的是 base64 字符串本身,不是任何对象。 const nativePlus = (bytesWritten = (data) => Buffer.from(data, 'base64').length, writes = []) => ({ io: { resolveLocalFileSystemURL: (path, ok) => { assert.equal(path, '_doc/'); ok({ getFile: (name, flags, onFile) => { assert.equal(flags.create, true); assert.match(name, /^aihr-tts-\d+\.mp3$/); let written = ''; onFile({ fullPath: `_doc/${name}`, file: (onMeta) => onMeta({ size: bytesWritten(written) }), createWriter: (onWriter) => onWriter({ onwriteend: null, onerror: null, writeAsBinary(data) { written = data; writes.push(data); this.onwriteend(); } }) }); } }); }, convertLocalFileSystemURL: (path) => `file:///storage/emulated/0/${path}` } }); test('App 环境:base64 经 writeAsBinary 落盘后返回 file:// 播放路径', async () => { const writes = []; const runtime = await loadModule({ plus: nativePlus(undefined, writes) }); const temp = await runtime.writeNativeTempAudio(MP3_DATA_URI); assert.match(temp.playbackUrl, /^file:\/\/\/storage\/emulated\/0\/_doc\/aihr-tts-\d+\.mp3$/); // 清理必须拿相对路径:传 file:// 绝对路径 resolveLocalFileSystemURL 解析不到,文件会堆积。 assert.match(temp.plusPath, /^_doc\/aihr-tts-\d+\.mp3$/); assert.equal(writes.length, 1); assert.equal(typeof writes[0], 'string'); assert.equal(writes[0], MP3_DATA_URI.split(',')[1]); assert.equal(runtime.hasNativeAudioFileSupport(), true); }); test('App 环境:落盘 0 字节判为失败,不返回可播放路径', async () => { // 生产真机曾写出 9 个 0 字节 mp3:onwriteend 正常回调、路径正常返回, // InnerAudioContext 播空文件不报错,表现为界面正常但无声。必须在此拦掉。 const runtime = await loadModule({ plus: nativePlus(() => 0) }); assert.equal(await runtime.writeNativeTempAudio(MP3_DATA_URI), null); }); test('清理临时音频:用相对路径解析并真的调用 remove()', async () => { const resolved = []; let removed = 0; const runtime = await loadModule({ plus: { io: { resolveLocalFileSystemURL: (path, ok) => { resolved.push(path); ok({ remove: () => { removed += 1; } }); }, convertLocalFileSystemURL: (path) => `file:///storage/emulated/0/${path}` } } }); runtime.removeNativeTempAudio('_doc/aihr-tts-123.mp3'); assert.deepEqual(resolved, ['_doc/aihr-tts-123.mp3']); assert.equal(removed, 1); }); test('提取 base64 载荷,拒绝空载荷与非音频 data URI', async () => { const runtime = await loadModule(); const payload = runtime.extractAudioBase64(MP3_DATA_URI); assert.equal(payload.mime, 'audio/mp3'); assert.equal(payload.base64, MP3_DATA_URI.split(',')[1]); assert.equal(runtime.extractAudioBase64('data:image/png;base64,SUQz'), null); assert.equal(runtime.extractAudioBase64('not-a-data-uri'), null); }); test('App 环境:写盘失败或无 plus 环境时返回 null,清理尽力而为', async () => { const failingPlus = { io: { resolveLocalFileSystemURL: (path, ok, fail) => fail(new Error('io error')), convertLocalFileSystemURL: (path) => path } }; const runtime = await loadModule({ plus: failingPlus }); assert.equal(await runtime.writeNativeTempAudio(MP3_DATA_URI), null); const webRuntime = await loadModule(); assert.equal(webRuntime.hasNativeAudioFileSupport(), false); assert.equal(await webRuntime.writeNativeTempAudio(MP3_DATA_URI), null); webRuntime.removeNativeTempAudio('file:///nonexistent.mp3'); });