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 后可再次处理 }); });