- 切换到 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+ 单元测试覆盖核心逻辑
152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { mergeMobileBean, MockGitBackend, syncWithGit, type GitConfig } from '../src/services/sync/gitSync';
|
|
import { WebDAVClient, MockFetch, syncWithWebDAV, backupToWebDAV, type WebDAVConfig } from '../src/services/sync/webdavSync';
|
|
import { getMonthGrid } from '../src/domain/calendarGrid';
|
|
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,
|
|
});
|
|
|
|
// ============ gitSync ============
|
|
|
|
describe('gitSync', () => {
|
|
const config: GitConfig = { remoteUrl: 'https://github.com/x/y.git', branch: 'main', autoSync: true, syncIntervalMin: 30 };
|
|
|
|
it('内容相同时 noop', async () => {
|
|
const backend = new MockGitBackend('same-content');
|
|
const result = await syncWithGit(backend, config, 'same-content');
|
|
expect(result.action).toBe('noop');
|
|
});
|
|
|
|
it('本地有新增内容时 pushed', async () => {
|
|
const backend = new MockGitBackend('remote-content');
|
|
const result = await syncWithGit(backend, config, 'remote-content\n2026-07-13 * "新增"');
|
|
expect(result.action).toBe('pushed');
|
|
expect(backend.pushed).toBe(true);
|
|
});
|
|
|
|
it('mergeMobileBean union 合并', () => {
|
|
const local = '2026-07-01 * "a"\n A 1 CNY\n B -1 CNY';
|
|
const remote = '2026-07-02 * "b"\n C 2 CNY\n D -2 CNY';
|
|
const result = mergeMobileBean(local, remote);
|
|
expect(result.content).toContain('"a"');
|
|
expect(result.content).toContain('"b"');
|
|
expect(result.conflicts).toHaveLength(0);
|
|
});
|
|
|
|
it('mergeMobileBean 相同内容无变化', () => {
|
|
const content = 'same';
|
|
expect(mergeMobileBean(content, content).content).toBe('same');
|
|
});
|
|
});
|
|
|
|
// ============ webdavSync ============
|
|
|
|
describe('webdavSync', () => {
|
|
const config: WebDAVConfig = {
|
|
url: 'https://dav.example.com/beancount/',
|
|
username: 'user', password: 'pass',
|
|
remotePath: '/mobile.bean', backupKeepCount: 3,
|
|
};
|
|
|
|
it('首次(远程无文件)→ pushed', async () => {
|
|
const mockFetch = new MockFetch();
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const result = await syncWithWebDAV(client, 'local-content', '2026-01-01');
|
|
expect(result.action).toBe('pushed');
|
|
});
|
|
|
|
it('内容相同 → noop', async () => {
|
|
const mockFetch = new MockFetch();
|
|
mockFetch.setFile('/mobile.bean', 'same');
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const result = await syncWithWebDAV(client, 'same', '2026-01-01');
|
|
expect(result.action).toBe('noop');
|
|
});
|
|
|
|
it('远程有更新 → conflict', async () => {
|
|
const mockFetch = new MockFetch();
|
|
mockFetch.setFile('/mobile.bean', 'remote-new', '2026-07-14T00:00:00');
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const result = await syncWithWebDAV(client, 'local-new', '2026-01-01');
|
|
expect(result.action).toBe('conflict');
|
|
expect(result.conflict).toBeDefined();
|
|
});
|
|
|
|
it('仅本地变更 → pushed', async () => {
|
|
const mockFetch = new MockFetch();
|
|
mockFetch.setFile('/mobile.bean', 'old', '2020-01-01');
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const result = await syncWithWebDAV(client, 'new-local', '2026-07-14T00:00:00');
|
|
expect(result.action).toBe('pushed');
|
|
});
|
|
|
|
it('backupToWebDAV 上传备份', async () => {
|
|
const mockFetch = new MockFetch();
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const backupPath = await backupToWebDAV(client, 'backup-content', config);
|
|
expect(backupPath).toContain('backup-');
|
|
});
|
|
|
|
it('WebDAVClient stat 获取 lastModified', async () => {
|
|
const mockFetch = new MockFetch();
|
|
mockFetch.setFile('/mobile.bean', 'content', '2026-07-13');
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const info = await client.stat();
|
|
expect(info.exists).toBe(true);
|
|
});
|
|
|
|
it('WebDAVClient get 下载', async () => {
|
|
const mockFetch = new MockFetch();
|
|
mockFetch.setFile('/mobile.bean', 'downloaded');
|
|
const client = new WebDAVClient(config, mockFetch.fetch);
|
|
const content = await client.get();
|
|
expect(content).toBe('downloaded');
|
|
});
|
|
});
|
|
|
|
// ============ CalendarView getMonthGrid ============
|
|
|
|
describe('CalendarView getMonthGrid', () => {
|
|
const transactions: Transaction[] = [
|
|
tx({ id: 't1', date: '2026-07-05', postings: [{ account: 'Assets:A', amount: '-10', currency: 'CNY' }, { account: 'Expenses:Food', amount: '10', currency: 'CNY' }] }),
|
|
tx({ id: 't2', date: '2026-07-10', postings: [{ account: 'Assets:A', amount: '100', currency: 'CNY' }, { account: 'Income:Salary', amount: '-100', currency: 'CNY' }] }),
|
|
tx({ id: 't3', date: '2026-07-15', postings: [{ account: 'Assets:A', amount: '-5', currency: 'CNY' }, { account: 'Expenses:Food', amount: '5', currency: 'CNY' }] }),
|
|
];
|
|
|
|
it('2026年7月日历网格', () => {
|
|
const weeks = getMonthGrid(2026, 7, transactions);
|
|
// 至少 5 周
|
|
expect(weeks.length).toBeGreaterThanOrEqual(5);
|
|
// 每周 7 天
|
|
for (const week of weeks) {
|
|
expect(week).toHaveLength(7);
|
|
}
|
|
});
|
|
|
|
it('有交易的日期标记 income/expense', () => {
|
|
const weeks = getMonthGrid(2026, 7, transactions);
|
|
const allCells = weeks.flat();
|
|
const day5 = allCells.find(c => c.day === 5 && c.isCurrentMonth);
|
|
expect(day5?.hasExpense).toBe(true);
|
|
expect(day5?.count).toBe(1);
|
|
const day10 = allCells.find(c => c.day === 10 && c.isCurrentMonth);
|
|
expect(day10?.hasIncome).toBe(true);
|
|
});
|
|
|
|
it('日期格式为 YYYY-MM-DD', () => {
|
|
const weeks = getMonthGrid(2026, 7, transactions);
|
|
const cell = weeks.flat().find(c => c.day === 1 && c.isCurrentMonth);
|
|
expect(cell?.date).toBe('2026-07-01');
|
|
});
|
|
|
|
it('上下月填充标记 isCurrentMonth=false', () => {
|
|
const weeks = getMonthGrid(2026, 7, transactions);
|
|
const firstCell = weeks[0][0];
|
|
// 2026-07-01 是周三,前 3 天是 6 月
|
|
expect(firstCell.isCurrentMonth).toBe(false);
|
|
});
|
|
});
|