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'; import { routes } from '../src/routes.ts'; const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const conciergePagePath = path.join(adminRoot, 'src/pages/ai/concierge/index.tsx'); const conciergeLocalePath = path.join(adminRoot, 'src/pages/ai/concierge/locale/index.ts'); function readPage(): string { return fs.readFileSync(conciergePagePath, 'utf-8'); } function readLocale(): string { return fs.readFileSync(conciergeLocalePath, 'utf-8'); } test('1. ai/concierge 页面必须使用 Arco Form/Select/Slider/Switch/Card/Radio 并接入 API', () => { assert.ok(fs.existsSync(conciergePagePath), 'AI 顾问配置页面必须存在'); const content = readPage(); assert.ok( content.includes('admin/ai/concierge'), '必须请求 /api/admin/ai/concierge 相关接口' ); assert.ok(content.includes('Form'), '必须使用 Arco Form'); assert.ok(content.includes('Select'), '必须使用 Arco Select'); assert.ok(content.includes('Slider'), '必须使用 Arco Slider'); assert.ok(content.includes('Switch'), '必须使用 Arco Switch'); assert.ok(content.includes('Card'), '必须使用 Arco Card'); assert.ok(content.includes('Radio'), '必须使用 Arco Radio'); assert.ok( content.includes("from '@arco-design/web-react'"), '必须从 @arco-design/web-react 引入组件' ); 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('2. ai/concierge 必须遵循设计 Token,禁止 emoji 与硬编码颜色,支持预设人设与阈值', () => { const content = readPage(); assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts'); assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token'); assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码十六进制颜色'); // 禁止 emoji const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; assert.ok(!emojiRegex.test(content), '页面中严禁使用 emoji 图标或字符'); // 人设切换支持 assert.ok(content.includes('trade_expert'), '必须支持国际贸易顾问预设 trade_expert'); assert.ok(content.includes('business_rep'), '必须支持商务代表预设 business_rep'); assert.ok(content.includes('tech_advisor'), '必须支持技术顾问预设 tech_advisor'); // 意向留资策略与欢迎语 assert.ok(content.includes('welcomeMsg'), '必须包含欢迎语 welcomeMsg 字段'); assert.ok(content.includes('triggerLeadThreshold'), '必须包含留资触发阈值 triggerLeadThreshold'); assert.ok(content.includes('promptTemplate'), '必须包含提示词模板 promptTemplate'); assert.ok(content.includes('toneStyle'), '必须包含语气风格 toneStyle'); assert.ok(content.includes('isEnabled'), '必须包含启停状态 isEnabled'); // 单一主 CTA const primaryButtons = content.match(/type=["']primary["']/g) || []; assert.equal(primaryButtons.length, 1, '一屏只允许 1 个主 CTA 按钮'); }); test('3. ai/concierge 路由与国际化配置必须齐备', () => { // 检查 routes.ts 包含 ai/concierge const aiRoute = routes.find(r => r.key === 'ai'); assert.ok(aiRoute, 'menu.ai 路由分组必须存在'); const conciergeRoute = aiRoute.children?.find(c => c.key === 'ai/concierge'); assert.ok(conciergeRoute, 'ai/concierge 路由项必须在 routes.ts 中注册'); assert.equal(conciergeRoute?.name, 'menu.ai.concierge'); // 检查语言包 assert.ok(fs.existsSync(conciergeLocalePath), '页面语言包必须存在'); const localeContent = readLocale(); assert.ok(localeContent.includes("'en-US'"), '必须包含英文案'); assert.ok(localeContent.includes("'zh-CN'"), '必须包含中文案'); assert.ok(localeContent.includes("'menu.ai.concierge'"), '页面语言包必须包含 menu.ai.concierge'); assert.ok(!/\bany\b/.test(localeContent), 'locale 禁止使用 any'); // 检查全局语言包 assert.ok( defaultI18n['en-US']['menu.ai.concierge'], 'src/locale/index.ts 必须包含 en-US menu.ai.concierge' ); assert.ok( defaultI18n['zh-CN']['menu.ai.concierge'], 'src/locale/index.ts 必须包含 zh-CN menu.ai.concierge' ); }); test('4. 保存 / 人设 / DeepSeek 等操作仅在 code===1000 且有 data 时 Message.success', () => { const content = readPage(); const localeContent = readLocale(); assert.ok(content.includes('Message.success'), '成功时仍应使用 Message.success'); assert.ok(content.includes('Message.error'), '失败必须使用 Message.error'); // 每个 Message.success 必须出现在 code === 1000 成功分支内,且附近要求 data const successMatches = [...content.matchAll(/Message\.success\s*\(/g)]; assert.ok(successMatches.length > 0, '至少应有一处成功提示'); for (const match of successMatches) { const idx = match.index ?? 0; const windowStart = Math.max(0, idx - 360); const preceding = content.slice(windowStart, idx); assert.ok( /code\s*===\s*1000/.test(preceding), `Message.success 必须位于 code === 1000 分支内,附近片段: ${preceding.slice(-100)}` ); assert.ok( /\.data\b|data\s*&&|&&\s*.*data/.test(preceding), `Message.success 前须校验 data 存在,附近片段: ${preceding.slice(-100)}` ); } // 保存接口必须同时校验 code 与 data assert.ok( /saveByCardId[\s\S]{0,800}code\s*===\s*1000[\s\S]{0,120}data/.test(content) || /SAVE_CONFIG_API[\s\S]{0,800}code\s*===\s*1000[\s\S]{0,120}data/.test( content ), '保存顾问配置须 code === 1000 且有 data' ); // 人设切换仅填充表单预览,不得 Message.success 假装已落库 assert.ok( content.includes('handlePersonaChange'), '必须存在人设切换处理' ); assert.ok( !/handlePersonaChange[\s\S]{0,500}Message\.success/.test(content), '人设切换不得 Message.success 假装已保存,须经保存接口' ); // FAQ:无独立保存接口时禁止假成功,须提示暂不可用 const hasFaqApi = /admin\/ai\/concierge\/(faq|saveFaq|upsertFaq)/i.test(content) || /FAQ_SAVE_API|SAVE_FAQ_API/i.test(content); assert.ok( localeContent.includes("'aiConcierge.faq.unavailable'"), 'locale 必须包含 aiConcierge.faq.unavailable' ); assert.ok( content.includes("t['aiConcierge.faq.unavailable']") || content.includes('aiConcierge.faq.unavailable'), '页面必须引用 FAQ 暂不可用文案' ); if (!hasFaqApi) { assert.ok( !/Message\.success[\s\S]{0,80}faq|Message\.success[\s\S]{0,80}FAQ/i.test( content ), '无 FAQ 接口时禁止 Message.success 假装 FAQ 已保存' ); assert.ok( /disabled[\s\S]{0,160}faq|faq[\s\S]{0,200}disabled|aiConcierge\.faq[\s\S]{0,200}disabled/i.test( content ) || content.includes("t['aiConcierge.faq.unavailable']"), '无 FAQ 接口时须禁用相关操作或提示暂不可用' ); } assert.ok( localeContent.includes("'aiConcierge.request.success'"), 'locale 须含保存成功文案' ); assert.ok( localeContent.includes("'aiConcierge.request.saveFail'"), 'locale 须含保存失败文案' ); }); test('5. 失败分支不得 Message.success,且不得本地假装已保存', () => { const content = readPage(); // catch / else 失败分支附近不得出现 Message.success const failBlocks = [ ...content.matchAll( /catch\s*(?:\([^)]*\))?\s*\{[\s\S]{0,280}\}|else\s*\{[\s\S]{0,280}\}/g ), ]; for (const block of failBlocks) { const text = block[0]; if (/Message\.error/.test(text) || /setLoading|setSaving|setDeepSeek/.test(text)) { assert.ok( !/Message\.success/.test(text), `失败分支内不得 Message.success: ${text.slice(0, 120)}` ); } } // 保存失败后不得用本地假数据冒充已保存成功提示 assert.ok( !/catch[\s\S]{0,200}Message\.success/.test(content), 'catch 失败后禁止 Message.success' ); assert.ok( !/code\s*!==\s*1000[\s\S]{0,120}Message\.success/.test(content), 'code 非 1000 时禁止 Message.success' ); });