- 切换到 expo-router 文件路由,删除 App.tsx - 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取), notification-listener, screenshot-monitor, sms-receiver - 实现核心领域逻辑:billPipeline (账单流水), dedup (去重), transferRecognizer (转账识别), ruleEngine + categories (双轨制分类), budgets, creditCards, recurring, sync, ocrProcessor - 增强 ledger.ts:支持 balance assertion, option, pad/note 指令, posting 级 metadata, cost/price 解析 - 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图, 分类管理, 信用卡, 定期交易, 规则管理 - 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore, metadataStore, automationStore + 持久化 - 新增 AI 功能:chatAssistant, monthlySummary, voiceInput - 实现多端同步:gitSync, webdavSync, icloudSync - 新增主题系统 (tokens/presets) 和 i18n (zh/en) - 添加 30+ 单元测试覆盖核心逻辑
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||
|
||
/**
|
||
* FileSystemBackend 逻辑测试。
|
||
*
|
||
* 由于 expo-file-system 在测试环境不可用,我们 mock 它的接口,
|
||
* 验证 FileSystemBackend 的 read/writeAtomic/recoverIfNeeded 编排逻辑正确。
|
||
*/
|
||
|
||
// Mock expo-file-system/legacy(fileSystemBackend.ts 用 import * as,所以直接导出属性)
|
||
const mockFs = {
|
||
documentDirectory: '/mock/',
|
||
getInfoAsync: vi.fn(),
|
||
readAsStringAsync: vi.fn(),
|
||
writeAsStringAsync: vi.fn(),
|
||
deleteAsync: vi.fn(),
|
||
moveAsync: vi.fn(),
|
||
};
|
||
|
||
vi.mock('expo-file-system/legacy', () => mockFs);
|
||
|
||
// Mock logger
|
||
vi.mock('../src/utils/logger', () => ({
|
||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||
}));
|
||
|
||
// 延迟导入(vi.mock 需要在模块加载前执行)
|
||
const { FileSystemBackend } = await import('../src/services/fileSystemBackend');
|
||
|
||
function resetMock() {
|
||
mockFs.getInfoAsync.mockReset();
|
||
mockFs.readAsStringAsync.mockReset();
|
||
mockFs.writeAsStringAsync.mockReset();
|
||
mockFs.deleteAsync.mockReset();
|
||
mockFs.moveAsync.mockReset();
|
||
}
|
||
|
||
describe('FileSystemBackend', () => {
|
||
beforeEach(resetMock);
|
||
|
||
it('read() 返回文件内容', async () => {
|
||
mockFs.getInfoAsync.mockResolvedValue({ exists: true });
|
||
mockFs.readAsStringAsync.mockResolvedValue('2026-01-01 *\n Assets:Bank');
|
||
|
||
const backend = new FileSystemBackend();
|
||
const content = await backend.read();
|
||
expect(content).toBe('2026-01-01 *\n Assets:Bank');
|
||
});
|
||
|
||
it('read() 文件不存在时返回空串', async () => {
|
||
mockFs.getInfoAsync.mockResolvedValue({ exists: false });
|
||
|
||
const backend = new FileSystemBackend();
|
||
const content = await backend.read();
|
||
expect(content).toBe('');
|
||
});
|
||
|
||
it('writeAtomic() 先写 tmp 再删旧文件再 rename', async () => {
|
||
mockFs.getInfoAsync.mockResolvedValue({ exists: true });
|
||
mockFs.writeAsStringAsync.mockResolvedValue(undefined);
|
||
mockFs.deleteAsync.mockResolvedValue(undefined);
|
||
mockFs.moveAsync.mockResolvedValue(undefined);
|
||
|
||
const backend = new FileSystemBackend();
|
||
await backend.writeAtomic('new content');
|
||
|
||
// 验证写入 tmp
|
||
expect(mockFs.writeAsStringAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', 'new content');
|
||
// 验证删除旧文件
|
||
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/mobile.bean', { idempotent: true });
|
||
// 验证 rename tmp → 正式文件
|
||
expect(mockFs.moveAsync).toHaveBeenCalledWith({ from: '/mock/mobile.bean.tmp', to: '/mock/mobile.bean' });
|
||
});
|
||
|
||
it('writeAtomic() 旧文件不存在时不删除', async () => {
|
||
mockFs.getInfoAsync.mockResolvedValue({ exists: false });
|
||
mockFs.writeAsStringAsync.mockResolvedValue(undefined);
|
||
mockFs.moveAsync.mockResolvedValue(undefined);
|
||
|
||
const backend = new FileSystemBackend();
|
||
await backend.writeAtomic('content');
|
||
|
||
expect(mockFs.deleteAsync).not.toHaveBeenCalled();
|
||
expect(mockFs.moveAsync).toHaveBeenCalled();
|
||
});
|
||
|
||
it('recoverIfNeeded() 检测到 tmp 残留时清理并返回 true', async () => {
|
||
// 第一次 getInfoAsync(检测 tmp)返回存在
|
||
mockFs.getInfoAsync.mockResolvedValue({ exists: true });
|
||
mockFs.deleteAsync.mockResolvedValue(undefined);
|
||
|
||
const backend = new FileSystemBackend();
|
||
const result = await backend.recoverIfNeeded();
|
||
expect(result).toBe(true);
|
||
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', { idempotent: true });
|
||
});
|
||
|
||
it('recoverIfNeeded() 无 tmp 残留时返回 false', async () => {
|
||
mockFs.getInfoAsync.mockResolvedValue({ exists: false });
|
||
|
||
const backend = new FileSystemBackend();
|
||
const result = await backend.recoverIfNeeded();
|
||
expect(result).toBe(false);
|
||
});
|
||
});
|