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\[\]/); });