品牌与配置 - 重命名 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 新增不同格式金额配对、指纹唯一性测试
237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
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';
|
||
|
||
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' }]);
|
||
|
||
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', counterparty: '', memo: '', raw: {},
|
||
id,
|
||
...rest,
|
||
};
|
||
};
|
||
|
||
describe('dedup - checkDuplicate', () => {
|
||
const config: DedupConfig = { timeWindowMinutes: 5, checkAccountLink: true };
|
||
|
||
it('时间完全相同 + 金额一致 → 高置信度重复', () => {
|
||
const a = evt({ id: 'a' });
|
||
const b = evt({ id: 'b' });
|
||
const result = checkDuplicate(b, [a], config);
|
||
expect(result.isDuplicate).toBe(true);
|
||
expect(result.confidence).toBe('high');
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
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(true);
|
||
expect(result.confidence).toBe('low');
|
||
});
|
||
|
||
it('时间超出窗口 → 非重复', () => {
|
||
const a = evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' });
|
||
const b = evt({ id: 'b', occurredAt: '2026-02-01T10:10:00' }); // 10 分钟后
|
||
const result = checkDuplicate(b, [a], config);
|
||
expect(result.isDuplicate).toBe(false);
|
||
});
|
||
|
||
it('金额不同 → 非重复', () => {
|
||
const a = evt({ id: 'a', amount: '100' });
|
||
const b = evt({ id: 'b', amount: '200' });
|
||
const result = checkDuplicate(b, [a], config);
|
||
expect(result.isDuplicate).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('dedup - batchDedup', () => {
|
||
it('批量去重保留首个、丢弃重复', () => {
|
||
const events = [
|
||
evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' }),
|
||
evt({ id: 'b', occurredAt: '2026-02-01T10:00:00' }), // 与 a 重复
|
||
evt({ id: 'c', occurredAt: '2026-02-01T11:00:00' }), // 不同时间
|
||
];
|
||
const result = batchDedup(events);
|
||
expect(result.accepted).toHaveLength(2);
|
||
expect(result.duplicates).toHaveLength(1);
|
||
expect(result.duplicates[0].id).toBe('b');
|
||
});
|
||
});
|
||
|
||
describe('dedup - dedupAgainstHistory', () => {
|
||
it('与已入账交易去重', () => {
|
||
const history: Transaction[] = [{
|
||
id: 't1', date: '2026-02-01T10:00:00', flag: '*',
|
||
postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }],
|
||
tags: [], links: [], source: 'mobile.bean', raw: '',
|
||
}];
|
||
const events = [
|
||
evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' }), // 与历史重复
|
||
evt({ id: 'b', occurredAt: '2026-02-02T10:00:00' }), // 新交易
|
||
];
|
||
const result = dedupAgainstHistory(events, history);
|
||
expect(result.accepted).toHaveLength(1);
|
||
expect(result.duplicates).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('transferRecognizer - recognizeTransfers', () => {
|
||
it('Income + Expend → Transfer(先于去重)', () => {
|
||
const events = [
|
||
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);
|
||
expect(result.transfers).toHaveLength(1);
|
||
// 当没有 raw 数据时,resolveSourceAccount 返回 Assets:Unknown
|
||
expect(result.transfers[0].draft.postings[0].account).toBe('Assets:Unknown');
|
||
expect(result.transfers[0].draft.postings[1].account).toBe('Assets:Unknown');
|
||
expect(result.remaining).toHaveLength(0);
|
||
});
|
||
|
||
it('相同对手方也会识别为转账(由金额和时间决定)', () => {
|
||
const events = [
|
||
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);
|
||
expect(result.transfers).toHaveLength(1);
|
||
expect(result.remaining).toHaveLength(0);
|
||
});
|
||
|
||
it('金额不一致不配对', () => {
|
||
const events = [
|
||
evt({ id: 'a', amount: '100', direction: 'expense', counterparty: '支付宝' }),
|
||
evt({ id: 'b', amount: '200', direction: 'income', counterparty: '银行卡' }),
|
||
];
|
||
const result = recognizeTransfers(events);
|
||
expect(result.transfers).toHaveLength(0);
|
||
});
|
||
|
||
it('超出时间窗口不配对', () => {
|
||
const events = [
|
||
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);
|
||
expect(result.transfers).toHaveLength(0);
|
||
});
|
||
|
||
it('金额格式不一致(包含多余的小数点/零)也应配对成功', () => {
|
||
const events = [
|
||
evt({ id: 'a', amount: '-100.00', 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);
|
||
expect(result.transfers).toHaveLength(1);
|
||
expect(result.remaining).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('BillPipeline - 责任链顺序', () => {
|
||
it('转账识别先于去重:Income+Expend 合并为 1 条转账,而非丢弃重复', async () => {
|
||
const pipeline = new BillPipeline();
|
||
const events = [
|
||
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: [],
|
||
});
|
||
expect(result.drafts).toHaveLength(1);
|
||
expect(result.drafts[0].isTransfer).toBe(true);
|
||
expect(result.transferCount).toBe(1);
|
||
expect(result.duplicates).toHaveLength(0);
|
||
});
|
||
|
||
it('重复事件被去重丢弃', async () => {
|
||
const pipeline = new BillPipeline();
|
||
const events = [
|
||
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: [],
|
||
});
|
||
expect(result.drafts).toHaveLength(1);
|
||
expect(result.duplicates).toHaveLength(1);
|
||
});
|
||
|
||
it('非转账非重复事件走分类', async () => {
|
||
const categories: Category[] = [
|
||
{ id: 'food', name: '餐饮', type: 'expense', linkedAccount: 'Expenses:Food', keywords: ['餐', '饭'] },
|
||
];
|
||
const pipeline = new BillPipeline();
|
||
const events = [
|
||
evt({ id: 'a', amount: '30', direction: 'expense', counterparty: '麦当劳餐厅', occurredAt: '2026-02-01T10:00:00' }),
|
||
];
|
||
const result = await pipeline.process(events, {
|
||
ledger, rules: [], categories, history: [],
|
||
});
|
||
expect(result.drafts).toHaveLength(1);
|
||
expect(result.drafts[0].isTransfer).toBe(false);
|
||
expect(result.drafts[0].draft.postings[1].account).toBe('Expenses:Food'); // 关键词"餐"命中
|
||
});
|
||
|
||
it('空事件列表返回空结果', async () => {
|
||
const pipeline = new BillPipeline();
|
||
const result = await pipeline.process([], {
|
||
ledger, rules: [], categories: [], history: [],
|
||
});
|
||
expect(result.drafts).toHaveLength(0);
|
||
expect(result.transferCount).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('BillPipeline - 并发串行化', () => {
|
||
it('并发 process 互斥,结果不交错', async () => {
|
||
const pipeline = new BillPipeline();
|
||
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: [] }),
|
||
pipeline.process(batch2, { ledger, rules: [], categories: [], history: [] }),
|
||
]);
|
||
|
||
expect(r1.drafts).toHaveLength(1);
|
||
expect(r2.drafts).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('dedupFingerprint', () => {
|
||
it('相同关键字段产生相同指纹', () => {
|
||
const a = evt({ id: 'a', counterparty: 'X' });
|
||
const b = evt({ id: 'b', counterparty: 'X' });
|
||
expect(dedupFingerprint(a)).toBe(dedupFingerprint(b));
|
||
});
|
||
|
||
it('不同金额产生不同指纹', () => {
|
||
const a = evt({ id: 'a', amount: '100', counterparty: 'X' });
|
||
const b = evt({ id: 'b', amount: '200', counterparty: 'X' });
|
||
expect(dedupFingerprint(a)).not.toBe(dedupFingerprint(b));
|
||
});
|
||
|
||
it('不同对手方产生不同指纹', () => {
|
||
const a = evt({ id: 'a', counterparty: 'Shop A' });
|
||
const b = evt({ id: 'b', counterparty: 'Shop B' });
|
||
expect(dedupFingerprint(a)).not.toBe(dedupFingerprint(b));
|
||
});
|
||
});
|