- 切换到 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+ 单元测试覆盖核心逻辑
220 lines
9.8 KiB
TypeScript
220 lines
9.8 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', 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));
|
||
});
|
||
});
|