/** * 无障碍文本解析器测试。 * * 重点验证: * - 银行详情页(兴业/建行/招行等)通用解析器,按页面特征词分派 * - 金额格式兼容性:¥ 前缀、千分位逗号、符号前置/后置负号 * - 不破坏支付宝/微信现有解析行为 * * 测试样本取自真实无障碍日志(兴业/建行/支付宝详情页节点文本)。 */ import { describe, it, expect } from 'vitest'; import { parseAccessibilityTexts } from '../src/services/automation/accessibilityParser'; // ─── 真实样本(取自日志) ─────────────────────────────────────── /** 兴业银行交易明细详情页(日志 14:45:49,支出 49.00)。 */ const CIB_TEXTS = [ '交易明细详情', '支出金额', '¥49.00', '对方信息', '1100000000', '对方银行', '财付通支付科技有限公司', '交易摘要', '跨行无卡消费', '交易时间', '2026/07/17 20:29:48', '记账日期', '2026/07/17', '用途', '财付通(银联云闪付)', '账户余额', '11,565.20 ', '', ]; /** 建设银行明细详情页(日志 14:47:57,还款 -12.32,京东白条)。 */ const CCB_TEXTS = [ '明细详情', 'png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8BAM', '还款', '¥-12.32', '交易账户', '湘潭大学 6217***9959', '交易时间', '2026-07-22 11:32:06', '交易地点/附言', '京东支付-京东金融-京东白条及金条业务网银平台', '对方账户', '京***及金条业务网银平台 Z******0011', '记账日', '2026-07-22', '账户余额', '¥ 0.79', '备注', ]; /** 支付宝账单详情页(日志 14:48:23,花呗消费 66.8)。 */ const ALIPAY_TEXTS = [ '盛香亭新式热卤', '支出66.8元', '交易成功', '支付时间', '2026-07-24 21:53:15', '2026-07-24 21:53:15', '付款方式', '花呗 ', '商品说明', '湘江新区闪亮云顶餐饮店', '湘江新区闪亮云顶餐饮店', '账单分类', '餐饮美食', '账单详情', ]; // ─── 银行解析器 ───────────────────────────────────────────── describe('parseBankTexts(通用银行解析器)', () => { it('解析兴业银行详情页:金额带 ¥ 前缀,方向为支出', () => { const event = parseAccessibilityTexts(CIB_TEXTS, 'com.cib.cibmb'); expect(event).not.toBeNull(); expect(event!.amount).toBe('49.00'); expect(event!.currency).toBe('CNY'); expect(event!.direction).toBe('expense'); expect(event!.occurredAt).toBe('2026/07/17 20:29:48'); }); it('解析兴业银行详情页:提取对手方(被动扣款通知无持卡账户)', () => { const event = parseAccessibilityTexts(CIB_TEXTS, 'com.cib.cibmb'); expect(event).not.toBeNull(); // 对手方:对方信息下一节点为手机号(纯数字被跳过),回退到对方银行或交易摘要 expect(event!.counterparty.length).toBeGreaterThan(0); // 兴业这类被动扣款通知没有"交易账户"标签,付款方式从包名友好名兜底 // (com.cib.cibmb → "兴业银行"),避免落入 Assets:Unknown 兜底 expect(event!.raw['收/付款方式']).toBe('兴业银行'); }); it('解析建设银行还款页:金额 ¥-12.32(符号前置负号)', () => { const event = parseAccessibilityTexts(CCB_TEXTS, 'com.chinamworld.main'); expect(event).not.toBeNull(); expect(event!.amount).toBe('12.32'); expect(event!.direction).toBe('expense'); expect(event!.occurredAt).toBe('2026-07-22 11:32:06'); }); it('付款方式缺省时从包名友好名兜底(建行 → 建设银行)', () => { // 建行这条有"交易账户"标签会优先提取,这里构造一个无任何付款方式标签的银行详情页 const texts = [ '明细详情', '支出金额', '¥100.00', '对方信息', '某商户', '交易时间', '2026-07-24 10:00:00', '记账日', '2026-07-24', '账户余额', '¥ 500.00', ]; const event = parseAccessibilityTexts(texts, 'com.chinamworld.main'); expect(event).not.toBeNull(); expect(event!.raw['收/付款方式']).toBe('建设银行'); }); it('解析建设银行还款页:备注包含"还款"文案(供还款识别规则命中)', () => { const event = parseAccessibilityTexts(CCB_TEXTS, 'com.chinamworld.main'); expect(event).not.toBeNull(); expect(event!.memo).toContain('还款'); }); it('解析建设银行还款页:交易账户作为付款方式写入 raw', () => { const event = parseAccessibilityTexts(CCB_TEXTS, 'com.chinamworld.main'); expect(event).not.toBeNull(); expect(event!.raw['收/付款方式']).toContain('湘潭大学'); }); it('跳过 base64 图片节点,不误识别为金额或对手方', () => { const event = parseAccessibilityTexts(CCB_TEXTS, 'com.chinamworld.main'); expect(event).not.toBeNull(); expect(event!.counterparty).not.toContain('base64'); expect(event!.amount).not.toContain('base64'); }); it('任意包名都能触发银行解析(按页面特征词分派,不依赖包名白名单)', () => { // 用一个未登记的包名,但文本含银行详情页特征词,应能解析 const event = parseAccessibilityTexts(CIB_TEXTS, 'com.unknown.bank.app'); expect(event).not.toBeNull(); expect(event!.amount).toBe('49.00'); }); it('招行掌银(交易明细 + 带冒号标签 + 无商户标签):不降级 OCR,商户取金额前节点', () => { // 真机日志:招行掌银 ChatGPT 订阅,特征词"交易明细",标签带冒号,商户名裸露在金额前 const CMB_TEXTS = [ '图标', '交易明细', 'GOOGLE *ChatGPT', '¥143.97', '已入账', '信用卡:', '3315', '交易时间:', '2026-07-23 00:00:00', '入账日期:', '2026-07-24', '国家或地区:', '美国', '交易地:', '交易地金额:', '21.19', '我要分期', ]; const event = parseAccessibilityTexts(CMB_TEXTS, 'com.cmbchina.ccd.pluto.cmbActivity'); expect(event).not.toBeNull(); // 金额取带 ¥ 的 143.97(非交易地金额 21.19) expect(event?.amount).toBe('143.97'); // 商户取金额前节点(GOOGLE *ChatGPT),跳过特征词"交易明细" expect(event?.counterparty).toBe('GOOGLE *ChatGPT'); // 时间:含"交易时间"的节点("交易时间:")取下一节点 expect(event?.occurredAt).toContain('2026-07-23'); }); }); // ─── 金额格式兼容性 ───────────────────────────────────────── describe('金额格式兼容性', () => { const makeTexts = (amountNode: string) => [ '交易明细详情', '支出金额', amountNode, '对方信息', '某商户', '交易时间', '2026-07-24 10:00:00', '记账日期', '2026-07-24', ]; it('支持 ¥ 前缀:¥49.00', () => { const event = parseAccessibilityTexts(makeTexts('¥49.00'), 'com.cib.cibmb'); expect(event?.amount).toBe('49.00'); }); it('支持 ¥ 前缀:¥49.00', () => { const event = parseAccessibilityTexts(makeTexts('¥49.00'), 'com.cib.cibmb'); expect(event?.amount).toBe('49.00'); }); it('支持符号前置负号:¥-12.32', () => { const event = parseAccessibilityTexts(makeTexts('¥-12.32'), 'com.cib.cibmb'); expect(event?.amount).toBe('12.32'); expect(event?.direction).toBe('expense'); }); it('支持金额前带空格:¥ 0.79', () => { const event = parseAccessibilityTexts(makeTexts('¥ 0.79'), 'com.cib.cibmb'); expect(event?.amount).toBe('0.79'); }); it('支持千分位逗号:11,565.20', () => { const event = parseAccessibilityTexts( ['交易明细详情', '交易摘要', '某消费', '交易金额', '1,234.56', '交易时间', '2026-07-24 10:00:00', '记账日', '2026-07-24'], 'com.cib.cibmb', ); expect(event?.amount).toBe('1234.56'); }); it('不匹配纯整数(避免误吞年份/卡号):2026', () => { const event = parseAccessibilityTexts( ['交易明细详情', '支出金额', '2026', '对方信息', '商户', '交易时间', '2026-07-24 10:00:00', '记账日', '2026-07-24'], 'com.cib.cibmb', ); // 金额无法解析(2026 无小数点),应返回 null expect(event).toBeNull(); }); it('不误匹配非金额的纯数字小数(无 ¥ 也无千分位时,兜底分支不接管)', () => { // "66.8" 是支付宝风格,但此处包名是银行,且无 ¥/千分位,兜底分支应跳过 // 只有当存在金额标签"支出金额"时才提取 const event = parseAccessibilityTexts( ['交易明细详情', '支出金额', '66.8', '对方信息', '商户', '交易时间', '2026-07-24 10:00:00', '记账日', '2026-07-24'], 'com.cib.cibmb', ); expect(event?.amount).toBe('66.8'); }); }); // ─── 不破坏支付宝/微信现有行为 ─────────────────────────────── describe('支付宝/微信解析行为回归(不应被银行解析器干扰)', () => { it('支付宝详情页正常解析(包名分派优先)', () => { const event = parseAccessibilityTexts(ALIPAY_TEXTS, 'com.eg.android.AlipayGphone'); expect(event).not.toBeNull(); expect(event!.amount).toBe('66.8'); expect(event!.direction).toBe('expense'); expect(event!.counterparty).toBe('盛香亭新式热卤'); expect(event!.raw['收/付款方式']).toBe('花呗'); }); it('微信详情页正常解析(包名分派优先)', () => { const wechatTexts = [ '某商户', '12.93', '交易单号', '4200001234202407123456789', '本服务由财付通提供', '2026-07-24 10:00:00', ]; const event = parseAccessibilityTexts(wechatTexts, 'com.tencent.mm'); expect(event).not.toBeNull(); expect(event!.amount).toBe('12.93'); expect(event!.direction).toBe('expense'); }); it('支付宝包名不误触发银行解析器', () => { // 支付宝文本不含银行特征词,即便强行走银行分支也应返回 null // (实际上包名分派会先走支付宝,这里验证不冲突) const event = parseAccessibilityTexts(ALIPAY_TEXTS, 'com.eg.android.AlipayGphone'); expect(event).not.toBeNull(); expect(event!.counterparty).toBe('盛香亭新式热卤'); }); }); // ─── 边界情况 ───────────────────────────────────────────── describe('边界情况', () => { it('空文本返回 null', () => { expect(parseAccessibilityTexts([], 'com.cib.cibmb')).toBeNull(); }); it('非详情页文本返回 null', () => { expect(parseAccessibilityTexts(['随便一些', '不相关', '文本'], 'com.cib.cibmb')).toBeNull(); }); it('详情页但金额全为 0 返回 null', () => { const event = parseAccessibilityTexts( ['交易明细详情', '支出金额', '¥0.00', '对方信息', '商户', '交易时间', '2026-07-24 10:00:00', '记账日', '2026-07-24'], 'com.cib.cibmb', ); expect(event).toBeNull(); }); it('ImportedEvent 必填字段完整', () => { const event = parseAccessibilityTexts(CIB_TEXTS, 'com.cib.cibmb'); expect(event).not.toBeNull(); expect(event!.id).toBeTruthy(); expect(event!.occurredAt).toBeTruthy(); expect(event!.amount).toBeTruthy(); expect(event!.currency).toBe('CNY'); expect(event!.raw.packageName).toBe('com.cib.cibmb'); }); }); describe('parseUnionPayTexts(云闪付交易详情页)', () => { // 真机日志实测:云闪付乘车码交易详情页无障碍节点 // 商户名裸露在 -¥金额 上一节点,付款方式有标签,时间含"延迟扣费"前缀 const UNIONPAY_TEXTS = [ '返回', '云闪付交易详情', '客服按钮', '长沙公交智慧大数据科技有限公司(乘车码)', '-¥1.16', '优惠信息', '绿色出行-天天有优惠(公交地铁)-¥0.24', '当前状态', '交易成功', '订单金额', '¥1.40', '付款方式', '招商银行银联信用卡[1589]', '扣款时间', '延迟扣费|2026年6月27日 13:30:15', '乘车信息', '8205路 2026年6月27日 13:28:46', '交易流水号', '956893083417414766021', '商户订单号', '1388149433202380800', '在此商户的交易', ]; it('云闪付详情页:识别金额 -1.16,方向支出', () => { const event = parseAccessibilityTexts(UNIONPAY_TEXTS, 'com.unionpay'); expect(event).not.toBeNull(); expect(event?.amount).toBe('1.16'); expect(event?.direction).toBe('expense'); }); it('云闪付详情页:商户名取自金额上一节点(含全角括号归一化)', () => { const event = parseAccessibilityTexts(UNIONPAY_TEXTS, 'com.unionpay'); expect(event).not.toBeNull(); // 全角()应归一化为半角 () expect(event?.counterparty).toBe('长沙公交智慧大数据科技有限公司(乘车码)'); }); it('云闪付详情页:付款方式提取到 raw', () => { const event = parseAccessibilityTexts(UNIONPAY_TEXTS, 'com.unionpay'); expect(event?.raw['收/付款方式']).toBe('招商银行银联信用卡[1589]'); }); it('云闪付详情页:时间提取(含延迟扣费前缀清洗)', () => { const event = parseAccessibilityTexts(UNIONPAY_TEXTS, 'com.unionpay'); expect(event).not.toBeNull(); // 原始 "延迟扣费|2026年6月27日 13:30:15" → occurredAt 含 2026-06-27 expect(event?.occurredAt).toContain('2026-06-27'); expect(event?.occurredAt).toContain('13:30'); }); it('云闪付列表页(非详情页)返回 null', () => { const listTexts = ['返回', '交易记录', '2026年7月', '云闪付APP', '筛选', '07.17 周五', '-¥49.00']; const event = parseAccessibilityTexts(listTexts, 'com.unionpay'); // 列表页无"云闪付交易详情"标题,也无乘车码+金额组合,不应误识别 expect(event).toBeNull(); }); }); describe('parseDidiTexts(滴滴出行订单详情页)', () => { // 真机日志:节点含粘连长文本(创建时间、订单号等合并成一个节点) const DIDO_TEXTS = [ '返回', '订单详情', '特惠快车打车-方师傅-04月25日行程', '10.80', '付款成功', '消费场景特惠快车创建时间2026-04-25 16:24:03订单号212_202604254026406101466527订单编号XY0021308320260425162402GM3bd6付款手机号176****7202', '订单金额10.80', '距离更近', '湘江科创基地1期(湘江科创', '园)8栋', ]; it('滴滴:商户取标题"-"前主体(特惠快车打车)', () => { const event = parseAccessibilityTexts(DIDO_TEXTS, 'com.sdu.didi.psnger'); expect(event).not.toBeNull(); expect(event?.counterparty).toBe('特惠快车打车'); }); it('滴滴:金额提取(兼容粘连"订单金额10.80")', () => { const event = parseAccessibilityTexts(DIDO_TEXTS, 'com.sdu.didi.psnger'); expect(event?.amount).toBe('10.80'); expect(event?.direction).toBe('expense'); }); it('滴滴:从粘连节点提取创建时间', () => { const event = parseAccessibilityTexts(DIDO_TEXTS, 'com.sdu.didi.psnger'); expect(event?.occurredAt).toContain('2026-04-25'); expect(event?.occurredAt).toContain('16:24'); }); it('滴滴非订单页返回 null', () => { expect(parseAccessibilityTexts(['返回', '我的行程', '进行中'], 'com.sdu.didi.psnger')).toBeNull(); }); }); describe('parseMeituanTexts(美团消费详情页)', () => { // 真机日志:节点含 base64 图片哈希(混在金额附近,需跳过) const MEITUAN_TEXTS = [ '返回', '消费详情', '牛爷烧土家鲜肉饼', '9.36', '订单状态', '3cfaedc4036dbc99467a0afda37ae64f1671', '支付成功', '2026-07-24 18:51:36', '月付付款9.36元,计入2026年8月账单,还款日8月15日', '468934a59ff36d2e29fd2db3796011bb1188', '美团商品订单', '查看', '42846b1ee2ad163a01ba2c8e5462675722729', '牛爷烧土家鲜肉饼-【低GI】2个黑椒牛肉饼', '支付方式', '美团月付(极速支付)', '入账情况', '已计入8月账单', '支付时间', '2026-07-24 18:51:36', '下单平台', '美团', '消费类型', '美食团购', ]; it('美团:跳过 base64 哈希取商户(牛爷烧土家鲜肉饼)', () => { const event = parseAccessibilityTexts(MEITUAN_TEXTS, 'com.sankuai.meituan'); expect(event).not.toBeNull(); expect(event?.counterparty).toBe('牛爷烧土家鲜肉饼'); }); it('美团:金额 9.36,方向 expense(非 income)', () => { const event = parseAccessibilityTexts(MEITUAN_TEXTS, 'com.sankuai.meituan'); expect(event?.amount).toBe('9.36'); expect(event?.direction).toBe('expense'); }); it('美团:付款方式提取(美团月付极速支付)', () => { const event = parseAccessibilityTexts(MEITUAN_TEXTS, 'com.sankuai.meituan'); expect(event?.raw['收/付款方式']).toBe('美团月付(极速支付)'); }); it('美团:时间提取(支付时间标签)', () => { const event = parseAccessibilityTexts(MEITUAN_TEXTS, 'com.sankuai.meituan'); expect(event?.occurredAt).toBe('2026-07-24 18:51'); }); it('美团非消费页返回 null', () => { expect(parseAccessibilityTexts(['返回', '我的订单', '待使用'], 'com.sankuai.meituan')).toBeNull(); }); }); describe('parseJingdongTexts(京东账单详情页)', () => { // 真机日志:金额带负号,商户在金额前 const JD_TEXTS = [ 'dancing', 'dancing', '账单详情', '京东外卖', '-12.30', '交易成功', '付款方式', '白条', 'dancing', '付款形式', '先享后付自动扣款', '服务详情', '共1笔订单', '【新品推荐】江西炒粉+香辣猪耳+鸡蛋。', '0', '耳蛋炒', '创建时间 2026-07-2418:54:58', '账单分类', '食品酒饮', ]; it('京东:带负号金额 -12.30,方向 expense', () => { const event = parseAccessibilityTexts(JD_TEXTS, 'com.jingdong.app.mall'); expect(event).not.toBeNull(); expect(event?.amount).toBe('12.30'); expect(event?.direction).toBe('expense'); }); it('京东:商户取金额前节点(京东外卖)', () => { const event = parseAccessibilityTexts(JD_TEXTS, 'com.jingdong.app.mall'); expect(event?.counterparty).toBe('京东外卖'); }); it('京东:付款方式提取(白条)', () => { const event = parseAccessibilityTexts(JD_TEXTS, 'com.jingdong.app.mall'); expect(event?.raw['收/付款方式']).toBe('白条'); }); it('京东:时间提取(含日期时间粘连修复)', () => { const event = parseAccessibilityTexts(JD_TEXTS, 'com.jingdong.app.mall'); expect(event?.occurredAt).toContain('2026-07-24'); expect(event?.occurredAt).toContain('18:54'); }); it('京东非账单页返回 null', () => { expect(parseAccessibilityTexts(['返回', '我的京东', '待收货'], 'com.jingdong.app.mall')).toBeNull(); }); }); describe('parseDouyinTexts(抖音月付还款页)', () => { // 真机日志:标题即商户,金额纯数字 const DOUYIN_TEXTS = [ '加防', '抖音月付还款', '5.88', '支付成功', '还款时间', '2026-06-1320:18:44', '支付方式', '支支付宝', '点击展开', '交易订单', '主动还款-抖音月付2026年6月账单', ]; it('抖音:商户固定为"抖音月付还款"', () => { const event = parseAccessibilityTexts(DOUYIN_TEXTS, 'com.ss.android.ugc.aweme'); expect(event).not.toBeNull(); expect(event?.counterparty).toBe('抖音月付还款'); }); it('抖音:金额 5.88,方向 expense(还款是支出)', () => { const event = parseAccessibilityTexts(DOUYIN_TEXTS, 'com.ss.android.ugc.aweme'); expect(event?.amount).toBe('5.88'); expect(event?.direction).toBe('expense'); }); it('抖音:还款时间提取(日期时间粘连 2026-06-1320:18:44)', () => { const event = parseAccessibilityTexts(DOUYIN_TEXTS, 'com.ss.android.ugc.aweme'); expect(event?.occurredAt).toBe('2026-06-13 20:18'); }); it('抖音:付款方式提取(支支付宝)', () => { const event = parseAccessibilityTexts(DOUYIN_TEXTS, 'com.ss.android.ugc.aweme'); expect(event?.raw['收/付款方式']).toBe('支支付宝'); }); it('抖音非还款页返回 null', () => { expect(parseAccessibilityTexts(['返回', '首页', '推荐'], 'com.ss.android.ugc.aweme')).toBeNull(); }); });