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 { return this.responses.shift() ?? ''; } } describe('AI 工具函数', () => { it('removeThink 去除思考链', () => { expect(removeThink('reasoning答案')).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', 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, 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'; 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 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 超时检测', () => { 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 v2 含设置和元数据', () => { const settings = { language: 'zh', theme: 'dark' }; const metadata = { categories: [{ id: 'food', name: '餐饮' }], rules: [] }; const bundle = createBackupBundle( [{ 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.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.mainBean).toBe(bundle.mainBean); expect(restored.settings).toEqual(settings); expect(restored.metadata).toEqual(metadata); }); 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 === 'main.bean')?.content).toBe('y'); }); it('deserializeBundle 兼容 v1(无 settings/metadata)', () => { const v1 = '{"version":1,"createdAt":"2026-01-01","files":[],"mobileBean":""}'; const bundle = deserializeBundle(v1); expect(bundle.version).toBe(1); expect(bundle.settings).toBeUndefined(); expect(bundle.metadata).toBeUndefined(); }); it('deserializeBundle 拒绝错误版本', () => { expect(() => deserializeBundle('{"version":99,"files":[],"mobileBean":""}')).toThrow('不支持的备份版本'); }); });