补齐移动壳文件动作单测

移动壳 files helper 增加系统文件动作直接单测

移动壳配置门禁反查文件动作测试边界

宿主壳文档和共享决策同步移动文件动作覆盖
This commit is contained in:
2026-06-20 08:57:19 +08:00
parent 3873fdaf64
commit 05e7ca1015
6 changed files with 341 additions and 5 deletions
@@ -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' },
});
});
});