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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
This commit is contained in:
fengmengqi
2026-07-28 20:57:37 +08:00
parent bf04400852
commit 6767dd538a
222 changed files with 8970 additions and 2500 deletions
+432
View File
@@ -0,0 +1,432 @@
/**
* 无障碍文本解析器测试。
*
* 重点验证:
* - 银行详情页(兴业/建行/招行等)通用解析器,按页面特征词分派
* - 金额格式兼容性:¥ 前缀、千分位逗号、符号前置/后置负号
* - 不破坏支付宝/微信现有解析行为
*
* 测试样本取自真实无障碍日志(兴业/建行/支付宝详情页节点文本)。
*/
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();
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseCmbCsv, parseIcbcCsv, parseGenericBankCsv } from '../src/domain/adapters';
import { parseCmbCsv, parseIcbcCsv, parseGenericBankCsv } from '../src/domain/pipeline/adapters';
import { migrateDb, MemoryMigrationDb, SCHEMA_VERSION } from '../src/storage/migrations';
describe('CSV adapters', () => {
+4 -4
View File
@@ -1,9 +1,9 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { NativeOcrBridge, SimulatedOcrBridge, type NativeOcrModule } from '../src/services/ocrBridge';
import { ScreenshotChannel, SimulatedScreenshotListener, type ScreenshotEvent } from '../src/services/screenshot';
import { NotificationChannel, SimulatedNotificationListener, parseNotification, DEFAULT_PAYMENT_PACKAGES, type NotificationEvent } from '../src/services/notification';
import { NativeOcrBridge, SimulatedOcrBridge, type NativeOcrModule } from '../src/services/ocr/ocrBridge';
import { ScreenshotChannel, SimulatedScreenshotListener, type ScreenshotEvent } from '../src/services/automation/screenshot';
import { NotificationChannel, parseNotification, DEFAULT_PAYMENT_PACKAGES, type NotificationEvent } from '../src/services/notification';
import { SmsChannel, SimulatedSmsListener, parseSms, isBankSms, type SmsEvent } from '../src/services/sms';
import { OcrProcessor, MockOcrEngine } from '../src/domain/ocrProcessor';
import { OcrProcessor, MockOcrEngine } from '../src/domain/pipeline/ocrProcessor';
beforeEach(() => {
// 各测试自建实例,无需全局重置
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { useAutomationStore } from '../src/store/automationStore';
import { parseLedger } from '../src/domain/ledger';
import type { ImportedEvent } from '../src/domain/types';
import { parseLedger } from '../src/domain/core/ledger';
import type { ImportedEvent } from '../src/domain/core/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n2026-01-01 open Expenses:Uncategorized CNY\n2026-01-01 open Income:Uncategorized CNY\n' }]);
+6 -6
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { BillPipeline } from '../src/domain/billPipeline';
import { batchDedup, checkDuplicate, dedupAgainstHistory, dedupFingerprint, type DedupConfig } from '../src/domain/dedup';
import { recognizeTransfers } from '../src/domain/transferRecognizer';
import { parseLedger } from '../src/domain/ledger';
import type { Category } from '../src/domain/categories';
import type { ImportedEvent, Transaction } from '../src/domain/types';
import { BillPipeline } from '../src/domain/pipeline/billPipeline';
import { batchDedup, checkDuplicate, dedupAgainstHistory, dedupFingerprint, type DedupConfig } from '../src/domain/transaction/dedup';
import { recognizeTransfers } from '../src/domain/transaction/transferRecognizer';
import { parseLedger } from '../src/domain/core/ledger';
import type { Category } from '../src/domain/taxonomy/categories';
import type { ImportedEvent, Transaction } from '../src/domain/core/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Assets:WeChat CNY\n2026-01-01 open Assets:Bank:CMB CNY\n2026-01-01 open Expenses:Food CNY\n2026-01-01 open Expenses:Uncategorized CNY\n2026-01-01 open Income:Uncategorized CNY\n' }]);
+6 -6
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { filterByType, getSubCategories, getTopLevelCategories, matchCategory, resolveCategoryAccount, type Category } from '../src/domain/categories';
import { classifyWithCategories, resolveSourceAccount } from '../src/domain/rules';
import { extractTagNames, isValidTagName, resolveTagsByName, tagsToBeanSyntax, type Tag } from '../src/domain/tags';
import { parseLedger } from '../src/domain/ledger';
import { importStatement } from '../src/domain/statements';
import type { ImportedEvent } from '../src/domain/types';
import { filterByType, getSubCategories, getTopLevelCategories, matchCategory, resolveCategoryAccount, type Category } from '../src/domain/taxonomy/categories';
import { classifyWithCategories, resolveSourceAccount } from '../src/domain/rules/rules';
import { extractTagNames, isValidTagName, resolveTagsByName, tagsToBeanSyntax, type Tag } from '../src/domain/taxonomy/tags';
import { parseLedger } from '../src/domain/core/ledger';
import { importStatement } from '../src/domain/pipeline/statements';
import type { ImportedEvent } from '../src/domain/core/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n2026-01-01 open Expenses:Uncategorized CNY\n2026-01-01 open Income:Uncategorized CNY\n' }]);
+1 -1
View File
@@ -4,7 +4,7 @@ import {
resolveChannelByName,
BUILTIN_CHANNELS,
type ChannelConfig,
} from '../src/domain/channelConfig';
} from '../src/domain/platform/channelConfig';
describe('resolveChannelByPackage', () => {
const customChannels: ChannelConfig[] = [
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
import { buildMonthGrid } from '../src/components/dateGrid';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/category/categoryIcons';
import { buildMonthGrid } from '../src/components/stats/dateGrid';
describe('getCategoryIcon', () => {
it('15 个内置分类都有具名图标', () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { detectDirection, PAYMENT_PACKAGES, PAYMENT_PACKAGE_SET } from '../src/domain/constants';
import { detectDirection, PAYMENT_PACKAGES, PAYMENT_PACKAGE_SET } from '../src/domain/core/constants';
describe('detectDirection', () => {
describe('refund priority', () => {
@@ -18,7 +18,7 @@ describe('detectDirection', () => {
});
it('detects interest', () => {
expect(detectDirection('利息收入')).toBe('fee');
expect(detectDirection('利息收入')).toBe('income');
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseLedger, getDefaultCurrency } from '../src/domain/ledger';
import { parseLedger, getDefaultCurrency } from '../src/domain/core/ledger';
describe('getDefaultCurrency', () => {
it('从 operating_currency option 读取默认币种', () => {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
subtractDecimals,
multiplyDecimals,
divideDecimals,
} from '../src/domain/decimal';
} from '../src/domain/core/decimal';
describe('parseDecimal', () => {
+173
View File
@@ -0,0 +1,173 @@
/**
* dedup 低置信度对手方约束 & transferRecognizer 方向归类测试。
* 验证 #7/#10 修复。
*/
import { describe, expect, it } from 'vitest';
import { checkDuplicate, batchDedup, type DedupConfig, DEFAULT_DEDUP_CONFIG } from '../src/domain/transaction/dedup';
import { recognizeTransfers, type TransferConfig, DEFAULT_TRANSFER_CONFIG } from '../src/domain/transaction/transferRecognizer';
import type { ImportedEvent } from '../src/domain/core/types';
// ============ dedup 低置信度对手方约束 ============
describe('dedup 低置信度对手方约束 (#7)', () => {
const config: DedupConfig = DEFAULT_DEDUP_CONFIG;
const makeEvent = (overrides: Partial<ImportedEvent>): ImportedEvent => ({
id: 'test-id',
occurredAt: '2026-07-01 12:00:00',
amount: '100.00',
currency: 'CNY',
direction: 'expense',
counterparty: '星巴克',
memo: '',
raw: {},
...overrides,
});
const makeHistoryItem = (overrides: Partial<ImportedEvent>): ImportedEvent => ({
id: 'hist-id',
occurredAt: '2026-07-01',
amount: '100.00',
currency: 'CNY',
direction: 'expense',
counterparty: '星巴克',
memo: '',
raw: {},
...overrides,
});
it('同天+同金额+同对手方 → medium 置信度重复', () => {
const event = makeEvent({ occurredAt: '2026-07-01 10:00:00' });
const history = [makeHistoryItem({ occurredAt: '2026-07-01' })];
const result = checkDuplicate(event, history, config);
expect(result.isDuplicate).toBe(true);
expect(result.confidence).toBe('medium');
});
it('同天+同金额+双方对手方不同 → 非重复(避免误杀)', () => {
const event = makeEvent({
occurredAt: '2026-07-01 10:00:00',
counterparty: '星巴克',
});
const history = [makeHistoryItem({
occurredAt: '2026-07-01',
counterparty: '瑞幸咖啡', // 不同对手方
})];
const result = checkDuplicate(event, history, config);
expect(result.isDuplicate).toBe(false);
});
it('同天+同金额+一方无对手方 → low 置信度重复', () => {
const event = makeEvent({
occurredAt: '2026-07-01 10:00:00',
counterparty: '星巴克',
});
const history = [makeHistoryItem({
occurredAt: '2026-07-01',
counterparty: '', // 无对手方
})];
const result = checkDuplicate(event, history, config);
expect(result.isDuplicate).toBe(true);
expect(result.confidence).toBe('low');
});
it('同天+同金额+双方都无对手方 → medium 置信度重复(sameCounterparty=true', () => {
const event = makeEvent({
occurredAt: '2026-07-01 10:00:00',
counterparty: '',
});
const history = [makeHistoryItem({
occurredAt: '2026-07-01',
counterparty: '',
})];
const result = checkDuplicate(event, history, config);
expect(result.isDuplicate).toBe(true);
// 双方都无对手方时 sameCounterparty=true,返回 medium
expect(result.confidence).toBe('medium');
});
it('batchDedup 中同天同金额不同对手方的事件都保留', () => {
const events: ImportedEvent[] = [
makeEvent({ id: 'e1', occurredAt: '2026-07-01 10:00:00', counterparty: '星巴克', amount: '30.00' }),
makeEvent({ id: 'e2', occurredAt: '2026-07-01 14:00:00', counterparty: '瑞幸咖啡', amount: '30.00' }),
];
const result = batchDedup(events, config);
// 两笔不同对手方的交易都应该保留
expect(result.accepted.length).toBe(2);
expect(result.duplicates.length).toBe(0);
});
});
// ============ transferRecognizer 方向归类 ============
describe('transferRecognizer transfer 方向归类 (#10)', () => {
const config: TransferConfig = DEFAULT_TRANSFER_CONFIG;
const makeEvent = (overrides: Partial<ImportedEvent>): ImportedEvent => ({
id: 'test-id',
occurredAt: '2026-07-01 12:00:00',
amount: '100.00',
currency: 'CNY',
direction: 'transfer',
counterparty: '转账',
memo: '',
raw: {},
...overrides,
});
it('transfer 事件按金额正负归类:负数=支出,正数=收入', () => {
const events: ImportedEvent[] = [
makeEvent({ id: 'out', amount: '-500.00', occurredAt: '2026-07-01 10:00:00', counterparty: '转出到银行卡' }),
makeEvent({ id: 'in', amount: '500.00', occurredAt: '2026-07-01 10:00:05', counterparty: '从支付宝转入' }),
];
const result = recognizeTransfers(events, config);
// 应该识别为一笔转账
expect(result.transfers.length).toBe(1);
// TransferPair 使用 leftEventId/rightEventId
expect(result.transfers[0].leftEventId).toBe('out');
expect(result.transfers[0].rightEventId).toBe('in');
});
it('同一 transfer 事件不会同时出现在收入和支出列表', () => {
// 单笔 transfer 事件(只有支出方)
const events: ImportedEvent[] = [
makeEvent({ id: 'single', amount: '-100.00', occurredAt: '2026-07-01 10:00:00' }),
];
const result = recognizeTransfers(events, config);
// 没有配对,不应该识别为转账
expect(result.transfers.length).toBe(0);
// 事件应该保留在 remaining 中
expect(result.remaining.length).toBe(1);
});
it('income + expense 正常配对', () => {
const events: ImportedEvent[] = [
makeEvent({ id: 'exp', direction: 'expense', amount: '-200.00', occurredAt: '2026-07-01 10:00:00' }),
makeEvent({ id: 'inc', direction: 'income', amount: '200.00', occurredAt: '2026-07-01 10:00:03' }),
];
const result = recognizeTransfers(events, config);
expect(result.transfers.length).toBe(1);
expect(result.transfers[0].leftEventId).toBe('exp');
expect(result.transfers[0].rightEventId).toBe('inc');
});
it('时间窗口外的不配对', () => {
const events: ImportedEvent[] = [
makeEvent({ id: 'exp', direction: 'expense', amount: '-200.00', occurredAt: '2026-07-01 10:00:00' }),
makeEvent({ id: 'inc', direction: 'income', amount: '200.00', occurredAt: '2026-07-05 10:00:00' }), // 4天后
];
const result = recognizeTransfers(events, config);
// 默认时间窗口是 72 小时(3天),4天外的不配对
expect(result.transfers.length).toBe(0);
expect(result.remaining.length).toBe(2);
});
});
+7 -7
View File
@@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest';
import { parseAiJson, removeThink, sanitizeForAi, buildBillRecognitionPrompt, processNaturalLanguage, aiRecognizeCategory, type AiProvider, type ChatMessage } from '../src/domain';
import { parseAiJson, removeThink, sanitizeForAi, buildBillRecognitionPrompt, processNaturalLanguage, aiRecognizeCategory, type AiProvider } from '../src/domain';
import { parseDeepLink, buildDeepLink } from '../src/services/deepLink';
import type { ImportedEvent } from '../src/domain/types';
import type { ImportedEvent } from '../src/domain/core/types';
// ============ AI 测试 ============
class MockProvider implements AiProvider {
constructor(public readonly config: { id: string; name: string; apiKey: string; baseUrl: string; model: string }, public responses: string[] = []) {}
async chat(_messages: ChatMessage[]): Promise<string> {
async chat(): Promise<string> {
return this.responses.shift() ?? '';
}
}
@@ -61,7 +61,7 @@ describe('AI 高阶函数', () => {
// ============ 同步测试 ============
import { snapshotSync, MemorySyncBackend } from '../src/domain/sync';
import { snapshotSync, MemorySyncBackend } from '../src/domain/platform/sync';
describe('snapshotSync', () => {
it('远程无内容 → 推送本地', async () => {
@@ -126,9 +126,9 @@ describe('deepLink', () => {
// ============ services 测试 ============
import { authenticate, hashPin, verifyPin, shouldLockOnResume, type AuthProvider } from '../src/services/security';
import { computePrivacyBlur } from '../src/services/privacyBlur';
import { createBackupBundle, deserializeBundle, serializeBundle, restoreFiles, type BackupBundle } from '../src/services/backup';
import { authenticate, hashPin, verifyPin, shouldLockOnResume, type AuthProvider } from '../src/services/security/security';
import { computePrivacyBlur } from '../src/services/security/privacyBlur';
import { createBackupBundle, deserializeBundle, serializeBundle, restoreFiles, type BackupBundle } from '../src/services/data/backup';
describe('security', () => {
it('无硬件放行', async () => {
+5 -5
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { clearFlag, flagsToMetadata, hasFlag, setFlag, shouldCountInBudget, shouldCountInStats, toggleFlag, TransactionFlag } from '../src/domain/transactionFlags';
import { DEFAULT_FILTER, mergeFilter, passesKeywordFilter } from '../src/domain/keywordFilter';
import { generateRemark, normalizeAdjacentDuplicates } from '../src/domain/remarkTemplate';
import { FunctionSandboxExecutor, RuleEngine, type EnhancedRule } from '../src/domain/ruleEngine';
import type { ImportedEvent } from '../src/domain/types';
import { clearFlag, flagsToMetadata, hasFlag, setFlag, shouldCountInBudget, shouldCountInStats, toggleFlag, TransactionFlag } from '../src/domain/transaction/transactionFlags';
import { DEFAULT_FILTER, mergeFilter, passesKeywordFilter } from '../src/domain/rules/keywordFilter';
import { generateRemark, normalizeAdjacentDuplicates } from '../src/domain/rules/remarkTemplate';
import { FunctionSandboxExecutor, RuleEngine, type EnhancedRule } from '../src/domain/rules/ruleEngine';
import type { ImportedEvent } from '../src/domain/core/types';
const evt = (over: Partial<ImportedEvent> = {}): ImportedEvent => ({
id: 'e1', occurredAt: '2026-02-01T10:00:00', amount: '100', currency: 'CNY', direction: 'expense',
+5 -5
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { calculateBudgetProgress, getPeriodRange, type Budget } from '../src/domain/budgets';
import { calculateNextDueDate, getDueRecurring, instantiateRecurring, type RecurringTransaction } from '../src/domain/recurring';
import { buildTransactionCurrency, convertCurrency, getEffectiveRate, validateRateCoverage, type ExchangeRate } from '../src/domain/currency';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit, type CreditCard } from '../src/domain/creditCards';
import type { Transaction } from '../src/domain/types';
import { calculateBudgetProgress, getPeriodRange, type Budget } from '../src/domain/finance/budgets';
import { calculateNextDueDate, getDueRecurring, instantiateRecurring, type RecurringTransaction } from '../src/domain/finance/recurring';
import { buildTransactionCurrency, convertCurrency, getEffectiveRate, validateRateCoverage, type ExchangeRate } from '../src/domain/finance/currency';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit, type CreditCard } from '../src/domain/finance/creditCards';
import type { Transaction } from '../src/domain/core/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
+5 -5
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { calculateNetWorth, calculateBalanceByType, calculateNetWorthTrend } from '../src/domain/netWorth';
import { generateAnnualReport } from '../src/domain/annualReport';
import { buildAccountTree, calculateAccountTotalBalance, calculateReport } from '../src/domain/accountTree';
import { parseLedger } from '../src/domain/ledger';
import type { Transaction } from '../src/domain/types';
import { calculateNetWorth, calculateBalanceByType, calculateNetWorthTrend } from '../src/domain/stats/netWorth';
import { generateAnnualReport } from '../src/domain/stats/annualReport';
import { buildAccountTree, calculateAccountTotalBalance, calculateReport } from '../src/domain/taxonomy/accountTree';
import { parseLedger } from '../src/domain/core/ledger';
import type { Transaction } from '../src/domain/core/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
+1 -1
View File
@@ -25,7 +25,7 @@ vi.mock('../src/utils/logger', () => ({
}));
// 延迟导入(vi.mock 需要在模块加载前执行)
const { FileSystemBackend } = await import('../src/services/fileSystemBackend');
const { FileSystemBackend } = await import('../src/services/data/fileSystemBackend');
function resetMock() {
mockFs.getInfoAsync.mockReset();
+2 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from 'vitest';
import { colorToHex, buildFloatingUiConfig } from '../src/services/floatingUiConfig';
import { colorToHex, buildFloatingUiConfig } from '../src/services/automation/floatingUiConfig';
import { lightTheme, darkTheme } from '../src/theme/presets';
import { setLocale, t } from '../src/i18n';
@@ -33,7 +33,7 @@ describe('buildFloatingUiConfig', () => {
expect(config.colors.cardBg).toBe('#FFFFFF');
expect(config.colors.inputBg).toBe('#EFF1F4');
expect(config.colors.fgPrimary).toBe('#111318');
expect(config.colors.fgSecondary).toBe('#6B7280');
expect(config.colors.fgSecondary).toBe('#565F6D');
expect(config.colors.border).toBe('#E5E7EB');
expect(config.colors.income).toBe('#10B981');
expect(config.colors.expense).toBe('#EF4444');
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import * as XLSX from 'xlsx';
import { importStatement } from '../src/domain/statements';
import { batchDedup } from '../src/domain/dedup';
import { importStatement } from '../src/domain/pipeline/statements';
import { batchDedup } from '../src/domain/transaction/dedup';
// @ts-ignore
const GbkTextDecoder = (() => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { IosShortcutsChannel, SimulatedIosAppIntentBridge, handleSharedImage } from '../src/services/iosShortcuts';
describe('iOS Shortcuts', () => {
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { hash, parseLedger, serializeTransaction, validateTransaction } from '../src/domain/ledger';
import type { LedgerFile } from '../src/domain/types';
import { hash, parseLedger, serializeTransaction, validateTransaction } from '../src/domain/core/ledger';
const parse = (content: string, path = 'main.bean'): ReturnType<typeof parseLedger> =>
parseLedger([{ path, content }]);
+129
View File
@@ -0,0 +1,129 @@
/**
* ledgerStore reset lockGeneration
* #1/#2 reset() lockGenerationaddTransaction gen
*/
import { describe, expect, it, beforeEach, vi } from 'vitest';
// ledgerStore 间接依赖 txKeyStore → expo-file-systemmock 掉避免 react-native flow 语法解析失败
vi.mock('expo-file-system/legacy', () => ({
documentDirectory: '',
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
readAsStringAsync: vi.fn().mockResolvedValue('[]'),
writeAsStringAsync: vi.fn().mockResolvedValue(undefined),
moveAsync: vi.fn().mockResolvedValue(undefined),
}));
import { useLedgerStore } from '../src/store/ledgerStore';
import { MemoryBackend } from '../src/domain/platform/mobileStore';
import type { LedgerFile } from '../src/domain/core/types';
const sampleLedger: LedgerFile = {
path: 'main.bean',
content: `option "operating_currency" "CNY"
2026-01-01 open Assets:Alipay CNY
2026-01-01 open Expenses:Food CNY
include "mobile.bean"
`,
};
beforeEach(() => {
useLedgerStore.getState().reset();
});
describe('ledgerStore reset 与 lockGeneration', () => {
it('reset 后状态清空', () => {
const state = useLedgerStore.getState();
expect(state.ledger).toBeNull();
expect(state.mobileStore).toBeNull();
expect(state.mobileBean).toBe('');
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
it('reset 后重新 loadLedger 正常工作', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
expect(useLedgerStore.getState().ledger).not.toBeNull();
useLedgerStore.getState().reset();
expect(useLedgerStore.getState().ledger).toBeNull();
// 重新加载
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
expect(useLedgerStore.getState().ledger).not.toBeNull();
expect(useLedgerStore.getState().ledger?.transactions).toBeDefined();
});
it('reset 后 addTransaction 使用新的 mobileStore', async () => {
const backend1 = new MemoryBackend();
await useLedgerStore.getState().loadLedger([sampleLedger], backend1);
const store1 = useLedgerStore.getState().mobileStore;
useLedgerStore.getState().reset();
const backend2 = new MemoryBackend();
await useLedgerStore.getState().loadLedger([sampleLedger], backend2);
const store2 = useLedgerStore.getState().mobileStore;
// 两个 store 应该是不同的实例
expect(store1).not.toBe(store2);
});
it('并发 addTransaction 串行化执行', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const draft1 = {
date: '2026-07-01',
payee: '商户A',
narration: '交易1',
postings: [
{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' },
{ account: 'Expenses:Food', amount: '100', currency: 'CNY' },
],
};
const draft2 = {
date: '2026-07-02',
payee: '商户B',
narration: '交易2',
postings: [
{ account: 'Assets:Alipay', amount: '-200', currency: 'CNY' },
{ account: 'Expenses:Food', amount: '200', currency: 'CNY' },
],
};
// 并发执行两个 addTransaction
const [result1, result2] = await Promise.all([
useLedgerStore.getState().addTransaction(draft1),
useLedgerStore.getState().addTransaction(draft2),
]);
// MobileCommit 返回 content/diff,不包含 success 字段
expect(result1.content).toBeDefined();
expect(result2.content).toBeDefined();
// 两笔交易都应该入账
const bean = useLedgerStore.getState().mobileBean;
expect(bean).toContain('商户A');
expect(bean).toContain('商户B');
});
it('reset 后 addTransaction 报错(账本未加载)', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const draft = {
date: '2026-07-01',
payee: '测试商户',
narration: '测试交易',
postings: [
{ account: 'Assets:Alipay', amount: '-50', currency: 'CNY' },
{ account: 'Expenses:Food', amount: '50', currency: 'CNY' },
],
};
// reset 后状态清空
useLedgerStore.getState().reset();
// 此时 addTransaction 应该报错
await expect(useLedgerStore.getState().addTransaction(draft)).rejects.toThrow('账本未加载');
});
});
+57 -4
View File
@@ -1,7 +1,19 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { describe, expect, it, beforeEach, vi } from 'vitest';
// mock expo-file-system,避免 react-native flow 语法在 vitest 下解析失败
// txKeyStore 的内存 SetprocessedTxKeys)保持真实,仅文件持久化被 stub
vi.mock('expo-file-system/legacy', () => ({
documentDirectory: '',
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
readAsStringAsync: vi.fn().mockResolvedValue('[]'),
writeAsStringAsync: vi.fn().mockResolvedValue(undefined),
moveAsync: vi.fn().mockResolvedValue(undefined),
}));
import { useLedgerStore } from '../src/store/ledgerStore';
import { MemoryBackend } from '../src/domain/mobileStore';
import type { LedgerFile } from '../src/domain/types';
import { MemoryBackend } from '../src/domain/platform/mobileStore';
import { addProcessedTxKey, hasProcessedTxKey, clearProcessedTxKeys } from '../src/services/security/txKeyStore';
import type { LedgerFile } from '../src/domain/core/types';
const sampleLedger: LedgerFile = {
path: 'main.bean',
@@ -25,12 +37,12 @@ include "mobile.bean"
beforeEach(() => {
useLedgerStore.getState().reset();
clearProcessedTxKeys();
});
describe('ledgerStore 写操作', () => {
it('autoOpenAccounts 跳过已 open 的账户(去重)', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const before = useLedgerStore.getState().mobileBean;
// Assets:Alipay 已存在 → 应跳过
await useLedgerStore.getState().autoOpenAccounts(['Assets:Alipay', 'Assets:NewAccount']);
@@ -124,3 +136,44 @@ describe('ledgerStore 写操作', () => {
expect(txns.map(t => t.narration).sort()).toEqual(['交易1', '交易2']);
});
});
describe('deleteTransaction 清除去重键(删了重记流程)', () => {
it('删除交易后清除对应的去重键,允许重新识别', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
// 模拟一笔已入账交易(盛香亭 66.8)
await useLedgerStore.getState().addTransaction({
date: '2026-07-24', narration: '支出', payee: '盛香亭新式热卤',
postings: [{ account: 'Assets:Alipay', amount: '-66.8', currency: 'CNY' }, { account: 'Expenses:Food', amount: '66.8', currency: 'CNY' }],
});
const tx = useLedgerStore.getState().ledger!.transactions[0];
// 模拟入账确认时写入去重键(与 automationPipeline 格式一致)
const txKey = '2026-07-24_66.8_盛香亭新式热卤';
addProcessedTxKey(txKey);
expect(hasProcessedTxKey(txKey)).toBe(true); // 入账后已被去重拦截
// 删除交易(模拟用户发现账户错了,删除重记)
await useLedgerStore.getState().deleteTransaction(tx.raw);
// 去重键应被清除,允许重新识别
expect(hasProcessedTxKey(txKey)).toBe(false);
});
it('删除交易时 raw 格式异常也安全(不抛错,只是不清键)', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
// 写入一个不相关的键,确认不会被误删
addProcessedTxKey('2026-07-24_99.99_某商户');
// 添加并删除一笔正常交易
await useLedgerStore.getState().addTransaction({
date: '2026-07-24', narration: '测试',
postings: [{ account: 'Assets:Alipay', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }],
});
const tx = useLedgerStore.getState().ledger!.transactions[0];
await useLedgerStore.getState().deleteTransaction(tx.raw);
// 不相关的键不受影响
expect(hasProcessedTxKey('2026-07-24_99.99_某商户')).toBe(true);
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { LogFileBackend, LogFileInfo, LogLevel, createLogger } from '../src/utils/logger';
class MemoryLogBackend implements LogFileBackend {
+4 -4
View File
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { MockMaintenanceFs, MockMaintenanceDb, scanOrphanFiles, cleanOrphanFiles, runMaintenance } from '../src/services/maintenance';
import { AiVisionProcessor, MockAiVisionProcessor } from '../src/ocr/AiVisionProcessor';
import { MockMaintenanceFs, MockMaintenanceDb, scanOrphanFiles, cleanOrphanFiles, runMaintenance } from '../src/services/data/maintenance';
import { AiVisionProcessor, MockAiVisionProcessor } from '../src/services/ocr/AiVisionProcessor';
import { setupDeepLinking, parseDeepLink } from '../src/services/deepLink';
import { generateRemark } from '../src/domain/remarkTemplate';
import type { ImportedEvent } from '../src/domain/types';
import { generateRemark } from '../src/domain/rules/remarkTemplate';
import type { ImportedEvent } from '../src/domain/core/types';
const evt = (over: Partial<ImportedEvent> = {}): ImportedEvent => ({
id: 'e1', occurredAt: '2026-07-13T14:30:00', amount: '100', currency: 'CNY', direction: 'expense',
+5 -5
View File
@@ -1,10 +1,10 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { useMetadataStore, generateId, type PersistableMetadata } from '../src/store/metadataStore';
import type { Category } from '../src/domain/categories';
import type { Tag } from '../src/domain/tags';
import type { Budget } from '../src/domain/budgets';
import type { CreditCard } from '../src/domain/creditCards';
import type { Rule } from '../src/domain/types';
import type { Category } from '../src/domain/taxonomy/categories';
import type { Tag } from '../src/domain/taxonomy/tags';
import type { Budget } from '../src/domain/finance/budgets';
import type { CreditCard } from '../src/domain/finance/creditCards';
import type { Rule } from '../src/domain/core/types';
// metadataStore 是 zustand 单例,每个测试前重置到 DEFAULTS。
beforeEach(() => {
+4 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { MemoryBackend, MobileBeanStore, createMobileStore } from '../src/domain/mobileStore';
import { parseLedger } from '../src/domain/ledger';
import type { TransactionDraft } from '../src/domain/types';
import { MemoryBackend, MobileBeanStore, createMobileStore } from '../src/domain/platform/mobileStore';
import { parseLedger } from '../src/domain/core/ledger';
import type { TransactionDraft } from '../src/domain/core/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n' }]);
@@ -9,6 +9,7 @@ const draft = (n: number): TransactionDraft => ({
date: '2026-02-01',
narration: `test${n}`,
postings: [{ account: 'Assets:Alipay', amount: `-${n}`, currency: 'CNY' }, { account: 'Expenses:Food', amount: `${n}`, currency: 'CNY' }],
metadata: { 'dr-id': `test-dr-${n}` },
});
describe('MobileBeanStore - 基础写入', () => {
+5 -5
View File
@@ -3,11 +3,11 @@ import { syncWithICloud, MockICloudFileSystem } from '../src/services/sync/iclou
import { setupDailyReminder, setupCreditCardReminder, cancelAllReminders, MockNotificationScheduler } from '../src/services/reminder';
import { exportRules, importRules, parseRuleBundle, mergeRules, validateRule, ruleBundleChecksum, MockRuleFileSync, type RuleBundle } from '../src/services/ruleSync';
import { isTransactionIntent, processChatMessage, createConversation, appendMessage } from '../src/ai/chatAssistant';
import type { AiProvider, ChatMessage } from '../src/domain/ai';
import type { AiProvider } from '../src/domain/ai';
import { calculateMonthlyStats, generatePlainTextSummary } from '../src/ai/monthlySummary';
import { groupByMonth, groupByCategory, dailyExpenseMap } from '../src/domain/chartStats';
import type { Transaction, Rule } from '../src/domain/types';
import type { CreditCard } from '../src/domain/creditCards';
import { groupByMonth, groupByCategory, dailyExpenseMap } from '../src/domain/stats/chartStats';
import type { Transaction, Rule } from '../src/domain/core/types';
import type { CreditCard } from '../src/domain/finance/creditCards';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
@@ -21,7 +21,7 @@ const sampleTx: Transaction[] = [
class MockProvider implements AiProvider {
constructor(public readonly config: { id: string; name: string; apiKey: string; baseUrl: string; model: string }, public responses: string[] = []) {}
async chat(_messages: ChatMessage[] | Parameters<AiProvider['chat']>[0]): Promise<string> {
async chat(): Promise<string> {
return this.responses.shift() ?? 'AI 回复';
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { VoiceInput, MockAudioRecorder, MockSpeechToText, DEFAULT_VOICE_CONFIG } from '../src/ai/voiceInput';
import { calculatePosterStats, SharePosterService, MockViewShotter, type PosterConfig } from '../src/services/sharePoster';
import { exportToExcel, exportToExcelMultiSheet, exportToCsv, transactionToRow, transactionsToRows, MockExcelWorkbook } from '../src/services/exportToExcel';
import type { Transaction } from '../src/domain/types';
import { exportToExcel, exportToExcelMultiSheet, exportToCsv, transactionToRow, transactionsToRows, MockExcelWorkbook } from '../src/services/data/exportToExcel';
import type { Transaction } from '../src/domain/core/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
+161
View File
@@ -0,0 +1,161 @@
/**
* NotificationChannel LRU & Logger
* #6/#20
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { NotificationChannel, SimulatedNotificationListener, type NotificationEvent } from '../src/services/notification';
import { LoggerImpl } from '../src/utils/logger';
// ============ NotificationChannel LRU 批量淘汰 ============
describe('NotificationChannel LRU 批量淘汰 (#6)', () => {
let channel: NotificationChannel;
let listener: SimulatedNotificationListener;
beforeEach(() => {
listener = new SimulatedNotificationListener();
// 使用自定义白名单包含测试包名
channel = new NotificationChannel(listener, ['com.tencent.mm', 'com.test.app']);
});
it('正常去重:相同通知只处理一次', () => {
const event: NotificationEvent = {
packageName: 'com.tencent.mm',
title: '微信支付',
text: '微信支付 金额 10.00 商户:星巴克',
timestamp: Date.now(),
};
const result1 = channel.handleNotification(event);
expect(result1.processed).toBe(true);
const result2 = channel.handleNotification(event);
expect(result2.processed).toBe(false);
expect(result2.reason).toContain('MD5');
});
it('LRU 批量淘汰:超过 1000 条时裁剪到 800', () => {
// 注入 1001 条不同的通知(使用白名单包名)
for (let i = 0; i < 1001; i++) {
const event: NotificationEvent = {
packageName: 'com.tencent.mm',
title: `微信支付 ${i}`,
text: `微信支付 金额 ${i}.00 商户:测试${i}`,
timestamp: Date.now() + i,
};
channel.handleNotification(event);
}
// 验证缓存大小被限制(通过行为验证:新事件仍可处理)
const newEvent: NotificationEvent = {
packageName: 'com.tencent.mm',
title: '微信支付 新事件',
text: '微信支付 金额 999.00 商户:新商户',
timestamp: Date.now() + 2000,
};
const result = channel.handleNotification(newEvent);
expect(result.processed).toBe(true);
});
it('LRU 淘汰后保留较新的记录', () => {
// 注入 1001 条
for (let i = 0; i < 1001; i++) {
const event: NotificationEvent = {
packageName: 'com.tencent.mm',
title: `微信支付 ${i}`,
text: `微信支付 金额 ${i}.00`,
timestamp: Date.now() + i,
};
channel.handleNotification(event);
}
// 第 1000 条(最新的)应该还在缓存中
const lastEvent: NotificationEvent = {
packageName: 'com.tencent.mm',
title: '微信支付 1000',
text: '微信支付 金额 1000.00',
timestamp: Date.now() + 1000,
};
const result = channel.handleNotification(lastEvent);
expect(result.processed).toBe(false); // 仍然是重复
expect(result.reason).toContain('MD5');
});
});
// ============ Logger 队列上限 ============
describe('Logger 队列上限 (#20)', () => {
it('队列超过 MAX_QUEUE_SIZE 时丢弃最旧条目', () => {
const logger = new LoggerImpl(100);
logger.consoleOutput = false;
// 模拟一个总是失败的后端
const failingBackend = {
appendLog: async () => { throw new Error('写入失败'); },
readLogFile: async () => '',
listLogFiles: async () => [],
deleteLogFile: async () => {},
clearAllLogFiles: async () => {},
};
// 同步初始化后端(不等待清理)
logger.initFileBackend(failingBackend);
// 写入超过 5000 条日志
for (let i = 0; i < 5100; i++) {
logger.info('test', `消息 ${i}`);
}
// 队列不应该无限增长
// 由于是私有属性,我们通过行为验证:flushQueue 不会处理超过 5000 条
// 这里主要验证不会抛出 OOM 错误
expect(true).toBe(true);
});
it('正常写入时队列按日期分组写入', async () => {
const logger = new LoggerImpl(100);
logger.consoleOutput = false;
const writtenLines: string[] = [];
const successBackend = {
appendLog: async (_date: string, line: string) => { writtenLines.push(line); },
readLogFile: async () => '',
listLogFiles: async () => [],
deleteLogFile: async () => {},
clearAllLogFiles: async () => {},
};
await logger.initFileBackend(successBackend);
// 写入少量日志
for (let i = 0; i < 10; i++) {
logger.info('test', `消息 ${i}`);
}
// 刷新队列
await logger.flushQueue();
// 同一天的日志会被合并为一次写入(按日期分组)
expect(writtenLines.length).toBe(1);
// 但内容包含所有 10 条日志
expect(writtenLines[0]).toContain('消息 0');
expect(writtenLines[0]).toContain('消息 9');
});
it('内存缓冲区循环淘汰正常工作', () => {
const logger = new LoggerImpl(5); // 小缓冲区
logger.consoleOutput = false;
for (let i = 0; i < 10; i++) {
logger.info('test', `消息 ${i}`);
}
const logs = logger.getLogs();
expect(logs.length).toBe(5);
// 应该保留最新的 5 条
expect(logs[0].message).toBe('消息 5');
expect(logs[4].message).toBe('消息 9');
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { pressKey, evaluateExpression } from '../src/components/numpadExpression';
import { parsePostingsToLegs } from '../src/domain/postingLegs';
import { buildPostings } from '../src/domain/transactionBuilder';
import { pressKey, evaluateExpression } from '../src/components/form/numpadExpression';
import { parsePostingsToLegs } from '../src/domain/transaction/postingLegs';
import { buildPostings } from '../src/domain/transaction/transactionBuilder';
describe('pressKey 表达式编辑', () => {
it('数字键追加', () => {
+103 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { matchOcrRule, parseOcrBill } from '../src/domain/ocr';
import { MockAiVisionProvider, MockOcrEngine, OcrProcessor, type OcrProcessResult } from '../src/domain/ocrProcessor';
import { matchOcrRule, parseOcrBill, detectSuspiciousAmount } from '../src/domain/pipeline/ocr';
import { MockAiVisionProvider, MockOcrEngine, OcrProcessor } from '../src/domain/pipeline/ocrProcessor';
describe('ocr - matchOcrRule', () => {
it('微信支付规则匹配', () => {
@@ -203,6 +203,24 @@ describe('ocr - 账单详情页识别', () => {
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';
@@ -242,6 +260,49 @@ describe('ocr - 账单详情页识别', () => {
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 分层处理', () => {
@@ -274,13 +335,13 @@ describe('OcrProcessor 分层处理', () => {
expect(result.event?.amount).toBe('-88');
});
it('无文本时跳过到 Layer 3(未启用 AI → none', async () => {
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('non-bill');
expect(result.skipped).toBe('ocr-empty');
});
it('Layer 3 AI Vision 启用时降级', async () => {
@@ -335,3 +396,41 @@ describe('OcrProcessor 分层处理', () => {
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();
});
});
+2 -2
View File
@@ -7,8 +7,8 @@ describe('categoryPalette', () => {
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it('内置分类有具名颜色(与原 CategoryPicker 一致', () => {
expect(getCategoryColor('food')).toBe('#F59E0B');
it('内置分类有具名颜色(Amber/Lime 已按对比度加深', () => {
expect(getCategoryColor('food')).toBe('#D97706');
expect(getCategoryColor('transport')).toBe('#3B82F6');
expect(getCategoryColor('salary')).toBe('#22C55E');
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/periodNav';
import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/stats/periodNav';
describe('shiftAnchor 周期导航', () => {
it('周:±7 天', () => {
+1 -1
View File
@@ -15,7 +15,7 @@ vi.mock('react', () => ({
}));
import { useSearch, type SearchFilters } from '../src/hooks/useSearch';
import type { Transaction } from '../src/domain/types';
import type { Transaction } from '../src/domain/core/types';
function tx(id: string, postings: { account: string; amount?: string }[]): Transaction {
return {
+13 -3
View File
@@ -1,9 +1,19 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { describe, expect, it, beforeEach, vi } from 'vitest';
// ledgerStore/importStore 间接依赖 txKeyStore → expo-file-systemmock 掉避免 react-native flow 语法解析失败
vi.mock('expo-file-system/legacy', () => ({
documentDirectory: '',
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
readAsStringAsync: vi.fn().mockResolvedValue('[]'),
writeAsStringAsync: vi.fn().mockResolvedValue(undefined),
moveAsync: vi.fn().mockResolvedValue(undefined),
}));
import { useLedgerStore } from '../src/store/ledgerStore';
import { useImportStore } from '../src/store/importStore';
import { useSettingsStore } from '../src/store/settingsStore';
import { MemoryBackend } from '../src/domain/mobileStore';
import type { LedgerFile } from '../src/domain/types';
import { MemoryBackend } from '../src/domain/platform/mobileStore';
import type { LedgerFile } from '../src/domain/core/types';
const sampleLedger: LedgerFile = {
path: 'main.bean',
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { mergeMobileBean, MockGitBackend, syncWithGit, type GitConfig } from '../src/services/sync/gitSync';
import { WebDAVClient, MockFetch, syncWithWebDAV, backupToWebDAV, type WebDAVConfig } from '../src/services/sync/webdavSync';
import { getMonthGrid } from '../src/domain/calendarGrid';
import type { Transaction } from '../src/domain/types';
import { getMonthGrid } from '../src/domain/stats/calendarGrid';
import type { Transaction } from '../src/domain/core/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildAndSaveTransaction, type TransactionStore } from '../src/domain/transactionBuilder';
import type { LedgerIndex } from '../src/domain/types';
import { buildAndSaveTransaction, type TransactionStore } from '../src/domain/transaction/transactionBuilder';
import type { LedgerIndex } from '../src/domain/core/types';
// Mock store
const mockAutoOpenAccounts = vi.fn().mockResolvedValue(undefined);
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { groupTransactionsByDate } from '../src/components/transactionGroups';
import type { Transaction } from '../src/domain/types';
import { groupTransactionsByDate } from '../src/components/transaction/transactionGroups';
import type { Transaction } from '../src/domain/core/types';
function tx(id: string, date: string, postings: { account: string; amount?: string }[]): Transaction {
return {