/** * OmniCard AI (CRMmp) - 名片主移动潜客列表测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/leads 设计编写 * R30:筛选只用接口真实 status / intentScore;空态与失败不填示例线索 */ 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 LeadsState = { list: LeadEntity[]; radarTracks: unknown[]; hotUnread: number; page: number; size: number; total: number; status: string; keyWord: string; loading: boolean; empty: boolean; errorMessage: string; }; type UseLeadsModule = { parseLeadStatus: (raw: string | null | undefined) => LeadStatus | null; formatHotUnread: (unread: number) => string; loadLeads: ( query: LoadLeadsQuery, api?: LoadLeadsApi ) => Promise; createLeadsState: () => LeadsState; applyLeadStatusFilter: (state: LeadsState, status: string) => void; applyLoadResult: (state: LeadsState, result: LoadLeadsResult) => void; sanitizeLeadQuery: (query: LoadLeadsQuery) => OwnerPageQuery; }; const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; const SAMPLE_NAME_RE = /Alex Vance|Nexus Tech|示例线索|Fatima Al-Rashid|演示潜客/; 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].status).toBe('hot'); 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(''); expect(result.list.some((item) => SAMPLE_NAME_RE.test(item.name))).toBe( false ); }); 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('warm')).toBeNull(); expect(parseLeadStatus('cold')).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('3. R30 真实 status / intentScore 筛选与失败清列表', () => { it('3.1 sanitizeLeadQuery 只透传契约 status,不伪造 intent', () => { const { sanitizeLeadQuery } = loadUseLeads(); const hot = sanitizeLeadQuery({ page: 1, size: 20, status: 'hot', keyWord: 'score', }); expect(hot.status).toBe('hot'); expect(hot.keyWord).toBe('score'); expect(Object.prototype.hasOwnProperty.call(hot, 'intentScore')).toBe( false ); const cleared = sanitizeLeadQuery({ page: 1, size: 20, status: 'warm', }); expect(cleared.status).toBeUndefined(); }); it('3.2 applyLeadStatusFilter 只写入真实 status 枚举', () => { const { createLeadsState, applyLeadStatusFilter } = loadUseLeads(); const state = createLeadsState(); applyLeadStatusFilter(state, 'hot'); expect(state.status).toBe('hot'); expect(state.page).toBe(1); applyLeadStatusFilter(state, 'contacted'); expect(state.status).toBe('contacted'); applyLeadStatusFilter(state, 'warm'); expect(state.status).toBe(''); applyLeadStatusFilter(state, ''); expect(state.status).toBe(''); }); it('3.3 失败结果写回 state 时清空上一批线索名,不保留示例', () => { const { createLeadsState, applyLoadResult } = loadUseLeads(); const state = createLeadsState(); state.list = [buildLead({ name: 'Alex Vance', intentScore: 91 })]; state.total = 1; state.empty = false; state.errorMessage = ''; applyLoadResult(state, { list: [], pagination: { page: 1, size: 20, total: 0 }, empty: false, errorMessage: 'lead page unavailable', }); expect(state.list).toEqual([]); expect(state.total).toBe(0); expect(state.empty).toBe(false); expect(state.errorMessage).toBe('lead page unavailable'); expect(state.list.some((item) => item.name === 'Alex Vance')).toBe(false); }); it('3.4 空列表写回 state 标记空态,列表保持空数组', () => { const { createLeadsState, applyLoadResult } = loadUseLeads(); const state = createLeadsState(); state.list = [buildLead()]; applyLoadResult(state, { list: [], pagination: { page: 1, size: 20, total: 0 }, empty: true, errorMessage: '', }); expect(state.list).toEqual([]); expect(state.empty).toBe(true); expect(state.errorMessage).toBe(''); }); }); it('formatHotUnread 有未读才显示红点文案', () => { const { formatHotUnread } = loadUseLeads(); expect(formatHotUnread(0)).toBe(''); expect(formatHotUnread(2)).toContain('2'); }); }); 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(' { const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8'); const logicSource = fs.readFileSync(LEADS_UTS, 'utf8'); const template = pageSource.split('')[0] : ''; expect(template.includes('item.intentScore')).toBe(true); expect(template.includes('item.status')).toBe(true); expect(template.includes('state.empty')).toBe(true); expect( template.includes("t('empty')") || template.includes('t("empty")') ).toBe(true); expect(template.includes('leads-feedback')).toBe(true); expect(SAMPLE_NAME_RE.test(pageSource)).toBe(false); expect(SAMPLE_NAME_RE.test(logicSource)).toBe(false); expect(logicSource.includes('示例')).toBe(false); expect(pageSource.includes('示例')).toBe(false); expect(script.includes("'统计'") || script.includes('"统计"')).toBe(false); expect(script.includes('resolveLeadsText')).toBe(true); expect(script.includes('applyLeadStatusFilter')).toBe(true); expect(script.includes('refreshLeads')).toBe(true); expect( /function selectStatus[\s\S]*?applyLeadStatusFilter[\s\S]*?refreshLeads/.test( script ) ).toBe(true); expect(logicSource.includes('sanitizeLeadQuery')).toBe(true); expect(logicSource.includes('parseLeadStatus')).toBe(true); expect(logicSource.includes('intentScore')).toBe(false); }); });