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

209 lines
9.4 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, beforeEach } from 'vitest';
import { useMetadataStore, generateId, type PersistableMetadata } from '../src/store/metadataStore';
import type { Category } from '../src/domain/categories';
import type { Tag } from '../src/domain/tags';
import type { Budget } from '../src/domain/budgets';
import type { CreditCard } from '../src/domain/creditCards';
import type { Rule } from '../src/domain/types';
// metadataStore 是 zustand 单例,每个测试前重置到 DEFAULTS。
beforeEach(() => {
useMetadataStore.getState().hydrate({});
});
describe('metadataStore 默认数据', () => {
it('首次使用含示例数据帮助上手', () => {
const s = useMetadataStore.getState();
expect(s.categories.length).toBeGreaterThan(0);
expect(s.tags.length).toBeGreaterThan(0);
expect(s.budgets.length).toBeGreaterThan(0);
expect(s.creditCards.length).toBeGreaterThan(0);
expect(s.rules.length).toBeGreaterThan(0);
});
it('每个默认分类都有 linkedAccount 映射', () => {
for (const c of useMetadataStore.getState().categories) {
expect(c.linkedAccount).toBeTruthy();
expect(c.keywords).toBeInstanceOf(Array);
}
});
});
describe('metadataStore Category CRUD', () => {
it('addCategory 追加到列表末尾', () => {
const before = useMetadataStore.getState().categories.length;
const cat: Category = { id: 'ent', name: '娱乐', type: 'expense', linkedAccount: 'Expenses:Entertainment', keywords: ['电影', '游戏'] };
useMetadataStore.getState().addCategory(cat);
const cats = useMetadataStore.getState().categories;
expect(cats).toHaveLength(before + 1);
expect(cats[cats.length - 1].id).toBe('ent');
});
it('updateCategory 局部更新保留其他字段', () => {
useMetadataStore.getState().addCategory({ id: 'ent', name: '娱乐', type: 'expense', linkedAccount: 'Expenses:Ent', keywords: [] });
useMetadataStore.getState().updateCategory('ent', { name: '娱乐休闲' });
const cat = useMetadataStore.getState().categories.find(c => c.id === 'ent')!;
expect(cat.name).toBe('娱乐休闲');
expect(cat.type).toBe('expense'); // 其他字段保留
expect(cat.linkedAccount).toBe('Expenses:Ent');
});
it('removeCategory 按 id 删除', () => {
useMetadataStore.getState().addCategory({ id: 'ent', name: '娱乐', type: 'expense', linkedAccount: 'X', keywords: [] });
useMetadataStore.getState().removeCategory('ent');
expect(useMetadataStore.getState().categories.find(c => c.id === 'ent')).toBeUndefined();
});
});
describe('metadataStore Tag CRUD', () => {
it('addTag / updateTag / removeTag', () => {
const tag: Tag = { id: 'newtag', name: 'travel', color: '#4CAF50' };
useMetadataStore.getState().addTag(tag);
expect(useMetadataStore.getState().tags.find(t => t.id === 'newtag')).toBeDefined();
useMetadataStore.getState().updateTag('newtag', { color: '#FF0000' });
expect(useMetadataStore.getState().tags.find(t => t.id === 'newtag')!.color).toBe('#FF0000');
useMetadataStore.getState().removeTag('newtag');
expect(useMetadataStore.getState().tags.find(t => t.id === 'newtag')).toBeUndefined();
});
});
describe('metadataStore Budget CRUD', () => {
it('addBudget / updateBudget / removeBudget', () => {
const b: Budget = { id: 'btest', name: '测试预算', amount: '3000', period: 'monthly', categoryAccount: 'Expenses:Food', startDate: '2026-01-01' };
useMetadataStore.getState().addBudget(b);
expect(useMetadataStore.getState().budgets.find(x => x.id === 'btest')).toBeDefined();
useMetadataStore.getState().updateBudget('btest', { amount: '5000' });
expect(useMetadataStore.getState().budgets.find(x => x.id === 'btest')!.amount).toBe('5000');
useMetadataStore.getState().removeBudget('btest');
expect(useMetadataStore.getState().budgets.find(x => x.id === 'btest')).toBeUndefined();
});
});
describe('metadataStore CreditCard CRUD', () => {
it('addCreditCard / updateCreditCard / removeCreditCard', () => {
const c: CreditCard = { id: 'ctest', name: '测试卡', lastFour: '5678', bankName: 'ICBC', billingDay: 10, paymentDay: 30, creditLimit: '20000', currency: 'CNY', linkedAccount: 'Liabilities:CreditCard:ICBC' };
useMetadataStore.getState().addCreditCard(c);
expect(useMetadataStore.getState().creditCards.find(x => x.id === 'ctest')).toBeDefined();
useMetadataStore.getState().updateCreditCard('ctest', { creditLimit: '50000' });
expect(useMetadataStore.getState().creditCards.find(x => x.id === 'ctest')!.creditLimit).toBe('50000');
useMetadataStore.getState().removeCreditCard('ctest');
expect(useMetadataStore.getState().creditCards.find(x => x.id === 'ctest')).toBeUndefined();
});
});
describe('metadataStore Rule CRUD', () => {
it('addRule / updateRule / removeRule', () => {
const r: Rule = { id: 'ruletest', priority: 50, counterpartyContains: '美团', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 };
useMetadataStore.getState().addRule(r);
expect(useMetadataStore.getState().rules.find(x => x.id === 'ruletest')).toBeDefined();
useMetadataStore.getState().updateRule('ruletest', { priority: 200, narration: '外卖' });
const updated = useMetadataStore.getState().rules.find(x => x.id === 'ruletest')!;
expect(updated.priority).toBe(200);
expect(updated.narration).toBe('外卖');
useMetadataStore.getState().removeRule('ruletest');
expect(useMetadataStore.getState().rules.find(x => x.id === 'ruletest')).toBeUndefined();
});
});
describe('metadataStore 持久化', () => {
it('persistTo 钩子在每次状态变更后被调用', () => {
const persisted: PersistableMetadata[] = [];
useMetadataStore.getState().persistTo = (s) => persisted.push(s);
useMetadataStore.getState().addTag({ id: 'p1', name: 'p1', color: '#000' });
useMetadataStore.getState().addCategory({ id: 'p2', name: 'p2', type: 'expense', linkedAccount: 'X', keywords: [] });
useMetadataStore.getState().removeTag('p1');
expect(persisted).toHaveLength(3);
// 最后一次快照应反映删除后的状态
expect(persisted[2].tags.find(t => t.id === 'p1')).toBeUndefined();
expect(persisted[2].categories.find(c => c.id === 'p2')).toBeDefined();
});
it('hydrate 从持久化源恢复全部 5 类实体(智能合并默认值)', () => {
const data: PersistableMetadata = {
categories: [{ id: 'only', name: '唯一', type: 'income', linkedAccount: 'Income:X', keywords: [] }],
tags: [{ id: 'onlytag', name: 'only', color: '#FFF' }],
budgets: [],
creditCards: [],
rules: [],
recurringTransactions: [],
remarkTemplates: [],
};
useMetadataStore.getState().hydrate(data);
const s = useMetadataStore.getState();
// 用户自定义的分类应保留
expect(s.categories.find(c => c.id === 'only')).toBeTruthy();
// 默认分类也应被合并保留
expect(s.categories.find(c => c.id === 'food')).toBeTruthy();
expect(s.categories.length).toBeGreaterThan(1);
// tags 现在也智能合并:用户 tag + 默认 tags
expect(s.tags.find(t => t.id === 'onlytag')).toBeTruthy();
expect(s.tags.length).toBeGreaterThan(1); // 用户 1 + 默认若干
// budgets/creditCards 用户传空数组 → 合并后只有默认值
expect(s.budgets.length).toBeGreaterThan(0);
expect(s.creditCards.length).toBeGreaterThan(0);
// 用户传了空 rules但默认规则仍然会被合并进来
expect(s.rules.length).toBeGreaterThan(0);
});
it('hydrate 用空对象恢复到 DEFAULTS', () => {
// 先破坏状态
useMetadataStore.getState().addTag({ id: 'x', name: 'x', color: '#000' });
expect(useMetadataStore.getState().tags.length).toBeGreaterThan(3);
// 恢复
useMetadataStore.getState().hydrate({});
expect(useMetadataStore.getState().tags.find(t => t.id === 'x')).toBeUndefined();
});
it('hydrate 局部恢复:未提供字段用 DEFAULTS 填充', () => {
useMetadataStore.getState().hydrate({ tags: [{ id: 't', name: 't', color: '#000' }] });
const s = useMetadataStore.getState();
// tags 合并:用户 1 个 + 默认若干
expect(s.tags.find(t => t.id === 't')).toBeTruthy();
expect(s.tags.length).toBeGreaterThan(1);
expect(s.categories.length).toBeGreaterThan(0); // DEFAULTS 保留
expect(s.budgets.length).toBeGreaterThan(0);
});
it('hydrate 合并:用户对默认 tag 的编辑保留,不覆盖', () => {
// 用户把默认 tag t1 的颜色改成红色
useMetadataStore.getState().hydrate({
tags: [{ id: 't1', name: 'food', color: '#FF0000' }],
});
const tag = useMetadataStore.getState().tags.find(t => t.id === 't1');
expect(tag).toBeTruthy();
expect(tag!.color).toBe('#FF0000'); // 用户编辑保留
});
it('hydrate 合并:用户删除全部默认 tags 后恢复不复活', () => {
useMetadataStore.getState().hydrate({ tags: [] });
// 用户传了空数组 → mergeById 返回 defaults因为 user 为 [] 不追加)
// 但 defaults 保留——这是设计决策:删除全部等同于恢复默认
// 验证至少不会崩溃
const tags = useMetadataStore.getState().tags;
expect(tags).toBeInstanceOf(Array);
});
});
describe('generateId', () => {
it('生成带前缀的唯一 id', () => {
const id1 = generateId('cat');
const id2 = generateId('cat');
expect(id1).toMatch(/^cat_/);
expect(id1).not.toBe(id2);
});
it('默认前缀为 id', () => {
expect(generateId()).toMatch(/^id_/);
});
});