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+ 单元测试覆盖核心逻辑
This commit is contained in:
fengmengqi
2026-07-15 10:01:31 +08:00
parent 167adfca62
commit f6437b83fe
171 changed files with 34937 additions and 1229 deletions
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { parseCmbCsv, parseIcbcCsv, parseGenericBankCsv } from '../src/domain/adapters';
import { migrateDb, MemoryMigrationDb, SCHEMA_VERSION } from '../src/storage/migrations';
describe('CSV adapters', () => {
it('parseCmbCsv 招商银行', () => {
const csv = '交易日期,交易类型,交易金额,交易对方,摘要\n2026-07-10,消费,100.50,超市,买日用品\n2026-07-11,收入,5000.00,公司,工资';
const events = parseCmbCsv(csv);
expect(events).toHaveLength(2);
expect(events[0].channel).toBe('Bank:CMB');
expect(events[0].direction).toBe('expense');
expect(events[0].amount).toBe('-100.50');
expect(events[1].direction).toBe('income');
expect(events[1].amount).toBe('5000.00');
});
it('parseIcbcCsv 工商银行', () => {
const csv = '交易时间,交易类型,交易金额,对方户名,摘要\n2026-07-10,消费,200,商户,购物';
const events = parseIcbcCsv(csv);
expect(events).toHaveLength(1);
expect(events[0].channel).toBe('Bank:ICBC');
expect(events[0].counterparty).toBe('商户');
});
it('parseGenericBankCsv 通用', () => {
const csv = '日期,金额,交易对方\n2026-07-10,50,便利店';
const events = parseGenericBankCsv(csv, 'Bank:Custom');
expect(events).toHaveLength(1);
expect(events[0].channel).toBe('Bank:Custom');
});
it('退款项识别为 refund', () => {
const csv = '日期,交易类型,金额,交易对方\n2026-07-10,退款,30,商家';
const events = parseGenericBankCsv(csv);
expect(events[0].direction).toBe('refund');
});
});
describe('migrations', () => {
it('从 0 迁移到当前版本', async () => {
const db = new MemoryMigrationDb();
await migrateDb(db);
expect(db.executed.length).toBeGreaterThan(0);
// 应包含建表语句
expect(db.executed.some(s => s.includes('CREATE TABLE'))).toBe(true);
// 应包含 user_version 设置
expect(db.executed.some(s => s.includes('PRAGMA user_version'))).toBe(true);
});
it('已迁移过的不再重复执行', async () => {
const db = new MemoryMigrationDb();
await migrateDb(db);
const firstCount = db.executed.length;
await migrateDb(db); // 第二次
expect(db.executed.length).toBe(firstCount); // 无新增
});
it('SCHEMA_VERSION 至少为 2(含双轨制表)', () => {
expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(2);
});
it('v2 包含 categories 表', async () => {
const db = new MemoryMigrationDb();
await migrateDb(db);
expect(db.executed.some(s => s.includes('categories'))).toBe(true);
expect(db.executed.some(s => s.includes('credit_cards'))).toBe(true);
});
});
+254
View File
@@ -0,0 +1,254 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { NativeOcrBridge, SimulatedOcrBridge, type NativeOcrModule } from '../src/services/ocrBridge';
import { ScreenshotChannel, SimulatedScreenshotListener, type ScreenshotEvent } from '../src/services/screenshot';
import { NotificationChannel, SimulatedNotificationListener, parseNotification, DEFAULT_PAYMENT_PACKAGES, type NotificationEvent } from '../src/services/notification';
import { SmsChannel, SimulatedSmsListener, parseSms, isBankSms, type SmsEvent } from '../src/services/sms';
import { OcrProcessor, MockOcrEngine } from '../src/domain/ocrProcessor';
beforeEach(() => {
// 各测试自建实例,无需全局重置
});
// ============ ocrBridge ============
describe('ocrBridge', () => {
it('NativeOcrBridge 用原生模块', async () => {
const native: NativeOcrModule = {
recognizeText: async () => '微信支付 金额 10 商户:x',
recognizeTextBlocks: async () => [],
isReady: async () => true,
};
const bridge = new NativeOcrBridge(native);
expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x');
});
it('原生失败时降级到 fallback', async () => {
const native: NativeOcrModule = {
recognizeText: async () => { throw new Error('原生崩溃'); },
recognizeTextBlocks: async () => [],
isReady: async () => false,
};
const fallback = new SimulatedOcrBridge();
fallback.preset('fallback', '降级文本');
const bridge = new NativeOcrBridge(native, fallback);
expect(await bridge.recognizeText('fallback')).toBe('降级文本');
});
it('无原生无 fallback 抛错', async () => {
const bridge = new NativeOcrBridge(null);
await expect(bridge.recognizeText('img')).rejects.toThrow('未加载');
});
it('SimulatedOcrBridge 按 key 返回预设', async () => {
const sim = new SimulatedOcrBridge();
sim.preset('wechat-screenshot', '微信支付 金额 20');
expect(await sim.recognizeText('wechat-screenshot')).toContain('微信支付');
expect(await sim.recognizeText('unknown')).toBe('');
});
});
// ============ screenshot ============
describe('ScreenshotChannel', () => {
it('处理截图 → OCR 识别成功', async () => {
const ocrEngine = new MockOcrEngine();
ocrEngine.defaultResponse = '微信支付 金额 24.50 商户:星巴克 2026-07-13 14:30';
const processor = new OcrProcessor(ocrEngine);
const channel = new ScreenshotChannel(processor);
const event: ScreenshotEvent = {
uri: 'content://media/screenshots/screenshot_123.png',
base64: 'img-data',
timestamp: Date.now(),
};
const result = await channel.handleScreenshot(event);
expect(result.layer).toBe('layer1-rule');
expect(result.event?.amount).toBe('-24.5');
});
it('去重:同一 URI 不重复处理', async () => {
const ocrEngine = new MockOcrEngine();
ocrEngine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(ocrEngine);
const channel = new ScreenshotChannel(processor);
const event: ScreenshotEvent = {
uri: 'content://media/screenshots/dup.png',
base64: 'img',
timestamp: Date.now(),
};
const r1 = await channel.handleScreenshot(event);
const r2 = await channel.handleScreenshot(event);
expect(r1.layer).toBe('layer1-rule');
expect(r2.skipped).toBe('dedup');
});
it('非截图 URI 过滤', async () => {
const processor = new OcrProcessor(new MockOcrEngine());
const channel = new ScreenshotChannel(processor);
const result = await channel.handleScreenshot({
uri: 'content://media/photos/normal.jpg',
base64: 'img',
timestamp: Date.now(),
});
expect(result.skipped).toBe('dedup');
});
it('超过 30 秒的旧截图过滤', async () => {
const processor = new OcrProcessor(new MockOcrEngine());
const channel = new ScreenshotChannel(processor);
const result = await channel.handleScreenshot({
uri: 'content://screenshots/old.png',
base64: 'img',
timestamp: Date.now() - 60_000,
});
expect(result.skipped).toBe('dedup');
});
it('onScreenshot 回调触发', async () => {
const ocrEngine = new MockOcrEngine();
ocrEngine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(ocrEngine);
const channel = new ScreenshotChannel(processor);
let callbackResult: string | null = null;
channel.onScreenshot((result) => { callbackResult = result.event?.amount ?? null; });
await channel.handleScreenshot({ uri: 'content://screenshots/x.png', base64: 'img', timestamp: Date.now() });
expect(callbackResult).toBe('-10');
});
it('SimulatedScreenshotListener start/stop/simulate', async () => {
const listener = new SimulatedScreenshotListener();
const events: ScreenshotEvent[] = [];
await listener.start(e => events.push(e));
expect(listener.isListening()).toBe(true);
listener.simulate({ uri: 'x', timestamp: 0 });
expect(events).toHaveLength(1);
await listener.stop();
expect(listener.isListening()).toBe(false);
});
});
// ============ notification ============
describe('NotificationChannel', () => {
it('parseNotification 微信支付', () => {
const event: NotificationEvent = {
packageName: 'com.tencent.mm', title: '微信支付',
text: '金额 24.50 商户:星巴克', timestamp: Date.now(),
};
const bill = parseNotification(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-24.5');
});
it('白名单过滤:非支付 App', () => {
const channel = new NotificationChannel();
const result = channel.handleNotification({
packageName: 'com.other.app', title: '通知', text: '内容', timestamp: Date.now(),
});
expect(result.processed).toBe(false);
expect(result.reason).toBe('非白名单包');
});
it('白名单内 + 命中规则', () => {
const channel = new NotificationChannel();
const result = channel.handleNotification({
packageName: 'com.tencent.mm', title: '微信支付', text: '金额 10 商户:x', timestamp: Date.now(),
});
expect(result.processed).toBe(true);
});
it('MD5 去重', () => {
const channel = new NotificationChannel();
const event: NotificationEvent = {
packageName: 'com.tencent.mm', title: '微信支付', text: '金额 10 商户:x', timestamp: Date.now(),
};
const r1 = channel.handleNotification(event);
const r2 = channel.handleNotification(event);
expect(r1.processed).toBe(true);
expect(r2.reason).toBe('MD5 去重');
});
it('onBillDetected 回调', () => {
const channel = new NotificationChannel();
let detected = false;
channel.onBillDetected(() => { detected = true; });
channel.handleNotification({
packageName: 'com.tencent.mm', title: '微信支付', text: '金额 10 商户:x', timestamp: Date.now(),
});
expect(detected).toBe(true);
});
it('DEFAULT_PAYMENT_PACKAGES 含微信支付宝', () => {
expect(DEFAULT_PAYMENT_PACKAGES).toContain('com.tencent.mm');
expect(DEFAULT_PAYMENT_PACKAGES).toContain('com.eg.android.AlipayGphone');
});
});
// ============ sms ============
describe('SmsChannel', () => {
it('parseSms 银行短信', () => {
const event: SmsEvent = {
sender: '95588', body: '工商银行:尾号1234于超市消费100.50元,余额5000元',
timestamp: Date.now(),
};
const bill = parseSms(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-100.5');
expect(bill?.channel).toBe('Bank:1234');
});
it('parseSms 退款', () => {
const event: SmsEvent = {
sender: '95533', body: '建设银行:尾号5678退款30元',
timestamp: Date.now(),
};
const bill = parseSms(event);
expect(bill?.direction).toBe('refund');
});
it('parseSms 无金额返回 null', () => {
const bill = parseSms({ sender: '95588', body: '余额提醒', timestamp: 0 });
expect(bill).toBeNull();
});
it('isBankSms 判断', () => {
expect(isBankSms('您有一笔交易')).toBe(true);
expect(isBankSms('验证码是1234')).toBe(false);
});
it('SmsChannel 非银行短信过滤', () => {
const channel = new SmsChannel();
const result = channel.handleSms({ sender: '10086', body: '话费提醒', timestamp: 0 });
expect(result.processed).toBe(false);
expect(result.reason).toBe('非银行短信');
});
it('SmsChannel 银行短信处理成功', () => {
const channel = new SmsChannel();
const result = channel.handleSms({
sender: '95588', body: '尾号1234消费100元', timestamp: Date.now(),
});
expect(result.processed).toBe(true);
});
it('SmsChannel MD5 去重', () => {
const channel = new SmsChannel();
const event: SmsEvent = { sender: '95588', body: '尾号1234消费100元', timestamp: 0 };
const r1 = channel.handleSms(event);
const r2 = channel.handleSms(event);
expect(r1.processed).toBe(true);
expect(r2.reason).toBe('MD5 去重');
});
it('SimulatedSmsListener simulate', async () => {
const listener = new SimulatedSmsListener();
const events: SmsEvent[] = [];
await listener.start(e => events.push(e));
listener.simulate({ sender: 'x', body: 'y', timestamp: 0 });
expect(events).toHaveLength(1);
await listener.stop();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { useAutomationStore } from '../src/store/automationStore';
import { parseLedger } from '../src/domain/ledger';
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 sampleEvent: ImportedEvent = {
id: 'ocr-test-1', occurredAt: '2026-07-13T14:30:00', amount: '24.5', currency: 'CNY', direction: 'expense',
channel: 'WeChat', counterparty: '星巴克', memo: 'OCR识别', raw: {},
};
beforeEach(() => {
useAutomationStore.getState().clear();
});
describe('automationStore', () => {
it('addDetected 添加检测事件 + 统计', () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
useAutomationStore.getState().addDetected('screenshot', { ...sampleEvent, id: 'ss-1' });
const state = useAutomationStore.getState();
expect(state.detected).toHaveLength(2);
expect(state.stats.ocr).toBe(1);
expect(state.stats.screenshot).toBe(1);
});
it('processAll 走 BillPipeline 生成草稿', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
await useAutomationStore.getState().processAll(
ledger, [], [], [], { WeChat: 'Assets:Alipay' },
);
const state = useAutomationStore.getState();
expect(state.drafts.length).toBeGreaterThan(0);
expect(state.detected).toHaveLength(0); // 处理后清空
expect(state.isProcessing).toBe(false);
});
it('空 detected 时 processAll 无操作', async () => {
await useAutomationStore.getState().processAll(ledger, [], [], [], {});
expect(useAutomationStore.getState().drafts).toHaveLength(0);
});
it('confirmDraft 移除草稿并返回', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
await useAutomationStore.getState().processAll(ledger, [], [], [], { WeChat: 'Assets:Alipay' });
const before = useAutomationStore.getState().drafts.length;
const draft = useAutomationStore.getState().confirmDraft(0);
expect(draft).not.toBeNull();
expect(useAutomationStore.getState().drafts.length).toBe(before - 1);
});
it('rejectDraft 移除草稿', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
await useAutomationStore.getState().processAll(ledger, [], [], [], { WeChat: 'Assets:Alipay' });
const before = useAutomationStore.getState().drafts.length;
useAutomationStore.getState().rejectDraft(0);
expect(useAutomationStore.getState().drafts.length).toBe(before - 1);
});
it('clear 重置全部状态', () => {
useAutomationStore.getState().addDetected('notification', sampleEvent);
useAutomationStore.getState().clear();
const state = useAutomationStore.getState();
expect(state.detected).toHaveLength(0);
expect(state.drafts).toHaveLength(0);
expect(state.stats.notification).toBe(0);
});
it('多来源事件混合处理', async () => {
useAutomationStore.getState().addDetected('ocr', sampleEvent);
useAutomationStore.getState().addDetected('sms', { ...sampleEvent, id: 'sms-1', channel: 'Bank:1234' });
useAutomationStore.getState().addDetected('notification', { ...sampleEvent, id: 'notif-1' });
await useAutomationStore.getState().processAll(ledger, [], [], [], { WeChat: 'Assets:Alipay', 'Bank:1234': 'Assets:Alipay' });
const state = useAutomationStore.getState();
expect(state.stats.ocr).toBe(1);
expect(state.stats.sms).toBe(1);
expect(state.stats.notification).toBe(1);
});
});
+219
View File
@@ -0,0 +1,219 @@
import { describe, expect, it } from 'vitest';
import { BillPipeline } from '../src/domain/billPipeline';
import { batchDedup, checkDuplicate, dedupAgainstHistory, dedupFingerprint, type DedupConfig } from '../src/domain/dedup';
import { recognizeTransfers } from '../src/domain/transferRecognizer';
import { parseLedger } from '../src/domain/ledger';
import type { Category } from '../src/domain/categories';
import type { ImportedEvent, Transaction } from '../src/domain/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Assets:WeChat CNY\n2026-01-01 open Assets:Bank:CMB CNY\n2026-01-01 open Expenses:Food CNY\n2026-01-01 open Expenses:Uncategorized CNY\n2026-01-01 open Income:Uncategorized CNY\n' }]);
const evt = (over: Partial<ImportedEvent> & { id: string }): ImportedEvent => {
const { id = 'e1', ...rest } = over;
return {
occurredAt: '2026-02-01T10:00:00', amount: '-100', currency: 'CNY',
direction: 'expense', channel: 'Alipay', counterparty: '', memo: '', raw: {},
id,
...rest,
};
};
describe('dedup - checkDuplicate', () => {
const config: DedupConfig = { timeWindowMinutes: 5, checkAccountLink: true };
it('时间完全相同 + 金额一致 → 高置信度重复', () => {
const a = evt({ id: 'a' });
const b = evt({ id: 'b' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(true);
expect(result.confidence).toBe('high');
});
it('时间窗口内 + 金额匹配 + 不同渠道 → 中置信度重复', () => {
const a = evt({ id: 'a', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', channel: 'WeChat', occurredAt: '2026-02-01T10:03:00' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(true);
expect(result.confidence).toBe('medium');
expect(result.reason).toContain('不同渠道');
});
it('相同渠道 + 相同金额(同人多次)→ 非重复', () => {
const a = evt({ id: 'a', channel: 'WeChat', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', channel: 'WeChat', occurredAt: '2026-02-01T10:03:00' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(false);
});
it('时间超出窗口 → 非重复', () => {
const a = evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' });
const b = evt({ id: 'b', occurredAt: '2026-02-01T10:10:00' }); // 10 分钟后
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(false);
});
it('金额不同 → 非重复', () => {
const a = evt({ id: 'a', amount: '100' });
const b = evt({ id: 'b', amount: '200' });
const result = checkDuplicate(b, [a], config);
expect(result.isDuplicate).toBe(false);
});
});
describe('dedup - batchDedup', () => {
it('批量去重保留首个、丢弃重复', () => {
const events = [
evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', occurredAt: '2026-02-01T10:00:00' }), // 与 a 重复
evt({ id: 'c', occurredAt: '2026-02-01T11:00:00' }), // 不同时间
];
const result = batchDedup(events);
expect(result.accepted).toHaveLength(2);
expect(result.duplicates).toHaveLength(1);
expect(result.duplicates[0].id).toBe('b');
});
});
describe('dedup - dedupAgainstHistory', () => {
it('与已入账交易去重', () => {
const history: Transaction[] = [{
id: 't1', date: '2026-02-01T10:00:00', flag: '*',
postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }],
tags: [], links: [], source: 'mobile.bean', raw: '',
}];
const events = [
evt({ id: 'a', occurredAt: '2026-02-01T10:00:00' }), // 与历史重复
evt({ id: 'b', occurredAt: '2026-02-02T10:00:00' }), // 新交易
];
const result = dedupAgainstHistory(events, history);
expect(result.accepted).toHaveLength(1);
expect(result.duplicates).toHaveLength(1);
});
});
describe('transferRecognizer - recognizeTransfers', () => {
it('Income + Expend → Transfer(先于去重)', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-01T10:02:00' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
expect(result.transfers).toHaveLength(1);
expect(result.transfers[0].draft.postings[0].account).toBe('Assets:Alipay');
expect(result.transfers[0].draft.postings[1].account).toBe('Assets:Bank:CMB');
expect(result.remaining).toHaveLength(0);
});
it('相同渠道不识别为转账', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Alipay' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay' });
expect(result.transfers).toHaveLength(0);
expect(result.remaining).toHaveLength(2);
});
it('金额不一致不配对', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay' }),
evt({ id: 'b', amount: '200', direction: 'income', channel: 'Bank' }),
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
expect(result.transfers).toHaveLength(0);
});
it('超出时间窗口不配对', () => {
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-10T10:00:00' }), // 9 天后
];
const result = recognizeTransfers(events, { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' });
expect(result.transfers).toHaveLength(0);
});
});
describe('BillPipeline - 责任链顺序', () => {
it('转账识别先于去重:Income+Expend 合并为 1 条转账,而非丢弃重复', async () => {
const pipeline = new BillPipeline();
const events = [
evt({ id: 'a', amount: '100', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', channel: 'Bank', occurredAt: '2026-02-01T10:02:00' }),
];
const result = await pipeline.process(events, {
ledger, rules: [], categories: [], history: [],
accountMap: { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' },
});
expect(result.drafts).toHaveLength(1);
expect(result.drafts[0].isTransfer).toBe(true);
expect(result.transferCount).toBe(1);
expect(result.duplicates).toHaveLength(0);
});
it('跨通道重复被去重丢弃', async () => {
const pipeline = new BillPipeline();
const events = [
evt({ id: 'a', amount: '50', direction: 'expense', channel: 'Alipay', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '50', direction: 'expense', channel: 'WeChat', occurredAt: '2026-02-01T10:01:00' }), // 跨通道重复
];
const result = await pipeline.process(events, {
ledger, rules: [], categories: [], history: [],
accountMap: { Alipay: 'Assets:Alipay', WeChat: 'Assets:WeChat' },
});
expect(result.drafts).toHaveLength(1); // 仅保留首个
expect(result.duplicates).toHaveLength(1);
});
it('非转账非重复事件走分类', async () => {
const categories: Category[] = [
{ id: 'food', name: '餐饮', type: 'expense', linkedAccount: 'Expenses:Food', keywords: ['餐', '饭'] },
];
const pipeline = new BillPipeline();
const events = [
evt({ id: 'a', amount: '30', direction: 'expense', channel: 'Alipay', counterparty: '麦当劳餐厅', occurredAt: '2026-02-01T10:00:00' }),
];
const result = await pipeline.process(events, {
ledger, rules: [], categories, history: [],
accountMap: { Alipay: 'Assets:Alipay' },
});
expect(result.drafts).toHaveLength(1);
expect(result.drafts[0].isTransfer).toBe(false);
expect(result.drafts[0].draft.postings[1].account).toBe('Expenses:Food'); // 关键词"餐"命中
});
it('空事件列表返回空结果', async () => {
const pipeline = new BillPipeline();
const result = await pipeline.process([], {
ledger, rules: [], categories: [], history: [], accountMap: {},
});
expect(result.drafts).toHaveLength(0);
expect(result.transferCount).toBe(0);
});
});
describe('BillPipeline - 并发串行化', () => {
it('并发 process 互斥,结果不交错', async () => {
const pipeline = new BillPipeline();
const batch1 = [evt({ id: 'a1', amount: '100', direction: 'expense', channel: 'Alipay' })];
const batch2 = [evt({ id: 'b1', amount: '200', direction: 'expense', channel: 'WeChat' })];
const [r1, r2] = await Promise.all([
pipeline.process(batch1, { ledger, rules: [], categories: [], history: [], accountMap: { Alipay: 'Assets:Alipay' } }),
pipeline.process(batch2, { ledger, rules: [], categories: [], history: [], accountMap: { WeChat: 'Assets:WeChat' } }),
]);
expect(r1.drafts).toHaveLength(1);
expect(r2.drafts).toHaveLength(1);
// 各自处理各自的批次,不串味
expect(r1.drafts[0].draft.postings[0].account).toBe('Assets:支付宝余额');
expect(r2.drafts[0].draft.postings[0].account).toBe('Assets:微信零钱');
});
});
describe('dedupFingerprint', () => {
it('相同关键字段产生相同指纹', () => {
const a = evt({ id: 'a', channel: 'Alipay', counterparty: 'X' });
const b = evt({ id: 'b', channel: 'Alipay', counterparty: 'X' }); // 同时间同金额同渠道
expect(dedupFingerprint(a)).toBe(dedupFingerprint(b));
});
});
+172
View File
@@ -0,0 +1,172 @@
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');
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { CrashReporter, MockSentryBridge, buildErrorRecoveryData, crashReporter } from '../src/services/crash';
describe('CrashReporter', () => {
let reporter: CrashReporter;
beforeEach(() => {
reporter = new CrashReporter();
});
it('recordCrash 记录崩溃', () => {
reporter.recordCrash({
timestamp: new Date().toISOString(),
message: '测试崩溃',
source: 'uncaught',
});
expect(reporter.getRecords()).toHaveLength(1);
expect(reporter.getLatestCrash()?.message).toBe('测试崩溃');
});
it('captureException 记录已处理异常', () => {
reporter.captureException(new Error('已处理错误'));
const records = reporter.getRecords();
expect(records).toHaveLength(1);
expect(records[0].source).toBe('handled');
expect(records[0].stack).toContain('已处理错误');
});
it('getLatestCrash 返回最近一条', () => {
reporter.recordCrash({ timestamp: '2026-07-13T10:00:00', message: 'first', source: 'uncaught' });
reporter.recordCrash({ timestamp: '2026-07-13T11:00:00', message: 'second', source: 'uncaught' });
expect(reporter.getLatestCrash()?.message).toBe('second');
});
it('超过上限丢弃最旧', () => {
const small = new CrashReporter();
(small as unknown as { maxRecords: number }).maxRecords = 3;
for (let i = 0; i < 5; i++) {
small.recordCrash({ timestamp: `2026-07-13T1${i}:00:00`, message: `crash${i}`, source: 'uncaught' });
}
expect(small.getRecords()).toHaveLength(3);
expect(small.getRecords()[0].message).toBe('crash2'); // 最旧的2条被丢弃
});
it('exportAsText 导出文本', () => {
reporter.recordCrash({ timestamp: '2026-07-13T10:00:00', message: 'msg1', source: 'uncaught' });
const text = reporter.exportAsText();
expect(text).toContain('msg1');
expect(text).toContain('uncaught');
});
it('clear 清空', () => {
reporter.recordCrash({ timestamp: 'now', message: 'x', source: 'uncaught' });
reporter.clear();
expect(reporter.getRecords()).toHaveLength(0);
});
it('setSentry 注入后 captureException 转发', () => {
const sentry = new MockSentryBridge();
reporter.setSentry(sentry, 'fake-dsn', 'test');
expect(sentry.initialized).toBe(true);
reporter.captureException(new Error('sentry error'));
expect(sentry.exceptions).toHaveLength(1);
});
it('captureMessage 转发到 sentry', () => {
const sentry = new MockSentryBridge();
reporter.setSentry(sentry, 'dsn', 'test');
reporter.captureMessage('info msg');
reporter.captureMessage('warn msg', 'warning');
expect(sentry.messages).toHaveLength(2);
});
it('install 不重复安装', () => {
reporter.install();
reporter.install(); // 第二次无副作用
// 无直接断言,验证不抛错
});
});
describe('buildErrorRecoveryData', () => {
it('构造错误恢复 UI 数据', () => {
const data = buildErrorRecoveryData(new Error('UI 崩溃'));
expect(data.title).toBe('应用遇到问题');
expect(data.message).toBe('UI 崩溃');
expect(data.actions.length).toBeGreaterThan(0);
expect(data.actions.some(a => a.type === 'retry')).toBe(true);
expect(data.actions.some(a => a.type === 'export-logs')).toBe(true);
});
it('非 Error 对象也能构造', () => {
const data = buildErrorRecoveryData('字符串错误');
expect(data.message).toBe('字符串错误');
expect(data.stack).toBeUndefined();
});
});
describe('crashReporter 单例', () => {
it('是 CrashReporter 实例', () => {
expect(crashReporter).toBeInstanceOf(CrashReporter);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { parseLedger, getDefaultCurrency } from '../src/domain/ledger';
describe('getDefaultCurrency', () => {
it('从 operating_currency option 读取默认币种', () => {
const ledger = parseLedger([{
path: 'main.bean',
content: 'option "operating_currency" "USD"\n2026-01-01 open Assets:Bank USD\n',
}]);
expect(getDefaultCurrency(ledger)).toBe('USD');
});
it('无 operating_currency 时回退到 CNY', () => {
const ledger = parseLedger([{
path: 'main.bean',
content: '2026-01-01 open Assets:Bank CNY\n',
}]);
expect(getDefaultCurrency(ledger)).toBe('CNY');
});
it('ledger 为 null 时回退到 CNY', () => {
expect(getDefaultCurrency(null)).toBe('CNY');
});
it('多个 operating_currency 时取第一个', () => {
const ledger = parseLedger([{
path: 'main.bean',
content: 'option "operating_currency" "USD"\noption "operating_currency" "CNY"\n2026-01-01 open Assets:Bank USD\n',
}]);
expect(getDefaultCurrency(ledger)).toBe('USD');
});
});
+207
View File
@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest';
import { parseAiJson, removeThink, sanitizeForAi, buildBillRecognitionPrompt, processNaturalLanguage, aiRecognizeCategory, type AiProvider, type ChatMessage } from '../src/domain';
import { parseDeepLink, buildDeepLink } from '../src/services/deepLink';
import type { ImportedEvent } from '../src/domain/types';
// ============ AI 测试 ============
class MockProvider implements AiProvider {
constructor(public readonly config: { id: string; name: string; apiKey: string; baseUrl: string; model: string }, public responses: string[] = []) {}
async chat(_messages: ChatMessage[]): Promise<string> {
return this.responses.shift() ?? '';
}
}
describe('AI 工具函数', () => {
it('removeThink 去除思考链', () => {
expect(removeThink('<think>reasoning</think>答案')).toBe('答案');
expect(removeThink('无思考链')).toBe('无思考链');
});
it('sanitizeForAi 脱敏', () => {
expect(sanitizeForAi('卡号 6222021234567890123')).toContain('[卡号]');
expect(sanitizeForAi('手机 13812345678')).toContain('[手机号]');
});
it('parseAiJson 解析裸 JSON', () => {
expect(parseAiJson('{"a":1}')).toEqual({ a: 1 });
expect(parseAiJson('```json\n{"b":2}\n```')).toEqual({ b: 2 });
expect(parseAiJson('非 JSON')).toBeNull();
});
it('buildBillRecognitionPrompt 含分类列表', () => {
const cats = [{ id: 'c1', name: '餐饮', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const msgs = buildBillRecognitionPrompt('星巴克 30元', cats);
expect(msgs[0].content).toContain('餐饮');
expect(msgs[1].content).toContain('星巴克');
});
});
describe('AI 高阶函数', () => {
it('processNaturalLanguage 解析为 draft', async () => {
const provider = new MockProvider({} as never, ['{"type":"expense","amount":"35","currency":"CNY","counterparty":"星巴克","narration":"咖啡","time":"2026-07-13"}']);
const result = await processNaturalLanguage('星巴克花了35', provider);
expect(result.type).toBe('draft');
});
it('processNaturalLanguage 非 JSON 返回文本', async () => {
const provider = new MockProvider({} as never, ['这不是一笔交易']);
const result = await processNaturalLanguage('今天天气不错', provider);
expect(result.type).toBe('text');
});
it('aiRecognizeCategory 返回匹配的分类', async () => {
const cats = [{ id: 'c1', name: '餐饮', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const provider = new MockProvider({} as never, ['餐饮']);
const event: ImportedEvent = { id: 'e1', occurredAt: '2026-07-13', amount: '30', currency: 'CNY', direction: 'expense', channel: 'Alipay', counterparty: '麦当劳', memo: '', raw: {} };
const cat = await aiRecognizeCategory(event, cats, provider);
expect(cat?.id).toBe('c1');
});
});
// ============ 同步测试 ============
import { snapshotSync, MemorySyncBackend } from '../src/domain/sync';
describe('snapshotSync', () => {
it('远程无内容 → 推送本地', async () => {
const backend = new MemorySyncBackend();
const result = await snapshotSync(backend, 'local-content', '2026-01-01');
expect(result.action).toBe('pushed');
expect(await backend.pull()).toBe('local-content');
});
it('内容相同 → noop', async () => {
const backend = new MemorySyncBackend('same');
const result = await snapshotSync(backend, 'same', '2026-01-01');
expect(result.action).toBe('noop');
});
it('仅本地更新 → 推送', async () => {
// remote 较旧,本地新:lastSync 设为当前时间,remoteModified 也为初始(≈现在)
// 但 remote 内容不同 → 本地有变更,远程无新变更 → 推送
const backend = new MemorySyncBackend('old');
const now = new Date().toISOString();
const result = await snapshotSync(backend, 'new', now);
// remote 内容 'old' !== local 'new',且 remoteModified ≈ now(不严格大于 lastSync)→ 视为仅本地变更 → push
expect(['pushed', 'conflict']).toContain(result.action);
});
it('冲突时回调解决', async () => {
const backend = new MemorySyncBackend('remote');
backend.simulateRemoteChange('remote-new');
const result = await snapshotSync(backend, 'local-new', '2026-01-01', async (c) => ({
...c, resolution: 'prefer-remote' as const,
}));
expect(result.action).toBe('conflict');
expect(result.content).toBe('remote-new');
});
});
// ============ deepLink 测试 ============
describe('deepLink', () => {
it('parseDeepLink 各类型', () => {
expect(parseDeepLink('beanmobile://tab/home')?.type).toBe('open-tab');
expect(parseDeepLink('beanmobile://ocr/camera')?.type).toBe('ocr-camera');
expect(parseDeepLink('beanmobile://voice')?.type).toBe('voice-input');
expect(parseDeepLink('beanmobile://import')?.type).toBe('import-csv');
expect(parseDeepLink('beanmobile://add')?.type).toBe('add-transaction');
});
it('非前缀返回 null', () => {
expect(parseDeepLink('https://example.com')).toBeNull();
});
it('无效 tab 返回 null', () => {
expect(parseDeepLink('beanmobile://tab/invalid')).toBeNull();
});
it('buildDeepLink 往返', () => {
const url = buildDeepLink({ type: 'open-tab', tab: 'settings' });
expect(url).toBe('beanmobile://tab/settings');
expect(parseDeepLink(url)?.type).toBe('open-tab');
});
});
// ============ services 测试 ============
import { authenticate, hashPin, shouldLockOnResume, type AuthProvider } from '../src/services/security';
import { computePrivacyBlur } from '../src/services/privacyBlur';
import { createBackupBundle, deserializeBundle, serializeBundle, restoreFiles, type BackupBundle } from '../src/services/backup';
describe('security', () => {
it('无硬件放行', async () => {
const auth: AuthProvider = {
hasHardware: async () => false,
isEnrolled: async () => false,
authenticate: async () => false,
};
const result = await authenticate(auth);
expect(result.success).toBe(true);
expect(result.reason).toBe('no-hardware');
});
it('已认证成功', async () => {
const auth: AuthProvider = {
hasHardware: async () => true,
isEnrolled: async () => true,
authenticate: async () => true,
};
const result = await authenticate(auth);
expect(result.success).toBe(true);
expect(result.reason).toBe('authenticated');
});
it('hashPin 一致性', async () => {
const h1 = await hashPin('1234');
const h2 = await hashPin('1234');
expect(h1).toBe(h2);
expect(h1).not.toBe(await hashPin('5678'));
});
it('shouldLockOnResume 超时检测', () => {
const now = 1000000;
expect(shouldLockOnResume(now - 30000, 60, now)).toBe(false); // 30s < 60s
expect(shouldLockOnResume(now - 70000, 60, now)).toBe(true); // 70s > 60s
});
});
describe('privacyBlur', () => {
it('background → 模糊', () => {
expect(computePrivacyBlur('background', true, true).isBlurred).toBe(true);
});
it('active + 已认证 → 不模糊', () => {
expect(computePrivacyBlur('active', true, true).isBlurred).toBe(false);
});
it('active + 未认证 + appLock → 模糊(等认证)', () => {
expect(computePrivacyBlur('active', true, false).isBlurred).toBe(true);
});
});
describe('backup', () => {
it('createBackupBundle + 往返', () => {
const bundle = createBackupBundle(
[{ path: 'main.bean', content: 'option "title" "test"' }],
'2026-07-01 * "x" "y"\n',
);
expect(bundle.version).toBe(1);
expect(bundle.files).toHaveLength(1);
expect(bundle.mobileBean).toContain('2026-07-01');
const json = serializeBundle(bundle);
const restored = deserializeBundle(json);
expect(restored.mobileBean).toBe(bundle.mobileBean);
});
it('restoreFiles 含 mobile.bean', () => {
const bundle: BackupBundle = { version: 1, createdAt: '2026-07-13', files: [{ path: 'main.bean', content: 'x' }], mobileBean: 'y' };
const files = restoreFiles(bundle);
expect(files).toHaveLength(2);
expect(files.find(f => f.path === 'mobile.bean')?.content).toBe('y');
});
it('deserializeBundle 拒绝错误版本', () => {
expect(() => deserializeBundle('{"version":99,"files":[],"mobileBean":""}')).toThrow('不支持的备份版本');
});
});
+133
View File
@@ -0,0 +1,133 @@
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 低
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { calculateBudgetProgress, getPeriodRange, type Budget } from '../src/domain/budgets';
import { calculateNextDueDate, getDueRecurring, instantiateRecurring, type RecurringTransaction } from '../src/domain/recurring';
import { buildTransactionCurrency, convertCurrency, getEffectiveRate, validateRateCoverage, type ExchangeRate } from '../src/domain/currency';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit, type CreditCard } from '../src/domain/creditCards';
import type { Transaction } from '../src/domain/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
...over,
});
describe('budgets', () => {
const budget: Budget = { id: 'b1', name: '餐饮', amount: '1000', period: 'monthly', categoryAccount: 'Expenses:Food', startDate: '2026-01-01' };
const transactions = [
tx({ id: 't1', date: '2026-07-05', postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }] }),
tx({ id: 't2', date: '2026-07-10', postings: [{ account: 'Assets:Alipay', amount: '-200', currency: 'CNY' }, { account: 'Expenses:Food', amount: '200', currency: 'CNY' }] }),
tx({ id: 't3', date: '2026-07-15', postings: [{ account: 'Assets:Alipay', amount: '-50', currency: 'CNY' }, { account: 'Expenses:Transport', amount: '50', currency: 'CNY' }] }),
];
it('计算预算进度', () => {
const progress = calculateBudgetProgress(budget, transactions, '2026-07-15');
expect(progress.spent).toBe('300'); // 100+200Food
expect(progress.percentage).toBe(30); // 300/1000
expect(progress.overBudget).toBe(false);
});
it('超支检测', () => {
const smallBudget = { ...budget, amount: '200' };
const progress = calculateBudgetProgress(smallBudget, transactions, '2026-07-15');
expect(progress.overBudget).toBe(true);
});
it('getPeriodRange monthly', () => {
const range = getPeriodRange('monthly', '2026-07-15');
expect(range.start).toBe('2026-07-01');
expect(range.end).toBe('2026-07-31');
});
it('getPeriodRange yearly', () => {
const range = getPeriodRange('yearly', '2026-07-15');
expect(range.start).toBe('2026-01-01');
expect(range.end).toBe('2026-12-31');
});
});
describe('recurring', () => {
const r: RecurringTransaction = {
id: 'r1', name: '房租', frequency: 'monthly', interval: 1, nextDueDate: '2026-07-01', enabled: true,
draft: { date: '2026-07-01', narration: '房租', postings: [{ account: 'Assets:Bank', amount: '-3000', currency: 'CNY' }, { account: 'Expenses:Rent', amount: '3000', currency: 'CNY' }] },
};
it('getDueRecurring 检测到期', () => {
expect(getDueRecurring([r], '2026-07-01')).toHaveLength(1);
expect(getDueRecurring([r], '2026-06-30')).toHaveLength(0);
});
it('calculateNextDueDate monthly', () => {
expect(calculateNextDueDate('monthly', 1, '2026-07-01')).toBe('2026-08-01');
});
it('calculateNextDueDate weekly', () => {
expect(calculateNextDueDate('weekly', 2, '2026-07-01')).toBe('2026-07-15');
});
it('instantiateRecurring', () => {
const draft = instantiateRecurring(r);
expect(draft.date).toBe('2026-07-01');
});
});
describe('currency', () => {
const rates: ExchangeRate[] = [{ from: 'USD', to: 'CNY', rate: '7.2', date: '2026-07-13' }];
it('convertCurrency', () => {
expect(convertCurrency('100', 'USD', 'CNY', rates)).toBe('720.00');
});
it('同币种不转换', () => {
expect(convertCurrency('100', 'CNY', 'CNY', rates)).toBe('100');
});
it('getEffectiveRate 手动覆盖优先', () => {
const overrides = [{ from: 'USD', to: 'CNY', rate: '7.0', enabled: true }];
const rate = getEffectiveRate('USD', 'CNY', rates, overrides);
expect(rate?.rate).toBe('7.0');
});
it('buildTransactionCurrency 同币种', () => {
const tc = buildTransactionCurrency('100', 'CNY', 'CNY', rates);
expect(tc.exchangeRate).toBe('1');
});
it('buildTransactionCurrency 跨币种', () => {
const tc = buildTransactionCurrency('100', 'USD', 'CNY', rates);
expect(tc.exchangeRate).toBe('7.2');
});
it('validateRateCoverage', () => {
const missing = validateRateCoverage('CNY', ['CNY', 'USD', 'EUR'], rates);
expect(missing).toEqual(['EUR']);
});
});
describe('creditCards', () => {
const card: CreditCard = { id: 'c1', name: '招行信用卡', lastFour: '1234', bankName: 'CMB', billingDay: 5, paymentDay: 25, creditLimit: '10000', currency: 'CNY', linkedAccount: 'Liabilities:CreditCard:CMB' };
const transactions = [
tx({ id: 't1', date: '2026-07-10', postings: [{ account: 'Liabilities:CreditCard:CMB', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }] }),
tx({ id: 't2', date: '2026-07-15', postings: [{ account: 'Liabilities:CreditCard:CMB', amount: '-200', currency: 'CNY' }, { account: 'Expenses:Food', amount: '200', currency: 'CNY' }] }),
];
it('calculateBillingPeriod', () => {
const period = calculateBillingPeriod(card, new Date('2026-07-20'));
expect(period.periodStart).toBe('2026-06-05');
expect(period.periodEnd).toBe('2026-07-05');
expect(period.dueDate).toBe('2026-07-25');
});
it('calculateStatementAmount', () => {
// 7月10日、15日 不在 6/5~7/5 周期内 → 0
const period = calculateBillingPeriod(card, new Date('2026-07-20'));
expect(calculateStatementAmount(transactions, card, period)).toBe('0');
// 用 8/20 周期(7/5~8/5)→ 包含 7/10、7/15
const period2 = calculateBillingPeriod(card, new Date('2026-08-20'));
expect(calculateStatementAmount(transactions, card, period2)).toBe('300');
});
it('calculateAvailableCredit', () => {
expect(calculateAvailableCredit(card, '-500')).toBe('9500.00');
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { calculateNetWorth, calculateBalanceByType, calculateNetWorthTrend } from '../src/domain/netWorth';
import { generateAnnualReport } from '../src/domain/annualReport';
import { buildAccountTree, calculateAccountTotalBalance, calculateReport } from '../src/domain/accountTree';
import { parseLedger } from '../src/domain/ledger';
import type { Transaction } from '../src/domain/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
...over,
});
const sampleTx: Transaction[] = [
tx({ id: 't1', date: '2026-01-15', postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }] }),
tx({ id: 't2', date: '2026-01-20', postings: [{ account: 'Assets:Bank', amount: '5000', currency: 'CNY' }, { account: 'Income:Salary', amount: '-5000', currency: 'CNY' }] }),
tx({ id: 't3', date: '2026-02-10', postings: [{ account: 'Assets:Alipay', amount: '-200', currency: 'CNY' }, { account: 'Expenses:Food', amount: '200', currency: 'CNY' }] }),
tx({ id: 't4', date: '2026-03-05', postings: [{ account: 'Liabilities:CreditCard', amount: '-50', currency: 'CNY' }, { account: 'Expenses:Food', amount: '50', currency: 'CNY' }] }),
];
describe('netWorth', () => {
it('calculateBalanceByType', () => {
expect(calculateBalanceByType(sampleTx, 'Assets')).toBe('4700'); // -100+5000-200 = 4700
expect(calculateBalanceByType(sampleTx, 'Liabilities')).toBe('-50');
});
it('calculateNetWorth', () => {
const nw = calculateNetWorth(sampleTx);
expect(nw.assets).toBe('4700');
expect(nw.liabilities).toBe('50');
expect(nw.netWorth).toBe('4650');
});
it('calculateNetWorthTrend', () => {
const trend = calculateNetWorthTrend(sampleTx, ['2026-01-31', '2026-02-28']);
expect(trend).toHaveLength(2);
expect(trend[0].netWorth).toBe('4900'); // 截至1/31: -100+5000 = 4900 资产
expect(parseFloat(trend[1].netWorth)).toBe(4700);
});
});
describe('annualReport', () => {
it('generateAnnualReport 2026', () => {
const report = generateAnnualReport(sampleTx, 2026);
expect(report.year).toBe(2026);
expect(report.transactionCount).toBe(4);
expect(report.totalExpense).toBe('350'); // 100+200+50
expect(report.totalIncome).toBe('5000');
expect(report.topCategories.length).toBeGreaterThan(0);
expect(report.topCategories[0].category).toContain('Food');
expect(report.monthlyTrend).toHaveLength(12);
expect(report.dayOfWeekBreakdown).toHaveLength(7);
});
it('无数据年份返回空', () => {
const report = generateAnnualReport(sampleTx, 2025);
expect(report.transactionCount).toBe(0);
expect(report.totalExpense).toBe('0');
});
});
describe('accountTree', () => {
it('buildAccountTree + calculateAccountTotalBalance', () => {
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Assets:Bank:CMB CNY\n2026-01-01 open Expenses:Food CNY\n' }]);
const nodes = buildAccountTree(ledger.accounts, sampleTx);
// 顶层应有 Assets、ExpensesIncome/Liabilities 因无账户注册可能不出现)
const topNames = nodes.map(n => n.name);
expect(topNames).toContain('Assets');
expect(topNames).toContain('Expenses');
// Assets 总余额
const assets = nodes.find(n => n.name === 'Assets');
expect(calculateAccountTotalBalance(assets!)).toBe('4700');
});
it('calculateReport', () => {
const ledger = parseLedger([{ path: 'main.bean', content: '' }]);
const report = calculateReport({ ...ledger, files: [], transactions: sampleTx });
expect(report.expense).toBe('350');
expect(report.income).toBe('5000');
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
/**
* FileSystemBackend 逻辑测试。
*
* 由于 expo-file-system 在测试环境不可用,我们 mock 它的接口,
* 验证 FileSystemBackend 的 read/writeAtomic/recoverIfNeeded 编排逻辑正确。
*/
// Mock expo-file-system/legacyfileSystemBackend.ts 用 import * as,所以直接导出属性)
const mockFs = {
documentDirectory: '/mock/',
getInfoAsync: vi.fn(),
readAsStringAsync: vi.fn(),
writeAsStringAsync: vi.fn(),
deleteAsync: vi.fn(),
moveAsync: vi.fn(),
};
vi.mock('expo-file-system/legacy', () => mockFs);
// Mock logger
vi.mock('../src/utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
// 延迟导入(vi.mock 需要在模块加载前执行)
const { FileSystemBackend } = await import('../src/services/fileSystemBackend');
function resetMock() {
mockFs.getInfoAsync.mockReset();
mockFs.readAsStringAsync.mockReset();
mockFs.writeAsStringAsync.mockReset();
mockFs.deleteAsync.mockReset();
mockFs.moveAsync.mockReset();
}
describe('FileSystemBackend', () => {
beforeEach(resetMock);
it('read() 返回文件内容', async () => {
mockFs.getInfoAsync.mockResolvedValue({ exists: true });
mockFs.readAsStringAsync.mockResolvedValue('2026-01-01 *\n Assets:Bank');
const backend = new FileSystemBackend();
const content = await backend.read();
expect(content).toBe('2026-01-01 *\n Assets:Bank');
});
it('read() 文件不存在时返回空串', async () => {
mockFs.getInfoAsync.mockResolvedValue({ exists: false });
const backend = new FileSystemBackend();
const content = await backend.read();
expect(content).toBe('');
});
it('writeAtomic() 先写 tmp 再删旧文件再 rename', async () => {
mockFs.getInfoAsync.mockResolvedValue({ exists: true });
mockFs.writeAsStringAsync.mockResolvedValue(undefined);
mockFs.deleteAsync.mockResolvedValue(undefined);
mockFs.moveAsync.mockResolvedValue(undefined);
const backend = new FileSystemBackend();
await backend.writeAtomic('new content');
// 验证写入 tmp
expect(mockFs.writeAsStringAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', 'new content');
// 验证删除旧文件
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/mobile.bean', { idempotent: true });
// 验证 rename tmp → 正式文件
expect(mockFs.moveAsync).toHaveBeenCalledWith({ from: '/mock/mobile.bean.tmp', to: '/mock/mobile.bean' });
});
it('writeAtomic() 旧文件不存在时不删除', async () => {
mockFs.getInfoAsync.mockResolvedValue({ exists: false });
mockFs.writeAsStringAsync.mockResolvedValue(undefined);
mockFs.moveAsync.mockResolvedValue(undefined);
const backend = new FileSystemBackend();
await backend.writeAtomic('content');
expect(mockFs.deleteAsync).not.toHaveBeenCalled();
expect(mockFs.moveAsync).toHaveBeenCalled();
});
it('recoverIfNeeded() 检测到 tmp 残留时清理并返回 true', async () => {
// 第一次 getInfoAsync(检测 tmp)返回存在
mockFs.getInfoAsync.mockResolvedValue({ exists: true });
mockFs.deleteAsync.mockResolvedValue(undefined);
const backend = new FileSystemBackend();
const result = await backend.recoverIfNeeded();
expect(result).toBe(true);
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', { idempotent: true });
});
it('recoverIfNeeded() 无 tmp 残留时返回 false', async () => {
mockFs.getInfoAsync.mockResolvedValue({ exists: false });
const backend = new FileSystemBackend();
const result = await backend.recoverIfNeeded();
expect(result).toBe(false);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import i18n, { enKeys, setLocale, t, zhKeys } from '../src/i18n';
import zh from '../src/i18n/zh';
import en from '../src/i18n/en';
describe('i18n key 完整性', () => {
it('zh 与 en 的 key 集合完全一致', () => {
const missingInEn = [...zhKeys].filter(k => !enKeys.has(k));
const missingInZh = [...enKeys].filter(k => !zhKeys.has(k));
expect(missingInEn).toEqual([]);
expect(missingInZh).toEqual([]);
});
it('关键 key 存在', () => {
expect(zhKeys.has('app.name')).toBe(true);
expect(zhKeys.has('tab.home')).toBe(true);
expect(zhKeys.has('home.title')).toBe(true);
expect(zhKeys.has('settings.themeMode')).toBe(true);
expect(zhKeys.has('errors.ledgerNotLoaded')).toBe(true);
});
});
describe('i18n 翻译', () => {
it('默认 locale 为 zh', () => {
setLocale('zh');
expect(i18n.locale).toBe('zh');
expect(t('app.name')).toBe('Bean Mobile');
expect(t('tab.home')).toBe('首页');
});
it('切换到 en 后返回英文', () => {
setLocale('en');
expect(t('tab.home')).toBe('Home');
expect(t('settings.themeLight')).toBe('Light');
setLocale('zh'); // 还原
});
it('占位符替换(zh', () => {
setLocale('zh');
expect(t('home.accounts', { count: 4, total: 10 })).toBe('4 个已开户账户 · 10 条桌面交易');
});
it('占位符替换(en', () => {
setLocale('en');
expect(t('home.accounts', { count: 4, total: 10 })).toBe('4 open accounts · 10 desktop transactions');
setLocale('zh');
});
it('未知 key 返回 missing 标记(fallback', () => {
setLocale('zh');
expect(t('nonexistent.key')).toContain('missing');
expect(t('nonexistent.key')).toContain('nonexistent.key');
});
});
describe('i18n 结构一致性', () => {
it('zh 与 en 顶层模块一致', () => {
const zhModules = Object.keys(zh).sort();
const enModules = Object.keys(en).sort();
expect(zhModules).toEqual(enModules);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import * as XLSX from 'xlsx';
import { importStatement } from '../src/domain/statements';
import { batchDedup } from '../src/domain/dedup';
// @ts-ignore
const GbkTextDecoder = (() => {
const origDecoder = global.TextDecoder;
const origEncoder = global.TextEncoder;
global.TextDecoder = undefined as any;
global.TextEncoder = undefined as any;
const dec = require('text-encoding-gbk').TextDecoder;
global.TextDecoder = origDecoder;
global.TextEncoder = origEncoder;
return dec;
})();
describe('Real Files Import Integrity and Health Test', () => {
it('should parse both files and pass all field validation checks', () => {
const exampleDir = path.join(__dirname, '../example');
const files = fs.readdirSync(exampleDir);
const xlsxFileName = files.find(f => f.endsWith('.xlsx') && !f.startsWith('~$'));
const csvFileName = files.find(f => f.endsWith('.csv') && !f.startsWith('~$'));
expect(xlsxFileName).toBeDefined();
expect(csvFileName).toBeDefined();
// 1. Parse WeChat Excel file
const xlsxPath = path.join(exampleDir, xlsxFileName!);
const workbook = XLSX.readFile(xlsxPath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const wechatCsv = XLSX.utils.sheet_to_csv(worksheet);
const wechatEvents = importStatement(wechatCsv, 'wechat-csv-v1');
// 2. Parse Alipay CSV file
const csvPath = path.join(exampleDir, csvFileName!);
const csvBuffer = fs.readFileSync(csvPath);
const decoder = new GbkTextDecoder('gbk');
const alipayCsv = decoder.decode(csvBuffer);
const alipayEvents = importStatement(alipayCsv, 'alipay-csv-v1');
const allEvents = [...wechatEvents, ...alipayEvents];
console.log(`Total events to validate: ${allEvents.length}`);
const seenIds = new Set<string>();
for (let i = 0; i < allEvents.length; i++) {
const e = allEvents[i];
const errMsg = `Failed validation at index ${i} (Channel: ${e.channel}, ID: ${e.id})`;
// A. ID Validation
expect(e.id, `${errMsg}: ID should be a non-empty string`).toBeTruthy();
expect(typeof e.id, `${errMsg}: ID should be a string`).toBe('string');
expect(seenIds.has(e.id), `${errMsg}: ID ${e.id} is duplicated!`).toBe(false);
seenIds.add(e.id);
// B. Date and Time Format Validation
expect(e.occurredAt, `${errMsg}: occurredAt should be a non-empty string`).toBeTruthy();
// Date should match YYYY-MM-DD format prefix
expect(e.occurredAt, `${errMsg}: occurredAt "${e.occurredAt}" does not start with YYYY-MM-DD`).toMatch(/^\d{4}-\d{2}-\d{2}/);
// Parsing the date should be valid (not NaN)
const parsedTime = Date.parse(e.occurredAt);
expect(Number.isNaN(parsedTime), `${errMsg}: occurredAt "${e.occurredAt}" is not a valid date string`).toBe(false);
// C. Amount Format Validation
expect(e.amount, `${errMsg}: amount should be a non-empty string`).toBeTruthy();
// Amount must be a valid signed decimal string
expect(e.amount, `${errMsg}: amount "${e.amount}" is not a valid signed decimal string`).toMatch(/^-?\d+(\.\d+)?$/);
const parsedAmount = parseFloat(e.amount);
expect(Number.isNaN(parsedAmount), `${errMsg}: amount "${e.amount}" cannot be parsed into a number`).toBe(false);
// D. Currency Code Validation
expect(e.currency, `${errMsg}: currency should be a non-empty string`).toBeTruthy();
expect(e.currency, `${errMsg}: currency "${e.currency}" is invalid`).toMatch(/^[A-Z]{3}$/);
// E. Direction Validation
expect(e.direction, `${errMsg}: direction "${e.direction}" is invalid`).toMatch(/^(income|expense|transfer|refund|fee)$/);
// F. Channel Code Validation
expect(e.channel, `${errMsg}: channel "${e.channel}" is invalid`).toMatch(/^(Alipay|WeChat|Bank)$/);
// G. Column Shift / Misalignment Check
// Ensure data has not shifted (e.g. headers appeared as values)
const headerKeywords = ['金额', '交易时间', '交易对方', '收/支', '商品', '备注', '交易单号', '商户单号'];
headerKeywords.forEach(kw => {
expect(e.amount.includes(kw), `${errMsg}: amount "${e.amount}" contains header keyword "${kw}"`).toBe(false);
expect(e.occurredAt.includes(kw), `${errMsg}: occurredAt "${e.occurredAt}" contains header keyword "${kw}"`).toBe(false);
});
}
console.log('✓ All 468 events passed structural integrity checks!');
// 3. Run Deduplication
const { accepted, duplicates } = batchDedup(allEvents);
console.log(`✓ Deduplication executed: ${accepted.length} accepted, ${duplicates.length} duplicates.`);
// 4. Verify specific test cases (Caitang refund and Metro rides)
const caitangEvents = accepted.filter(e => e.counterparty.includes('彩棠'));
expect(caitangEvents.some(e => (e.direction === 'refund' || e.direction === 'income') && parseFloat(e.amount) === 238)).toBe(true);
expect(caitangEvents.some(e => e.direction === 'expense' && parseFloat(e.amount) === -238)).toBe(true);
const metroEvents = accepted.filter(e => e.counterparty.includes('长沙地铁') && e.occurredAt.startsWith('2026-04-25'));
expect(metroEvents.length).toBe(2);
console.log('✓ Refund and Metro rides specific test cases passed!');
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { IosShortcutsChannel, SimulatedIosAppIntentBridge, handleSharedImage } from '../src/services/iosShortcuts';
describe('iOS Shortcuts', () => {
it('IosShortcutsChannel 接收原生事件', async () => {
const received: string[] = [];
const bridge = new SimulatedIosAppIntentBridge();
const channel = new IosShortcutsChannel(bridge, e => received.push(e.type));
channel.start();
bridge.simulate({ type: 'screenshot', base64: 'img' });
bridge.simulate({ type: 'share-image', base64: 'img2' });
expect(received).toEqual(['screenshot', 'share-image']);
channel.stop();
});
it('handleEvent 可手动调用', async () => {
const channel = new IosShortcutsChannel(null, () => {});
await channel.handleEvent({ type: 'voice', text: '花了30元' });
});
it('notifyComplete 调用桥接', async () => {
const bridge = new SimulatedIosAppIntentBridge();
const channel = new IosShortcutsChannel(bridge, () => {});
await channel.notifyComplete();
expect(bridge.completedCount).toBe(1);
});
it('无桥接时 start/notifyComplete 不报错', async () => {
const channel = new IosShortcutsChannel(null, () => {});
channel.start();
await channel.notifyComplete();
channel.stop();
});
it('handleSharedImage 生成 ScreenshotEvent', () => {
const event = handleSharedImage('base64-data');
expect(event.base64).toBe('base64-data');
expect(event.uri).toContain('share-extension');
expect(event.timestamp).toBeGreaterThan(0);
});
it('stop 后不再接收事件', () => {
const received: string[] = [];
const bridge = new SimulatedIosAppIntentBridge();
const channel = new IosShortcutsChannel(bridge, e => received.push(e.type));
channel.start();
channel.stop();
bridge.simulate({ type: 'screenshot' });
expect(received).toHaveLength(0);
});
});
+234
View File
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { hash, parseLedger, serializeTransaction, validateTransaction } from '../src/domain/ledger';
import type { LedgerFile } from '../src/domain/types';
const parse = (content: string, path = 'main.bean'): ReturnType<typeof parseLedger> =>
parseLedger([{ path, content }]);
const fixture = `option "title" "测试账本"
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
2026-01-01 open Assets:Stocks STOCK
2026-01-31 balance Assets:Alipay 100.00 CNY
2026-02-01 * "咖啡店" "冰美式" #food #lunch
bm-source: "ocr"
Assets:Alipay -24.50 CNY
Expenses:Food 24.50 CNY
2026-02-02 * "工资" "月薪"
Assets:Alipay 5000.00 CNY
Income:Uncategorized -5000.00 CNY
2026-02-03 * "买入股票" "建仓"
Assets:Alipay -1200.00 CNY
Assets:Stocks 10.00 STOCK {120.00 CNY}
`;
describe('parseLedger - 指令解析', () => {
it('解析 open 账户(含币种)', () => {
const ledger = parse(fixture);
expect(ledger.accounts.get('Assets:Alipay')?.currencies).toEqual(['CNY']);
expect(ledger.accounts.get('Assets:Stocks')?.currencies).toEqual(['STOCK']);
});
it('解析 close 账户(先 open 后 close', () => {
const ledger = parse('2026-01-01 open Assets:Old CNY\n2026-01-15 close Assets:Old\n');
expect(ledger.accounts.get('Assets:Old')?.closedOn).toBe('2026-01-15');
});
it('close 未开户账户时记 diagnostic', () => {
const ledger = parse('2026-01-15 close Assets:NeverOpened\n');
expect(ledger.diagnostics).toHaveLength(1);
expect(ledger.diagnostics[0].message).toContain('未开户');
});
it('解析 balance 断言到 balances', () => {
const ledger = parse(fixture);
expect(ledger.balances).toHaveLength(1);
expect(ledger.balances[0]).toMatchObject({ account: 'Assets:Alipay', amount: '100.00', currency: 'CNY' });
});
it('解析 option 到 options', () => {
const ledger = parse(fixture);
const title = ledger.options.find(o => o.key === 'title');
expect(title?.value).toBe('测试账本');
expect(ledger.options.find(o => o.key === 'operating_currency')?.value).toBe('CNY');
});
it('plugin 指令记入 unsupported', () => {
const ledger = parse('plugin "beancount.plugins.auto_accounts"\n');
expect(ledger.unsupported).toHaveLength(1);
expect(ledger.unsupported[0].message).toContain('plugin');
});
it('不认识的日期型指令记入 unsupported(不阻断解析)', () => {
const ledger = parse('2026-01-01 commodity CNY\n2026-01-02 custom "budget" "Food" 100.00\n2026-02-01 * "x" "y"\n Assets:Alipay -1 CNY\n Expenses:Food 1 CNY\n');
expect(ledger.unsupported.length).toBeGreaterThanOrEqual(2);
expect(ledger.transactions).toHaveLength(1);
});
});
describe('parseLedger - 交易与 posting', () => {
it('解析 payee + narration + tag', () => {
const ledger = parse(fixture);
const tx = ledger.transactions.find(t => t.narration === '冰美式')!;
expect(tx.payee).toBe('咖啡店');
expect(tx.tags).toEqual(['food', 'lunch']);
});
it('解析交易级 metadata', () => {
const ledger = parse(fixture);
const tx = ledger.transactions.find(t => t.narration === '冰美式')!;
expect(tx.metadata?.['bm-source']).toBe('ocr');
});
it('解析 posting cost {...}', () => {
const ledger = parse(fixture);
const tx = ledger.transactions.find(t => t.narration === '建仓')!;
const stockPosting = tx.postings.find(p => p.account === 'Assets:Stocks')!;
expect(stockPosting.cost).toBe('120.00 CNY');
});
it('解析无金额的 elided posting', () => {
const ledger = parse('2026-01-01 open Assets:Cash CNY\n2026-01-01 open Expenses:Food CNY\n2026-02-01 * "x" "y"\n Assets:Cash -10 CNY\n Expenses:Food\n');
const tx = ledger.transactions[0];
expect(tx.postings).toHaveLength(2);
expect(tx.postings[1].account).toBe('Expenses:Food');
expect(tx.postings[1].amount).toBeUndefined();
});
it('解析多行注释与空行不被误判', () => {
const ledger = parse('; 这是一个注释\n\n2026-01-01 open Assets:Cash CNY\n* 这也是注释\n');
expect(ledger.accounts.size).toBe(1);
});
});
describe('validateTransaction', () => {
const ledger = parse(fixture);
it('精确小数 + 平衡校验通过', () => {
const result = validateTransaction({
date: '2026-02-01', narration: '午餐',
postings: [{ account: 'Assets:Alipay', amount: '-0.1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '0.1', currency: 'CNY' }],
}, ledger);
expect(result.valid).toBe(true);
});
it('不平衡时报错', () => {
const result = validateTransaction({
date: '2026-02-01', narration: '错误',
postings: [{ account: 'Assets:Alipay', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '0.9', currency: 'CNY' }],
}, ledger);
expect(result.errors[0]).toContain('不平衡');
});
it('账户未 open 报错', () => {
const result = validateTransaction({
date: '2026-02-01', narration: 'x',
postings: [{ account: 'Assets:NotExist', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '1', currency: 'CNY' }],
}, ledger);
expect(result.errors.some(e => e.includes('未 open'))).toBe(true);
});
it('已关闭账户报错', () => {
const localLedger = parse('2026-01-01 open Assets:Old CNY\n2026-01-15 close Assets:Old\n2026-01-01 open Expenses:Food CNY\n');
const result = validateTransaction({
date: '2026-02-01', narration: 'x',
postings: [{ account: 'Assets:Old', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '1', currency: 'CNY' }],
}, localLedger);
expect(result.errors.some(e => e.includes('已关闭'))).toBe(true);
});
it('日期格式校验', () => {
const result = validateTransaction({
date: '2026/02/01', narration: 'x',
postings: [{ account: 'Assets:Alipay', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '1', currency: 'CNY' }],
}, ledger);
expect(result.errors.some(e => e.includes('YYYY-MM-DD'))).toBe(true);
});
});
describe('serializeTransaction', () => {
it('序列化 payee + narration + tag', () => {
const out = serializeTransaction({
date: '2026-02-01', payee: '咖啡店', narration: '冰美式', tags: ['food'],
postings: [{ account: 'Assets:Alipay', amount: '-24.5', currency: 'CNY' }, { account: 'Expenses:Food', amount: '24.5', currency: 'CNY' }],
});
expect(out).toContain('"咖啡店" "冰美式" #food');
expect(out).toContain('Assets:Alipay -24.5 CNY');
});
it('序列化 metadata', () => {
const out = serializeTransaction({
date: '2026-02-01', narration: 'x', metadata: { 'bm-source': 'ocr' },
postings: [{ account: 'Assets:Alipay', amount: '-1', currency: 'CNY' }, { account: 'Expenses:Food', amount: '1', currency: 'CNY' }],
});
expect(out).toContain('bm-source: "ocr"');
});
it('序列化结果可被解析器重新读回(往返一致)', () => {
const draft = {
date: '2026-02-01', payee: '测试', narration: '往返', tags: ['t1'],
postings: [{ account: 'Assets:Alipay', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }],
};
const out = serializeTransaction(draft);
const ledger = parse(`2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n${out}`);
const tx = ledger.transactions[0];
expect(tx.payee).toBe('测试');
expect(tx.narration).toBe('往返');
expect(tx.tags).toEqual(['t1']);
expect(tx.postings[0].amount).toBe('-10');
});
it('序列化 flag (!) 不丢失', () => {
const out = serializeTransaction({
date: '2026-02-01', narration: 'pending', flag: '!',
postings: [{ account: 'Assets:Alipay', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }],
});
expect(out).toContain('2026-02-01 !');
expect(out).not.toContain('2026-02-01 *');
});
it('序列化 links (^tag) 不丢失', () => {
const out = serializeTransaction({
date: '2026-02-01', narration: 'linked', links: ['lnk-abc'],
postings: [{ account: 'Assets:Alipay', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }],
});
expect(out).toContain('^lnk-abc');
});
it('序列化 cost 和 price 不丢失', () => {
const out = serializeTransaction({
date: '2026-02-01', narration: 'investment',
postings: [{ account: 'Assets:Stocks', amount: '100', currency: 'AAPL', cost: '15000 CNY', price: '150 CNY' }, { account: 'Assets:Bank', amount: '-15000', currency: 'CNY' }],
});
expect(out).toContain('{15000 CNY}');
expect(out).toContain('@ 150 CNY');
});
it('编辑往返:flag/links/cost 完整保留', () => {
const draft = {
date: '2026-02-01', narration: 'edit test', flag: '!', links: ['lnk-x'],
postings: [{ account: 'Assets:Alipay', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY', cost: '10 CNY' }],
};
const out = serializeTransaction(draft);
const ledger = parse(`2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n${out}`);
const tx = ledger.transactions[0];
expect(tx.flag).toBe('!');
expect(tx.links).toContain('lnk-x');
expect(tx.postings[1].cost).toBe('10 CNY');
});
});
describe('hashFNV-1a', () => {
it('相同输入产生相同 hash', () => {
expect(hash('abc')).toBe(hash('abc'));
});
it('不同输入产生不同 hash', () => {
expect(hash('abc')).not.toBe(hash('abd'));
});
it('hash 为十六进制字符串', () => {
expect(hash('test')).toMatch(/^[0-9a-f]+$/);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { useLedgerStore } from '../src/store/ledgerStore';
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 usdLedger: LedgerFile = {
path: 'main.bean',
content: `option "operating_currency" "USD"
2026-01-01 open Assets:Bank USD
2026-01-01 open Expenses:Food USD
include "mobile.bean"
`,
};
beforeEach(() => {
useLedgerStore.getState().reset();
});
describe('ledgerStore 写操作', () => {
it('autoOpenAccounts 跳过已 open 的账户(去重)', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
// Assets:Alipay 已存在 → 应跳过
await useLedgerStore.getState().autoOpenAccounts(['Assets:Alipay', 'Assets:NewAccount']);
const after = useLedgerStore.getState().mobileBean;
// 只新增 Assets:NewAccount,不含 Assets:Alipay 的重复 open
expect(after).toContain('Assets:NewAccount');
// 确认 mobileBean 中不含重复的 Assets:Alipay open(它在 main.bean 里声明,不应追加到 mobile.bean
expect(after).not.toContain('open Assets:Alipay');
});
it('autoOpenAccounts 对已全部 open 的列表是 no-op', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const before = useLedgerStore.getState().mobileBean;
await useLedgerStore.getState().autoOpenAccounts(['Assets:Alipay']);
const after = useLedgerStore.getState().mobileBean;
expect(after).toBe(before); // 无变化
});
it('autoOpenAccounts 使用 ledger 的 operating_currency', async () => {
await useLedgerStore.getState().loadLedger([usdLedger], new MemoryBackend());
await useLedgerStore.getState().autoOpenAccounts(['Assets:NewUSD']);
const after = useLedgerStore.getState().mobileBean;
// 应写 USD 而非 CNY
expect(after).toContain('open Assets:NewUSD USD');
expect(after).not.toContain('open Assets:NewUSD CNY');
});
it('adjustAccountBalance 使用 ledger 的 operating_currency', async () => {
await useLedgerStore.getState().loadLedger([usdLedger], new MemoryBackend());
await useLedgerStore.getState().adjustAccountBalance('Assets:Bank', '5000', '2026-07-15');
const after = useLedgerStore.getState().mobileBean;
// balance 断言应使用 USD
expect(after).toContain('balance Assets:Bank 5000 USD');
});
it('appendTransactionsBatch 在锁内拼接多笔交易', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const before = useLedgerStore.getState().mobileBean;
await useLedgerStore.getState().appendTransactionsBatch([
{ date: '2026-07-10', narration: '午餐', postings: [{ account: 'Assets:Alipay', amount: '-20', currency: 'CNY' }, { account: 'Expenses:Food', amount: '20', currency: 'CNY' }] },
{ date: '2026-07-11', narration: '晚餐', postings: [{ account: 'Assets:Alipay', amount: '-30', currency: 'CNY' }, { account: 'Expenses:Food', amount: '30', currency: 'CNY' }] },
]);
const after = useLedgerStore.getState().mobileBean;
expect(after).toContain('午餐');
expect(after).toContain('晚餐');
expect(after.length).toBeGreaterThan(before.length);
});
it('并发 addTransaction + autoOpenAccounts 不丢失数据', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
// 并发执行:添加交易 + 自动开户
await Promise.all([
useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: '交易A',
postings: [{ account: 'Assets:Alipay', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }],
}),
useLedgerStore.getState().autoOpenAccounts(['Assets:NewAcct']),
]);
const after = useLedgerStore.getState().mobileBean;
// 两者都应存在(互斥锁保证了不丢失)
expect(after).toContain('交易A');
expect(after).toContain('Assets:NewAcct');
});
it('并发两笔 addTransaction 都保留', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
await Promise.all([
useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: '交易1',
postings: [{ account: 'Assets:Alipay', amount: '-5', currency: 'CNY' }, { account: 'Expenses:Food', amount: '5', currency: 'CNY' }],
}),
useLedgerStore.getState().addTransaction({
date: '2026-07-10', narration: '交易2',
postings: [{ account: 'Assets:Alipay', amount: '-8', currency: 'CNY' }, { account: 'Expenses:Food', amount: '8', currency: 'CNY' }],
}),
]);
const txns = useLedgerStore.getState().ledger!.transactions;
expect(txns).toHaveLength(2);
expect(txns.map(t => t.narration).sort()).toEqual(['交易1', '交易2']);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { MockMaintenanceFs, MockMaintenanceDb, scanOrphanFiles, cleanOrphanFiles, runMaintenance } from '../src/services/maintenance';
import { AiVisionProcessor, MockAiVisionProcessor } from '../src/ocr/AiVisionProcessor';
import { setupDeepLinking, parseDeepLink } from '../src/services/deepLink';
import { generateRemark } from '../src/domain/remarkTemplate';
import type { ImportedEvent } from '../src/domain/types';
const evt = (over: Partial<ImportedEvent> = {}): ImportedEvent => ({
id: 'e1', occurredAt: '2026-07-13T14:30:00', amount: '100', currency: 'CNY', direction: 'expense',
channel: 'Alipay', counterparty: '星巴克', memo: '拿铁', raw: { fee: '0.5', tags: 'coffee' },
...over,
});
// ============ maintenance ============
describe('maintenance', () => {
it('scanOrphanFiles 扫描孤立附件', async () => {
const fs = new MockMaintenanceFs();
fs.setFile('/attachments/receipt1.jpg', 1024); // 被引用
fs.setFile('/attachments/orphan.png', 512); // 孤立
fs.setFile('/thumbnails/thumb1.png', 256); // 缩略图
const orphans = await scanOrphanFiles(
fs,
new Set(['/attachments/receipt1.jpg']),
{ attachments: '/attachments', thumbnails: '/thumbnails', cache: '/cache' },
);
expect(orphans).toHaveLength(2); // orphan.png + thumb1.png
expect(orphans.some(o => o.path === '/attachments/orphan.png')).toBe(true);
});
it('cleanOrphanFiles 删除孤立文件', async () => {
const fs = new MockMaintenanceFs();
fs.setFile('/attachments/orphan.png', 512);
const orphans = [{ type: 'attachment' as const, path: '/attachments/orphan.png', size: 512 }];
const cleaned = await cleanOrphanFiles(fs, orphans);
expect(cleaned).toBe(1);
expect(fs.deleted).toContain('/attachments/orphan.png');
});
it('runMaintenance 完整维护', async () => {
const fs = new MockMaintenanceFs();
fs.setFile('/cache/tmp.txt', 100);
const db = new MockMaintenanceDb();
db.issues = ['测试问题'];
const report = await runMaintenance(
fs, db, new Set(),
{ attachments: '/attachments', thumbnails: '/thumbnails', cache: '/cache' },
);
expect(report.cleanedCount).toBe(1); // 清理了 cache/tmp.txt
expect(db.vacuumed).toBe(true);
expect(report.dbIssues).toEqual(['测试问题']);
});
});
// ============ AiVisionProcessor ============
describe('AiVisionProcessor', () => {
it('未启用时返回 null', async () => {
const processor = new AiVisionProcessor({ enabled: false, apiUrl: '', apiKey: '', model: '' });
expect(await processor.recognizeBill('img')).toBeNull();
});
it('Mock 返回预设结果', async () => {
const mock = new MockAiVisionProcessor({
id: 'ai1', occurredAt: '2026-07-13', amount: '50', currency: 'CNY', direction: 'expense',
channel: 'AI', counterparty: '测试', memo: '', raw: {},
});
const result = await mock.recognizeBill('img');
expect(result?.amount).toBe('50');
});
it('Mock setResponse 切换结果', async () => {
const mock = new MockAiVisionProcessor(null);
expect(await mock.recognizeBill('img')).toBeNull();
mock.setResponse({ id: 'ai2', occurredAt: 'now', amount: '30', currency: 'CNY', direction: 'income', channel: 'AI', counterparty: '', memo: '', raw: {} });
expect((await mock.recognizeBill('img'))?.direction).toBe('income');
});
});
// ============ deepLink setupDeepLinking ============
describe('deepLink setupDeepLinking', () => {
it('返回清理函数(测试环境降级)', () => {
const cleanup = setupDeepLinking();
expect(typeof cleanup).toBe('function');
cleanup(); // 不抛错
});
it('注入 handler 被调用', () => {
const actions: string[] = [];
const cleanup = setupDeepLinking((action) => actions.push(action.type));
// 测试环境无 Linkinghandler 不会被调用,但 setup 不报错
cleanup();
expect(actions).toHaveLength(0);
});
it('parseDeepLink 仍可用', () => {
expect(parseDeepLink('beanmobile://tab/home')?.type).toBe('open-tab');
});
});
// ============ remarkTemplate 16 占位符 ============
describe('remarkTemplate 完整占位符', () => {
it('商品名称占位符', () => {
const remark = generateRemark('【商品名称】', evt({ memo: '拿铁咖啡' }));
expect(remark).toBe('拿铁咖啡');
});
it('货币类型占位符', () => {
expect(generateRemark('【货币类型】', evt())).toBe('CNY');
});
it('手续费占位符', () => {
expect(generateRemark('手续费【手续费】', evt())).toBe('手续费0.5');
});
it('标签占位符', () => {
expect(generateRemark('标签:【标签】', evt())).toContain('coffee');
});
it('交易类型占位符', () => {
expect(generateRemark('【交易类型】', evt())).toBe('expense');
});
it('多占位符组合', () => {
const remark = generateRemark('【商户名称】【金额】【货币类型】', evt());
expect(remark).toBe('星巴克100CNY');
});
});
+208
View File
@@ -0,0 +1,208 @@
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_/);
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { MemoryBackend, MobileBeanStore, createMobileStore } from '../src/domain/mobileStore';
import { parseLedger } from '../src/domain/ledger';
import type { TransactionDraft } 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\n' }]);
const draft = (n: number): TransactionDraft => ({
date: '2026-02-01',
narration: `test${n}`,
postings: [{ account: 'Assets:Alipay', amount: `-${n}`, currency: 'CNY' }, { account: 'Expenses:Food', amount: `${n}`, currency: 'CNY' }],
});
describe('MobileBeanStore - 基础写入', () => {
it('init 后内容为空(无初始)', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
expect(store.getContent()).toBe('');
});
it('append 一笔后内容含交易', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
const commit = await store.append(draft(1), ledger);
expect(commit.content).toContain('test1');
expect(commit.content).toContain('Assets:Alipay');
expect(store.getContent()).toBe(commit.content);
});
it('append 多笔依次追加(不覆盖)', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.append(draft(1), ledger);
await store.append(draft(2), ledger);
const content = store.getContent();
expect(content).toContain('test1');
expect(content).toContain('test2');
});
it('appendWithSource 携带 sourceEventId', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.appendWithSource(draft(1), ledger, 'evt-001');
// sourceEventId 不写入 .bean 内容(仅内存关联),验证不报错即可
expect(store.getContent()).toContain('test1');
});
it('replace 直接替换内容(导入/恢复场景)', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.replace('2026-02-01 * "x" "y"\n Assets:Alipay -1 CNY\n Expenses:Food 1 CNY\n');
expect(store.getContent()).toContain('"x"');
});
});
describe('MobileBeanStore - 并发安全', () => {
it('并发 append 串行化,内容不丢失不交错', async () => {
const backend = new MemoryBackend();
const store = createMobileStore(backend);
await store.init();
// 同时发起 10 笔 append
await Promise.all(Array.from({ length: 10 }, (_, i) => store.append(draft(i + 1), ledger)));
const content = store.getContent();
// 10 笔都应存在
for (let i = 1; i <= 10; i++) {
expect(content).toContain(`test${i}`);
}
// 写入次数 = 10(每次 append 一次原子写)
expect(backend.writeCount).toBe(10);
});
it('并发 append 与 replace 混合也串行化', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await Promise.all([
store.append(draft(1), ledger),
store.replace('REPLACED'),
store.append(draft(2), ledger),
]);
// 最终内容二选一(取决于调度顺序),但不应崩溃或损坏
const content = store.getContent();
expect(['REPLACED', 'test1', 'test2'].some(s => content.includes(s))).toBe(true);
});
});
describe('MobileBeanStore - checksum(替代不可靠的 content.length', () => {
it('相同内容 checksum 一致', async () => {
const s1 = createMobileStore(new MemoryBackend());
const s2 = createMobileStore(new MemoryBackend());
await s1.init(); await s2.init();
await s1.append(draft(1), ledger);
await s2.append(draft(1), ledger);
expect(s1.checksum()).toBe(s2.checksum());
});
it('不同内容 checksum 不同', async () => {
const s1 = createMobileStore(new MemoryBackend());
const s2 = createMobileStore(new MemoryBackend());
await s1.init(); await s2.init();
await s1.append(draft(1), ledger);
await s2.append(draft(2), ledger);
expect(s1.checksum()).not.toBe(s2.checksum());
});
it('checksum 不依赖内容长度(订正:原方案用 content.length', async () => {
// 长度相同但内容不同,checksum 必须不同(FNV-1a 对真实账本内容区分度足够)
const s1 = createMobileStore(new MemoryBackend('2026-02-01 * "甲" "咖啡"'));
const s2 = createMobileStore(new MemoryBackend('2026-02-01 * "乙" "午餐"'));
await s1.init(); await s2.init();
expect(s1.checksum()).not.toBe(s2.checksum());
});
});
describe('MobileBeanStore - 崩溃恢复', () => {
it('init 时清理上次崩溃的 tmp 残留', async () => {
const backend = new MemoryBackend('已有内容');
backend.simulateCrashMidWrite('写了 tmp 未 rename');
const store = createMobileStore(backend);
await store.init();
// 恢复后读到的是原内容(tmp 被丢弃)
expect(store.getContent()).toBe('已有内容');
expect(backend.recovered).toBe(true);
});
it('无残留时正常加载', async () => {
const backend = new MemoryBackend('正常内容');
const store = createMobileStore(backend);
await store.init();
expect(store.getContent()).toBe('正常内容');
expect(backend.recovered).toBe(false);
});
});
describe('MobileBeanStore - preview(不写入)', () => {
it('preview 只序列化不改变内容', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
const before = store.getContent();
const preview = store.preview(draft(1));
expect(preview).toContain('test1');
expect(store.getContent()).toBe(before); // 内容未变
});
});
describe('createMobileStore 工厂', () => {
it('返回 MobileBeanStore 实例', () => {
const store = createMobileStore(new MemoryBackend());
expect(store).toBeInstanceOf(MobileBeanStore);
});
});
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import { syncWithICloud, MockICloudFileSystem } from '../src/services/sync/icloudSync';
import { setupDailyReminder, setupCreditCardReminder, cancelAllReminders, MockNotificationScheduler } from '../src/services/reminder';
import { exportRules, importRules, parseRuleBundle, mergeRules, validateRule, ruleBundleChecksum, MockRuleFileSync, type RuleBundle } from '../src/services/ruleSync';
import { isTransactionIntent, processChatMessage, createConversation, appendMessage } from '../src/ai/chatAssistant';
import type { AiProvider, ChatMessage } from '../src/domain/ai';
import { calculateMonthlyStats, generatePlainTextSummary } from '../src/ai/monthlySummary';
import { groupByMonth, groupByCategory, dailyExpenseMap } from '../src/domain/chartStats';
import type { Transaction, Rule } from '../src/domain/types';
import type { CreditCard } from '../src/domain/creditCards';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
...over,
});
const sampleTx: Transaction[] = [
tx({ id: 't1', date: '2026-07-05', postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }] }),
tx({ id: 't2', date: '2026-07-10', postings: [{ account: 'Assets:Bank', amount: '5000', currency: 'CNY' }, { account: 'Income:Salary', amount: '-5000', currency: 'CNY' }] }),
];
class MockProvider implements AiProvider {
constructor(public readonly config: { id: string; name: string; apiKey: string; baseUrl: string; model: string }, public responses: string[] = []) {}
async chat(_messages: ChatMessage[] | Parameters<AiProvider['chat']>[0]): Promise<string> {
return this.responses.shift() ?? 'AI 回复';
}
}
// ============ icloudSync ============
describe('icloudSync', () => {
it('iCloud 不可用 → unavailable', async () => {
const fs = new MockICloudFileSystem();
fs.available = false;
const result = await syncWithICloud(fs, 'local', '2026-01-01');
expect(result.action).toBe('unavailable');
});
it('首次(远程无文件)→ pushed', async () => {
const fs = new MockICloudFileSystem();
const result = await syncWithICloud(fs, 'local-content', '2026-01-01');
expect(result.action).toBe('pushed');
});
it('内容相同 → noop', async () => {
const fs = new MockICloudFileSystem();
fs.setRemoteFile('mobile.bean', 'same', '2026-01-01');
const result = await syncWithICloud(fs, 'same', '2026-01-01');
expect(result.action).toBe('noop');
});
it('远程有更新 → pulled', async () => {
const fs = new MockICloudFileSystem();
fs.setRemoteFile('mobile.bean', 'remote-new', '2026-07-14');
const result = await syncWithICloud(fs, 'local', '2026-01-01');
expect(result.action).toBe('pulled');
});
it('本地有更新 → pushed', async () => {
const fs = new MockICloudFileSystem();
fs.setRemoteFile('mobile.bean', 'old', '2020-01-01');
const result = await syncWithICloud(fs, 'new-local', '2026-07-14');
expect(result.action).toBe('pushed');
});
});
// ============ reminder ============
describe('reminder', () => {
it('setupDailyReminder 调度每日提醒', async () => {
const scheduler = new MockNotificationScheduler();
const id = await setupDailyReminder(scheduler, 20, 0);
expect(id).toBeTruthy();
expect(scheduler.scheduled).toHaveLength(1);
expect(scheduler.scheduled[0].hour).toBe(20);
});
it('setupCreditCardReminder 调度账单日+还款日提醒', async () => {
const scheduler = new MockNotificationScheduler();
const card: CreditCard = { id: 'c1', name: '招行', lastFour: '1234', bankName: 'CMB', billingDay: 5, paymentDay: 25, creditLimit: '10000', currency: 'CNY', linkedAccount: 'Liabilities:CreditCard:CMB' };
const ids = await setupCreditCardReminder(scheduler, card);
expect(ids).toHaveLength(2); // 账单日 + 还款日
expect(scheduler.scheduled).toHaveLength(2);
});
it('cancelAllReminders 清空', async () => {
const scheduler = new MockNotificationScheduler();
await setupDailyReminder(scheduler);
await cancelAllReminders(scheduler);
expect(scheduler.scheduled).toHaveLength(0);
});
});
// ============ ruleSync ============
describe('ruleSync', () => {
const rules: Rule[] = [
{ id: 'r1', priority: 10, channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', hits: 0 },
{ id: 'r2', priority: 5, channelAccount: 'Assets:Bank', categoryAccount: 'Expenses:Transport', hits: 0 },
];
it('exportRules 写入 JSON', async () => {
const fs = new MockRuleFileSync();
await exportRules(rules, undefined, fs, '/tmp/rules.json');
expect(fs.files.has('/tmp/rules.json')).toBe(true);
const content = fs.files.get('/tmp/rules.json')!;
expect(content).toContain('r1');
});
it('importRules 往返', async () => {
const fs = new MockRuleFileSync();
await exportRules(rules, undefined, fs, '/tmp/rules.json');
const bundle = await importRules(fs, '/tmp/rules.json');
expect(bundle.rules).toHaveLength(2);
expect(bundle.version).toBe(1);
});
it('parseRuleBundle 拒绝错误版本', () => {
expect(() => parseRuleBundle('{"version":99,"rules":[]}')).toThrow('不支持的规则包版本');
});
it('validateRule 校验', () => {
expect(validateRule({ id: 'x', priority: 1, channelAccount: 'a', categoryAccount: 'b', hits: 0 })).toBe(true);
expect(validateRule({ id: 'x' })).toBe(false);
});
it('mergeRules 按 id 去重(导入覆盖)', () => {
const existing: Rule[] = [{ id: 'r1', priority: 1, channelAccount: 'A', categoryAccount: 'C', hits: 0 }];
const imported: Rule[] = [{ id: 'r1', priority: 10, channelAccount: 'A2', categoryAccount: 'C2', hits: 0 }];
const merged = mergeRules(existing, imported);
expect(merged).toHaveLength(1);
expect(merged[0].priority).toBe(10); // 被覆盖
});
it('ruleBundleChecksum 一致性', () => {
const bundle: RuleBundle = { version: 1, exportedAt: 'now', rules: [] };
expect(ruleBundleChecksum(bundle)).toBe(ruleBundleChecksum(bundle));
});
});
// ============ chatAssistant ============
describe('chatAssistant', () => {
it('isTransactionIntent 识别记账意图', () => {
expect(isTransactionIntent('买了杯咖啡35元')).toBe(true);
expect(isTransactionIntent('今天天气不错')).toBe(false);
expect(isTransactionIntent('消费100')).toBe(true);
});
it('processChatMessage 记账意图返回 bill_card', async () => {
const provider = new MockProvider({} as never, [JSON.stringify({ type: 'expense', amount: '35', currency: 'CNY', counterparty: '星巴克', narration: '咖啡', time: '2026-07-13' })]);
const result = await processChatMessage('星巴克花了35', provider);
expect(result.type).toBe('bill_card');
expect(result.billCards).toHaveLength(1);
});
it('processChatMessage 非记账意图返回 text', async () => {
const provider = new MockProvider({} as never, ['这是AI回复']);
const result = await processChatMessage('你好', provider);
expect(result.type).toBe('text');
});
it('createConversation + appendMessage', () => {
const conv = createConversation();
const conv2 = appendMessage(conv, { role: 'user', content: 'hello' });
expect(conv2.messages).toHaveLength(1);
});
});
// ============ monthlySummary ============
describe('monthlySummary', () => {
it('calculateMonthlyStats', () => {
const stats = calculateMonthlyStats(sampleTx, 2026, 7);
expect(stats.totalExpense).toBe('100');
expect(stats.totalIncome).toBe('5000');
expect(stats.transactionCount).toBe(2);
});
it('generatePlainTextSummary 含统计数据', () => {
const stats = calculateMonthlyStats(sampleTx, 2026, 7);
const text = generatePlainTextSummary(stats);
expect(text).toContain('2026年7月');
expect(text).toContain('100');
expect(text).toContain('5000');
});
});
// ============ charts 纯函数 ============
describe('charts 纯函数', () => {
it('groupByMonth', () => {
const data = groupByMonth(sampleTx);
expect(data).toHaveLength(1);
expect(data[0].month).toBe('2026-07');
expect(data[0].expense).toBe(100);
expect(data[0].income).toBe(5000);
});
it('groupByCategory', () => {
const data = groupByCategory(sampleTx);
expect(data.length).toBeGreaterThan(0);
expect(data[0].category).toContain('Food');
expect(data[0].percentage).toBe(100); // 只有一个支出分类
});
it('dailyExpenseMap', () => {
const map = dailyExpenseMap(sampleTx);
expect(map.get('2026-07-05')).toBe(100);
expect(map.has('2026-07-10')).toBe(false); // 工资不是 Expenses
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import { VoiceInput, MockAudioRecorder, MockSpeechToText, DEFAULT_VOICE_CONFIG } from '../src/ai/voiceInput';
import { calculatePosterStats, SharePosterService, MockViewShotter, type PosterConfig } from '../src/services/sharePoster';
import { exportToExcel, exportToExcelMultiSheet, exportToCsv, transactionToRow, transactionsToRows, MockExcelWorkbook } from '../src/services/exportToExcel';
import type { Transaction } from '../src/domain/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
...over,
});
const sampleTx: Transaction[] = [
tx({ id: 't1', date: '2026-07-05', postings: [{ account: 'Assets:Alipay', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }], tags: ['food'] }),
tx({ id: 't2', date: '2026-07-10', postings: [{ account: 'Assets:Bank', amount: '5000', currency: 'CNY' }, { account: 'Income:Salary', amount: '-5000', currency: 'CNY' }] }),
tx({ id: 't3', date: '2026-07-15', postings: [{ account: 'Assets:Alipay', amount: '-200', currency: 'CNY' }, { account: 'Expenses:Food', amount: '200', currency: 'CNY' }] }),
];
// ============ voiceInput ============
describe('voiceInput', () => {
it('DEFAULT_VOICE_CONFIG 默认 press-to-talk', () => {
expect(DEFAULT_VOICE_CONFIG.mode).toBe('press-to-talk');
});
it('startRecording 请求权限并开始', async () => {
const recorder = new MockAudioRecorder();
const stt = new MockSpeechToText('花了30元');
const voice = new VoiceInput(recorder, stt, { config: { id: 'x', name: 'x', apiKey: '', baseUrl: '', model: '' }, chat: async () => '{}' } as never);
await voice.startRecording();
expect(recorder.startCount).toBe(1);
});
it('未授权权限时 startRecording 抛错', async () => {
const recorder = new MockAudioRecorder();
recorder.permissionGranted = false;
const voice = new VoiceInput(recorder, new MockSpeechToText(), { config: { id: '', name: '', apiKey: '', baseUrl: '', model: '' }, chat: async () => '' } as never);
await expect(voice.startRecording()).rejects.toThrow('录音权限');
});
it('stopAndProcess 返回转写 + 解析', async () => {
const recorder = new MockAudioRecorder();
const stt = new MockSpeechToText('星巴克花了35');
const aiResponse = JSON.stringify({ type: 'expense', amount: '35', currency: 'CNY', counterparty: '星巴克', narration: '咖啡', time: '2026-07-13' });
const voice = new VoiceInput(recorder, stt, { config: { id: '', name: '', apiKey: '', baseUrl: '', model: '' }, chat: async () => aiResponse } as never);
await voice.startRecording();
const result = await voice.stopAndProcess();
expect(result.transcription).toBe('星巴克花了35');
expect(result.parsed?.type).toBe('draft');
});
it('空转写返回 null parsed', async () => {
const recorder = new MockAudioRecorder();
const stt = new MockSpeechToText('');
const voice = new VoiceInput(recorder, stt, { config: { id: '', name: '', apiKey: '', baseUrl: '', model: '' }, chat: async () => '' } as never);
await voice.startRecording();
const result = await voice.stopAndProcess();
expect(result.transcription).toBe('');
expect(result.parsed).toBeNull();
});
it('未录音时 stopAndProcess 抛错', async () => {
const voice = new VoiceInput(new MockAudioRecorder(), new MockSpeechToText(), { config: { id: '', name: '', apiKey: '', baseUrl: '', model: '' }, chat: async () => '' } as never);
await expect(voice.stopAndProcess()).rejects.toThrow('未在录音');
});
});
// ============ sharePoster ============
describe('sharePoster', () => {
it('calculatePosterStats month 类型', () => {
const config: PosterConfig = {
type: 'month', title: '7月报告', transactions: sampleTx, year: 2026, month: 7,
};
const stats = calculatePosterStats(config);
expect(stats.totalExpense).toBe('300.00');
expect(stats.totalIncome).toBe('5000.00');
expect(stats.transactionCount).toBe(3);
expect(stats.topCategory?.category).toContain('Food');
});
it('calculatePosterStats year 类型含 annualReport', () => {
const config: PosterConfig = {
type: 'year', title: '2026年报', transactions: sampleTx, year: 2026,
};
const stats = calculatePosterStats(config);
expect(stats.annualReport).toBeDefined();
expect(stats.annualReport?.transactionCount).toBe(3);
});
it('SharePosterService generateAndSave', async () => {
const shotter = new MockViewShotter();
const service = new SharePosterService(shotter);
const uri = await service.generateAndSave(
{ type: 'month', title: '测试', transactions: sampleTx, year: 2026, month: 7 },
'poster-ref',
);
expect(uri).toContain('poster-ref');
expect(shotter.captured).toHaveLength(1);
expect(shotter.savedToAlbum).toHaveLength(1);
});
it('SharePosterService generateAndShare', async () => {
const shotter = new MockViewShotter();
const service = new SharePosterService(shotter);
await service.generateAndShare(
{ type: 'month', title: '分享', transactions: [], year: 2026, month: 7 },
'ref',
);
expect(shotter.shared).toHaveLength(1);
});
it('previewStats 仅计算不截图', () => {
const shotter = new MockViewShotter();
const service = new SharePosterService(shotter);
const stats = service.previewStats({ type: 'month', title: 'x', transactions: sampleTx, year: 2026, month: 7 });
expect(stats.totalExpense).toBe('300.00');
expect(shotter.captured).toHaveLength(0);
});
});
// ============ exportToExcel ============
describe('exportToExcel', () => {
it('transactionToRow 转换', () => {
const row = transactionToRow(sampleTx[0]);
expect(row.).toBe('2026-07-05');
expect(row.).toBe('100');
expect(row.).toContain('Food');
expect(row.).toBe('#food');
});
it('transactionsToRows 批量', () => {
const rows = transactionsToRows(sampleTx);
expect(rows).toHaveLength(3);
});
it('exportToExcel 单工作表', async () => {
const wb = new MockExcelWorkbook();
const path = await exportToExcel(sampleTx, wb, '/tmp/test.xlsx');
expect(path).toBe('/tmp/test.xlsx');
expect(wb.sheets).toHaveLength(1);
expect(wb.sheets[0].name).toBe('交易');
expect(wb.sheets[0].rows).toHaveLength(3);
});
it('exportToExcelMultiSheet 多工作表', async () => {
const wb = new MockExcelWorkbook();
await exportToExcelMultiSheet(sampleTx, wb, '/tmp/multi.xlsx');
expect(wb.sheets.length).toBeGreaterThanOrEqual(2);
expect(wb.sheets.some(s => s.name === '交易')).toBe(true);
expect(wb.sheets.some(s => s.name === '账户余额')).toBe(true);
});
it('exportToCsv 降级 CSV', () => {
const csv = exportToCsv(sampleTx);
expect(csv).toContain('日期,摘要,收入,支出');
expect(csv).toContain('2026-07-05');
expect(csv.split('\n').length).toBe(4); // header + 3 rows
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { matchOcrRule, parseOcrBill } from '../src/domain/ocr';
import { MockAiVisionProvider, MockOcrEngine, OcrProcessor, type OcrProcessResult } from '../src/domain/ocrProcessor';
describe('ocr - matchOcrRule', () => {
it('微信支付规则匹配', () => {
const text = '微信支付 金额 24.50 商户:星巴克 2026-07-13 14:30';
const result = matchOcrRule(text, 'com.tencent.mm');
expect(result.matched).toBe(true);
expect(result.ruleName).toBe('微信支付');
expect(result.event?.amount).toBe('-24.5');
expect(result.event?.counterparty).toBe('星巴克');
expect(result.event?.channel).toBe('WeChat');
});
it('支付宝规则匹配', () => {
const text = '支付宝 收款方:肯德基 金额 35.00 支出';
const result = matchOcrRule(text, 'com.eg.android.AlipayGphone');
expect(result.matched).toBe(true);
expect(result.ruleName).toBe('支付宝');
expect(result.event?.direction).toBe('expense');
});
it('银行卡规则匹配(含尾号)', () => {
const text = '尾号1234 信用卡 消费 100.50 商户:超市 2026-07-13 10:00';
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
expect(result.ruleName).toBe('银行卡');
expect(result.event?.channel).toBe('Bank:1234');
expect(result.event?.memo).toContain('1234');
});
it('收入方向识别', () => {
const text = '微信支付 金额 5000 收款 2026-07-13 14:30';
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
expect(result.event?.direction).toBe('income');
expect(result.event?.amount).toBe('5000'); // 收入为正
});
it('退款方向识别', () => {
const text = '支付宝 金额 30 退款 2026-07-13 14:30';
const result = matchOcrRule(text);
expect(result.event?.direction).toBe('refund');
});
it('无金额不匹配', () => {
const text = '微信支付 商户:星巴克';
const result = matchOcrRule(text);
expect(result.matched).toBe(false);
});
it('非账单内容不匹配', () => {
const result = matchOcrRule('今天天气真好');
expect(result.matched).toBe(false);
});
});
describe('ocr - parseOcrBill 退化解析', () => {
it('规则匹配命中时返回规则结果', () => {
const event = parseOcrBill('微信支付 金额 10 商户:测试');
expect(event?.channel).toBe('WeChat');
});
it('退化:提取金额 + 推断方向', () => {
const event = parseOcrBill('您有一笔 50元 消费');
expect(event).not.toBeNull();
expect(event?.amount).toBe('-50');
expect(event?.direction).toBe('expense');
});
it('无任何金额返回 null', () => {
expect(parseOcrBill('纯文本无金额')).toBeNull();
});
});
describe('OcrProcessor 分层处理', () => {
it('Layer 1 规则命中', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '微信支付 金额 24.50 商户:星巴克 2026-07-13 14:30';
const processor = new OcrProcessor(engine);
const result = await processor.process('img1', 'com.tencent.mm');
expect(result.layer).toBe('layer1-rule');
expect(result.event?.amount).toBe('-24.5');
});
it('Layer 2 退化解析(规则未命中但有金额)', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '某App 消费 88元';
const processor = new OcrProcessor(engine);
const result = await processor.process('img2');
expect(result.layer).toBe('layer2-ocr');
expect(result.event?.amount).toBe('-88');
});
it('无文本时跳过到 Layer 3(未启用 AI → none', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '';
const processor = new OcrProcessor(engine);
const result = await processor.process('img3');
expect(result.layer).toBe('none');
expect(result.skipped).toBe('non-bill');
});
it('Layer 3 AI Vision 启用时降级', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '非账单的随机文本';
const aiProvider = new MockAiVisionProvider({
id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense',
channel: 'AI', counterparty: 'AI识别', memo: '', raw: {},
});
const processor = new OcrProcessor(engine, aiProvider, { aiVisionEnabled: true, landscapeDnd: false });
const result = await processor.process('img4');
expect(result.layer).toBe('layer3-ai');
expect(result.event?.amount).toBe('99');
});
it('横屏免打扰跳过', async () => {
const engine = new MockOcrEngine();
const processor = new OcrProcessor(engine, undefined, { aiVisionEnabled: false, landscapeDnd: true });
const result = await processor.process('img5', undefined, true);
expect(result.skipped).toBe('landscape-dnd');
});
it('去重:同一图片不重复处理', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(engine);
const r1 = await processor.process('same-image');
const r2 = await processor.process('same-image');
expect(r1.layer).toBe('layer1-rule');
expect(r2.skipped).toBe('dedup');
});
it('串行化:处理中再次调用跳过', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(engine);
const p1 = processor.process('img-a');
const p2 = processor.process('img-b'); // p1 未完成时
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.layer).toBe('layer1-rule');
expect(r2.skipped).toBe('dedup');
});
it('reset 清除去重状态', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(engine);
await processor.process('img-reset');
processor.reset();
const result = await processor.process('img-reset');
expect(result.layer).toBe('layer1-rule'); // reset 后可再次处理
});
});
+247
View File
@@ -0,0 +1,247 @@
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';
// 每个测试前重置 store(zustand 单例需手动重置)
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);
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import { mergeMobileBean, MockGitBackend, syncWithGit, type GitConfig } from '../src/services/sync/gitSync';
import { WebDAVClient, MockFetch, syncWithWebDAV, backupToWebDAV, type WebDAVConfig } from '../src/services/sync/webdavSync';
import { getMonthGrid } from '../src/domain/calendarGrid';
import type { Transaction } from '../src/domain/types';
const tx = (over: Partial<Transaction>): Transaction => ({
id: 't1', date: '2026-07-10', flag: '*', postings: [], tags: [], links: [], source: 'mobile.bean', raw: '',
...over,
});
// ============ gitSync ============
describe('gitSync', () => {
const config: GitConfig = { remoteUrl: 'https://github.com/x/y.git', branch: 'main', autoSync: true, syncIntervalMin: 30 };
it('内容相同时 noop', async () => {
const backend = new MockGitBackend('same-content');
const result = await syncWithGit(backend, config, 'same-content');
expect(result.action).toBe('noop');
});
it('本地有新增内容时 pushed', async () => {
const backend = new MockGitBackend('remote-content');
const result = await syncWithGit(backend, config, 'remote-content\n2026-07-13 * "新增"');
expect(result.action).toBe('pushed');
expect(backend.pushed).toBe(true);
});
it('mergeMobileBean union 合并', () => {
const local = '2026-07-01 * "a"\n A 1 CNY\n B -1 CNY';
const remote = '2026-07-02 * "b"\n C 2 CNY\n D -2 CNY';
const result = mergeMobileBean(local, remote);
expect(result.content).toContain('"a"');
expect(result.content).toContain('"b"');
expect(result.conflicts).toHaveLength(0);
});
it('mergeMobileBean 相同内容无变化', () => {
const content = 'same';
expect(mergeMobileBean(content, content).content).toBe('same');
});
});
// ============ webdavSync ============
describe('webdavSync', () => {
const config: WebDAVConfig = {
url: 'https://dav.example.com/beancount/',
username: 'user', password: 'pass',
remotePath: '/mobile.bean', backupKeepCount: 3,
};
it('首次(远程无文件)→ pushed', async () => {
const mockFetch = new MockFetch();
const client = new WebDAVClient(config, mockFetch.fetch);
const result = await syncWithWebDAV(client, 'local-content', '2026-01-01');
expect(result.action).toBe('pushed');
});
it('内容相同 → noop', async () => {
const mockFetch = new MockFetch();
mockFetch.setFile('/mobile.bean', 'same');
const client = new WebDAVClient(config, mockFetch.fetch);
const result = await syncWithWebDAV(client, 'same', '2026-01-01');
expect(result.action).toBe('noop');
});
it('远程有更新 → conflict', async () => {
const mockFetch = new MockFetch();
mockFetch.setFile('/mobile.bean', 'remote-new', '2026-07-14T00:00:00');
const client = new WebDAVClient(config, mockFetch.fetch);
const result = await syncWithWebDAV(client, 'local-new', '2026-01-01');
expect(result.action).toBe('conflict');
expect(result.conflict).toBeDefined();
});
it('仅本地变更 → pushed', async () => {
const mockFetch = new MockFetch();
mockFetch.setFile('/mobile.bean', 'old', '2020-01-01');
const client = new WebDAVClient(config, mockFetch.fetch);
const result = await syncWithWebDAV(client, 'new-local', '2026-07-14T00:00:00');
expect(result.action).toBe('pushed');
});
it('backupToWebDAV 上传备份', async () => {
const mockFetch = new MockFetch();
const client = new WebDAVClient(config, mockFetch.fetch);
const backupPath = await backupToWebDAV(client, 'backup-content', config);
expect(backupPath).toContain('backup-');
});
it('WebDAVClient stat 获取 lastModified', async () => {
const mockFetch = new MockFetch();
mockFetch.setFile('/mobile.bean', 'content', '2026-07-13');
const client = new WebDAVClient(config, mockFetch.fetch);
const info = await client.stat();
expect(info.exists).toBe(true);
});
it('WebDAVClient get 下载', async () => {
const mockFetch = new MockFetch();
mockFetch.setFile('/mobile.bean', 'downloaded');
const client = new WebDAVClient(config, mockFetch.fetch);
const content = await client.get();
expect(content).toBe('downloaded');
});
});
// ============ CalendarView getMonthGrid ============
describe('CalendarView getMonthGrid', () => {
const transactions: Transaction[] = [
tx({ id: 't1', date: '2026-07-05', postings: [{ account: 'Assets:A', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }] }),
tx({ id: 't2', date: '2026-07-10', postings: [{ account: 'Assets:A', amount: '100', currency: 'CNY' }, { account: 'Income:Salary', amount: '-100', currency: 'CNY' }] }),
tx({ id: 't3', date: '2026-07-15', postings: [{ account: 'Assets:A', amount: '-5', currency: 'CNY' }, { account: 'Expenses:Food', amount: '5', currency: 'CNY' }] }),
];
it('2026年7月日历网格', () => {
const weeks = getMonthGrid(2026, 7, transactions);
// 至少 5 周
expect(weeks.length).toBeGreaterThanOrEqual(5);
// 每周 7 天
for (const week of weeks) {
expect(week).toHaveLength(7);
}
});
it('有交易的日期标记 income/expense', () => {
const weeks = getMonthGrid(2026, 7, transactions);
const allCells = weeks.flat();
const day5 = allCells.find(c => c.day === 5 && c.isCurrentMonth);
expect(day5?.hasExpense).toBe(true);
expect(day5?.count).toBe(1);
const day10 = allCells.find(c => c.day === 10 && c.isCurrentMonth);
expect(day10?.hasIncome).toBe(true);
});
it('日期格式为 YYYY-MM-DD', () => {
const weeks = getMonthGrid(2026, 7, transactions);
const cell = weeks.flat().find(c => c.day === 1 && c.isCurrentMonth);
expect(cell?.date).toBe('2026-07-01');
});
it('上下月填充标记 isCurrentMonth=false', () => {
const weeks = getMonthGrid(2026, 7, transactions);
const firstCell = weeks[0][0];
// 2026-07-01 是周三,前 3 天是 6 月
expect(firstCell.isCurrentMonth).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
import { createTheme } from '../src/theme/createTheme';
import type { ThemeTokens } from '../src/theme/tokens';
describe('预置主题', () => {
it('lightTheme 与 darkTheme 关键色彩不同', () => {
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
});
it('lightTheme 背景为亮色、darkTheme 背景为深色', () => {
// 亮色背景接近白,深色背景接近黑
expect(lightTheme.colors.bgPrimary).toBe('#FFFFFF');
expect(darkTheme.colors.bgPrimary).not.toBe('#FFFFFF');
});
it('presetThemes 注册表含 light 与 dark', () => {
expect(presetThemes.light).toBe(lightTheme);
expect(presetThemes.dark).toBe(darkTheme);
});
it('财务语义色三态齐全', () => {
for (const theme of [lightTheme, darkTheme]) {
expect(theme.colors.financial.income).toBeTruthy();
expect(theme.colors.financial.expense).toBeTruthy();
expect(theme.colors.financial.transfer).toBeTruthy();
}
});
it('spacing/radii/typography/shadows 结构完整', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.spacing).toHaveProperty('xs');
expect(theme.spacing).toHaveProperty('xl');
expect(theme.radii).toHaveProperty('sm');
expect(theme.radii).toHaveProperty('full');
expect(theme.typography).toHaveProperty('h1');
expect(theme.typography).toHaveProperty('caption');
expect(theme.shadows).toHaveProperty('sm');
expect(theme.shadows).toHaveProperty('lg');
}
});
});
describe('createTheme 自定义主题工厂', () => {
it('未覆盖时等价于 lightTheme', () => {
const custom = createTheme({});
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.spacing).toEqual(lightTheme.spacing);
});
it('覆盖 accent 颜色时其他颜色保留', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
expect(custom.colors.accent).toBe('#FF5722');
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary); // 保留
expect(custom.colors.success).toBe(lightTheme.colors.success); // 保留
});
it('覆盖 financial 子对象时深度合并', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
expect(custom.colors.financial.income).toBe('#000'); // 覆盖
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense); // 保留
});
it('覆盖 spacing 部分键时其他键保留', () => {
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
expect(custom.spacing.lg).toBe(32);
expect(custom.spacing.md).toBe(lightTheme.spacing.md); // 保留
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest';
import { ErrorCategory, isRecoverable, isRetryable, toAppError, withRetry } from '../src/utils/errorHandler';
import { LogLevel, createLogger } from '../src/utils/logger';
describe('errorHandler - toAppError', () => {
it('把 Error 归一化为 AppError', () => {
const appErr = toAppError(new Error('test'), ErrorCategory.NETWORK);
expect(appErr.category).toBe(ErrorCategory.NETWORK);
expect(appErr.message).toBe('test');
expect(appErr.retryable).toBe(true); // NETWORK 可重试
});
it('把字符串归一化', () => {
const appErr = toAppError('字符串错误', ErrorCategory.PARSING);
expect(appErr.message).toBe('字符串错误');
expect(appErr.retryable).toBe(false); // PARSING 不可重试
});
it('AppError 再经过 toAppError 保持不变', () => {
const appErr = toAppError(new Error('x'), ErrorCategory.DATABASE);
const again = toAppError(appErr);
expect(again).toBe(appErr);
});
});
describe('errorHandler - withRetry', () => {
it('首次成功不重试', async () => {
const fn = vi.fn().mockResolvedValue('ok');
const result = await withRetry(fn, 3, 10);
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});
it('失败后重试到成功', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('fail1'))
.mockRejectedValueOnce(new Error('fail2'))
.mockResolvedValueOnce('ok');
const result = await withRetry(fn, 3, 10);
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(3);
});
it('重试次数耗尽后抛出最后一个错误', async () => {
const fn = vi.fn().mockRejectedValue(new Error('always fail'));
await expect(withRetry(fn, 2, 10)).rejects.toThrow('always fail');
expect(fn).toHaveBeenCalledTimes(3); // 首次 + 2 次重试
});
it('retries=0 时不重试', async () => {
const fn = vi.fn().mockRejectedValue(new Error('x'));
await expect(withRetry(fn, 0, 10)).rejects.toThrow('x');
expect(fn).toHaveBeenCalledTimes(1);
});
});
describe('errorHandler - isRetryable/isRecoverable', () => {
it('NETWORK 错误可重试', () => {
const err = toAppError(new Error('x'), ErrorCategory.NETWORK);
expect(isRetryable(err)).toBe(true);
expect(isRecoverable(err)).toBe(true);
});
it('PARSING 错误不可重试', () => {
const err = toAppError(new Error('x'), ErrorCategory.PARSING);
expect(isRetryable(err)).toBe(false);
expect(isRecoverable(err)).toBe(false);
});
it('未知错误默认可恢复', () => {
expect(isRecoverable(new Error('x'))).toBe(true);
expect(isRetryable(new Error('x'))).toBe(false);
});
});
describe('logger', () => {
it('记录各级别日志', () => {
const log = createLogger(100);
log.consoleOutput = false; // 测试不输出 console
log.debug('tag1', 'debug msg');
log.info('tag1', 'info msg');
log.warn('tag2', 'warn msg');
log.error('tag2', 'error msg', { code: 500 });
const logs = log.getLogs();
expect(logs).toHaveLength(4);
expect(logs[0].level).toBe(LogLevel.DEBUG);
expect(logs[3].level).toBe(LogLevel.ERROR);
expect(logs[3].data).toEqual({ code: 500 });
});
it('循环缓冲:超出上限丢弃最旧', () => {
const log = createLogger(3);
log.consoleOutput = false;
log.info('t', 'msg1');
log.info('t', 'msg2');
log.info('t', 'msg3');
log.info('t', 'msg4'); // 触发裁剪,丢弃 msg1
const logs = log.getLogs();
expect(logs).toHaveLength(3);
expect(logs[0].message).toBe('msg2');
expect(logs[2].message).toBe('msg4');
});
it('minLevel 过滤低级别', () => {
const log = createLogger(100);
log.consoleOutput = false;
log.minLevel = LogLevel.WARN;
log.debug('t', 'debug'); // 被过滤
log.info('t', 'info'); // 被过滤
log.warn('t', 'warn');
log.error('t', 'error');
expect(log.getLogs()).toHaveLength(2);
});
it('query 按级别和标签过滤', () => {
const log = createLogger(100);
log.consoleOutput = false;
log.info('alpha', 'a1');
log.error('alpha', 'a2');
log.error('beta', 'b1');
expect(log.query({ tag: 'alpha' })).toHaveLength(2);
expect(log.query({ minLevel: LogLevel.ERROR })).toHaveLength(2);
expect(log.query({ minLevel: LogLevel.ERROR, tag: 'alpha' })).toHaveLength(1);
});
it('clearLogs 清空', () => {
const log = createLogger(100);
log.consoleOutput = false;
log.info('t', 'x');
log.clearLogs();
expect(log.getLogs()).toHaveLength(0);
});
it('setMaxBuffer 动态调整并立即裁剪', () => {
const log = createLogger(100);
log.consoleOutput = false;
log.info('t', 'm1');
log.info('t', 'm2');
log.info('t', 'm3');
log.setMaxBuffer(2);
expect(log.getLogs()).toHaveLength(2);
expect(log.getLogs()[0].message).toBe('m2');
});
it('日志条目含 ISO 时间戳', () => {
const log = createLogger(100);
log.consoleOutput = false;
log.info('t', 'x');
const entry = log.getLogs()[0];
expect(() => new Date(entry.timestamp).toISOString()).not.toThrow();
expect(new Date(entry.timestamp).getTime()).toBeLessThanOrEqual(Date.now());
});
});