/** * OmniCard AI (CRMmp) - 门户 AI 顾问对话页测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/portal/ai-chat 设计编写 */ 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'; type ChatLeadPayload = { title: string; description: string; name: string; contact: string; submitted: boolean; submitting: boolean; successMessage?: string; errorMessage?: string; }; type AiChatMessage = { role: AiChatRole; content: string; type?: ChatMessageType; cardPayload?: ChatLeadPayload; }; type AiChatRequest = { cardId: string | number; locale?: string; messages: AiChatMessage[]; }; type AiChatReply = { reply: string; locale: string; shouldCollectLead: boolean; }; type SendChatInput = { cardId: string | number; locale?: string; messages: AiChatMessage[]; text: string; }; type SendChatApi = { askAiChat: (req: AiChatRequest) => Promise; }; type SendChatResult = { sent: boolean; reply: AiChatReply | null; messages: AiChatMessage[]; shouldCollectLead: boolean; leadHint: string; }; type AiChatState = { cardId: string; locale: string; draft: string; sending: boolean; messages: AiChatMessage[]; showLeadHint: boolean; leadHint: string; errorMessage: string; }; type SubmitLeadParams = { cardId: string | number; name: string; phone?: string; email?: string; source?: string; notes?: string; }; type SubmitLeadApi = { submitLead: (params: SubmitLeadParams) => Promise; }; type UseAiChatModule = { buildUserMessage: (text: string) => AiChatMessage; appendTurn: ( messages: AiChatMessage[], userText: string, reply: AiChatReply ) => AiChatMessage[]; sendChat: (input: SendChatInput, api?: SendChatApi) => Promise; hasLeadIntent: (text: string) => boolean; createAiChatState: () => AiChatState; createLeadCardPayload: (locale?: string) => ChatLeadPayload; createLeadCardMessage: (locale?: string) => AiChatMessage; sendStreamChat: ( state: AiChatState, text?: string, options?: { intervalMs?: number } ) => Promise; submitLeadCard: ( cardId: string | number, payload: ChatLeadPayload, api?: SubmitLeadApi ) => Promise; resolveAiChatText: (key: string) => string; }; const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u; describe('OmniCard 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 loadUseAiChat(): UseAiChatModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../mpuniapp/pages/portal/useAiChat.uts') as UseAiChatModule; } function buildReply(extra?: Partial): AiChatReply { return { reply: 'I am the digital concierge for this card.', locale: 'en-US', shouldCollectLead: false, ...extra, }; } describe('1. 空文本不发', () => { it('1.1 空字符串与空白不调用 askAiChat', async () => { const { sendChat } = loadUseAiChat(); const askAiChat = jest.fn, [AiChatRequest]>(); const empty = await sendChat( { cardId: 'card-101', messages: [], text: '' }, { askAiChat } ); const blank = await sendChat( { cardId: 'card-101', messages: [], text: ' ' }, { askAiChat } ); expect(empty.sent).toBe(false); expect(blank.sent).toBe(false); expect(askAiChat).not.toHaveBeenCalled(); }); it('1.2 缺少 cardId 时即使有文本也不发', async () => { const { sendChat } = loadUseAiChat(); const askAiChat = jest.fn, [AiChatRequest]>(); const result = await sendChat( { cardId: ' ', locale: 'zh-CN', messages: [], text: 'Hello' }, { askAiChat } ); expect(result.sent).toBe(false); expect(askAiChat).not.toHaveBeenCalled(); }); }); describe('2. sendChat 调 askAiChat', () => { it('2.1 合法输入会注入并调用 askAiChat', async () => { const { sendChat } = loadUseAiChat(); const askAiChat = jest .fn, [AiChatRequest]>() .mockResolvedValue(buildReply({ locale: 'en-US' })); const result = await sendChat( { cardId: 'card-101', locale: 'en-US', messages: [], text: 'Tell me about the business', }, { askAiChat } ); expect(result.sent).toBe(true); expect(askAiChat).toHaveBeenCalledTimes(1); const req = askAiChat.mock.calls[0][0]; expect(req.cardId).toBe('card-101'); expect(req.locale).toBe('en-US'); expect(req.messages.length).toBe(1); expect(req.messages[0].role).toBe('user'); expect(req.messages[0].content).toBe('Tell me about the business'); expect(result.reply?.reply).toBe('I am the digital concierge for this card.'); }); }); describe('3. appendTurn 追加 user+assistant', () => { it('3.1 返回新数组并追加一轮 user 与 assistant', () => { const { appendTurn, buildUserMessage } = loadUseAiChat(); const prev: AiChatMessage[] = [buildUserMessage('prev')]; const reply = buildReply({ reply: 'Happy to introduce the team.' }); const next = appendTurn(prev, 'Who are you?', reply); expect(next).not.toBe(prev); expect(prev.length).toBe(1); expect(next.length).toBe(3); expect(next[1].role).toBe('user'); expect(next[1].content).toBe('Who are you?'); expect(next[2].role).toBe('assistant'); expect(next[2].content).toBe('Happy to introduce the team.'); }); it('3.2 shouldCollectLead 时给出引导标记', async () => { const { sendChat } = loadUseAiChat(); const askAiChat = jest .fn, [AiChatRequest]>() .mockResolvedValue( buildReply({ reply: 'Please leave your name and contact details.', locale: 'zh-CN', shouldCollectLead: true, }) ); const result = await sendChat( { cardId: 'card-202', locale: 'zh-CN', messages: [], text: '报价' }, { askAiChat } ); expect(result.sent).toBe(true); expect(result.shouldCollectLead).toBe(true); expect(result.leadHint.length).toBeGreaterThan(0); expect(result.messages[result.messages.length - 1].role).toBe('assistant'); }); }); describe('4. P49 留资意向判定 hasLeadIntent', () => { it('4.1 命中价格/报价/合作/buy/quote/price 等关键词为真', () => { const { hasLeadIntent } = loadUseAiChat(); expect(hasLeadIntent('请问标准版价格多少')).toBe(true); expect(hasLeadIntent('发一份报价')).toBe(true); expect(hasLeadIntent('希望商务合作')).toBe(true); expect(hasLeadIntent('I want to buy')).toBe(true); expect(hasLeadIntent('Can you quote pricing?')).toBe(true); expect(hasLeadIntent('What is the PRICE')).toBe(true); }); it('4.2 普通闲聊与空文本不为真', () => { const { hasLeadIntent } = loadUseAiChat(); expect(hasLeadIntent('你好')).toBe(false); expect(hasLeadIntent('介绍一下服务')).toBe(false); expect(hasLeadIntent('Good morning')).toBe(false); expect(hasLeadIntent('')).toBe(false); expect(hasLeadIntent(' ')).toBe(false); }); }); describe('5. P49 sendStreamChat 按意向弹留资卡', () => { it('5.1 有意向时追加 lead_card,姓名为空且未提交', async () => { const { createAiChatState, sendStreamChat } = loadUseAiChat(); const state = createAiChatState(); state.cardId = 'card-p49'; state.draft = '请问方案报价多少?'; const ok = await sendStreamChat(state, undefined, { intervalMs: 0 }); expect(ok).toBe(true); expect(state.messages.length).toBe(3); const cardMsg = state.messages[2]; expect(cardMsg.type).toBe('lead_card'); expect(cardMsg.cardPayload).toBeDefined(); expect(cardMsg.cardPayload?.name).toBe(''); expect(cardMsg.cardPayload?.contact).toBe(''); expect(cardMsg.cardPayload?.submitted).toBe(false); expect(state.showLeadHint).toBe(true); }); it('5.2 无意向时不追加留资卡', async () => { const { createAiChatState, sendStreamChat } = loadUseAiChat(); const state = createAiChatState(); state.cardId = 'card-p49'; state.draft = '名片支持哪些展示模块?'; const ok = await sendStreamChat(state, undefined, { intervalMs: 0 }); expect(ok).toBe(true); expect(state.messages.length).toBe(2); expect(state.messages.some((m) => m.type === 'lead_card')).toBe(false); expect(state.showLeadHint).toBe(false); }); }); describe('6. P49 submitLeadCard 走真实线索接口契约', () => { it('6.1 成功提交时调用 submitLead 并标记 submitted', async () => { const { createLeadCardPayload, submitLeadCard } = loadUseAiChat(); const payload = createLeadCardPayload('zh-CN'); payload.name = 'Alex Wang'; payload.contact = 'alex@example.com'; const submitLead = jest .fn, [SubmitLeadParams]>() .mockResolvedValue({ id: 'lead-1' }); const ok = await submitLeadCard('card-p49', payload, { submitLead }); expect(ok).toBe(true); expect(submitLead).toHaveBeenCalledTimes(1); const params = submitLead.mock.calls[0][0]; expect(params.cardId).toBe('card-p49'); expect(params.name).toBe('Alex Wang'); expect(params.email).toBe('alex@example.com'); expect(params.source).toBe('ai_chat'); expect(payload.submitted).toBe(true); expect(payload.submitting).toBe(false); expect(payload.errorMessage).toBe(''); }); it('6.2 接口失败显示错误且不把卡片标成已提交', async () => { const { createLeadCardPayload, submitLeadCard, resolveAiChatText } = loadUseAiChat(); const payload = createLeadCardPayload('en-US'); payload.name = 'Jordan Lee'; payload.contact = '+15551234567'; const submitLead = jest .fn, [SubmitLeadParams]>() .mockRejectedValue(new Error('network')); const ok = await submitLeadCard('card-p49', payload, { submitLead }); expect(ok).toBe(false); expect(payload.submitted).toBe(false); expect(payload.submitting).toBe(false); expect(payload.errorMessage).toBe(resolveAiChatText('submitFailed')); expect(payload.errorMessage.length).toBeGreaterThan(0); }); it('6.3 留资卡初始不造假客户姓名', () => { const { createLeadCardPayload, createLeadCardMessage } = loadUseAiChat(); const payload = createLeadCardPayload('zh-CN'); expect(payload.name).toBe(''); expect(payload.contact).toBe(''); expect(payload.submitted).toBe(false); const msg = createLeadCardMessage('en-US'); expect(msg.type).toBe('lead_card'); expect(msg.cardPayload?.name).toBe(''); }); }); }); describe('OmniCard AI 门户顾问对话页契约 (ai-chat.uvue)', () => { it('页面存在 list-view', () => { expect(fs.existsSync(AI_CHAT_PAGE)).toBe(true); expect(fs.existsSync(AI_CHAT_UTS)).toBe(true); const pageSource = fs.readFileSync(AI_CHAT_PAGE, 'utf8'); const logicSource = fs.readFileSync(AI_CHAT_UTS, 'utf8'); expect(pageSource.includes('script setup lang="uts"')).toBe(true); expect(pageSource.includes(' { const pageSource = fs.readFileSync(AI_CHAT_PAGE, 'utf8'); const logicSource = fs.readFileSync(AI_CHAT_UTS, 'utf8'); expect(pageSource.includes("item.type === 'lead_card'")).toBe(true); expect(pageSource.includes('ai-lead-card')).toBe(true); expect(pageSource.includes('cardPayload.errorMessage')).toBe(true); expect(pageSource.includes('submitLeadCard')).toBe(true); expect(pageSource.includes('handleSubmitCard')).toBe(true); expect(logicSource.includes('hasLeadIntent')).toBe(true); expect(logicSource.includes('createLeadCardMessage')).toBe(true); expect(logicSource.includes('submitLead')).toBe(true); expect(logicSource.includes("source: 'ai_chat'")).toBe(true); expect(logicSource.includes("payload.errorMessage = resolveAiChatText('submitFailed')")).toBe( true ); const submitFnStart = logicSource.indexOf('export async function submitLeadCard'); expect(submitFnStart).toBeGreaterThanOrEqual(0); const submitFnBody = logicSource.slice(submitFnStart); const catchIdx = submitFnBody.indexOf('} catch (_e) {'); expect(catchIdx).toBeGreaterThan(0); const failCatch = submitFnBody.slice(catchIdx, catchIdx + 220); expect(failCatch.includes('payload.submitted = true')).toBe(false); expect(failCatch.includes("resolveAiChatText('submitFailed')")).toBe(true); }); it('无 emoji、无 any,对话不用 view v-for,无自动播放声音', () => { const pageSource = fs.readFileSync(AI_CHAT_PAGE, 'utf8'); const logicSource = fs.readFileSync(AI_CHAT_UTS, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); const template = pageSource.split(']*v-for=/.test(pageSource)).toBe(false); expect(/[\u4e00-\u9fff]/.test(template)).toBe(false); expect(pageSource.includes('innerAudioContext') || pageSource.includes('autoplay')).toBe( false ); expect((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0); expect(pageSource.includes('ai-lead-card-submitted')).toBe(true); expect(pageSource.includes('cubic-bezier(0.2, 0.8, 0.2, 1)')).toBe(true); }); });