补齐移动壳文件动作单测
移动壳 files helper 增加系统文件动作直接单测 移动壳配置门禁反查文件动作测试边界 宿主壳文档和共享决策同步移动文件动作覆盖
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
captureImageFile,
|
||||
exportAudioFile,
|
||||
exportImageFile,
|
||||
exportTextFile,
|
||||
importAudioFile,
|
||||
importDocumentFile,
|
||||
importImageFile,
|
||||
importTextFile,
|
||||
} from './files';
|
||||
|
||||
function encodeBytes(bytes: readonly number[]) {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
const PNG_BASE64 = encodeBytes([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0,
|
||||
]);
|
||||
const DOCX_BASE64 = Buffer.from('PK\x03\x04docx', 'binary').toString('base64');
|
||||
const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString(
|
||||
'base64',
|
||||
);
|
||||
|
||||
const fileTexts = vi.hoisted(() => new Map<string, string>());
|
||||
const fileBase64Data = vi.hoisted(() => new Map<string, string>());
|
||||
const fileSizes = vi.hoisted(() => new Map<string, number | null>());
|
||||
const writtenFiles = vi.hoisted(
|
||||
() =>
|
||||
[] as {
|
||||
uri: string;
|
||||
content: string;
|
||||
options?: { encoding?: 'utf8' | 'base64' };
|
||||
}[],
|
||||
);
|
||||
|
||||
vi.mock('expo-file-system', () => ({
|
||||
Paths: {
|
||||
cache: 'file:///cache/',
|
||||
},
|
||||
File: class TestFile {
|
||||
uri: string;
|
||||
|
||||
constructor(base: string, fileName?: string) {
|
||||
this.uri =
|
||||
typeof fileName === 'string' ? `file:///cache/${fileName}` : base;
|
||||
}
|
||||
|
||||
write(content: string, options?: { encoding?: 'utf8' | 'base64' }) {
|
||||
writtenFiles.push({
|
||||
uri: this.uri,
|
||||
content,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
get size() {
|
||||
return fileSizes.get(this.uri) ?? null;
|
||||
}
|
||||
|
||||
text() {
|
||||
return Promise.resolve(fileTexts.get(this.uri) ?? '');
|
||||
}
|
||||
|
||||
base64() {
|
||||
return Promise.resolve(fileBase64Data.get(this.uri) ?? '');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('expo-document-picker', () => ({
|
||||
getDocumentAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
PermissionStatus: {
|
||||
DENIED: 'denied',
|
||||
GRANTED: 'granted',
|
||||
},
|
||||
launchCameraAsync: vi.fn(),
|
||||
launchImageLibraryAsync: vi.fn(),
|
||||
requestCameraPermissionsAsync: vi.fn(),
|
||||
requestMediaLibraryPermissionsAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: vi.fn(),
|
||||
shareAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const documentPickerMock = vi.mocked(DocumentPicker.getDocumentAsync);
|
||||
const shareAvailableMock = vi.mocked(Sharing.isAvailableAsync);
|
||||
const shareAsyncMock = vi.mocked(Sharing.shareAsync);
|
||||
const libraryPermissionMock = vi.mocked(
|
||||
ImagePicker.requestMediaLibraryPermissionsAsync,
|
||||
);
|
||||
const cameraPermissionMock = vi.mocked(ImagePicker.requestCameraPermissionsAsync);
|
||||
const imageLibraryMock = vi.mocked(ImagePicker.launchImageLibraryAsync);
|
||||
const cameraMock = vi.mocked(ImagePicker.launchCameraAsync);
|
||||
|
||||
describe('mobile HostBridge file actions', () => {
|
||||
beforeEach(() => {
|
||||
shareAvailableMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fileTexts.clear();
|
||||
fileBase64Data.clear();
|
||||
fileSizes.clear();
|
||||
writtenFiles.length = 0;
|
||||
});
|
||||
|
||||
test('exports text through Expo cache file and system share sheet', async () => {
|
||||
const result = await exportTextFile({
|
||||
content: '泥巴AI',
|
||||
fileName: '创作记录',
|
||||
mimeType: 'text/markdown',
|
||||
});
|
||||
|
||||
expect(writtenFiles).toEqual([
|
||||
{
|
||||
uri: 'file:///cache/创作记录',
|
||||
content: '泥巴AI',
|
||||
options: undefined,
|
||||
},
|
||||
]);
|
||||
expect(shareAsyncMock).toHaveBeenCalledWith('file:///cache/创作记录', {
|
||||
mimeType: 'text/markdown',
|
||||
UTI: 'public.plain-text',
|
||||
dialogTitle: '创作记录',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
action: 'saved',
|
||||
fileName: '创作记录',
|
||||
bytes: Buffer.byteLength('泥巴AI'),
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects exports when system sharing is unavailable', async () => {
|
||||
shareAvailableMock.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
exportImageFile({
|
||||
base64Data: PNG_BASE64,
|
||||
fileName: '分享卡',
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'unsupported_capability',
|
||||
});
|
||||
expect(shareAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('imports text and document files without exposing local URIs', async () => {
|
||||
fileTexts.set('file:///picked/story.md', '故事');
|
||||
fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事'));
|
||||
documentPickerMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/story.md',
|
||||
name: 'story.md',
|
||||
mimeType: 'text/markdown',
|
||||
size: Buffer.byteLength('故事'),
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importTextFile()).resolves.toEqual({
|
||||
action: 'selected',
|
||||
fileName: 'story.md',
|
||||
content: '故事',
|
||||
mimeType: 'text/markdown',
|
||||
bytes: Buffer.byteLength('故事'),
|
||||
});
|
||||
|
||||
fileBase64Data.set('file:///picked/brief.docx', DOCX_BASE64);
|
||||
fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx'));
|
||||
documentPickerMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/brief.docx',
|
||||
name: 'brief.docx',
|
||||
mimeType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
size: Buffer.byteLength('PK\x03\x04docx'),
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importDocumentFile()).resolves.toEqual({
|
||||
action: 'selected',
|
||||
fileName: 'brief.docx',
|
||||
base64Data: DOCX_BASE64,
|
||||
mimeType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
bytes: Buffer.byteLength('PK\x03\x04docx'),
|
||||
});
|
||||
});
|
||||
|
||||
test('maps picker cancellation to HostBridge cancellation errors', async () => {
|
||||
documentPickerMock.mockResolvedValue({
|
||||
canceled: true,
|
||||
assets: null,
|
||||
});
|
||||
|
||||
await expect(importAudioFile()).rejects.toMatchObject({
|
||||
code: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports and captures images only after native permissions are granted', async () => {
|
||||
libraryPermissionMock.mockResolvedValueOnce({
|
||||
status: ImagePicker.PermissionStatus.GRANTED,
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
expires: 'never',
|
||||
});
|
||||
imageLibraryMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/image.png',
|
||||
width: 120,
|
||||
height: 80,
|
||||
type: 'image',
|
||||
fileName: 'image.png',
|
||||
fileSize: Buffer.byteLength(Buffer.from(PNG_BASE64, 'base64')),
|
||||
base64: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importImageFile()).resolves.toMatchObject({
|
||||
action: 'selected',
|
||||
fileName: 'image.png',
|
||||
base64Data: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
expect(imageLibraryMock).toHaveBeenCalledWith({
|
||||
allowsEditing: false,
|
||||
allowsMultipleSelection: false,
|
||||
base64: true,
|
||||
exif: false,
|
||||
mediaTypes: ['images'],
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
cameraPermissionMock.mockResolvedValueOnce({
|
||||
status: ImagePicker.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 'never',
|
||||
});
|
||||
|
||||
await expect(captureImageFile()).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'camera permission denied',
|
||||
});
|
||||
expect(cameraMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('imports audio and exports binary files through controlled payloads', async () => {
|
||||
fileBase64Data.set('file:///picked/voice.mp3', MP3_BASE64);
|
||||
fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'));
|
||||
documentPickerMock.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///picked/voice.mp3',
|
||||
name: 'voice.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
size: Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'),
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(importAudioFile()).resolves.toMatchObject({
|
||||
action: 'selected',
|
||||
fileName: 'voice.mp3',
|
||||
base64Data: MP3_BASE64,
|
||||
mimeType: 'audio/mpeg',
|
||||
});
|
||||
|
||||
await expect(
|
||||
exportAudioFile({
|
||||
base64Data: MP3_BASE64,
|
||||
fileName: '声浪',
|
||||
mimeType: 'audio/mpeg',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
action: 'saved',
|
||||
fileName: '声浪.mp3',
|
||||
});
|
||||
expect(writtenFiles.at(-1)).toEqual({
|
||||
uri: 'file:///cache/声浪.mp3',
|
||||
content: MP3_BASE64,
|
||||
options: { encoding: 'base64' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user