feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构

品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
This commit is contained in:
fengmengqi
2026-07-21 15:16:44 +08:00
parent 76a5853ab6
commit 7fa345b558
119 changed files with 3348 additions and 1302 deletions
+3 -3
View File
@@ -63,7 +63,7 @@ describe('ScreenshotChannel', () => {
};
const result = await channel.handleScreenshot(event);
expect(result.layer).toBe('layer1-rule');
expect(result.event?.amount).toBe('-24.5');
expect(result.event?.amount).toBe('-24.50');
});
it('去重:同一 URI 不重复处理', async () => {
@@ -139,7 +139,7 @@ describe('NotificationChannel', () => {
};
const bill = parseNotification(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-24.5');
expect(bill?.amount).toBe('-24.50');
});
it('白名单过滤:非支付 App', () => {
@@ -196,7 +196,7 @@ describe('SmsChannel', () => {
};
const bill = parseSms(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-100.5');
expect(bill?.amount).toBe('-100.50');
expect(bill?.direction).toBe('expense');
expect(bill?.memo).toContain('1234');
});
+25 -2
View File
@@ -99,8 +99,9 @@ describe('transferRecognizer - recognizeTransfers', () => {
];
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(1);
expect(result.transfers[0].draft.postings[0].account).toBe('Assets:转账-支付宝');
expect(result.transfers[0].draft.postings[1].account).toBe('Assets:转账-银行卡');
// 当没有 raw 数据时,resolveSourceAccount 返回 Assets:Unknown
expect(result.transfers[0].draft.postings[0].account).toBe('Assets:Unknown');
expect(result.transfers[0].draft.postings[1].account).toBe('Assets:Unknown');
expect(result.remaining).toHaveLength(0);
});
@@ -131,6 +132,16 @@ describe('transferRecognizer - recognizeTransfers', () => {
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(0);
});
it('金额格式不一致(包含多余的小数点/零)也应配对成功', () => {
const events = [
evt({ id: 'a', amount: '-100.00', direction: 'expense', counterparty: '支付宝', occurredAt: '2026-02-01T10:00:00' }),
evt({ id: 'b', amount: '100', direction: 'income', counterparty: '银行卡', occurredAt: '2026-02-01T10:02:00' }),
];
const result = recognizeTransfers(events);
expect(result.transfers).toHaveLength(1);
expect(result.remaining).toHaveLength(0);
});
});
describe('BillPipeline - 责任链顺序', () => {
@@ -210,4 +221,16 @@ describe('dedupFingerprint', () => {
const b = evt({ id: 'b', counterparty: 'X' });
expect(dedupFingerprint(a)).toBe(dedupFingerprint(b));
});
it('不同金额产生不同指纹', () => {
const a = evt({ id: 'a', amount: '100', counterparty: 'X' });
const b = evt({ id: 'b', amount: '200', counterparty: 'X' });
expect(dedupFingerprint(a)).not.toBe(dedupFingerprint(b));
});
it('不同对手方产生不同指纹', () => {
const a = evt({ id: 'a', counterparty: 'Shop A' });
const b = evt({ id: 'b', counterparty: 'Shop B' });
expect(dedupFingerprint(a)).not.toBe(dedupFingerprint(b));
});
});
+37 -1
View File
@@ -1,6 +1,6 @@
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 { classifyWithCategories, resolveSourceAccount } 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';
@@ -207,3 +207,39 @@ describe('tags(标签序列化)', () => {
expect(resolved[0].name).toBe('food');
});
});
describe('resolveSourceAccount(账户名规范化与去空格)', () => {
it('正确过滤并替换支付方式中的括号和空格为连字符', () => {
const event: ImportedEvent = {
id: 'e1',
occurredAt: '2026-07-21 12:00:00',
amount: '-10.00',
currency: 'CNY',
direction: 'expense',
counterparty: '商户',
memo: '备注',
raw: {
'收付款方式': '招商银行 信用卡(1234)'
}
};
const acct = resolveSourceAccount(event);
expect(acct).toBe('Liabilities:招商银行-信用卡-1234');
});
it('保留普通合法字符,替换多个空格和连字符', () => {
const event: ImportedEvent = {
id: 'e2',
occurredAt: '2026-07-21 12:00:00',
amount: '-10.00',
currency: 'CNY',
direction: 'expense',
counterparty: '商户',
memo: '备注',
raw: {
'支付方式': ' 建设银行 储蓄卡 '
}
};
const acct = resolveSourceAccount(event);
expect(acct).toBe('Assets:建设银行-储蓄卡');
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import {
resolveChannelByPackage,
resolveChannelByName,
BUILTIN_CHANNELS,
type ChannelConfig,
} from '../src/domain/channelConfig';
describe('resolveChannelByPackage', () => {
const customChannels: ChannelConfig[] = [
{
id: 'custom_bank',
name: 'CustomBank',
defaultAccount: 'Assets:CustomBank',
packageNames: ['com.custom.bank'],
},
];
it('finds built-in channel by package name', () => {
const result = resolveChannelByPackage('com.eg.android.AlipayGphone', []);
expect(result).toBeDefined();
expect(result?.name).toBe('Alipay');
expect(result?.defaultAccount).toBe('Assets:支付宝余额');
});
it('finds WeChat channel', () => {
const result = resolveChannelByPackage('com.tencent.mm', []);
expect(result).toBeDefined();
expect(result?.name).toBe('WeChat');
});
it('finds Bank channel', () => {
const result = resolveChannelByPackage('com.unionpay', []);
expect(result).toBeDefined();
expect(result?.name).toBe('Bank');
});
it('prefers user channel over built-in', () => {
const result = resolveChannelByPackage('com.eg.android.AlipayGphone', customChannels);
// Custom channels don't have Alipay package, so falls back to built-in
expect(result?.name).toBe('Alipay');
});
it('finds user custom channel', () => {
const result = resolveChannelByPackage('com.custom.bank', customChannels);
expect(result).toBeDefined();
expect(result?.name).toBe('CustomBank');
expect(result?.defaultAccount).toBe('Assets:CustomBank');
});
it('returns undefined for unknown package', () => {
const result = resolveChannelByPackage('com.unknown.app', []);
expect(result).toBeUndefined();
});
it('returns undefined for empty string', () => {
const result = resolveChannelByPackage('', []);
expect(result).toBeUndefined();
});
});
describe('resolveChannelByName', () => {
const customChannels: ChannelConfig[] = [
{
id: 'custom_channel',
name: 'MyWallet',
defaultAccount: 'Assets:MyWallet',
},
];
it('finds built-in channel by name', () => {
const result = resolveChannelByName('Alipay', []);
expect(result).toBeDefined();
expect(result?.id).toBe('ch_alipay');
});
it('finds channel case-insensitively', () => {
const result = resolveChannelByName('alipay', []);
expect(result).toBeDefined();
expect(result?.id).toBe('ch_alipay');
});
it('finds WeChat by name', () => {
const result = resolveChannelByName('WeChat', []);
expect(result).toBeDefined();
expect(result?.id).toBe('ch_wechat');
});
it('prefers user channel over built-in', () => {
const result = resolveChannelByName('MyWallet', customChannels);
expect(result).toBeDefined();
expect(result?.id).toBe('custom_channel');
});
it('falls back to built-in if user channel not found', () => {
const result = resolveChannelByName('Bank', customChannels);
expect(result).toBeDefined();
expect(result?.id).toBe('ch_bank');
});
it('returns undefined for unknown name', () => {
const result = resolveChannelByName('UnknownChannel', []);
expect(result).toBeUndefined();
});
});
describe('BUILTIN_CHANNELS', () => {
it('has Alipay channel', () => {
const alipay = BUILTIN_CHANNELS.find(ch => ch.name === 'Alipay');
expect(alipay).toBeDefined();
expect(alipay?.packageNames).toContain('com.eg.android.AlipayGphone');
});
it('has WeChat channel', () => {
const wechat = BUILTIN_CHANNELS.find(ch => ch.name === 'WeChat');
expect(wechat).toBeDefined();
expect(wechat?.packageNames).toContain('com.tencent.mm');
});
it('has Bank channel', () => {
const bank = BUILTIN_CHANNELS.find(ch => ch.name === 'Bank');
expect(bank).toBeDefined();
expect(bank?.packageNames).toContain('com.unionpay');
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { detectDirection, PAYMENT_PACKAGES, PAYMENT_PACKAGE_SET } from '../src/domain/constants';
describe('detectDirection', () => {
describe('refund priority', () => {
it('detects refund', () => {
expect(detectDirection('退款100元')).toBe('refund');
});
it('detects return', () => {
expect(detectDirection('退回50元')).toBe('refund');
});
});
describe('fee priority', () => {
it('detects fee', () => {
expect(detectDirection('手续费2元')).toBe('fee');
});
it('detects interest', () => {
expect(detectDirection('利息收入')).toBe('fee');
});
});
describe('income detection', () => {
it('detects 转入 as income', () => {
expect(detectDirection('转入1000')).toBe('income');
});
it('detects 收入 as income', () => {
expect(detectDirection('收入5000')).toBe('income');
});
it('detects 到账 as income', () => {
expect(detectDirection('到账100')).toBe('income');
});
it('detects 入账 as income', () => {
expect(detectDirection('入账200')).toBe('income');
});
it('detects 向你转账 as income', () => {
expect(detectDirection('向你转账')).toBe('income');
});
it('detects 收到转账 as income', () => {
expect(detectDirection('收到转账')).toBe('income');
});
});
describe('expense detection', () => {
it('detects 支出 as expense', () => {
expect(detectDirection('支出100')).toBe('expense');
});
it('detects 付款 as expense', () => {
expect(detectDirection('付款50')).toBe('expense');
});
it('detects 消费 as expense', () => {
expect(detectDirection('消费200')).toBe('expense');
});
it('detects 转出 as expense', () => {
expect(detectDirection('转出300')).toBe('expense');
});
});
describe('transfer detection', () => {
it('detects 转账 as transfer', () => {
expect(detectDirection('转账')).toBe('transfer');
});
});
describe('收款方 guard', () => {
it('returns defaultDir for 收款方', () => {
expect(detectDirection('商户:收款方')).toBe('expense');
});
it('returns custom defaultDir for 收款方', () => {
expect(detectDirection('商户:收款方', 'income')).toBe('income');
});
});
describe('收款 detection', () => {
it('detects 收款 as income', () => {
expect(detectDirection('收款')).toBe('income');
});
});
describe('default direction', () => {
it('returns expense as default', () => {
expect(detectDirection('随机文本')).toBe('expense');
});
it('returns custom default', () => {
expect(detectDirection('随机文本', 'income')).toBe('income');
});
});
});
describe('PAYMENT_PACKAGES', () => {
it('contains Alipay', () => {
expect(PAYMENT_PACKAGES['com.eg.android.AlipayGphone']).toBe('支付宝');
});
it('contains WeChat', () => {
expect(PAYMENT_PACKAGES['com.tencent.mm']).toBe('微信');
});
});
describe('PAYMENT_PACKAGE_SET', () => {
it('contains Alipay package', () => {
expect(PAYMENT_PACKAGE_SET.has('com.eg.android.AlipayGphone')).toBe(true);
});
it('does not contain unknown package', () => {
expect(PAYMENT_PACKAGE_SET.has('com.unknown.app')).toBe(false);
});
});
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect } from 'vitest';
import {
parseDecimal,
formatDecimal,
addDecimals,
compareDecimals,
negateDecimal,
subtractDecimals,
multiplyDecimals,
divideDecimals,
} from '../src/domain/decimal';
describe('parseDecimal', () => {
it('parses positive integer', () => {
expect(parseDecimal('123')).toEqual({ coefficient: 123n, scale: 0 });
});
it('parses negative integer', () => {
expect(parseDecimal('-456')).toEqual({ coefficient: -456n, scale: 0 });
});
it('parses positive decimal', () => {
expect(parseDecimal('12.34')).toEqual({ coefficient: 1234n, scale: 2 });
});
it('parses negative decimal', () => {
expect(parseDecimal('-56.78')).toEqual({ coefficient: -5678n, scale: 2 });
});
it('parses zero', () => {
expect(parseDecimal('0')).toEqual({ coefficient: 0n, scale: 0 });
});
it('parses -0 as zero', () => {
expect(parseDecimal('-0')).toEqual({ coefficient: 0n, scale: 0 });
});
it('parses with commas', () => {
expect(parseDecimal('1,234.56')).toEqual({ coefficient: 123456n, scale: 2 });
});
it('parses with leading/trailing whitespace', () => {
expect(parseDecimal(' 123.45 ')).toEqual({ coefficient: 12345n, scale: 2 });
});
it('parses large numbers', () => {
expect(parseDecimal('99999999999999999.99')).toEqual({ coefficient: 9999999999999999999n, scale: 2 });
});
it('throws on empty string', () => {
expect(() => parseDecimal('')).toThrow('Invalid decimal');
});
it('throws on non-numeric', () => {
expect(() => parseDecimal('abc')).toThrow('Invalid decimal');
});
it('throws on double sign', () => {
expect(() => parseDecimal('+-5')).toThrow('Invalid decimal');
});
});
describe('formatDecimal', () => {
it('formats integer', () => {
expect(formatDecimal(123n, 0)).toBe('123');
});
it('formats negative integer', () => {
expect(formatDecimal(-456n, 0)).toBe('-456');
});
it('formats decimal with scale', () => {
expect(formatDecimal(1234n, 2)).toBe('12.34');
});
it('formats zero', () => {
expect(formatDecimal(0n, 0)).toBe('0');
});
it('formats zero with scale', () => {
expect(formatDecimal(0n, 2)).toBe('0');
});
it('strips trailing zeros', () => {
expect(formatDecimal(1200n, 4)).toBe('0.12');
});
it('strips trailing zeros after decimal point', () => {
expect(formatDecimal(1200n, 2)).toBe('12');
});
it('formats negative decimal', () => {
expect(formatDecimal(-5678n, 2)).toBe('-56.78');
});
});
describe('addDecimals', () => {
it('adds two positive integers', () => {
expect(addDecimals(['100', '200'])).toBe('300');
});
it('adds positive and negative', () => {
expect(addDecimals(['100', '-100'])).toBe('0');
});
it('adds decimals with different scales', () => {
expect(addDecimals(['1.5', '2.33'])).toBe('3.83');
});
it('handles empty array', () => {
expect(addDecimals([])).toBe('0');
});
it('handles single value', () => {
expect(addDecimals(['42'])).toBe('42');
});
it('adds multiple values', () => {
expect(addDecimals(['10', '20', '30'])).toBe('60');
});
it('handles carry', () => {
expect(addDecimals(['0.99', '0.01'])).toBe('1');
});
it('handles large numbers', () => {
expect(addDecimals(['999999999', '1'])).toBe('1000000000');
});
});
describe('compareDecimals', () => {
it('returns 0 for equal values', () => {
expect(compareDecimals('100', '100')).toBe(0);
});
it('returns 1 when a > b', () => {
expect(compareDecimals('200', '100')).toBe(1);
});
it('returns -1 when a < b', () => {
expect(compareDecimals('100', '200')).toBe(-1);
});
it('compares decimals correctly', () => {
expect(compareDecimals('1.1', '1.01')).toBe(1);
});
it('compares negative numbers', () => {
expect(compareDecimals('-100', '-200')).toBe(1);
});
it('compares zero', () => {
expect(compareDecimals('0', '0')).toBe(0);
});
});
describe('negateDecimal', () => {
it('negates positive to negative', () => {
expect(negateDecimal('100')).toBe('-100');
});
it('negates negative to positive', () => {
expect(negateDecimal('-100')).toBe('100');
});
it('negates zero', () => {
expect(negateDecimal('0')).toBe('0');
});
it('negates decimal', () => {
expect(negateDecimal('12.34')).toBe('-12.34');
});
});
describe('subtractDecimals', () => {
it('subtracts two positive integers', () => {
expect(subtractDecimals('300', '100')).toBe('200');
});
it('subtracts to negative', () => {
expect(subtractDecimals('100', '300')).toBe('-200');
});
it('subtracts to zero', () => {
expect(subtractDecimals('100', '100')).toBe('0');
});
it('subtracts decimals', () => {
expect(subtractDecimals('3.5', '1.2')).toBe('2.3');
});
});
describe('multiplyDecimals', () => {
it('multiplies two integers', () => {
expect(multiplyDecimals('10', '20')).toBe('200');
});
it('multiplies by zero', () => {
expect(multiplyDecimals('100', '0')).toBe('0');
});
it('multiplies negative numbers', () => {
expect(multiplyDecimals('-10', '20')).toBe('-200');
});
it('multiplies decimals', () => {
expect(multiplyDecimals('1.5', '2')).toBe('3');
});
it('multiplies decimals with scale', () => {
expect(multiplyDecimals('1.1', '1.1')).toBe('1.21');
});
});
describe('divideDecimals', () => {
it('divides integers', () => {
expect(divideDecimals('10', '2')).toBe('5');
});
it('divides integers to decimal results', () => {
expect(divideDecimals('5', '2')).toBe('2.5');
});
it('divides decimals where numerator has a scale', () => {
expect(divideDecimals('10.0', '2.0')).toBe('5');
});
it('divides to recurring decimals with maxScale limit', () => {
expect(divideDecimals('10', '3', 4)).toBe('3.3333');
});
it('throws on division by zero', () => {
expect(() => divideDecimals('5', '0')).toThrow('除数不能为零');
});
});
+24 -11
View File
@@ -126,7 +126,7 @@ describe('deepLink', () => {
// ============ services 测试 ============
import { authenticate, hashPin, shouldLockOnResume, type AuthProvider } from '../src/services/security';
import { authenticate, hashPin, verifyPin, shouldLockOnResume, type AuthProvider } from '../src/services/security';
import { computePrivacyBlur } from '../src/services/privacyBlur';
import { createBackupBundle, deserializeBundle, serializeBundle, restoreFiles, type BackupBundle } from '../src/services/backup';
@@ -154,10 +154,23 @@ describe('security', () => {
});
it('hashPin 一致性', async () => {
const h1 = await hashPin('1234');
const h2 = await hashPin('1234');
expect(h1).toBe(h2);
expect(h1).not.toBe(await hashPin('5678'));
const r1 = await hashPin('1234');
const r2 = await hashPin('1234', r1.salt);
expect(r1.hash).toBe(r2.hash);
const r3 = await hashPin('5678', r1.salt);
expect(r1.hash).not.toBe(r3.hash);
});
it('verifyPin 兼容旧版无 salt 哈希', async () => {
// 模拟旧版直接 hash (使用空盐)
const { hash: legacyHash } = await hashPin('1234', '');
// 验证时,不传入 salt (表示旧版数据无 salt)
const valid = await verifyPin('1234', legacyHash);
expect(valid).toBe(true);
const invalid = await verifyPin('5678', legacyHash);
expect(invalid).toBe(false);
});
it('shouldLockOnResume 超时检测', () => {
@@ -184,29 +197,29 @@ describe('backup', () => {
const settings = { language: 'zh', theme: 'dark' };
const metadata = { categories: [{ id: 'food', name: '餐饮' }], rules: [] };
const bundle = createBackupBundle(
[{ path: 'main.bean', content: 'option "title" "test"' }],
[{ path: 'other.bean', content: 'some content' }],
'2026-07-01 * "x" "y"\n',
settings,
metadata,
);
expect(bundle.version).toBe(2);
expect(bundle.files).toHaveLength(1);
expect(bundle.mobileBean).toContain('2026-07-01');
expect(bundle.mainBean).toContain('2026-07-01');
expect(bundle.settings).toEqual(settings);
expect(bundle.metadata).toEqual(metadata);
const json = serializeBundle(bundle);
const restored = deserializeBundle(json);
expect(restored.mobileBean).toBe(bundle.mobileBean);
expect(restored.mainBean).toBe(bundle.mainBean);
expect(restored.settings).toEqual(settings);
expect(restored.metadata).toEqual(metadata);
});
it('restoreFiles 含 mobile.bean', () => {
const bundle: BackupBundle = { version: 1, createdAt: '2026-07-13', files: [{ path: 'main.bean', content: 'x' }], mobileBean: 'y' };
it('restoreFiles 含 main.bean', () => {
const bundle: BackupBundle = { version: 1, createdAt: '2026-07-13', files: [{ path: 'other.bean', content: 'x' }], mainBean: 'y' };
const files = restoreFiles(bundle);
expect(files).toHaveLength(2);
expect(files.find(f => f.path === 'mobile.bean')?.content).toBe('y');
expect(files.find(f => f.path === 'main.bean')?.content).toBe('y');
});
it('deserializeBundle 兼容 v1(无 settings/metadata', () => {
+45 -1
View File
@@ -25,6 +25,41 @@ describe('budgets', () => {
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');
@@ -57,12 +92,21 @@ describe('recurring', () => {
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');
@@ -126,6 +170,6 @@ describe('creditCards', () => {
});
it('calculateAvailableCredit', () => {
expect(calculateAvailableCredit(card, '-500')).toBe('9500.00');
expect(calculateAvailableCredit(card, '-500')).toBe('9500');
});
});
+3 -3
View File
@@ -65,9 +65,9 @@ describe('FileSystemBackend', () => {
await backend.writeAtomic('new content');
// 验证写入 tmp
expect(mockFs.writeAsStringAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', 'new content');
expect(mockFs.writeAsStringAsync).toHaveBeenCalledWith('/mock/main.bean.tmp', 'new content');
// 验证 rename tmp → 正式文件(覆盖旧文件,无需删除)
expect(mockFs.moveAsync).toHaveBeenCalledWith({ from: '/mock/mobile.bean.tmp', to: '/mock/mobile.bean' });
expect(mockFs.moveAsync).toHaveBeenCalledWith({ from: '/mock/main.bean.tmp', to: '/mock/main.bean' });
});
it('writeAtomic() 旧文件不存在时不删除', async () => {
@@ -90,7 +90,7 @@ describe('FileSystemBackend', () => {
const backend = new FileSystemBackend();
const result = await backend.recoverIfNeeded();
expect(result).toBe(true);
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/mobile.bean.tmp', { idempotent: true });
expect(mockFs.deleteAsync).toHaveBeenCalledWith('/mock/main.bean.tmp', { idempotent: true });
});
it('recoverIfNeeded() 无 tmp 残留时返回 false', async () => {
+3 -3
View File
@@ -24,7 +24,7 @@ describe('i18n 翻译', () => {
it('默认 locale 为 zh', () => {
setLocale('zh');
expect(i18n.locale).toBe('zh');
expect(t('app.name')).toBe('Bean Mobile');
expect(t('app.name')).toBe('浮记');
expect(t('tab.home')).toBe('首页');
});
@@ -37,12 +37,12 @@ describe('i18n 翻译', () => {
it('占位符替换(zh', () => {
setLocale('zh');
expect(t('home.accounts', { count: 4, total: 10 })).toBe('4 个已开户账户 · 10 条桌面交易');
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');
expect(t('home.accounts', { count: 4, total: 10 })).toBe('4 open accounts · 10 transactions');
setLocale('zh');
});
+2 -2
View File
@@ -97,8 +97,8 @@ describe('Real Files Import Integrity and Health Test', () => {
// 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);
// 彩棠退款/收入事件(金额 238.00)
expect(caitangEvents.some(e => e.amount === '238.00')).toBe(true);
const metroEvents = accepted.filter(e => e.counterparty.includes('长沙地铁') && e.occurredAt.startsWith('2026-04-25'));
expect(metroEvents.length).toBe(2);
+5 -3
View File
@@ -30,15 +30,17 @@ beforeEach(() => {
describe('ledgerStore 写操作', () => {
it('autoOpenAccounts 跳过已 open 的账户(去重)', async () => {
await useLedgerStore.getState().loadLedger([sampleLedger], new MemoryBackend());
const before = useLedgerStore.getState().mobileBean;
// Assets:Alipay 已存在 → 应跳过
await useLedgerStore.getState().autoOpenAccounts(['Assets:Alipay', 'Assets:NewAccount']);
const after = useLedgerStore.getState().mobileBean;
// 只新增 Assets:NewAccount,不含 Assets:Alipay 的重复 open
// 只新增 Assets:NewAccount
expect(after).toContain('Assets:NewAccount');
// 确认 mobileBean 中不含重复的 Assets:Alipay open(它在 main.bean 里声明,不应追加到 mobile.bean
expect(after).not.toContain('open Assets:Alipay');
// 不应添加重复的 open Assets:Alipay(只保留原有的
const openAlipayCount = (after.match(/open Assets:Alipay/g) ?? []).length;
expect(openAlipayCount).toBe(1); // 只有原始的一条,没有重复添加
});
it('autoOpenAccounts 对已全部 open 的列表是 no-op', async () => {
+3 -3
View File
@@ -37,11 +37,11 @@ describe('MobileBeanStore - 基础写入', () => {
expect(content).toContain('test2');
});
it('appendWithSource 携带 sourceEventId', async () => {
it('appendWithSource 携带 sourceEventIds', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.appendWithSource(draft(1), ledger, 'evt-001');
// sourceEventId 不写入 .bean 内容(仅内存关联),验证不报错即可
await store.appendWithSource(draft(1), ledger, ['evt-001']);
// sourceEventIds 不写入 .bean 内容(仅内存关联),验证不报错即可
expect(store.getContent()).toContain('test1');
});
+2 -2
View File
@@ -44,14 +44,14 @@ describe('icloudSync', () => {
it('内容相同 → noop', async () => {
const fs = new MockICloudFileSystem();
fs.setRemoteFile('mobile.bean', 'same', '2026-01-01');
fs.setRemoteFile('main.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');
fs.setRemoteFile('main.bean', 'remote-new', '2026-07-14');
const result = await syncWithICloud(fs, 'local', '2026-01-01');
expect(result.action).toBe('pulled');
});
+3 -3
View File
@@ -72,8 +72,8 @@ describe('sharePoster', () => {
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.totalExpense).toBe('300');
expect(stats.totalIncome).toBe('5000');
expect(stats.transactionCount).toBe(3);
expect(stats.topCategory?.category).toContain('Food');
});
@@ -113,7 +113,7 @@ describe('sharePoster', () => {
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(stats.totalExpense).toBe('300');
expect(shotter.captured).toHaveLength(0);
});
});
+6 -5
View File
@@ -8,7 +8,7 @@ describe('ocr - matchOcrRule', () => {
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?.amount).toBe('-24.50');
expect(result.event?.counterparty).toBe('星巴克');
});
@@ -57,7 +57,7 @@ describe('ocr - matchOcrRule', () => {
const text = '微信支付 金额 1,234.50 商户:星巴克 2026-07-13 14:30';
const result = matchOcrRule(text);
expect(result.matched).toBe(true);
expect(result.event?.amount).toBe('-1234.5');
expect(result.event?.amount).toBe('-1234.50');
});
it('英文商户名空格保留', () => {
@@ -251,7 +251,7 @@ describe('OcrProcessor 分层处理', () => {
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');
expect(result.event?.amount).toBe('-24.50');
});
it('分层处理:详情页应当绕过 Layer 1 规则匹配进入 Layer 2', async () => {
@@ -313,7 +313,7 @@ describe('OcrProcessor 分层处理', () => {
expect(r2.skipped).toBe('dedup');
});
it('串行化:处理中再次调用跳过', async () => {
it('串行化:处理中再次调用排队处理', async () => {
const engine = new MockOcrEngine();
engine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(engine);
@@ -321,7 +321,8 @@ describe('OcrProcessor 分层处理', () => {
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('busy');
expect(r2.layer).toBe('layer1-rule');
expect(r2.skipped).toBeUndefined();
});
it('reset 清除去重状态', async () => {
+11 -6
View File
@@ -31,7 +31,7 @@ describe('ledgerStore', () => {
const state = useLedgerStore.getState();
expect(state.ledger).not.toBeNull();
expect(state.ledger!.accounts.size).toBe(4);
expect(state.mobileBean).toBe('');
expect(state.mobileBean).toContain('include "mobile.bean"');
expect(state.error).toBeNull();
});
@@ -225,12 +225,17 @@ describe('settingsStore', () => {
});
it('persistTo 钩子在状态变更后被调用', () => {
const persisted: unknown[] = [];
// 设置钩子(模拟 AsyncStorage 写入)
(useSettingsStore.getState() as { persistTo?: (s: unknown) => void }).persistTo = (s) => persisted.push(s);
// 使用 store 的 subscribe 来验证状态变更通知
const changes: string[] = [];
const unsub = useSettingsStore.subscribe((state, prevState) => {
if (state !== prevState) {
changes.push(state.themeMode);
}
});
useSettingsStore.getState().setThemeMode('dark');
expect(persisted).toHaveLength(1);
expect((persisted[0] as { themeMode: string }).themeMode).toBe('dark');
expect(changes).toHaveLength(1);
expect(changes[0]).toBe('dark');
unsub();
});
it('hydrate 从持久化源恢复', () => {
+284
View File
@@ -0,0 +1,284 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildAndSaveTransaction, type TransactionStore } from '../src/domain/transactionBuilder';
import type { LedgerIndex } from '../src/domain/types';
// Mock store
const mockAutoOpenAccounts = vi.fn().mockResolvedValue(undefined);
const mockAddTransaction = vi.fn().mockResolvedValue('tx-123');
let mockLedger: LedgerIndex | null = null;
const createMockStore = (): TransactionStore => ({
ledger: mockLedger,
autoOpenAccounts: mockAutoOpenAccounts,
addTransaction: mockAddTransaction,
});
describe('buildAndSaveTransaction', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLedger = null;
});
describe('amount validation', () => {
it('throws on empty amount', async () => {
await expect(
buildAndSaveTransaction({
date: '2026-07-18',
amount: '',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore())
).rejects.toThrow('金额不能为空');
});
it('throws on zero amount', async () => {
await expect(
buildAndSaveTransaction({
date: '2026-07-18',
amount: '0',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore())
).rejects.toThrow('金额数值必须大于 0');
});
it('throws on negative amount', async () => {
await expect(
buildAndSaveTransaction({
date: '2026-07-18',
amount: '-100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore())
).rejects.toThrow('金额数值必须大于 0');
});
it('accepts valid decimal amount', async () => {
await expect(
buildAndSaveTransaction({
date: '2026-07-18',
amount: '89.30',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore())
).resolves.not.toThrow();
});
});
describe('date normalization', () => {
it('normalizes YYYY/M/D format', async () => {
await buildAndSaveTransaction({
date: '2026/7/18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({ date: '2026-07-18' }),
undefined,
undefined
);
});
it('normalizes YYYY年M月D日 format', async () => {
await buildAndSaveTransaction({
date: '2026年7月18日',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({ date: '2026-07-18' }),
undefined,
undefined
);
});
it('normalizes YYYY.M.D format', async () => {
await buildAndSaveTransaction({
date: '2026.7.18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({ date: '2026-07-18' }),
undefined,
undefined
);
});
it('pads single digit month and day', async () => {
await buildAndSaveTransaction({
date: '2026-1-5',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({ date: '2026-01-05' }),
undefined,
undefined
);
});
it('throws on invalid date format', async () => {
await expect(
buildAndSaveTransaction({
date: 'invalid',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore())
).rejects.toThrow('日期格式不正确');
});
});
describe('posting construction', () => {
it('creates expense postings correctly', async () => {
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({
postings: [
{ account: 'Assets:Cash', amount: '-100', currency: 'CNY' },
{ account: 'Expenses:Food', amount: '100', currency: 'CNY' },
],
}),
undefined,
undefined
);
});
it('creates income postings correctly', async () => {
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '5000',
categoryAccount: 'Income:Salary',
sourceAccount: 'Assets:Bank',
direction: 'income',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({
postings: [
{ account: 'Assets:Bank', amount: '5000', currency: 'CNY' },
{ account: 'Income:Salary', amount: '-5000', currency: 'CNY' },
],
}),
undefined,
undefined
);
});
it('creates transfer postings correctly', async () => {
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '1000',
categoryAccount: 'Assets:Bank',
sourceAccount: 'Assets:Cash',
direction: 'transfer',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({
postings: [
{ account: 'Assets:Cash', amount: '-1000', currency: 'CNY' },
{ account: 'Assets:Bank', amount: '1000', currency: 'CNY' },
],
}),
undefined,
undefined
);
});
});
describe('narration defaults', () => {
it('uses default narration for expense', async () => {
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({ narration: '支出交易' }),
undefined,
undefined
);
});
it('uses custom narration when provided', async () => {
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
narration: '午餐',
}, createMockStore());
expect(mockAddTransaction).toHaveBeenCalledWith(
expect.objectContaining({ narration: '午餐' }),
undefined,
undefined
);
});
});
describe('auto-open accounts', () => {
it('opens unopened accounts', async () => {
mockLedger = {
accounts: new Map([
['Assets:Cash', { name: 'Assets:Cash' } as any],
]),
} as unknown as LedgerIndex;
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
expect(mockAutoOpenAccounts).toHaveBeenCalledWith(['Expenses:Food']);
});
it('opens multiple unopened accounts when ledger is null', async () => {
mockLedger = null;
await buildAndSaveTransaction({
date: '2026-07-18',
amount: '100',
categoryAccount: 'Expenses:Food',
sourceAccount: 'Assets:Cash',
direction: 'expense',
}, createMockStore());
// When ledger is null, autoOpenAccounts is not called (graceful skip)
expect(mockAutoOpenAccounts).not.toHaveBeenCalled();
});
});
});