文件
OmniCard/backend/test/mpuniapp/owner-lead-detail.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

791 行
28 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* 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);
});
});