文件
OmniCard/backend/test/mpuniapp/ai-chat.test.ts

249 行
8.0 KiB
TypeScript

/**
* 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<AiChatReply>;
};
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<SendChatResult>;
};
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u;
describe('OmniCard 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 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>): 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<Promise<AiChatReply>, [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<Promise<AiChatReply>, [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<Promise<AiChatReply>, [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<Promise<AiChatReply>, [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('<list-view')).toBe(true);
expect(pageSource.includes('<list-item')).toBe(true);
expect(pageSource.includes('<input')).toBe(true);
expect(pageSource.includes('getOmniText')).toBe(true);
expect(pageSource.includes('card.ai_chat') || logicSource.includes('card.ai_chat')).toBe(
true
);
expect(pageSource.includes('#F7F8FA')).toBe(true);
expect(pageSource.includes('#0B5CFF')).toBe(true);
expect(pageSource.includes('#7C5CFF')).toBe(true);
expect(pageSource.includes('prefers-reduced-motion')).toBe(true);
expect(pageSource.includes('askAiChat') || logicSource.includes('askAiChat')).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('<script')[0];
expect(EMOJI_RE.test(pageSource)).toBe(false);
expect(EMOJI_RE.test(logicSource)).toBe(false);
expect(EMOJI_RE.test(testSource)).toBe(false);
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
expect(/<view[^>]*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);
});
});