DriftLedger/tests/mobileStore.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

151 lines
5.8 KiB
TypeScript
Raw 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 { MemoryBackend, MobileBeanStore, createMobileStore } from '../src/domain/mobileStore';
import { parseLedger } from '../src/domain/ledger';
import type { TransactionDraft } from '../src/domain/types';
const ledger = parseLedger([{ path: 'main.bean', content: '2026-01-01 open Assets:Alipay CNY\n2026-01-01 open Expenses:Food CNY\n' }]);
const draft = (n: number): TransactionDraft => ({
date: '2026-02-01',
narration: `test${n}`,
postings: [{ account: 'Assets:Alipay', amount: `-${n}`, currency: 'CNY' }, { account: 'Expenses:Food', amount: `${n}`, currency: 'CNY' }],
});
describe('MobileBeanStore - 基础写入', () => {
it('init 后内容为空(无初始)', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
expect(store.getContent()).toBe('');
});
it('append 一笔后内容含交易', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
const commit = await store.append(draft(1), ledger);
expect(commit.content).toContain('test1');
expect(commit.content).toContain('Assets:Alipay');
expect(store.getContent()).toBe(commit.content);
});
it('append 多笔依次追加(不覆盖)', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.append(draft(1), ledger);
await store.append(draft(2), ledger);
const content = store.getContent();
expect(content).toContain('test1');
expect(content).toContain('test2');
});
it('appendWithSource 携带 sourceEventId', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.appendWithSource(draft(1), ledger, 'evt-001');
// sourceEventId 不写入 .bean 内容(仅内存关联),验证不报错即可
expect(store.getContent()).toContain('test1');
});
it('replace 直接替换内容(导入/恢复场景)', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await store.replace('2026-02-01 * "x" "y"\n Assets:Alipay -1 CNY\n Expenses:Food 1 CNY\n');
expect(store.getContent()).toContain('"x"');
});
});
describe('MobileBeanStore - 并发安全', () => {
it('并发 append 串行化,内容不丢失不交错', async () => {
const backend = new MemoryBackend();
const store = createMobileStore(backend);
await store.init();
// 同时发起 10 笔 append
await Promise.all(Array.from({ length: 10 }, (_, i) => store.append(draft(i + 1), ledger)));
const content = store.getContent();
// 10 笔都应存在
for (let i = 1; i <= 10; i++) {
expect(content).toContain(`test${i}`);
}
// 写入次数 = 10每次 append 一次原子写)
expect(backend.writeCount).toBe(10);
});
it('并发 append 与 replace 混合也串行化', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
await Promise.all([
store.append(draft(1), ledger),
store.replace('REPLACED'),
store.append(draft(2), ledger),
]);
// 最终内容二选一(取决于调度顺序),但不应崩溃或损坏
const content = store.getContent();
expect(['REPLACED', 'test1', 'test2'].some(s => content.includes(s))).toBe(true);
});
});
describe('MobileBeanStore - checksum替代不可靠的 content.length', () => {
it('相同内容 checksum 一致', async () => {
const s1 = createMobileStore(new MemoryBackend());
const s2 = createMobileStore(new MemoryBackend());
await s1.init(); await s2.init();
await s1.append(draft(1), ledger);
await s2.append(draft(1), ledger);
expect(s1.checksum()).toBe(s2.checksum());
});
it('不同内容 checksum 不同', async () => {
const s1 = createMobileStore(new MemoryBackend());
const s2 = createMobileStore(new MemoryBackend());
await s1.init(); await s2.init();
await s1.append(draft(1), ledger);
await s2.append(draft(2), ledger);
expect(s1.checksum()).not.toBe(s2.checksum());
});
it('checksum 不依赖内容长度(订正:原方案用 content.length', async () => {
// 长度相同但内容不同checksum 必须不同FNV-1a 对真实账本内容区分度足够)
const s1 = createMobileStore(new MemoryBackend('2026-02-01 * "甲" "咖啡"'));
const s2 = createMobileStore(new MemoryBackend('2026-02-01 * "乙" "午餐"'));
await s1.init(); await s2.init();
expect(s1.checksum()).not.toBe(s2.checksum());
});
});
describe('MobileBeanStore - 崩溃恢复', () => {
it('init 时清理上次崩溃的 tmp 残留', async () => {
const backend = new MemoryBackend('已有内容');
backend.simulateCrashMidWrite('写了 tmp 未 rename');
const store = createMobileStore(backend);
await store.init();
// 恢复后读到的是原内容tmp 被丢弃)
expect(store.getContent()).toBe('已有内容');
expect(backend.recovered).toBe(true);
});
it('无残留时正常加载', async () => {
const backend = new MemoryBackend('正常内容');
const store = createMobileStore(backend);
await store.init();
expect(store.getContent()).toBe('正常内容');
expect(backend.recovered).toBe(false);
});
});
describe('MobileBeanStore - preview不写入', () => {
it('preview 只序列化不改变内容', async () => {
const store = createMobileStore(new MemoryBackend());
await store.init();
const before = store.getContent();
const preview = store.preview(draft(1));
expect(preview).toContain('test1');
expect(store.getContent()).toBe(before); // 内容未变
});
});
describe('createMobileStore 工厂', () => {
it('返回 MobileBeanStore 实例', () => {
const store = createMobileStore(new MemoryBackend());
expect(store).toBeInstanceOf(MobileBeanStore);
});
});