- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
317 行
9.5 KiB
TypeScript
317 行
9.5 KiB
TypeScript
/**
|
|
* 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<T> = {
|
|
list: T[];
|
|
pagination: {
|
|
page: number;
|
|
size: number;
|
|
total: number;
|
|
};
|
|
};
|
|
|
|
type LoadDetailApi = {
|
|
fetchOwnerLeadInfo?: (id: string | number) => Promise<LeadEntity>;
|
|
fetchOwnerRadarPage?: (query: OwnerPageQuery) => Promise<OwnerPageData<RadarTrack>>;
|
|
};
|
|
|
|
type LoadDetailResult = {
|
|
lead: LeadEntity | null;
|
|
tracks: RadarTrack[];
|
|
empty: boolean;
|
|
errorMessage: string;
|
|
};
|
|
|
|
type SaveNoteApi = {
|
|
httpPost?: (
|
|
url: string,
|
|
data: Record<string, string | number>
|
|
) => Promise<LeadEntity>;
|
|
};
|
|
|
|
type UseLeadDetailModule = {
|
|
parseLeadId: (raw: string | number | null | undefined) => string | number | null;
|
|
loadDetail: (
|
|
id: string | number | null | undefined,
|
|
api?: LoadDetailApi
|
|
) => Promise<LoadDetailResult>;
|
|
appendNote: (
|
|
existing: string | null | undefined,
|
|
next: string | null | undefined
|
|
) => string;
|
|
saveNote: (
|
|
input: {
|
|
id: string | number;
|
|
notes?: string;
|
|
next: string;
|
|
status?: LeadStatus;
|
|
},
|
|
api?: SaveNoteApi
|
|
) => Promise<LeadEntity>;
|
|
};
|
|
|
|
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
|
|
|
|
function buildLead(overrides?: Partial<LeadEntity>): 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>): 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<string, unknown> }).uni = {
|
|
getLocale: () => 'zh-CN',
|
|
showToast: jest.fn(),
|
|
navigateBack: jest.fn(),
|
|
navigateTo: jest.fn(),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (global as { uni?: Record<string, unknown> }).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<string, string | number>) => {
|
|
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('<scroll-view')).toBe(true);
|
|
expect(pageSource.includes('<textarea')).toBe(true);
|
|
expect(pageSource.includes('primary-cta-btn')).toBe(true);
|
|
expect(
|
|
combined.includes('fetchOwnerLeadInfo') || combined.includes('loadDetail')
|
|
).toBe(true);
|
|
expect(
|
|
combined.includes('fetchOwnerRadarPage') || combined.includes('loadDetail')
|
|
).toBe(true);
|
|
expect(combined.includes('appendNote') || combined.includes('saveNote')).toBe(
|
|
true
|
|
);
|
|
expect(pageSource.includes('#0B5CFF')).toBe(true);
|
|
expect(pageSource.includes('#F7F8FA')).toBe(true);
|
|
expect(pageSource.includes('12px')).toBe(true);
|
|
expect(pageSource.includes('16px')).toBe(true);
|
|
expect(
|
|
pageSource.includes('navigateBack') || logicSource.includes('navigateBack')
|
|
).toBe(true);
|
|
});
|
|
|
|
it('无 emoji、无 any,模板无硬编码中文主文案', () => {
|
|
const pageSource = fs.readFileSync(DETAIL_PAGE, 'utf8');
|
|
const logicSource = fs.readFileSync(DETAIL_UTS, 'utf8');
|
|
const testSource = fs.readFileSync(__filename, 'utf8');
|
|
const template = pageSource.split('<script')[0];
|
|
|
|
expect(EMOJI_RE.test(pageSource)).toBe(false);
|
|
expect(EMOJI_RE.test(logicSource)).toBe(false);
|
|
expect(EMOJI_RE.test(testSource)).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);
|
|
});
|
|
});
|