/** * 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 AiChatMessage = { role: AiChatRole; content: string; }; 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 UseAiChatModule = { buildUserMessage: (text: string) => AiChatMessage; appendTurn: ( messages: AiChatMessage[], userText: string, reply: AiChatReply ) => AiChatMessage[]; sendChat: (input: SendChatInput, api?: SendChatApi) => Promise; }; 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('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'); 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); }); });