/** * 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; saveNote: ( input: { id: string | number; notes?: string; next: string; status?: LeadStatus; }, api?: SaveNoteApi ) => Promise; }; 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)', () => { beforeEach(() => { jest.resetModules(); (global as { uni?: Record }).uni = { getLocale: () => 'zh-CN', showToast: jest.fn(), navigateBack: jest.fn(), navigateTo: jest.fn(), }; }); afterEach(() => { delete (global as { uni?: Record }).uni; }); 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.notes).toBe('Met at GITEX\nAsked for quote'); expect(result.status).toBe('hot'); }); }); }); describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => { it('页面存在,足迹用 scroll-view,备忘用 textarea,单一主 CTA', () => { 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}`; expect(pageSource.includes('script setup lang="uts"')).toBe(true); expect(pageSource.includes('getOmniText')).toBe(true); expect(pageSource.includes(' { const pageSource = fs.readFileSync(DETAIL_PAGE, 'utf8'); const logicSource = fs.readFileSync(DETAIL_UTS, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); const template = pageSource.split('