文件
OmniCard/admin/tests/crm-lead-profile.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

300 行
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import defaultI18n from '../src/locale/index.ts';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const profilePagePath = path.join(
adminRoot,
'src/pages/crm/lead-profile/index.tsx'
);
const profileLocalePath = path.join(
adminRoot,
'src/pages/crm/lead-profile/locale/index.ts'
);
const emojiPattern = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
test('1. crm/lead-profile 必须 GET lead/info 并 POST radar/page', () => {
assert.ok(fs.existsSync(profilePagePath), '线索画像页面必须存在');
const content = fs.readFileSync(profilePagePath, 'utf-8');
assert.ok(
content.includes('admin/crm/lead/info'),
'必须请求 GET /api/admin/crm/lead/info'
);
assert.ok(
content.includes('axios.get'),
'lead/info 必须使用 GET,而不是 POST'
);
assert.ok(
content.includes('admin/crm/radar/page'),
'必须请求 POST /api/admin/crm/radar/page'
);
assert.ok(content.includes('axios.post'), '足迹必须使用 POST radar/page');
assert.ok(
content.includes('keyWord') && content.includes('visitorId'),
'足迹请求必须用 visitorId 作为 keyWord'
);
assert.ok(content.includes('cardId'), '足迹请求必须支持 cardId 字段筛选');
assert.ok(
content.includes('code === 1000') || content.includes('code ===1000'),
'必须按 Cool-Admin code === 1000 判断成功'
);
assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
assert.ok(
!content.includes('Mock.js') &&
!content.includes("from 'mockjs'") &&
!content.includes('mockjs'),
'禁止用 Mock.js 假数据冒充成功'
);
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
assert.ok(!emojiPattern.test(content), '禁止使用 emoji');
});
test('2. crm/lead-profile 必须用 Descriptions 或 Card + Timeline 展示画像与足迹时间轴', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
assert.ok(
content.includes("from '@arco-design/web-react'"),
'必须从 @arco-design/web-react 引入组件'
);
assert.ok(content.includes('Descriptions') || content.includes('Card'), '必须使用 Descriptions 或 Card');
assert.ok(content.includes('Timeline'), '必须使用 Arco Timeline 展示访问时间轴');
assert.ok(content.includes('Empty'), '无足迹事件必须使用 Arco Empty');
assert.ok(content.includes('LeadEntity'), '必须使用 LeadEntity 类型');
assert.ok(content.includes('RadarTrack'), '必须使用 RadarTrack 类型');
assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
assert.ok(content.includes("from '@/style/tokens'"), '颜色必须引用设计 token');
for (const field of [
'name',
'company',
'email',
'phone',
'status',
'intentScore',
'source',
'notes',
]) {
assert.ok(content.includes(field), `画像必须展示字段: ${field}`);
}
for (const field of [
'touchType',
'stayDurationMs',
'scrollDepth',
'viewedBlocks',
'country',
'city',
]) {
assert.ok(content.includes(field), `时间轴必须展示字段: ${field}`);
}
});
test('3. crm/lead-profile 必须从 URL search 读取 id,无 id 给空状态主 CTA', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
assert.ok(content.includes('useLocation'), '必须用 react-router useLocation 读路由');
assert.ok(content.includes('location.search'), '必须从 location.search 读取查询串');
assert.ok(
content.includes('query-string') ||
content.includes('queryString') ||
content.includes('URLSearchParams'),
'必须用 querystring 或 URLSearchParams 解析 id'
);
assert.ok(
content.includes("get('id')") ||
content.includes('.id') ||
content.includes("['id']"),
'必须读取 query 中的 id'
);
assert.ok(
content.includes('empty') || content.includes('Empty'),
'无 id 必须有空状态'
);
assert.ok(content.includes('type="primary"') || content.includes("type='primary'"), '空状态必须有一个主 CTA');
assert.ok(content.includes('/crm/leads'), '空状态主 CTA 应回到线索说明页');
});
test('4. locale 必须包含 menu.crm.leadProfile 中英文案,且禁止 any / mockjs / emoji', () => {
assert.ok(fs.existsSync(profileLocalePath), '线索画像语言包必须存在');
const content = fs.readFileSync(profileLocalePath, 'utf-8');
assert.ok(
content.includes("'menu.crm.leadProfile'"),
'页面语言包必须包含 menu.crm.leadProfile'
);
assert.equal(defaultI18n['en-US']['menu.crm.leadProfile'], 'Lead Profile');
assert.equal(defaultI18n['zh-CN']['menu.crm.leadProfile'], '线索画像');
assert.ok(content.includes('crmLeadProfile.request.fail'), '必须包含失败提示文案');
assert.ok(content.includes('crmLeadProfile.empty'), '必须包含空状态文案');
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
assert.ok(
!content.includes('Mock.js') && !content.includes('mockjs'),
'locale 禁止 Mock.js'
);
assert.ok(!emojiPattern.test(content), 'locale 禁止使用 emoji');
});
test('5. crm/lead-profile 次按钮生成跟进话术须调已有 copilot/generate,成功才填框', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
const localeContent = fs.readFileSync(profileLocalePath, 'utf-8');
assert.ok(
content.includes('admin/ai/copilot/generate'),
'必须请求已有接口 POST /api/admin/ai/copilot/generate'
);
assert.ok(
content.includes('axios.post') && content.includes('COPILOT'),
'跟进话术必须用 axios.post 调用 copilot'
);
assert.ok(
content.includes('Input.TextArea') || content.includes('TextArea'),
'必须有文本框承接返回话术'
);
assert.ok(
content.includes("type=\"outline\"") ||
content.includes("type='outline'") ||
content.includes('type="text"') ||
content.includes("type='text'"),
'生成按钮必须是描边或文字次按钮,不得再加实心主按钮'
);
assert.ok(
content.includes('crmLeadProfile.copilot.generate'),
'生成按钮文案必须走 locale key'
);
assert.ok(
content.includes('crmLeadProfile.copilot.fail') &&
content.includes('Message.'),
'失败必须用 Message 说明暂不可生成'
);
assert.ok(
content.includes('payload.data') || content.includes('data?.script') || content.includes('.script'),
'成功后必须用接口返回的 script 填入文本框'
);
const hardcodedEmail =
/Dear\s+\w+|Looking forward to|Best regards|Hi\s+there,|I hope this (email|message) finds you/i;
assert.ok(
!hardcodedEmail.test(content),
'页面禁止写死英文跟进邮件话术'
);
assert.ok(
!hardcodedEmail.test(localeContent),
'locale 禁止写死英文跟进邮件话术'
);
for (const lang of ['en-US', 'zh-CN', 'ar-SA'] as const) {
assert.ok(
localeContent.includes(`'${lang}'`),
`locale 必须包含 ${lang}`
);
}
assert.ok(
localeContent.includes('crmLeadProfile.copilot.generate'),
'locale 必须包含生成按钮文案'
);
assert.ok(
localeContent.includes('crmLeadProfile.copilot.fail'),
'locale 必须包含暂不可生成提示'
);
assert.ok(
localeContent.includes('crmLeadProfile.copilot.script') ||
localeContent.includes('crmLeadProfile.copilot.title'),
'locale 必须包含话术文本框标签'
);
assert.ok(
content.includes('setFollowUpScript(script)'),
'成功时只能用接口返回的 script 变量填框'
);
assert.ok(
!/setFollowUpScript\(\s*[`'"][^`'"]+[`'"]/.test(content),
'禁止用非空字符串字面量给话术框填假内容'
);
const primaryMatches = content.match(/type=["']primary["']/g) ?? [];
assert.ok(
primaryMatches.length <= 1,
'画像有 id 时不得再加第二个实心主按钮;primary 仅保留空状态 CTA'
);
const generateHandler = content.slice(
content.indexOf('handleGenerateFollowUp'),
content.indexOf('const pagination')
);
assert.ok(generateHandler.includes('catch'), '必须有失败 catch');
const catchBlock = generateHandler.slice(generateHandler.indexOf('catch'));
assert.ok(
!catchBlock.includes('setFollowUpScript'),
'接口失败时不得调用 setFollowUpScript 填假话术'
);
});
test('6. R49: 画像时间轴只渲染接口事件;无事件 Empty;失败清空,禁止示例动态', () => {
const content = fs.readFileSync(profilePagePath, 'utf-8');
const localeContent = fs.readFileSync(profileLocalePath, 'utf-8');
assert.ok(
content.includes('Timeline') && content.includes('Timeline.Item'),
'必须用 Timeline / Timeline.Item 渲染访问事件'
);
assert.ok(
content.includes('Empty') &&
(content.includes('crmLeadProfile.radar.empty') ||
content.includes("t['crmLeadProfile.radar.empty']")),
'无事件必须渲染 Arco Empty,文案走 locale'
);
assert.ok(
localeContent.includes('crmLeadProfile.radar.empty'),
'locale 必须包含时间轴空状态文案'
);
assert.ok(
/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[\s*\]\s*\)/.test(content) ||
/useState[<\s(][^)]*RadarLogRow[^)]*\)?\s*\(\s*\[\s*\]\s*\)/.test(content),
'tracks 初始必须为空数组'
);
assert.ok(
content.includes('setTracks(payload.data?.list ?? [])') ||
content.includes('setTracks(payload.data?.list') ||
/setTracks\(\s*payload\.data\?\.list/.test(content),
'成功时只能写入接口返回的 list'
);
assert.ok(content.includes('setTracks([])'), '失败或无数据必须 setTracks([])');
assert.ok(content.includes('setTotal(0)'), '失败必须把 total 清零');
const radarStart = content.indexOf('RADAR_PAGE_API');
const radarEnd = content.indexOf('handleGenerateFollowUp');
assert.ok(radarStart >= 0 && radarEnd > radarStart, '必须存在足迹请求与生成话术逻辑');
const radarEffect = content.slice(radarStart, radarEnd);
assert.ok(radarEffect.includes('catch'), '足迹请求必须有 catch');
assert.ok(
radarEffect.includes('setTracks([])') &&
/code\s*===\s*1000/.test(radarEffect) &&
radarEffect.includes('catch'),
'code 非 1000 与 catch 都必须清空时间轴'
);
assert.ok(
!content.includes('示例动态') &&
!content.includes('示例时间') &&
!content.includes('假跟进') &&
!content.includes('Just Now') &&
!content.includes('sample-track') &&
!content.includes('demo-event') &&
!/const\s+(MOCK_|SAMPLE_|DEMO_|FAKE_)/.test(content),
'禁止示例动态、示例时间或假跟进常量'
);
assert.ok(
!/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[[^\]]+id/.test(content),
'禁止用示例事件初始化时间轴'
);
assert.ok(
content.includes('tracks.map') || content.includes('tracks.length'),
'时间轴必须基于 tracks 状态渲染,不得写死事件节点'
);
});