文件
OmniCard/backend/test/mpuniapp/ai-chat.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

443 行
15 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 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<AiChatReply>;
};
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<unknown>;
};
type UseAiChatModule = {
buildUserMessage: (text: string) => AiChatMessage;
appendTurn: (
messages: AiChatMessage[],
userText: string,
reply: AiChatReply
) => AiChatMessage[];
sendChat: (input: SendChatInput, api?: SendChatApi) => Promise<SendChatResult>;
hasLeadIntent: (text: string) => boolean;
createAiChatState: () => AiChatState;
createLeadCardPayload: (locale?: string) => ChatLeadPayload;
createLeadCardMessage: (locale?: string) => AiChatMessage;
sendStreamChat: (
state: AiChatState,
text?: string,
options?: { intervalMs?: number }
) => Promise<boolean>;
submitLeadCard: (
cardId: string | number,
payload: ChatLeadPayload,
api?: SubmitLeadApi
) => Promise<boolean>;
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<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('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<Promise<unknown>, [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<Promise<unknown>, [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('<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('P49 留资卡按 type===lead_card 渲染,提交走 submitLead,失败展示 errorMessage', () => {
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('<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);
});
});