DriftLedger/tests/stores.test.ts
fengmengqi 76a5853ab6 feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能
核心重构 — 去除 channel 字段,引入 sourceAccount 模型:
- 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段
- rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念
- dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断
- transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户
- 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试
新增基础设施:
- domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义
- domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找
- domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题
- domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听
OCR 增强:
- 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取
- checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额)
- 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配
- 时间解析支持中文格式(年月日)和跨年推断
- OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配
UI 全面升级:
- 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体
- 新增 commonStyles.ts 统一 chip/input/modal 等通用样式
- 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示
- 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细)
- TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线
- CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标
- Button/Card 增加 press 缩放动画
管道与自动化改进:
- automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认)
- 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR
- rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理
- metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则
存储与同步:
- storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制
- 备份升级到 v2 格式,包含 settings 和 metadata
- 同步路径统一从 mobile.bean 改为 main.bean
- _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean
其他:
- 删除独立日历页面,功能合并到报表月报标签
- i18n 清理:移除渠道相关翻译,新增 50+ 翻译键
- docs/android-build-guide.md 重写为 APK 体积优化指南
- 新增 design-system/beancount-mobile/MASTER.md 设计系统文档
- 测试全面更新覆盖以上所有变更
2026-07-18 18:02:45 +08:00

246 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: '咖啡', sourceAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 }],
});
expect(useImportStore.getState().rules).toHaveLength(1);
});
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);
});
});