DriftLedger/tests/ocr.test.ts
fengmengqi 76a5853ab6 feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能
核心重构 — 去除 channel 字段,引入 sourceAccount 模型:
- 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段
- rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念
- dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断
- transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户
- 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试
新增基础设施:
- domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义
- domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找
- domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题
- domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听
OCR 增强:
- 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取
- checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额)
- 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配
- 时间解析支持中文格式(年月日)和跨年推断
- OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配
UI 全面升级:
- 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体
- 新增 commonStyles.ts 统一 chip/input/modal 等通用样式
- 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示
- 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细)
- TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线
- CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标
- Button/Card 增加 press 缩放动画
管道与自动化改进:
- automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认)
- 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR
- rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理
- metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则
存储与同步:
- storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制
- 备份升级到 v2 格式,包含 settings 和 metadata
- 同步路径统一从 mobile.bean 改为 main.bean
- _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean
其他:
- 删除独立日历页面,功能合并到报表月报标签
- i18n 清理:移除渠道相关翻译,新增 50+ 翻译键
- docs/android-build-guide.md 重写为 APK 体积优化指南
- 新增 design-system/beancount-mobile/MASTER.md 设计系统文档
- 测试全面更新覆盖以上所有变更
2026-07-18 18:02:45 +08:00

