DriftLedger/tests/ocr.test.ts
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform)
  - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction)
  - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取
  - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦

  ### 微信账单抓取:节点混淆绕过(核心突破)
  - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务
  - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验
  - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动
  - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错)
  - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录)

  ### 新 UI 组件体系
  - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失)
  - ErrorBoundary:React class 错误边界(降级 UI + 重试)
  - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker
  - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让
  - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField)
  - 新增 PeriodSwitcher、RangeStatsCard 独立组件

  ### 新 Hooks & 工具
  - useBottomInset — 统一底部安全区留白
  - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案)
  - sanitize.ts — 日志脱敏工具提取

  ### 账本增删改增强
  - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set
  - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean)
  - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」
  - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误)
  - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致

  ### OCR 原生模块增强
  - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」
  - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload
  - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别)
  - 图片缩放策略重命名(scaleDownForOcr → capLongEdge)

  ### OCR 模型按需下载
  - 移除了启动时自动下载 ~30MB OCR 模型的逻辑
  - 改为首次使用 OCR 时才触发下载

  ### 设置页重设计
  - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头)
  - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片

  ### 首页优化
  - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条)
  - 日期/金额格式化增加 locale 感知(zh/en)

  ### 通知管道增强
  - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除
  - 增加 debug 日志输出(过滤原因/包名)

  ### ESLint
  - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集)
  - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies

  ### 文档
  - accessibility-wechat-guide.md — 微信无障碍伪装完整方案
  - modal-keyboard-guide.md — 弹窗键盘避让方案
  - ocr-pipeline-guide.md — OCR 三层层级管线
  - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

