/** * OmniCard AI (CRMmp) - 名片主独立数据统计与漏斗分析页测试套件 (TDD Red -> Green) * * 验证独立统计页面的数据计算、漏斗转化分析、渠道矩阵与界面规范 */ import * as fs from 'fs'; import * as path from 'path'; const STATS_PAGE = path.resolve( __dirname, '../../../mpuniapp/pages/owner/stats.uvue' ); const STATS_UTS = path.resolve( __dirname, '../../../mpuniapp/pages/owner/useStats.uts' ); const PAGES_JSON = path.resolve( __dirname, '../../../mpuniapp/pages.json' ); type StatsPeriod = 'today' | '7d' | '30d' | 'all'; type LeadEntity = { id: string | number; cardId: string | number; name: string; company?: string; status: 'new' | 'contacted' | 'hot' | 'won' | 'archived'; intentScore: number; source: 'nfc' | 'qr' | 'link' | 'ai_chat'; }; type FunnelStep = { key: string; label: string; count: number; rate: number; }; type ChannelMetric = { source: 'nfc' | 'qr' | 'link' | 'ai_chat'; label: string; leadsCount: number; wonCount: number; conversionRate: number; }; type StatsSummary = { totalLeads: number; hotLeads: number; wonLeads: number; overallRate: number; }; type StatsState = { period: StatsPeriod; summary: StatsSummary; funnel: FunnelStep[]; channels: ChannelMetric[]; loading: boolean; errorMessage: string; }; type UseStatsModule = { createStatsState: () => StatsState; calcConversionRate: (total: number, won: number) => number; calcFunnelStepRate: (currentCount: number, baseCount: number) => number; computeStatsFromLeads: (leads: LeadEntity[], period: StatsPeriod) => StatsState; resolveStatsText: (key: string) => string; }; const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; function loadUseStats(): UseStatsModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require(STATS_UTS) as UseStatsModule; } describe('OmniCard AI 名片主独立统计与漏斗业务层 (useStats.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. 漏斗与转化率纯函数计算', () => { it('1.1 calcConversionRate 在总数为0时返回0,正常时保留一位小数', () => { const { calcConversionRate } = loadUseStats(); expect(calcConversionRate(0, 0)).toBe(0); expect(calcConversionRate(0, 5)).toBe(0); expect(calcConversionRate(10, 3)).toBe(30); expect(calcConversionRate(3, 1)).toBe(33.3); expect(calcConversionRate(7, 2)).toBe(28.6); }); it('1.2 calcFunnelStepRate 计算阶梯相对率', () => { const { calcFunnelStepRate } = loadUseStats(); expect(calcFunnelStepRate(0, 100)).toBe(0); expect(calcFunnelStepRate(50, 100)).toBe(50); expect(calcFunnelStepRate(80, 0)).toBe(0); }); }); describe('2. computeStatsFromLeads 多维度聚合', () => { it('2.1 从潜客样本计算出总数、高意向、成交数与漏斗4阶梯', () => { const { computeStatsFromLeads } = loadUseStats(); const mockLeads: LeadEntity[] = [ { id: 1, cardId: 1, name: 'Lead 1', status: 'new', intentScore: 40, source: 'nfc' }, { id: 2, cardId: 1, name: 'Lead 2', status: 'hot', intentScore: 85, source: 'qr' }, { id: 3, cardId: 1, name: 'Lead 3', status: 'contacted', intentScore: 70, source: 'link' }, { id: 4, cardId: 1, name: 'Lead 4', status: 'won', intentScore: 92, source: 'ai_chat' }, ]; const state = computeStatsFromLeads(mockLeads, 'all'); expect(state.summary.totalLeads).toBe(4); expect(state.summary.wonLeads).toBe(1); expect(state.summary.hotLeads).toBe(2); // intentScore >= 80 (Lead 2, Lead 4) or status === 'hot' expect(state.summary.overallRate).toBe(25); // 漏斗必须包含 new, hot, contacted, won 四个阶梯 expect(state.funnel).toHaveLength(4); expect(state.funnel[0].key).toBe('new'); expect(state.funnel[3].key).toBe('won'); // 渠道分析必须覆盖 4 种渠道 expect(state.channels).toHaveLength(4); const aiChannel = state.channels.find(c => c.source === 'ai_chat'); expect(aiChannel).toBeDefined(); expect(aiChannel?.leadsCount).toBe(1); expect(aiChannel?.wonCount).toBe(1); expect(aiChannel?.conversionRate).toBe(100); }); }); }); describe('OmniCard AI 独立统计视图契约 (stats.uvue & pages.json)', () => { it('1. pages.json 中已成功注册 pages/owner/stats 页面路由', () => { expect(fs.existsSync(PAGES_JSON)).toBe(true); const content = fs.readFileSync(PAGES_JSON, 'utf-8'); const cleanJson = content.replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '$1'); const config = JSON.parse(cleanJson); const hasRoute = config.pages.some( (p: { path: string }) => p.path === 'pages/owner/stats' ); expect(hasRoute).toBe(true); }); it('2. stats.uvue 包含完整的便当盒统计组件与设计 Token', () => { expect(fs.existsSync(STATS_PAGE)).toBe(true); expect(fs.existsSync(STATS_UTS)).toBe(true); const pageSource = fs.readFileSync(STATS_PAGE, 'utf8'); const logicSource = fs.readFileSync(STATS_UTS, 'utf8'); expect(pageSource.includes('script setup lang="uts"')).toBe(true); expect(pageSource.includes('#0B5CFF')).toBe(true); expect(pageSource.includes('#F7F8FA')).toBe(true); expect(pageSource.includes('#101828')).toBe(true); expect(pageSource.includes('#667085')).toBe(true); expect(pageSource.includes('#E3E7ED')).toBe(true); expect(pageSource.includes('period-chip')).toBe(true); expect(pageSource.includes('stats-funnel')).toBe(true); expect(pageSource.includes('channel-grid')).toBe(true); expect(logicSource.includes('resolveStatsText')).toBe(true); }); it('3. stats.uvue 无 emoji、无 any、模板无硬编码中文', () => { const pageSource = fs.readFileSync(STATS_PAGE, 'utf8'); const logicSource = fs.readFileSync(STATS_UTS, 'utf8'); const template = pageSource.split('