/** * OmniCard AI (CRMmp) - 名片主潜客详情与跟进备忘测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/leads-detail 设计编写 */ import * as fs from 'fs'; import * as path from 'path'; const DETAIL_PAGE = path.resolve( __dirname, '../../../mpuniapp/pages/owner/leads-detail.uvue' ); const DETAIL_UTS = path.resolve( __dirname, '../../../mpuniapp/pages/owner/useLeadDetail.uts' ); type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived'; type LeadEntity = { id: string | number; cardId: string | number; visitorId?: string; name: string; company?: string; status: LeadStatus; intentScore: number; source: 'nfc' | 'qr' | 'link' | 'ai_chat'; notes?: string; }; type RadarTrack = { id: string | number; cardId: string | number; visitorId: string; touchType: 'nfc' | 'qr' | 'link'; stayDurationMs: number; scrollDepth: number; viewedBlocks: string[]; country?: string; city?: string; createdAt?: string | Date; }; type OwnerPageQuery = { page: number; size: number; keyWord?: string; cardId?: string | number; }; type OwnerPageData = { list: T[]; pagination: { page: number; size: number; total: number; }; }; type LoadDetailApi = { fetchOwnerLeadInfo?: (id: string | number) => Promise; fetchOwnerRadarPage?: (query: OwnerPageQuery) => Promise>; }; type LoadDetailResult = { lead: LeadEntity | null; tracks: RadarTrack[]; empty: boolean; errorMessage: string; }; type SaveNoteApi = { httpPost?: ( url: string, data: Record ) => Promise; }; type UseLeadDetailModule = { parseLeadId: (raw: string | number | null | undefined) => string | number | null; loadDetail: ( id: string | number | null | undefined, api?: LoadDetailApi ) => Promise; appendNote: ( existing: string | null | undefined, next: string | null | undefined ) => string; appendStructuredNote: ( existing: string | null | undefined, next: string | null | undefined, attachments?: string[] ) => string; parseNoteAttachments: (rawNote: string | null | undefined) => { cleanText: string; imageUrls: string[]; }; formatWhatsAppUrl: (phone: string | null | undefined) => string; callLeadPhone: (phone: string | null | undefined) => boolean; openLeadWhatsApp: (phoneOrWhatsApp: string | null | undefined) => boolean; createLeadDetailState?: () => Record; generateAiIcebreaker: ( lead: LeadEntity | null, tracks: RadarTrack[], locale?: string ) => string; saveNote: ( input: { id: string | number; notes?: string; next: string; status?: LeadStatus; attachments?: string[]; }, api?: SaveNoteApi ) => Promise<{ ok: boolean; code?: number; lead: LeadEntity | null; errorMessage: string; }>; resolveLeadDetailText: (key: string) => string; }; const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; function buildLead(overrides?: Partial): LeadEntity { return { id: 21, cardId: 8, visitorId: 'vis_ae_1201', name: 'Alex Vance', company: 'Nexus Tech', status: 'hot', intentScore: 91, source: 'nfc', notes: 'Met at GITEX', ...overrides, }; } function buildTrack(overrides?: Partial): RadarTrack { return { id: 81, cardId: 8, visitorId: 'vis_ae_1201', touchType: 'nfc', stayDurationMs: 45000, scrollDepth: 72, viewedBlocks: ['hero', 'case'], country: 'United Arab Emirates', city: 'Dubai', createdAt: '2026-09-20T10:00:00.000Z', ...overrides, }; } function loadUseLeadDetail(): UseLeadDetailModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require(DETAIL_UTS) as UseLeadDetailModule; } describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', () => { let showToastMock: jest.Mock; let makePhoneCallMock: jest.Mock; let setClipboardDataMock: jest.Mock; let openUrlCalls: string[]; beforeEach(() => { jest.resetModules(); showToastMock = jest.fn(); makePhoneCallMock = jest.fn(); setClipboardDataMock = jest.fn( (options: { data: string; success?: () => void }) => { options.success?.(); } ); openUrlCalls = []; (global as { uni?: Record }).uni = { getLocale: () => 'zh-CN', showToast: showToastMock, makePhoneCall: makePhoneCallMock, setClipboardData: setClipboardDataMock, navigateBack: jest.fn(), navigateTo: jest.fn(), }; (global as { window?: { open?: (url: string, target?: string) => void } }).window = { open: (url: string, _target?: string) => { openUrlCalls.push(url); }, }; }); afterEach(() => { delete (global as { uni?: Record }).uni; delete (global as { window?: unknown }).window; }); describe('1. parseLeadId', () => { it('1.1 正整数与数字字符串解析为 id,非法值丢弃', () => { const { parseLeadId } = loadUseLeadDetail(); expect(parseLeadId(21)).toBe(21); expect(parseLeadId('21')).toBe(21); expect(parseLeadId(' 21 ')).toBe(21); expect(parseLeadId('')).toBeNull(); expect(parseLeadId('0')).toBeNull(); expect(parseLeadId(-3)).toBeNull(); expect(parseLeadId('abc')).toBeNull(); expect(parseLeadId(null)).toBeNull(); expect(parseLeadId(undefined)).toBeNull(); }); }); describe('2. loadDetail 调 info + radar', () => { it('2.1 注入后先拉 info,再按 visitorId / cardId 拉雷达分页', async () => { const { loadDetail } = loadUseLeadDetail(); const lead = buildLead(); const track = buildTrack(); const fetchOwnerLeadInfo = jest.fn(async (id: string | number) => { expect(id).toBe(21); return lead; }); const fetchOwnerRadarPage = jest.fn(async (query: OwnerPageQuery) => { expect(query.keyWord).toBe('vis_ae_1201'); expect(query.cardId).toBe(8); expect(query.page).toBe(1); expect(query.size).toBe(20); return { list: [track], pagination: { page: 1, size: 20, total: 1 }, }; }); const result = await loadDetail(21, { fetchOwnerLeadInfo, fetchOwnerRadarPage, }); expect(fetchOwnerLeadInfo).toHaveBeenCalledTimes(1); expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1); expect(result.lead).not.toBeNull(); expect(result.lead?.name).toBe('Alex Vance'); expect(result.lead?.intentScore).toBe(91); expect(result.tracks).toHaveLength(1); expect(result.tracks[0].visitorId).toBe('vis_ae_1201'); expect(result.empty).toBe(false); expect(result.errorMessage).toBe(''); }); it('2.2 缺 id 不请求接口,标记空态', async () => { const { loadDetail } = loadUseLeadDetail(); const fetchOwnerLeadInfo = jest.fn(async () => buildLead()); const fetchOwnerRadarPage = jest.fn(async () => ({ list: [], pagination: { page: 1, size: 20, total: 0 }, })); const result = await loadDetail('', { fetchOwnerLeadInfo, fetchOwnerRadarPage, }); expect(fetchOwnerLeadInfo).not.toHaveBeenCalled(); expect(fetchOwnerRadarPage).not.toHaveBeenCalled(); expect(result.lead).toBeNull(); expect(result.tracks).toEqual([]); expect(result.empty).toBe(true); }); }); describe('3. appendNote 不覆盖', () => { it('3.1 新备忘拼到已有内容后面,空值不抹掉旧备忘', () => { const { appendNote } = loadUseLeadDetail(); expect(appendNote('Met at GITEX', 'Asked for quote')).toBe( 'Met at GITEX\nAsked for quote' ); expect(appendNote('Met at GITEX', '')).toBe('Met at GITEX'); expect(appendNote('Met at GITEX', null)).toBe('Met at GITEX'); expect(appendNote('', 'Asked for quote')).toBe('Asked for quote'); expect(appendNote(null, 'Asked for quote')).toBe('Asked for quote'); expect(appendNote(undefined, undefined)).toBe(''); }); it('3.2 saveNote 走 /admin/crm/lead/update 只写拼接后的 notes', async () => { const { saveNote } = loadUseLeadDetail(); const saved = buildLead({ notes: 'Met at GITEX\nAsked for quote' }); const httpPost = jest.fn( async (url: string, data: Record) => { expect(url).toBe('/admin/crm/lead/update'); expect(data.id).toBe(21); expect(data.notes).toBe('Met at GITEX\nAsked for quote'); return saved; } ); const result = await saveNote( { id: 21, notes: 'Met at GITEX', next: 'Asked for quote', status: 'hot' }, { httpPost } ); expect(httpPost).toHaveBeenCalledTimes(1); expect(result.ok).toBe(true); expect(result.code).toBe(1000); expect(result.lead?.notes).toBe('Met at GITEX\nAsked for quote'); expect(result.lead?.status).toBe('hot'); }); it('3.3 updateLeadStage 调用 updateOwnerLeadStatus 推进商机阶段', async () => { const { updateLeadStage } = loadUseLeadDetail() as UseLeadDetailModule & { updateLeadStage: ( state: Record, newStatus: LeadStatus, api?: SaveNoteApi & { updateOwnerLeadStatus?: ( id: string | number, status: LeadStatus ) => Promise; } ) => Promise; }; const lead = buildLead({ status: 'new' }); const state: Record = { leadId: 21, lead, status: 'new', saving: false, errorMessage: '', }; const updateOwnerLeadStatus = jest.fn( async (id: string | number, status: LeadStatus) => { expect(id).toBe(21); expect(status).toBe('hot'); return buildLead({ id, status }); } ); const ok = await updateLeadStage(state, 'hot', { updateOwnerLeadStatus }); expect(ok).toBe(true); expect(state.status).toBe('hot'); expect(state.saving).toBe(false); expect(updateOwnerLeadStatus).toHaveBeenCalledTimes(1); }); it('3.4 submitLeadNote 在 nextNote 为空但有 attachments 时仍可提交附件', async () => { const { submitLeadNote } = loadUseLeadDetail() as UseLeadDetailModule & { submitLeadNote: ( state: Record, api?: SaveNoteApi ) => Promise; }; const httpPost = jest.fn( async (url: string, data: Record) => { expect(url).toBe('/admin/crm/lead/update'); expect(data.id).toBe(21); expect(data.notes).toContain('https://cdn.example.com/card.png'); return buildLead({ notes: String(data.notes) }); } ); const state: Record = { leadId: 21, existingNotes: 'Initial meeting', nextNote: '', attachments: ['https://cdn.example.com/card.png'], saving: false, errorMessage: '', }; const ok = await submitLeadNote(state, { httpPost }); expect(ok).toBe(true); expect(state.existingNotes).toContain('https://cdn.example.com/card.png'); expect(state.attachments).toEqual([]); expect(httpPost).toHaveBeenCalledTimes(1); }); it('3.5 submitLeadNote 在 nextNote 为空且无 attachments 时返回 false', async () => { const { submitLeadNote } = loadUseLeadDetail() as UseLeadDetailModule & { submitLeadNote: ( state: Record, api?: SaveNoteApi ) => Promise; }; const httpPost = jest.fn(); const state: Record = { leadId: 21, existingNotes: '', nextNote: ' ', attachments: [], saving: false, errorMessage: '', }; const ok = await submitLeadNote(state, { httpPost }); expect(ok).toBe(false); expect(httpPost).not.toHaveBeenCalled(); }); it('3.6 submitLeadNote 仅 code===1000 清空 nextNote;网络错误/非1000 保留输入', async () => { const mod = loadUseLeadDetail() as UseLeadDetailModule & { submitLeadNote: ( state: Record, api?: SaveNoteApi ) => Promise; saveNote: UseLeadDetailModule['saveNote']; }; const successPost = jest.fn( async (_url: string, data: Record) => { return buildLead({ notes: String(data.notes) }); } ); const okState: Record = { leadId: 21, lead: buildLead(), existingNotes: 'Met at GITEX', nextNote: 'Asked for quote', attachments: [], saving: false, errorMessage: '', }; const ok = await mod.submitLeadNote(okState, { httpPost: successPost }); expect(ok).toBe(true); expect(okState.nextNote).toBe(''); expect(okState.existingNotes).toContain('Asked for quote'); const failPost = jest.fn(async () => { throw new Error('网络连接异常,请重试'); }); const failState: Record = { leadId: 21, lead: buildLead(), existingNotes: 'Met at GITEX', nextNote: 'Keep this draft', attachments: ['https://cdn.example.com/shot.jpg'], saving: false, errorMessage: '', }; const failOk = await mod.submitLeadNote(failState, { httpPost: failPost }); expect(failOk).toBe(false); expect(failState.nextNote).toBe('Keep this draft'); expect(failState.attachments).toEqual([ 'https://cdn.example.com/shot.jpg', ]); expect(failState.existingNotes).toBe('Met at GITEX'); expect(failState.errorMessage.length).toBeGreaterThan(0); const non1000Post = jest.fn(async () => { throw new Error('请求处理失败'); }); const non1000State: Record = { leadId: 21, lead: buildLead(), existingNotes: 'Old', nextNote: 'Draft stays', attachments: [], saving: false, errorMessage: '', }; const non1000Ok = await mod.submitLeadNote(non1000State, { httpPost: non1000Post, }); expect(non1000Ok).toBe(false); expect(non1000State.nextNote).toBe('Draft stays'); }); it('3.7 saveNote 成功结果带 code===1000;失败不伪装成功', async () => { const { saveNote } = loadUseLeadDetail(); const httpPost = jest.fn( async (_url: string, data: Record) => { return buildLead({ notes: String(data.notes) }); } ); const result = await saveNote( { id: 21, notes: 'A', next: 'B' }, { httpPost } ); expect(result.ok).toBe(true); expect(result.code).toBe(1000); expect(result.lead?.notes).toBe('A\nB'); const failPost = jest.fn(async () => { throw new Error('请求处理失败'); }); const failResult = await saveNote( { id: 21, notes: 'A', next: 'B' }, { httpPost: failPost } ); expect(failResult.ok).toBe(false); expect(failResult.code).not.toBe(1000); expect(failResult.lead).toBeNull(); }); }); describe('4. resolveLeadDetailText 只增文案', () => { it('4.1 话术与上传说明有文案,保存语义仍可用', () => { const { resolveLeadDetailText } = loadUseLeadDetail(); expect(resolveLeadDetailText('aiScript').length).toBeGreaterThan(0); expect(resolveLeadDetailText('comingSoon')).toBe('即将提供'); expect(resolveLeadDetailText('uploadHint').length).toBeGreaterThan(0); expect(resolveLeadDetailText('save').length).toBeGreaterThan(0); expect(resolveLeadDetailText('stageTitle').length).toBeGreaterThan(0); expect(resolveLeadDetailText('historyTitle').length).toBeGreaterThan(0); expect(resolveLeadDetailText('stageUpdated').length).toBeGreaterThan(0); expect(resolveLeadDetailText('copiedToast').length).toBeGreaterThan(0); expect(resolveLeadDetailText('whatsappAction').length).toBeGreaterThan(0); expect(resolveLeadDetailText('saveContactAction').length).toBeGreaterThan(0); expect(resolveLeadDetailText('generateAiAction').length).toBeGreaterThan(0); expect(resolveLeadDetailText('applyScriptAction').length).toBeGreaterThan(0); expect(resolveLeadDetailText('noteSavedToast')).toBe('备忘已保存'); expect(resolveLeadDetailText('notesRequiredToast')).toBe('请输入备忘或上传附件'); }); }); describe('5. 国际销售触达与智能话术函数', () => { it('5.1 formatWhatsAppUrl 清洗国际号码并生成 wa.me 链接', () => { const { formatWhatsAppUrl } = loadUseLeadDetail(); expect(formatWhatsAppUrl('+971-50-1234567')).toBe('https://wa.me/971501234567'); expect(formatWhatsAppUrl('0086 138-0000-0000')).toBe('https://wa.me/8613800000000'); expect(formatWhatsAppUrl('')).toBe(''); expect(formatWhatsAppUrl(null)).toBe(''); expect(formatWhatsAppUrl(undefined)).toBe(''); }); it('5.2 generateAiIcebreaker 生成个性化破冰销售话术', () => { const { generateAiIcebreaker } = loadUseLeadDetail(); const lead = buildLead({ name: 'Elena Rostova', company: 'Dubai Global Trading' }); const tracks = [buildTrack({ viewedBlocks: ['case', 'pricing'] })]; const scriptZh = generateAiIcebreaker(lead, tracks, 'zh-CN'); expect(scriptZh).toContain('Elena Rostova'); expect(scriptZh.length).toBeGreaterThan(20); const scriptEn = generateAiIcebreaker(lead, tracks, 'en-US'); expect(scriptEn).toContain('Elena'); expect(scriptEn.length).toBeGreaterThan(20); }); it('5.3 appendStructuredNote 结构化拼接备忘与附件链接', () => { const { appendStructuredNote } = loadUseLeadDetail(); const resWithAttach = appendStructuredNote( 'Initial meeting', 'Follow-up call agreed', ['https://cdn.example.com/card.jpg'] ); expect(resWithAttach).toContain('Initial meeting'); expect(resWithAttach).toContain('Follow-up call agreed'); expect(resWithAttach).toContain('https://cdn.example.com/card.jpg'); }); it('5.4 parseNoteAttachments 提取历史备忘中的文本与图片附件链接', () => { const { parseNoteAttachments } = loadUseLeadDetail(); const sample = 'Discussed custom pricing\n[https://cdn.example.com/card1.jpg https://cdn.example.com/card2.jpg]'; const parsed = parseNoteAttachments(sample); expect(parsed.cleanText).toBe('Discussed custom pricing'); expect(parsed.imageUrls).toHaveLength(2); expect(parsed.imageUrls[0]).toBe('https://cdn.example.com/card1.jpg'); expect(parsed.imageUrls[1]).toBe('https://cdn.example.com/card2.jpg'); const plain = parseNoteAttachments('Just plain text'); expect(plain.cleanText).toBe('Just plain text'); expect(plain.imageUrls).toHaveLength(0); const empty = parseNoteAttachments(''); expect(empty.cleanText).toBe(''); expect(empty.imageUrls).toHaveLength(0); }); it('7.3 createLeadDetailState 必须初始化 phone, email, source 字段', () => { const { createLeadDetailState } = loadUseLeadDetail(); expect(typeof createLeadDetailState).toBe('function'); const state = createLeadDetailState!(); expect(state.phone).toBe(''); expect(state.email).toBe(''); expect(state.source).toBe(''); }); }); describe('6. 电话与 WhatsApp 真实触达 (callLeadPhone / openLeadWhatsApp)', () => { it('6.1 有合法电话时调用 makePhoneCall,不 toast 即将提供', () => { const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail(); const ok = callLeadPhone('+971-50-1234567'); expect(ok).toBe(true); expect(makePhoneCallMock).toHaveBeenCalledTimes(1); expect(makePhoneCallMock.mock.calls[0][0].phoneNumber).toBe('+971-50-1234567'); const comingSoon = resolveLeadDetailText('comingSoon'); const toastedComingSoon = showToastMock.mock.calls.some( (c: unknown[]) => c[0] != null && typeof c[0] === 'object' && (c[0] as { title?: string }).title === comingSoon ); expect(toastedComingSoon).toBe(false); }); it('6.2 无合法电话时 toast comingSoon,不调 makePhoneCall', () => { const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail(); expect(callLeadPhone('')).toBe(false); expect(callLeadPhone(null)).toBe(false); expect(callLeadPhone(' ')).toBe(false); expect(callLeadPhone('---')).toBe(false); expect(makePhoneCallMock).not.toHaveBeenCalled(); expect(showToastMock).toHaveBeenCalled(); expect(showToastMock.mock.calls[0][0].title).toBe( resolveLeadDetailText('comingSoon') ); }); it('6.3 makePhoneCall fail 回调提示错误,不伪造成功', () => { makePhoneCallMock.mockImplementation( (options: { phoneNumber: string; fail?: (err: { errMsg?: string }) => void; }) => { options.fail?.({ errMsg: 'makePhoneCall:fail' }); } ); const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail(); const ok = callLeadPhone('+8613800000000'); expect(ok).toBe(true); expect(makePhoneCallMock).toHaveBeenCalledTimes(1); const failToast = showToastMock.mock.calls.find( (c: unknown[]) => c[0] != null && typeof c[0] === 'object' && (c[0] as { title?: string }).title === resolveLeadDetailText('callFailed') ); expect(failToast).toBeDefined(); expect(failToast![0].title).not.toBe(resolveLeadDetailText('comingSoon')); }); it('6.4 有号码时 openLeadWhatsApp 打开 wa.me 数字链接', () => { const { openLeadWhatsApp, formatWhatsAppUrl } = loadUseLeadDetail(); const expected = formatWhatsAppUrl('+971-50-1234567'); const ok = openLeadWhatsApp('+971-50-1234567'); expect(ok).toBe(true); expect(expected).toBe('https://wa.me/971501234567'); expect(openUrlCalls).toContain(expected); expect( showToastMock.mock.calls.some( (c: unknown[]) => c[0] != null && typeof c[0] === 'object' && (c[0] as { title?: string }).title === '即将提供' ) ).toBe(false); }); it('6.5 无号码时 openLeadWhatsApp toast comingSoon,不打开空 wa.me', () => { const { openLeadWhatsApp, resolveLeadDetailText } = loadUseLeadDetail(); expect(openLeadWhatsApp('')).toBe(false); expect(openLeadWhatsApp(null)).toBe(false); expect(openLeadWhatsApp('abc')).toBe(false); expect(openUrlCalls).toEqual([]); expect(setClipboardDataMock).not.toHaveBeenCalled(); const openedEmpty = setClipboardDataMock.mock.calls.some( (c: unknown[]) => c[0] != null && typeof c[0] === 'object' && String((c[0] as { data?: string }).data || '').includes('wa.me/') ); expect(openedEmpty).toBe(false); expect(showToastMock.mock.calls[0][0].title).toBe( resolveLeadDetailText('comingSoon') ); }); it('6.6 无 window.open 时回退 setClipboardData 写入 wa.me 链接', () => { delete (global as { window?: unknown }).window; const { openLeadWhatsApp } = loadUseLeadDetail(); const ok = openLeadWhatsApp('0086 138-0000-0000'); expect(ok).toBe(true); expect(setClipboardDataMock).toHaveBeenCalledTimes(1); expect(setClipboardDataMock.mock.calls[0][0].data).toBe( 'https://wa.me/8613800000000' ); }); }); }); describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => { it('页面存在,足迹用 list-view,备忘用 textarea,单一主 CTA,支持 WhatsApp 与 AI 破冰', () => { expect(fs.existsSync(DETAIL_PAGE)).toBe(true); expect(fs.existsSync(DETAIL_UTS)).toBe(true); const pageSource = fs.readFileSync(DETAIL_PAGE, 'utf8'); const logicSource = fs.readFileSync(DETAIL_UTS, 'utf8'); const combined = `${pageSource}\n${logicSource}`; const template = pageSource.split(']*v-for=/.test(pageSource)).toBe(false); expect(pageSource.includes('= 0 ? pageSource.slice(saveStart, saveStart + 720) : ''; expect(handleSaveBlock.includes('noteSavedToast')).toBe(true); expect(handleSaveBlock.includes('if (ok)')).toBe(true); expect(handleSaveBlock.includes('submitLeadNote')).toBe(true); // 失败不得 toast 已保存:noteSavedToast 仅出现在 if (ok) 分支内 expect(handleSaveBlock.indexOf('if (ok)')).toBeLessThan( handleSaveBlock.indexOf('noteSavedToast') ); }); it('无 emoji、无 any,模板无硬编码中文主文案', () => { const pageSource = fs.readFileSync(DETAIL_PAGE, 'utf8'); const logicSource = fs.readFileSync(DETAIL_UTS, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); const template = pageSource.split('