补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
443 行
15 KiB
TypeScript
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);
|
|
});
|
|
});
|