DriftLedger/tests/ledger.test.ts
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

235 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import { 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]+$/);
});
});