DriftLedger/tests/billPipeline.test.ts
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

220 lines
9.8 KiB
TypeScript
Raw 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 { 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', channel: 'Alipay', 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', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', channel: 'WeChat', occurredAt: '2026-02-01T10:03:00' });
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' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(false);
});
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', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-01T10:02:00' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
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.remaining).toHaveLength(0);
});
it('相同渠道不识别为转账', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Alipay' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay' });
expect(result.transfers).toHaveLength(0);
expect(result.remaining).toHaveLength(2);
});
it('金额不一致不配对', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay' }),
evt({ id: 'b', amount: '200', direction: 'income', channel: 'Bank' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
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 天后
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
expect(result.transfers).toHaveLength(0);
});
});
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' }),
];
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);
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', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '50', direction: 'expense', channel: 'WeChat', 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.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', channel: 'Alipay', 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);
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: [], accountMap: {},
});
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', channel: 'Alipay' })];
const batch2 = [evt({ id: 'b1', amount: '200', direction: 'expense', channel: 'WeChat' })];
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' } }),
]);
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' }); // 同时间同金额同渠道
expect(dedupFingerprint(a)).toBe(dedupFingerprint(b));
});
});