/** * OmniCard AI (CRMmp) - 名片主移动潜客列表测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/leads 设计编写 */ import * as fs from 'fs'; import * as path from 'path'; const LEADS_PAGE = path.resolve( __dirname, '../../../mpuniapp/pages/owner/leads.uvue' ); const LEADS_UTS = path.resolve( __dirname, '../../../mpuniapp/pages/owner/useLeads.uts' ); type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived'; type LeadEntity = { id: string | number; cardId: string | number; name: string; company?: string; status: LeadStatus; intentScore: number; source: 'nfc' | 'qr' | 'link' | 'ai_chat'; }; type OwnerPageQuery = { page: number; size: number; keyWord?: string; status?: LeadStatus; }; type OwnerPageData = { list: T[]; pagination: { page: number; size: number; total: number; }; }; type LoadLeadsQuery = { page: number; size: number; status?: string; keyWord?: string; }; type LoadLeadsApi = { fetchOwnerLeadPage: ( query: OwnerPageQuery ) => Promise>; }; type LoadLeadsResult = { list: LeadEntity[]; pagination: { page: number; size: number; total: number; }; empty: boolean; errorMessage: string; }; type UseLeadsModule = { parseLeadStatus: (raw: string | null | undefined) => LeadStatus | null; loadLeads: ( query: LoadLeadsQuery, api?: LoadLeadsApi ) => Promise; }; const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; function buildLead(overrides?: Partial): LeadEntity { return { id: 21, cardId: 8, name: 'Alex Vance', company: 'Nexus Tech', status: 'new', intentScore: 72, source: 'nfc', ...overrides, }; } function emptyPage(page: number, size: number): OwnerPageData { return { list: [], pagination: { page, size, total: 0 }, }; } function loadUseLeads(): UseLeadsModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require(LEADS_UTS) as UseLeadsModule; } describe('OmniCard AI 名片主移动潜客列表 (useLeads.uts)', () => { beforeEach(() => { jest.resetModules(); (global as { uni?: Record }).uni = { getLocale: () => 'zh-CN', showToast: jest.fn(), navigateTo: jest.fn(), }; }); afterEach(() => { delete (global as { uni?: Record }).uni; }); describe('1. loadLeads 调用 page API', () => { it('1.1 将 page / size / status / keyWord 交给 fetchOwnerLeadPage', async () => { const { loadLeads } = loadUseLeads(); const lead = buildLead({ status: 'hot', intentScore: 91 }); const fetchOwnerLeadPage = jest.fn( async (query: OwnerPageQuery): Promise> => { expect(query.page).toBe(2); expect(query.size).toBe(20); expect(query.status).toBe('hot'); expect(query.keyWord).toBe('Vance'); return { list: [lead], pagination: { page: 2, size: 20, total: 1 }, }; } ); const result = await loadLeads( { page: 2, size: 20, status: 'hot', keyWord: 'Vance' }, { fetchOwnerLeadPage } ); expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1); expect(result.list).toHaveLength(1); expect(result.list[0].name).toBe('Alex Vance'); expect(result.list[0].intentScore).toBe(91); expect(result.empty).toBe(false); expect(result.errorMessage).toBe(''); expect(result.pagination.total).toBe(1); }); it('1.2 空列表标记空态,不写错误', async () => { const { loadLeads } = loadUseLeads(); const fetchOwnerLeadPage = jest.fn( async (): Promise> => emptyPage(1, 10) ); const result = await loadLeads( { page: 1, size: 10 }, { fetchOwnerLeadPage } ); expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1); expect(result.list).toEqual([]); expect(result.empty).toBe(true); expect(result.errorMessage).toBe(''); }); it('1.3 接口失败写入错误,不伪装空态成功', async () => { const { loadLeads } = loadUseLeads(); const fetchOwnerLeadPage = jest.fn(async (): Promise> => { throw new Error('lead page unavailable'); }); const result = await loadLeads( { page: 1, size: 10, status: 'new' }, { fetchOwnerLeadPage } ); expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1); expect(result.list).toEqual([]); expect(result.empty).toBe(false); expect(result.errorMessage).toBe('lead page unavailable'); }); }); describe('2. 非法 status 丢弃', () => { it('2.1 parseLeadStatus 只放行契约枚举', () => { const { parseLeadStatus } = loadUseLeads(); expect(parseLeadStatus('new')).toBe('new'); expect(parseLeadStatus('contacted')).toBe('contacted'); expect(parseLeadStatus('hot')).toBe('hot'); expect(parseLeadStatus('won')).toBe('won'); expect(parseLeadStatus('archived')).toBe('archived'); expect(parseLeadStatus('pending')).toBeNull(); expect(parseLeadStatus('NEW')).toBeNull(); expect(parseLeadStatus('')).toBeNull(); expect(parseLeadStatus(null)).toBeNull(); expect(parseLeadStatus(undefined)).toBeNull(); }); it('2.2 loadLeads 不把非法 status 传给 page API', async () => { const { loadLeads } = loadUseLeads(); const fetchOwnerLeadPage = jest.fn( async (query: OwnerPageQuery): Promise> => { expect(query.status).toBeUndefined(); return emptyPage(query.page, query.size); } ); await loadLeads( { page: 1, size: 10, status: 'pending', keyWord: ' ' }, { fetchOwnerLeadPage } ); expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1); const sent = fetchOwnerLeadPage.mock.calls[0][0] as OwnerPageQuery; expect(sent.page).toBe(1); expect(sent.size).toBe(10); expect(sent.status).toBeUndefined(); expect(sent.keyWord).toBeUndefined(); }); }); }); describe('OmniCard AI 名片主潜客列表页契约 (leads.uvue)', () => { it('页面含 list-view,并引用 fetchOwnerLeadPage 或 loadLeads', () => { expect(fs.existsSync(LEADS_PAGE)).toBe(true); expect(fs.existsSync(LEADS_UTS)).toBe(true); const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8'); const logicSource = fs.readFileSync(LEADS_UTS, 'utf8'); const combined = `${pageSource}\n${logicSource}`; expect(pageSource.includes(' { const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8'); const logicSource = fs.readFileSync(LEADS_UTS, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); const template = pageSource.split(']*v-for/.test(template)).toBe(false); expect(template.includes('