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 状态渲染,不得写死事件节点' ); });