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

173 lines
8.0 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 { filterByType, getSubCategories, getTopLevelCategories, matchCategory, resolveCategoryAccount, type Category } from '../src/domain/categories';
import { classifyWithCategories } from '../src/domain/rules';
import { extractTagNames, isValidTagName, resolveTagsByName, tagsToBeanSyntax, type Tag } from '../src/domain/tags';
import { parseLedger } from '../src/domain/ledger';
import { importStatement } from '../src/domain/statements';
import type { ImportedEvent } from '../src/domain/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n2026-01-01 open Expenses:Uncategorized CNY\n2026-01-01 open Income:Uncategorized CNY\n' }]);
const expenseCats: Category[] = [
{ id: 'food', name: '餐饮', type: 'expense', linkedAccount: 'Expenses:Food', keywords: ['餐', '饭', '咖啡', '食'] },
{ id: 'transport', name: '交通', type: 'expense', linkedAccount: 'Expenses:Transport', keywords: ['打车', '地铁', '公交'] },
{ id: 'other', name: '其他', type: 'expense', linkedAccount: 'Expenses:Uncategorized', keywords: [] },
];
const incomeCats: Category[] = [
{ id: 'salary', name: '工资', type: 'income', linkedAccount: 'Income:Salary', keywords: ['工资', '薪'] },
{ id: 'other-i', name: '其他', type: 'income', linkedAccount: 'Income:Uncategorized', keywords: [] },
];
describe('matchCategory', () => {
it('精确匹配(分类名等于文本)', () => {
const m = matchCategory('餐饮', expenseCats);
expect(m.category?.id).toBe('food');
expect(m.matchedBy).toBe('exact');
});
it('关键词匹配', () => {
const m = matchCategory('星巴克咖啡', expenseCats);
expect(m.category?.id).toBe('food');
expect(m.matchedBy).toBe('keyword');
});
it('模糊匹配(包含关系)', () => {
const cats = [{ id: 'x', name: '咖啡店', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const m = matchCategory('某咖啡店分店', cats);
expect(m.category?.id).toBe('x');
expect(m.matchedBy).toBe('fuzzy');
});
it('未匹配时兜底到"其他"', () => {
const m = matchCategory('完全无关的文本', expenseCats);
expect(m.category?.id).toBe('other');
expect(m.matchedBy).toBe('fallback');
});
it('空文本返回 none', () => {
const m = matchCategory(' ', expenseCats);
expect(m.category).toBeNull();
expect(m.matchedBy).toBe('none');
});
});
describe('resolveCategoryAccount双轨制核心', () => {
it('linkedAccount 存在时返回该账户', () => {
const r = resolveCategoryAccount(expenseCats[0], 'expense', ledger);
expect(r.account).toBe('Expenses:Food');
expect(r.valid).toBe(true);
});
it('linkedAccount 不存在时不降级,返回原始账户', () => {
const r = resolveCategoryAccount(expenseCats[1], 'expense', ledger); // Transport 未 open
expect(r.account).toBe('Expenses:Transport');
expect(r.valid).toBe(false);
expect(r.fallbackReason).toContain('尚未开户');
});
it('category 为 null 时使用未分类兜底', () => {
const r = resolveCategoryAccount(null, 'income', ledger);
expect(r.account).toBe('Income:未分类');
expect(r.valid).toBe(false);
});
it('收入类型未匹配时兜底 Income:未分类', () => {
const r = resolveCategoryAccount(null, 'income', ledger);
expect(r.account).toBe('Income:未分类');
});
});
describe('classifyWithCategories双轨制记账', () => {
it('规则命中时优先用规则的 categoryAccount', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,25.50,支出,咖啡店,A1', 'alipay-csv-v1');
const result = classifyWithCategories(event, [{ id: 'coffee', priority: 10, counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }], expenseCats, ledger);
expect(result.ruleId).toBe('coffee');
expect(result.draft.postings[1].account).toBe('Expenses:Food');
});
it('规则未命中时用分类库匹配(关键词)', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,30,支出,麦当劳快餐,A2', 'alipay-csv-v1');
const result = classifyWithCategories(event, [], expenseCats, ledger);
expect(result.ruleId).toBeUndefined();
expect(result.categoryId).toBe('food'); // 关键词"餐"命中
expect(result.draft.postings[1].account).toBe('Expenses:Food');
});
it('分类匹配但 linkedAccount 未开户时不降级', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,15,支出,打车滴滴,A3', 'alipay-csv-v1');
const result = classifyWithCategories(event, [], expenseCats, ledger);
// 交通分类命中Expenses:Transport 未 open但不降级保留真实账户
expect(result.categoryId).toBe('transport');
expect(result.draft.postings[1].account).toBe('Expenses:Transport');
expect(result.categoryFallbackReason).toContain('尚未开户');
expect(result.confidence).toBe('low');
});
it('完全未匹配时兜底到其他分类', () => {
const [event] = importStatement('交易时间,金额(元),收/支,交易对方,交易订单号\n2026-02-01,99,支出,未知商户,A4', 'alipay-csv-v1');
const result = classifyWithCategories(event, [], expenseCats, ledger);
// 未匹配到具体分类,兜底到 expenseCats 中的 'other' 分类
expect(result.draft.postings[1].account).toBe('Expenses:Uncategorized');
});
it('收入方向用收入分类', () => {
const event: ImportedEvent = {
id: 'test1', occurredAt: '2026-02-01', amount: '5000', currency: 'CNY', direction: 'income',
channel: 'Bank', counterparty: '公司', memo: '工资', raw: {},
};
const result = classifyWithCategories(event, [], incomeCats, ledger);
expect(result.categoryId).toBe('salary');
expect(result.draft.postings[1].account).toBe('Income:Salary'); // Income:Salary 未 open 但不降级,保留真实账户
});
});
describe('分类工具函数', () => {
const all = [...expenseCats, ...incomeCats, { id: 'food-lunch', name: '午餐', type: 'expense' as const, parentId: 'food', linkedAccount: 'Expenses:Food', keywords: [] }];
it('filterByType', () => {
expect(filterByType(all, 'expense')).toHaveLength(4);
expect(filterByType(all, 'income')).toHaveLength(2);
});
it('getTopLevelCategories无 parentId', () => {
expect(getTopLevelCategories(all)).toHaveLength(5);
});
it('getSubCategories', () => {
expect(getSubCategories(all, 'food')).toHaveLength(1);
expect(getSubCategories(all, 'food')[0].id).toBe('food-lunch');
});
});
describe('tags标签序列化', () => {
it('isValidTagName', () => {
expect(isValidTagName('food')).toBe(true);
expect(isValidTagName('lunch-1')).toBe(true);
expect(isValidTagName('餐饮')).toBe(false); // 中文不符合 \w
expect(isValidTagName('')).toBe(false);
expect(isValidTagName('a b')).toBe(false); // 空格
});
it('tagsToBeanSyntax 序列化为 #tag', () => {
const tags: Tag[] = [{ id: 't1', name: 'food', color: '#f00' }, { id: 't2', name: 'lunch', color: '#0f0' }];
expect(tagsToBeanSyntax(tags)).toBe(' #food #lunch');
});
it('tagsToBeanSyntax 过滤非法名', () => {
const tags: Tag[] = [{ id: 't1', name: 'food', color: '' }, { id: 't2', name: '非法 名', color: '' }];
expect(tagsToBeanSyntax(tags)).toBe(' #food');
});
it('extractTagNames 从 narration 提取', () => {
expect(extractTagNames('冰美式 #food #lunch')).toEqual(['food', 'lunch']);
expect(extractTagNames('无标签的备注')).toEqual([]);
});
it('resolveTagsByName 从库匹配', () => {
const all: Tag[] = [{ id: 't1', name: 'food', color: '#f00' }];
const resolved = resolveTagsByName(['food', 'unknown'], all);
expect(resolved).toHaveLength(1);
expect(resolved[0].name).toBe('food');
});
});