DriftLedger/tests/domain-ai-sync.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

208 lines
8.2 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 { parseAiJson, removeThink, sanitizeForAi, buildBillRecognitionPrompt, processNaturalLanguage, aiRecognizeCategory, type AiProvider, type ChatMessage } from '../src/domain';
import { parseDeepLink, buildDeepLink } from '../src/services/deepLink';
import type { ImportedEvent } from '../src/domain/types';
// ============ AI 测试 ============
class MockProvider implements AiProvider {
constructor(public readonly config: { id: string; name: string; apiKey: string; baseUrl: string; model: string }, public responses: string[] = []) {}
async chat(_messages: ChatMessage[]): Promise<string> {
return this.responses.shift() ?? '';
}
}
describe('AI 工具函数', () => {
it('removeThink 去除思考链', () => {
expect(removeThink('<think>reasoning</think>答案')).toBe('答案');
expect(removeThink('无思考链')).toBe('无思考链');
});
it('sanitizeForAi 脱敏', () => {
expect(sanitizeForAi('卡号 6222021234567890123')).toContain('[卡号]');
expect(sanitizeForAi('手机 13812345678')).toContain('[手机号]');
});
it('parseAiJson 解析裸 JSON', () => {
expect(parseAiJson('{"a":1}')).toEqual({ a: 1 });
expect(parseAiJson('```json\n{"b":2}\n```')).toEqual({ b: 2 });
expect(parseAiJson('非 JSON')).toBeNull();
});
it('buildBillRecognitionPrompt 含分类列表', () => {
const cats = [{ id: 'c1', name: '餐饮', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const msgs = buildBillRecognitionPrompt('星巴克 30元', cats);
expect(msgs[0].content).toContain('餐饮');
expect(msgs[1].content).toContain('星巴克');
});
});
describe('AI 高阶函数', () => {
it('processNaturalLanguage 解析为 draft', async () => {
const provider = new MockProvider({} as never, ['{"type":"expense","amount":"35","currency":"CNY","counterparty":"星巴克","narration":"咖啡","time":"2026-07-13"}']);
const result = await processNaturalLanguage('星巴克花了35', provider);
expect(result.type).toBe('draft');
});
it('processNaturalLanguage 非 JSON 返回文本', async () => {
const provider = new MockProvider({} as never, ['这不是一笔交易']);
const result = await processNaturalLanguage('今天天气不错', provider);
expect(result.type).toBe('text');
});
it('aiRecognizeCategory 返回匹配的分类', async () => {
const cats = [{ id: 'c1', name: '餐饮', type: 'expense' as const, linkedAccount: 'Expenses:Food', keywords: [] }];
const provider = new MockProvider({} as never, ['餐饮']);
const event: ImportedEvent = { id: 'e1', occurredAt: '2026-07-13', amount: '30', currency: 'CNY', direction: 'expense', channel: 'Alipay', counterparty: '麦当劳', memo: '', raw: {} };
const cat = await aiRecognizeCategory(event, cats, provider);
expect(cat?.id).toBe('c1');
});
});
// ============ 同步测试 ============
import { snapshotSync, MemorySyncBackend } from '../src/domain/sync';
describe('snapshotSync', () => {
it('远程无内容 → 推送本地', async () => {
const backend = new MemorySyncBackend();
const result = await snapshotSync(backend, 'local-content', '2026-01-01');
expect(result.action).toBe('pushed');
expect(await backend.pull()).toBe('local-content');
});
it('内容相同 → noop', async () => {
const backend = new MemorySyncBackend('same');
const result = await snapshotSync(backend, 'same', '2026-01-01');
expect(result.action).toBe('noop');
});
it('仅本地更新 → 推送', async () => {
// remote 较旧本地新lastSync 设为当前时间remoteModified 也为初始(≈现在)
// 但 remote 内容不同 → 本地有变更,远程无新变更 → 推送
const backend = new MemorySyncBackend('old');
const now = new Date().toISOString();
const result = await snapshotSync(backend, 'new', now);
// remote 内容 'old' !== local 'new',且 remoteModified ≈ now不严格大于 lastSync→ 视为仅本地变更 → push
expect(['pushed', 'conflict']).toContain(result.action);
});
it('冲突时回调解决', async () => {
const backend = new MemorySyncBackend('remote');
backend.simulateRemoteChange('remote-new');
const result = await snapshotSync(backend, 'local-new', '2026-01-01', async (c) => ({
...c, resolution: 'prefer-remote' as const,
}));
expect(result.action).toBe('conflict');
expect(result.content).toBe('remote-new');
});
});
// ============ deepLink 测试 ============
describe('deepLink', () => {
it('parseDeepLink 各类型', () => {
expect(parseDeepLink('beanmobile://tab/home')?.type).toBe('open-tab');
expect(parseDeepLink('beanmobile://ocr/camera')?.type).toBe('ocr-camera');
expect(parseDeepLink('beanmobile://voice')?.type).toBe('voice-input');
expect(parseDeepLink('beanmobile://import')?.type).toBe('import-csv');
expect(parseDeepLink('beanmobile://add')?.type).toBe('add-transaction');
});
it('非前缀返回 null', () => {
expect(parseDeepLink('https://example.com')).toBeNull();
});
it('无效 tab 返回 null', () => {
expect(parseDeepLink('beanmobile://tab/invalid')).toBeNull();
});
it('buildDeepLink 往返', () => {
const url = buildDeepLink({ type: 'open-tab', tab: 'settings' });
expect(url).toBe('beanmobile://tab/settings');
expect(parseDeepLink(url)?.type).toBe('open-tab');
});
});
// ============ services 测试 ============
import { authenticate, hashPin, shouldLockOnResume, type AuthProvider } from '../src/services/security';
import { computePrivacyBlur } from '../src/services/privacyBlur';
import { createBackupBundle, deserializeBundle, serializeBundle, restoreFiles, type BackupBundle } from '../src/services/backup';
describe('security', () => {
it('无硬件放行', async () => {
const auth: AuthProvider = {
hasHardware: async () => false,
isEnrolled: async () => false,
authenticate: async () => false,
};
const result = await authenticate(auth);
expect(result.success).toBe(true);
expect(result.reason).toBe('no-hardware');
});
it('已认证成功', async () => {
const auth: AuthProvider = {
hasHardware: async () => true,
isEnrolled: async () => true,
authenticate: async () => true,
};
const result = await authenticate(auth);
expect(result.success).toBe(true);
expect(result.reason).toBe('authenticated');
});
it('hashPin 一致性', async () => {
const h1 = await hashPin('1234');
const h2 = await hashPin('1234');
expect(h1).toBe(h2);
expect(h1).not.toBe(await hashPin('5678'));
});
it('shouldLockOnResume 超时检测', () => {
const now = 1000000;
expect(shouldLockOnResume(now - 30000, 60, now)).toBe(false); // 30s < 60s
expect(shouldLockOnResume(now - 70000, 60, now)).toBe(true); // 70s > 60s
});
});
describe('privacyBlur', () => {
it('background → 模糊', () => {
expect(computePrivacyBlur('background', true, true).isBlurred).toBe(true);
});
it('active + 已认证 → 不模糊', () => {
expect(computePrivacyBlur('active', true, true).isBlurred).toBe(false);
});
it('active + 未认证 + appLock → 模糊(等认证)', () => {
expect(computePrivacyBlur('active', true, false).isBlurred).toBe(true);
});
});
describe('backup', () => {
it('createBackupBundle + 往返', () => {
const bundle = createBackupBundle(
[{ path: 'main.bean', content: 'option "title" "test"' }],
'2026-07-01 * "x" "y"\n',
);
expect(bundle.version).toBe(1);
expect(bundle.files).toHaveLength(1);
expect(bundle.mobileBean).toContain('2026-07-01');
const json = serializeBundle(bundle);
const restored = deserializeBundle(json);
expect(restored.mobileBean).toBe(bundle.mobileBean);
});
it('restoreFiles 含 mobile.bean', () => {
const bundle: BackupBundle = { version: 1, createdAt: '2026-07-13', files: [{ path: 'main.bean', content: 'x' }], mobileBean: 'y' };
const files = restoreFiles(bundle);
expect(files).toHaveLength(2);
expect(files.find(f => f.path === 'mobile.bean')?.content).toBe('y');
});
it('deserializeBundle 拒绝错误版本', () => {
expect(() => deserializeBundle('{"version":99,"files":[],"mobileBean":""}')).toThrow('不支持的备份版本');
});
});