feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能

核心重构 — 去除 channel 字段,引入 sourceAccount 模型:
- 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段
- rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念
- dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断
- transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户
- 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试
新增基础设施:
- domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义
- domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找
- domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题
- domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听
OCR 增强:
- 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取
- checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额)
- 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配
- 时间解析支持中文格式(年月日)和跨年推断
- OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配
UI 全面升级:
- 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体
- 新增 commonStyles.ts 统一 chip/input/modal 等通用样式
- 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示
- 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细)
- TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线
- CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标
- Button/Card 增加 press 缩放动画
管道与自动化改进:
- automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认)
- 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR
- rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理
- metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则
存储与同步:
- storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制
- 备份升级到 v2 格式,包含 settings 和 metadata
- 同步路径统一从 mobile.bean 改为 main.bean
- _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean
其他:
- 删除独立日历页面,功能合并到报表月报标签
- i18n 清理:移除渠道相关翻译,新增 50+ 翻译键
- docs/android-build-guide.md 重写为 APK 体积优化指南
- 新增 design-system/beancount-mobile/MASTER.md 设计系统文档
- 测试全面更新覆盖以上所有变更
This commit is contained in:
fengmengqi
2026-07-18 18:02:45 +08:00
parent f6437b83fe
commit 76a5853ab6
133 changed files with 26279 additions and 8594 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { classify, commitMobileTransaction, deduplicate, importStatement, parseLedger, validateTransaction } from '../src/domain';
import { classifyWithCategories, commitMobileTransaction, deduplicate, importStatement, parseLedger, validateTransaction } from '../src/domain';
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\ninclude "mobile.bean"\n` }]);
describe('复式账务', () => {
@@ -15,7 +15,7 @@ describe('复式账务', () => {
describe('账单导入', () => {
it('识别 CSV 并按规则生成草稿', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,25.50,支出,咖啡店,A1', 'alipay-csv-v1');
const classified = classify(event, [{ id: 'coffee', priority: 10, counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], ledger);
const classified = classifyWithCategories(event, [{ id: 'coffee', priority: 10, counterpartyContains: '咖啡', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], [], ledger);
expect(classified.draft.postings).toEqual([{ account: 'Assets:Alipay', amount: '-25.50', currency: 'CNY' }, { account: 'Expenses:Food', amount: '25.50', currency: 'CNY' }]);
});
it('不会重复接收同一流水', () => {
+1 -3
View File
@@ -7,7 +7,6 @@ describe('CSV adapters', () => {
const csv = '交易日期,交易类型,交易金额,交易对方,摘要\n2026-07-10,消费,100.50,超市,买日用品\n2026-07-11,收入,5000.00,公司,工资';
const events = parseCmbCsv(csv);
expect(events).toHaveLength(2);
expect(events[0].channel).toBe('Bank:CMB');
expect(events[0].direction).toBe('expense');
expect(events[0].amount).toBe('-100.50');
expect(events[1].direction).toBe('income');
@@ -18,7 +17,6 @@ describe('CSV adapters', () => {
const csv = '交易时间,交易类型,交易金额,对方户名,摘要\n2026-07-10,消费,200,商户,购物';
const events = parseIcbcCsv(csv);
expect(events).toHaveLength(1);
expect(events[0].channel).toBe('Bank:ICBC');
expect(events[0].counterparty).toBe('商户');
});
@@ -26,7 +24,7 @@ describe('CSV adapters', () => {
const csv = '日期,金额,交易对方\n2026-07-10,50,便利店';
const events = parseGenericBankCsv(csv, 'Bank:Custom');
expect(events).toHaveLength(1);
expect(events[0].channel).toBe('Bank:Custom');
expect(events[0].id).toContain('Bank:Custom');
});
it('退款项识别为 refund', () => {
+2 -1
View File
@@ -197,7 +197,8 @@ describe('SmsChannel', () => {
const bill = parseSms(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-100.5');
expect(bill?.channel).toBe('Bank:1234');
expect(bill?.direction).toBe('expense');
expect(bill?.memo).toContain('1234');
});
it('parseSms 退款', () => {
+7 -7
View File
@@ -7,7 +7,7 @@ const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Asset
const sampleEvent: ImportedEvent = {
id: 'ocr-test-1', occurredAt: '2026-07-13T14:30:00', amount: '24.5', currency: 'CNY', direction: 'expense',
channel: 'WeChat', counterparty: '星巴克', memo: 'OCR识别', raw: {},
counterparty: '星巴克', memo: 'OCR识别', raw: {},
};
beforeEach(() => {
@@ -27,7 +27,7 @@ describe('automationStore', () => {
it('processAll 走 BillPipeline 生成草稿', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
await useAutomationStore.getState().processAll(
ledger, [], [], [], { WeChat: 'Assets:Alipay' },
ledger, [], [], [],
);
const state = useAutomationStore.getState();
expect(state.drafts.length).toBeGreaterThan(0);
@@ -36,13 +36,13 @@ describe('automationStore', () => {
});
it('空 detected 时 processAll 无操作', async () => {
await useAutomationStore.getState().processAll(ledger, [], [], [], {});
await useAutomationStore.getState().processAll(ledger, [], [], []);
expect(useAutomationStore.getState().drafts).toHaveLength(0);
});
it('confirmDraft 移除草稿并返回', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
await useAutomationStore.getState().processAll(ledger, [], [], [], { WeChat: 'Assets:Alipay' });
await useAutomationStore.getState().processAll(ledger, [], [], []);
const before = useAutomationStore.getState().drafts.length;
const draft = useAutomationStore.getState().confirmDraft(0);
expect(draft).not.toBeNull();
@@ -51,7 +51,7 @@ describe('automationStore', () => {
it('rejectDraft 移除草稿', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
await useAutomationStore.getState().processAll(ledger, [], [], [], { WeChat: 'Assets:Alipay' });
await useAutomationStore.getState().processAll(ledger, [], [], []);
const before = useAutomationStore.getState().drafts.length;
useAutomationStore.getState().rejectDraft(0);
expect(useAutomationStore.getState().drafts.length).toBe(before - 1);
@@ -68,9 +68,9 @@ describe('automationStore', () => {
it('多来源事件混合处理', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
useAutomationStore.getState().addDetected('sms', { ...sampleEvent, id: 'sms-1', channel: 'Bank:1234' });
useAutomationStore.getState().addDetected('sms', { ...sampleEvent, id: 'sms-1' });
useAutomationStore.getState().addDetected('notification', { ...sampleEvent, id: 'notif-1' });
await useAutomationStore.getState().processAll(ledger, [], [], [], { WeChat: 'Assets:Alipay', 'Bank:1234': 'Assets:Alipay' });
await useAutomationStore.getState().processAll(ledger, [], [], []);
const state = useAutomationStore.getState();
expect(state.stats.ocr).toBe(1);
expect(state.stats.sms).toBe(1);
+40 -46
View File
@@ -12,7 +12,7 @@ const evt = (over: Partial<ImportedEvent> & { id: string }): ImportedEvent => {
const { id = 'e1', ...rest } = over;
return {
occurredAt: '2026-02-01T10:00:00', amount: '-100', currency: 'CNY',
direction: 'expense', channel: 'Alipay', counterparty: '', memo: '', raw: {},
direction: 'expense', counterparty: '', memo: '', raw: {},
id,
...rest,
};
@@ -29,20 +29,20 @@ describe('dedup - checkDuplicate', () => {
expect(result.confidence).toBe('high');
});
it('时间窗口内 + 金额匹配 + 不同渠道 → 中置信度重复', () => {
const a = evt({ id: 'a', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', channel: 'WeChat', occurredAt: '2026-02-01T10:03:00' });
it('时间窗口内 + 金额匹配 + 对手方一致 → 中置信度重复', () => {
const a = evt({ id: 'a', occurredAt: '2026-02-01T10:00:00', counterparty: '星巴克' });
const b = evt({ id: 'b', occurredAt: '2026-02-01T10:03:00', counterparty: '星巴克' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(true);
expect(result.confidence).toBe('medium');
expect(result.reason).toContain('不同渠道');
});
it('相同渠道 + 相同金额(同人多次)→ 非重复', () => {
const a = evt({ id: 'a', channel: 'WeChat', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', channel: 'WeChat', occurredAt: '2026-02-01T10:03:00' });
it('时间窗口内 + 金额匹配 + 无对手方 → 低置信度重复', () => {
const a = evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', occurredAt: '2026-02-01T10:03:00' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(false);
expect(result.isDuplicate).toBe(true);
expect(result.confidence).toBe('low');
});
it('时间超出窗口 → 非重复', () => {
@@ -94,41 +94,41 @@ describe('dedup - dedupAgainstHistory', () => {
describe('transferRecognizer - recognizeTransfers', () => {
it('Income + Expend → Transfer(先于去重)', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-01T10:02:00' }),
evt({ id: 'a', amount: '100', direction: 'expense', counterparty: '支付宝', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', counterparty: '银行卡', occurredAt: '2026-02-01T10:02:00' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(1);
expect(result.transfers[0].draft.postings[0].account).toBe('Assets:Alipay');
expect(result.transfers[0].draft.postings[1].account).toBe('Assets:Bank:CMB');
expect(result.transfers[0].draft.postings[0].account).toBe('Assets:转账-支付宝');
expect(result.transfers[0].draft.postings[1].account).toBe('Assets:转账-银行卡');
expect(result.remaining).toHaveLength(0);
});
it('相同渠道不识别为转账', () => {
it('相同对手方也会识别为转账(由金额和时间决定)', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Alipay' }),
evt({ id: 'a', amount: '100', direction: 'expense', counterparty: '支付宝', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', counterparty: '支付宝', occurredAt: '2026-02-01T10:02:00' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay' });
expect(result.transfers).toHaveLength(0);
expect(result.remaining).toHaveLength(2);
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(1);
expect(result.remaining).toHaveLength(0);
});
it('金额不一致不配对', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay' }),
evt({ id: 'b', amount: '200', direction: 'income', channel: 'Bank' }),
evt({ id: 'a', amount: '100', direction: 'expense', counterparty: '支付宝' }),
evt({ id: 'b', amount: '200', direction: 'income', counterparty: '银行卡' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(0);
});
it('超出时间窗口不配对', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-10T10:00:00' }), // 9 天后
evt({ id: 'a', amount: '100', direction: 'expense', counterparty: '支付宝', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', counterparty: '银行卡', occurredAt: '2026-02-10T10:00:00' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(0);
});
});
@@ -137,12 +137,11 @@ describe('BillPipeline - 责任链顺序', () => {
it('转账识别先于去重:Income+Expend 合并为 1 条转账,而非丢弃重复', async () => {
const pipeline = new BillPipeline();
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-01T10:02:00' }),
evt({ id: 'a', amount: '100', direction: 'expense', counterparty: '支付宝', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', counterparty: '银行卡', occurredAt: '2026-02-01T10:02:00' }),
];
const result = await pipeline.process(events, {
ledger, rules: [], categories: [], history: [],
accountMap: { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' },
});
expect(result.drafts).toHaveLength(1);
expect(result.drafts[0].isTransfer).toBe(true);
@@ -150,17 +149,16 @@ describe('BillPipeline - 责任链顺序', () => {
expect(result.duplicates).toHaveLength(0);
});
it('跨通道重复被去重丢弃', async () => {
it('重复事件被去重丢弃', async () => {
const pipeline = new BillPipeline();
const events = [
evt({ id: 'a', amount: '50', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '50', direction: 'expense', channel: 'WeChat', occurredAt: '2026-02-01T10:01:00' }), // 跨通道重复
evt({ id: 'a', amount: '50', direction: 'expense', counterparty: '超市', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '50', direction: 'expense', counterparty: '超市', occurredAt: '2026-02-01T10:01:00' }),
];
const result = await pipeline.process(events, {
ledger, rules: [], categories: [], history: [],
accountMap: { Alipay: 'Assets:Alipay', WeChat: 'Assets:WeChat' },
});
expect(result.drafts).toHaveLength(1); // 仅保留首个
expect(result.drafts).toHaveLength(1);
expect(result.duplicates).toHaveLength(1);
});
@@ -170,11 +168,10 @@ describe('BillPipeline - 责任链顺序', () => {
];
const pipeline = new BillPipeline();
const events = [
evt({ id: 'a', amount: '30', direction: 'expense', channel: 'Alipay', counterparty: '麦当劳餐厅', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'a', amount: '30', direction: 'expense', counterparty: '麦当劳餐厅', occurredAt: '2026-02-01T10:00:00' }),
];
const result = await pipeline.process(events, {
ledger, rules: [], categories, history: [],
accountMap: { Alipay: 'Assets:Alipay' },
});
expect(result.drafts).toHaveLength(1);
expect(result.drafts[0].isTransfer).toBe(false);
@@ -184,7 +181,7 @@ describe('BillPipeline - 责任链顺序', () => {
it('空事件列表返回空结果', async () => {
const pipeline = new BillPipeline();
const result = await pipeline.process([], {
ledger, rules: [], categories: [], history: [], accountMap: {},
ledger, rules: [], categories: [], history: [],
});
expect(result.drafts).toHaveLength(0);
expect(result.transferCount).toBe(0);
@@ -194,26 +191,23 @@ describe('BillPipeline - 责任链顺序', () => {
describe('BillPipeline - 并发串行化', () => {
it('并发 process 互斥,结果不交错', async () => {
const pipeline = new BillPipeline();
const batch1 = [evt({ id: 'a1', amount: '100', direction: 'expense', channel: 'Alipay' })];
const batch2 = [evt({ id: 'b1', amount: '200', direction: 'expense', channel: 'WeChat' })];
const batch1 = [evt({ id: 'a1', amount: '100', direction: 'expense', counterparty: '支付宝' })];
const batch2 = [evt({ id: 'b1', amount: '200', direction: 'expense', counterparty: '微信' })];
const [r1, r2] = await Promise.all([
pipeline.process(batch1, { ledger, rules: [], categories: [], history: [], accountMap: { Alipay: 'Assets:Alipay' } }),
pipeline.process(batch2, { ledger, rules: [], categories: [], history: [], accountMap: { WeChat: 'Assets:WeChat' } }),
pipeline.process(batch1, { ledger, rules: [], categories: [], history: [] }),
pipeline.process(batch2, { ledger, rules: [], categories: [], history: [] }),
]);
expect(r1.drafts).toHaveLength(1);
expect(r2.drafts).toHaveLength(1);
// 各自处理各自的批次,不串味
expect(r1.drafts[0].draft.postings[0].account).toBe('Assets:支付宝余额');
expect(r2.drafts[0].draft.postings[0].account).toBe('Assets:微信零钱');
});
});
describe('dedupFingerprint', () => {
it('相同关键字段产生相同指纹', () => {
const a = evt({ id: 'a', channel: 'Alipay', counterparty: 'X' });
const b = evt({ id: 'b', channel: 'Alipay', counterparty: 'X' }); // 同时间同金额同渠道
const a = evt({ id: 'a', counterparty: 'X' });
const b = evt({ id: 'b', counterparty: 'X' });
expect(dedupFingerprint(a)).toBe(dedupFingerprint(b));
});
});
+39 -2
View File
@@ -80,7 +80,7 @@ describe('resolveCategoryAccount(双轨制核心)', () => {
describe('classifyWithCategories(双轨制记账)', () => {
it('规则命中时优先用规则的 categoryAccount', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,25.50,支出,咖啡店,A1', 'alipay-csv-v1');
const result = classifyWithCategories(event, [{ id: 'coffee', priority: 10, counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], expenseCats, ledger);
const result = classifyWithCategories(event, [{ id: 'coffee', priority: 10, counterpartyContains: '咖啡', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], expenseCats, ledger);
expect(result.ruleId).toBe('coffee');
expect(result.draft.postings[1].account).toBe('Expenses:Food');
});
@@ -113,12 +113,49 @@ describe('classifyWithCategories(双轨制记账)', () => {
it('收入方向用收入分类', () => {
const event: ImportedEvent = {
id: 'test1', occurredAt: '2026-02-01', amount: '5000', currency: 'CNY', direction: 'income',
channel: 'Bank', counterparty: '公司', memo: '工资', raw: {},
counterparty: '公司', memo: '工资', raw: {},
};
const result = classifyWithCategories(event, [], incomeCats, ledger);
expect(result.categoryId).toBe('salary');
expect(result.draft.postings[1].account).toBe('Income:Salary'); // Income:Salary 未 open 但不降级,保留真实账户
});
it('回归:raw 中有支付方式时正确解析资金来源账户', () => {
const event: ImportedEvent = {
id: 'pkg1', occurredAt: '2026-07-15', amount: '-718.07', currency: 'CNY', direction: 'expense',
counterparty: '', memo: 'OCR 退化解析',
raw: { packageName: 'com.eg.android.AlipayGphone', '收/付款方式': '余额宝' },
};
const result = classifyWithCategories(event, [], expenseCats, ledger);
expect(result.draft.postings[0].account).toBe('Assets:余额宝');
});
it('回归:无支付方式时兜底 Assets:Unknown', () => {
const event: ImportedEvent = {
id: 'pkg2', occurredAt: '2026-07-15', amount: '-50', currency: 'CNY', direction: 'expense',
counterparty: '', memo: '', raw: {},
};
const result = classifyWithCategories(event, [], expenseCats, ledger);
expect(result.draft.postings[0].account).toBe('Assets:Unknown');
});
it('智能还款匹配:还款且包含花呗或信用卡时自动解析为 Liabilities 账户', () => {
// 1. 花呗还款
const event1: ImportedEvent = {
id: 'repay1', occurredAt: '2026-07-16', amount: '-1898.34', currency: 'CNY', direction: 'expense',
counterparty: '还款', memo: '花呗主动还款', raw: { text: '还款成功\n还款到 花呗' },
};
const result1 = classifyWithCategories(event1, [], expenseCats, ledger);
expect(result1.draft.postings[1].account).toBe('Liabilities:支付宝花呗');
// 2. 信用卡还款
const event2: ImportedEvent = {
id: 'repay2', occurredAt: '2026-07-16', amount: '-1000', currency: 'CNY', direction: 'expense',
counterparty: '招商银行信用卡(2586', memo: '还款', raw: { text: '还款成功\n招商银行信用卡(2586)' },
};
const result2 = classifyWithCategories(event2, [], expenseCats, ledger);
expect(result2.draft.postings[1].account).toBe('Liabilities:招商银行信用卡-2586');
});
});
describe('分类工具函数', () => {
+19 -3
View File
@@ -53,7 +53,7 @@ describe('AI 高阶函数', () => {
it('aiRecognizeCategory 返回匹配的分类', async () => {
const cats = [{ id: 'c1', name: '餐饮', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const provider = new MockProvider({} as never, ['餐饮']);
const event: ImportedEvent = { id: 'e1', occurredAt: '2026-07-13', amount: '30', currency: 'CNY', direction: 'expense', channel: 'Alipay', counterparty: '麦当劳', memo: '', raw: {} };
const event: ImportedEvent = { id: 'e1', occurredAt: '2026-07-13', amount: '30', currency: 'CNY', direction: 'expense', counterparty: '麦当劳', memo: '', raw: {} };
const cat = await aiRecognizeCategory(event, cats, provider);
expect(cat?.id).toBe('c1');
});
@@ -180,18 +180,26 @@ describe('privacyBlur', () => {
});
describe('backup', () => {
it('createBackupBundle + 往返', () => {
it('createBackupBundle v2 含设置和元数据', () => {
const settings = { language: 'zh', theme: 'dark' };
const metadata = { categories: [{ id: 'food', name: '餐饮' }], rules: [] };
const bundle = createBackupBundle(
[{ path: 'main.bean', content: 'option "title" "test"' }],
'2026-07-01 * "x" "y"\n',
settings,
metadata,
);
expect(bundle.version).toBe(1);
expect(bundle.version).toBe(2);
expect(bundle.files).toHaveLength(1);
expect(bundle.mobileBean).toContain('2026-07-01');
expect(bundle.settings).toEqual(settings);
expect(bundle.metadata).toEqual(metadata);
const json = serializeBundle(bundle);
const restored = deserializeBundle(json);
expect(restored.mobileBean).toBe(bundle.mobileBean);
expect(restored.settings).toEqual(settings);
expect(restored.metadata).toEqual(metadata);
});
it('restoreFiles 含 mobile.bean', () => {
@@ -201,6 +209,14 @@ describe('backup', () => {
expect(files.find(f => f.path === 'mobile.bean')?.content).toBe('y');
});
it('deserializeBundle 兼容 v1(无 settings/metadata', () => {
const v1 = '{"version":1,"createdAt":"2026-01-01","files":[],"mobileBean":""}';
const bundle = deserializeBundle(v1);
expect(bundle.version).toBe(1);
expect(bundle.settings).toBeUndefined();
expect(bundle.metadata).toBeUndefined();
});
it('deserializeBundle 拒绝错误版本', () => {
expect(() => deserializeBundle('{"version":99,"files":[],"mobileBean":""}')).toThrow('不支持的备份版本');
});
+6 -6
View File
@@ -7,7 +7,7 @@ import type { ImportedEvent } from '../src/domain/types';
const evt = (over: Partial<ImportedEvent> = {}): ImportedEvent => ({
id: 'e1', occurredAt: '2026-02-01T10:00:00', amount: '100', currency: 'CNY', direction: 'expense',
channel: 'Alipay', counterparty: '', memo: '', raw: {},
counterparty: '', memo: '', raw: {},
...over,
});
@@ -91,7 +91,7 @@ describe('ruleEngine', () => {
const engine = new RuleEngine();
const rules: EnhancedRule[] = [{
id: 'r1', name: '咖啡', priority: 10, enabled: true, isSystem: false,
counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
counterpartyContains: '咖啡', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
}];
const result = engine.matchEvent(evt({ counterparty: '星巴克咖啡' }), rules);
expect(result?.rule.id).toBe('r1');
@@ -103,7 +103,7 @@ describe('ruleEngine', () => {
const rules: EnhancedRule[] = [{
id: 'r1', name: 'JS规则', priority: 10, enabled: true, isSystem: false,
jsCode: 'print(JSON.stringify({money: data.money, shopName: data.shopName}))',
channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
}];
const result = engine.matchEvent(evt({ amount: '50', counterparty: '测试' }), rules);
expect(result?.result.matched).toBe(true);
@@ -115,7 +115,7 @@ describe('ruleEngine', () => {
const rules: EnhancedRule[] = [{
id: 'r1', name: 'JS规则', priority: 10, enabled: true, isSystem: false,
jsCode: 'print(JSON.stringify({money: 0}))',
channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
}];
const result = engine.matchEvent(evt({ amount: '50' }), rules);
expect(result).toBeNull();
@@ -124,8 +124,8 @@ describe('ruleEngine', () => {
it('用户规则优先于系统规则', () => {
const engine = new RuleEngine();
const rules: EnhancedRule[] = [
{ id: 'sys', name: '系统', priority: 100, enabled: true, isSystem: true, channelAccount: 'A1', categoryAccount: 'C1', hits: 0 },
{ id: 'usr', name: '用户', priority: 1, enabled: true, isSystem: false, channelAccount: 'A2', categoryAccount: 'C2', hits: 0 },
{ id: 'sys', name: '系统', priority: 100, enabled: true, isSystem: true, sourceAccount: 'A1', categoryAccount: 'C1', hits: 0 },
{ id: 'usr', name: '用户', priority: 1, enabled: true, isSystem: false, sourceAccount: 'A2', categoryAccount: 'C2', hits: 0 },
];
const result = engine.matchEvent(evt(), rules);
expect(result?.rule.id).toBe('usr'); // 用户优先,即使 priority 低
+1 -3
View File
@@ -66,9 +66,7 @@ describe('FileSystemBackend', () => {
// 验证写入 tmp
expect(mockFs.writeAsStringAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', 'new content');
// 验证删除旧文件
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/mobile.bean', { idempotent: true });
// 验证 rename tmp → 正式文件
// 验证 rename tmp → 正式文件(覆盖旧文件,无需删除)
expect(mockFs.moveAsync).toHaveBeenCalledWith({ from: '/mock/mobile.bean.tmp', to: '/mock/mobile.bean' });
});
+2 -5
View File
@@ -50,7 +50,7 @@ describe('Real Files Import Integrity and Health Test', () => {
for (let i = 0; i < allEvents.length; i++) {
const e = allEvents[i];
const errMsg = `Failed validation at index ${i} (Channel: ${e.channel}, ID: ${e.id})`;
const errMsg = `Failed validation at index ${i} (ID: ${e.id})`;
// A. ID Validation
expect(e.id, `${errMsg}: ID should be a non-empty string`).toBeTruthy();
@@ -80,10 +80,7 @@ describe('Real Files Import Integrity and Health Test', () => {
// E. Direction Validation
expect(e.direction, `${errMsg}: direction "${e.direction}" is invalid`).toMatch(/^(income|expense|transfer|refund|fee)$/);
// F. Channel Code Validation
expect(e.channel, `${errMsg}: channel "${e.channel}" is invalid`).toMatch(/^(Alipay|WeChat|Bank)$/);
// G. Column Shift / Misalignment Check
// F. Column Shift / Misalignment Check
// Ensure data has not shifted (e.g. headers appeared as values)
const headerKeywords = ['金额', '交易时间', '交易对方', '收/支', '商品', '备注', '交易单号', '商户单号'];
headerKeywords.forEach(kw => {
+3 -3
View File
@@ -7,7 +7,7 @@ import type { ImportedEvent } from '../src/domain/types';
const evt = (over: Partial<ImportedEvent> = {}): ImportedEvent => ({
id: 'e1', occurredAt: '2026-07-13T14:30:00', amount: '100', currency: 'CNY', direction: 'expense',
channel: 'Alipay', counterparty: '星巴克', memo: '拿铁', raw: { fee: '0.5', tags: 'coffee' },
counterparty: '星巴克', memo: '拿铁', raw: { fee: '0.5', tags: 'coffee' },
...over,
});
@@ -63,7 +63,7 @@ describe('AiVisionProcessor', () => {
it('Mock 返回预设结果', async () => {
const mock = new MockAiVisionProcessor({
id: 'ai1', occurredAt: '2026-07-13', amount: '50', currency: 'CNY', direction: 'expense',
channel: 'AI', counterparty: '测试', memo: '', raw: {},
counterparty: '测试', memo: '', raw: {},
});
const result = await mock.recognizeBill('img');
expect(result?.amount).toBe('50');
@@ -72,7 +72,7 @@ describe('AiVisionProcessor', () => {
it('Mock setResponse 切换结果', async () => {
const mock = new MockAiVisionProcessor(null);
expect(await mock.recognizeBill('img')).toBeNull();
mock.setResponse({ id: 'ai2', occurredAt: 'now', amount: '30', currency: 'CNY', direction: 'income', channel: 'AI', counterparty: '', memo: '', raw: {} });
mock.setResponse({ id: 'ai2', occurredAt: 'now', amount: '30', currency: 'CNY', direction: 'income', counterparty: '', memo: '', raw: {} });
expect((await mock.recognizeBill('img'))?.direction).toBe('income');
});
});
+1 -1
View File
@@ -99,7 +99,7 @@ describe('metadataStore CreditCard CRUD', () => {
describe('metadataStore Rule CRUD', () => {
it('addRule / updateRule / removeRule', () => {
const r: Rule = { id: 'ruletest', priority: 50, counterpartyContains: '美团', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 };
const r: Rule = { id: 'ruletest', priority: 50, counterpartyContains: '美团', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 };
useMetadataStore.getState().addRule(r);
expect(useMetadataStore.getState().rules.find(x => x.id === 'ruletest')).toBeDefined();
+5 -5
View File
@@ -95,8 +95,8 @@ describe('reminder', () => {
describe('ruleSync', () => {
const rules: Rule[] = [
{ id: 'r1', priority: 10, channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 },
{ id: 'r2', priority: 5, channelAccount: 'Assets:Bank', categoryAccount: 'Expenses:Transport', hits: 0 },
{ id: 'r1', priority: 10, sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 },
{ id: 'r2', priority: 5, sourceAccount: 'Assets:Bank', categoryAccount: 'Expenses:Transport', hits: 0 },
];
it('exportRules 写入 JSON', async () => {
@@ -120,13 +120,13 @@ describe('ruleSync', () => {
});
it('validateRule 校验', () => {
expect(validateRule({ id: 'x', priority: 1, channelAccount: 'a', categoryAccount: 'b', hits: 0 })).toBe(true);
expect(validateRule({ id: 'x', priority: 1, sourceAccount: 'a', categoryAccount: 'b', hits: 0 })).toBe(true);
expect(validateRule({ id: 'x' })).toBe(false);
});
it('mergeRules 按 id 去重(导入覆盖)', () => {
const existing: Rule[] = [{ id: 'r1', priority: 1, channelAccount: 'A', categoryAccount: 'C', hits: 0 }];
const imported: Rule[] = [{ id: 'r1', priority: 10, channelAccount: 'A2', categoryAccount: 'C2', hits: 0 }];
const existing: Rule[] = [{ id: 'r1', priority: 1, sourceAccount: 'A', categoryAccount: 'C', hits: 0 }];
const imported: Rule[] = [{ id: 'r1', priority: 10, sourceAccount: 'A2', categoryAccount: 'C2', hits: 0 }];
const merged = mergeRules(existing, imported);
expect(merged).toHaveLength(1);
expect(merged[0].priority).toBe(10); // 被覆盖
+186 -5
View File
@@ -10,7 +10,6 @@ describe('ocr - matchOcrRule', () => {
expect(result.ruleName).toBe('微信支付');
expect(result.event?.amount).toBe('-24.5');
expect(result.event?.counterparty).toBe('星巴克');
expect(result.event?.channel).toBe('WeChat');
});
it('支付宝规则匹配', () => {
@@ -26,7 +25,6 @@ describe('ocr - matchOcrRule', () => {
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
expect(result.ruleName).toBe('银行卡');
expect(result.event?.channel).toBe('Bank:1234');
expect(result.event?.memo).toContain('1234');
});
@@ -54,12 +52,42 @@ describe('ocr - matchOcrRule', () => {
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.5');
});
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?.channel).toBe('WeChat');
expect(event?.direction).toBe('expense');
});
it('退化:提取金额 + 推断方向', () => {
@@ -72,6 +100,148 @@ describe('ocr - parseOcrBill 退化解析', () => {
it('无任何金额返回 null', () => {
expect(parseOcrBill('纯文本无金额')).toBeNull();
});
it('退化分支:非支付App包名保留在 raw 中', () => {
const event = parseOcrBill('消费 ¥100', 'com.test.app');
expect(event).not.toBeNull();
expect(event?.raw.packageName).toBe('com.test.app');
});
it('退化分支:拦截支付App包名以防止列表页误匹配', () => {
const event1 = parseOcrBill('消费 ¥100', 'com.eg.android.AlipayGphone');
expect(event1).toBeNull();
const event2 = parseOcrBill('付款 50元', 'com.tencent.mm');
expect(event2).toBeNull();
});
it('退化分支:未知包名原样保留', () => {
const event = parseOcrBill('支付 ¥9.9', 'com.unknown.app');
expect(event?.raw.packageName).toBe('com.unknown.app');
});
it('退化分支:无 packageName 时为空字符串', () => {
const event = parseOcrBill('支付 ¥9.9');
expect(event?.raw.packageName).toBe('');
});
});
describe('ocr - 账单详情页识别', () => {
// 本次真机实测的抖音月付还款账单详情页(| 分隔格式)
const douyinCase = '中工 | d | 突圳市中融小额贷款有限公司 | -8.51 | 交易成功 | 支付时间 | 2026-07-15.09:01:18 | 付款方式 | 兴业银行储蓄卡(2586) | 商品说明 | 抖音月付 | 支付奖励 | 立即领取10积分 | 收款万全称 | 深圳市中融小额贷款有限公司 | 订单号 | 2026071523001442621427676501 | 商家订单号 | 200107260715010284934083340 | 账单管理 | 账单分类 | 信用借还 | 标签 | 请选择 | 为您推荐 | 贷款十 | 计入收支 | 备注 | 添加';
it('抖音月付详情页:识别成功,金额 -8.51', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-8.51');
expect(event?.direction).toBe('expense');
expect(event?.raw.packageName).toBe('com.eg.android.AlipayGphone');
});
it('详情页:提取付款方式到 raw(供账户解析)', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.raw['收/付款方式']).toBe('兴业银行储蓄卡(2586');
});
it('详情页:提取支付时间(日期时间用 . 连接的格式)', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.occurredAt).toBe('2026-07-15 09:01');
});
it('详情页:提取商品说明', () => {
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.counterparty).toBe('抖音月付');
});
it('详情页:换行分隔格式也能识别', () => {
const text = '交易详情\n-15.00\n交易成功\n付款方式 余额宝\n商品说明 星巴克咖啡\n支付时间 2026-07-14 10:30:00\n订单号 20260714103000123456';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-15.00');
expect(event?.counterparty).toBe('星巴克咖啡');
expect(event?.raw['收/付款方式']).toBe('余额宝');
});
it('详情页:金额不误抓日期时间里的数字', () => {
// 2026-07-15.09 不应被误认为 -15.09
const event = parseOcrBill(douyinCase, 'com.eg.android.AlipayGphone');
expect(event?.amount).toBe('-8.51'); // 而非 -15.09
});
it('详情页:正数金额识别为收入', () => {
const text = '退款详情\n+88.88\n退款成功\n付款方式 花呗\n商品说明 商品退款\n支付时间 2026-07-14 10:30:00';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event?.amount).toBe('88.88');
expect(event?.direction).toBe('income');
});
it('详情页:无小数点但有正负号金额识别', () => {
const text = '交易详情\n+7540\n交易成功\n付款方式 余额宝\n商品说明 收益转账\n支付时间 2026-07-14 10:30:00';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event?.amount).toBe('7540');
expect(event?.direction).toBe('income');
});
it('详情页:交易对手提取与后缀清理', () => {
// 微信商品+订单号后缀
const text1 = '交易详情\n-15.50\n支付时间 2026-07-15 19:39:58\n商品 京东-订单编号356043400524804\n付款方式 零钱';
const event1 = parseOcrBill(text1, 'com.tencent.mm');
expect(event1?.counterparty).toBe('京东');
// 支付宝对方账户
const text2 = '交易成功\n+7540\n对方账户 广州市弘智服饰有限公司\n创建时间 2026-07-15 20:13:14\n付款方式 余额宝';
const event2 = parseOcrBill(text2, 'com.eg.android.AlipayGphone');
expect(event2?.counterparty).toBe('广州市弘智服饰有限公司');
});
it('详情页:支持中文年月日时间格式及补零', () => {
// 包含中文年月日,且没有空格隔开时间
const text = '付款方:黄*青\n50.00\n支付时间 2026年7月13日20:29:46\n商品 收款\n付款方式 余额';
const event = parseOcrBill(text, 'com.eg.android.AlipayGphone');
expect(event).not.toBeNull();
// 验证转换正确,月/日/时/分格式和空格规范化
expect(event?.occurredAt).toBe('2026-07-13 20:29');
});
it('详情页:金额 OCR 常见符号错识纠错(如 898-34 -> 898.34', () => {
// 模拟还款成功页面,金额中的点被错识为减号或者逗号
const text1 = '还款成功\n898-34\n付款方式 余额\n商品说明 还款\n创建时间 2026-07-13 17:57:45';
const event1 = parseOcrBill(text1, 'com.eg.android.AlipayGphone');
expect(event1).not.toBeNull();
expect(event1?.amount).toBe('-898.34'); // 应当成功纠错为小数点并记为支出
const text2 = '交易成功\n1234,56\n付款方式 余额\n商品说明 交易\n创建时间 2026-07-13 17:57:45';
const event2 = parseOcrBill(text2, 'com.eg.android.AlipayGphone');
expect(event2?.amount).toBe('-1234.56'); // 应当成功将逗号规范化为小数点
const text3 = '还款成功\n1.898.34\n付款方式 余额\n商品说明 还款\n创建时间 2026-07-13 17:57:45';
const event3 = parseOcrBill(text3, 'com.eg.android.AlipayGphone');
expect(event3).not.toBeNull();
expect(event3?.amount).toBe('-1898.34'); // 应当成功将千分位点号规范化并保留小数位点号
});
it('误判防护:纯订单号页面不识别', () => {
const text = '订单号 2026071523001442621427676501\n商家订单号 200107260715010284934083340';
expect(parseOcrBill(text)).toBeNull();
});
it('误判防护:仅 1 个特征词不识别(需 ≥2 个)', () => {
const text = '订单号 这是一个普通页面,只有订单号一个特征词,金额 100';
// 「金额 100」会被退化解析抓到,但不应走详情页分支
const event = parseOcrBill(text);
// 退化解析会命中(有"金额"关键词),验证它走的是退化而非详情页
expect(event?.memo).toBe('OCR 退化解析');
});
it('误判防护:支付App列表页不应被退化解析匹配', () => {
// 模拟支付宝列表页
const listTextAlipay = '全部 支出 转账 余额宝-自动转入 75.40 抖音月付 -8.51';
expect(parseOcrBill(listTextAlipay, 'com.eg.android.AlipayGphone')).toBeNull();
// 模拟微信列表页
const listTextWechat = '收支统计 2026年7月 支出¥37032收入¥5651 京东 -15.50';
expect(parseOcrBill(listTextWechat, 'com.tencent.mm')).toBeNull();
});
});
describe('OcrProcessor 分层处理', () => {
@@ -84,6 +254,17 @@ describe('OcrProcessor 分层处理', () => {
expect(result.event?.amount).toBe('-24.5');
});
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元';
@@ -107,7 +288,7 @@ describe('OcrProcessor 分层处理', () => {
engine.defaultResponse = '非账单的随机文本';
const aiProvider = new MockAiVisionProvider({
id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense',
channel: 'AI', counterparty: 'AI识别', memo: '', raw: {},
counterparty: 'AI识别', memo: '', raw: {},
});
const processor = new OcrProcessor(engine, aiProvider, { aiVisionEnabled: true, landscapeDnd: false });
const result = await processor.process('img4');
@@ -140,7 +321,7 @@ describe('OcrProcessor 分层处理', () => {
const p2 = processor.process('img-b'); // p1 未完成时
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.layer).toBe('layer1-rule');
expect(r2.skipped).toBe('dedup');
expect(r2.skipped).toBe('busy');
});
it('reset 清除去重状态', async () => {
+2 -4
View File
@@ -143,13 +143,11 @@ describe('importStore', () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
});
it('setContext 设置规则/分类/账户映射', () => {
it('setContext 设置规则/分类', () => {
useImportStore.getState().setContext({
rules: [{ id: 'r1', priority: 10, counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }],
accountMap: { Alipay: 'Assets:Alipay' },
rules: [{ id: 'r1', priority: 10, counterpartyContains: '咖啡', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }],
});
expect(useImportStore.getState().rules).toHaveLength(1);
expect(useImportStore.getState().accountMap.Alipay).toBe('Assets:Alipay');
});
it('importCsv 生成事件', () => {
+2 -2
View File
@@ -11,8 +11,8 @@ describe('预置主题', () => {
it('lightTheme 背景为亮色、darkTheme 背景为深色', () => {
// 亮色背景接近白,深色背景接近黑
expect(lightTheme.colors.bgPrimary).toBe('#FFFFFF');
expect(darkTheme.colors.bgPrimary).not.toBe('#FFFFFF');
expect(lightTheme.colors.bgPrimary).toBe('#F8FAFC');
expect(darkTheme.colors.bgPrimary).not.toBe('#F8FAFC');
});
it('presetThemes 注册表含 light 与 dark', () => {