文件
OmniCard/backend/test/mpuniapp/owner-lead-detail.test.ts

534 行
19 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;
appendStructuredNote: (
existing: string | null | undefined,
next: string | null | undefined,
attachments?: string[]
) => string;
parseNoteAttachments: (rawNote: string | null | undefined) => {
cleanText: string;
imageUrls: string[];
};
formatWhatsAppUrl: (phone: string | null | undefined) => string;
createLeadDetailState?: () => Record<string, any>;
generateAiIcebreaker: (
lead: LeadEntity | null,
tracks: RadarTrack[],
locale?: string
) => string;
saveNote: (
input: {
id: string | number;
notes?: string;
next: string;
status?: LeadStatus;
attachments?: string[];
},
api?: SaveNoteApi
) => Promise<LeadEntity>;
resolveLeadDetailText: (key: string) => string;
};
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');
});
it('3.3 updateLeadStage 调用 updateOwnerLeadStatus 推进商机阶段', async () => {
const { updateLeadStage } = loadUseLeadDetail() as UseLeadDetailModule & {
updateLeadStage: (
state: Record<string, unknown>,
newStatus: LeadStatus,
api?: SaveNoteApi & {
updateOwnerLeadStatus?: (
id: string | number,
status: LeadStatus
) => Promise<LeadEntity>;
}
) => Promise<boolean>;
};
const lead = buildLead({ status: 'new' });
const state: Record<string, unknown> = {
leadId: 21,
lead,
status: 'new',
saving: false,
errorMessage: '',
};
const updateOwnerLeadStatus = jest.fn(
async (id: string | number, status: LeadStatus) => {
expect(id).toBe(21);
expect(status).toBe('hot');
return buildLead({ id, status });
}
);
const ok = await updateLeadStage(state, 'hot', { updateOwnerLeadStatus });
expect(ok).toBe(true);
expect(state.status).toBe('hot');
expect(state.saving).toBe(false);
expect(updateOwnerLeadStatus).toHaveBeenCalledTimes(1);
});
it('3.4 submitLeadNote 在 nextNote 为空但有 attachments 时仍可提交附件', async () => {
const { submitLeadNote } = loadUseLeadDetail() as UseLeadDetailModule & {
submitLeadNote: (
state: Record<string, any>,
api?: SaveNoteApi
) => Promise<boolean>;
};
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).toContain('https://cdn.example.com/card.png');
return buildLead({ notes: String(data.notes) });
}
);
const state: Record<string, any> = {
leadId: 21,
existingNotes: 'Initial meeting',
nextNote: '',
attachments: ['https://cdn.example.com/card.png'],
saving: false,
errorMessage: '',
};
const ok = await submitLeadNote(state, { httpPost });
expect(ok).toBe(true);
expect(state.existingNotes).toContain('https://cdn.example.com/card.png');
expect(state.attachments).toEqual([]);
expect(httpPost).toHaveBeenCalledTimes(1);
});
it('3.5 submitLeadNote 在 nextNote 为空且无 attachments 时返回 false', async () => {
const { submitLeadNote } = loadUseLeadDetail() as UseLeadDetailModule & {
submitLeadNote: (
state: Record<string, any>,
api?: SaveNoteApi
) => Promise<boolean>;
};
const httpPost = jest.fn();
const state: Record<string, any> = {
leadId: 21,
existingNotes: '',
nextNote: ' ',
attachments: [],
saving: false,
errorMessage: '',
};
const ok = await submitLeadNote(state, { httpPost });
expect(ok).toBe(false);
expect(httpPost).not.toHaveBeenCalled();
});
});
describe('4. resolveLeadDetailText 只增文案', () => {
it('4.1 话术与上传说明有文案,保存语义仍可用', () => {
const { resolveLeadDetailText } = loadUseLeadDetail();
expect(resolveLeadDetailText('aiScript').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('comingSoon')).toBe('即将提供');
expect(resolveLeadDetailText('uploadHint').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('save').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('stageTitle').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('historyTitle').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('stageUpdated').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('copiedToast').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('whatsappAction').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('saveContactAction').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('generateAiAction').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('applyScriptAction').length).toBeGreaterThan(0);
expect(resolveLeadDetailText('noteSavedToast')).toBe('备忘已保存');
expect(resolveLeadDetailText('notesRequiredToast')).toBe('请输入备忘或上传附件');
});
});
describe('5. 国际销售触达与智能话术函数', () => {
it('5.1 formatWhatsAppUrl 清洗国际号码并生成 wa.me 链接', () => {
const { formatWhatsAppUrl } = loadUseLeadDetail();
expect(formatWhatsAppUrl('+971-50-1234567')).toBe('https://wa.me/971501234567');
expect(formatWhatsAppUrl('0086 138-0000-0000')).toBe('https://wa.me/8613800000000');
expect(formatWhatsAppUrl('')).toBe('');
expect(formatWhatsAppUrl(null)).toBe('');
expect(formatWhatsAppUrl(undefined)).toBe('');
});
it('5.2 generateAiIcebreaker 生成个性化破冰销售话术', () => {
const { generateAiIcebreaker } = loadUseLeadDetail();
const lead = buildLead({ name: 'Elena Rostova', company: 'Dubai Global Trading' });
const tracks = [buildTrack({ viewedBlocks: ['case', 'pricing'] })];
const scriptZh = generateAiIcebreaker(lead, tracks, 'zh-CN');
expect(scriptZh).toContain('Elena Rostova');
expect(scriptZh.length).toBeGreaterThan(20);
const scriptEn = generateAiIcebreaker(lead, tracks, 'en-US');
expect(scriptEn).toContain('Elena');
expect(scriptEn.length).toBeGreaterThan(20);
});
it('5.3 appendStructuredNote 结构化拼接备忘与附件链接', () => {
const { appendStructuredNote } = loadUseLeadDetail();
const resWithAttach = appendStructuredNote(
'Initial meeting',
'Follow-up call agreed',
['https://cdn.example.com/card.jpg']
);
expect(resWithAttach).toContain('Initial meeting');
expect(resWithAttach).toContain('Follow-up call agreed');
expect(resWithAttach).toContain('https://cdn.example.com/card.jpg');
});
it('5.4 parseNoteAttachments 提取历史备忘中的文本与图片附件链接', () => {
const { parseNoteAttachments } = loadUseLeadDetail();
const sample = 'Discussed custom pricing\n[https://cdn.example.com/card1.jpg https://cdn.example.com/card2.jpg]';
const parsed = parseNoteAttachments(sample);
expect(parsed.cleanText).toBe('Discussed custom pricing');
expect(parsed.imageUrls).toHaveLength(2);
expect(parsed.imageUrls[0]).toBe('https://cdn.example.com/card1.jpg');
expect(parsed.imageUrls[1]).toBe('https://cdn.example.com/card2.jpg');
const plain = parseNoteAttachments('Just plain text');
expect(plain.cleanText).toBe('Just plain text');
expect(plain.imageUrls).toHaveLength(0);
const empty = parseNoteAttachments('');
expect(empty.cleanText).toBe('');
expect(empty.imageUrls).toHaveLength(0);
});
it('7.3 createLeadDetailState 必须初始化 phone, email, source 字段', () => {
const { createLeadDetailState } = loadUseLeadDetail();
expect(typeof createLeadDetailState).toBe('function');
const state = createLeadDetailState!();
expect(state.phone).toBe('');
expect(state.email).toBe('');
expect(state.source).toBe('');
});
});
});
describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => {
it('页面存在,足迹用 list-view,备忘用 textarea,单一主 CTA,支持 WhatsApp 与 AI 破冰', () => {
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}`;
const template = pageSource.split('<script')[0];
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
expect(pageSource.includes('getOmniText')).toBe(true);
expect(pageSource.includes('<list-view')).toBe(true);
expect(pageSource.includes('<list-item')).toBe(true);
expect(/<view[^>]*v-for=/.test(pageSource)).toBe(false);
expect(pageSource.includes('<textarea')).toBe(true);
expect(pageSource.includes('min-height: 88px')).toBe(true);
expect(pageSource.includes('primary-cta-btn')).toBe(true);
expect(pageSource.includes('calc(88px + env(safe-area-inset-bottom, 0px))')).toBe(
true
);
expect(pageSource.includes('lead-status-hot')).toBe(true);
expect(pageSource.includes('#F04438')).toBe(true);
expect(pageSource.includes('aiScript')).toBe(true);
expect(pageSource.includes('uploadHint')).toBe(true);
expect(pageSource.includes('image-uploader')).toBe(true);
expect(logicSource.includes('async function submitLeadNote')).toBe(true);
expect(
combined.includes('handleWhatsApp') || logicSource.includes('formatWhatsAppUrl')
).toBe(true);
expect(
combined.includes('handleSaveContact') || logicSource.includes('saveContactToDevice')
).toBe(true);
expect(
combined.includes('handleGenerateAiScript') || logicSource.includes('generateAiIcebreaker')
).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('notes-input')).toBe(true);
expect(pageSource.includes('font-size: 16px')).toBe(true);
expect(pageSource.includes('lead-cta-inner')).toBe(true);
expect(pageSource.includes('max-width: 480px')).toBe(true);
expect(pageSource.includes("'contactSaved'")).toBe(false);
});
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);
});
});