DriftLedger/tests/domain-extended.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

134 lines
5.5 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 { clearFlag, flagsToMetadata, hasFlag, setFlag, shouldCountInBudget, shouldCountInStats, toggleFlag, TransactionFlag } from '../src/domain/transactionFlags';
import { DEFAULT_FILTER, mergeFilter, passesKeywordFilter } from '../src/domain/keywordFilter';
import { generateRemark, normalizeAdjacentDuplicates } from '../src/domain/remarkTemplate';
import { FunctionSandboxExecutor, RuleEngine, type EnhancedRule } from '../src/domain/ruleEngine';
import type { ImportedEvent } from '../src/domain/types';
const evt = (over: Partial<ImportedEvent> = {}): ImportedEvent => ({
id: 'e1', occurredAt: '2026-02-01T10:00:00', amount: '100', currency: 'CNY', direction: 'expense',
channel: 'Alipay', counterparty: '', memo: '', raw: {},
...over,
});
describe('transactionFlags', () => {
it('setFlag / hasFlag', () => {
let f = TransactionFlag.NONE;
f = setFlag(f, TransactionFlag.EXCLUDE_FROM_STATS);
expect(hasFlag(f, TransactionFlag.EXCLUDE_FROM_STATS)).toBe(true);
expect(hasFlag(f, TransactionFlag.EXCLUDE_FROM_BUDGET)).toBe(false);
});
it('clearFlag', () => {
let f = setFlag(TransactionFlag.NONE, TransactionFlag.TRANSFER);
f = clearFlag(f, TransactionFlag.TRANSFER);
expect(hasFlag(f, TransactionFlag.TRANSFER)).toBe(false);
});
it('toggleFlag', () => {
let f = TransactionFlag.NONE;
f = toggleFlag(f, TransactionFlag.RECURRING);
expect(hasFlag(f, TransactionFlag.RECURRING)).toBe(true);
f = toggleFlag(f, TransactionFlag.RECURRING);
expect(hasFlag(f, TransactionFlag.RECURRING)).toBe(false);
});
it('shouldCountInStats / shouldCountInBudget', () => {
const f = setFlag(TransactionFlag.NONE, TransactionFlag.EXCLUDE_FROM_STATS);
expect(shouldCountInStats(f)).toBe(false);
expect(shouldCountInBudget(f)).toBe(true); // 未设置 budget 标志
});
it('flagsToMetadata', () => {
const f = setFlag(setFlag(TransactionFlag.NONE, TransactionFlag.EXCLUDE_FROM_STATS), TransactionFlag.TRANSFER);
const meta = flagsToMetadata(f);
expect(meta['bm-exclude-stats']).toBe('true');
expect(meta['bm-transfer']).toBe('true');
expect(meta['bm-recurring']).toBeUndefined();
});
});
describe('keywordFilter', () => {
it('账单内容通过', () => {
expect(passesKeywordFilter('微信支付 24.50元 消费')).toBe(true);
});
it('广告被拒', () => {
expect(passesKeywordFilter('限时优惠广告')).toBe(false);
});
it('空文本被拒', () => {
expect(passesKeywordFilter('')).toBe(false);
});
it('mergeFilter 用户优先', () => {
const merged = mergeFilter({ whitelist: ['自定义'] });
expect(merged.whitelist).toEqual(['自定义']);
expect(merged.blacklist).toEqual(DEFAULT_FILTER.blacklist);
});
});
describe('remarkTemplate', () => {
it('占位符替换', () => {
const event = evt({ counterparty: '咖啡店', amount: '24.50' });
const remark = generateRemark('【商户名称】-【金额】', event);
expect(remark).toBe('咖啡店-24.50');
});
it('相邻重复归一化', () => {
expect(normalizeAdjacentDuplicates('买菜买菜')).toBe('买菜');
expect(normalizeAdjacentDuplicates('京东自营京东自营旗舰店')).toBe('京东自营旗舰店');
});
it('日期/时间占位符', () => {
const event = evt({ occurredAt: '2026-07-13T14:30:00' });
expect(generateRemark('【日期】 【时间】', event)).toBe('2026-07-13 14:30');
});
});
describe('ruleEngine', () => {
it('静态条件匹配(无 JS', () => {
const engine = new RuleEngine();
const rules: EnhancedRule[] = [{
id: 'r1', name: '咖啡', priority: 10, enabled: true, isSystem: false,
counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
}];
const result = engine.matchEvent(evt({ counterparty: '星巴克咖啡' }), rules);
expect(result?.rule.id).toBe('r1');
expect(result?.rule.hits).toBe(1);
});
it('JS 规则执行(命中)', () => {
const engine = new RuleEngine(new FunctionSandboxExecutor());
const rules: EnhancedRule[] = [{
id: 'r1', name: 'JS规则', priority: 10, enabled: true, isSystem: false,
jsCode: 'print(JSON.stringify({money: data.money, shopName: data.shopName}))',
channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
}];
const result = engine.matchEvent(evt({ amount: '50', counterparty: '测试' }), rules);
expect(result?.result.matched).toBe(true);
expect(result?.result.extracted?.shopName).toBe('测试');
});
it('JS 规则不命中(金额 0', () => {
const engine = new RuleEngine(new FunctionSandboxExecutor());
const rules: EnhancedRule[] = [{
id: 'r1', name: 'JS规则', priority: 10, enabled: true, isSystem: false,
jsCode: 'print(JSON.stringify({money: 0}))',
channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0,
}];
const result = engine.matchEvent(evt({ amount: '50' }), rules);
expect(result).toBeNull();
});
it('用户规则优先于系统规则', () => {
const engine = new RuleEngine();
const rules: EnhancedRule[] = [
{ id: 'sys', name: '系统', priority: 100, enabled: true, isSystem: true, channelAccount: 'A1', categoryAccount: 'C1', hits: 0 },
{ id: 'usr', name: '用户', priority: 1, enabled: true, isSystem: false, channelAccount: 'A2', categoryAccount: 'C2', hits: 0 },
];
const result = engine.matchEvent(evt(), rules);
expect(result?.rule.id).toBe('usr'); // 用户优先,即使 priority 低
});
});