补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
791 行
28 KiB
TypeScript
791 行
28 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;
|
||
callLeadPhone: (phone: string | null | undefined) => boolean;
|
||
openLeadWhatsApp: (phoneOrWhatsApp: string | null | undefined) => boolean;
|
||
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<{
|
||
ok: boolean;
|
||
code?: number;
|
||
lead: LeadEntity | null;
|
||
errorMessage: string;
|
||
}>;
|
||
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)', () => {
|
||
let showToastMock: jest.Mock;
|
||
let makePhoneCallMock: jest.Mock;
|
||
let setClipboardDataMock: jest.Mock;
|
||
let openUrlCalls: string[];
|
||
|
||
beforeEach(() => {
|
||
jest.resetModules();
|
||
showToastMock = jest.fn();
|
||
makePhoneCallMock = jest.fn();
|
||
setClipboardDataMock = jest.fn(
|
||
(options: { data: string; success?: () => void }) => {
|
||
options.success?.();
|
||
}
|
||
);
|
||
openUrlCalls = [];
|
||
(global as { uni?: Record<string, unknown> }).uni = {
|
||
getLocale: () => 'zh-CN',
|
||
showToast: showToastMock,
|
||
makePhoneCall: makePhoneCallMock,
|
||
setClipboardData: setClipboardDataMock,
|
||
navigateBack: jest.fn(),
|
||
navigateTo: jest.fn(),
|
||
};
|
||
(global as { window?: { open?: (url: string, target?: string) => void } }).window =
|
||
{
|
||
open: (url: string, _target?: string) => {
|
||
openUrlCalls.push(url);
|
||
},
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete (global as { uni?: Record<string, unknown> }).uni;
|
||
delete (global as { window?: unknown }).window;
|
||
});
|
||
|
||
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.ok).toBe(true);
|
||
expect(result.code).toBe(1000);
|
||
expect(result.lead?.notes).toBe('Met at GITEX\nAsked for quote');
|
||
expect(result.lead?.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();
|
||
});
|
||
|
||
it('3.6 submitLeadNote 仅 code===1000 清空 nextNote;网络错误/非1000 保留输入', async () => {
|
||
const mod = loadUseLeadDetail() as UseLeadDetailModule & {
|
||
submitLeadNote: (
|
||
state: Record<string, any>,
|
||
api?: SaveNoteApi
|
||
) => Promise<boolean>;
|
||
saveNote: UseLeadDetailModule['saveNote'];
|
||
};
|
||
|
||
const successPost = jest.fn(
|
||
async (_url: string, data: Record<string, string | number>) => {
|
||
return buildLead({ notes: String(data.notes) });
|
||
}
|
||
);
|
||
const okState: Record<string, any> = {
|
||
leadId: 21,
|
||
lead: buildLead(),
|
||
existingNotes: 'Met at GITEX',
|
||
nextNote: 'Asked for quote',
|
||
attachments: [],
|
||
saving: false,
|
||
errorMessage: '',
|
||
};
|
||
const ok = await mod.submitLeadNote(okState, { httpPost: successPost });
|
||
expect(ok).toBe(true);
|
||
expect(okState.nextNote).toBe('');
|
||
expect(okState.existingNotes).toContain('Asked for quote');
|
||
|
||
const failPost = jest.fn(async () => {
|
||
throw new Error('网络连接异常,请重试');
|
||
});
|
||
const failState: Record<string, any> = {
|
||
leadId: 21,
|
||
lead: buildLead(),
|
||
existingNotes: 'Met at GITEX',
|
||
nextNote: 'Keep this draft',
|
||
attachments: ['https://cdn.example.com/shot.jpg'],
|
||
saving: false,
|
||
errorMessage: '',
|
||
};
|
||
const failOk = await mod.submitLeadNote(failState, { httpPost: failPost });
|
||
expect(failOk).toBe(false);
|
||
expect(failState.nextNote).toBe('Keep this draft');
|
||
expect(failState.attachments).toEqual([
|
||
'https://cdn.example.com/shot.jpg',
|
||
]);
|
||
expect(failState.existingNotes).toBe('Met at GITEX');
|
||
expect(failState.errorMessage.length).toBeGreaterThan(0);
|
||
|
||
const non1000Post = jest.fn(async () => {
|
||
throw new Error('请求处理失败');
|
||
});
|
||
const non1000State: Record<string, any> = {
|
||
leadId: 21,
|
||
lead: buildLead(),
|
||
existingNotes: 'Old',
|
||
nextNote: 'Draft stays',
|
||
attachments: [],
|
||
saving: false,
|
||
errorMessage: '',
|
||
};
|
||
const non1000Ok = await mod.submitLeadNote(non1000State, {
|
||
httpPost: non1000Post,
|
||
});
|
||
expect(non1000Ok).toBe(false);
|
||
expect(non1000State.nextNote).toBe('Draft stays');
|
||
});
|
||
|
||
it('3.7 saveNote 成功结果带 code===1000;失败不伪装成功', async () => {
|
||
const { saveNote } = loadUseLeadDetail();
|
||
const httpPost = jest.fn(
|
||
async (_url: string, data: Record<string, string | number>) => {
|
||
return buildLead({ notes: String(data.notes) });
|
||
}
|
||
);
|
||
const result = await saveNote(
|
||
{ id: 21, notes: 'A', next: 'B' },
|
||
{ httpPost }
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
expect(result.code).toBe(1000);
|
||
expect(result.lead?.notes).toBe('A\nB');
|
||
|
||
const failPost = jest.fn(async () => {
|
||
throw new Error('请求处理失败');
|
||
});
|
||
const failResult = await saveNote(
|
||
{ id: 21, notes: 'A', next: 'B' },
|
||
{ httpPost: failPost }
|
||
);
|
||
expect(failResult.ok).toBe(false);
|
||
expect(failResult.code).not.toBe(1000);
|
||
expect(failResult.lead).toBeNull();
|
||
});
|
||
});
|
||
|
||
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('6. 电话与 WhatsApp 真实触达 (callLeadPhone / openLeadWhatsApp)', () => {
|
||
it('6.1 有合法电话时调用 makePhoneCall,不 toast 即将提供', () => {
|
||
const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail();
|
||
|
||
const ok = callLeadPhone('+971-50-1234567');
|
||
|
||
expect(ok).toBe(true);
|
||
expect(makePhoneCallMock).toHaveBeenCalledTimes(1);
|
||
expect(makePhoneCallMock.mock.calls[0][0].phoneNumber).toBe('+971-50-1234567');
|
||
const comingSoon = resolveLeadDetailText('comingSoon');
|
||
const toastedComingSoon = showToastMock.mock.calls.some(
|
||
(c: unknown[]) =>
|
||
c[0] != null &&
|
||
typeof c[0] === 'object' &&
|
||
(c[0] as { title?: string }).title === comingSoon
|
||
);
|
||
expect(toastedComingSoon).toBe(false);
|
||
});
|
||
|
||
it('6.2 无合法电话时 toast comingSoon,不调 makePhoneCall', () => {
|
||
const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail();
|
||
|
||
expect(callLeadPhone('')).toBe(false);
|
||
expect(callLeadPhone(null)).toBe(false);
|
||
expect(callLeadPhone(' ')).toBe(false);
|
||
expect(callLeadPhone('---')).toBe(false);
|
||
expect(makePhoneCallMock).not.toHaveBeenCalled();
|
||
expect(showToastMock).toHaveBeenCalled();
|
||
expect(showToastMock.mock.calls[0][0].title).toBe(
|
||
resolveLeadDetailText('comingSoon')
|
||
);
|
||
});
|
||
|
||
it('6.3 makePhoneCall fail 回调提示错误,不伪造成功', () => {
|
||
makePhoneCallMock.mockImplementation(
|
||
(options: {
|
||
phoneNumber: string;
|
||
fail?: (err: { errMsg?: string }) => void;
|
||
}) => {
|
||
options.fail?.({ errMsg: 'makePhoneCall:fail' });
|
||
}
|
||
);
|
||
const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail();
|
||
|
||
const ok = callLeadPhone('+8613800000000');
|
||
expect(ok).toBe(true);
|
||
expect(makePhoneCallMock).toHaveBeenCalledTimes(1);
|
||
const failToast = showToastMock.mock.calls.find(
|
||
(c: unknown[]) =>
|
||
c[0] != null &&
|
||
typeof c[0] === 'object' &&
|
||
(c[0] as { title?: string }).title ===
|
||
resolveLeadDetailText('callFailed')
|
||
);
|
||
expect(failToast).toBeDefined();
|
||
expect(failToast![0].title).not.toBe(resolveLeadDetailText('comingSoon'));
|
||
});
|
||
|
||
it('6.4 有号码时 openLeadWhatsApp 打开 wa.me 数字链接', () => {
|
||
const { openLeadWhatsApp, formatWhatsAppUrl } = loadUseLeadDetail();
|
||
const expected = formatWhatsAppUrl('+971-50-1234567');
|
||
|
||
const ok = openLeadWhatsApp('+971-50-1234567');
|
||
|
||
expect(ok).toBe(true);
|
||
expect(expected).toBe('https://wa.me/971501234567');
|
||
expect(openUrlCalls).toContain(expected);
|
||
expect(
|
||
showToastMock.mock.calls.some(
|
||
(c: unknown[]) =>
|
||
c[0] != null &&
|
||
typeof c[0] === 'object' &&
|
||
(c[0] as { title?: string }).title === '即将提供'
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
it('6.5 无号码时 openLeadWhatsApp toast comingSoon,不打开空 wa.me', () => {
|
||
const { openLeadWhatsApp, resolveLeadDetailText } = loadUseLeadDetail();
|
||
|
||
expect(openLeadWhatsApp('')).toBe(false);
|
||
expect(openLeadWhatsApp(null)).toBe(false);
|
||
expect(openLeadWhatsApp('abc')).toBe(false);
|
||
expect(openUrlCalls).toEqual([]);
|
||
expect(setClipboardDataMock).not.toHaveBeenCalled();
|
||
const openedEmpty = setClipboardDataMock.mock.calls.some(
|
||
(c: unknown[]) =>
|
||
c[0] != null &&
|
||
typeof c[0] === 'object' &&
|
||
String((c[0] as { data?: string }).data || '').includes('wa.me/')
|
||
);
|
||
expect(openedEmpty).toBe(false);
|
||
expect(showToastMock.mock.calls[0][0].title).toBe(
|
||
resolveLeadDetailText('comingSoon')
|
||
);
|
||
});
|
||
|
||
it('6.6 无 window.open 时回退 setClipboardData 写入 wa.me 链接', () => {
|
||
delete (global as { window?: unknown }).window;
|
||
const { openLeadWhatsApp } = loadUseLeadDetail();
|
||
|
||
const ok = openLeadWhatsApp('0086 138-0000-0000');
|
||
expect(ok).toBe(true);
|
||
expect(setClipboardDataMock).toHaveBeenCalledTimes(1);
|
||
expect(setClipboardDataMock.mock.calls[0][0].data).toBe(
|
||
'https://wa.me/8613800000000'
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
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(logicSource.includes('function callLeadPhone')).toBe(true);
|
||
expect(logicSource.includes('function openLeadWhatsApp')).toBe(true);
|
||
expect(logicSource.includes('uni.makePhoneCall')).toBe(true);
|
||
expect(logicSource.includes('https://wa.me/')).toBe(true);
|
||
expect(pageSource.includes('callLeadPhone')).toBe(true);
|
||
expect(pageSource.includes('openLeadWhatsApp')).toBe(true);
|
||
expect(pageSource.includes('handleCallPhone')).toBe(true);
|
||
expect(pageSource.includes('handleWhatsApp')).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);
|
||
expect(
|
||
pageSource.includes('code === 1000') ||
|
||
logicSource.includes('code === 1000')
|
||
).toBe(true);
|
||
expect(pageSource.includes('noteSavedToast')).toBe(true);
|
||
expect(pageSource.includes('handleSave')).toBe(true);
|
||
const saveStart = pageSource.indexOf('async function handleSave():');
|
||
const handleSaveBlock =
|
||
saveStart >= 0 ? pageSource.slice(saveStart, saveStart + 720) : '';
|
||
expect(handleSaveBlock.includes('noteSavedToast')).toBe(true);
|
||
expect(handleSaveBlock.includes('if (ok)')).toBe(true);
|
||
expect(handleSaveBlock.includes('submitLeadNote')).toBe(true);
|
||
// 失败不得 toast 已保存:noteSavedToast 仅出现在 if (ok) 分支内
|
||
expect(handleSaveBlock.indexOf('if (ok)')).toBeLessThan(
|
||
handleSaveBlock.indexOf('noteSavedToast')
|
||
);
|
||
});
|
||
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);
|
||
});
|
||
});
|