品牌与配置 - 重命名 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 新增不同格式金额配对、指纹唯一性测试
338 lines
15 KiB
TypeScript
338 lines
15 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.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 后可再次处理
|
||
});
|
||
});
|