实时陪练失败时,getUserMedia 抛出的 Chromium 英文原文会直接透给用户(真机实测见过 "Could not start audio source"),既不可读也不含任何可执行指引。新增 utils/microphone.ts 把权限、设备占用、无设备、非安全上下文四类底层错误翻译成中文 引导;业务层自己抛的中文文案原样透出,未识别的错误回落到原有通用提示。 同时在建会话之前主动申请 RECORD_AUDIO,并在 manifest 显式声明该权限。 需要说明:此权限预申请是防御性的。真机核查 RECORD_AUDIO 已授予、APK 本就声明该权限、 实时链路可正常跑通,先前那次 "Could not start audio source" 是麦克风被其他应用占用 的瞬时故障,不是权限缺陷。未在"权限未授予"的设备上实测过 WebView 是否会自行弹出 Android 运行时授权框,故此调用在已授权设备上为无害空转;若确实不弹,则它是唯一授权入口。 manifest 声明同样只为防未来模块调整导致权限丢失。 新增 6 项单测覆盖错误映射与授权分支(共 168 项通过)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
89 lines
3.4 KiB
JavaScript
89 lines
3.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { after, test } from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createServer } from 'vite';
|
|
|
|
const vite = await createServer({
|
|
root: fileURLToPath(new URL('..', import.meta.url)),
|
|
configFile: false,
|
|
optimizeDeps: { noDiscovery: true },
|
|
server: { middlewareMode: true, hmr: false },
|
|
appType: 'custom'
|
|
});
|
|
|
|
after(async () => {
|
|
await vite.close();
|
|
delete globalThis.plus;
|
|
});
|
|
|
|
const load = () => vite.ssrLoadModule('/src/utils/microphone.ts');
|
|
|
|
test('底层音频错误一律翻译为中文,英文原文不得透给用户', async () => {
|
|
const { describeMicrophoneError } = await load();
|
|
const fallback = '实时陪练连接失败,请使用录音对练';
|
|
|
|
// 这些是 Chromium / WebRTC 的真实原文,真机上曾直接展示给用户。
|
|
const rawErrors = [
|
|
new Error('Could not start audio source'),
|
|
Object.assign(new Error('Permission denied'), { name: 'NotAllowedError' }),
|
|
Object.assign(new Error('Requested device not found'), { name: 'NotFoundError' }),
|
|
Object.assign(new Error('Could not start audio source'), { name: 'NotReadableError' }),
|
|
'getUserMedia requires a secure context'
|
|
];
|
|
|
|
for (const error of rawErrors) {
|
|
const text = describeMicrophoneError(error, fallback);
|
|
assert.match(text, /[一-龥]/, `应翻译为中文: ${error}`);
|
|
assert.ok(!/[A-Za-z]{6,}/.test(text), `不应残留英文原文: ${text}`);
|
|
}
|
|
});
|
|
|
|
test('业务层自抛的中文错误原样透出,不被兜底文案吞掉', async () => {
|
|
const { describeMicrophoneError } = await load();
|
|
const own = '实时语音模块尚未就绪,请稍后重试';
|
|
assert.equal(describeMicrophoneError(new Error(own), '兜底文案'), own);
|
|
});
|
|
|
|
test('无法识别的错误回落到兜底文案', async () => {
|
|
const { describeMicrophoneError } = await load();
|
|
assert.equal(describeMicrophoneError(new Error('zzz unknown'), '兜底文案'), '兜底文案');
|
|
assert.equal(describeMicrophoneError(undefined, '兜底文案'), '兜底文案');
|
|
});
|
|
|
|
test('Android 运行时主动申请 RECORD_AUDIO 并区分拒绝与永久拒绝', async () => {
|
|
const requested = [];
|
|
globalThis.plus = {
|
|
android: {
|
|
requestPermissions: (permissions, success) => {
|
|
requested.push(...permissions);
|
|
success(pendingResult);
|
|
}
|
|
}
|
|
};
|
|
let pendingResult = { granted: ['android.permission.RECORD_AUDIO'] };
|
|
const { requestMicrophonePermission } = await load();
|
|
|
|
assert.equal(await requestMicrophonePermission(), 'granted');
|
|
assert.deepEqual(requested, ['android.permission.RECORD_AUDIO']);
|
|
|
|
pendingResult = { deniedPresent: ['android.permission.RECORD_AUDIO'] };
|
|
assert.equal(await requestMicrophonePermission(), 'denied');
|
|
|
|
pendingResult = { deniedAlways: ['android.permission.RECORD_AUDIO'] };
|
|
assert.equal(await requestMicrophonePermission(), 'denied-always');
|
|
|
|
delete globalThis.plus;
|
|
});
|
|
|
|
test('非 App 运行时返回 unsupported,交由平台原生授权流程处理', async () => {
|
|
delete globalThis.plus;
|
|
const { requestMicrophonePermission } = await load();
|
|
assert.equal(await requestMicrophonePermission(), 'unsupported');
|
|
});
|
|
|
|
test('永久拒绝的引导文案指向系统设置,普通拒绝不误导', async () => {
|
|
const { microphoneDeniedMessage } = await load();
|
|
assert.match(microphoneDeniedMessage('denied-always'), /系统设置/);
|
|
assert.ok(!/系统设置/.test(microphoneDeniedMessage('denied')));
|
|
});
|