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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user