- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
282 行
9.7 KiB
TypeScript
282 行
9.7 KiB
TypeScript
/**
|
|
* OmniCard AI (CRMmp) - 移动端 AI 顾问流式对话与留资卡片测试套件 (Point 08)
|
|
* 严格践行 TDD 模式与 AGENTS.md 规范
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const AI_CHAT_PAGE = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages/portal/ai-chat.uvue'
|
|
);
|
|
const AI_CHAT_UTS = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages/portal/useAiChat.uts'
|
|
);
|
|
|
|
type AiChatRole = 'user' | 'assistant' | 'system';
|
|
type ChatMessageType = 'text' | 'lead_card';
|
|
|
|
interface ChatLeadPayload {
|
|
title: string;
|
|
description: string;
|
|
name: string;
|
|
contact: string;
|
|
submitted: boolean;
|
|
submitting?: boolean;
|
|
successMessage?: string;
|
|
errorMessage?: string;
|
|
}
|
|
|
|
interface ChatMessage {
|
|
role: AiChatRole;
|
|
content: string;
|
|
type?: ChatMessageType;
|
|
cardPayload?: ChatLeadPayload;
|
|
}
|
|
|
|
interface AiChatState {
|
|
cardId: string;
|
|
locale: string;
|
|
draft: string;
|
|
sending: boolean;
|
|
messages: ChatMessage[];
|
|
showLeadHint: boolean;
|
|
leadHint: string;
|
|
errorMessage: string;
|
|
}
|
|
|
|
interface UseAiChatModule {
|
|
hasLeadIntent: (text: string) => boolean;
|
|
sendStreamMessage: (
|
|
prompt: string,
|
|
onChunk: (chunk: string) => void,
|
|
onComplete: () => void,
|
|
intervalMs?: number
|
|
) => Promise<void>;
|
|
createLeadCardPayload: (locale?: string) => ChatLeadPayload;
|
|
createLeadCardMessage: (locale?: string) => ChatMessage;
|
|
sendStreamChat: (
|
|
state: AiChatState,
|
|
text?: string,
|
|
options?: { intervalMs?: number }
|
|
) => Promise<boolean>;
|
|
submitLeadCard: (
|
|
cardId: string | number,
|
|
payload: ChatLeadPayload,
|
|
api?: { submitLead: (p: Record<string, unknown>) => Promise<unknown> }
|
|
) => Promise<boolean>;
|
|
createAiChatState: () => AiChatState;
|
|
}
|
|
|
|
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u;
|
|
|
|
describe('Point 08: 移动端 AI 顾问流式对话与留资卡片逻辑 (useAiChat.uts)', () => {
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
(global as { uni?: Record<string, unknown> }).uni = {
|
|
getStorageSync: () => '',
|
|
setStorageSync: () => {},
|
|
removeStorageSync: () => {},
|
|
getLocale: () => 'zh-CN',
|
|
getSystemInfoSync: () => ({ language: 'zh-CN' }),
|
|
showToast: jest.fn(),
|
|
request: () => ({ abort: jest.fn() }),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (global as { uni?: Record<string, unknown> }).uni;
|
|
});
|
|
|
|
function loadModule(): UseAiChatModule {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
return require('../../../mpuniapp/pages/portal/useAiChat.uts') as UseAiChatModule;
|
|
}
|
|
|
|
describe('1. 空输入防护', () => {
|
|
it('1.1 空字符串与空白字符不触发流式推送与回调', async () => {
|
|
const { sendStreamMessage } = loadModule();
|
|
const onChunk = jest.fn();
|
|
const onComplete = jest.fn();
|
|
|
|
await sendStreamMessage('', onChunk, onComplete, 0);
|
|
expect(onChunk).not.toHaveBeenCalled();
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
await sendStreamMessage(' ', onChunk, onComplete, 0);
|
|
expect(onChunk).not.toHaveBeenCalled();
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('1.2 sendStreamChat 在空草稿或无 cardId 时直接返回 false', async () => {
|
|
const { createAiChatState, sendStreamChat } = loadModule();
|
|
const state = createAiChatState();
|
|
state.cardId = '';
|
|
state.draft = '价格多少';
|
|
|
|
const resNoCard = await sendStreamChat(state, undefined, { intervalMs: 0 });
|
|
expect(resNoCard).toBe(false);
|
|
expect(state.messages.length).toBe(0);
|
|
|
|
state.cardId = 'card-1001';
|
|
state.draft = ' ';
|
|
const resBlank = await sendStreamChat(state, undefined, { intervalMs: 0 });
|
|
expect(resBlank).toBe(false);
|
|
expect(state.messages.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('2. 流式数据分块与拼装', () => {
|
|
it('2.1 合法输入触发分块回调并拼装出完整应答', async () => {
|
|
const { sendStreamMessage } = loadModule();
|
|
const chunks: string[] = [];
|
|
const onChunk = jest.fn((c: string) => chunks.push(c));
|
|
const onComplete = jest.fn();
|
|
|
|
await sendStreamMessage('请介绍一下名片主理人', onChunk, onComplete, 0);
|
|
|
|
expect(onChunk).toHaveBeenCalled();
|
|
expect(chunks.length).toBeGreaterThan(1);
|
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
|
|
const assembled = chunks.join('');
|
|
expect(assembled.length).toBeGreaterThan(5);
|
|
});
|
|
});
|
|
|
|
describe('3. 意向词检测算法', () => {
|
|
it('3.1 命中价格、报价、合作、buy、quote、price 等核心关键词', () => {
|
|
const { hasLeadIntent } = loadModule();
|
|
|
|
expect(hasLeadIntent('请问你们的标准版价格是多少?')).toBe(true);
|
|
expect(hasLeadIntent('发一份详细报价单')).toBe(true);
|
|
expect(hasLeadIntent('希望能与你们商务合作')).toBe(true);
|
|
expect(hasLeadIntent('I want to buy the enterprise tier')).toBe(true);
|
|
expect(hasLeadIntent('Can you provide a quote?')).toBe(true);
|
|
expect(hasLeadIntent('What is the best PRICE?')).toBe(true);
|
|
});
|
|
|
|
it('3.2 普通闲聊或无关内容不命中意向词', () => {
|
|
const { hasLeadIntent } = loadModule();
|
|
|
|
expect(hasLeadIntent('你好')).toBe(false);
|
|
expect(hasLeadIntent('你是谁')).toBe(false);
|
|
expect(hasLeadIntent('Good morning, how are you?')).toBe(false);
|
|
expect(hasLeadIntent('')).toBe(false);
|
|
expect(hasLeadIntent(' ')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('4. 意向触发自动插入留资卡片消息', () => {
|
|
it('4.1 命中意向词时自动在对话末尾追加留资卡片类型消息', async () => {
|
|
const { createAiChatState, sendStreamChat } = loadModule();
|
|
const state = createAiChatState();
|
|
state.cardId = 'card-2002';
|
|
state.draft = '请问这个微官网方案价格是多少?';
|
|
|
|
const success = await sendStreamChat(state, undefined, { intervalMs: 0 });
|
|
expect(success).toBe(true);
|
|
|
|
// 包含 user 消息、assistant 流式消息、以及留资卡片消息
|
|
expect(state.messages.length).toBe(3);
|
|
expect(state.messages[0].role).toBe('user');
|
|
expect(state.messages[0].type).toBe('text');
|
|
|
|
expect(state.messages[1].role).toBe('assistant');
|
|
expect(state.messages[1].type).toBe('text');
|
|
expect(state.messages[1].content.length).toBeGreaterThan(0);
|
|
|
|
const cardMsg = state.messages[2];
|
|
expect(cardMsg.role).toBe('assistant');
|
|
expect(cardMsg.type).toBe('lead_card');
|
|
expect(cardMsg.cardPayload).toBeDefined();
|
|
expect(cardMsg.cardPayload?.submitted).toBe(false);
|
|
expect(state.showLeadHint).toBe(true);
|
|
});
|
|
|
|
it('4.2 未命中意向词时不追加留资卡片消息', async () => {
|
|
const { createAiChatState, sendStreamChat } = loadModule();
|
|
const state = createAiChatState();
|
|
state.cardId = 'card-2002';
|
|
state.draft = '请问名片支持哪些功能?';
|
|
|
|
const success = await sendStreamChat(state, undefined, { intervalMs: 0 });
|
|
expect(success).toBe(true);
|
|
|
|
expect(state.messages.length).toBe(2);
|
|
expect(state.messages[0].role).toBe('user');
|
|
expect(state.messages[1].role).toBe('assistant');
|
|
expect(state.messages[1].type).toBe('text');
|
|
});
|
|
});
|
|
|
|
describe('5. 留资卡片提交线索处理', () => {
|
|
it('5.1 姓名或联系方式为空时拒绝提交', async () => {
|
|
const { createLeadCardPayload, submitLeadCard } = loadModule();
|
|
const payload = createLeadCardPayload();
|
|
const mockSubmit = jest.fn();
|
|
|
|
payload.name = '';
|
|
payload.contact = 'test@example.com';
|
|
const r1 = await submitLeadCard('card-1', payload, { submitLead: mockSubmit });
|
|
expect(r1).toBe(false);
|
|
expect(mockSubmit).not.toHaveBeenCalled();
|
|
|
|
payload.name = 'Alex';
|
|
payload.contact = ' ';
|
|
const r2 = await submitLeadCard('card-1', payload, { submitLead: mockSubmit });
|
|
expect(r2).toBe(false);
|
|
expect(mockSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('5.2 填写完整后成功提交并置 submitted 为 true', async () => {
|
|
const { createLeadCardPayload, submitLeadCard } = loadModule();
|
|
const payload = createLeadCardPayload();
|
|
const mockSubmit = jest.fn().mockResolvedValue({ id: 'lead-999' });
|
|
|
|
payload.name = 'Alex Wang';
|
|
payload.contact = '+1234567890';
|
|
|
|
const success = await submitLeadCard('card-3003', payload, {
|
|
submitLead: mockSubmit,
|
|
});
|
|
|
|
expect(success).toBe(true);
|
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
expect(payload.submitted).toBe(true);
|
|
expect(payload.submitting).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Point 08: 页面与代码规范断言 (ai-chat.uvue)', () => {
|
|
it('页面模板中包含留资卡片渲染、无中文字面量、无 emoji、无 any', () => {
|
|
expect(fs.existsSync(AI_CHAT_PAGE)).toBe(true);
|
|
const pageSource = fs.readFileSync(AI_CHAT_PAGE, 'utf8');
|
|
const logicSource = fs.readFileSync(AI_CHAT_UTS, 'utf8');
|
|
const template = pageSource.split('<script')[0];
|
|
|
|
// 留资卡片结构
|
|
expect(pageSource.includes('ai-lead-card')).toBe(true);
|
|
expect(pageSource.includes('cardPayload')).toBe(true);
|
|
expect(pageSource.includes('lead_card')).toBe(true);
|
|
|
|
// 规范:无 emoji
|
|
expect(EMOJI_RE.test(pageSource)).toBe(false);
|
|
expect(EMOJI_RE.test(logicSource)).toBe(false);
|
|
|
|
// 规范:无 any
|
|
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
|
|
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
|
|
|
|
// 规范:模板中无硬编码中文字面量,全走 i18n
|
|
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
|
|
|
|
// 规范:单一主 CTA
|
|
const primaryCtas = pageSource.match(/primary-cta-btn/g) || [];
|
|
expect(primaryCtas.length).toBeGreaterThan(0);
|
|
});
|
|
});
|