337 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('星巴克');
});
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?.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);
});
it('千分位金额识别', () => {
const text = '微信支付 金额 1,234.50 商户:星巴克 2026-07-13 14:30';
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
expect(result.event?.amount).toBe('-1234.5');
});
it('英文商户名空格保留', () => {
const text = '微信支付 金额 24.50 商户:Starbucks Coffee 2026-07-13 14:30';
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
expect(result.event?.counterparty).toBe('Starbucks Coffee');
});
it('中文年月日及缺失年份时间识别', () => {
const text = '微信支付 金额 24.50 商户:星巴克 7月13日 14:30';
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
const thisYear = new Date().getFullYear();
expect(result.event?.occurredAt).toBe(`${thisYear}-07-13 14:30`);
});
it('向你转账推断为收入', () => {
const text = '微信支付 金额 100 张三向你转账 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('100');
});
});
describe('ocr - parseOcrBill 退化解析', () => {
it('规则匹配命中时返回规则结果', () => {
const event = parseOcrBill('微信支付 金额 10 商户:测试');
expect(event?.direction).toBe('expense');
});
it('退化:提取金额 + 推断方向', () => {
const event = parseOcrBill('您有一笔 50元 消费');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-50');
expect(event?.direction).toBe('expense');
});
it('无任何金额返回 null', () => {
expect(parseOcrBill('纯文本无金额')).toBeNull();
});
it('退化分支非支付App包名保留在 raw 中', () => {
const event = parseOcrBill('消费 ¥100', 'com.test.app');
expect(event).not.toBeNull();
expect(event?.raw.packageName).toBe('com.test.app');
});
it('退化分支拦截支付App包名以防止列表页误匹配', () => {
const event1 = parseOcrBill('消费 ¥100', 'com.eg.android.AlipayGphone');
expect(event1).toBeNull();
const event2 = parseOcrBill('付款 50元', 'com.tencent.mm');
expect(event2).toBeNull();
});
it('退化分支:未知包名原样保留', () => {
const event = parseOcrBill('支付 ¥9.9', 'com.unknown.app');
expect(event?.raw.packageName).toBe('com.unknown.app');
});
it('退化分支:无 packageName 时为空字符串', () => {
const event = parseOcrBill('支付 ¥9.9');
expect(event?.raw.packageName).toBe('');
});
});
describe('ocr - 账单详情页识别', () => {
// 本次真机实测的抖音月付还款账单详情页(| 分隔格式)
const douyinCase = '中工 | d | 突圳市中融小额贷款有限公司 | -8.51 | 交易成功 | 支付时间 | 2026-07-15.09:01:18 | 付款方式 | 兴业银行储蓄卡2586 | 商品说明 | 抖音月付 | 支付奖励 | 立即领取10积分 | 收款万全称 | 深圳市中融小额贷款有限公司 | 订单号 | 2026071523001442621427676501 | 商家订单号 | 200107260715010284934083340 | 账单管理 | 账单分类 | 信用借还 | 标签 | 请选择 | 为您推荐 | 贷款十 | 计入收支 | 备注 | 添加';
it('抖音月付详情页:识别成功,金额 -8.51', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-8.51');
expect(event?.direction).toBe('expense');
expect(event?.raw.packageName).toBe('com.eg.android.AlipayGphone');
});
it('详情页:提取付款方式到 raw供账户解析', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.raw['收/付款方式']).toBe('兴业银行储蓄卡2586');
});
it('详情页:提取支付时间(日期时间用 . 连接的格式)', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.occurredAt).toBe('2026-07-15 09:01');
});
it('详情页:提取商品说明', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.counterparty).toBe('抖音月付');
});
it('详情页:换行分隔格式也能识别', () => {
const text = '交易详情\n-15.00\n交易成功\n付款方式 余额宝\n商品说明 星巴克咖啡\n支付时间 2026-07-14 10:30:00\n订单号 20260714103000123456';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-15.00');
expect(event?.counterparty).toBe('星巴克咖啡');
expect(event?.raw['收/付款方式']).toBe('余额宝');
});
it('详情页:金额不误抓日期时间里的数字', () => {
// 2026-07-15.09 不应被误认为 -15.09
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.amount).toBe('-8.51'); // 而非 -15.09
});
it('详情页:正数金额识别为收入', () => {
const text = '退款详情\n+88.88\n退款成功\n付款方式 花呗\n商品说明 商品退款\n支付时间 2026-07-14 10:30:00';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event?.amount).toBe('88.88');
expect(event?.direction).toBe('income');
});
it('详情页:无小数点但有正负号金额识别', () => {
const text = '交易详情\n+7540\n交易成功\n付款方式 余额宝\n商品说明 收益转账\n支付时间 2026-07-14 10:30:00';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event?.amount).toBe('7540');
expect(event?.direction).toBe('income');
});
it('详情页:交易对手提取与后缀清理', () => {
// 微信商品+订单号后缀
const text1 = '交易详情\n-15.50\n支付时间 2026-07-15 19:39:58\n商品 京东-订单编号356043400524804\n付款方式 零钱';
const event1 = parseOcrBill(text1, 'com.tencent.mm');
expect(event1?.counterparty).toBe('京东');
// 支付宝对方账户
const text2 = '交易成功\n+7540\n对方账户 广州市弘智服饰有限公司\n创建时间 2026-07-15 20:13:14\n付款方式 余额宝';
const event2 = parseOcrBill(text2, 'com.eg.android.AlipayGphone');
expect(event2?.counterparty).toBe('广州市弘智服饰有限公司');
});
it('详情页:支持中文年月日时间格式及补零', () => {
// 包含中文年月日,且没有空格隔开时间
const text = '付款方:黄*青\n50.00\n支付时间 2026年7月13日20:29:46\n商品 收款\n付款方式 余额';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event).not.toBeNull();
// 验证转换正确,月/日/时/分格式和空格规范化
expect(event?.occurredAt).toBe('2026-07-13 20:29');
});
it('详情页:金额 OCR 常见符号错识纠错(如 898-34 -> 898.34', () => {
// 模拟还款成功页面,金额中的点被错识为减号或者逗号
const text1 = '还款成功\n898-34\n付款方式 余额\n商品说明 还款\n创建时间 2026-07-13 17:57:45';
const event1 = parseOcrBill(text1, 'com.eg.android.AlipayGphone');
expect(event1).not.toBeNull();
expect(event1?.amount).toBe('-898.34'); // 应当成功纠错为小数点并记为支出
const text2 = '交易成功\n1234,56\n付款方式 余额\n商品说明 交易\n创建时间 2026-07-13 17:57:45';
const event2 = parseOcrBill(text2, 'com.eg.android.AlipayGphone');
expect(event2?.amount).toBe('-1234.56'); // 应当成功将逗号规范化为小数点
const text3 = '还款成功\n1.898.34\n付款方式 余额\n商品说明 还款\n创建时间 2026-07-13 17:57:45';
const event3 = parseOcrBill(text3, 'com.eg.android.AlipayGphone');
expect(event3).not.toBeNull();
expect(event3?.amount).toBe('-1898.34'); // 应当成功将千分位点号规范化并保留小数位点号
});
it('误判防护:纯订单号页面不识别', () => {
const text = '订单号 2026071523001442621427676501\n商家订单号 200107260715010284934083340';
expect(parseOcrBill(text)).toBeNull();
});
it('误判防护:仅 1 个特征词不识别(需 ≥2 个)', () => {
const text = '订单号 这是一个普通页面,只有订单号一个特征词,金额 100';
// 「金额 100」会被退化解析抓到但不应走详情页分支
const event = parseOcrBill(text);
// 退化解析会命中(有"金额"关键词),验证它走的是退化而非详情页
expect(event?.memo).toBe('OCR 退化解析');
});
it('误判防护支付App列表页不应被退化解析匹配', () => {
// 模拟支付宝列表页
const listTextAlipay = '全部 支出 转账 余额宝-自动转入 75.40 抖音月付 -8.51';
expect(parseOcrBill(listTextAlipay, 'com.eg.android.AlipayGphone')).toBeNull();
// 模拟微信列表页
const listTextWechat = '收支统计 2026年7月 支出¥37032收入¥5651 京东 -15.50';
expect(parseOcrBill(listTextWechat, 'com.tencent.mm')).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 1 规则匹配进入 Layer 2', async () => {
const engine = new MockOcrEngine();
// 模拟包含“招商银行信用卡”的详情页文本(容易误触发 Layer 1 银行卡规则)
engine.defaultResponse = '淘宝闪购 | 15.96 | 交易成功 | 支付时间 2026-07-12 19:19:39 | 付款方式 招商银行信用卡1589';
const processor = new OcrProcessor(engine);
const result = await processor.process('img-detail', 'com.eg.android.AlipayGphone');
// 应绕过 layer1-rule 并通过 layer2-ocr (详情页解析) 提取到正确的 -15.96 金额
expect(result.layer).toBe('layer2-ocr');
expect(result.event?.amount).toBe('-15.96');
});
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',
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('busy');
});
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 后可再次处理
});
});