/** * OmniCard AI (CRMmp) - 移动端拍照上传纸质名片 OCR 识别入库测试套件 (Point 25) * 严格按照 TDD 模式 (Red -> Green -> Refactor) 与 AGENTS.md 规范编写 */ type ExchangeForm = { name: string; company: string; phone: string; email: string; notes: string; }; type ExtractedContactInfo = { name: string; company: string; phone: string; email: string; title: string; notes: string; rawText: string; }; describe('Point 25: 移动端拍照上传纸质名片 OCR 识别入库 (useOcrExchange.uts)', () => { let toastCalls: Array<{ title: string; icon?: string }>; beforeEach(() => { toastCalls = []; (global as unknown as { uni?: Record }).uni = { showToast: (options: { title: string; icon?: string }) => { toastCalls.push(options); }, chooseImage: jest.fn(), }; }); afterEach(() => { delete (global as unknown as { uni?: Record }).uni; }); function loadUseOcrExchange() { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../mpuniapp/pages/portal/useOcrExchange.uts') as { parseOcrCardText: (rawOcrText: string) => ExtractedContactInfo; fillFormFromOcr: ( form: ExchangeForm, extracted: ExtractedContactInfo, overwrite?: boolean ) => ExchangeForm; normalizeE164Phone: (rawPhone: string) => string; createOcrScanState: () => { scanning: boolean; rawText: string; lastExtracted: ExtractedContactInfo | null; errorMessage: string; }; simulateOcrScan: (sampleType?: string) => ExtractedContactInfo; handleCardOcrUpload: ( form: ExchangeForm, ocrState: ReturnType extends never ? never : any, options?: { mockText?: string; httpPost?: ( url: string, data?: Record ) => Promise<{ name: string; company: string; phone: string; email: string; title: string; }>; } ) => Promise; }; } describe('1. 纯文本识别提取 (Basic Plain Text Extraction)', () => { it('1.1 提取带有标准冒号标签的名片文本', () => { const { parseOcrCardText } = loadUseOcrExchange(); const rawText = ` 姓名: 张伟 职务: 研发总监 公司: 深圳超维智能科技有限公司 电话: +86 138-0013-8000 邮箱: zhangwei@chaowei-ai.com `; const result = parseOcrCardText(rawText); expect(result.name).toBe('张伟'); expect(result.title).toBe('研发总监'); expect(result.company).toBe('深圳超维智能科技有限公司'); expect(result.phone).toBe('+8613800138000'); expect(result.email).toBe('zhangwei@chaowei-ai.com'); }); it('1.2 提取无标签前缀的英文标准商务名片', () => { const { parseOcrCardText } = loadUseOcrExchange(); const rawText = ` OmniCard AI Global Inc. Alex Morgan Chief Technology Officer alex.morgan@omnicard.ai +1 555-234-5678 `; const result = parseOcrCardText(rawText); expect(result.name).toBe('Alex Morgan'); expect(result.title).toBe('Chief Technology Officer'); expect(result.company).toBe('OmniCard AI Global Inc.'); expect(result.email).toBe('alex.morgan@omnicard.ai'); expect(result.phone).toBe('+15552345678'); }); }); describe('2. 国际标准 E.164 电话与多格式邮箱提取', () => { it('2.1 正确识别与规范化中国、阿联酋、英国、美国等国际 E.164 电话', () => { const { normalizeE164Phone, parseOcrCardText } = loadUseOcrExchange(); // 单独格式化函数验证 expect(normalizeE164Phone('+86 138 0013 8000')).toBe('+8613800138000'); expect(normalizeE164Phone('+971 50 123 4567')).toBe('+971501234567'); expect(normalizeE164Phone('+1 (555) 234-5678')).toBe('+15552345678'); expect(normalizeE164Phone('+44 20 7946 0919')).toBe('+442079460919'); expect(normalizeE164Phone('0086 13800138000')).toBe('+8613800138000'); expect(normalizeE164Phone('13912345678')).toBe('+8613912345678'); // 从 OCR 文本中识别中东阿联酋电话 const uaeCard = ` Tariq Mansour Dubai Trade Center LLC WhatsApp: +971 50 123 4567 tariq@dubaitrade.ae `; const uaeResult = parseOcrCardText(uaeCard); expect(uaeResult.phone).toBe('+971501234567'); expect(uaeResult.email).toBe('tariq@dubaitrade.ae'); }); it('2.2 提取多级域名及包含加号/下划线的复杂邮箱地址', () => { const { parseOcrCardText } = loadUseOcrExchange(); const rawText = ` Contact person: Sarah Connor Organization: Cyberdyne Systems UK Ltd. E-mail: sarah.connor+biz_dev@corp.subdomain.co.uk Mobile: +44 7700 900123 `; const result = parseOcrCardText(rawText); expect(result.email).toBe('sarah.connor+biz_dev@corp.subdomain.co.uk'); expect(result.name).toBe('Sarah Connor'); expect(result.company).toBe('Cyberdyne Systems UK Ltd.'); expect(result.phone).toBe('+447700900123'); }); }); describe('3. 复杂多行格式、乱序与噪音干扰提取', () => { it('3.1 识别同一行内的姓名与职位组合', () => { const { parseOcrCardText } = loadUseOcrExchange(); const rawText = ` 杭州智算科技有限公司 李小峰 / 首席架构师 Tel: +86-571-88889999 Email: lxf@zhisuan.cn `; const result = parseOcrCardText(rawText); expect(result.name).toBe('李小峰'); expect(result.title).toBe('首席架构师'); expect(result.company).toBe('杭州智算科技有限公司'); expect(result.email).toBe('lxf@zhisuan.cn'); }); it('3.2 乱序排版及含有地址、网址、标语等干扰信息时准确定位核心字段', () => { const { parseOcrCardText } = loadUseOcrExchange(); const messyCard = ` www.futurecloud.io service@futurecloud.io 地址:北京市海淀区中关村南大街1号科技大厦B座18层 致力于打造全球顶尖分布式智算基础设施 +86 186 1111 2222 王建国 VP of Operations 北京未来云智科技有限公司 `; const result = parseOcrCardText(messyCard); expect(result.name).toBe('王建国'); expect(result.title).toBe('VP of Operations'); expect(result.company).toBe('北京未来云智科技有限公司'); expect(result.email).toBe('service@futurecloud.io'); expect(result.phone).toBe('+8618611112222'); }); }); describe('4. 空文本与极端异常容错 (Resilience & Edge Cases)', () => { it('4.1 传入空文本或空白字符时返回安全的空字段结构且不报错', () => { const { parseOcrCardText } = loadUseOcrExchange(); const emptyResult = parseOcrCardText(''); expect(emptyResult).toEqual({ name: '', company: '', phone: '', email: '', title: '', notes: '', rawText: '', }); const whitespaceResult = parseOcrCardText(' \n\t \r\n '); expect(whitespaceResult.name).toBe(''); expect(whitespaceResult.company).toBe(''); expect(whitespaceResult.phone).toBe(''); expect(whitespaceResult.email).toBe(''); }); it('4.2 传入无任何有效联系信息的文本时安全容错', () => { const { parseOcrCardText } = loadUseOcrExchange(); const gibberish = '### !@@$ \n --- \n 12345 \n ?????'; const result = parseOcrCardText(gibberish); expect(result.name).toBe(''); expect(result.company).toBe(''); expect(result.phone).toBe(''); expect(result.email).toBe(''); }); }); describe('5. fillFormFromOcr 智能填单与缺失字段保护', () => { it('5.1 完整信息直接填充空表单', () => { const { fillFormFromOcr } = loadUseOcrExchange(); const form: ExchangeForm = { name: '', company: '', phone: '', email: '', notes: '', }; const extracted: ExtractedContactInfo = { name: '张三', company: '极客互联', phone: '+8613800138000', email: 'zhangsan@geek.com', title: '产品总监', notes: '', rawText: '...', }; fillFormFromOcr(form, extracted); expect(form.name).toBe('张三'); expect(form.company).toBe('极客互联'); expect(form.phone).toBe('+8613800138000'); expect(form.email).toBe('zhangsan@geek.com'); // 若原 notes 为空且 title 存在,智能将 title 放入 notes 方便跟进 expect(form.notes).toBe('产品总监'); }); it('5.2 缺失字段保护:OCR 缺失的字段绝不覆盖已有用户输入', () => { const { fillFormFromOcr } = loadUseOcrExchange(); const form: ExchangeForm = { name: '', company: '已有预填公司', phone: '', email: '', notes: '已有重要客户需求备注', }; // OCR 仅识别到了姓名与手机号 const extracted: ExtractedContactInfo = { name: '李四', company: '', phone: '+8613900001111', email: '', title: '', notes: '', rawText: '...', }; fillFormFromOcr(form, extracted); expect(form.name).toBe('李四'); expect(form.phone).toBe('+8613900001111'); // 原有非空内容受到保护,未被清空 expect(form.company).toBe('已有预填公司'); expect(form.notes).toBe('已有重要客户需求备注'); }); it('5.3 非覆盖模式 (overwrite: false) 仅填充空白字段', () => { const { fillFormFromOcr } = loadUseOcrExchange(); const form: ExchangeForm = { name: '手动输入的姓名', company: '', phone: '+8613777777777', email: '', notes: '', }; const extracted: ExtractedContactInfo = { name: 'OCR识别出的新姓名', company: 'OCR公司', phone: '+8613888888888', email: 'new@corp.com', title: '', notes: '', rawText: '...', }; fillFormFromOcr(form, extracted, false); // 手动输入的姓名与电话保持原样 expect(form.name).toBe('手动输入的姓名'); expect(form.phone).toBe('+8613777777777'); // 原为空的字段被填入 expect(form.company).toBe('OCR公司'); expect(form.email).toBe('new@corp.com'); }); }); describe('6. 客户端拍照上传流程辅助与模拟 (handleCardOcrUpload)', () => { it('6.1 模拟名片识别成功后自动填充表单并提示用户', async () => { const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange(); const form: ExchangeForm = { name: '', company: '', phone: '', email: '', notes: '', }; const ocrState = createOcrScanState(); const mockText = ` Name: Tariq Mansour Company: Al Futtaim Group LLC Phone: +971 50 123 4567 Email: tariq.mansour@alfuttaim.ae `; const success = await handleCardOcrUpload(form, ocrState, { mockText }); expect(success).toBe(true); expect(form.name).toBe('Tariq Mansour'); expect(form.company).toBe('Al Futtaim Group LLC'); expect(form.phone).toBe('+971501234567'); expect(form.email).toBe('tariq.mansour@alfuttaim.ae'); expect(ocrState.scanning).toBe(false); expect(ocrState.lastExtracted).not.toBeNull(); expect(toastCalls.length).toBeGreaterThan(0); expect(toastCalls[0].title).toContain('识别成功'); }); it('6.2 通过 chooseImage 成功选图后请求 /open/ai/ocr/recognize 并填表', async () => { const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange(); const form: ExchangeForm = { name: '', company: '', phone: '', email: '', notes: '', }; const ocrState = createOcrScanState(); (global as unknown as { uni: Record }).uni.chooseImage = (opt: { success?: (res: unknown) => void; fail?: (err: unknown) => void; }) => { opt.success?.({ tempFilePaths: ['temp://photo.jpg'] }); }; const httpPost = jest.fn().mockResolvedValue({ name: '赵敏', company: '星河智联科技有限公司', phone: '+8613911112222', email: 'zhaomin@xinghe.ai', title: '商务经理', }); const success = await handleCardOcrUpload(form, ocrState, { httpPost }); expect(success).toBe(true); expect(httpPost).toHaveBeenCalledTimes(1); expect(httpPost.mock.calls[0][0]).toBe('/open/ai/ocr/recognize'); const body = httpPost.mock.calls[0][1] as Record; expect( (body.imageBase64 != null && body.imageBase64 !== '') || (body.imageUrl != null && body.imageUrl !== '') ).toBe(true); expect(form.name).toBe('赵敏'); expect(form.phone).toBe('+8613911112222'); expect(form.company).toBe('星河智联科技有限公司'); expect(form.email).toBe('zhaomin@xinghe.ai'); expect(form.notes).toBe('商务经理'); expect(form.name).not.toBe('张伟'); expect(ocrState.scanning).toBe(false); expect(ocrState.errorMessage).toBe(''); expect(toastCalls.length).toBeGreaterThan(0); }); it('6.3 用户取消拍照选图时优雅处理并不覆盖已有表单', async () => { const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange(); const form: ExchangeForm = { name: '已有姓名', company: '', phone: '', email: '', notes: '', }; const ocrState = createOcrScanState(); (global as unknown as { uni: Record }).uni.chooseImage = (opt: { success?: (res: unknown) => void; fail?: (err: unknown) => void; }) => { opt.fail?.(new Error('用户取消操作')); }; const success = await handleCardOcrUpload(form, ocrState); expect(success).toBe(false); expect(form.name).toBe('已有姓名'); expect(ocrState.scanning).toBe(false); expect(ocrState.errorMessage).toContain('用户取消操作'); }); it('6.4 simulateOcrScan 针对国内外商务预设名片准确提取', () => { const { simulateOcrScan } = loadUseOcrExchange(); const uaeSample = simulateOcrScan('uae'); expect(uaeSample.name).toBe('Tariq Mansour'); expect(uaeSample.phone).toBe('+971501234567'); expect(uaeSample.company).toBe('Dubai Trade Center LLC'); const globalSample = simulateOcrScan('global'); expect(globalSample.name).toBe('Alex Morgan'); expect(globalSample.phone).toBe('+15552345678'); expect(globalSample.company).toBe('OmniCard AI Global Inc.'); const defaultSample = simulateOcrScan(); expect(defaultSample.name).toBe('张伟'); expect(defaultSample.phone).toBe('+8613800138000'); }); it('6.5 OCR 接口失败或未配置时写入 errorMessage 且不用样例填表', async () => { const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange(); const form: ExchangeForm = { name: '已有姓名', company: '', phone: '', email: '', notes: '', }; const ocrState = createOcrScanState(); (global as unknown as { uni: Record }).uni.chooseImage = (opt: { success?: (res: unknown) => void; fail?: (err: unknown) => void; }) => { opt.success?.({ tempFilePaths: ['temp://photo.jpg'] }); }; const httpPost = jest .fn() .mockRejectedValue(new Error('未配置 OCR 服务(OCR_API_KEY / OCR_BASE_URL)')); const success = await handleCardOcrUpload(form, ocrState, { httpPost }); expect(success).toBe(false); expect(httpPost).toHaveBeenCalledWith( '/open/ai/ocr/recognize', expect.any(Object) ); expect(form.name).toBe('已有姓名'); expect(form.name).not.toBe('张伟'); expect(form.company).toBe(''); expect(form.phone).toBe(''); expect(ocrState.scanning).toBe(false); expect(ocrState.errorMessage).toContain('未配置'); expect(ocrState.lastExtracted).toBeNull(); }); }); });