DriftLedger/tests/ocr.test.ts
fengmengqi bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00

338 lines
15 KiB
TypeScript
Raw Permalink 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.50');
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.50');
});
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.50');
});
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, { layer1Enabled: true, layer2Enabled: true, layer3Enabled: 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, { layer1Enabled: true, layer2Enabled: true, layer3Enabled: 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.layer).toBe('layer1-rule');
expect(r2.skipped).toBeUndefined();
});
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 后可再次处理
});
});