Files
prop-ai-hr/mobile-uni/tests/file-selection.test.mjs
T

291 lines
11 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 权限 Activity 期间保持原生交互状态,但不冒充文件选择', async () => {
let finishPermission;
const permission = fileSelectionLifecycle.withPlatformPermissionActivity(() =>
new Promise((resolve) => { finishPermission = resolve; })
);
assert.equal(fileSelectionLifecycle.isPlatformNativeActivityActive(), true);
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
finishPermission('denied');
assert.equal(await permission, 'denied');
assert.equal(fileSelectionLifecycle.isPlatformNativeActivityActive(), 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',
'../src/pages/user/today/index.vue',
'../src/pages/user/direct/index.vue'
].map(read));
for (const page of pages) assert.match(page, /isPlatformNativeActivityActive\(\)/);
assert.match(pages[0], /if \(!isPlatformNativeActivityActive\(\)\) cancelQuestionRecording\(\)/);
assert.match(pages[1], /if \(!isPlatformNativeActivityActive\(\)\) cancelRecording\(\)/);
assert.match(pages[2], /if \(!isPlatformNativeActivityActive\(\)\)[\s\S]*?cancelVoiceTranscription\(\)/);
assert.match(pages[3], /teardown\(isPlatformNativeActivityActive\(\)\)/);
assert.match(pages[4], /if \(!isPlatformNativeActivityActive\(\)\)[\s\S]*?holdGeneration \+= 1/);
assert.match(pages[5], /if \(!isPlatformNativeActivityActive\(\)\) teardownVoice\(\)/);
});
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\[\]/);
});