Files
prop-ai-hr/mobile-uni/tests/microphone-permission.test.mjs
T
adminandClaude Opus 5 7096545e82 fix(mobile): translate microphone errors and pre-request permission
实时陪练失败时,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>
2026-07-26 00:58:40 +08:00

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')));
});