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', 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)); }); });