DriftLedger/tests/domain-finance.test.ts
fengmengqi 2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
2026-07-22 13:33:29 +08:00

196 lines
8.9 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 { 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 testTxs = [
// 1. 多分录交易:买了食物 100交通 50支付宝付了 150
tx({ id: 't_multi', date: '2026-07-06', postings: [
{ account: 'Assets:Alipay', amount: '-150', currency: 'CNY' },
{ account: 'Expenses:Food', amount: '100', currency: 'CNY' },
{ account: 'Expenses:Transport', amount: '50', currency: 'CNY' }
] }),
// 2. 退款交易:食物退了 10
tx({ id: 't_refund', date: '2026-07-07', postings: [
{ account: 'Assets:Alipay', amount: '10', currency: 'CNY' },
{ account: 'Expenses:Food', amount: '-10', currency: 'CNY' }
] }),
// 3. 转账交易:微信转支付宝 50不应计入预算
tx({ id: 't_transfer', date: '2026-07-08', postings: [
{ account: 'Assets:WeChat', amount: '-50', currency: 'CNY' },
{ account: 'Assets:Alipay', amount: '50', currency: 'CNY' }
] }),
// 4. 工资收入5000不应计入预算
tx({ id: 't_salary', date: '2026-07-09', postings: [
{ account: 'Assets:Alipay', amount: '5000', currency: 'CNY' },
{ account: 'Income:Salary', amount: '-5000', currency: 'CNY' }
] })
];
// 食物预算进度应该只计算食物分录100 - 10 = 90
const foodProgress = calculateBudgetProgress(budget, testTxs, '2026-07-15');
expect(foodProgress.spent).toBe('90');
// 总预算进度应该计算所有 Expenses:* 分录:(100 + 50) - 10 = 140
const totalBudget: Budget = { id: 'b_total', name: '总预算', amount: '1000', period: 'monthly', startDate: '2026-01-01' };
const totalProgress = calculateBudgetProgress(totalBudget, testTxs, '2026-07-15');
expect(totalProgress.spent).toBe('140');
});
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');
});
// 边界日回归:旧实现 new Date('YYYY-MM-DD') 按 UTC 解析,负时区会把 07-01 算到 06-30
it('getPeriodRange monthly 边界日(月初)', () => {
const range = getPeriodRange('monthly', '2026-07-01');
expect(range.start).toBe('2026-07-01');
expect(range.end).toBe('2026-07-31');
});
it('getPeriodRange weekly 边界日(周三,跨月起止)', () => {
// 2026-07-01 是周三 → 本周一起 2026-06-29周日止 2026-07-05
const range = getPeriodRange('weekly', '2026-07-01');
expect(range.start).toBe('2026-06-29');
expect(range.end).toBe('2026-07-05');
});
it('getPeriodRange yearly 边界日(元旦)', () => {
const range = getPeriodRange('yearly', '2026-01-01');
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');
expect(calculateNextDueDate('monthly', 1, '2026-07-15')).toBe('2026-08-15');
expect(calculateNextDueDate('monthly', 1, '2026-01-31')).toBe('2026-02-28');
expect(calculateNextDueDate('monthly', 2, '2026-01-31')).toBe('2026-03-31');
});
it('calculateNextDueDate weekly', () => {
expect(calculateNextDueDate('weekly', 2, '2026-07-01')).toBe('2026-07-15');
});
it('calculateNextDueDate yearly', () => {
expect(calculateNextDueDate('yearly', 1, '2026-07-15')).toBe('2027-07-15');
expect(calculateNextDueDate('yearly', 1, '2024-02-29')).toBe('2025-02-28');
expect(calculateNextDueDate('yearly', 4, '2024-02-29')).toBe('2028-02-29');
});
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');
});
});