diff --git a/backend/test/mpuniapp/ocr-exchange.test.ts b/backend/test/mpuniapp/ocr-exchange.test.ts new file mode 100644 index 0000000..b28e6f9 --- /dev/null +++ b/backend/test/mpuniapp/ocr-exchange.test.ts @@ -0,0 +1,416 @@ +/** + * 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 } + ) => 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 成功拍照或选图后触发名片自动填充', 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 success = await handleCardOcrUpload(form, ocrState); + + expect(success).toBe(true); + expect(form.name).toBe('张伟'); + expect(form.phone).toBe('+8613800138000'); + expect(form.company).toBe('深圳超维智能科技有限公司'); + expect(ocrState.scanning).toBe(false); + 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'); + }); + }); +}); diff --git a/mpuniapp/pages/portal/exchange.uvue b/mpuniapp/pages/portal/exchange.uvue new file mode 100644 index 0000000..ee82f05 --- /dev/null +++ b/mpuniapp/pages/portal/exchange.uvue @@ -0,0 +1,322 @@ +