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

248 lines
11 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 { useLedgerStore } from '../src/store/ledgerStore';
import { useImportStore } from '../src/store/importStore';
import { useSettingsStore } from '../src/store/settingsStore';
import { MemoryBackend } from '../src/domain/mobileStore';
import type { LedgerFile } from '../src/domain/types';
const sampleLedger: LedgerFile = {
path: 'main.bean',
content: `option "operating_currency" "CNY"
2026-01-01 open Assets:Alipay CNY
2026-01-01 open Expenses:Food CNY
2026-01-01 open Expenses:Uncategorized CNY
2026-01-01 open Income:Uncategorized CNY
include "mobile.bean"
`,
};
const sampleStatement = '交易时间,金额(元),收/支,交易对方,商品说明,交易订单号\n2026-07-01,24.50,支出,咖啡店,冰美式,ORDER-1\n2026-07-02,5000,收入,公司,工资,ORDER-2';
// 每个测试前重置 storezustand 单例需手动重置)
beforeEach(() => {
useLedgerStore.getState().reset();
useImportStore.getState().clear();
useSettingsStore.getState().hydrate({});
});
describe('ledgerStore', () => {
it('loadLedger 解析账本并初始化 mobile.bean 存储', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const state = useLedgerStore.getState();
expect(state.ledger).not.toBeNull();
expect(state.ledger!.accounts.size).toBe(4);
expect(state.mobileBean).toBe('');
expect(state.error).toBeNull();
});
it('addTransaction 写入 mobile.bean并发安全', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const commit = await useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: '午餐',
postings: [{ account: 'Assets:Alipay', amount: '-20', currency: 'CNY' }, { account: 'Expenses:Food', amount: '20', currency: 'CNY' }],
});
expect(commit.content).toContain('午餐');
expect(useLedgerStore.getState().mobileBean).toContain('午餐');
});
// 回归:用户反馈"添加交易后最近交易还是 0"。
// 根因addTransaction 只更新了 mobileBean 字符串,未重新解析 ledger.transactions。
// parseLedger 不解析 include 指令,需把 mobile.bean 作为额外文件传入。
it('addTransaction 后 ledger.transactions 包含新交易', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
expect(useLedgerStore.getState().ledger!.transactions).toHaveLength(0);
await useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: '午餐',
postings: [{ account: 'Assets:Alipay', amount: '-20', currency: 'CNY' }, { account: 'Expenses:Food', amount: '20', currency: 'CNY' }],
});
const txns = useLedgerStore.getState().ledger!.transactions;
expect(txns).toHaveLength(1);
expect(txns[0].narration).toBe('午餐');
expect(txns[0].postings).toHaveLength(2);
});
it('addTransaction 多笔后 ledger.transactions 全部可见', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
for (const n of [1, 2, 3]) {
await useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: `t${n}`,
postings: [{ account: 'Assets:Alipay', amount: `-${n}`, currency: 'CNY' }, { account: 'Expenses:Food', amount: `${n}`, currency: 'CNY' }],
});
}
const txns = useLedgerStore.getState().ledger!.transactions;
expect(txns).toHaveLength(3);
expect(txns.map(t => t.narration).sort()).toEqual(['t1', 't2', 't3']);
});
it('replaceMobileBean 后 ledger.transactions 同步更新', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
await useLedgerStore.getState().replaceMobileBean(
'2026-07-10 * "导入交易"\n Assets:Alipay -50 CNY\n Expenses:Food\n',
);
const txns = useLedgerStore.getState().ledger!.transactions;
expect(txns).toHaveLength(1);
expect(txns[0].narration).toBe('导入交易');
});
it('addTransaction 多笔并发不丢失', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
await Promise.all([1, 2, 3].map(n =>
useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: `t${n}`,
postings: [{ account: 'Assets:Alipay', amount: `-${n}`, currency: 'CNY' }, { account: 'Expenses:Food', amount: `${n}`, currency: 'CNY' }],
}),
));
const content = useLedgerStore.getState().mobileBean;
expect(content).toContain('t1');
expect(content).toContain('t2');
expect(content).toContain('t3');
});
it('未加载账本时 addTransaction 抛错', async () => {
await expect(useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: 'x',
postings: [{ account: 'A', amount: '-1', currency: 'CNY' }, { account: 'B', amount: '1', currency: 'CNY' }],
})).rejects.toThrow('账本未加载');
});
it('不平衡交易 addTransaction 抛错并设置 error', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
await expect(useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: '错误',
postings: [{ account: 'Assets:Alipay', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '0.9', currency: 'CNY' }],
})).rejects.toThrow();
});
it('replaceMobileBean 直接替换内容', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
await useLedgerStore.getState().replaceMobileBean('REPLACED');
expect(useLedgerStore.getState().mobileBean).toBe('REPLACED');
});
it('refresh 重新解析账本', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
// refresh 不改变账户数(文件未变)
await useLedgerStore.getState().refresh();
expect(useLedgerStore.getState().ledger!.accounts.size).toBe(4);
});
it('reset 回到初始状态', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
useLedgerStore.getState().reset();
const state = useLedgerStore.getState();
expect(state.ledger).toBeNull();
expect(state.mobileStore).toBeNull();
expect(state.mobileBean).toBe('');
});
});
describe('importStore', () => {
beforeEach(async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
});
it('setContext 设置规则/分类/账户映射', () => {
useImportStore.getState().setContext({
rules: [{ id: 'r1', priority: 10, counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }],
accountMap: { Alipay: 'Assets:Alipay' },
});
expect(useImportStore.getState().rules).toHaveLength(1);
expect(useImportStore.getState().accountMap.Alipay).toBe('Assets:Alipay');
});
it('importCsv 生成事件', () => {
const events = useImportStore.getState().importCsv(sampleStatement, 'alipay-csv-v1');
expect(events).toHaveLength(2);
expect(useImportStore.getState().events).toHaveLength(2);
});
it('process 走 BillPipeline 生成草稿', async () => {
useImportStore.getState().importCsv(sampleStatement, 'alipay-csv-v1');
const ledger = useLedgerStore.getState().ledger!;
const result = await useImportStore.getState().process(ledger, []);
expect(result.drafts.length).toBeGreaterThan(0);
expect(useImportStore.getState().pendingDrafts.length).toBeGreaterThan(0);
});
it('confirmDraft 移除已确认草稿并返回它', async () => {
useImportStore.getState().importCsv(sampleStatement, 'alipay-csv-v1');
const ledger = useLedgerStore.getState().ledger!;
await useImportStore.getState().process(ledger, []);
const before = useImportStore.getState().pendingDrafts.length;
const confirmed = useImportStore.getState().confirmDraft(0);
expect(confirmed).not.toBeNull();
expect(useImportStore.getState().pendingDrafts.length).toBe(before - 1);
});
it('rejectDraft 移除草稿', async () => {
useImportStore.getState().importCsv(sampleStatement, 'alipay-csv-v1');
const ledger = useLedgerStore.getState().ledger!;
await useImportStore.getState().process(ledger, []);
const before = useImportStore.getState().pendingDrafts.length;
useImportStore.getState().rejectDraft(0);
expect(useImportStore.getState().pendingDrafts.length).toBe(before - 1);
});
it('clear 清空本次导入', async () => {
useImportStore.getState().importCsv(sampleStatement, 'alipay-csv-v1');
const ledger = useLedgerStore.getState().ledger!;
await useImportStore.getState().process(ledger, []);
useImportStore.getState().clear();
expect(useImportStore.getState().events).toHaveLength(0);
expect(useImportStore.getState().pendingDrafts).toHaveLength(0);
});
});
describe('settingsStore', () => {
it('默认值正确', () => {
const s = useSettingsStore.getState();
expect(s.dedupEnabled).toBe(true);
expect(s.transferRecognitionEnabled).toBe(true);
expect(s.themeMode).toBe('system');
expect(s.locale).toBe('zh');
expect(s.appLockEnabled).toBe(false);
});
it('updateDedupConfig 局部更新', () => {
useSettingsStore.getState().updateDedupConfig({ timeWindowMinutes: 10 });
expect(useSettingsStore.getState().dedupConfig.timeWindowMinutes).toBe(10);
expect(useSettingsStore.getState().dedupConfig.checkAccountLink).toBe(true); // 其他字段保留
});
it('updateTransferConfig 局部更新', () => {
useSettingsStore.getState().updateTransferConfig({ timeWindowHours: 48 });
expect(useSettingsStore.getState().transferConfig.timeWindowHours).toBe(48);
});
it('setThemeMode / setLocale / setAppLockEnabled', () => {
useSettingsStore.getState().setThemeMode('dark');
useSettingsStore.getState().setLocale('en');
useSettingsStore.getState().setAppLockEnabled(true);
const s = useSettingsStore.getState();
expect(s.themeMode).toBe('dark');
expect(s.locale).toBe('en');
expect(s.appLockEnabled).toBe(true);
});
it('persistTo 钩子在状态变更后被调用', () => {
const persisted: unknown[] = [];
// 设置钩子(模拟 AsyncStorage 写入)
(useSettingsStore.getState() as { persistTo?: (s: unknown) => void }).persistTo = (s) => persisted.push(s);
useSettingsStore.getState().setThemeMode('dark');
expect(persisted).toHaveLength(1);
expect((persisted[0] as { themeMode: string }).themeMode).toBe('dark');
});
it('hydrate 从持久化源恢复', () => {
useSettingsStore.getState().hydrate({ themeMode: 'dark', locale: 'en', appLockEnabled: true });
const s = useSettingsStore.getState();
expect(s.themeMode).toBe('dark');
expect(s.locale).toBe('en');
expect(s.appLockEnabled).toBe(true);
// 未提供的字段用默认值
expect(s.dedupEnabled).toBe(true);
});
});