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 & { 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)); }); });