DriftLedger/tests/categories.test.ts
fengmengqi 7fa345b558 feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00

246 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import { 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';
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' }]);
const expenseCats: Category[] = [
{ id: 'food', name: '餐饮', type: 'expense', linkedAccount: 'Expenses:Food', keywords: ['餐', '饭', '咖啡', '食'] },
{ id: 'transport', name: '交通', type: 'expense', linkedAccount: 'Expenses:Transport', keywords: ['打车', '地铁', '公交'] },
{ id: 'other', name: '其他', type: 'expense', linkedAccount: 'Expenses:Uncategorized', keywords: [] },
];
const incomeCats: Category[] = [
{ id: 'salary', name: '工资', type: 'income', linkedAccount: 'Income:Salary', keywords: ['工资', '薪'] },
{ id: 'other-i', name: '其他', type: 'income', linkedAccount: 'Income:Uncategorized', keywords: [] },
];
describe('matchCategory', () => {
it('精确匹配(分类名等于文本)', () => {
const m = matchCategory('餐饮', expenseCats);
expect(m.category?.id).toBe('food');
expect(m.matchedBy).toBe('exact');
});
it('关键词匹配', () => {
const m = matchCategory('星巴克咖啡', expenseCats);
expect(m.category?.id).toBe('food');
expect(m.matchedBy).toBe('keyword');
});
it('模糊匹配(包含关系)', () => {
const cats = [{ id: 'x', name: '咖啡店', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const m = matchCategory('某咖啡店分店', cats);
expect(m.category?.id).toBe('x');
expect(m.matchedBy).toBe('fuzzy');
});
it('未匹配时兜底到"其他"', () => {
const m = matchCategory('完全无关的文本', expenseCats);
expect(m.category?.id).toBe('other');
expect(m.matchedBy).toBe('fallback');
});
it('空文本返回 none', () => {
const m = matchCategory(' ', expenseCats);
expect(m.category).toBeNull();
expect(m.matchedBy).toBe('none');
});
});
describe('resolveCategoryAccount双轨制核心', () => {
it('linkedAccount 存在时返回该账户', () => {
const r = resolveCategoryAccount(expenseCats[0], 'expense', ledger);
expect(r.account).toBe('Expenses:Food');
expect(r.valid).toBe(true);
});
it('linkedAccount 不存在时不降级,返回原始账户', () => {
const r = resolveCategoryAccount(expenseCats[1], 'expense', ledger); // Transport 未 open
expect(r.account).toBe('Expenses:Transport');
expect(r.valid).toBe(false);
expect(r.fallbackReason).toContain('尚未开户');
});
it('category 为 null 时使用未分类兜底', () => {
const r = resolveCategoryAccount(null, 'income', ledger);
expect(r.account).toBe('Income:未分类');
expect(r.valid).toBe(false);
});
it('收入类型未匹配时兜底 Income:未分类', () => {
const r = resolveCategoryAccount(null, 'income', ledger);
expect(r.account).toBe('Income:未分类');
});
});
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: '咖啡', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], expenseCats, ledger);
expect(result.ruleId).toBe('coffee');
expect(result.draft.postings[1].account).toBe('Expenses:Food');
});
it('规则未命中时用分类库匹配(关键词)', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,30,支出,麦当劳快餐,A2', 'alipay-csv-v1');
const result = classifyWithCategories(event, [], expenseCats, ledger);
expect(result.ruleId).toBeUndefined();
expect(result.categoryId).toBe('food'); // 关键词"餐"命中
expect(result.draft.postings[1].account).toBe('Expenses:Food');
});
it('分类匹配但 linkedAccount 未开户时不降级', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,15,支出,打车滴滴,A3', 'alipay-csv-v1');
const result = classifyWithCategories(event, [], expenseCats, ledger);
// 交通分类命中Expenses:Transport 未 open但不降级保留真实账户
expect(result.categoryId).toBe('transport');
expect(result.draft.postings[1].account).toBe('Expenses:Transport');
expect(result.categoryFallbackReason).toContain('尚未开户');
expect(result.confidence).toBe('low');
});
it('完全未匹配时兜底到其他分类', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,99,支出,未知商户,A4', 'alipay-csv-v1');
const result = classifyWithCategories(event, [], expenseCats, ledger);
// 未匹配到具体分类,兜底到 expenseCats 中的 'other' 分类
expect(result.draft.postings[1].account).toBe('Expenses:Uncategorized');
});
it('收入方向用收入分类', () => {
const event: ImportedEvent = {
id: 'test1', occurredAt: '2026-02-01', amount: '5000', currency: 'CNY', direction: 'income',
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('分类工具函数', () => {
const all = [...expenseCats, ...incomeCats, { id: 'food-lunch', name: '午餐', type: 'expense' as const, parentId: 'food', linkedAccount: 'Expenses:Food', keywords: [] }];
it('filterByType', () => {
expect(filterByType(all, 'expense')).toHaveLength(4);
expect(filterByType(all, 'income')).toHaveLength(2);
});
it('getTopLevelCategories无 parentId', () => {
expect(getTopLevelCategories(all)).toHaveLength(5);
});
it('getSubCategories', () => {
expect(getSubCategories(all, 'food')).toHaveLength(1);
expect(getSubCategories(all, 'food')[0].id).toBe('food-lunch');
});
});
describe('tags标签序列化', () => {
it('isValidTagName', () => {
expect(isValidTagName('food')).toBe(true);
expect(isValidTagName('lunch-1')).toBe(true);
expect(isValidTagName('餐饮')).toBe(false); // 中文不符合 \w
expect(isValidTagName('')).toBe(false);
expect(isValidTagName('a b')).toBe(false); // 空格
});
it('tagsToBeanSyntax 序列化为 #tag', () => {
const tags: Tag[] = [{ id: 't1', name: 'food', color: '#f00' }, { id: 't2', name: 'lunch', color: '#0f0' }];
expect(tagsToBeanSyntax(tags)).toBe(' #food #lunch');
});
it('tagsToBeanSyntax 过滤非法名', () => {
const tags: Tag[] = [{ id: 't1', name: 'food', color: '' }, { id: 't2', name: '非法 名', color: '' }];
expect(tagsToBeanSyntax(tags)).toBe(' #food');
});
it('extractTagNames 从 narration 提取', () => {
expect(extractTagNames('冰美式 #food #lunch')).toEqual(['food', 'lunch']);
expect(extractTagNames('无标签的备注')).toEqual([]);
});
it('resolveTagsByName 从库匹配', () => {
const all: Tag[] = [{ id: 't1', name: 'food', color: '#f00' }];
const resolved = resolveTagsByName(['food', 'unknown'], all);
expect(resolved).toHaveLength(1);
expect(resolved[0].name).toBe('food');
});
});
describe('resolveSourceAccount账户名规范化与去空格', () => {
it('正确过滤并替换支付方式中的括号和空格为连字符', () => {
const event: ImportedEvent = {
id: 'e1',
occurredAt: '2026-07-21 12:00:00',
amount: '-10.00',
currency: 'CNY',
direction: 'expense',
counterparty: '商户',
memo: '备注',
raw: {
'收付款方式': '招商银行 信用卡(1234)'
}
};
const acct = resolveSourceAccount(event);
expect(acct).toBe('Liabilities:招商银行-信用卡-1234');
});
it('保留普通合法字符,替换多个空格和连字符', () => {
const event: ImportedEvent = {
id: 'e2',
occurredAt: '2026-07-21 12:00:00',
amount: '-10.00',
currency: 'CNY',
direction: 'expense',
counterparty: '商户',
memo: '备注',
raw: {
'支付方式': ' 建设银行 储蓄卡 '
}
};
const acct = resolveSourceAccount(event);
expect(acct).toBe('Assets:建设银行-储蓄卡');
});
});