补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
368 行
13 KiB
TypeScript
368 行
13 KiB
TypeScript
/**
|
||
* OmniCard AI (CRMmp) - 名片主独立数据统计与漏斗分析页测试套件 (TDD Red -> Green)
|
||
*
|
||
* 验证独立统计页面的数据计算、漏斗转化分析、渠道矩阵与界面规范
|
||
* R31: 接口失败/缺字段时数字为 0、图表为空,禁止示例柱与写死访客数
|
||
*/
|
||
|
||
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 OwnerPageQuery = {
|
||
page: number;
|
||
size: number;
|
||
};
|
||
|
||
type OwnerPageData<T> = {
|
||
list: T[];
|
||
pagination?: {
|
||
page: number;
|
||
size: number;
|
||
total: number;
|
||
};
|
||
};
|
||
|
||
type LoadStatsApi = {
|
||
fetchOwnerLeadPage?: (
|
||
query: OwnerPageQuery
|
||
) => Promise<OwnerPageData<LeadEntity> | Record<string, unknown> | null>;
|
||
};
|
||
|
||
type UseStatsModule = {
|
||
createStatsState: () => StatsState;
|
||
calcConversionRate: (total: number, won: number) => number;
|
||
calcFunnelStepRate: (currentCount: number, baseCount: number) => number;
|
||
computeStatsFromLeads: (leads: LeadEntity[], period: StatsPeriod) => StatsState;
|
||
loadStatsData: (state: StatsState, api?: LoadStatsApi) => Promise<void>;
|
||
resolveStatsText: (key: string) => string;
|
||
resetStatsToEmpty: (state: StatsState) => void;
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
function buildLead(partial: Partial<LeadEntity> & Pick<LeadEntity, 'id' | 'name' | 'status'>): LeadEntity {
|
||
return {
|
||
cardId: 1,
|
||
intentScore: 40,
|
||
source: 'link',
|
||
...partial,
|
||
};
|
||
}
|
||
|
||
describe('OmniCard AI 名片主独立统计与漏斗业务层 (useStats.uts)', () => {
|
||
beforeEach(() => {
|
||
jest.resetModules();
|
||
(global as { uni?: Record<string, unknown> }).uni = {
|
||
getLocale: () => 'zh-CN',
|
||
showToast: jest.fn(),
|
||
navigateTo: jest.fn(),
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete (global as { uni?: Record<string, unknown> }).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[] = [
|
||
buildLead({ id: 1, name: 'Lead 1', status: 'new', intentScore: 40, source: 'nfc' }),
|
||
buildLead({ id: 2, name: 'Lead 2', status: 'hot', intentScore: 85, source: 'qr' }),
|
||
buildLead({ id: 3, name: 'Lead 3', status: 'contacted', intentScore: 70, source: 'link' }),
|
||
buildLead({ id: 4, 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);
|
||
});
|
||
|
||
it('2.2 空列表时数字为 0、漏斗与渠道图表为空,不造示例百分比', () => {
|
||
const { computeStatsFromLeads } = loadUseStats();
|
||
const state = computeStatsFromLeads([], 'all');
|
||
|
||
expect(state.summary.totalLeads).toBe(0);
|
||
expect(state.summary.hotLeads).toBe(0);
|
||
expect(state.summary.wonLeads).toBe(0);
|
||
expect(state.summary.overallRate).toBe(0);
|
||
expect(state.funnel).toEqual([]);
|
||
expect(state.channels).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('3. loadStatsData 失败/缺字段清零 (R31)', () => {
|
||
it('3.1 接口失败时数字归零、图表清空,不保留旧值填洞', async () => {
|
||
const { createStatsState, loadStatsData } = loadUseStats();
|
||
const state = createStatsState();
|
||
state.summary = {
|
||
totalLeads: 128,
|
||
hotLeads: 36,
|
||
wonLeads: 12,
|
||
overallRate: 45.5,
|
||
};
|
||
state.funnel = [
|
||
{ key: 'new', label: 'New', count: 128, rate: 100 },
|
||
{ key: 'hot', label: 'Hot', count: 36, rate: 28 },
|
||
];
|
||
state.channels = [
|
||
{ source: 'nfc', label: 'NFC', leadsCount: 64, wonCount: 8, conversionRate: 12.5 },
|
||
];
|
||
state.errorMessage = '';
|
||
|
||
const fetchOwnerLeadPage = jest.fn(async () => {
|
||
throw new Error('network down');
|
||
});
|
||
|
||
await loadStatsData(state, { fetchOwnerLeadPage });
|
||
|
||
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
|
||
expect(state.errorMessage).toBe('network down');
|
||
expect(state.summary.totalLeads).toBe(0);
|
||
expect(state.summary.hotLeads).toBe(0);
|
||
expect(state.summary.wonLeads).toBe(0);
|
||
expect(state.summary.overallRate).toBe(0);
|
||
expect(state.funnel).toEqual([]);
|
||
expect(state.channels).toEqual([]);
|
||
expect(state.loading).toBe(false);
|
||
});
|
||
|
||
it('3.2 响应缺 list 字段时按空数据处理,数字为 0、图表为空', async () => {
|
||
const { createStatsState, loadStatsData } = loadUseStats();
|
||
const state = createStatsState();
|
||
state.summary.totalLeads = 99;
|
||
|
||
const fetchOwnerLeadPage = jest.fn(async () => ({ pagination: { page: 1, size: 100, total: 0 } }));
|
||
|
||
await loadStatsData(state, { fetchOwnerLeadPage });
|
||
|
||
expect(state.errorMessage).toBe('');
|
||
expect(state.summary.totalLeads).toBe(0);
|
||
expect(state.summary.hotLeads).toBe(0);
|
||
expect(state.summary.wonLeads).toBe(0);
|
||
expect(state.summary.overallRate).toBe(0);
|
||
expect(state.funnel).toEqual([]);
|
||
expect(state.channels).toEqual([]);
|
||
});
|
||
|
||
it('3.3 成功时仅使用接口 list 聚合,不写死访客数', async () => {
|
||
const { createStatsState, loadStatsData } = loadUseStats();
|
||
const state = createStatsState();
|
||
const fetchOwnerLeadPage = jest.fn(async () => ({
|
||
list: [
|
||
buildLead({ id: 1, name: 'A', status: 'new', intentScore: 20, source: 'nfc' }),
|
||
buildLead({ id: 2, name: 'B', status: 'won', intentScore: 90, source: 'qr' }),
|
||
],
|
||
pagination: { page: 1, size: 100, total: 2 },
|
||
}));
|
||
|
||
await loadStatsData(state, { fetchOwnerLeadPage });
|
||
|
||
expect(state.errorMessage).toBe('');
|
||
expect(state.summary.totalLeads).toBe(2);
|
||
expect(state.summary.wonLeads).toBe(1);
|
||
expect(state.summary.overallRate).toBe(50);
|
||
expect(state.funnel.length).toBe(4);
|
||
expect(state.channels.length).toBe(4);
|
||
expect(state.summary.totalLeads).not.toBe(128);
|
||
});
|
||
});
|
||
|
||
describe('4. i18n 文案', () => {
|
||
it('4.1 resolveStatsText 走现有词典,空/错/重试非空', () => {
|
||
const { resolveStatsText } = loadUseStats();
|
||
expect(resolveStatsText('title').length).toBeGreaterThan(0);
|
||
expect(resolveStatsText('empty').length).toBeGreaterThan(0);
|
||
expect(resolveStatsText('error').length).toBeGreaterThan(0);
|
||
expect(resolveStatsText('retry').length).toBeGreaterThan(0);
|
||
expect(resolveStatsText('metricTotal').length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
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);
|
||
expect(logicSource.includes('fetchOwnerLeadPage')).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('<script')[0];
|
||
|
||
expect(EMOJI_RE.test(pageSource)).toBe(false);
|
||
expect(EMOJI_RE.test(logicSource)).toBe(false);
|
||
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
|
||
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
|
||
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
|
||
});
|
||
|
||
it('4. R31: 健康度柱与漏斗禁止示例柱/写死访客数填洞', () => {
|
||
const pageSource = fs.readFileSync(STATS_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(STATS_UTS, 'utf8');
|
||
const combined = `${pageSource}\n${logicSource}`;
|
||
const script = pageSource.includes('<script')
|
||
? pageSource.split('<script')[1].split('</script>')[0]
|
||
: '';
|
||
|
||
// 健康度 flex 不得在无数据时回落为 1(示例柱)
|
||
expect(script.includes('? state.summary.hotLeads : 1')).toBe(false);
|
||
expect(script.includes('warm > 0 ? warm : 1')).toBe(false);
|
||
expect(script.includes('healthCoolFlex') && /healthCoolFlex[\s\S]*return 1;/.test(script)).toBe(
|
||
false
|
||
);
|
||
expect(script.includes(': 1') && script.includes('healthHotFlex')).toBe(false);
|
||
|
||
// 禁止写死访客/示例数
|
||
expect(combined.includes('totalLeads: 128')).toBe(false);
|
||
expect(combined.includes('访客数')).toBe(false);
|
||
expect(logicSource.includes('示例')).toBe(false);
|
||
expect(pageSource.includes('示例柱')).toBe(false);
|
||
|
||
// 失败清零与空图表
|
||
expect(logicSource.includes('resetStatsToEmpty') || logicSource.includes('funnel = []')).toBe(
|
||
true
|
||
);
|
||
expect(logicSource.includes('channels = []') || logicSource.includes('funnel: []')).toBe(true);
|
||
});
|
||
|
||
it('5. 模板文案走 resolveStatsText / t(),徽章不硬编码英文装饰字', () => {
|
||
const pageSource = fs.readFileSync(STATS_PAGE, 'utf8');
|
||
const template = pageSource.split('<script')[0];
|
||
|
||
expect(template.includes('{{ t(')).toBe(true);
|
||
expect(template.includes('>TOTAL<')).toBe(false);
|
||
expect(template.includes('>HOT<')).toBe(false);
|
||
expect(template.includes('>WON<')).toBe(false);
|
||
expect(template.includes('>CVR<')).toBe(false);
|
||
expect(template.includes('% CVR')).toBe(false);
|
||
expect(template.includes('4 STAGES')).toBe(false);
|
||
expect(template.includes("t('badgeRate')") || template.includes('t("badgeRate")')).toBe(
|
||
true
|
||
);
|
||
});
|
||
});
|