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

344 行
11 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>;
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');
});
});
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);
});
});
});
describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => {
it('页面存在,足迹用 list-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}`;
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(template.includes('WhatsApp')).toBe(false);
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('comingSoon')).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('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);
});
});