文件
OmniCard/backend/test/mpuniapp/ai-chat-stream.test.ts
wikiboss eec259a3a7 feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
2026-09-20 23:47:47 +08:00

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);
});
});