- 切换到 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+ 单元测试覆盖核心逻辑
156 lines
6.0 KiB
TypeScript
156 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { matchOcrRule, parseOcrBill } from '../src/domain/ocr';
|
||
import { MockAiVisionProvider, MockOcrEngine, OcrProcessor, type OcrProcessResult } from '../src/domain/ocrProcessor';
|
||
|
||
describe('ocr - matchOcrRule', () => {
|
||
it('微信支付规则匹配', () => {
|
||
const text = '微信支付 金额 24.50 商户:星巴克 2026-07-13 14:30';
|
||
const result = matchOcrRule(text, 'com.tencent.mm');
|
||
expect(result.matched).toBe(true);
|
||
expect(result.ruleName).toBe('微信支付');
|
||
expect(result.event?.amount).toBe('-24.5');
|
||
expect(result.event?.counterparty).toBe('星巴克');
|
||
expect(result.event?.channel).toBe('WeChat');
|
||
});
|
||
|
||
it('支付宝规则匹配', () => {
|
||
const text = '支付宝 收款方:肯德基 金额 35.00 支出';
|
||
const result = matchOcrRule(text, 'com.eg.android.AlipayGphone');
|
||
expect(result.matched).toBe(true);
|
||
expect(result.ruleName).toBe('支付宝');
|
||
expect(result.event?.direction).toBe('expense');
|
||
});
|
||
|
||
it('银行卡规则匹配(含尾号)', () => {
|
||
const text = '尾号1234 信用卡 消费 100.50 商户:超市 2026-07-13 10:00';
|
||
const result = matchOcrRule(text);
|
||
expect(result.matched).toBe(true);
|
||
expect(result.ruleName).toBe('银行卡');
|
||
expect(result.event?.channel).toBe('Bank:1234');
|
||
expect(result.event?.memo).toContain('1234');
|
||
});
|
||
|
||
it('收入方向识别', () => {
|
||
const text = '微信支付 金额 5000 收款 2026-07-13 14:30';
|
||
const result = matchOcrRule(text);
|
||
expect(result.matched).toBe(true);
|
||
expect(result.event?.direction).toBe('income');
|
||
expect(result.event?.amount).toBe('5000'); // 收入为正
|
||
});
|
||
|
||
it('退款方向识别', () => {
|
||
const text = '支付宝 金额 30 退款 2026-07-13 14:30';
|
||
const result = matchOcrRule(text);
|
||
expect(result.event?.direction).toBe('refund');
|
||
});
|
||
|
||
it('无金额不匹配', () => {
|
||
const text = '微信支付 商户:星巴克';
|
||
const result = matchOcrRule(text);
|
||
expect(result.matched).toBe(false);
|
||
});
|
||
|
||
it('非账单内容不匹配', () => {
|
||
const result = matchOcrRule('今天天气真好');
|
||
expect(result.matched).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('ocr - parseOcrBill 退化解析', () => {
|
||
it('规则匹配命中时返回规则结果', () => {
|
||
const event = parseOcrBill('微信支付 金额 10 商户:测试');
|
||
expect(event?.channel).toBe('WeChat');
|
||
});
|
||
|
||
it('退化:提取金额 + 推断方向', () => {
|
||
const event = parseOcrBill('您有一笔 50元 消费');
|
||
expect(event).not.toBeNull();
|
||
expect(event?.amount).toBe('-50');
|
||
expect(event?.direction).toBe('expense');
|
||
});
|
||
|
||
it('无任何金额返回 null', () => {
|
||
expect(parseOcrBill('纯文本无金额')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('OcrProcessor 分层处理', () => {
|
||
it('Layer 1 规则命中', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '微信支付 金额 24.50 商户:星巴克 2026-07-13 14:30';
|
||
const processor = new OcrProcessor(engine);
|
||
const result = await processor.process('img1', 'com.tencent.mm');
|
||
expect(result.layer).toBe('layer1-rule');
|
||
expect(result.event?.amount).toBe('-24.5');
|
||
});
|
||
|
||
it('Layer 2 退化解析(规则未命中但有金额)', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '某App 消费 88元';
|
||
const processor = new OcrProcessor(engine);
|
||
const result = await processor.process('img2');
|
||
expect(result.layer).toBe('layer2-ocr');
|
||
expect(result.event?.amount).toBe('-88');
|
||
});
|
||
|
||
it('无文本时跳过到 Layer 3(未启用 AI → none)', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '';
|
||
const processor = new OcrProcessor(engine);
|
||
const result = await processor.process('img3');
|
||
expect(result.layer).toBe('none');
|
||
expect(result.skipped).toBe('non-bill');
|
||
});
|
||
|
||
it('Layer 3 AI Vision 启用时降级', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '非账单的随机文本';
|
||
const aiProvider = new MockAiVisionProvider({
|
||
id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense',
|
||
channel: 'AI', counterparty: 'AI识别', memo: '', raw: {},
|
||
});
|
||
const processor = new OcrProcessor(engine, aiProvider, { aiVisionEnabled: true, landscapeDnd: false });
|
||
const result = await processor.process('img4');
|
||
expect(result.layer).toBe('layer3-ai');
|
||
expect(result.event?.amount).toBe('99');
|
||
});
|
||
|
||
it('横屏免打扰跳过', async () => {
|
||
const engine = new MockOcrEngine();
|
||
const processor = new OcrProcessor(engine, undefined, { aiVisionEnabled: false, landscapeDnd: true });
|
||
const result = await processor.process('img5', undefined, true);
|
||
expect(result.skipped).toBe('landscape-dnd');
|
||
});
|
||
|
||
it('去重:同一图片不重复处理', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '微信支付 金额 10 商户:x';
|
||
const processor = new OcrProcessor(engine);
|
||
const r1 = await processor.process('same-image');
|
||
const r2 = await processor.process('same-image');
|
||
expect(r1.layer).toBe('layer1-rule');
|
||
expect(r2.skipped).toBe('dedup');
|
||
});
|
||
|
||
it('串行化:处理中再次调用跳过', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '微信支付 金额 10 商户:x';
|
||
const processor = new OcrProcessor(engine);
|
||
const p1 = processor.process('img-a');
|
||
const p2 = processor.process('img-b'); // p1 未完成时
|
||
const [r1, r2] = await Promise.all([p1, p2]);
|
||
expect(r1.layer).toBe('layer1-rule');
|
||
expect(r2.skipped).toBe('dedup');
|
||
});
|
||
|
||
it('reset 清除去重状态', async () => {
|
||
const engine = new MockOcrEngine();
|
||
engine.defaultResponse = '微信支付 金额 10 商户:x';
|
||
const processor = new OcrProcessor(engine);
|
||
await processor.process('img-reset');
|
||
processor.reset();
|
||
const result = await processor.process('img-reset');
|
||
expect(result.layer).toBe('layer1-rule'); // reset 后可再次处理
|
||
});
|
||
});
|