274 lines
9.8 KiB
JavaScript
274 lines
9.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
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'
|
|
});
|
|
|
|
const originalUni = globalThis.uni;
|
|
const originalPlus = globalThis.plus;
|
|
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
|
|
|
after(async () => {
|
|
await vite.close();
|
|
if (originalUni === undefined) delete globalThis.uni;
|
|
else globalThis.uni = originalUni;
|
|
if (originalPlus === undefined) delete globalThis.plus;
|
|
else globalThis.plus = originalPlus;
|
|
});
|
|
|
|
const [
|
|
fileSelection,
|
|
speech,
|
|
caseService,
|
|
candidate,
|
|
knowledge,
|
|
personalAssistant
|
|
] = await Promise.all([
|
|
vite.ssrLoadModule('/src/utils/file-selection.ts'),
|
|
vite.ssrLoadModule('/src/services/speech.ts'),
|
|
vite.ssrLoadModule('/src/services/case.ts'),
|
|
vite.ssrLoadModule('/src/services/candidate.ts'),
|
|
vite.ssrLoadModule('/src/services/knowledge.ts'),
|
|
vite.ssrLoadModule('/src/services/personal-assistant.ts')
|
|
]);
|
|
const fileSelectionLifecycle = await vite.ssrLoadModule('/src/services/file-selection-lifecycle.ts');
|
|
|
|
const pickers = [
|
|
['音频', speech.chooseSpeechAudio],
|
|
['音频', caseService.chooseCaseAudio],
|
|
['文件', candidate.chooseCandidateFile],
|
|
['附件', knowledge.chooseKnowledgeMedia],
|
|
['文件', personalAssistant.choosePersonalFile]
|
|
];
|
|
|
|
const androidPickerRuntime = ({ cancelled = false } = {}) => {
|
|
class FakeIntent {
|
|
static ACTION_OPEN_DOCUMENT = 'android.intent.action.OPEN_DOCUMENT';
|
|
static CATEGORY_OPENABLE = 'android.intent.category.OPENABLE';
|
|
static FLAG_GRANT_READ_URI_PERMISSION = 1;
|
|
constructor(action) { this.action = action; }
|
|
addCategory(category) { this.category = category; }
|
|
setType(type) { this.type = type; }
|
|
addFlags(flags) { this.flags = flags; }
|
|
}
|
|
class FakeActivity {
|
|
static RESULT_OK = 1;
|
|
}
|
|
class FakeFile {
|
|
constructor(parent, name) {
|
|
this.path = `${typeof parent === 'string' ? parent : parent.path}/${name}`;
|
|
}
|
|
mkdirs() {}
|
|
delete() {}
|
|
getAbsolutePath() { return this.path; }
|
|
}
|
|
class FakeFileOutputStream {
|
|
constructor(target) { this.target = target; }
|
|
getChannel() {
|
|
return {
|
|
transferFrom(source) {
|
|
const count = source.remaining;
|
|
source.remaining = 0;
|
|
return count;
|
|
}
|
|
};
|
|
}
|
|
flush() {}
|
|
close() {}
|
|
}
|
|
class FakeFileInputStream {
|
|
constructor(fileDescriptor) { this.fileDescriptor = fileDescriptor; }
|
|
getChannel() { return { remaining: 3 }; }
|
|
close() {}
|
|
}
|
|
const cursor = {
|
|
moveToFirst: () => true,
|
|
getColumnIndex: (name) => name === '_display_name' ? 0 : 1,
|
|
getString: () => 'acceptance.mp3',
|
|
getLong: () => 3,
|
|
close() {}
|
|
};
|
|
const resolver = {
|
|
query: () => cursor,
|
|
openFileDescriptor: () => ({
|
|
getFileDescriptor: () => ({}),
|
|
close() {}
|
|
})
|
|
};
|
|
const main = {
|
|
onActivityResult: null,
|
|
getCacheDir: () => ({ path: '/cache' }),
|
|
getExternalCacheDir: () => ({ path: '/external-cache' }),
|
|
getContentResolver: () => resolver,
|
|
startActivityForResult(intent, requestCode) {
|
|
assert.equal(intent.action, FakeIntent.ACTION_OPEN_DOCUMENT);
|
|
assert.equal(intent.category, FakeIntent.CATEGORY_OPENABLE);
|
|
assert.equal(intent.type, 'audio/*');
|
|
queueMicrotask(() => this.onActivityResult(
|
|
requestCode,
|
|
cancelled ? 0 : FakeActivity.RESULT_OK,
|
|
cancelled ? null : { getData: () => ({}) }
|
|
));
|
|
}
|
|
};
|
|
const classes = {
|
|
'android.content.Intent': FakeIntent,
|
|
'android.app.Activity': FakeActivity,
|
|
'java.io.File': FakeFile,
|
|
'java.io.FileInputStream': FakeFileInputStream,
|
|
'java.io.FileOutputStream': FakeFileOutputStream
|
|
};
|
|
return {
|
|
os: { name: 'Android' },
|
|
android: {
|
|
importClass: (target) => typeof target === 'string' ? classes[target] : target,
|
|
invoke: (target, method, ...args) => target[method](...args),
|
|
runtimeMainActivity: () => main
|
|
}
|
|
};
|
|
};
|
|
|
|
test('所有文件选择器把用户取消识别为同一可静默处理的信号', async () => {
|
|
globalThis.uni = {
|
|
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail cancel' })
|
|
};
|
|
|
|
for (const [label, picker] of pickers) {
|
|
await assert.rejects(picker(), (error) => {
|
|
assert.equal(fileSelection.isFileSelectionCancelled(error), true);
|
|
assert.equal(error.code, fileSelection.FILE_SELECTION_CANCELLED);
|
|
assert.equal(error.message, `未选择${label}`);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
|
|
test('系统选择失败不是用户取消,并只展示稳定中文提示', async () => {
|
|
globalThis.uni = {
|
|
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail permission denied' })
|
|
};
|
|
|
|
for (const [label, picker] of pickers) {
|
|
await assert.rejects(picker(), (error) => {
|
|
assert.equal(fileSelection.isFileSelectionCancelled(error), false);
|
|
assert.equal(error.message, `无法选择${label},请重试`);
|
|
assert.doesNotMatch(error.message, /permission|denied|chooseFile/i);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
|
|
test('无结构异常也不会被误判为取消', () => {
|
|
const error = fileSelection.fileSelectionFailure(undefined, '附件');
|
|
assert.equal(fileSelection.isFileSelectionCancelled(error), false);
|
|
assert.equal(error.message, '无法选择附件,请重试');
|
|
});
|
|
|
|
test('Android App 使用系统文档选择器并复制音频到应用缓存', async () => {
|
|
globalThis.plus = androidPickerRuntime();
|
|
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
|
|
|
|
const selected = await speech.chooseSpeechAudio();
|
|
assert.equal(selected.name, 'acceptance.mp3');
|
|
assert.equal(selected.size, 3);
|
|
assert.match(selected.path, /^\/external-cache\/aihr-picker\/\d+-acceptance\.mp3$/);
|
|
delete globalThis.plus;
|
|
});
|
|
|
|
test('Android 文件选择期间保持页面请求有效,并在完成后恢复生命周期状态', async () => {
|
|
let finishSelection;
|
|
const selection = fileSelectionLifecycle.withPlatformFileSelection(() =>
|
|
new Promise((resolve) => { finishSelection = resolve; })
|
|
);
|
|
|
|
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), true);
|
|
finishSelection('selected');
|
|
assert.equal(await selection, 'selected');
|
|
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
|
|
});
|
|
|
|
test('Android App 图片和视频使用系统媒体选择器', async () => {
|
|
globalThis.plus = androidPickerRuntime();
|
|
globalThis.uni = {
|
|
chooseFile: () => assert.fail('Android App 媒体不应调用不受支持的 uni.chooseFile'),
|
|
chooseMedia: ({ mediaType, sourceType, success }) => {
|
|
assert.deepEqual(mediaType, ['image', 'video']);
|
|
assert.deepEqual(sourceType, ['album']);
|
|
success({
|
|
tempFiles: [{
|
|
tempFilePath: '/cache/acceptance.mp4',
|
|
fileType: 'video',
|
|
size: 12
|
|
}]
|
|
});
|
|
}
|
|
};
|
|
|
|
const selected = await knowledge.chooseKnowledgeMedia();
|
|
assert.equal(selected.path, '/cache/acceptance.mp4');
|
|
assert.equal(selected.kind, 'video');
|
|
assert.equal(selected.size, 12 * 1024);
|
|
delete globalThis.plus;
|
|
});
|
|
|
|
test('Android 系统文档选择器取消仍返回统一取消信号', async () => {
|
|
globalThis.plus = androidPickerRuntime({ cancelled: true });
|
|
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
|
|
|
|
await assert.rejects(speech.chooseSpeechAudio(), (error) => {
|
|
assert.equal(fileSelection.isFileSelectionCancelled(error), true);
|
|
assert.equal(error.message, '未选择音频');
|
|
return true;
|
|
});
|
|
delete globalThis.plus;
|
|
});
|
|
|
|
test('所有使用文件选择器的页面都按取消信号恢复空闲态而不展示错误', async () => {
|
|
const pages = await Promise.all([
|
|
'../src/pages/candidate/interview/index.vue',
|
|
'../src/pages/candidate/materials/index.vue',
|
|
'../src/pages/user/web-ai/index.vue',
|
|
'../src/pages/user/today/index.vue',
|
|
'../src/pages/user/cases/index.vue',
|
|
'../src/pages/user/sop/index.vue',
|
|
'../src/pages/user/practice/index.vue',
|
|
'../src/pages/user/report/index.vue',
|
|
'../src/pages/user/assistant/capture.vue'
|
|
].map(read));
|
|
const combined = pages.join('\n');
|
|
|
|
for (const page of pages) assert.match(page, /isFileSelectionCancelled/);
|
|
assert.doesNotMatch(combined, /(?:!==|===)\s*['"]未选择(?:音频|附件|文件)['"]/);
|
|
assert.match(pages[0], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
|
|
assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
|
|
});
|
|
|
|
test('会作废转写请求的页面在 Android 系统选择器打开时保留选择任务', async () => {
|
|
const pages = await Promise.all([
|
|
'../src/pages/candidate/interview/index.vue',
|
|
'../src/pages/user/practice/index.vue',
|
|
'../src/pages/user/sop/index.vue',
|
|
'../src/pages/user/report/index.vue'
|
|
].map(read));
|
|
|
|
for (const page of pages) assert.match(page, /isPlatformFileSelectionActive\(\)/);
|
|
assert.match(pages[0], /if \(!isPlatformFileSelectionActive\(\)\) cancelQuestionRecording\(\)/);
|
|
assert.match(pages[1], /if \(!isPlatformFileSelectionActive\(\)\) cancelRecording\(\)/);
|
|
assert.match(pages[2], /if \(!isPlatformFileSelectionActive\(\)\) cancelVoiceTranscription\(\)/);
|
|
assert.match(pages[3], /teardown\(isPlatformFileSelectionActive\(\)\)/);
|
|
});
|
|
|
|
test('Android SAF 复制完全在 Java FileChannel 内完成,不回退 Native.js byte 数组', async () => {
|
|
const picker = await read('../src/services/local-file-picker.ts');
|
|
assert.match(picker, /transferFrom/);
|
|
assert.doesNotMatch(picker, /newObject\(\s*['"]byte\[\]/);
|
|
});
|