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 { routes } from '../src/routes.ts'; import defaultI18n from '../src/locale/index.ts'; const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const radarLogPagePath = path.join(adminRoot, 'src/pages/crm/radar-log/index.tsx'); const radarLogLocalePath = path.join( adminRoot, 'src/pages/crm/radar-log/locale/index.ts' ); function flattenRouteKeys(items: typeof routes): string[] { return items.flatMap((item) => [ item.key, ...(item.children ? flattenRouteKeys(item.children) : []), ]); } test('1. routes.ts 必须包含 key crm/radar-log,供 layout glob 加载', () => { const keys = flattenRouteKeys(routes); assert.ok(keys.includes('crm'), '应存在一级菜单 key: crm'); assert.ok(keys.includes('crm/radar-log'), '应存在路由 key: crm/radar-log'); const crmGroup = routes.find((item) => item.key === 'crm'); assert.ok(crmGroup, '应存在 menu.crm 分组'); assert.equal(crmGroup?.name, 'menu.crm'); assert.ok( crmGroup?.children?.some( (child) => child.key === 'crm/radar-log' && child.name === 'menu.crm.radarLog' ), 'crm 分组下应有 menu.crm.radarLog / crm/radar-log' ); }); test('2. locale 必须包含 menu.crm.radarLog 中英文案', () => { assert.equal(defaultI18n['en-US']['menu.crm.radarLog'], 'Visitor Logs'); assert.equal(defaultI18n['zh-CN']['menu.crm.radarLog'], '访客足迹'); }); test('3. radar-log/index.tsx 必须请求 admin/crm/radar/page 并使用 Arco Table', () => { assert.ok(fs.existsSync(radarLogPagePath), '访客足迹页面必须存在'); const content = fs.readFileSync(radarLogPagePath, 'utf-8'); assert.ok( content.includes('admin/crm/radar/page'), '必须请求 POST /api/admin/crm/radar/page' ); assert.ok(content.includes('Table'), '必须使用 Arco Table'); assert.ok( content.includes("from '@arco-design/web-react'"), '必须从 @arco-design/web-react 引入组件' ); assert.ok(content.includes('Form'), '必须使用 Arco Form'); assert.ok(content.includes('Select'), '必须使用 Arco Select'); assert.ok(content.includes('Tag'), '必须使用 Arco Tag'); 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'"), '禁止用 Mock.js 假数据冒充成功' ); assert.ok(!/\bany\b/.test(content), '禁止使用 any'); }); test('4. radar-log/index.tsx 必须按 RadarTrack / TouchType 渲染足迹列与筛选', () => { const content = fs.readFileSync(radarLogPagePath, 'utf-8'); assert.ok( content.includes('RadarTrack') && content.includes('TouchType'), '必须引用 RadarTrack / TouchType' ); assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts'); assert.ok(content.includes('touchType'), '必须包含 touchType 筛选与列'); assert.ok(content.includes("'nfc'") && content.includes("'qr'") && content.includes("'link'"), 'touchType 必须覆盖 nfc/qr/link'); assert.ok(content.includes('cardId'), '必须支持 cardId 筛选'); assert.ok(content.includes('country'), '必须支持 country 筛选'); assert.ok(content.includes('keyWord'), '请求体必须包含 keyWord'); assert.ok( content.includes('createTime') && content.includes('createdAt'), '必须兼容 createTime / createdAt' ); assert.ok(content.includes('visitorId'), '必须展示 visitorId'); assert.ok(content.includes('stayDurationMs'), '必须展示 stayDurationMs'); assert.ok(content.includes('scrollDepth'), '必须展示 scrollDepth'); assert.ok(content.includes('viewedBlocks'), '必须展示 viewedBlocks'); assert.ok(content.includes('setData([])'), '失败必须清空表格'); }); test('5. 页面 locale 必须覆盖筛选与列文案,且禁止 any', () => { assert.ok(fs.existsSync(radarLogLocalePath), '访客足迹 locale 必须存在'); const content = fs.readFileSync(radarLogLocalePath, 'utf-8'); assert.ok(content.includes('radarLog.filter.cardId'), '必须有 cardId 筛选文案'); assert.ok(content.includes('radarLog.filter.touchType'), '必须有 touchType 筛选文案'); assert.ok(content.includes('radarLog.filter.country'), '必须有 country 筛选文案'); assert.ok(content.includes('radarLog.columns.viewedBlocks'), '必须有 viewedBlocks 列文案'); assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any'); }); test('6. 深度访问高亮:停留 ≥180000ms 或滚动深度 ≥0.6;缺字段不高亮', () => { const content = fs.readFileSync(radarLogPagePath, 'utf-8'); assert.ok( content.includes('180000'), '必须包含停留时长阈值 180000 毫秒' ); assert.ok( content.includes('0.6'), '必须包含滚动深度阈值 0.6' ); assert.ok( /isDeepVisit|deepVisit/.test(content), '必须具备 isDeepVisit / deepVisit 判定' ); assert.ok( content.includes('rowClassName') || content.includes('onRow'), '必须通过 rowClassName 或 onRow 对深度访问行高亮' ); // 缺字段:须对 stayDurationMs / scrollDepth 做有限数值校验后再比阈值 assert.ok( /Number\.isFinite|typeof[\s\S]{0,40}number/.test(content), '字段缺失或非有限数时不得误判为深度访问' ); assert.ok( content.includes("t['radarLog.deepVisit']") || content.includes('radarLog.deepVisit'), '深度访问文案必须走 locale' ); }); test('7. locale 必须含深度访问文案;页面禁止写死国家或示例访客', () => { const pageContent = fs.readFileSync(radarLogPagePath, 'utf-8'); const localeContent = fs.readFileSync(radarLogLocalePath, 'utf-8'); assert.ok( localeContent.includes("'radarLog.deepVisit'"), 'locale 必须包含 radarLog.deepVisit' ); assert.ok(localeContent.includes("'en-US'"), 'locale 必须含 en-US'); assert.ok(localeContent.includes("'zh-CN'"), 'locale 必须含 zh-CN'); assert.ok(localeContent.includes("'ar-SA'"), 'locale 必须含 ar-SA'); const bannedCountries = [ 'United States', 'United Kingdom', 'Singapore', 'Germany', 'Japan', 'China', 'Dubai', 'Saudi Arabia', ]; for (const name of bannedCountries) { assert.ok(!pageContent.includes(name), `禁止硬编码国家/地区名: ${name}`); } assert.ok( !pageContent.includes('示例访客') && !pageContent.includes('sample-visitor') && !pageContent.includes('visitor_demo') && !pageContent.includes('Alex') && !/visitorId\s*:\s*['"][^'"]+['"]/.test(pageContent), '禁止写死示例访客行' ); assert.ok( !/useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[[^\]]+visitorId/.test( pageContent ) && !/const\s+MOCK_|const\s+SAMPLE_|const\s+DEMO_/.test(pageContent), '禁止用示例访客初始化表格数据' ); }); test('8. 请求失败必须空表,不得残留示例访客', () => { const content = fs.readFileSync(radarLogPagePath, 'utf-8'); assert.ok(content.includes('setData([])'), '失败必须 setData([])'); assert.ok( /useState[<\s(][^)]*RadarLogRow[^)]*\)?\s*\(\s*\[\s*\]\s*\)/.test(content) || /useState\s*<\s*RadarLogRow\s*\[\s*\]\s*>\s*\(\s*\[\s*\]\s*\)/.test(content), '初始 data 必须为空数组' ); // 非 1000 与 catch 均应清空 assert.ok( /code\s*===\s*1000[\s\S]*?setData\(\[\]\)/.test(content) || (content.includes('setData([])') && content.includes('catch')), 'code 非 1000 或 catch 必须清空表格' ); assert.ok( !content.includes('Mock.js') && !content.includes('mockList'), '失败路径禁止留下 mock/示例列表' ); });