/** * 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; createLeadCardPayload: (locale?: string) => ChatLeadPayload; createLeadCardMessage: (locale?: string) => ChatMessage; sendStreamChat: ( state: AiChatState, text?: string, options?: { intervalMs?: number } ) => Promise; submitLeadCard: ( cardId: string | number, payload: ChatLeadPayload, api?: { submitLead: (p: Record) => Promise } ) => Promise; 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 }).uni = { getStorageSync: () => '', setStorageSync: () => {}, removeStorageSync: () => {}, getLocale: () => 'zh-CN', getSystemInfoSync: () => ({ language: 'zh-CN' }), showToast: jest.fn(), request: () => ({ abort: jest.fn() }), }; }); afterEach(() => { delete (global as { uni?: Record }).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('