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
This commit is contained in:
fengmengqi
2026-07-22 13:33:29 +08:00
parent 7fa345b558
commit 2e73b5a2c6
96 changed files with 26951 additions and 20619 deletions
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
import { buildMonthGrid } from '../src/components/dateGrid';
describe('getCategoryIcon', () => {
it('15 个内置分类都有具名图标', () => {
const builtin = [
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
'housing_communication', 'entertainment', 'services', 'personal_care',
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
];
for (const id of builtin) {
const icon = getCategoryIcon(id);
expect(typeof icon).toBe('string');
expect(icon.length).toBeGreaterThan(0);
}
});
it('未知分类返回 fallback 图标', () => {
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
});
it('原型链属性名 id 不穿透(返回 fallback', () => {
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
});
it('所有图标名以 -outline 结尾(风格统一)', () => {
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
expect(name).toMatch(/-outline$/);
}
});
});
describe('buildMonthGrid', () => {
it('固定 6 行 × 7 列', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid).toHaveLength(6);
for (const week of grid) expect(week).toHaveLength(7);
});
it('2026-07:首日周三,周一开头填充 6 月末两天', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
});
it('2026-02:首日周日,填充 1 月最后 6 天', () => {
const grid = buildMonthGrid(2026, 2);
expect(grid[0][0].date).toBe('2026-01-26');
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
});
it('首日恰为周一时无跨月填充(2026-06)', () => {
const grid = buildMonthGrid(2026, 6);
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
});
it('inMonth 标记与当月天数一致(2026-07 共 31 天)', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
});
});
+20
View File
@@ -77,6 +77,26 @@ describe('budgets', () => {
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', () => {
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { pressKey, evaluateExpression } from '../src/components/numpadExpression';
import { parsePostingsToLegs } from '../src/domain/postingLegs';
import { buildPostings } from '../src/domain/transactionBuilder';
describe('pressKey 表达式编辑', () => {
it('数字键追加', () => {
expect(pressKey('', '5')).toBe('5');
expect(pressKey('5', '3')).toBe('53');
});
it('前导零被替换', () => {
expect(pressKey('0', '5')).toBe('5');
});
it('小数点:空段补 0,同段不重复', () => {
expect(pressKey('', '.')).toBe('0.');
expect(pressKey('5', '.')).toBe('5.');
expect(pressKey('5.5', '.')).toBe('5.5');
});
it('小数最多两位', () => {
expect(pressKey('5.55', '5')).toBe('5.55');
expect(pressKey('5.5', '5')).toBe('5.55');
});
it('整数位最多 9 位', () => {
expect(pressKey('123456789', '0')).toBe('123456789');
expect(pressKey('12345678', '9')).toBe('123456789');
});
it('9 位整数后仍可输入小数', () => {
expect(pressKey('123456789.', '5')).toBe('123456789.5');
expect(pressKey('123456789.5', '5')).toBe('123456789.55');
});
it('运算符:数字后可加,连续运算符替换,空表达式不可加', () => {
expect(pressKey('5', '+')).toBe('5+');
expect(pressKey('5+', '-')).toBe('5-');
expect(pressKey('', '+')).toBe('');
expect(pressKey('', '-')).toBe('');
});
it('运算符后开启新数字段(小数规则独立)', () => {
expect(pressKey('5.5+', '.')).toBe('5.5+0.');
expect(pressKey('5.55+3', '.')).toBe('5.55+3.');
});
it('backspace 删除末字符', () => {
expect(pressKey('5.5', 'backspace')).toBe('5.');
expect(pressKey('5', 'backspace')).toBe('');
expect(pressKey('', 'backspace')).toBe('');
});
});
describe('evaluateExpression 求值', () => {
it('单值', () => {
expect(evaluateExpression('35')).toBe('35');
});
it('加减混合(字符串十进制,无浮点误差)', () => {
expect(evaluateExpression('20+15')).toBe('35');
expect(evaluateExpression('20+15-5.5')).toBe('29.5');
expect(evaluateExpression('0.1+0.2')).toBe('0.3');
});
it('尾随运算符被忽略', () => {
expect(evaluateExpression('20+')).toBe('20');
expect(evaluateExpression('20+15-')).toBe('35');
});
it('空表达式返回空串', () => {
expect(evaluateExpression('')).toBe('');
});
it('段尾小数点不崩溃(键盘可达状态)', () => {
expect(evaluateExpression('5.')).toBe('5');
expect(evaluateExpression('5.+3')).toBe('8');
});
it('净额可为负', () => {
expect(evaluateExpression('5-10')).toBe('-5');
});
});
describe('parsePostingsToLegs 双腿解析', () => {
it('与 buildPostings 往返一致:支出', () => {
const legs = parsePostingsToLegs(buildPostings('expense', 'Assets:招行', 'Expenses:餐饮', '35', 'CNY'));
expect(legs).toEqual({
direction: 'expense',
sourceAccount: 'Assets:招行',
targetAccount: 'Expenses:餐饮',
amount: '35',
currency: 'CNY',
});
});
it('与 buildPostings 往返一致:收入', () => {
const legs = parsePostingsToLegs(buildPostings('income', 'Assets:招行', 'Income:工资', '12000', 'CNY'));
expect(legs).toEqual({
direction: 'income',
sourceAccount: 'Assets:招行',
targetAccount: 'Income:工资',
amount: '12000',
currency: 'CNY',
});
});
it('与 buildPostings 往返一致:转账', () => {
const legs = parsePostingsToLegs(buildPostings('transfer', 'Assets:招行', 'Liabilities:花呗', '2000', 'CNY'));
expect(legs).toEqual({
direction: 'transfer',
sourceAccount: 'Assets:招行',
targetAccount: 'Liabilities:花呗',
amount: '2000',
currency: 'CNY',
});
});
it('金额取绝对值(去掉负号)', () => {
const legs = parsePostingsToLegs([
{ account: 'Assets:招行', amount: '-35', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '35', currency: 'CNY' },
]);
expect(legs?.amount).toBe('35');
});
it('无法识别时返回 null', () => {
expect(parsePostingsToLegs([])).toBeNull();
expect(parsePostingsToLegs([{ account: 'Assets:招行', amount: '35', currency: 'CNY' }])).toBeNull();
});
it('拆分交易(3 条分录)返回 null,走高级模式', () => {
expect(
parsePostingsToLegs([
{ account: 'Assets:招行', amount: '-35', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '30', currency: 'CNY' },
{ account: 'Expenses:小费', amount: '5', currency: 'CNY' },
])
).toBeNull();
});
it('识别腿缺金额时回退用对侧资金腿', () => {
const legs = parsePostingsToLegs([
{ account: 'Assets:招行', amount: '-35', currency: 'CNY' },
{ account: 'Expenses:餐饮' },
]);
expect(legs?.amount).toBe('35');
});
it('转账正腿在前时按负号判定转出方', () => {
const legs = parsePostingsToLegs([
{ account: 'Assets:A', amount: '100', currency: 'CNY' },
{ account: 'Assets:B', amount: '-100', currency: 'CNY' },
]);
expect(legs?.sourceAccount).toBe('Assets:B');
expect(legs?.targetAccount).toBe('Assets:A');
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
describe('categoryPalette', () => {
it('色板为 12 色循环', () => {
expect(CATEGORY_PALETTE).toHaveLength(12);
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
expect(getCategoryColor('food')).toBe('#F59E0B');
expect(getCategoryColor('transport')).toBe('#3B82F6');
expect(getCategoryColor('salary')).toBe('#22C55E');
});
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
const a = getCategoryColor('user_custom_abc');
expect(CATEGORY_PALETTE).toContain(a);
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
});
it('空字符串 id 不崩溃', () => {
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
});
it('原型链属性名 id(如 constructor)走哈希兜底,返回合法色值', () => {
expect(CATEGORY_PALETTE).toContain(getCategoryColor('constructor'));
expect(CATEGORY_PALETTE).toContain(getCategoryColor('toString'));
});
it('渠道品牌色常量', () => {
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/periodNav';
describe('shiftAnchor 周期导航', () => {
it('周:±7 天', () => {
expect(shiftAnchor('2026-07-21', 'weekly', 1)).toBe('2026-07-28');
expect(shiftAnchor('2026-07-21', 'weekly', -1)).toBe('2026-07-14');
});
it('月:月末回退钳制(1月31日 → 2月28日)', () => {
expect(shiftAnchor('2026-01-31', 'monthly', 1)).toBe('2026-02-28');
expect(shiftAnchor('2026-03-31', 'monthly', -1)).toBe('2026-02-28');
});
it('月:跨年', () => {
expect(shiftAnchor('2026-12-15', 'monthly', 1)).toBe('2027-01-15');
expect(shiftAnchor('2026-01-15', 'monthly', -1)).toBe('2025-12-15');
});
it('年:±1 年', () => {
expect(shiftAnchor('2026-07-21', 'annual', 1)).toBe('2027-07-21');
expect(shiftAnchor('2026-07-21', 'annual', -1)).toBe('2025-07-21');
});
it('多步 delta', () => {
expect(shiftAnchor('2026-07-21', 'monthly', 3)).toBe('2026-10-21');
expect(shiftAnchor('2026-07-21', 'weekly', -2)).toBe('2026-07-07');
});
});
describe('periodRange 周期范围', () => {
it('周:周一起周日止(周二 anchor', () => {
expect(periodRange('2026-07-21', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
});
it('周:周日 anchor 属于本周(周一开头)', () => {
expect(periodRange('2026-07-26', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
});
it('月:整月', () => {
expect(periodRange('2026-02-10', 'monthly')).toEqual({ start: '2026-02-01', end: '2026-02-28' });
});
it('年:整年', () => {
expect(periodRange('2026-07-21', 'annual')).toEqual({ start: '2026-01-01', end: '2026-12-31' });
});
});
describe('isoWeekNumber ISO 周数', () => {
it('2026-01-01 是第 1 周', () => {
expect(isoWeekNumber('2026-01-01')).toBe(1);
});
it('2026-07-20 是第 30 周', () => {
expect(isoWeekNumber('2026-07-20')).toBe(30);
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* useSearch 金额区间筛选(P4)。
* 仓库无 testing-library,且 node_modules 中 react-dom(19.2.7) 与 react(19.1.0)
* 版本不一致无法 SSR,因此 vi.mock('react') 提供最小 hook 实现,将 useSearch
* 当纯函数直接调用(useState 恒返回初值 → useDebounce 直通,无需 timer)。
*/
import { vi, describe, expect, it } from 'vitest';
vi.mock('react', () => ({
useMemo: (fn: () => unknown) => fn(),
useState: (init: unknown) => [init, () => {}],
useEffect: () => {},
useCallback: (fn: unknown) => fn,
useRef: (v: unknown) => ({ current: v }),
}));
import { useSearch, type SearchFilters } from '../src/hooks/useSearch';
import type { Transaction } from '../src/domain/types';
function tx(id: string, postings: { account: string; amount?: string }[]): Transaction {
return {
id, date: '2026-07-21', flag: '*', narration: id,
postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })),
tags: [], links: [], source: 'test', raw: '',
};
}
describe('金额区间筛选', () => {
const txs = [
tx('expense35', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]),
tx('expense200', [{ account: 'Assets:招行', amount: '-200' }, { account: 'Expenses:购物', amount: '200' }]),
tx('income5000', [{ account: 'Assets:招行', amount: '5000' }, { account: 'Income:工资', amount: '-5000' }]),
];
it('amountMin/amountMax 按主金额绝对值过滤', () => {
// 三笔:支出 35、支出 200、收入 5000(收入主金额为负 → 绝对值 5000)
const range = useSearch(txs, { amountMin: '30', amountMax: '300' } as SearchFilters);
expect(range.map(t => t.id)).toEqual(['expense35', 'expense200']);
const minOnly = useSearch(txs, { amountMin: '1000' } as SearchFilters);
expect(minOnly.map(t => t.id)).toEqual(['income5000']);
});
it('无金额 posting 的交易在设了下限时被排除', () => {
const withNoAmount = [
...txs,
tx('noAmount', [{ account: 'Assets:招行' }, { account: 'Expenses:其他' }]),
];
const result = useSearch(withNoAmount, { amountMin: '1' } as SearchFilters);
expect(result.map(t => t.id)).not.toContain('noAmount');
expect(result).toHaveLength(3);
});
it('不设区间时不过滤(回归兜底)', () => {
const result = useSearch(txs, {});
expect(result).toHaveLength(3);
});
});
+51 -23
View File
@@ -3,16 +3,56 @@ import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
import { createTheme } from '../src/theme/createTheme';
import type { ThemeTokens } from '../src/theme/tokens';
describe('预置主题', () => {
describe('预置主题(明亮 Bento 设计系统)', () => {
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('#F8FAFC');
expect(darkTheme.colors.bgPrimary).not.toBe('#F8FAFC');
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
expect(darkTheme.colors.bgPrimary).toBe('#040508');
});
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
expect(lightTheme.colors.accent).toBe('#111318');
expect(darkTheme.colors.accent).toBe('#F3F4F6');
});
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
});
it('财务语义色:暗色整体提亮一档', () => {
expect(lightTheme.colors.financial.income).toBe('#10B981');
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
expect(darkTheme.colors.financial.income).toBe('#34D399');
expect(darkTheme.colors.financial.expense).toBe('#F87171');
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
});
it('圆角含 xl(24),卡片默认大圆角', () => {
for (const theme of [lightTheme, darkTheme]) {
expect(theme.radii.sm).toBe(8);
expect(theme.radii.md).toBe(12);
expect(theme.radii.lg).toBe(16);
expect(theme.radii.xl).toBe(24);
expect(theme.radii.full).toBe(9999);
}
});
it('字阶含 display(34),且不指定 fontFamily(系统字体)', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.typography.display.fontSize).toBe(34);
expect(theme.typography.h1.fontSize).toBe(28);
expect(theme.typography.h2.fontSize).toBe(22);
expect(theme.typography.h3.fontSize).toBe(17);
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
expect(theme.typography[key].fontFamily).toBeUndefined();
}
}
});
it('presetThemes 注册表含 light 与 dark', () => {
@@ -20,22 +60,10 @@ describe('预置主题', () => {
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 结构完整', () => {
it('spacing/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');
}
@@ -52,19 +80,19 @@ describe('createTheme 自定义主题工厂', () => {
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); // 保留
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); // 保留
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); // 保留
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { groupTransactionsByDate } from '../src/components/transactionGroups';
import type { Transaction } from '../src/domain/types';
function tx(id: string, date: string, postings: { account: string; amount?: string }[]): Transaction {
return {
id, date, flag: '*', narration: id,
postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })),
tags: [], links: [], source: 'test', raw: '',
};
}
describe('groupTransactionsByDate 日期分组', () => {
it('同日合并并计算收支小计', () => {
const groups = groupTransactionsByDate([
tx('a', '2026-07-21', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]),
tx('b', '2026-07-21', [{ account: 'Assets:招行', amount: '100' }, { account: 'Income:工资', amount: '-100' }]),
]);
expect(groups).toHaveLength(1);
expect(groups[0].date).toBe('2026-07-21');
expect(groups[0].expense).toBe('35');
expect(groups[0].income).toBe('100');
expect(groups[0].items.map(t => t.id)).toEqual(['a', 'b']);
});
it('保持输入顺序(调用方已按日期倒序)', () => {
const groups = groupTransactionsByDate([
tx('a', '2026-07-21', [{ account: 'Expenses:餐饮', amount: '35' }]),
tx('b', '2026-07-20', [{ account: 'Expenses:餐饮', amount: '20' }]),
]);
expect(groups.map(g => g.date)).toEqual(['2026-07-21', '2026-07-20']);
});
it('转账日收支小计为零', () => {
const groups = groupTransactionsByDate([
tx('a', '2026-07-21', [{ account: 'Assets:A', amount: '-50' }, { account: 'Assets:B', amount: '50' }]),
]);
expect(groups[0].income).toBe('0');
expect(groups[0].expense).toBe('0');
});
it('空输入返回空数组', () => {
expect(groupTransactionsByDate([])).toEqual([]);
});
});