DriftLedger/tests/ocr.test.ts
fengmengqi 7fa345b558 feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00

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