437 lines
21 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, detectSuspiciousAmount } from '../src/domain/pipeline/ocr';
import { MockAiVisionProvider, MockOcrEngine, OcrProcessor } from '../src/domain/pipeline/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 把"延迟扣费2026年6月27日 13:30:15"识别成日期与时间被换行分隔
// 修复前:[^\s|,]+ 会截断到 2026-06-27时间丢失
const text = '云闪付交易详情\n-1.16\n交易成功\n付款方式 招商银行银联信用卡[1589]\n扣款时间 延迟扣费2026年6月27日\n13:30:15\n订单金额 ¥1.40';
const event = parseOcrBill(text, 'com.unionpay');
expect(event).not.toBeNull();
expect(event?.occurredAt).toBe('2026-06-27 13:30');
});
it('云闪付详情页:提取"公司(乘车码)"模式商户名', () => {
// 云闪付乘车码详情页:商户名裸露,无标准标签(商品说明/收款方全称)
const text = '云闪付交易详情\n长沙公交智慧大数据科技有限公司(乘车码)\n-1.16\n交易成功\n付款方式 招商银行银联信用卡[1589]\n扣款时间 2026年6月27日 13:30:15\n订单金额 ¥1.40';
const event = parseOcrBill(text, 'com.unionpay');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-1.16');
expect(event?.counterparty).toContain('长沙公交');
});
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();
});
// ─── 真机日志回归2026-07-24 微信账单详情页) ───
it('微信消费详情页:金额负号被 OCR 识别为 en-dash U+2013也能识别', () => {
// 真机日志OCR 把 -18.56 的负号识别成了 en-dash 18.56
const text = '京东 | \u201318.56 | 当前状态 | 支付成功 | 支付时间 | 2026年7月19日19:11:41 | 商品 | 京东-订单编号3564434013915992 | 支付方式 | 零钱 | 交易单号 | 4500000272202607191472165639 | 商户单号 | 6183532607191911310261343162 | 账单服务 | 申请电子凭证 | 联系商家';
const event = parseOcrBill(text, 'com.tencent.mm');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-18.56'); // en-dash 归一化为半角减号
expect(event?.direction).toBe('expense');
});
it('微信消费详情页:半角负号 -12.50 正常识别(对照)', () => {
// 真机日志:同页面不同截图,负号正常识别为半角
const text = '京东 | -12.50 | 当前状态 | 支付成功 | 支付时间 | 2026年7月19日11:53:27 | 商品 | 京东-订单编号3564434013806431 | 商户全称 | 江苏京东信息技术有限公司 | 支付方式 | 零钱 | 交易单号 | 45000000202607194998529686 | 账单服务 | 联系商家';
const event = parseOcrBill(text, 'com.tencent.mm');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-12.50');
expect(event?.direction).toBe('expense');
});
it('微信消费详情页:提取「支付方式」作为付款方式(非「付款方式」)', () => {
// 微信账单详情页用「支付方式」而非「付款方式」,应能提取到「零钱」
const text = '京东 | -12.50 | 支付成功 | 支付时间 | 2026年7月19日11:53:27 | 商品 | 京东 | 支付方式 | 零钱 | 交易单号 | 45000000202607194998529686';
const event = parseOcrBill(text, 'com.tencent.mm');
expect(event).not.toBeNull();
expect(event?.raw['收/付款方式']).toBe('零钱'); // 应提取到,而非 Assets:Unknown
});
it('微信转账详情页:转账类特征词命中,+143.00 识别为收入', () => {
// 真机日志:转账详情页用「转账说明/转账时间/转账单号/已存入零钱」标签
const text = '转账-来自颜梓好 | +143.00 | 当前状态 | 已存入零钱 | 转账说明 | chatGPT | 转账时间 | 2026年7月24日17:09:30 | 收款时间 | 2026年7月24日17:09:48 | 转账单号 | 10002026024032540093172 | 账单服务';
const event = parseOcrBill(text, 'com.tencent.mm');
expect(event).not.toBeNull();
expect(event?.amount).toBe('143.00');
expect(event?.direction).toBe('income'); // + 号 → 收入
});
it('微信转账详情页:特征词命中不误伤列表页(列表页仍返回 null', () => {
// 列表页「转账」二字不应触发转账详情页判定(需多个转账特征词同时命中)
const listText = '全部 支出 转账 余额宝-自动转入 75.40';
expect(parseOcrBill(listText, '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('OCR 返回空文本时标记 ocr-empty区别于有内容但非账单', 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('ocr-empty');
});
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 后可再次处理
});
});
describe('detectSuspiciousAmount 可疑金额启发式', () => {
it('整数大额 + 外币特征 → 可疑(典型 OCR 丢小数点)', () => {
// 模拟招行 ChatGPT 订阅:实际 ¥143.97OCR 丢小数点变 ¥14397
const ocrText = '交易明细 GOOGLE*ChatGpt ¥14397 已入账 交易地金额 21.19 美国';
const result = detectSuspiciousAmount('14397', ocrText);
expect(result).not.toBeNull();
expect(result?.reason).toContain('14397');
expect(result?.reason).toContain('小数点');
});
it('带负号的整数大额 + 外币特征 → 可疑(去符号后判定)', () => {
const ocrText = '消费 14397 交易地金额 21.19 USD';
const result = detectSuspiciousAmount('-14397', ocrText);
expect(result).not.toBeNull();
});
it('整数大额但无外币特征 → 不可疑(正常大额整数消费)', () => {
const ocrText = '微信支付 金额 5000 商户:某商户';
expect(detectSuspiciousAmount('5000', ocrText)).toBeNull();
});
it('有小数点的金额 → 不可疑(金额正常)', () => {
const ocrText = '交易明细 ¥143.97 交易地金额 21.19 美国';
expect(detectSuspiciousAmount('143.97', ocrText)).toBeNull();
});
it('小整数(<1000→ 不可疑(即使无小数点)', () => {
const ocrText = '消费 500 交易地金额 21.19';
expect(detectSuspiciousAmount('500', ocrText)).toBeNull();
});
it('千分位整数大额 + 外币 → 可疑(去逗号后判定)', () => {
const ocrText = '消费 14,397 交易地 美国';
const result = detectSuspiciousAmount('14,397', ocrText);
expect(result).not.toBeNull();
});
});