- 切换到 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+ 单元测试覆盖核心逻辑
154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { ErrorCategory, isRecoverable, isRetryable, toAppError, withRetry } from '../src/utils/errorHandler';
|
|
import { LogLevel, createLogger } from '../src/utils/logger';
|
|
|
|
describe('errorHandler - toAppError', () => {
|
|
it('把 Error 归一化为 AppError', () => {
|
|
const appErr = toAppError(new Error('test'), ErrorCategory.NETWORK);
|
|
expect(appErr.category).toBe(ErrorCategory.NETWORK);
|
|
expect(appErr.message).toBe('test');
|
|
expect(appErr.retryable).toBe(true); // NETWORK 可重试
|
|
});
|
|
|
|
it('把字符串归一化', () => {
|
|
const appErr = toAppError('字符串错误', ErrorCategory.PARSING);
|
|
expect(appErr.message).toBe('字符串错误');
|
|
expect(appErr.retryable).toBe(false); // PARSING 不可重试
|
|
});
|
|
|
|
it('AppError 再经过 toAppError 保持不变', () => {
|
|
const appErr = toAppError(new Error('x'), ErrorCategory.DATABASE);
|
|
const again = toAppError(appErr);
|
|
expect(again).toBe(appErr);
|
|
});
|
|
});
|
|
|
|
describe('errorHandler - withRetry', () => {
|
|
it('首次成功不重试', async () => {
|
|
const fn = vi.fn().mockResolvedValue('ok');
|
|
const result = await withRetry(fn, 3, 10);
|
|
expect(result).toBe('ok');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('失败后重试到成功', async () => {
|
|
const fn = vi.fn()
|
|
.mockRejectedValueOnce(new Error('fail1'))
|
|
.mockRejectedValueOnce(new Error('fail2'))
|
|
.mockResolvedValueOnce('ok');
|
|
const result = await withRetry(fn, 3, 10);
|
|
expect(result).toBe('ok');
|
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('重试次数耗尽后抛出最后一个错误', async () => {
|
|
const fn = vi.fn().mockRejectedValue(new Error('always fail'));
|
|
await expect(withRetry(fn, 2, 10)).rejects.toThrow('always fail');
|
|
expect(fn).toHaveBeenCalledTimes(3); // 首次 + 2 次重试
|
|
});
|
|
|
|
it('retries=0 时不重试', async () => {
|
|
const fn = vi.fn().mockRejectedValue(new Error('x'));
|
|
await expect(withRetry(fn, 0, 10)).rejects.toThrow('x');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('errorHandler - isRetryable/isRecoverable', () => {
|
|
it('NETWORK 错误可重试', () => {
|
|
const err = toAppError(new Error('x'), ErrorCategory.NETWORK);
|
|
expect(isRetryable(err)).toBe(true);
|
|
expect(isRecoverable(err)).toBe(true);
|
|
});
|
|
|
|
it('PARSING 错误不可重试', () => {
|
|
const err = toAppError(new Error('x'), ErrorCategory.PARSING);
|
|
expect(isRetryable(err)).toBe(false);
|
|
expect(isRecoverable(err)).toBe(false);
|
|
});
|
|
|
|
it('未知错误默认可恢复', () => {
|
|
expect(isRecoverable(new Error('x'))).toBe(true);
|
|
expect(isRetryable(new Error('x'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('logger', () => {
|
|
it('记录各级别日志', () => {
|
|
const log = createLogger(100);
|
|
log.consoleOutput = false; // 测试不输出 console
|
|
log.debug('tag1', 'debug msg');
|
|
log.info('tag1', 'info msg');
|
|
log.warn('tag2', 'warn msg');
|
|
log.error('tag2', 'error msg', { code: 500 });
|
|
const logs = log.getLogs();
|
|
expect(logs).toHaveLength(4);
|
|
expect(logs[0].level).toBe(LogLevel.DEBUG);
|
|
expect(logs[3].level).toBe(LogLevel.ERROR);
|
|
expect(logs[3].data).toEqual({ code: 500 });
|
|
});
|
|
|
|
it('循环缓冲:超出上限丢弃最旧', () => {
|
|
const log = createLogger(3);
|
|
log.consoleOutput = false;
|
|
log.info('t', 'msg1');
|
|
log.info('t', 'msg2');
|
|
log.info('t', 'msg3');
|
|
log.info('t', 'msg4'); // 触发裁剪,丢弃 msg1
|
|
const logs = log.getLogs();
|
|
expect(logs).toHaveLength(3);
|
|
expect(logs[0].message).toBe('msg2');
|
|
expect(logs[2].message).toBe('msg4');
|
|
});
|
|
|
|
it('minLevel 过滤低级别', () => {
|
|
const log = createLogger(100);
|
|
log.consoleOutput = false;
|
|
log.minLevel = LogLevel.WARN;
|
|
log.debug('t', 'debug'); // 被过滤
|
|
log.info('t', 'info'); // 被过滤
|
|
log.warn('t', 'warn');
|
|
log.error('t', 'error');
|
|
expect(log.getLogs()).toHaveLength(2);
|
|
});
|
|
|
|
it('query 按级别和标签过滤', () => {
|
|
const log = createLogger(100);
|
|
log.consoleOutput = false;
|
|
log.info('alpha', 'a1');
|
|
log.error('alpha', 'a2');
|
|
log.error('beta', 'b1');
|
|
expect(log.query({ tag: 'alpha' })).toHaveLength(2);
|
|
expect(log.query({ minLevel: LogLevel.ERROR })).toHaveLength(2);
|
|
expect(log.query({ minLevel: LogLevel.ERROR, tag: 'alpha' })).toHaveLength(1);
|
|
});
|
|
|
|
it('clearLogs 清空', () => {
|
|
const log = createLogger(100);
|
|
log.consoleOutput = false;
|
|
log.info('t', 'x');
|
|
log.clearLogs();
|
|
expect(log.getLogs()).toHaveLength(0);
|
|
});
|
|
|
|
it('setMaxBuffer 动态调整并立即裁剪', () => {
|
|
const log = createLogger(100);
|
|
log.consoleOutput = false;
|
|
log.info('t', 'm1');
|
|
log.info('t', 'm2');
|
|
log.info('t', 'm3');
|
|
log.setMaxBuffer(2);
|
|
expect(log.getLogs()).toHaveLength(2);
|
|
expect(log.getLogs()[0].message).toBe('m2');
|
|
});
|
|
|
|
it('日志条目含 ISO 时间戳', () => {
|
|
const log = createLogger(100);
|
|
log.consoleOutput = false;
|
|
log.info('t', 'x');
|
|
const entry = log.getLogs()[0];
|
|
expect(() => new Date(entry.timestamp).toISOString()).not.toThrow();
|
|
expect(new Date(entry.timestamp).getTime()).toBeLessThanOrEqual(Date.now());
|
|
});
|
|
});
|