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

255 lines
9.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, beforeEach } from 'vitest';
import { NativeOcrBridge, SimulatedOcrBridge, type NativeOcrModule } from '../src/services/ocrBridge';
import { ScreenshotChannel, SimulatedScreenshotListener, type ScreenshotEvent } from '../src/services/screenshot';
import { NotificationChannel, SimulatedNotificationListener, parseNotification, DEFAULT_PAYMENT_PACKAGES, type NotificationEvent } from '../src/services/notification';
import { SmsChannel, SimulatedSmsListener, parseSms, isBankSms, type SmsEvent } from '../src/services/sms';
import { OcrProcessor, MockOcrEngine } from '../src/domain/ocrProcessor';
beforeEach(() => {
// 各测试自建实例,无需全局重置
});
// ============ ocrBridge ============
describe('ocrBridge', () => {
it('NativeOcrBridge 用原生模块', async () => {
const native: NativeOcrModule = {
recognizeText: async () => '微信支付 金额 10 商户:x',
recognizeTextBlocks: async () => [],
isReady: async () => true,
};
const bridge = new NativeOcrBridge(native);
expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x');
});
it('原生失败时降级到 fallback', async () => {
const native: NativeOcrModule = {
recognizeText: async () => { throw new Error('原生崩溃'); },
recognizeTextBlocks: async () => [],
isReady: async () => false,
};
const fallback = new SimulatedOcrBridge();
fallback.preset('fallback', '降级文本');
const bridge = new NativeOcrBridge(native, fallback);
expect(await bridge.recognizeText('fallback')).toBe('降级文本');
});
it('无原生无 fallback 抛错', async () => {
const bridge = new NativeOcrBridge(null);
await expect(bridge.recognizeText('img')).rejects.toThrow('未加载');
});
it('SimulatedOcrBridge 按 key 返回预设', async () => {
const sim = new SimulatedOcrBridge();
sim.preset('wechat-screenshot', '微信支付 金额 20');
expect(await sim.recognizeText('wechat-screenshot')).toContain('微信支付');
expect(await sim.recognizeText('unknown')).toBe('');
});
});
// ============ screenshot ============
describe('ScreenshotChannel', () => {
it('处理截图 → OCR 识别成功', async () => {
const ocrEngine = new MockOcrEngine();
ocrEngine.defaultResponse = '微信支付 金额 24.50 商户:星巴克 2026-07-13 14:30';
const processor = new OcrProcessor(ocrEngine);
const channel = new ScreenshotChannel(processor);
const event: ScreenshotEvent = {
uri: 'content://media/screenshots/screenshot_123.png',
base64: 'img-data',
timestamp: Date.now(),
};
const result = await channel.handleScreenshot(event);
expect(result.layer).toBe('layer1-rule');
expect(result.event?.amount).toBe('-24.5');
});
it('去重:同一 URI 不重复处理', async () => {
const ocrEngine = new MockOcrEngine();
ocrEngine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(ocrEngine);
const channel = new ScreenshotChannel(processor);
const event: ScreenshotEvent = {
uri: 'content://media/screenshots/dup.png',
base64: 'img',
timestamp: Date.now(),
};
const r1 = await channel.handleScreenshot(event);
const r2 = await channel.handleScreenshot(event);
expect(r1.layer).toBe('layer1-rule');
expect(r2.skipped).toBe('dedup');
});
it('非截图 URI 过滤', async () => {
const processor = new OcrProcessor(new MockOcrEngine());
const channel = new ScreenshotChannel(processor);
const result = await channel.handleScreenshot({
uri: 'content://media/photos/normal.jpg',
base64: 'img',
timestamp: Date.now(),
});
expect(result.skipped).toBe('dedup');
});
it('超过 30 秒的旧截图过滤', async () => {
const processor = new OcrProcessor(new MockOcrEngine());
const channel = new ScreenshotChannel(processor);
const result = await channel.handleScreenshot({
uri: 'content://screenshots/old.png',
base64: 'img',
timestamp: Date.now() - 60_000,
});
expect(result.skipped).toBe('dedup');
});
it('onScreenshot 回调触发', async () => {
const ocrEngine = new MockOcrEngine();
ocrEngine.defaultResponse = '微信支付 金额 10 商户:x';
const processor = new OcrProcessor(ocrEngine);
const channel = new ScreenshotChannel(processor);
let callbackResult: string | null = null;
channel.onScreenshot((result) => { callbackResult = result.event?.amount ?? null; });
await channel.handleScreenshot({ uri: 'content://screenshots/x.png', base64: 'img', timestamp: Date.now() });
expect(callbackResult).toBe('-10');
});
it('SimulatedScreenshotListener start/stop/simulate', async () => {
const listener = new SimulatedScreenshotListener();
const events: ScreenshotEvent[] = [];
await listener.start(e => events.push(e));
expect(listener.isListening()).toBe(true);
listener.simulate({ uri: 'x', timestamp: 0 });
expect(events).toHaveLength(1);
await listener.stop();
expect(listener.isListening()).toBe(false);
});
});
// ============ notification ============
describe('NotificationChannel', () => {
it('parseNotification 微信支付', () => {
const event: NotificationEvent = {
packageName: 'com.tencent.mm', title: '微信支付',
text: '金额 24.50 商户:星巴克', timestamp: Date.now(),
};
const bill = parseNotification(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-24.5');
});
it('白名单过滤:非支付 App', () => {
const channel = new NotificationChannel();
const result = channel.handleNotification({
packageName: 'com.other.app', title: '通知', text: '内容', timestamp: Date.now(),
});
expect(result.processed).toBe(false);
expect(result.reason).toBe('非白名单包');
});
it('白名单内 + 命中规则', () => {
const channel = new NotificationChannel();
const result = channel.handleNotification({
packageName: 'com.tencent.mm', title: '微信支付', text: '金额 10 商户:x', timestamp: Date.now(),
});
expect(result.processed).toBe(true);
});
it('MD5 去重', () => {
const channel = new NotificationChannel();
const event: NotificationEvent = {
packageName: 'com.tencent.mm', title: '微信支付', text: '金额 10 商户:x', timestamp: Date.now(),
};
const r1 = channel.handleNotification(event);
const r2 = channel.handleNotification(event);
expect(r1.processed).toBe(true);
expect(r2.reason).toBe('MD5 去重');
});
it('onBillDetected 回调', () => {
const channel = new NotificationChannel();
let detected = false;
channel.onBillDetected(() => { detected = true; });
channel.handleNotification({
packageName: 'com.tencent.mm', title: '微信支付', text: '金额 10 商户:x', timestamp: Date.now(),
});
expect(detected).toBe(true);
});
it('DEFAULT_PAYMENT_PACKAGES 含微信支付宝', () => {
expect(DEFAULT_PAYMENT_PACKAGES).toContain('com.tencent.mm');
expect(DEFAULT_PAYMENT_PACKAGES).toContain('com.eg.android.AlipayGphone');
});
});
// ============ sms ============
describe('SmsChannel', () => {
it('parseSms 银行短信', () => {
const event: SmsEvent = {
sender: '95588', body: '工商银行尾号1234于超市消费100.50元余额5000元',
timestamp: Date.now(),
};
const bill = parseSms(event);
expect(bill).not.toBeNull();
expect(bill?.amount).toBe('-100.5');
expect(bill?.channel).toBe('Bank:1234');
});
it('parseSms 退款', () => {
const event: SmsEvent = {
sender: '95533', body: '建设银行尾号5678退款30元',
timestamp: Date.now(),
};
const bill = parseSms(event);
expect(bill?.direction).toBe('refund');
});
it('parseSms 无金额返回 null', () => {
const bill = parseSms({ sender: '95588', body: '余额提醒', timestamp: 0 });
expect(bill).toBeNull();
});
it('isBankSms 判断', () => {
expect(isBankSms('您有一笔交易')).toBe(true);
expect(isBankSms('验证码是1234')).toBe(false);
});
it('SmsChannel 非银行短信过滤', () => {
const channel = new SmsChannel();
const result = channel.handleSms({ sender: '10086', body: '话费提醒', timestamp: 0 });
expect(result.processed).toBe(false);
expect(result.reason).toBe('非银行短信');
});
it('SmsChannel 银行短信处理成功', () => {
const channel = new SmsChannel();
const result = channel.handleSms({
sender: '95588', body: '尾号1234消费100元', timestamp: Date.now(),
});
expect(result.processed).toBe(true);
});
it('SmsChannel MD5 去重', () => {
const channel = new SmsChannel();
const event: SmsEvent = { sender: '95588', body: '尾号1234消费100元', timestamp: 0 };
const r1 = channel.handleSms(event);
const r2 = channel.handleSms(event);
expect(r1.processed).toBe(true);
expect(r2.reason).toBe('MD5 去重');
});
it('SimulatedSmsListener simulate', async () => {
const listener = new SimulatedSmsListener();
const events: SmsEvent[] = [];
await listener.start(e => events.push(e));
listener.simulate({ sender: 'x', body: 'y', timestamp: 0 });
expect(events).toHaveLength(1);
await listener.stop();
});
});