feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -17,10 +17,24 @@ const AI_CHAT_UTS = path.resolve(
);
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 = {
@@ -54,6 +68,30 @@ type SendChatResult = {
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: (
@@ -62,6 +100,21 @@ type UseAiChatModule = {
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;
@@ -200,6 +253,119 @@ describe('OmniCard AI 门户顾问对话业务逻辑测试 (useAiChat.uts)', ()
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)', () => {
@@ -225,6 +391,34 @@ describe('OmniCard AI 门户顾问对话页契约 (ai-chat.uvue)', () => {
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');

查看文件

@@ -74,6 +74,10 @@ type ArticleDetailModule = {
getArticleShareFields: (state: ArticleDetailState, pageUrl: string) => ArticleShareFields;
loadArticleDetail: (state: ArticleDetailState) => Promise<BentoBlockFixture | null>;
copyArticleShareLink: (state: ArticleDetailState, pageUrl?: string) => boolean;
shareArticleViaSystem: (
state: ArticleDetailState,
pageUrl?: string
) => Promise<boolean>;
resolveArticleDetailText: (key: string, locale?: string) => string;
updateArticleScrollDepth: (
state: ArticleDetailState,
@@ -85,6 +89,12 @@ type ArticleDetailModule = {
navToAuthorPortal: (state: ArticleDetailState) => void;
};
const SHARE_SUCCESS_TOAST_RE = /已分享|Shared(?! content)|Partagé|Compartido|Geteilt|共有しました/i;
function assertNoShareSuccessToast(toasts: UniToastOption[]): void {
expect(toasts.some(t => SHARE_SUCCESS_TOAST_RE.test(t.title))).toBe(false);
}
type UniToastOption = {
title: string;
icon?: string;
@@ -135,10 +145,21 @@ function buildPortalPayload(blocks: BentoBlockFixture[]) {
};
}
type ShareWithSystemOption = {
type?: string;
summary?: string;
href?: string;
success?: () => void;
fail?: (err: { errCode?: number; errMsg?: string }) => void;
};
describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.uts)', () => {
let storageMap: Map<string, string>;
let toastCalls: UniToastOption[];
let clipboardText: string;
let clipboardMode: 'success' | 'fail';
let shareMode: 'success' | 'fail' | 'cancel' | 'missing';
let shareCalls: ShareWithSystemOption[];
let lastRequestOptions: UniRequestOption | null;
let requestCallCount: number;
let mockRequestResponse: {
@@ -155,6 +176,9 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
storageMap = new Map();
toastCalls = [];
clipboardText = '';
clipboardMode = 'success';
shareMode = 'success';
shareCalls = [];
lastRequestOptions = null;
requestCallCount = 0;
mockRequestResponse = {
@@ -192,7 +216,7 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
]),
};
(global as { uni?: Record<string, unknown> }).uni = {
const uniMock: Record<string, unknown> = {
getStorageSync: (key: string) => storageMap.get(key) || '',
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
removeStorageSync: (key: string) => storageMap.delete(key),
@@ -204,10 +228,34 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
reLaunch: jest.fn(),
navigateBack: jest.fn(),
getCurrentPages: () => [],
setClipboardData: (options: { data: string; success?: () => void }) => {
setClipboardData: (options: {
data: string;
success?: () => void;
fail?: (err: { errMsg: string }) => void;
}) => {
if (clipboardMode === 'fail') {
options.fail?.({ errMsg: 'setClipboardData:fail' });
return;
}
clipboardText = options.data;
options.success?.();
},
shareWithSystem: (options: ShareWithSystemOption) => {
shareCalls.push(options);
if (shareMode === 'missing') {
options.fail?.({ errCode: 1310602, errMsg: 'share unavailable' });
return;
}
if (shareMode === 'success') {
options.success?.();
return;
}
if (shareMode === 'cancel') {
options.fail?.({ errCode: 1310600, errMsg: 'Cancel share' });
return;
}
options.fail?.({ errCode: 1310602, errMsg: 'share fail' });
},
request: (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
@@ -228,6 +276,8 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
};
},
};
(global as { uni?: Record<string, unknown> }).uni = uniMock;
});
afterEach(() => {
@@ -406,6 +456,8 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
applyArticleDetailOptions,
loadArticleDetail,
getArticleShareFields,
resolveArticleDetailText,
copyArticleShareLink,
} = loadUseArticleDetail();
const state = createArticleDetailState();
@@ -425,13 +477,22 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
expect(typeof share[key]).toBe('string');
expect(share[key].length).toBeGreaterThan(0);
}
expect(share.ctaLabel).toBe('复制链接');
// 底栏主 CTA 语义:复制分享链接(走 resolve,中英齐全)
expect(share.ctaLabel).toBe(resolveArticleDetailText('shareCta', 'zh-CN'));
expect(share.ctaLabel).toBe('复制分享链接');
expect(resolveArticleDetailText('shareCta', 'en-US')).toBe('Copy share link');
expect(share.shareUrl).toBe('https://omnicard.ai/p/article-3');
expect(share.title).toBe('The Future of Cross-Border Trade');
expect(share.image).toBe('https://cdn.omnicard.ai/media/article-cover.jpg');
// 有 linkUrl 时剪贴板复制块链接(不新造域名)
const copied = copyArticleShareLink(state, 'https://omnicard.ai/h5/article');
expect(copied).toBe(true);
expect(clipboardText).toBe('https://omnicard.ai/p/article-3');
expect(toastCalls.length).toBeGreaterThan(0);
});
it('3.2 无 linkUrl 时回退当前页 URL,复制写入剪贴板', async () => {
it('3.2 无 linkUrl 时回退当前页路径 URL,复制写入剪贴板', async () => {
const article = buildArticleBlock();
article.linkUrl = '';
mockRequestResponse = {
@@ -446,6 +507,7 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
resolveShareUrl,
getArticleShareFields,
copyArticleShareLink,
resolveArticleDetailText,
} = loadUseArticleDetail();
expect(resolveShareUrl('', 'https://omnicard.ai/h5/article-fallback')).toBe(
@@ -462,7 +524,8 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
const pageUrl = 'https://omnicard.ai/h5/article-fallback';
const share = getArticleShareFields(state, pageUrl);
expect(share.shareUrl).toBe(pageUrl);
expect(share.ctaLabel).toBe('复制链接');
expect(share.ctaLabel).toBe(resolveArticleDetailText('shareCta'));
expect(share.ctaLabel).toBe('复制分享链接');
expect(share.copiedToast.length).toBeGreaterThan(0);
const copied = copyArticleShareLink(state, pageUrl);
@@ -566,11 +629,13 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
expect(resolveArticleDetailText('summary', 'zh-CN')).toBe('核心导读');
expect(resolveArticleDetailText('author', 'zh-CN')).toBe('专栏作者');
expect(resolveArticleDetailText('viewPortal', 'zh-CN')).toBe('查看微官网');
expect(resolveArticleDetailText('shareCta', 'zh-CN')).toBe('复制分享链接');
expect(resolveArticleDetailText('kicker', 'en-US')).toBe('Thought Leadership');
expect(resolveArticleDetailText('summary', 'en-US')).toBe('Key Takeaways');
expect(resolveArticleDetailText('author', 'en-US')).toBe('Author');
expect(resolveArticleDetailText('viewPortal', 'en-US')).toBe('View Profile');
expect(resolveArticleDetailText('shareCta', 'en-US')).toBe('Copy share link');
expect(resolveArticleDetailText('kicker', 'ar-SA')).toBe('رؤى متعمقة');
expect(resolveArticleDetailText('summary', 'ar-SA')).toBe('أبرز النقاط');
@@ -579,4 +644,167 @@ describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.
expect(resolveArticleDetailText('summary', 'es-ES')).toBe('Puntos clave');
});
});
describe('7. R50 系统分享仅成功才提示已分享', () => {
async function loadArticleState(): Promise<ArticleDetailState> {
const {
createArticleDetailState,
applyArticleDetailOptions,
loadArticleDetail,
} = loadUseArticleDetail();
const state = createArticleDetailState();
applyArticleDetailOptions(state, { cardId: 'card-101', blockId: '3' });
await loadArticleDetail(state);
return state;
}
it('7.1 shareWithSystem 明确 success 才 toast 已分享', async () => {
const { shareArticleViaSystem, resolveArticleDetailText } = loadUseArticleDetail();
const state = await loadArticleState();
toastCalls = [];
shareMode = 'success';
const ok = await shareArticleViaSystem(state, 'https://omnicard.ai/h5/article');
expect(ok).toBe(true);
expect(shareCalls.length).toBe(1);
expect(shareCalls[0].href).toBe('https://omnicard.ai/p/article-3');
expect(toastCalls.some(t => t.title === resolveArticleDetailText('shareSuccess'))).toBe(
true
);
expect(resolveArticleDetailText('shareSuccess', 'zh-CN')).toBe('已分享');
});
it('7.2 系统分享 API 失败不 toast 已分享', async () => {
const { shareArticleViaSystem } = loadUseArticleDetail();
const state = await loadArticleState();
toastCalls = [];
shareMode = 'fail';
const ok = await shareArticleViaSystem(state, 'https://omnicard.ai/h5/article');
expect(ok).toBe(false);
expect(shareCalls.length).toBe(1);
assertNoShareSuccessToast(toastCalls);
});
it('7.3 用户取消分享不 toast 已分享', async () => {
const { shareArticleViaSystem } = loadUseArticleDetail();
const state = await loadArticleState();
toastCalls = [];
shareMode = 'cancel';
const ok = await shareArticleViaSystem(state, 'https://omnicard.ai/h5/article');
expect(ok).toBe(false);
expect(shareCalls.length).toBe(1);
assertNoShareSuccessToast(toastCalls);
});
it('7.4 系统分享 API 不存在时不 toast 已分享', async () => {
const uniRef = (global as { uni?: Record<string, unknown> }).uni;
if (uniRef != null) {
delete uniRef.shareWithSystem;
}
const { shareArticleViaSystem } = loadUseArticleDetail();
const state = await loadArticleState();
toastCalls = [];
shareCalls = [];
const ok = await shareArticleViaSystem(state, 'https://omnicard.ai/h5/article');
expect(ok).toBe(false);
expect(shareCalls.length).toBe(0);
assertNoShareSuccessToast(toastCalls);
});
it('7.5 空分享链接不调用分享 API,不 toast 已分享', async () => {
const { createArticleDetailState, shareArticleViaSystem } = loadUseArticleDetail();
const state = createArticleDetailState();
state.linkUrl = '';
toastCalls = [];
shareCalls = [];
const ok = await shareArticleViaSystem(state, '');
expect(ok).toBe(false);
expect(shareCalls.length).toBe(0);
assertNoShareSuccessToast(toastCalls);
});
it('7.6 复制链接失败不说成已分享;成功仍用直链已复制', async () => {
const {
copyArticleShareLink,
getArticleShareFields,
resolveArticleDetailText,
} = loadUseArticleDetail();
const state = await loadArticleState();
// 复制失败
clipboardMode = 'fail';
toastCalls = [];
const failed = copyArticleShareLink(state, 'https://omnicard.ai/h5/article');
expect(failed).toBe(true);
assertNoShareSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveArticleDetailText('shareSuccess'))
).toBe(false);
// P22:复制成功提示保持不变
clipboardMode = 'success';
toastCalls = [];
const share = getArticleShareFields(state, 'https://omnicard.ai/h5/article');
const ok = copyArticleShareLink(state, 'https://omnicard.ai/h5/article');
expect(ok).toBe(true);
expect(toastCalls.some(t => t.title === share.copiedToast)).toBe(true);
expect(toastCalls.some(t => t.title === '直链已复制')).toBe(true);
assertNoShareSuccessToast(toastCalls);
});
});
});
describe('OmniCard AI 文章详情页分享诚实反馈 (article-detail.uvue)', () => {
const fs = require('fs') as typeof import('fs');
const path = require('path') as typeof import('path');
const ROOT = path.resolve(__dirname, '../../../mpuniapp/pages/portal');
const PAGE = path.join(ROOT, 'article-detail.uvue');
const UTS = path.join(ROOT, 'useArticleDetail.uts');
it('系统分享成功 toast 仅出现在 shareWithSystem success 回调;失败路径无已分享', () => {
const pageSource = fs.readFileSync(PAGE, 'utf8');
const logicSource = fs.readFileSync(UTS, 'utf8');
expect(logicSource.includes('shareArticleViaSystem')).toBe(true);
expect(logicSource.includes('shareWithSystem')).toBe(true);
expect(
pageSource.includes('shareArticleViaSystem') ||
pageSource.includes('performArticleShareAction')
).toBe(true);
const successKeyIdx = logicSource.indexOf("resolveArticleDetailText('shareSuccess')");
expect(successKeyIdx).toBeGreaterThan(-1);
const shareApiIdx = logicSource.indexOf('shareWithSystem');
expect(shareApiIdx).toBeGreaterThan(-1);
expect(successKeyIdx).toBeGreaterThan(shareApiIdx);
// fail 分支不得含已分享
const failIdx = logicSource.indexOf('fail:', shareApiIdx);
expect(failIdx).toBeGreaterThan(successKeyIdx);
const failBody = logicSource.slice(failIdx, failIdx + 280);
expect(failBody.includes('shareSuccess')).toBe(false);
expect(SHARE_SUCCESS_TOAST_RE.test(failBody)).toBe(false);
// 复制失败分支也不得已分享
const clipIdx = logicSource.indexOf('setClipboardData');
expect(clipIdx).toBeGreaterThan(-1);
const clipFailIdx = logicSource.indexOf('fail:', clipIdx);
expect(clipFailIdx).toBeGreaterThan(clipIdx);
const clipFailBody = logicSource.slice(clipFailIdx, clipFailIdx + 200);
expect(clipFailBody.includes('shareSuccess')).toBe(false);
expect(SHARE_SUCCESS_TOAST_RE.test(clipFailBody)).toBe(false);
// P22 复制成功仍用 portal.copied_link
expect(logicSource.includes("portal.copied_link")).toBe(true);
});
});

查看文件

@@ -21,6 +21,8 @@ type CaseDetailState = {
kpis: CaseKpi[];
pdfUrl: string;
youtubeUrl: string;
videoUrl?: string;
blockType?: string;
};
type CaseDetailPageOptions = {
@@ -39,6 +41,7 @@ type MockBentoBlock = {
title?: string;
content?: string;
mediaUrl?: string;
linkUrl?: string;
metaJson?:
| string
| {
@@ -95,7 +98,7 @@ type UseCaseDetailModule = {
buildExchangePath: (cardId: string) => string;
openCaseExchange: (cardId: string) => void;
goCaseBack: () => void;
copyCaseExternalLink: (url: string) => boolean;
openCaseExternalLink: (url: string) => boolean;
resolveCaseDetailText: (key: string) => string;
};
@@ -150,6 +153,24 @@ function buildMockPortal(): MockPortalCard {
sortOrder: 2,
title: 'The Future of Cross-Border Trade',
},
{
id: 4,
cardId: 'card-101',
type: 'video',
colSpan: 2,
rowSpan: 2,
sortOrder: 3,
title: 'Automation Screening Lab Video Demo',
content: 'Robotic arms 7x24 high throughput screening.',
mediaUrl: 'https://cdn.omnicard.ai/video/poster.jpg',
linkUrl: 'https://cdn.omnicard.ai/video/demo.mp4',
metaJson: {
videoUrl: 'https://cdn.omnicard.ai/video/demo.mp4',
poster: 'https://cdn.omnicard.ai/video/poster.jpg',
duration: '02:05',
kpis: [{ label: 'Speedup', value: '4.2x' }],
},
},
],
};
}
@@ -158,6 +179,7 @@ describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts
let lastRequestOptions: UniRequestOption | null;
let navigateToUrl: string;
let navigateBackCount: number;
let openUrlCalls: string[];
let mockRequestResponse: {
statusCode?: number;
data?: {
@@ -167,11 +189,13 @@ describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts
};
failError?: { errMsg: string };
};
let originalWindow: (Window & typeof globalThis) | undefined;
beforeEach(() => {
lastRequestOptions = null;
navigateToUrl = '';
navigateBackCount = 0;
openUrlCalls = [];
mockRequestResponse = {
statusCode: 200,
data: {
@@ -181,10 +205,19 @@ describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts
},
};
originalWindow = (global as { window?: Window & typeof globalThis }).window;
(global as { window?: { open: (url: string, target?: string) => null } }).window = {
open: (url: string) => {
openUrlCalls.push(url);
return null;
},
};
(global as { uni?: Record<string, unknown> }).uni = {
getStorageSync: () => '',
setStorageSync: () => undefined,
showToast: () => undefined,
setClipboardData: () => undefined,
navigateTo: (options: { url: string }) => {
navigateToUrl = options.url;
},
@@ -214,6 +247,11 @@ describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts
afterEach(() => {
delete (global as { uni?: unknown }).uni;
if (originalWindow != null) {
(global as { window?: Window & typeof globalThis }).window = originalWindow;
} else {
delete (global as { window?: unknown }).window;
}
});
describe('1. 从门户聚合数据选出 case 块', () => {
@@ -410,7 +448,7 @@ describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts
expect(state.kpis).toEqual([{ label: 'On-time Delivery', value: '99.8%' }]);
});
it('5.2 resolveCaseDetailText 正确解析新增的 Bento 模组词条', () => {
it('5.2 resolveCaseDetailText 外链行动作为打开语义(中英,非裸复制)', () => {
const { resolveCaseDetailText } = loadUseCaseDetail();
expect(resolveCaseDetailText('badge')).toBeDefined();
@@ -419,32 +457,133 @@ describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts
expect(resolveCaseDetailText('solution')).toBeDefined();
expect(resolveCaseDetailText('kpis')).toBeDefined();
expect(resolveCaseDetailText('resources')).toBeDefined();
expect(resolveCaseDetailText('pdfAction')).toBeDefined();
expect(resolveCaseDetailText('videoAction')).toBeDefined();
const pdfAction = resolveCaseDetailText('pdfAction');
const videoAction = resolveCaseDetailText('videoAction');
expect(pdfAction.length).toBeGreaterThan(0);
expect(videoAction.length).toBeGreaterThan(0);
expect(pdfAction).not.toMatch(/^https?:\/\//i);
expect(videoAction).not.toMatch(/^https?:\/\//i);
expect(pdfAction.toLowerCase()).not.toContain('copy');
expect(videoAction.toLowerCase()).not.toContain('copy');
expect(pdfAction).not.toContain('复制');
expect(videoAction).not.toContain('复制');
});
it('5.3 copyCaseExternalLink 触发 uni.setClipboardData 与 Toast 提示', () => {
let clipboardText = '';
let toastCalled = false;
it('5.3 openCaseExternalLink 有 url 时 window.open 打开外链', () => {
const { openCaseExternalLink } = loadUseCaseDetail();
const pdf = 'https://cdn.omnicard.ai/case/brief.pdf';
const video = 'https://www.youtube.com/watch?v=abc123';
expect(openCaseExternalLink(pdf)).toBe(true);
expect(openCaseExternalLink(video)).toBe(true);
expect(openUrlCalls).toEqual([pdf, video]);
});
it('5.4 openCaseExternalLink 无 url 时不打开、不复制', () => {
let clipboardCalls = 0;
(global as { uni?: Record<string, unknown> }).uni = {
setClipboardData: (opts: { data: string; success?: () => void }) => {
clipboardText = opts.data;
opts.success?.();
},
showToast: () => {
toastCalled = true;
...(global as { uni?: Record<string, unknown> }).uni,
setClipboardData: () => {
clipboardCalls += 1;
},
};
const { copyCaseExternalLink } = loadUseCaseDetail();
const res = copyCaseExternalLink('https://cdn.omnicard.ai/doc.pdf');
const { openCaseExternalLink } = loadUseCaseDetail();
expect(openCaseExternalLink('')).toBe(false);
expect(openCaseExternalLink(' ')).toBe(false);
expect(openUrlCalls).toEqual([]);
expect(clipboardCalls).toBe(0);
});
expect(res).toBe(true);
expect(clipboardText).toBe('https://cdn.omnicard.ai/doc.pdf');
expect(toastCalled).toBe(true);
it('5.5 缺 pdf/youtube 时 applyPortalCase 外链字段为空(页面应隐藏行)', () => {
const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } =
loadUseCaseDetail();
const card = buildMockPortal();
card.bentoBlocks[1].metaJson = {
kpis: [{ label: 'ROI', value: '32%' }],
};
const emptyRes = copyCaseExternalLink('');
expect(emptyRes).toBe(false);
const state = createCaseDetailState();
applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '2' });
applyPortalCase(state, card);
expect(state.pdfUrl).toBe('');
expect(state.youtubeUrl).toBe('');
});
});
describe('6. 视频演示 (video) 块详情支持与直链解析 (TDD)', () => {
it('6.1 pickCaseBlock 支持匹配 type 为 video 的块', () => {
const { pickCaseBlock } = loadUseCaseDetail();
const card = buildMockPortal();
const block = pickCaseBlock(card, '4');
expect(block).not.toBeNull();
expect(block?.type).toBe('video');
expect(block?.title).toBe('Automation Screening Lab Video Demo');
});
it('6.2 applyPortalCase 对 video 块能正确提取 videoUrl、blockType 与海报封面', () => {
const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } =
loadUseCaseDetail();
const state = createCaseDetailState();
applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '4' });
const found = applyPortalCase(state, buildMockPortal());
expect(found).toBe(true);
expect(state.empty).toBe(false);
expect((state as any).blockType).toBe('video');
expect((state as any).videoUrl).toBe('https://cdn.omnicard.ai/video/demo.mp4');
expect(state.title).toBe('Automation Screening Lab Video Demo');
expect(state.content).toBe('Robotic arms 7x24 high throughput screening.');
expect(state.images).toContain('https://cdn.omnicard.ai/video/poster.jpg');
expect(state.kpis).toEqual([{ label: 'Speedup', value: '4.2x' }]);
});
it('6.3 无 metaJson.videoUrl 时,能回退从 block.linkUrl 解析为 videoUrl', () => {
const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } =
loadUseCaseDetail();
const card = buildMockPortal();
card.bentoBlocks[3].metaJson = {};
card.bentoBlocks[3].linkUrl = 'https://cdn.omnicard.ai/sample/lab.mp4';
const state = createCaseDetailState();
applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '4' });
applyPortalCase(state, card);
expect((state as any).videoUrl).toBe('https://cdn.omnicard.ai/sample/lab.mp4');
});
it('6.4 当视频链接为 YouTube 时,能正确解析为 youtubeUrl', () => {
const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } =
loadUseCaseDetail();
const card = buildMockPortal();
card.bentoBlocks[3].metaJson = {
youtubeUrl: 'https://www.youtube.com/watch?v=xyz789',
};
card.bentoBlocks[3].linkUrl = '';
const state = createCaseDetailState();
applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '4' });
applyPortalCase(state, card);
expect(state.youtubeUrl).toBe('https://www.youtube.com/watch?v=xyz789');
});
it('6.5 loadCaseDetail 针对 video 块能异步正常加载且 empty=false', async () => {
const { createCaseDetailState, applyCaseDetailOptions, loadCaseDetail } =
loadUseCaseDetail();
const state = createCaseDetailState();
applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '4' });
const found = await loadCaseDetail(state);
expect(found).toBe(true);
expect(state.empty).toBe(false);
expect((state as any).videoUrl).toBe('https://cdn.omnicard.ai/video/demo.mp4');
expect(state.title).toBe('Automation Screening Lab Video Demo');
});
});
});

查看文件

@@ -66,6 +66,7 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
name: 'Tariq Mansour',
status: 'new',
intentScore: 20,
persisted: true,
},
},
};
@@ -104,6 +105,14 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
delete (global as { uni?: Record<string, unknown> }).uni;
});
const SUCCESS_TOAST_RE = /已提交|已完成|已互换|已保存|交换成功|提交成功|Card Exchanged/i;
function assertNoSuccessToast(): void {
for (const toast of toastCalls) {
expect(SUCCESS_TOAST_RE.test(toast.title)).toBe(false);
}
}
function loadUseExchange() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../../mpuniapp/pages/portal/useExchange.uts') as {
@@ -139,16 +148,18 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
canSubmit: boolean;
errorMessage: string;
}) => Promise<unknown>;
isExchangeSubmitSuccess: (lead: unknown) => boolean;
};
}
describe('1. 表单校验不触发 submitLead', () => {
it('1.1 缺少姓名时不调用 submitLead', async () => {
describe('1. 表单校验不触发 submitLead,且不得报成功', () => {
it('1.1 缺少姓名时不调用 submitLead,不弹出已互换/已保存', async () => {
const { createExchangeState, applyExchangeOptions, submitExchange } =
loadUseExchange();
const state = createExchangeState();
applyExchangeOptions(state, { cardId: 'card-101' });
toastCalls = [];
state.form.phone = '+971501234567';
const result = await submitExchange(state);
@@ -158,14 +169,16 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
expect(lastRequestOptions).toBeNull();
expect(toastCalls.length).toBeGreaterThan(0);
expect(toastCalls[0].title).toContain('姓名');
assertNoSuccessToast();
});
it('1.2 缺少 WhatsApp 与邮箱时不调用 submitLead', async () => {
it('1.2 缺少 WhatsApp 与邮箱时不调用 submitLead,不报成功', async () => {
const { createExchangeState, applyExchangeOptions, submitExchange } =
loadUseExchange();
const state = createExchangeState();
applyExchangeOptions(state, { cardId: 'card-101' });
toastCalls = [];
state.form.name = 'Tariq Mansour';
const result = await submitExchange(state);
@@ -174,16 +187,34 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
expect(requestCallCount).toBe(0);
expect(lastRequestOptions).toBeNull();
expect(toastCalls.length).toBeGreaterThan(0);
assertNoSuccessToast();
});
});
describe('2. 合法数据调用 submitLead 并携带 cardId', () => {
it('2.1 姓名 + WhatsApp 会提交并带上 cardId', async () => {
it('1.3 姓名与联系方式全空时提交不得报成功', async () => {
const { createExchangeState, applyExchangeOptions, submitExchange } =
loadUseExchange();
const state = createExchangeState();
applyExchangeOptions(state, { cardId: 'card-101' });
toastCalls = [];
const result = await submitExchange(state);
expect(result).toBeNull();
expect(requestCallCount).toBe(0);
expect(state.form.name).toBe('');
assertNoSuccessToast();
});
});
describe('2. 合法数据调用 submitLead 并携带 cardId', () => {
it('2.1 姓名 + WhatsApp 会提交并带上 cardId,仅明确成功才提示', async () => {
const { createExchangeState, applyExchangeOptions, submitExchange } =
loadUseExchange();
const state = createExchangeState();
applyExchangeOptions(state, { cardId: 'card-101' });
toastCalls = [];
state.form.name = 'Tariq Mansour';
state.form.company = 'Dubai Energy Holding';
state.form.phone = '+971501234567';
@@ -202,6 +233,7 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
expect(state.form.name).toBe('');
expect(state.form.phone).toBe('');
expect(state.form.notes).toBe('');
expect(toastCalls.some(t => SUCCESS_TOAST_RE.test(t.title))).toBe(true);
});
it('2.2 姓名 + 邮箱同样会调用 submitLead', async () => {
@@ -221,6 +253,92 @@ describe('OmniCard AI 用户端无摩擦互换名片业务逻辑测试 (useExcha
});
});
describe('4. 接口失败或未落库不得报互换完成', () => {
it('4.1 接口失败时返回 null,不弹出已互换/已保存', async () => {
const { createExchangeState, applyExchangeOptions, submitExchange } =
loadUseExchange();
mockRequestResponse = {
statusCode: 200,
data: { code: 1001, message: '交换名片失败' },
};
const state = createExchangeState();
applyExchangeOptions(state, { cardId: 'card-101' });
toastCalls = [];
state.form.name = 'Tariq Mansour';
state.form.phone = '+971501234567';
const result = await submitExchange(state);
expect(result).toBeNull();
expect(requestCallCount).toBe(1);
expect(state.form.name).toBe('Tariq Mansour');
assertNoSuccessToast();
});
it('4.2 persisted 不为 true 时不得报成功、不得清空表单', async () => {
const {
createExchangeState,
applyExchangeOptions,
submitExchange,
isExchangeSubmitSuccess,
} = loadUseExchange();
mockRequestResponse.data = {
code: 1000,
message: 'success',
data: {
id: 'lead_mem_1',
cardId: 'card-101',
name: 'Tariq Mansour',
status: 'new',
intentScore: 20,
persisted: false,
},
};
expect(isExchangeSubmitSuccess({ id: 'x', persisted: false })).toBe(false);
expect(isExchangeSubmitSuccess({ id: 'x', persisted: true })).toBe(true);
const state = createExchangeState();
applyExchangeOptions(state, { cardId: 'card-101' });
toastCalls = [];
state.form.name = 'Tariq Mansour';
state.form.phone = '+971501234567';
const result = await submitExchange(state);
expect(result).toBeNull();
expect(requestCallCount).toBe(1);
expect(state.form.name).toBe('Tariq Mansour');
expect(state.errorMessage.length).toBeGreaterThan(0);
assertNoSuccessToast();
});
});
describe('5. 页面仅在明确成功时展示完成态', () => {
it('5.1 exchange.uvue 用 isExchangeSubmitSuccess 判定再置 submitSuccess', () => {
const fs = require('fs') as typeof import('fs');
const path = require('path') as typeof import('path');
const pagePath = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/exchange.uvue'
);
const pageSource = fs.readFileSync(pagePath, 'utf8');
expect(pageSource.includes('isExchangeSubmitSuccess')).toBe(true);
expect(pageSource.includes('submitSuccess.value = true')).toBe(true);
expect(pageSource.includes('successTitle')).toBe(true);
const submitFn = pageSource.indexOf('async function handleSubmit');
expect(submitFn).toBeGreaterThan(-1);
const submitBody = pageSource.slice(submitFn, submitFn + 600);
expect(submitBody.includes('isExchangeSubmitSuccess')).toBe(true);
expect(submitBody.includes('submitSuccess.value = true')).toBe(true);
});
});
describe('3. onLoad 名片标识', () => {
it('3.1 缺少 cardId 与 slug 时 toast 并禁止提交', () => {
const { createExchangeState, applyExchangeOptions, submitExchange } =

查看文件

@@ -179,4 +179,35 @@ describe('门户进入页语言解析 (resolvePortalLocale)', () => {
expect(storageMap.get('omni_locale')).toBe('en-US');
expect(readStoredPortalLocale()).toBe('en-US');
});
it('TabBar 5大菜单 key 在七语均有对应本地化文案,且支持 updateTabBarTitles()', () => {
const setTabBarItemMock = jest.fn();
(global as { uni?: unknown }).uni = {
setTabBarItem: setTabBarItemMock,
getStorageSync: () => 'en-US',
setStorageSync: () => {},
$emit: () => {},
};
const { omniLocales, updateTabBarTitles } = require(LOCALE_UTS) as {
omniLocales: Record<string, { messages: Record<string, string> }>;
updateTabBarTitles: (locale?: string) => void;
};
const tabKeys = ['tab.workplace', 'tab.card', 'tab.radar', 'tab.leads', 'tab.profile'];
for (const locale of ['zh-CN', 'en-US', 'ar-SA', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP']) {
for (const key of tabKeys) {
const text = omniLocales[locale].messages[key];
expect(typeof text).toBe('string');
expect(text.length).toBeGreaterThan(0);
}
}
setTabBarItemMock.mockClear();
updateTabBarTitles('en-US');
expect(setTabBarItemMock).toHaveBeenCalledTimes(5);
expect(setTabBarItemMock).toHaveBeenNthCalledWith(1, { index: 0, text: 'Workplace' });
expect(setTabBarItemMock).toHaveBeenNthCalledWith(2, { index: 1, text: 'Card' });
expect(setTabBarItemMock).toHaveBeenNthCalledWith(3, { index: 2, text: 'Radar' });
expect(setTabBarItemMock).toHaveBeenNthCalledWith(4, { index: 3, text: 'Leads' });
expect(setTabBarItemMock).toHaveBeenNthCalledWith(5, { index: 4, text: 'Profile' });
});
});

查看文件

@@ -41,15 +41,21 @@ type UseLoginModule = {
createLoginState: () => LoginState;
completeLogin: (token: string, tenantId?: string) => boolean;
submitPasswordLogin: (state: LoginState) => Promise<boolean>;
submitPhoneLogin: (state: LoginState) => boolean;
sendMagicLink: (state: LoginState) => boolean;
sendSmsCode: (state: LoginState) => boolean;
tapSocialLogin: (provider: 'google' | 'apple') => void;
submitPhoneLogin: (state: LoginState) => Promise<boolean>;
sendMagicLink: (state: LoginState) => Promise<boolean>;
sendSmsCode: (state: LoginState) => Promise<boolean>;
tapSocialLogin: (provider: 'google' | 'apple') => Promise<void>;
resolveLoginText: (key: string) => string;
tickCountdown: (state: LoginState) => void;
fillTestAccount: (state: LoginState) => void;
};
type MockPayload = {
statusCode?: number;
data?: unknown;
failError?: { errMsg: string };
};
describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', () => {
let storageMap: Map<string, string>;
let toastCalls: UniToastOption[];
@@ -57,6 +63,10 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
let requestCallCount: number;
let navigateBackCount: number;
let switchTabCount: number;
let clipboardCalls: Array<{ data: string }>;
let openUrlCalls: string[];
let mockByUrl: Map<string, MockPayload>;
let defaultMock: MockPayload;
beforeEach(() => {
storageMap = new Map();
@@ -65,6 +75,13 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
requestCallCount = 0;
navigateBackCount = 0;
switchTabCount = 0;
clipboardCalls = [];
openUrlCalls = [];
mockByUrl = new Map();
defaultMock = {
statusCode: 200,
data: { code: 1000, message: 'success', data: {} },
};
jest.resetModules();
(global as { uni?: Record<string, unknown> }).uni = {
@@ -81,16 +98,28 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
navigateBack: () => {
navigateBackCount += 1;
},
setClipboardData: (options: { data: string; success?: () => void }) => {
clipboardCalls.push({ data: options.data });
options.success?.();
},
getLocale: () => 'zh-CN',
request: (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
const matched = Array.from(mockByUrl.entries()).find(([path]) =>
(options.url || '').includes(path)
);
const payload = matched ? matched[1] : defaultMock;
setTimeout(() => {
options.success?.({
statusCode: 200,
data: { code: 1000, message: 'success', data: {} },
header: {},
});
if (payload.failError) {
options.fail?.(payload.failError);
} else {
options.success?.({
statusCode: payload.statusCode ?? 200,
data: payload.data,
header: {},
});
}
options.complete?.();
}, 0);
return {
@@ -98,10 +127,17 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
};
},
};
(global as { window?: { open?: (url: string) => void } }).window = {
open: (url: string) => {
openUrlCalls.push(url);
},
};
});
afterEach(() => {
delete (global as { uni?: Record<string, unknown> }).uni;
delete (global as { window?: unknown }).window;
});
function loadUseLogin(): UseLoginModule {
@@ -109,6 +145,13 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
return require('../../../mpuniapp/pages/common/useLogin.uts') as UseLoginModule;
}
function mockEndpoint(path: string, data: unknown, statusCode = 200): void {
mockByUrl.set(path, {
statusCode,
data: { code: 1000, message: 'success', data },
});
}
describe('1. completeLogin 写入本地凭证', () => {
it('1.1 completeLogin 会 setStorage token 并跳转名片页', () => {
const { completeLogin } = loadUseLogin();
@@ -142,13 +185,13 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
});
describe('2. 空手机号 / 空邮箱不提交', () => {
it('2.1 空手机号不提交验证码登录', () => {
it('2.1 空手机号不提交验证码登录', async () => {
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.smsCode = '123456';
const ok = submitPhoneLogin(state);
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
@@ -158,13 +201,13 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
expect(toastCalls[0].title).toContain('手机号');
});
it('2.2 空邮箱不提交 Magic Link', () => {
it('2.2 空邮箱不提交 Magic Link', async () => {
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.tab = 'email';
const ok = sendMagicLink(state);
const ok = await sendMagicLink(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
@@ -175,19 +218,192 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
});
});
describe('3. 社交登录不伪造成功', () => {
it('3.1 Google / Apple 仅 toast 即将开放且不写 token', () => {
describe('3. 外部登录:未配置不写 token / sent 不写 / ok 才写', () => {
it('3.1 smsSend not_configured:toast 即将开放且不写 token', async () => {
mockEndpoint('/app/user/external/smsSend', { status: 'not_configured' });
const { createLoginState, sendSmsCode } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
const ok = await sendSmsCode(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/smsSend');
expect(lastRequestOptions?.data).toEqual({ phone: '13800138000' });
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
expect(state.countdown).toBe(0);
});
it('3.2 smsSend sent:提示已发送,不写 token,启动倒计时', async () => {
mockEndpoint('/app/user/external/smsSend', { status: 'sent' });
const { createLoginState, sendSmsCode, tickCountdown } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
const ok = await sendSmsCode(state);
expect(ok).toBe(true);
expect(storageMap.has('token')).toBe(false);
expect(state.countdown).toBe(60);
expect(toastCalls[toastCalls.length - 1].title).toMatch(/已发送|sent/i);
tickCountdown(state);
expect(state.countdown).toBe(59);
const again = await sendSmsCode(state);
expect(again).toBe(false);
expect(state.countdown).toBe(59);
});
it('3.3 smsLogin not_configured:不写 token', async () => {
mockEndpoint('/app/user/external/smsLogin', { status: 'not_configured' });
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '123456';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/smsLogin');
expect(lastRequestOptions?.data).toEqual({ phone: '13800138000', code: '123456' });
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
});
it('3.4 smsLogin ok + token:写入 token 并跳转', async () => {
mockEndpoint('/app/user/external/smsLogin', {
status: 'ok',
token: 'mock_sms_jwt_token',
});
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '654321';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(true);
expect(storageMap.get('token')).toBe('mock_sms_jwt_token');
expect(switchTabCount).toBe(1);
});
it('3.5 smsLogin 请求失败:不写 token', async () => {
mockByUrl.set('/app/user/external/smsLogin', {
statusCode: 200,
data: { code: 1001, message: '验证码错误' },
});
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '000000';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
it('3.6 magicLink not_configured:不写 token', async () => {
mockEndpoint('/app/user/external/magicLink', { status: 'not_configured' });
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.email = 'demo@omnicard.ai';
const ok = await sendMagicLink(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/magicLink');
expect(lastRequestOptions?.data).toEqual({ email: 'demo@omnicard.ai' });
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
});
it('3.7 magicLink sent:提示已发送,不写 token', async () => {
mockEndpoint('/app/user/external/magicLink', { status: 'sent' });
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.email = 'demo@omnicard.ai';
const ok = await sendMagicLink(state);
expect(ok).toBe(true);
expect(storageMap.has('token')).toBe(false);
expect(toastCalls[toastCalls.length - 1].title).toMatch(/已发送|sent/i);
});
it('3.8 magicLink ok + token:写入 token', async () => {
mockEndpoint('/app/user/external/magicLink', {
status: 'ok',
token: 'mock_magic_jwt_token',
});
const { createLoginState, sendMagicLink } = loadUseLogin();
const state = createLoginState();
state.email = 'demo@omnicard.ai';
const ok = await sendMagicLink(state);
expect(ok).toBe(true);
expect(storageMap.get('token')).toBe('mock_magic_jwt_token');
expect(switchTabCount).toBe(1);
});
it('3.9 Google not_configured 且无 url:toast 即将开放,不写 token,不打开假地址', async () => {
mockEndpoint('/app/user/external/googleStart', { status: 'not_configured' });
const { tapSocialLogin } = loadUseLogin();
tapSocialLogin('google');
tapSocialLogin('apple');
await tapSocialLogin('google');
expect(storageMap.has('token')).toBe(false);
expect(navigateBackCount).toBe(0);
expect(requestCallCount).toBe(0);
expect(toastCalls.length).toBe(2);
expect(toastCalls[0].title).toContain('即将开放');
expect(toastCalls[1].title).toContain('即将开放');
expect(lastRequestOptions?.url).toContain('/app/user/external/googleStart');
expect(toastCalls[toastCalls.length - 1].title).toContain('即将开放');
expect(openUrlCalls.length).toBe(0);
expect(clipboardCalls.length).toBe(0);
});
it('3.10 Apple 返回 url:可复制或打开,不写 token', async () => {
mockEndpoint('/app/user/external/appleStart', {
status: 'ok',
url: 'https://appleid.apple.com/auth/authorize?client_id=omni',
});
const { tapSocialLogin } = loadUseLogin();
await tapSocialLogin('apple');
expect(storageMap.has('token')).toBe(false);
expect(lastRequestOptions?.url).toContain('/app/user/external/appleStart');
const openedOrCopied =
openUrlCalls.includes('https://appleid.apple.com/auth/authorize?client_id=omni') ||
clipboardCalls.some(
(c) => c.data === 'https://appleid.apple.com/auth/authorize?client_id=omni'
);
expect(openedOrCopied).toBe(true);
});
it('3.11 社交登录请求失败:不写 token', async () => {
mockByUrl.set('/app/user/external/googleStart', {
failError: { errMsg: 'network error' },
});
const { tapSocialLogin } = loadUseLogin();
await tapSocialLogin('google');
expect(storageMap.has('token')).toBe(false);
expect(openUrlCalls.length).toBe(0);
});
it('3.12 smsLogin status=ok 但无 token:不写凭证,不跳转', async () => {
mockEndpoint('/app/user/external/smsLogin', { status: 'ok' });
const { createLoginState, submitPhoneLogin } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
state.smsCode = '123456';
const ok = await submitPhoneLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
});
@@ -205,21 +421,20 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
});
describe('5. 验证码倒计时机制', () => {
it('5.1 手机号合法时发送验证码触发 60s 倒计时', () => {
it('5.1 手机号合法且 smsSend sent 时触发 60s 倒计时', async () => {
mockEndpoint('/app/user/external/smsSend', { status: 'sent' });
const { createLoginState, sendSmsCode, tickCountdown } = loadUseLogin();
const state = createLoginState();
state.phone = '13800138000';
sendSmsCode(state);
await sendSmsCode(state);
expect(state.countdown).toBe(60);
// 递减一次
tickCountdown(state);
expect(state.countdown).toBe(59);
// 倒计时中不能重复触发
const again = sendSmsCode(state);
const again = await sendSmsCode(state);
expect(again).toBe(false);
expect(state.countdown).toBe(59);
});
@@ -241,7 +456,6 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
expect(toastCalls.length).toBeGreaterThan(0);
expect(toastCalls[0].title).toContain('账号');
// 仅有账号没有密码
state.username = 'admin';
state.password = '';
const ok2 = await submitPasswordLogin(state);
@@ -257,28 +471,11 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
state.username = 'admin';
state.password = '123456';
// 模拟 uni.request 响应 Cool-Admin 成功契约
(global as { uni?: Record<string, unknown> }).uni!.request = (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
setTimeout(() => {
options.success?.({
statusCode: 200,
data: {
code: 1000,
message: 'success',
data: {
token: 'mock_admin_jwt_token_2026',
expire: 7200,
refreshToken: 'mock_refresh_token',
},
},
header: {},
});
options.complete?.();
}, 0);
return { abort: jest.fn() };
};
mockEndpoint('/admin/base/open/login', {
token: 'mock_admin_jwt_token_2026',
expire: 7200,
refreshToken: 'mock_refresh_token',
});
const ok = await submitPasswordLogin(state);
@@ -299,22 +496,13 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
state.username = 'admin';
state.password = 'wrong_password';
(global as { uni?: Record<string, unknown> }).uni!.request = (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
setTimeout(() => {
options.success?.({
statusCode: 200,
data: {
code: 1001,
message: '账户或密码不正确~',
},
header: {},
});
options.complete?.();
}, 0);
return { abort: jest.fn() };
};
mockByUrl.set('/admin/base/open/login', {
statusCode: 200,
data: {
code: 1001,
message: '账户或密码不正确~',
},
});
const ok = await submitPasswordLogin(state);
@@ -337,5 +525,80 @@ describe('OmniCard AI 用户端统一登录业务逻辑测试 (useLogin.uts)', (
expect(state.username).toBe('admin');
expect(state.password).toBe('123456');
});
it('6.5 接口成功但无 token 字段:不得写入 token,不得当成已登录', async () => {
const { createLoginState, submitPasswordLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
mockEndpoint('/admin/base/open/login', {
expire: 7200,
refreshToken: 'should_not_be_used_as_token',
});
const ok = await submitPasswordLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
expect(state.errorMessage.length).toBeGreaterThan(0);
});
it('6.6 接口返回空 token:不得写入本地凭证', async () => {
const { createLoginState, submitPasswordLogin, completeLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
mockEndpoint('/admin/base/open/login', { token: '' });
const okEmpty = await submitPasswordLogin(state);
expect(okEmpty).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
mockEndpoint('/admin/base/open/login', { token: ' ' });
const okBlank = await submitPasswordLogin(state);
expect(okBlank).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(completeLogin('')).toBe(false);
expect(completeLogin(' ')).toBe(false);
expect(storageMap.has('token')).toBe(false);
});
it('6.7 账号登录网络失败:不得写入 token', async () => {
mockByUrl.set('/admin/base/open/login', {
failError: { errMsg: 'network error' },
});
const { createLoginState, submitPasswordLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
const ok = await submitPasswordLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
it('6.8 账号登录 HTTP 非成功:不得写入 token', async () => {
mockByUrl.set('/admin/base/open/login', {
statusCode: 500,
data: { message: 'Internal Server Error' },
});
const { createLoginState, submitPasswordLogin } = loadUseLogin();
const state = createLoginState();
state.username = 'admin';
state.password = '123456';
const ok = await submitPasswordLogin(state);
expect(ok).toBe(false);
expect(storageMap.has('token')).toBe(false);
expect(switchTabCount).toBe(0);
});
});
});

查看文件

@@ -50,12 +50,24 @@ type ApplySceneResult = {
device: NFCRecord | null;
};
type OfflineWriteResult = {
ok: boolean;
error: string;
};
type UseModeModule = {
parseSceneMode: (raw: string | null | undefined) => NFCSceneMode | null;
applyScene: (
params: ApplySceneParams,
api?: ApplySceneApi
) => Promise<ApplySceneResult>;
canWriteOfflineVCard: () => boolean;
writeOfflineVCard: (payload?: {
uid?: string;
cardId?: string | number;
vcard?: string;
}) => Promise<OfflineWriteResult>;
resolveModeText: (key: string) => string;
};
function loadUseMode(): UseModeModule {
@@ -215,10 +227,66 @@ describe('OmniCard AI 名片主 NFC 场景模式切换 (useMode.uts)', () => {
expect(api.updateNfcDevice).not.toHaveBeenCalled();
});
});
describe('3. 离线 vCard 烧录无真实写芯片时必须失败', () => {
it('3.1 canWriteOfflineVCard 为 false,无 NDEF 写入能力', () => {
const { canWriteOfflineVCard } = loadUseMode();
expect(canWriteOfflineVCard()).toBe(false);
});
it('3.2 writeOfflineVCard 不得返回成功,error 走 resolve 中英文案', async () => {
const { writeOfflineVCard, resolveModeText } = loadUseMode();
const uniBag = (global as { uni?: Record<string, unknown> }).uni as {
showToast: jest.Mock;
getLocale: () => string;
};
const result = await writeOfflineVCard({
uid: 'UID-NFC-8801',
cardId: 101,
vcard: 'BEGIN:VCARD\nVERSION:3.0\nEND:VCARD',
});
expect(result.ok).toBe(false);
expect(result.error).toBe(resolveModeText('offlineUnavailable'));
expect(result.error).toMatch(/暂不能写入离线名片/);
uniBag.getLocale = () => 'en-US';
const enResult = await writeOfflineVCard({ uid: 'UID-NFC-8801' });
expect(enResult.ok).toBe(false);
expect(enResult.error).toMatch(/cannot write offline/i);
const toastTitles = uniBag.showToast.mock.calls.map(
(c: unknown[]) => (c[0] as { title?: string }).title || ''
);
expect(
toastTitles.some((t: string) =>
/已烧录|burned|written|success/i.test(t)
)
).toBe(false);
});
it('3.3 resolveModeText(offlineUnavailable) 中英均有自然文案', () => {
const { resolveModeText } = loadUseMode();
const uniBag = (global as { uni?: Record<string, unknown> }).uni as {
getLocale: () => string;
};
uniBag.getLocale = () => 'zh-CN';
expect(resolveModeText('offlineUnavailable')).toBe(
'此设备暂不能写入离线名片'
);
uniBag.getLocale = () => 'en-US';
expect(resolveModeText('offlineUnavailable')).toMatch(
/cannot write offline/i
);
});
});
});
describe('OmniCard AI NFC 场景切换页存在 (mode.uvue)', () => {
it('3.1 页面文件存在,且为 uts 单页与单一主 CTA', () => {
it('4.1 页面文件存在,且为 uts 单页与单一主 CTA', () => {
expect(fs.existsSync(MODE_PAGE)).toBe(true);
const source = fs.readFileSync(MODE_PAGE, 'utf8');
expect(source.includes('script setup lang="uts"')).toBe(true);
@@ -232,4 +300,17 @@ describe('OmniCard AI NFC 场景切换页存在 (mode.uvue)', () => {
expect(source.includes('v-model="state.uid"')).toBe(true);
expect(source.includes('v-model="state.cardId"')).toBe(true);
});
it('4.2 页面展示离线不可写说明,且不伪造烧录成功', () => {
const source = fs.readFileSync(MODE_PAGE, 'utf8');
expect(
source.includes("offlineUnavailable") ||
source.includes('此设备暂不能写入离线名片')
).toBe(true);
expect(source.includes('mode-offline-hint')).toBe(true);
expect(source.includes('已烧录')).toBe(false);
expect(/NDEF[\s\S]{0,40}success|writeNdef|假写入/i.test(source)).toBe(
false
);
});
});

查看文件

@@ -48,8 +48,25 @@ type UseScanModule = {
) => Promise<NFCRecordFixture>;
startScan: (state: ScanState) => boolean;
createScanState: () => ScanState;
buildNdefUri: (cardId: string | number | null | undefined) => string;
writeNdefToTag: (uri: string | null | undefined) => Promise<boolean>;
resolveScanText: (key: string) => string;
};
type NdefWriteOption = {
uris?: string[];
records?: unknown[];
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
};
const WRITE_SUCCESS_TOAST_RE =
/已写入(?!失败)|写入成功|Written to tag|Write successful/i;
function assertNoWriteSuccessToast(toasts: UniToastOption[]): void {
expect(toasts.some(t => WRITE_SUCCESS_TOAST_RE.test(t.title))).toBe(false);
}
type UniToastOption = {
title: string;
icon?: string;
@@ -91,6 +108,8 @@ describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)
let toastCalls: UniToastOption[];
let lastRequestOptions: UniRequestOption | null;
let requestCallCount: number;
let writeNdefCalls: NdefWriteOption[];
let writeMode: 'success' | 'fail' | 'cancel' | 'noAdapter' | 'noWriteApi';
let mockRequestResponse: {
statusCode?: number;
data?: {
@@ -106,6 +125,8 @@ describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)
toastCalls = [];
lastRequestOptions = null;
requestCallCount = 0;
writeNdefCalls = [];
writeMode = 'success';
mockRequestResponse = {
statusCode: 200,
data: {
@@ -125,6 +146,39 @@ describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)
},
reLaunch: jest.fn(),
getLocale: () => 'zh-CN',
getNFCAdapter: () => {
if (writeMode === 'noAdapter') {
return null;
}
return {
startDiscovery: (opts: {
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => {
opts.fail?.({ errMsg: 'startDiscovery:fail' });
},
onDiscovered: () => {},
getNdef: () => {
if (writeMode === 'noWriteApi') {
return null;
}
return {
writeNdefMessage: (opts: NdefWriteOption) => {
writeNdefCalls.push(opts);
if (writeMode === 'success') {
opts.success?.();
return;
}
if (writeMode === 'cancel') {
opts.fail?.({ errMsg: 'writeNdefMessage:fail cancel' });
return;
}
opts.fail?.({ errMsg: 'writeNdefMessage:fail' });
},
};
},
};
},
request: (options: UniRequestOption) => {
requestCallCount += 1;
lastRequestOptions = options;
@@ -282,9 +336,10 @@ describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)
});
describe('4. startScan 不伪造成功', () => {
it('4.1 调不到原生 NFC 时回退手动输入且不发激活请求', () => {
it('4.1 天线 API 不可用时失败回退手动输入,不伪造成功、不发激活请求', () => {
const { createScanState, startScan } = loadUseScan();
const state = createScanState();
const uidBefore = state.uid;
const ok = startScan(state);
@@ -292,8 +347,161 @@ describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)
expect(state.manualInput).toBe(true);
expect(state.scanning).toBe(false);
expect(state.record).toBeNull();
expect(state.uid).toBe(uidBefore);
expect(state.errorMessage.length).toBeGreaterThan(0);
expect(requestCallCount).toBe(0);
});
it('4.2 源码契约:APP 块尝试官方 getNFCAdapter 读 UID,成功仅在拿到合法 UID', () => {
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
const startScanBlock = logicSource.slice(
logicSource.indexOf('export function startScan'),
logicSource.indexOf('export function resolveStatusLine')
);
expect(logicSource.includes('#ifdef APP')).toBe(true);
expect(logicSource.includes('getNFCAdapter')).toBe(true);
expect(logicSource.includes('startDiscovery') || logicSource.includes('onDiscovered')).toBe(
true
);
expect(startScanBlock.includes('parseUid')).toBe(true);
expect(startScanBlock.includes('state.uid')).toBe(true);
expect(startScanBlock.includes('return true')).toBe(true);
expect(startScanBlock.includes('manualInput = true')).toBe(true);
expect(startScanBlock.includes('return false')).toBe(true);
// 禁止在 startScan 内硬编码伪造芯片 UID
expect(/state\.uid\s*=\s*['"][0-9A-Fa-f:]+['"]/.test(startScanBlock)).toBe(false);
expect(startScanBlock.includes('Native NFC antenna is not wired')).toBe(false);
});
});
describe('5. writeNdefToTag 仅系统写入 API 成功才 toast 已写入', () => {
it('5.1 buildNdefUri 无合法 cardId 时返回空串', () => {
const { buildNdefUri } = loadUseScan();
expect(buildNdefUri(null)).toBe('');
expect(buildNdefUri(undefined)).toBe('');
expect(buildNdefUri('')).toBe('');
expect(buildNdefUri(0)).toBe('');
expect(buildNdefUri(-1)).toBe('');
expect(buildNdefUri(12)).toBe('https://omnicard.ai/c/12');
expect(buildNdefUri(' 8 ')).toBe('https://omnicard.ai/c/8');
});
it('5.2 写入 API 成功才 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
toastCalls = [];
writeMode = 'success';
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(true);
expect(writeNdefCalls.length).toBe(1);
expect(writeNdefCalls[0].uris).toEqual(['https://omnicard.ai/c/12']);
expect(
toastCalls.some(t => t.title === resolveScanText('writeSuccess'))
).toBe(true);
expect(WRITE_SUCCESS_TOAST_RE.test(resolveScanText('writeSuccess'))).toBe(
true
);
});
it('5.3 无可写内容不调写入 API,不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
toastCalls = [];
const ok = await writeNdefToTag(' ');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(0);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeUnavailable'))
).toBe(true);
});
it('5.4 天线/Adapter 不可用时不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'noAdapter';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(0);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeFailed'))
).toBe(true);
});
it('5.5 无 writeNdefMessage 能力时不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'noWriteApi';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(0);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeFailed'))
).toBe(true);
});
it('5.6 用户取消写入不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'cancel';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(1);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeCancelled'))
).toBe(true);
});
it('5.7 写入失败不 toast 已写入', async () => {
const { writeNdefToTag, resolveScanText } = loadUseScan();
writeMode = 'fail';
toastCalls = [];
const ok = await writeNdefToTag('https://omnicard.ai/c/12');
expect(ok).toBe(false);
expect(writeNdefCalls.length).toBe(1);
assertNoWriteSuccessToast(toastCalls);
expect(
toastCalls.some(t => t.title === resolveScanText('writeFailed'))
).toBe(true);
});
it('5.8 源码契约:writeSuccess 仅出现在 writeNdefMessage 的 success 回调内', () => {
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
const writeIdx = logicSource.indexOf('ndef.writeNdefMessage(');
expect(writeIdx).toBeGreaterThan(-1);
const successIdx = logicSource.indexOf('success:', writeIdx);
expect(successIdx).toBeGreaterThan(writeIdx);
const successBody = logicSource.slice(successIdx, successIdx + 220);
expect(successBody.includes('writeSuccess')).toBe(true);
const failIdx = logicSource.indexOf('fail:', successIdx);
expect(failIdx).toBeGreaterThan(successIdx);
const failBody = logicSource.slice(failIdx, failIdx + 360);
expect(failBody.includes('writeSuccess')).toBe(false);
expect(WRITE_SUCCESS_TOAST_RE.test(failBody)).toBe(false);
expect(
failBody.includes('writeFailed') ||
failBody.includes('writeCancelled') ||
failBody.includes('toastWriteFailed') ||
failBody.includes('toastWriteCancelled')
).toBe(true);
});
});
});
@@ -338,4 +546,26 @@ describe('OmniCard AI 名片主 NFC 贴卡激活页契约 (scan.uvue)', () => {
false
);
});
it('主流程走 writeNdefToTag,失败路径无已写入文案,不把云端绑定冒充芯片写入', () => {
const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8');
const logicSource = fs.readFileSync(SCAN_UTS, 'utf8');
expect(logicSource.includes('writeNdefToTag')).toBe(true);
expect(logicSource.includes('writeNdefMessage')).toBe(true);
expect(pageSource.includes('writeNdefToTag')).toBe(true);
expect(pageSource.includes('buildNdefUri')).toBe(true);
expect(pageSource.includes('已写入')).toBe(false);
expect(pageSource.includes('烧录至')).toBe(false);
expect(WRITE_SUCCESS_TOAST_RE.test(pageSource)).toBe(false);
const unavailableIdx = logicSource.indexOf("resolveScanText('writeUnavailable')");
expect(unavailableIdx).toBeGreaterThan(-1);
const unavailableSlice = logicSource.slice(
Math.max(0, unavailableIdx - 80),
unavailableIdx + 80
);
expect(WRITE_SUCCESS_TOAST_RE.test(unavailableSlice)).toBe(false);
});
});

查看文件

@@ -67,6 +67,7 @@ export interface UseSecurityModule {
id: number,
api?: SecurityApi
) => Promise<NfcSecurityDevice>;
resolveSecurityText: (key: string, locale?: string) => string;
}
function loadUseSecurity(): UseSecurityModule {
@@ -279,6 +280,8 @@ describe('Point 13: 移动端 NFC 安全与远程挂失测试套件', () => {
// 设计 Token 引用检查
expect(content).toContain('--oc-primary');
expect(content).toContain('--oc-border');
expect(content).toContain('--oc-error');
expect(content).toContain('--oc-surface');
});
it('4.2 pages.json 中已正确注册 pages/owner/nfc/security 页面', () => {
@@ -292,5 +295,36 @@ describe('Point 13: 移动端 NFC 安全与远程挂失测试套件', () => {
);
expect(registered).toBe(true);
});
it('4.3 挂失主按钮用 error token,禁止 #D92D20 与纯白硬编码底', () => {
const content = fs.readFileSync(SECURITY_PAGE, 'utf-8');
// 禁止旧危险色硬编码
expect(content).not.toContain('#D92D20');
expect(content).not.toMatch(/#d92d20/i);
// 挂失 danger CTA 必须引用 CSS 变量 --oc-error
expect(content).toMatch(/\.cta-btn-danger\s*\{[^}]*--oc-error/s);
// pageTheme 注入 error token
expect(content).toContain("'--oc-error': utsLightColors.error");
// 禁止裸写纯白底(允许 var(--oc-surface, #FFFFFF) 回退)
expect(content).not.toMatch(/background-color:\s*#FFFFFF\b/i);
expect(content).not.toMatch(/background:\s*#FFFFFF\b/i);
});
it('4.4 「处理中」禁止写死在页面,须走 resolveSecurityText 且中英齐全', () => {
const pageContent = fs.readFileSync(SECURITY_PAGE, 'utf-8');
const utsContent = fs.readFileSync(SECURITY_UTS, 'utf-8');
expect(pageContent).not.toContain('处理中');
expect(pageContent).toContain("t('submitting')");
expect(utsContent).toContain("case 'submitting'");
const { resolveSecurityText } = loadUseSecurity();
expect(resolveSecurityText('submitting', 'zh-CN')).toBe('处理中...');
expect(resolveSecurityText('submitting', 'en-US')).toBe('Processing...');
});
});
});

查看文件

@@ -58,7 +58,19 @@ describe('Point 25: 移动端拍照上传纸质名片 OCR 识别入库 (useOcrEx
handleCardOcrUpload: (
form: ExchangeForm,
ocrState: ReturnType<typeof loadUseOcrExchange> extends never ? never : any,
options?: { mockText?: string }
options?: {
mockText?: string;
httpPost?: (
url: string,
data?: Record<string, string>
) => Promise<{
name: string;
company: string;
phone: string;
email: string;
title: string;
}>;
}
) => Promise<boolean>;
};
}
@@ -341,7 +353,7 @@ describe('Point 25: 移动端拍照上传纸质名片 OCR 识别入库 (useOcrEx
expect(toastCalls[0].title).toContain('识别成功');
});
it('6.2 通过 chooseImage 成功拍照或选图后触发名片自动填充', async () => {
it('6.2 通过 chooseImage 成功选图后请求 /open/ai/ocr/recognize 并填表', async () => {
const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '',
@@ -359,13 +371,32 @@ describe('Point 25: 移动端拍照上传纸质名片 OCR 识别入库 (useOcrEx
opt.success?.({ tempFilePaths: ['temp://photo.jpg'] });
};
const success = await handleCardOcrUpload(form, ocrState);
const httpPost = jest.fn().mockResolvedValue({
name: '赵敏',
company: '星河智联科技有限公司',
phone: '+8613911112222',
email: 'zhaomin@xinghe.ai',
title: '商务经理',
});
const success = await handleCardOcrUpload(form, ocrState, { httpPost });
expect(success).toBe(true);
expect(form.name).toBe('张伟');
expect(form.phone).toBe('+8613800138000');
expect(form.company).toBe('深圳超维智能科技有限公司');
expect(httpPost).toHaveBeenCalledTimes(1);
expect(httpPost.mock.calls[0][0]).toBe('/open/ai/ocr/recognize');
const body = httpPost.mock.calls[0][1] as Record<string, string>;
expect(
(body.imageBase64 != null && body.imageBase64 !== '') ||
(body.imageUrl != null && body.imageUrl !== '')
).toBe(true);
expect(form.name).toBe('赵敏');
expect(form.phone).toBe('+8613911112222');
expect(form.company).toBe('星河智联科技有限公司');
expect(form.email).toBe('zhaomin@xinghe.ai');
expect(form.notes).toBe('商务经理');
expect(form.name).not.toBe('张伟');
expect(ocrState.scanning).toBe(false);
expect(ocrState.errorMessage).toBe('');
expect(toastCalls.length).toBeGreaterThan(0);
});
@@ -412,5 +443,43 @@ describe('Point 25: 移动端拍照上传纸质名片 OCR 识别入库 (useOcrEx
expect(defaultSample.name).toBe('张伟');
expect(defaultSample.phone).toBe('+8613800138000');
});
it('6.5 OCR 接口失败或未配置时写入 errorMessage 且不用样例填表', async () => {
const { createOcrScanState, handleCardOcrUpload } = loadUseOcrExchange();
const form: ExchangeForm = {
name: '已有姓名',
company: '',
phone: '',
email: '',
notes: '',
};
const ocrState = createOcrScanState();
(global as unknown as { uni: Record<string, unknown> }).uni.chooseImage = (opt: {
success?: (res: unknown) => void;
fail?: (err: unknown) => void;
}) => {
opt.success?.({ tempFilePaths: ['temp://photo.jpg'] });
};
const httpPost = jest
.fn()
.mockRejectedValue(new Error('未配置 OCR 服务(OCR_API_KEY / OCR_BASE_URL)'));
const success = await handleCardOcrUpload(form, ocrState, { httpPost });
expect(success).toBe(false);
expect(httpPost).toHaveBeenCalledWith(
'/open/ai/ocr/recognize',
expect.any(Object)
);
expect(form.name).toBe('已有姓名');
expect(form.name).not.toBe('张伟');
expect(form.company).toBe('');
expect(form.phone).toBe('');
expect(ocrState.scanning).toBe(false);
expect(ocrState.errorMessage).toContain('未配置');
expect(ocrState.lastExtracted).toBeNull();
});
});
});

查看文件

@@ -16,12 +16,20 @@ const EDIT_UTS = path.resolve(
'../../../mpuniapp/pages/owner/card/useCardEdit.uts'
);
type BrandKitFixture = {
logoUrl?: string;
primaryColor?: string;
lockedFields?: string[] | null;
whitelist?: string[] | null;
};
type CardProfile = {
id?: string | number;
name: string;
title?: string;
company?: string;
slogan?: string;
avatarUrl?: string;
phone?: string;
email?: string;
whatsapp?: string;
@@ -29,6 +37,9 @@ type CardProfile = {
telegram?: string;
twitter?: string;
wechat?: string;
timezone?: string;
lockedFields?: string[] | null;
brandKit?: BrandKitFixture | null;
};
type CardEditForm = {
@@ -37,6 +48,7 @@ type CardEditForm = {
title?: string;
company?: string;
slogan?: string;
avatarUrl?: string;
phone?: string;
email?: string;
whatsapp?: string;
@@ -44,6 +56,8 @@ type CardEditForm = {
telegram?: string;
twitter?: string;
wechat?: string;
timezone?: string;
lockedFields?: string[] | null;
};
type CardUpdatePayload = {
@@ -52,6 +66,7 @@ type CardUpdatePayload = {
title?: string;
company?: string;
slogan?: string;
avatarUrl?: string;
phone?: string;
email?: string;
whatsapp?: string;
@@ -59,6 +74,7 @@ type CardUpdatePayload = {
telegram?: string;
twitter?: string;
wechat?: string;
timezone?: string;
};
type LoadCardApi = {
@@ -79,18 +95,66 @@ type SaveCardApi = {
type SaveCardResult = {
ok: boolean;
code?: number;
card: CardProfile | null;
errorMessage: string;
};
type CardEditState = {
rawId: string;
cardId: number | null;
name: string;
title: string;
company: string;
slogan: string;
avatarUrl: string;
phone: string;
email: string;
whatsapp: string;
linkedin: string;
timezone: string;
moduleOrder: string[];
lockedFields: string[];
loading: boolean;
saving: boolean;
empty: boolean;
missingId: boolean;
errorMessage: string;
};
type UseCardEditModule = {
parseCardId: (raw: string | number | null | undefined) => number | null;
buildUpdatePayload: (form: CardEditForm) => CardUpdatePayload | null;
buildUpdatePayload: (
form: CardEditForm,
lockedFields?: string[] | null
) => CardUpdatePayload | null;
extractLockedFields: (
source: CardProfile | BrandKitFixture | null | undefined
) => string[];
isFieldLocked: (
lockedFields: string[] | null | undefined,
field: string
) => boolean;
resolveCardEditText: (key: string) => string;
createCardEditState: () => CardEditState;
applyLoadResult: (state: CardEditState, result: LoadCardResult) => void;
formFromState: (state: CardEditState) => CardEditForm;
DEFAULT_MODULE_ORDER: string[];
normalizeModuleOrder: (raw: unknown) => string[];
moveModuleInOrder: (
order: string[],
index: number,
delta: number
) => string[];
loadCard: (
id: string | number | null | undefined,
api?: LoadCardApi
) => Promise<LoadCardResult>;
saveCard: (form: CardEditForm, api?: SaveCardApi) => Promise<SaveCardResult>;
saveCard: (
form: CardEditForm,
api?: SaveCardApi,
lockedFields?: string[] | null
) => Promise<SaveCardResult>;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
@@ -271,6 +335,436 @@ describe('OmniCard AI 名片主极速编辑业务逻辑 (useCardEdit.uts)', () =
expect(result.card).toBeNull();
});
});
describe('4. 品牌套件 lockedFields 锁定', () => {
it('4.1 无 lockedFields / 空数组 / 仅有 brandKit 资产时不锁任何字段', () => {
const { extractLockedFields, isFieldLocked, buildUpdatePayload } =
loadUseCardEdit();
expect(extractLockedFields(null)).toEqual([]);
expect(extractLockedFields(undefined)).toEqual([]);
expect(extractLockedFields(buildCard())).toEqual([]);
expect(
extractLockedFields(
buildCard({
brandKit: { logoUrl: 'https://cdn.example/logo.png', primaryColor: '#0088FF' },
})
)
).toEqual([]);
expect(
extractLockedFields(buildCard({ lockedFields: [] }))
).toEqual([]);
expect(
extractLockedFields(buildCard({ brandKit: { lockedFields: null } }))
).toEqual([]);
expect(isFieldLocked([], 'company')).toBe(false);
expect(isFieldLocked(null, 'company')).toBe(false);
expect(isFieldLocked(undefined, 'name')).toBe(false);
const payload = buildUpdatePayload(
{
id: 8,
name: 'Alex Vance',
company: 'Edited Co',
avatarUrl: 'https://cdn.example/a.png',
},
[]
);
expect(payload?.company).toBe('Edited Co');
expect(payload?.name).toBe('Alex Vance');
expect(payload?.avatarUrl).toBe('https://cdn.example/a.png');
});
it('4.2 读取 brandKit.lockedFields 与顶层 lockedFields,别名映射生效', () => {
const { extractLockedFields, isFieldLocked } = loadUseCardEdit();
expect(
extractLockedFields(
buildCard({
brandKit: {
logoUrl: 'https://cdn.example/logo.png',
primaryColor: '#0088FF',
lockedFields: ['companyName', 'logoUrl', 'primaryColor'],
},
})
)
).toEqual(['companyName', 'logoUrl', 'primaryColor']);
expect(
extractLockedFields(
buildCard({ lockedFields: ['company', 'logo'] })
)
).toEqual(['company', 'logo']);
const locked = ['companyName', 'logoUrl', 'primaryColor'];
expect(isFieldLocked(locked, 'company')).toBe(true);
expect(isFieldLocked(locked, 'companyName')).toBe(true);
expect(isFieldLocked(locked, 'logoUrl')).toBe(true);
expect(isFieldLocked(locked, 'logo')).toBe(true);
expect(isFieldLocked(locked, 'primaryColor')).toBe(true);
expect(isFieldLocked(locked, 'name')).toBe(false);
expect(isFieldLocked(locked, 'phone')).toBe(false);
});
it('4.3 whitelist 存在时,未列入白名单的品牌字段视为锁定', () => {
const { extractLockedFields, isFieldLocked } = loadUseCardEdit();
const locked = extractLockedFields(
buildCard({
brandKit: {
logoUrl: 'https://cdn.example/logo.png',
primaryColor: '#0088FF',
whitelist: ['name', 'phone', 'email', 'avatarUrl'],
},
})
);
expect(locked).toContain('company');
expect(locked).toContain('logoUrl');
expect(locked).toContain('primaryColor');
expect(isFieldLocked(locked, 'company')).toBe(true);
expect(isFieldLocked(locked, 'name')).toBe(false);
expect(isFieldLocked(locked, 'phone')).toBe(false);
});
it('4.4 buildUpdatePayload / saveCard 不提交锁定字段的新值', async () => {
const { buildUpdatePayload, saveCard } = loadUseCardEdit();
const locked = ['companyName', 'logoUrl', 'primaryColor'];
const payload = buildUpdatePayload(
{
id: 8,
name: 'Alex Vance',
company: 'Hacked Corp',
avatarUrl: 'https://cdn.example/me.png',
phone: '+971500000008',
},
locked
);
expect(payload).not.toBeNull();
expect(payload?.name).toBe('Alex Vance');
expect(payload?.phone).toBe('+971500000008');
// 品牌 Logo 锁定不影响个人头像提交
expect(payload?.avatarUrl).toBe('https://cdn.example/me.png');
expect(payload?.company).toBeUndefined();
const updateOwnerCard = jest.fn(
async (body: CardProfile & { id: string | number }) => {
expect(body.company).toBeUndefined();
expect(body.avatarUrl).toBe('https://cdn.example/me.png');
expect(body.name).toBe('Alex Vance');
return buildCard({ name: body.name });
}
);
const result = await saveCard(
{
id: 8,
name: 'Alex Vance',
company: 'Hacked Corp',
avatarUrl: 'https://cdn.example/me.png',
},
{ updateOwnerCard },
locked
);
expect(updateOwnerCard).toHaveBeenCalledTimes(1);
expect(result.ok).toBe(true);
});
it('4.5 applyLoadResult 写入 lockedFields;resolve 提供锁定提示文案', () => {
const {
createCardEditState,
applyLoadResult,
formFromState,
resolveCardEditText,
isFieldLocked,
} = loadUseCardEdit();
const state = createCardEditState();
applyLoadResult(state, {
card: buildCard({
brandKit: {
lockedFields: ['companyName', 'logoUrl'],
},
}),
empty: false,
errorMessage: '',
});
expect(state.lockedFields).toEqual(['companyName', 'logoUrl']);
expect(isFieldLocked(state.lockedFields, 'company')).toBe(true);
expect(formFromState(state).lockedFields).toEqual([
'companyName',
'logoUrl',
]);
expect(resolveCardEditText('lockedHint')).toBe('品牌规范已锁定,不可修改');
});
it('4.6 resolveCardEditText(lockedHint) 英文自然文案', () => {
(global as { uni?: Record<string, unknown> }).uni = {
getLocale: () => 'en-US',
showToast: jest.fn(),
navigateBack: jest.fn(),
navigateTo: jest.fn(),
};
jest.resetModules();
const { resolveCardEditText } = loadUseCardEdit();
expect(resolveCardEditText('lockedHint')).toBe(
'Locked by brand kit'
);
});
});
describe('5. slogan 口号字段', () => {
it('5.1 applyLoadResult / formFromState 读写 slogan,空口号不进 payload', () => {
const {
createCardEditState,
applyLoadResult,
formFromState,
buildUpdatePayload,
resolveCardEditText,
} = loadUseCardEdit();
const state = createCardEditState();
expect(state.slogan).toBe('');
applyLoadResult(state, {
card: buildCard({ slogan: 'Build trust first' }),
empty: false,
errorMessage: '',
});
expect(state.slogan).toBe('Build trust first');
expect(formFromState(state).slogan).toBe('Build trust first');
const filled = buildUpdatePayload({
id: 8,
name: 'Alex Vance',
slogan: ' Build trust first ',
});
expect(filled?.slogan).toBe('Build trust first');
const emptySlogan = buildUpdatePayload({
id: 8,
name: 'Alex Vance',
slogan: ' ',
});
expect(emptySlogan?.slogan).toBeUndefined();
expect(Object.keys(emptySlogan as CardUpdatePayload)).not.toContain(
'slogan'
);
expect(resolveCardEditText('slogan')).toBe('口号');
});
it('5.2 saveCard 提交已填 slogan,不写入假文案', async () => {
const { saveCard } = loadUseCardEdit();
const updateOwnerCard = jest.fn(
async (payload: CardProfile & { id: string | number }) => {
expect(payload.slogan).toBe('Trusted globally');
return buildCard({ slogan: payload.slogan });
}
);
const ok = await saveCard(
{
id: 8,
name: 'Alex Vance',
slogan: 'Trusted globally',
},
{ updateOwnerCard }
);
expect(ok.ok).toBe(true);
expect(updateOwnerCard).toHaveBeenCalledTimes(1);
const updateEmpty = jest.fn(
async (payload: CardProfile & { id: string | number }) => {
expect(payload.slogan).toBeUndefined();
return buildCard();
}
);
await saveCard(
{ id: 8, name: 'Alex Vance', slogan: '' },
{ updateOwnerCard: updateEmpty }
);
expect(updateEmpty).toHaveBeenCalledTimes(1);
});
it('5.3 锁定 slogan 时不提交', () => {
const { buildUpdatePayload } = loadUseCardEdit();
const payload = buildUpdatePayload(
{
id: 8,
name: 'Alex Vance',
slogan: 'Should not send',
},
['slogan']
);
expect(payload?.name).toBe('Alex Vance');
expect(payload?.slogan).toBeUndefined();
});
});
describe('6. timezone 时区字段', () => {
it('6.1 applyLoadResult / formFromState 读写 timezone,空时区不进 payload、无默认值', () => {
const {
createCardEditState,
applyLoadResult,
formFromState,
buildUpdatePayload,
resolveCardEditText,
} = loadUseCardEdit();
const state = createCardEditState();
expect(state.timezone).toBe('');
expect(state.timezone).not.toBe('UTC+8');
expect(state.timezone).not.toBe('Asia/Shanghai');
applyLoadResult(state, {
card: buildCard({ timezone: 'Asia/Dubai' }),
empty: false,
errorMessage: '',
});
expect(state.timezone).toBe('Asia/Dubai');
expect(formFromState(state).timezone).toBe('Asia/Dubai');
const filled = buildUpdatePayload({
id: 8,
name: 'Alex Vance',
timezone: ' America/New_York ',
});
expect(filled?.timezone).toBe('America/New_York');
const emptyTz = buildUpdatePayload({
id: 8,
name: 'Alex Vance',
timezone: ' ',
});
expect(emptyTz?.timezone).toBeUndefined();
expect(Object.keys(emptyTz as CardUpdatePayload)).not.toContain('timezone');
expect(Object.prototype.hasOwnProperty.call(emptyTz, 'timezone')).toBe(
false
);
const omitted = buildUpdatePayload({
id: 8,
name: 'Alex Vance',
timezone: '',
});
expect(omitted?.timezone).toBeUndefined();
expect(Object.keys(omitted as CardUpdatePayload)).not.toContain(
'timezone'
);
expect(resolveCardEditText('timezone')).toBe('时区');
});
it('6.2 saveCard 提交已填 timezone;清空时不带 timezone、不默认 UTC+8/Asia/Shanghai', async () => {
const { saveCard } = loadUseCardEdit();
const updateOwnerCard = jest.fn(
async (payload: CardProfile & { id: string | number }) => {
expect(payload.timezone).toBe('Europe/Paris');
return buildCard({ timezone: payload.timezone });
}
);
const ok = await saveCard(
{
id: 8,
name: 'Alex Vance',
timezone: 'Europe/Paris',
},
{ updateOwnerCard }
);
expect(ok.ok).toBe(true);
expect(ok.code).toBe(1000);
expect(updateOwnerCard).toHaveBeenCalledTimes(1);
const updateEmpty = jest.fn(
async (payload: CardProfile & { id: string | number }) => {
expect(payload.timezone).toBeUndefined();
expect(
Object.prototype.hasOwnProperty.call(payload, 'timezone')
).toBe(false);
expect(payload.timezone).not.toBe('UTC+8');
expect(payload.timezone).not.toBe('Asia/Shanghai');
return buildCard({ timezone: undefined });
}
);
const cleared = await saveCard(
{ id: 8, name: 'Alex Vance', timezone: '' },
{ updateOwnerCard: updateEmpty }
);
expect(cleared.ok).toBe(true);
expect(cleared.code).toBe(1000);
expect(updateEmpty).toHaveBeenCalledTimes(1);
const sent = updateEmpty.mock.calls[0][0] as Record<string, unknown>;
expect(Object.keys(sent)).not.toContain('timezone');
});
it('6.3 saveCard 失败时 code 不为 1000、ok 为 false', async () => {
const { saveCard } = loadUseCardEdit();
const updateOwnerCard = jest.fn(async () => {
throw new Error('update failed');
});
const result = await saveCard(
{ id: 8, name: 'Alex Vance', timezone: 'UTC+4' },
{ updateOwnerCard }
);
expect(result.ok).toBe(false);
expect(result.code).not.toBe(1000);
expect(result.card).toBeNull();
});
});
describe('7. 模块排序(无服务端排序字段,走客户端顺序)', () => {
it('7.1 默认顺序与 normalize / move 可编辑且不伪造 API 字段', () => {
const {
DEFAULT_MODULE_ORDER,
normalizeModuleOrder,
moveModuleInOrder,
createCardEditState,
buildUpdatePayload,
formFromState,
} = loadUseCardEdit();
expect(DEFAULT_MODULE_ORDER).toEqual(['basic', 'social', 'privacy']);
expect(normalizeModuleOrder(null)).toEqual(DEFAULT_MODULE_ORDER);
expect(normalizeModuleOrder(['privacy', 'basic', 'extra'])).toEqual([
'privacy',
'basic',
'social',
]);
const moved = moveModuleInOrder(
['basic', 'social', 'privacy'],
0,
1
);
expect(moved).toEqual(['social', 'basic', 'privacy']);
expect(
moveModuleInOrder(['basic', 'social', 'privacy'], 0, -1)
).toEqual(['basic', 'social', 'privacy']);
const state = createCardEditState();
expect(state.moduleOrder).toEqual(DEFAULT_MODULE_ORDER);
state.moduleOrder = moved;
const form = formFromState(state);
expect((form as { moduleOrder?: string[] }).moduleOrder).toBeUndefined();
const payload = buildUpdatePayload({
id: 8,
name: 'Alex Vance',
slogan: 'Hi',
});
expect(
Object.prototype.hasOwnProperty.call(payload, 'moduleOrder')
).toBe(false);
});
});
});
describe('OmniCard AI 名片主极速编辑页契约 (edit.uvue)', () => {
@@ -291,13 +785,40 @@ describe('OmniCard AI 名片主极速编辑页契约 (edit.uvue)', () => {
);
expect(logicSource.includes('export {') && logicSource.includes('fetchOwnerCardInfo')).toBe(true);
expect(combined.includes('buildUpdatePayload')).toBe(true);
expect(combined.includes('extractLockedFields')).toBe(true);
expect(combined.includes('isFieldLocked')).toBe(true);
expect(combined.includes('lockedFields')).toBe(true);
expect(pageSource.includes('v-model="state.name"')).toBe(true);
expect(pageSource.includes('v-model="state.title"')).toBe(true);
expect(pageSource.includes('v-model="state.company"')).toBe(true);
expect(pageSource.includes('v-model="state.slogan"')).toBe(true);
expect(pageSource.includes('v-model="state.timezone"')).toBe(true);
expect(
pageSource.includes('isFieldLocked(state.lockedFields, \'company\')') ||
pageSource.includes('isFieldLocked(state.lockedFields, "company")') ||
pageSource.includes('companyLocked')
).toBe(true);
expect(pageSource.includes('lockedHint') || pageSource.includes("t('lockedHint')") || pageSource.includes('resolveCardEditText(\'lockedHint\')')).toBe(true);
expect(pageSource.includes('v-model="state.phone"')).toBe(true);
expect(pageSource.includes('v-model="state.email"')).toBe(true);
expect(pageSource.includes('v-model="state.whatsapp"')).toBe(true);
expect(pageSource.includes('v-model="state.linkedin"')).toBe(true);
expect(
pageSource.includes('code === 1000') ||
logicSource.includes('code === 1000')
).toBe(true);
expect(logicSource.includes('UTC+8') || pageSource.includes('UTC+8')).toBe(
false
);
expect(
logicSource.includes('Asia/Shanghai') ||
pageSource.includes('Asia/Shanghai')
).toBe(false);
expect(
pageSource.includes('state.moduleOrder') ||
pageSource.includes('handleMoveModule') ||
logicSource.includes('moveModuleInOrder')
).toBe(true);
expect(pageSource.includes('primary-cta-btn')).toBe(true);
expect(pageSource.includes('#0B5CFF')).toBe(true);
expect(pageSource.includes('#F7F8FA')).toBe(true);

查看文件

@@ -304,4 +304,45 @@ describe('OmniCard AI 名片主名片中心页契约 (index.uvue)', () => {
expect(/<view[^>]*v-for/.test(template)).toBe(false);
expect(template.includes('<list-item')).toBe(true);
});
it('有真实 svg 时用 image 或 rich-text 渲染二维码,不把 URL 当码图', () => {
const pageSource = fs.readFileSync(CARD_PAGE, 'utf8');
const template = pageSource.split('<script')[0];
const qrBoxMatch = template.match(
/class="showcase-qr-box"[\s\S]*?<\/view>\s*<view class="showcase-glow-guide"/
);
const qrBoxBlock = qrBoxMatch != null ? qrBoxMatch[0] : '';
expect(pageSource.includes('fetchCardQrcodeSvg')).toBe(true);
expect(pageSource.includes('activeQrSvg')).toBe(true);
expect(template.includes('<image')).toBe(true);
expect(template.includes(':src="activeQrSvg"')).toBe(true);
expect(template.includes('<rich-text')).toBe(true);
expect(template.includes(':nodes="activeQrSvg"')).toBe(true);
expect(qrBoxBlock.includes(':src="activeQrUrl"')).toBe(false);
expect(qrBoxBlock.includes('{{ activeQrUrl }}')).toBe(false);
expect(qrBoxBlock.includes('{{ currentDisplayLink }}')).toBe(false);
});
it('无 svg 或请求失败时显示空态文案,不画假码占位块', () => {
const pageSource = fs.readFileSync(CARD_PAGE, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes('showcase-qr-empty')).toBe(true);
expect(
pageSource.includes("resolveCardHomeText('empty')") ||
(pageSource.includes('getOmniText(') &&
pageSource.includes("'common.empty'"))
).toBe(true);
expect(pageSource.includes('activeQrSvg.value = \'\'')).toBe(true);
expect(template.includes('showcase-qr-placeholder')).toBe(false);
expect(template.includes('showcase-qr-skeleton')).toBe(false);
expect(template.includes('showcase-qr-pulse')).toBe(false);
expect(pageSource.includes('fake-qr')).toBe(false);
expect(pageSource.includes('decorative-qr')).toBe(false);
expect(pageSource.includes('placeholder-qr')).toBe(false);
expect(/:src="activeQrUrl"/.test(template)).toBe(false);
expect((template.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0);
expect((template.match(/class="primary-cta-btn"/g) || []).length).toBe(1);
});
});

查看文件

@@ -44,15 +44,28 @@ type UseCardQrcodeModule = {
api?: LoadPosterApi
) => Promise<LoadPosterResult>;
copyPosterUrl: (url: string | null | undefined) => boolean;
savePosterToAlbum: (
imageSource: string | null | undefined
) => Promise<boolean>;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
const SAVE_SUCCESS_TOAST_RE =
/已保存到相册|已保存(?!失败)|保存成功|Saved to (Photos|album)|Saved successfully/i;
function loadUseCardQrcode(): UseCardQrcodeModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(QRCODE_UTS) as UseCardQrcodeModule;
}
function assertNoSaveSuccessToast(
toastCalls: Array<{ title: string; icon?: string }>
): void {
for (const toast of toastCalls) {
expect(SAVE_SUCCESS_TOAST_RE.test(toast.title)).toBe(false);
}
}
function buildPoster(overrides?: Partial<CardQrcodePoster>): CardQrcodePoster {
return {
svg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
@@ -64,11 +77,19 @@ function buildPoster(overrides?: Partial<CardQrcodePoster>): CardQrcodePoster {
describe('OmniCard AI 名片主参展分享码海报 (useCardQrcode.uts)', () => {
let clipboardText: string;
let toastCalls: Array<{ title: string; icon?: string }>;
let saveAlbumCalls: Array<{ filePath: string }>;
let saveAlbumMode: 'success' | 'fail' | 'authDeny';
let downloadMode: 'success' | 'fail' | 'emptyPath';
let lastDownloadedUrl: string;
beforeEach(() => {
jest.resetModules();
clipboardText = '';
toastCalls = [];
saveAlbumCalls = [];
saveAlbumMode = 'success';
downloadMode = 'success';
lastDownloadedUrl = '';
(global as { uni?: Record<string, unknown> }).uni = {
getLocale: () => 'zh-CN',
showToast: (options: { title: string; icon?: string }) => {
@@ -78,6 +99,41 @@ describe('OmniCard AI 名片主参展分享码海报 (useCardQrcode.uts)', () =>
clipboardText = options.data;
options.success?.();
},
downloadFile: (options: {
url: string;
success?: (res: { statusCode: number; tempFilePath: string }) => void;
fail?: (err: { errMsg: string }) => void;
}) => {
lastDownloadedUrl = options.url;
if (downloadMode === 'fail') {
options.fail?.({ errMsg: 'downloadFile:fail' });
return;
}
if (downloadMode === 'emptyPath') {
options.success?.({ statusCode: 200, tempFilePath: '' });
return;
}
options.success?.({
statusCode: 200,
tempFilePath: '/tmp/poster-qr.png',
});
},
saveImageToPhotosAlbum: (options: {
filePath: string;
success?: () => void;
fail?: (err: { errMsg: string }) => void;
}) => {
saveAlbumCalls.push({ filePath: options.filePath });
if (saveAlbumMode === 'success') {
options.success?.();
return;
}
if (saveAlbumMode === 'authDeny') {
options.fail?.({ errMsg: 'saveImageToPhotosAlbum:fail auth deny' });
return;
}
options.fail?.({ errMsg: 'saveImageToPhotosAlbum:fail' });
},
};
});
@@ -160,6 +216,23 @@ describe('OmniCard AI 名片主参展分享码海报 (useCardQrcode.uts)', () =>
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('qrcode svg unavailable');
});
it('2.4 svg 为空时保留空串,不把 url 填进 svg', async () => {
const { loadPoster } = loadUseCardQrcode();
const fetchCardQrcodeSvg = jest.fn(async (): Promise<CardQrcodePoster> =>
buildPoster({
svg: '',
url: 'https://omnicard.ai/c/alex-vance',
})
);
const result = await loadPoster('alex-vance', { fetchCardQrcodeSvg });
expect(result.svg).toBe('');
expect(result.url).toBe('https://omnicard.ai/c/alex-vance');
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('');
});
});
describe('3. copyPosterUrl 复制短链', () => {
@@ -180,6 +253,114 @@ describe('OmniCard AI 名片主参展分享码海报 (useCardQrcode.uts)', () =>
expect(clipboardText).toBe('');
});
});
describe('4. savePosterToAlbum 仅相册 API 成功才提示已保存', () => {
it('4.1 本地路径保存成功才 toast 已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
toastCalls = [];
const ok = await savePosterToAlbum('/tmp/local-poster.png');
expect(ok).toBe(true);
expect(saveAlbumCalls).toEqual([{ filePath: '/tmp/local-poster.png' }]);
expect(
toastCalls.some(t => SAVE_SUCCESS_TOAST_RE.test(t.title))
).toBe(true);
});
it('4.2 空图源不调用相册 API,不 toast 已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
toastCalls = [];
const ok = await savePosterToAlbum(' ');
expect(ok).toBe(false);
expect(saveAlbumCalls).toEqual([]);
assertNoSaveSuccessToast(toastCalls);
});
it('4.3 纯 SVG 标记不可直接入库,不 toast 已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
toastCalls = [];
const ok = await savePosterToAlbum(
'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
);
expect(ok).toBe(false);
expect(saveAlbumCalls).toEqual([]);
assertNoSaveSuccessToast(toastCalls);
});
it('4.4 用户拒绝授权失败不 toast 已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
saveAlbumMode = 'authDeny';
toastCalls = [];
const ok = await savePosterToAlbum('/tmp/local-poster.png');
expect(ok).toBe(false);
expect(saveAlbumCalls.length).toBe(1);
assertNoSaveSuccessToast(toastCalls);
});
it('4.5 相册 API 失败不 toast 已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
saveAlbumMode = 'fail';
toastCalls = [];
const ok = await savePosterToAlbum('/tmp/local-poster.png');
expect(ok).toBe(false);
expect(saveAlbumCalls.length).toBe(1);
assertNoSaveSuccessToast(toastCalls);
});
it('4.6 远程图下载无 tempFilePath 时不调用相册且不报已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
downloadMode = 'emptyPath';
toastCalls = [];
const ok = await savePosterToAlbum(
'https://cdn.omnicard.ai/poster/alex.png'
);
expect(ok).toBe(false);
expect(lastDownloadedUrl).toBe('https://cdn.omnicard.ai/poster/alex.png');
expect(saveAlbumCalls).toEqual([]);
assertNoSaveSuccessToast(toastCalls);
});
it('4.7 远程图下载失败不 toast 已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
downloadMode = 'fail';
toastCalls = [];
const ok = await savePosterToAlbum(
'https://cdn.omnicard.ai/poster/alex.png'
);
expect(ok).toBe(false);
expect(saveAlbumCalls).toEqual([]);
assertNoSaveSuccessToast(toastCalls);
});
it('4.8 远程图下载成功后再调相册,成功才提示已保存', async () => {
const { savePosterToAlbum } = loadUseCardQrcode();
toastCalls = [];
const ok = await savePosterToAlbum(
'https://cdn.omnicard.ai/poster/alex.png'
);
expect(ok).toBe(true);
expect(lastDownloadedUrl).toBe('https://cdn.omnicard.ai/poster/alex.png');
expect(saveAlbumCalls).toEqual([{ filePath: '/tmp/poster-qr.png' }]);
expect(
toastCalls.some(t => SAVE_SUCCESS_TOAST_RE.test(t.title))
).toBe(true);
});
});
});
describe('OmniCard AI 参展分享码海报页存在 (qrcode.uvue)', () => {
@@ -234,4 +415,79 @@ describe('OmniCard AI 参展分享码海报页存在 (qrcode.uvue)', () => {
expect((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0);
expect((template.match(/<button/g) || []).length).toBe(1);
});
it('展台中间渲染真实 svg 图形:image 或 rich-text 绑定 state.svg', () => {
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
const template = pageSource.split('<script')[0];
const slotMatch = template.match(
/class="qrcode-slot"[\s\S]*?<\/view>\s*<!-- Micro Glow/
);
const slotBlock = slotMatch != null ? slotMatch[0] : '';
expect(logicSource.includes('fetchCardQrcodeSvg')).toBe(true);
expect(template.includes('class="qrcode-slot"')).toBe(true);
expect(template.includes('<image')).toBe(true);
expect(template.includes(':src="state.svg"')).toBe(true);
expect(template.includes('<rich-text')).toBe(true);
expect(template.includes(':nodes="state.svg"')).toBe(true);
expect(slotBlock.includes(':src="state.url"')).toBe(false);
expect(slotBlock.includes('{{ state.url }}')).toBe(false);
});
it('svg 为空或失败时显示空态提示,不画假码、不把 URL 当码图', () => {
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes('qrcode-slot-hint')).toBe(true);
expect(pageSource.includes('codeUnavailable')).toBe(true);
expect(pageSource.includes('codePending')).toBe(true);
expect(template.includes('qrcode-feedback') || pageSource.includes('empty')).toBe(
true
);
expect(logicSource.includes("svg: ''")).toBe(true);
expect(pageSource.includes('fake-qr')).toBe(false);
expect(pageSource.includes('decorative-qr')).toBe(false);
expect(pageSource.includes('placeholder-qr')).toBe(false);
expect(/:src="state\.url"/.test(template)).toBe(false);
});
it('主 CTA 走 savePosterToAlbum,成功依赖 saveImageToPhotosAlbum,失败路径无已保存文案', () => {
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
expect(logicSource.includes('savePosterToAlbum')).toBe(true);
expect(logicSource.includes('saveImageToPhotosAlbum')).toBe(true);
expect(pageSource.includes('savePosterToAlbum')).toBe(true);
expect(pageSource.includes('handlePrimaryCta')).toBe(true);
expect(logicSource.includes("resolveQrcodeText('saveSuccess')")).toBe(true);
expect(logicSource.includes("resolveQrcodeText('saveFailed')")).toBe(true);
expect(logicSource.includes("resolveQrcodeText('saveUnavailable')")).toBe(
true
);
const successIdx = logicSource.indexOf("resolveQrcodeText('saveSuccess')");
const albumIdx = logicSource.indexOf('uni.saveImageToPhotosAlbum');
expect(albumIdx).toBeGreaterThan(-1);
expect(successIdx).toBeGreaterThan(albumIdx);
const albumFailIdx = logicSource.indexOf('fail:', albumIdx);
expect(albumFailIdx).toBeGreaterThan(successIdx);
const albumFailBody = logicSource.slice(albumFailIdx, albumFailIdx + 120);
expect(albumFailBody.includes('toastSaveFailed')).toBe(true);
expect(albumFailBody.includes('saveSuccess')).toBe(false);
expect(SAVE_SUCCESS_TOAST_RE.test(albumFailBody)).toBe(false);
const downloadIdx = logicSource.indexOf('uni.downloadFile');
expect(downloadIdx).toBeGreaterThan(-1);
const downloadFailIdx = logicSource.indexOf('fail:', downloadIdx);
expect(downloadFailIdx).toBeGreaterThan(downloadIdx);
const downloadFailBody = logicSource.slice(
downloadFailIdx,
downloadFailIdx + 80
);
expect(downloadFailBody.includes('saveSuccess')).toBe(false);
expect(SAVE_SUCCESS_TOAST_RE.test(downloadFailBody)).toBe(false);
});
});

查看文件

@@ -170,6 +170,34 @@ describe('OmniCard AI 名片大厅 (useHall.uts)', () => {
expect(state.empty).toBe(false);
expect(state.list[0].peerName).toBe('Tariq Mansour');
});
it('P48: 空列表标记空态,刷新不注入示例联系人', async () => {
const { createHallState, refreshHall, resolveHallText } = loadHall();
const state = createHallState();
await refreshHall(state, {
fetchHallContactPage: async () => ({
list: [],
pagination: { page: 1, size: 20, total: 0 },
}),
});
expect(state.empty).toBe(true);
expect(state.list).toHaveLength(0);
expect(state.errorMessage).toBe('');
expect(state.list.some((row: { peerName?: string }) => row.peerName === 'Tariq Mansour')).toBe(
false
);
expect(resolveHallText('empty')).toBeTruthy();
expect(resolveHallText('refresh')).toBeTruthy();
await refreshHall(state, {
fetchHallContactPage: async () => ({
list: [],
pagination: { page: 1, size: 20, total: 0 },
}),
});
expect(state.empty).toBe(true);
expect(state.list).toEqual([]);
});
});
describe('OmniCard AI 名片大厅页契约 (index.uvue)', () => {
@@ -184,4 +212,43 @@ describe('OmniCard AI 名片大厅页契约 (index.uvue)', () => {
expect(logicSource.includes('/admin/card/contact/hallPage') || logicSource.includes('fetchHallContactPage')).toBe(true);
expect(pageSource.includes('#0B5CFF')).toBe(true);
});
it('P48: 空态仅一个 primary-cta-btn,点击走 handlePrimary,不造假数据', () => {
const pageSource = fs.readFileSync(HALL_PAGE, 'utf8');
const logicSource = fs.readFileSync(HALL_UTS, 'utf8');
const template = pageSource.split('<script')[0];
const script = pageSource.includes('<script')
? pageSource.split('<script')[1].split('</script>')[0]
: '';
const emptyBlockMatch = template.match(
/v-else-if="state\.empty"[\s\S]*?(?=<list-view|<!-- Fixed Floating|<\/template>)/
);
const emptyBlock = emptyBlockMatch != null ? emptyBlockMatch[0] : '';
expect(emptyBlock.length).toBeGreaterThan(0);
expect(emptyBlock.includes("t('empty')") || emptyBlock.includes('t("empty")')).toBe(
true
);
expect(emptyBlock.includes('emptyDesc')).toBe(true);
expect(emptyBlock.includes('primary-cta-btn')).toBe(false);
expect(emptyBlock.includes('<button')).toBe(false);
expect((template.match(/class="primary-cta-btn"/g) || []).length).toBe(1);
expect((template.match(/<button/g) || []).length).toBe(1);
expect(template.includes('@click="handlePrimary"')).toBe(true);
expect(script.includes('async function handlePrimary')).toBe(true);
expect(script.includes('submitHallEventJoin(state)')).toBe(true);
expect(script.includes('submitHallExchange(state)')).toBe(true);
expect(script.includes('refreshHall(state)')).toBe(true);
expect(script.includes('comingSoon')).toBe(false);
expect(logicSource.includes('comingSoon')).toBe(false);
expect(pageSource.includes('comingSoon')).toBe(false);
expect(script.includes('peerName:')).toBe(false);
expect(logicSource.includes("peerName: 'Tariq")).toBe(false);
expect(logicSource.includes('peerName: "Tariq')).toBe(false);
expect(logicSource.includes('示例')).toBe(false);
expect(pageSource.includes('示例联系人')).toBe(false);
});
});

查看文件

@@ -98,6 +98,8 @@ type UseLeadDetailModule = {
imageUrls: string[];
};
formatWhatsAppUrl: (phone: string | null | undefined) => string;
callLeadPhone: (phone: string | null | undefined) => boolean;
openLeadWhatsApp: (phoneOrWhatsApp: string | null | undefined) => boolean;
createLeadDetailState?: () => Record<string, any>;
generateAiIcebreaker: (
lead: LeadEntity | null,
@@ -113,7 +115,12 @@ type UseLeadDetailModule = {
attachments?: string[];
},
api?: SaveNoteApi
) => Promise<LeadEntity>;
) => Promise<{
ok: boolean;
code?: number;
lead: LeadEntity | null;
errorMessage: string;
}>;
resolveLeadDetailText: (key: string) => string;
};
@@ -156,18 +163,40 @@ function loadUseLeadDetail(): UseLeadDetailModule {
}
describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', () => {
let showToastMock: jest.Mock;
let makePhoneCallMock: jest.Mock;
let setClipboardDataMock: jest.Mock;
let openUrlCalls: string[];
beforeEach(() => {
jest.resetModules();
showToastMock = jest.fn();
makePhoneCallMock = jest.fn();
setClipboardDataMock = jest.fn(
(options: { data: string; success?: () => void }) => {
options.success?.();
}
);
openUrlCalls = [];
(global as { uni?: Record<string, unknown> }).uni = {
getLocale: () => 'zh-CN',
showToast: jest.fn(),
showToast: showToastMock,
makePhoneCall: makePhoneCallMock,
setClipboardData: setClipboardDataMock,
navigateBack: jest.fn(),
navigateTo: jest.fn(),
};
(global as { window?: { open?: (url: string, target?: string) => void } }).window =
{
open: (url: string, _target?: string) => {
openUrlCalls.push(url);
},
};
});
afterEach(() => {
delete (global as { uni?: Record<string, unknown> }).uni;
delete (global as { window?: unknown }).window;
});
describe('1. parseLeadId', () => {
@@ -276,8 +305,10 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', ()
);
expect(httpPost).toHaveBeenCalledTimes(1);
expect(result.notes).toBe('Met at GITEX\nAsked for quote');
expect(result.status).toBe('hot');
expect(result.ok).toBe(true);
expect(result.code).toBe(1000);
expect(result.lead?.notes).toBe('Met at GITEX\nAsked for quote');
expect(result.lead?.status).toBe('hot');
});
it('3.3 updateLeadStage 调用 updateOwnerLeadStatus 推进商机阶段', async () => {
@@ -366,6 +397,101 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', ()
expect(ok).toBe(false);
expect(httpPost).not.toHaveBeenCalled();
});
it('3.6 submitLeadNote 仅 code===1000 清空 nextNote;网络错误/非1000 保留输入', async () => {
const mod = loadUseLeadDetail() as UseLeadDetailModule & {
submitLeadNote: (
state: Record<string, any>,
api?: SaveNoteApi
) => Promise<boolean>;
saveNote: UseLeadDetailModule['saveNote'];
};
const successPost = jest.fn(
async (_url: string, data: Record<string, string | number>) => {
return buildLead({ notes: String(data.notes) });
}
);
const okState: Record<string, any> = {
leadId: 21,
lead: buildLead(),
existingNotes: 'Met at GITEX',
nextNote: 'Asked for quote',
attachments: [],
saving: false,
errorMessage: '',
};
const ok = await mod.submitLeadNote(okState, { httpPost: successPost });
expect(ok).toBe(true);
expect(okState.nextNote).toBe('');
expect(okState.existingNotes).toContain('Asked for quote');
const failPost = jest.fn(async () => {
throw new Error('网络连接异常,请重试');
});
const failState: Record<string, any> = {
leadId: 21,
lead: buildLead(),
existingNotes: 'Met at GITEX',
nextNote: 'Keep this draft',
attachments: ['https://cdn.example.com/shot.jpg'],
saving: false,
errorMessage: '',
};
const failOk = await mod.submitLeadNote(failState, { httpPost: failPost });
expect(failOk).toBe(false);
expect(failState.nextNote).toBe('Keep this draft');
expect(failState.attachments).toEqual([
'https://cdn.example.com/shot.jpg',
]);
expect(failState.existingNotes).toBe('Met at GITEX');
expect(failState.errorMessage.length).toBeGreaterThan(0);
const non1000Post = jest.fn(async () => {
throw new Error('请求处理失败');
});
const non1000State: Record<string, any> = {
leadId: 21,
lead: buildLead(),
existingNotes: 'Old',
nextNote: 'Draft stays',
attachments: [],
saving: false,
errorMessage: '',
};
const non1000Ok = await mod.submitLeadNote(non1000State, {
httpPost: non1000Post,
});
expect(non1000Ok).toBe(false);
expect(non1000State.nextNote).toBe('Draft stays');
});
it('3.7 saveNote 成功结果带 code===1000;失败不伪装成功', async () => {
const { saveNote } = loadUseLeadDetail();
const httpPost = jest.fn(
async (_url: string, data: Record<string, string | number>) => {
return buildLead({ notes: String(data.notes) });
}
);
const result = await saveNote(
{ id: 21, notes: 'A', next: 'B' },
{ httpPost }
);
expect(result.ok).toBe(true);
expect(result.code).toBe(1000);
expect(result.lead?.notes).toBe('A\nB');
const failPost = jest.fn(async () => {
throw new Error('请求处理失败');
});
const failResult = await saveNote(
{ id: 21, notes: 'A', next: 'B' },
{ httpPost: failPost }
);
expect(failResult.ok).toBe(false);
expect(failResult.code).not.toBe(1000);
expect(failResult.lead).toBeNull();
});
});
describe('4. resolveLeadDetailText 只增文案', () => {
@@ -456,6 +582,116 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', ()
expect(state.source).toBe('');
});
});
describe('6. 电话与 WhatsApp 真实触达 (callLeadPhone / openLeadWhatsApp)', () => {
it('6.1 有合法电话时调用 makePhoneCall,不 toast 即将提供', () => {
const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail();
const ok = callLeadPhone('+971-50-1234567');
expect(ok).toBe(true);
expect(makePhoneCallMock).toHaveBeenCalledTimes(1);
expect(makePhoneCallMock.mock.calls[0][0].phoneNumber).toBe('+971-50-1234567');
const comingSoon = resolveLeadDetailText('comingSoon');
const toastedComingSoon = showToastMock.mock.calls.some(
(c: unknown[]) =>
c[0] != null &&
typeof c[0] === 'object' &&
(c[0] as { title?: string }).title === comingSoon
);
expect(toastedComingSoon).toBe(false);
});
it('6.2 无合法电话时 toast comingSoon,不调 makePhoneCall', () => {
const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail();
expect(callLeadPhone('')).toBe(false);
expect(callLeadPhone(null)).toBe(false);
expect(callLeadPhone(' ')).toBe(false);
expect(callLeadPhone('---')).toBe(false);
expect(makePhoneCallMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalled();
expect(showToastMock.mock.calls[0][0].title).toBe(
resolveLeadDetailText('comingSoon')
);
});
it('6.3 makePhoneCall fail 回调提示错误,不伪造成功', () => {
makePhoneCallMock.mockImplementation(
(options: {
phoneNumber: string;
fail?: (err: { errMsg?: string }) => void;
}) => {
options.fail?.({ errMsg: 'makePhoneCall:fail' });
}
);
const { callLeadPhone, resolveLeadDetailText } = loadUseLeadDetail();
const ok = callLeadPhone('+8613800000000');
expect(ok).toBe(true);
expect(makePhoneCallMock).toHaveBeenCalledTimes(1);
const failToast = showToastMock.mock.calls.find(
(c: unknown[]) =>
c[0] != null &&
typeof c[0] === 'object' &&
(c[0] as { title?: string }).title ===
resolveLeadDetailText('callFailed')
);
expect(failToast).toBeDefined();
expect(failToast![0].title).not.toBe(resolveLeadDetailText('comingSoon'));
});
it('6.4 有号码时 openLeadWhatsApp 打开 wa.me 数字链接', () => {
const { openLeadWhatsApp, formatWhatsAppUrl } = loadUseLeadDetail();
const expected = formatWhatsAppUrl('+971-50-1234567');
const ok = openLeadWhatsApp('+971-50-1234567');
expect(ok).toBe(true);
expect(expected).toBe('https://wa.me/971501234567');
expect(openUrlCalls).toContain(expected);
expect(
showToastMock.mock.calls.some(
(c: unknown[]) =>
c[0] != null &&
typeof c[0] === 'object' &&
(c[0] as { title?: string }).title === '即将提供'
)
).toBe(false);
});
it('6.5 无号码时 openLeadWhatsApp toast comingSoon,不打开空 wa.me', () => {
const { openLeadWhatsApp, resolveLeadDetailText } = loadUseLeadDetail();
expect(openLeadWhatsApp('')).toBe(false);
expect(openLeadWhatsApp(null)).toBe(false);
expect(openLeadWhatsApp('abc')).toBe(false);
expect(openUrlCalls).toEqual([]);
expect(setClipboardDataMock).not.toHaveBeenCalled();
const openedEmpty = setClipboardDataMock.mock.calls.some(
(c: unknown[]) =>
c[0] != null &&
typeof c[0] === 'object' &&
String((c[0] as { data?: string }).data || '').includes('wa.me/')
);
expect(openedEmpty).toBe(false);
expect(showToastMock.mock.calls[0][0].title).toBe(
resolveLeadDetailText('comingSoon')
);
});
it('6.6 无 window.open 时回退 setClipboardData 写入 wa.me 链接', () => {
delete (global as { window?: unknown }).window;
const { openLeadWhatsApp } = loadUseLeadDetail();
const ok = openLeadWhatsApp('0086 138-0000-0000');
expect(ok).toBe(true);
expect(setClipboardDataMock).toHaveBeenCalledTimes(1);
expect(setClipboardDataMock.mock.calls[0][0].data).toBe(
'https://wa.me/8613800000000'
);
});
});
});
describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => {
@@ -485,9 +721,14 @@ describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () =>
expect(pageSource.includes('uploadHint')).toBe(true);
expect(pageSource.includes('image-uploader')).toBe(true);
expect(logicSource.includes('async function submitLeadNote')).toBe(true);
expect(
combined.includes('handleWhatsApp') || logicSource.includes('formatWhatsAppUrl')
).toBe(true);
expect(logicSource.includes('function callLeadPhone')).toBe(true);
expect(logicSource.includes('function openLeadWhatsApp')).toBe(true);
expect(logicSource.includes('uni.makePhoneCall')).toBe(true);
expect(logicSource.includes('https://wa.me/')).toBe(true);
expect(pageSource.includes('callLeadPhone')).toBe(true);
expect(pageSource.includes('openLeadWhatsApp')).toBe(true);
expect(pageSource.includes('handleCallPhone')).toBe(true);
expect(pageSource.includes('handleWhatsApp')).toBe(true);
expect(
combined.includes('handleSaveContact') || logicSource.includes('saveContactToDevice')
).toBe(true);
@@ -512,6 +753,22 @@ describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () =>
expect(pageSource.includes('lead-cta-inner')).toBe(true);
expect(pageSource.includes('max-width: 480px')).toBe(true);
expect(pageSource.includes("'contactSaved'")).toBe(false);
expect(
pageSource.includes('code === 1000') ||
logicSource.includes('code === 1000')
).toBe(true);
expect(pageSource.includes('noteSavedToast')).toBe(true);
expect(pageSource.includes('handleSave')).toBe(true);
const saveStart = pageSource.indexOf('async function handleSave():');
const handleSaveBlock =
saveStart >= 0 ? pageSource.slice(saveStart, saveStart + 720) : '';
expect(handleSaveBlock.includes('noteSavedToast')).toBe(true);
expect(handleSaveBlock.includes('if (ok)')).toBe(true);
expect(handleSaveBlock.includes('submitLeadNote')).toBe(true);
// 失败不得 toast 已保存:noteSavedToast 仅出现在 if (ok) 分支内
expect(handleSaveBlock.indexOf('if (ok)')).toBeLessThan(
handleSaveBlock.indexOf('noteSavedToast')
);
});
it('无 emoji、无 any,模板无硬编码中文主文案', () => {
const pageSource = fs.readFileSync(DETAIL_PAGE, 'utf8');

查看文件

@@ -2,6 +2,7 @@
* OmniCard AI (CRMmp) - 名片主移动潜客列表测试套件 (TDD Red -> Green)
*
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/leads 设计编写
* R30:筛选只用接口真实 status / intentScore;空态与失败不填示例线索
*/
import * as fs from 'fs';
@@ -68,6 +69,20 @@ type LoadLeadsResult = {
errorMessage: string;
};
type LeadsState = {
list: LeadEntity[];
radarTracks: unknown[];
hotUnread: number;
page: number;
size: number;
total: number;
status: string;
keyWord: string;
loading: boolean;
empty: boolean;
errorMessage: string;
};
type UseLeadsModule = {
parseLeadStatus: (raw: string | null | undefined) => LeadStatus | null;
formatHotUnread: (unread: number) => string;
@@ -75,9 +90,14 @@ type UseLeadsModule = {
query: LoadLeadsQuery,
api?: LoadLeadsApi
) => Promise<LoadLeadsResult>;
createLeadsState: () => LeadsState;
applyLeadStatusFilter: (state: LeadsState, status: string) => void;
applyLoadResult: (state: LeadsState, result: LoadLeadsResult) => void;
sanitizeLeadQuery: (query: LoadLeadsQuery) => OwnerPageQuery;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
const SAMPLE_NAME_RE = /Alex Vance|Nexus Tech|示例线索|Fatima Al-Rashid|演示潜客/;
function buildLead(overrides?: Partial<LeadEntity>): LeadEntity {
return {
@@ -143,13 +163,14 @@ describe('OmniCard AI 名片主移动潜客列表 (useLeads.uts)', () => {
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(result.list).toHaveLength(1);
expect(result.list[0].name).toBe('Alex Vance');
expect(result.list[0].status).toBe('hot');
expect(result.list[0].intentScore).toBe(91);
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('');
expect(result.pagination.total).toBe(1);
});
it('1.2 空列表标记空态,不写错误', async () => {
it('1.2 空列表标记空态,不写错误、不填示例', async () => {
const { loadLeads } = loadUseLeads();
const fetchOwnerLeadPage = jest.fn(
async (): Promise<OwnerPageData<LeadEntity>> => emptyPage(1, 10)
@@ -164,9 +185,12 @@ describe('OmniCard AI 名片主移动潜客列表 (useLeads.uts)', () => {
expect(result.list).toEqual([]);
expect(result.empty).toBe(true);
expect(result.errorMessage).toBe('');
expect(result.list.some((item) => SAMPLE_NAME_RE.test(item.name))).toBe(
false
);
});
it('1.3 接口失败写入错误,不伪装空态成功', async () => {
it('1.3 接口失败写入错误,列表清空,不伪装空态成功', async () => {
const { loadLeads } = loadUseLeads();
const fetchOwnerLeadPage = jest.fn(async (): Promise<OwnerPageData<LeadEntity>> => {
throw new Error('lead page unavailable');
@@ -194,6 +218,8 @@ describe('OmniCard AI 名片主移动潜客列表 (useLeads.uts)', () => {
expect(parseLeadStatus('won')).toBe('won');
expect(parseLeadStatus('archived')).toBe('archived');
expect(parseLeadStatus('pending')).toBeNull();
expect(parseLeadStatus('warm')).toBeNull();
expect(parseLeadStatus('cold')).toBeNull();
expect(parseLeadStatus('NEW')).toBeNull();
expect(parseLeadStatus('')).toBeNull();
expect(parseLeadStatus(null)).toBeNull();
@@ -223,6 +249,87 @@ describe('OmniCard AI 名片主移动潜客列表 (useLeads.uts)', () => {
});
});
describe('3. R30 真实 status / intentScore 筛选与失败清列表', () => {
it('3.1 sanitizeLeadQuery 只透传契约 status,不伪造 intent', () => {
const { sanitizeLeadQuery } = loadUseLeads();
const hot = sanitizeLeadQuery({
page: 1,
size: 20,
status: 'hot',
keyWord: 'score',
});
expect(hot.status).toBe('hot');
expect(hot.keyWord).toBe('score');
expect(Object.prototype.hasOwnProperty.call(hot, 'intentScore')).toBe(
false
);
const cleared = sanitizeLeadQuery({
page: 1,
size: 20,
status: 'warm',
});
expect(cleared.status).toBeUndefined();
});
it('3.2 applyLeadStatusFilter 只写入真实 status 枚举', () => {
const { createLeadsState, applyLeadStatusFilter } = loadUseLeads();
const state = createLeadsState();
applyLeadStatusFilter(state, 'hot');
expect(state.status).toBe('hot');
expect(state.page).toBe(1);
applyLeadStatusFilter(state, 'contacted');
expect(state.status).toBe('contacted');
applyLeadStatusFilter(state, 'warm');
expect(state.status).toBe('');
applyLeadStatusFilter(state, '');
expect(state.status).toBe('');
});
it('3.3 失败结果写回 state 时清空上一批线索名,不保留示例', () => {
const { createLeadsState, applyLoadResult } = loadUseLeads();
const state = createLeadsState();
state.list = [buildLead({ name: 'Alex Vance', intentScore: 91 })];
state.total = 1;
state.empty = false;
state.errorMessage = '';
applyLoadResult(state, {
list: [],
pagination: { page: 1, size: 20, total: 0 },
empty: false,
errorMessage: 'lead page unavailable',
});
expect(state.list).toEqual([]);
expect(state.total).toBe(0);
expect(state.empty).toBe(false);
expect(state.errorMessage).toBe('lead page unavailable');
expect(state.list.some((item) => item.name === 'Alex Vance')).toBe(false);
});
it('3.4 空列表写回 state 标记空态,列表保持空数组', () => {
const { createLeadsState, applyLoadResult } = loadUseLeads();
const state = createLeadsState();
state.list = [buildLead()];
applyLoadResult(state, {
list: [],
pagination: { page: 1, size: 20, total: 0 },
empty: true,
errorMessage: '',
});
expect(state.list).toEqual([]);
expect(state.empty).toBe(true);
expect(state.errorMessage).toBe('');
});
});
it('formatHotUnread 有未读才显示红点文案', () => {
const { formatHotUnread } = loadUseLeads();
expect(formatHotUnread(0)).toBe('');
@@ -278,4 +385,41 @@ describe('OmniCard AI 名片主潜客列表页契约 (leads.uvue)', () => {
expect(/<view[^>]*v-for/.test(template)).toBe(false);
expect(template.includes('<list-item')).toBe(true);
});
it('R30: 展示用真实 status/intentScore,空态无示例,芯片筛选会刷新', () => {
const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8');
const logicSource = fs.readFileSync(LEADS_UTS, 'utf8');
const template = pageSource.split('<script')[0];
const script = pageSource.includes('<script')
? pageSource.split('<script')[1].split('</script>')[0]
: '';
expect(template.includes('item.intentScore')).toBe(true);
expect(template.includes('item.status')).toBe(true);
expect(template.includes('state.empty')).toBe(true);
expect(
template.includes("t('empty')") || template.includes('t("empty")')
).toBe(true);
expect(template.includes('leads-feedback')).toBe(true);
expect(SAMPLE_NAME_RE.test(pageSource)).toBe(false);
expect(SAMPLE_NAME_RE.test(logicSource)).toBe(false);
expect(logicSource.includes('示例')).toBe(false);
expect(pageSource.includes('示例')).toBe(false);
expect(script.includes("'统计'") || script.includes('"统计"')).toBe(false);
expect(script.includes('resolveLeadsText')).toBe(true);
expect(script.includes('applyLeadStatusFilter')).toBe(true);
expect(script.includes('refreshLeads')).toBe(true);
expect(
/function selectStatus[\s\S]*?applyLeadStatusFilter[\s\S]*?refreshLeads/.test(
script
)
).toBe(true);
expect(logicSource.includes('sanitizeLeadQuery')).toBe(true);
expect(logicSource.includes('parseLeadStatus')).toBe(true);
expect(logicSource.includes('intentScore')).toBe(false);
});
});

查看文件

@@ -55,6 +55,36 @@ type StorageApi = {
setStorageSync: (key: string, value: unknown) => void;
};
type CardProfileFixture = {
id?: string | number;
name: string;
title?: string;
company?: string;
department?: string;
avatarUrl?: string;
};
type OwnerPageQuery = {
page: number;
size: number;
keyWord?: string;
};
type OwnerPageData<T> = {
list: T[];
pagination: {
page: number;
size: number;
total: number;
};
};
type LoadProfileAccountApi = {
fetchOwnerCardPage: (
query: OwnerPageQuery
) => Promise<OwnerPageData<CardProfileFixture>>;
};
type UseProfileModule = {
PROFILE_ACCOUNT_STORAGE_KEY: string;
PROFILE_NOTIFICATIONS_STORAGE_KEY: string;
@@ -70,9 +100,17 @@ type UseProfileModule = {
setPreferredLocale: (state: ProfileState, locale: string, storage?: StorageApi) => OmniLocale;
calculateQuotaPercent: (used: number, limit: number) => number;
resolveProfileText: (key: string) => string;
displayOrgField: (value: string | null | undefined) => string;
clearOrgFields: (state: ProfileState) => void;
loadOwnerProfileAccount: (
state: ProfileState,
api?: LoadProfileAccountApi
) => Promise<boolean>;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
const SAMPLE_ORG_RE =
/Nexus Tech Global|Overseas Business Dept|Cyberdyne Systems|Global Trade Co\.|示例(公司|组织|部门)/;
function loadUseProfile(): UseProfileModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -102,14 +140,17 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
});
describe('1. 配置读取与初始化 (loadProfileSettings)', () => {
it('1.1 首次无缓存时,返回默认账户资料与通知开关开启', () => {
const { loadProfileSettings } = loadUseProfile();
it('1.1 首次无缓存时,公司/部门为空串,通知开关开启', () => {
const { loadProfileSettings, getDefaultAccount } = loadUseProfile();
const defaults = getDefaultAccount();
const state = loadProfileSettings();
expect(state.account.name).toBeTruthy();
expect(state.account.company).toBeTruthy();
expect(state.account.department).toBeTruthy();
expect(state.account.avatar).toBeTruthy();
expect(defaults.company).toBe('');
expect(defaults.department).toBe('');
expect(SAMPLE_ORG_RE.test(defaults.company)).toBe(false);
expect(SAMPLE_ORG_RE.test(defaults.department)).toBe(false);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(state.notifications.radarPush).toBe(true);
expect(state.notifications.leadNotice).toBe(true);
expect(state.locale).toBe('zh-CN');
@@ -125,7 +166,7 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = JSON.stringify({
name: 'Sarah Connor',
company: 'Cyberdyne Systems',
company: 'Acme Corp',
department: 'Security Division',
avatar: 'https://example.com/sarah.jpg',
});
@@ -136,14 +177,14 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
const state = loadProfileSettings();
expect(state.account.name).toBe('Sarah Connor');
expect(state.account.company).toBe('Cyberdyne Systems');
expect(state.account.company).toBe('Acme Corp');
expect(state.account.department).toBe('Security Division');
expect(state.account.avatar).toBe('https://example.com/sarah.jpg');
expect(state.notifications.radarPush).toBe(false);
expect(state.notifications.leadNotice).toBe(true);
});
it('1.3 防御保护:缓存数据损坏时不抛错,优雅回退默认值', () => {
it('1.3 防御保护:缓存数据损坏时不抛错,公司/部门保持空串', () => {
const {
loadProfileSettings,
PROFILE_ACCOUNT_STORAGE_KEY,
@@ -156,14 +197,14 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = 'not-an-object';
const state = loadProfileSettings();
expect(state.account.name).toBeTruthy();
expect(state.account.company).toBeTruthy();
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(state.notifications.radarPush).toBe(true);
expect(state.notifications.leadNotice).toBe(true);
expect(state.errorMessage).toBe('');
});
it('1.4 防御保护:缓存字段部分缺失时自动补充缺省字段', () => {
it('1.4 防御保护:缓存缺公司/部门时保持空串,不回填示例组织', () => {
const {
loadProfileSettings,
PROFILE_ACCOUNT_STORAGE_KEY,
@@ -179,8 +220,8 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
const state = loadProfileSettings();
expect(state.account.name).toBe('John Doe');
expect(state.account.company).toBeTruthy();
expect(state.account.department).toBeTruthy();
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(state.notifications.radarPush).toBe(false);
expect(state.notifications.leadNotice).toBe(true);
});
@@ -297,12 +338,12 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
updateAccount(state, {
name: ' Elena Rostova ',
company: 'Global Trade Co.',
company: 'Northwind Ltd',
department: 'Marketing',
});
expect(state.account.name).toBe('Elena Rostova');
expect(state.account.company).toBe('Global Trade Co.');
expect(state.account.company).toBe('Northwind Ltd');
expect(state.account.department).toBe('Marketing');
const saved = saveProfileSettings(state);
@@ -312,22 +353,28 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] as string
) as UserProfileAccount;
expect(parsed.name).toBe('Elena Rostova');
expect(parsed.company).toBe('Global Trade Co.');
expect(parsed.company).toBe('Northwind Ltd');
});
it('4.2 防御保护:空值或非法输入不覆盖已有有效信息', () => {
const { loadProfileSettings, updateAccount } = loadUseProfile();
const state = loadProfileSettings();
const originalName = state.account.name;
updateAccount(state, {
name: 'Elena',
company: 'Northwind Ltd',
department: 'Sales',
});
updateAccount(state, {
name: ' ',
company: '',
department: '',
});
// 空白字符串不应覆盖已有名称与企业
expect(state.account.name).toBe(originalName);
expect(state.account.company).toBeTruthy();
expect(state.account.name).toBe('Elena');
expect(state.account.company).toBe('Northwind Ltd');
expect(state.account.department).toBe('Sales');
});
});
@@ -352,6 +399,133 @@ describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts
expect(resolveProfileText('upgradeQuota')).toBeTruthy();
expect(resolveProfileText('confirmLogoutTitle')).toBeTruthy();
});
it('5.3 displayOrgField:缺失或空串走 i18n 空态,有值原样返回', () => {
const { displayOrgField, resolveProfileText } = loadUseProfile();
const emptyText = resolveProfileText('empty');
expect(emptyText).toBeTruthy();
expect(displayOrgField('')).toBe(emptyText);
expect(displayOrgField(' ')).toBe(emptyText);
expect(displayOrgField(null)).toBe(emptyText);
expect(displayOrgField(undefined)).toBe(emptyText);
expect(displayOrgField('Acme Corp')).toBe('Acme Corp');
});
});
describe('6. R39 公司/部门仅来自接口 (loadOwnerProfileAccount)', () => {
it('6.1 成功时写入接口 company / department,缺字段为空串', async () => {
const { createProfileState, loadOwnerProfileAccount } = loadUseProfile();
const state = createProfileState();
state.account.company = 'stale-company';
state.account.department = 'stale-dept';
const fetchOwnerCardPage = jest.fn(
async (): Promise<OwnerPageData<CardProfileFixture>> => ({
list: [
{
id: 8,
name: 'Elena',
company: 'Acme Corp',
},
],
pagination: { page: 1, size: 1, total: 1 },
})
);
const ok = await loadOwnerProfileAccount(state, { fetchOwnerCardPage });
expect(ok).toBe(true);
expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1);
expect(state.account.company).toBe('Acme Corp');
expect(state.account.department).toBe('');
});
it('6.1b 接口同时返回 company 与 department 时原样写入', async () => {
const { createProfileState, loadOwnerProfileAccount } = loadUseProfile();
const state = createProfileState();
await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => ({
list: [
{
id: 3,
name: 'Elena',
company: 'Acme Corp',
department: 'APAC Sales',
},
],
pagination: { page: 1, size: 1, total: 1 },
}),
});
expect(state.account.company).toBe('Acme Corp');
expect(state.account.department).toBe('APAC Sales');
});
it('6.2 接口返回空串时保持空串,不回填示例组织', async () => {
const { createProfileState, loadOwnerProfileAccount, displayOrgField, resolveProfileText } =
loadUseProfile();
const state = createProfileState();
state.account.company = 'stale';
state.account.department = 'stale';
await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => ({
list: [
{
id: 1,
name: 'Elena',
company: '',
department: ' ',
},
],
pagination: { page: 1, size: 1, total: 1 },
}),
});
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
expect(displayOrgField(state.account.company)).toBe(resolveProfileText('empty'));
expect(displayOrgField(state.account.department)).toBe(resolveProfileText('empty'));
expect(resolveProfileText('empty')).toBe('暂无数据');
});
it('6.3 接口失败时清空公司与部门,不保留上次或示例值', async () => {
const { createProfileState, loadOwnerProfileAccount, clearOrgFields } = loadUseProfile();
const state = createProfileState();
state.account.company = 'stale-company';
state.account.department = 'stale-dept';
const ok = await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => {
throw new Error('network down');
},
});
expect(ok).toBe(false);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
clearOrgFields(state);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
});
it('6.4 列表为空时同样清空公司与部门', async () => {
const { createProfileState, loadOwnerProfileAccount } = loadUseProfile();
const state = createProfileState();
state.account.company = 'stale-company';
state.account.department = 'stale-dept';
const ok = await loadOwnerProfileAccount(state, {
fetchOwnerCardPage: async () => ({
list: [],
pagination: { page: 1, size: 1, total: 0 },
}),
});
expect(ok).toBe(true);
expect(state.account.company).toBe('');
expect(state.account.department).toBe('');
});
});
});
@@ -379,13 +553,15 @@ describe('OmniCard AI 名片主个人中心视图契约 (index.uvue & pages.json
expect(combined.includes('toggleRadarPush')).toBe(true);
expect(combined.includes('toggleLeadNotice')).toBe(true);
expect(combined.includes('setPreferredLocale')).toBe(true);
expect(combined.includes('loadOwnerProfileAccount')).toBe(true);
expect(combined.includes('displayOrgField')).toBe(true);
// 瑞士国际主义风格 Token 引用
expect(pageSource.includes('#0B5CFF')).toBe(true); // primary
expect(pageSource.includes('#F7F8FA')).toBe(true); // surface
// OmniCard 设计 Token
expect(pageSource.includes('#0088FF')).toBe(true); // primary
expect(pageSource.includes('#F5F8FC')).toBe(true); // bg
expect(pageSource.includes('#101828')).toBe(true); // text
expect(pageSource.includes('#667085')).toBe(true); // text-2
expect(pageSource.includes('#E3E7ED')).toBe(true); // border
expect(pageSource.includes('#64748B')).toBe(true); // text-2
expect(pageSource.includes('#EBF1F7')).toBe(true); // border
// Switch 触控区 >= 44x44 规范验证 (min-height >= 44px 或 height >= 44px)
expect(
@@ -397,7 +573,7 @@ describe('OmniCard AI 名片主个人中心视图契约 (index.uvue & pages.json
).toBe(true);
});
it('零 emoji、无 any,无硬编码中文主文案,符合瑞士国际主义约束', () => {
it('零 emoji、无 any,公司/部门无写死示例组织', () => {
const pageSource = fs.readFileSync(PROFILE_PAGE, 'utf8');
const logicSource = fs.readFileSync(PROFILE_UTS, 'utf8');
const testSource = fs.readFileSync(__filename, 'utf8');
@@ -410,8 +586,17 @@ describe('OmniCard AI 名片主个人中心视图契约 (index.uvue & pages.json
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
// 模板中主文案走词典或 resolve 函数,无硬编码中文汉字
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
// 公司/部门展示走 displayOrgField,禁止写死示例组织
expect(template.includes('displayOrgField')).toBe(true);
expect(template.includes('state.account.company }}')).toBe(false);
expect(template.includes('state.account.department }}')).toBe(false);
expect(SAMPLE_ORG_RE.test(pageSource)).toBe(false);
expect(SAMPLE_ORG_RE.test(logicSource)).toBe(false);
expect(logicSource.includes("company: 'Nexus")).toBe(false);
expect(logicSource.includes('company: "Nexus')).toBe(false);
expect(logicSource.includes('Overseas Business')).toBe(false);
expect(logicSource.includes("resolveProfileText('empty')")).toBe(true);
expect(logicSource.includes("getOmniText(locale, 'common.empty')")).toBe(true);
});
it('mpuniapp/pages.json 中已成功注册 pages/owner/profile/index', () => {

查看文件

@@ -24,7 +24,12 @@ type RadarTrackFixture = {
stayDurationMs: number;
scrollDepth: number;
viewedBlocks: string[];
viewedContents?: { key: string; title?: string }[];
visitorName?: string;
visitorCompany?: string;
avatarUrl?: string;
createdAt?: string | Date;
createTime?: string | Date;
};
type OwnerPageQueryFixture = {
@@ -61,12 +66,37 @@ type LoadTracksResult = {
total: number;
};
type RadarStateFixture = {
cardIdRaw: string;
page: number;
size: number;
total: number;
loading: boolean;
empty: boolean;
errorMessage: string;
tracks: RadarTrackFixture[];
};
type UseRadarModule = {
formatStay: (ms: number | null | undefined) => string;
formatDepth: (depth: number | null | undefined) => string;
formatLocation: (country?: string, city?: string) => string;
isHighIntent: (stayDurationMs: number, scrollDepth: number) => boolean;
loadTracks: (input: LoadTracksInput, api?: RadarFetchApi) => Promise<LoadTracksResult>;
createRadarState: () => RadarStateFixture;
refreshTracks: (
state: RadarStateFixture,
api?: RadarFetchApi
) => Promise<LoadTracksResult>;
resolveRadarText: (key: string) => string;
};
/** 与后端热线索 / 页面既有阈值一致:停留 ≥ 3 分钟 */
const HIGH_INTENT_STAY_MS = 180000;
/** tokens.uts light error */
const ERROR_TOKEN = '#F04438';
const ERROR_CSS_VAR = '--oc-error';
type UniToastOption = {
title: string;
icon?: string;
@@ -296,6 +326,139 @@ describe('OmniCard AI 名片主移动实时雷达业务逻辑测试 (useRadar.ut
expect(result.total).toBe(0);
});
});
describe('3. isHighIntent 高意向阈值(停留 ≥ 3 分钟)', () => {
it('3.1 停留达到 180000ms 判定为高意向,未达阈值且深度不足则为否', () => {
const { isHighIntent } = loadUseRadar();
expect(isHighIntent(HIGH_INTENT_STAY_MS, 0)).toBe(true);
expect(isHighIntent(HIGH_INTENT_STAY_MS - 1, 0)).toBe(false);
expect(isHighIntent(0, 0.6)).toBe(true);
expect(isHighIntent(0, 60)).toBe(true);
expect(isHighIntent(29999, 0.2)).toBe(false);
});
});
describe('4. formatLocation 空国家显示未知,禁止默认示例城市', () => {
it('4.1 无国家或国家为空时走 i18n 未知,不回退迪拜/阿联酋', () => {
const { formatLocation, resolveRadarText } = loadUseRadar();
const unknownText = resolveRadarText('unknown');
expect(unknownText).toBeTruthy();
expect(unknownText).toBe('未知');
expect(formatLocation(undefined, undefined)).toBe(unknownText);
expect(formatLocation(null as unknown as string, null as unknown as string)).toBe(
unknownText
);
expect(formatLocation('', '')).toBe(unknownText);
expect(formatLocation(' ', ' ')).toBe(unknownText);
expect(formatLocation('', 'Paris')).toBe(`${unknownText} / Paris`);
expect(formatLocation('China', 'Shanghai')).toBe('China / Shanghai');
expect(formatLocation('Japan', '')).toBe('Japan');
expect(formatLocation(undefined, undefined)).not.toContain('Dubai');
expect(formatLocation('', '')).not.toContain('United Arab Emirates');
expect(formatLocation('', '')).not.toContain('阿联酋');
});
it('4.2 接口失败时 refreshTracks 清空既有列表,不保留示例国家', async () => {
const { createRadarState, refreshTracks } = loadUseRadar();
const state = createRadarState();
state.tracks = [
buildTrack({ id: 1, country: 'United Arab Emirates', city: 'Dubai' }),
buildTrack({ id: 2, country: 'China', city: 'Shanghai' }),
];
state.empty = false;
state.total = 2;
const fetchOwnerRadarPage = jest.fn(async () => {
throw new Error('雷达服务暂不可用');
});
const result = await refreshTracks(state, { fetchOwnerRadarPage });
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
expect(result.list).toEqual([]);
expect(state.tracks).toEqual([]);
expect(state.empty).toBe(true);
expect(state.total).toBe(0);
expect(state.errorMessage).toBe('雷达服务暂不可用');
expect(JSON.stringify(state.tracks)).not.toContain('Dubai');
expect(JSON.stringify(state.tracks)).not.toContain('United Arab Emirates');
});
});
});
describe('OmniCard AI 雷达监控准确性 (useRadar.uts)', () => {
function loadRadar(): {
formatLocation: (country?: string, city?: string) => string;
formatPublicIp: (ip?: string) => string;
formatViewedLabels: (item: RadarTrackFixture) => string[];
consolidateRadarTracks: (tracks: RadarTrackFixture[]) => RadarTrackFixture[];
isVisitorOnline: (
item: RadarTrackFixture,
now?: number
) => boolean;
formatVisitorAction: (item: RadarTrackFixture) => string;
formatClockTime: (createdAt?: string | Date) => string;
RADAR_ONLINE_WINDOW_MS: number;
} {
return require('../../../mpuniapp/pages/owner/useRadar.uts');
}
it('内网与 Unknown 不当成真实城市,公网 IP 才保留', () => {
const { formatLocation, formatPublicIp } = loadRadar();
expect(formatLocation('Local', 'Local')).toBe('本地网络');
expect(formatLocation('Unknown', 'Unknown')).toBe('未知');
expect(formatPublicIp('127.0.0.1')).toBe('');
expect(formatPublicIp('192.168.1.8')).toBe('');
expect(formatPublicIp('8.8.8.8')).toBe('8.8.8.8');
});
it('同一访客的进入与离开合并,停留、深度和浏览模块取完整会话', () => {
const { consolidateRadarTracks, formatViewedLabels, formatVisitorAction } = loadRadar();
const rows = consolidateRadarTracks([
buildTrack({
id: 1,
stayDurationMs: 0,
scrollDepth: 0,
viewedBlocks: ['hero'],
createdAt: '2026-09-23T08:00:00.000Z',
}),
buildTrack({
id: 2,
stayDurationMs: 45000,
scrollDepth: 80,
viewedBlocks: ['case', 'article'],
visitorName: 'Alex',
avatarUrl: 'https://cdn.example.com/a.png',
visitorCompany: 'Nexus',
createdAt: '2026-09-23T08:02:00.000Z',
viewedContents: [
{ key: 'case', title: 'Middle East Hub' },
{ key: 'article', title: '' },
],
}),
]);
expect(rows).toHaveLength(1);
expect(rows[0].stayDurationMs).toBe(45000);
expect(rows[0].scrollDepth).toBe(80);
expect(rows[0].visitorName).toBe('Alex');
expect(rows[0].avatarUrl).toBe('https://cdn.example.com/a.png');
expect(formatViewedLabels(rows[0])).toEqual(['Middle East Hub', '文章']);
expect(formatVisitorAction(rows[0])).toBe('通过 NFC 碰一碰打开名片');
expect(formatVisitorAction(buildTrack({ touchType: 'link' }))).not.toContain('留资');
});
it('5 分钟内算在线,更早的足迹不算在线', () => {
const { isVisitorOnline, formatClockTime, RADAR_ONLINE_WINDOW_MS } = loadRadar();
const now = Date.parse('2026-09-23T10:00:00.000Z');
const recent = buildTrack({ createdAt: new Date(now - 60 * 1000).toISOString() });
const stale = buildTrack({ createdAt: new Date(now - RADAR_ONLINE_WINDOW_MS - 1000).toISOString() });
expect(isVisitorOnline(recent, now)).toBe(true);
expect(isVisitorOnline(stale, now)).toBe(false);
expect(formatClockTime('2026-09-23T10:05:00.000Z')).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/);
});
});
describe('OmniCard AI 名片主移动实时雷达页契约 (radar.uvue)', () => {
@@ -338,4 +501,90 @@ describe('OmniCard AI 名片主移动实时雷达页契约 (radar.uvue)', () =>
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
expect(pageSource.includes('innerAudioContext') || pageSource.includes('autoplay')).toBe(false);
});
it('高意向行用 error token 标出,不用品牌蓝当警告', () => {
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
const styleBlock = pageSource.includes('<style')
? pageSource.slice(pageSource.indexOf('<style'))
: '';
expect(logicSource.includes('180000')).toBe(true);
expect(pageSource.includes('isHighIntent')).toBe(true);
expect(pageSource.includes('radar-row-hot')).toBe(true);
expect(pageSource.includes(`'--oc-error'`) || pageSource.includes(`"--oc-error"`) || pageSource.includes(ERROR_CSS_VAR)).toBe(
true
);
expect(pageSource.includes(ERROR_TOKEN) || pageSource.includes('utsLightColors.error')).toBe(true);
const hotRuleMatch = styleBlock.match(/\.radar-row-hot\s*\{[^}]+\}/);
expect(hotRuleMatch).not.toBeNull();
const hotRule = hotRuleMatch != null ? hotRuleMatch[0] : '';
expect(hotRule.includes('#E3E7ED')).toBe(true);
expect(hotRule.includes(ERROR_TOKEN)).toBe(false);
expect(hotRule.includes('#F04438')).toBe(false);
expect(hotRule.includes('#FEF3F2')).toBe(false);
expect(hotRule.includes('#0088FF')).toBe(false);
expect(hotRule.includes('#0B5CFF')).toBe(false);
expect(hotRule.includes('#3894FF')).toBe(false);
expect(hotRule.includes('var(--oc-primary')).toBe(false);
const intentHighMatch = styleBlock.match(/\.intent-high\s*\{[^}]+\}/);
expect(intentHighMatch).not.toBeNull();
const intentHighRule = intentHighMatch != null ? intentHighMatch[0] : '';
expect(intentHighRule.includes('#0088FF')).toBe(false);
expect(intentHighRule.includes('#0B5CFF')).toBe(false);
const intentHighTextMatch = styleBlock.match(/\.intent-high\s+\.intent-text\s*\{[^}]+\}/);
expect(intentHighTextMatch).not.toBeNull();
const intentHighTextRule = intentHighTextMatch != null ? intentHighTextMatch[0] : '';
expect(
intentHighTextRule.includes(ERROR_CSS_VAR) || intentHighTextRule.includes(ERROR_TOKEN)
).toBe(true);
expect(intentHighTextRule.includes('#0088FF')).toBe(false);
expect(intentHighTextRule.includes('#0B5CFF')).toBe(false);
});
it('R42: 足迹空国家显示未知;源码不以 Dubai/阿联酋为默认;失败清空列表', () => {
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
const template = pageSource.split('<script')[0];
expect(logicSource.includes("'unknown'") || logicSource.includes('"unknown"')).toBe(true);
expect(logicSource.includes('resolveRadarText(') && logicSource.includes('unknown')).toBe(
true
);
expect(logicSource.includes('formatLocation')).toBe(true);
expect(pageSource.includes('formatLocation')).toBe(true);
expect(template.includes('formatLocation(item.country, item.city)')).toBe(true);
expect(logicSource.includes('Dubai')).toBe(false);
expect(logicSource.includes('United Arab Emirates')).toBe(false);
expect(logicSource.includes('阿联酋')).toBe(false);
expect(pageSource.includes('Dubai')).toBe(false);
expect(pageSource.includes('United Arab Emirates')).toBe(false);
expect(pageSource.includes('阿联酋')).toBe(false);
expect(logicSource.includes('list: []')).toBe(true);
expect(logicSource.includes('tracks: []')).toBe(true);
expect(logicSource.includes('state.tracks = result.list')).toBe(true);
});
it('监控行展示头像、地址、时刻与浏览内容', () => {
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes('formatLocation(item.country, item.city)')).toBe(true);
expect(template.includes('avatar-img')).toBe(true);
expect(template.includes('mini-avatar-img')).toBe(true);
expect(template.includes('avatar-online')).toBe(true);
expect(template.includes('formatBlocks(item)')).toBe(true);
expect(logicSource.includes('formatMonitorTime')).toBe(true);
expect(logicSource.includes('formatViewedLine')).toBe(true);
expect(logicSource.includes('isVisitorOnline')).toBe(true);
expect(logicSource.includes('consolidateRadarTracks')).toBe(true);
expect(logicSource.includes('saved your contact card')).toBe(false);
expect(logicSource.includes('? 32')).toBe(false);
});
});

查看文件

@@ -2,6 +2,7 @@
* OmniCard AI (CRMmp) - 名片主独立数据统计与漏斗分析页测试套件 (TDD Red -> Green)
*
* 验证独立统计页面的数据计算、漏斗转化分析、渠道矩阵与界面规范
* R31: 接口失败/缺字段时数字为 0、图表为空,禁止示例柱与写死访客数
*/
import * as fs from 'fs';
@@ -63,12 +64,34 @@ type StatsState = {
errorMessage: string;
};
type OwnerPageQuery = {
page: number;
size: number;
};
type OwnerPageData<T> = {
list: T[];
pagination?: {
page: number;
size: number;
total: number;
};
};
type LoadStatsApi = {
fetchOwnerLeadPage?: (
query: OwnerPageQuery
) => Promise<OwnerPageData<LeadEntity> | Record<string, unknown> | null>;
};
type UseStatsModule = {
createStatsState: () => StatsState;
calcConversionRate: (total: number, won: number) => number;
calcFunnelStepRate: (currentCount: number, baseCount: number) => number;
computeStatsFromLeads: (leads: LeadEntity[], period: StatsPeriod) => StatsState;
loadStatsData: (state: StatsState, api?: LoadStatsApi) => Promise<void>;
resolveStatsText: (key: string) => string;
resetStatsToEmpty: (state: StatsState) => void;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
@@ -78,6 +101,15 @@ function loadUseStats(): UseStatsModule {
return require(STATS_UTS) as UseStatsModule;
}
function buildLead(partial: Partial<LeadEntity> & Pick<LeadEntity, 'id' | 'name' | 'status'>): LeadEntity {
return {
cardId: 1,
intentScore: 40,
source: 'link',
...partial,
};
}
describe('OmniCard AI 名片主独立统计与漏斗业务层 (useStats.uts)', () => {
beforeEach(() => {
jest.resetModules();
@@ -114,10 +146,10 @@ describe('OmniCard AI 名片主独立统计与漏斗业务层 (useStats.uts)', (
it('2.1 从潜客样本计算出总数、高意向、成交数与漏斗4阶梯', () => {
const { computeStatsFromLeads } = loadUseStats();
const mockLeads: LeadEntity[] = [
{ id: 1, cardId: 1, name: 'Lead 1', status: 'new', intentScore: 40, source: 'nfc' },
{ id: 2, cardId: 1, name: 'Lead 2', status: 'hot', intentScore: 85, source: 'qr' },
{ id: 3, cardId: 1, name: 'Lead 3', status: 'contacted', intentScore: 70, source: 'link' },
{ id: 4, cardId: 1, name: 'Lead 4', status: 'won', intentScore: 92, source: 'ai_chat' },
buildLead({ id: 1, name: 'Lead 1', status: 'new', intentScore: 40, source: 'nfc' }),
buildLead({ id: 2, name: 'Lead 2', status: 'hot', intentScore: 85, source: 'qr' }),
buildLead({ id: 3, name: 'Lead 3', status: 'contacted', intentScore: 70, source: 'link' }),
buildLead({ id: 4, name: 'Lead 4', status: 'won', intentScore: 92, source: 'ai_chat' }),
];
const state = computeStatsFromLeads(mockLeads, 'all');
@@ -140,6 +172,106 @@ describe('OmniCard AI 名片主独立统计与漏斗业务层 (useStats.uts)', (
expect(aiChannel?.wonCount).toBe(1);
expect(aiChannel?.conversionRate).toBe(100);
});
it('2.2 空列表时数字为 0、漏斗与渠道图表为空,不造示例百分比', () => {
const { computeStatsFromLeads } = loadUseStats();
const state = computeStatsFromLeads([], 'all');
expect(state.summary.totalLeads).toBe(0);
expect(state.summary.hotLeads).toBe(0);
expect(state.summary.wonLeads).toBe(0);
expect(state.summary.overallRate).toBe(0);
expect(state.funnel).toEqual([]);
expect(state.channels).toEqual([]);
});
});
describe('3. loadStatsData 失败/缺字段清零 (R31)', () => {
it('3.1 接口失败时数字归零、图表清空,不保留旧值填洞', async () => {
const { createStatsState, loadStatsData } = loadUseStats();
const state = createStatsState();
state.summary = {
totalLeads: 128,
hotLeads: 36,
wonLeads: 12,
overallRate: 45.5,
};
state.funnel = [
{ key: 'new', label: 'New', count: 128, rate: 100 },
{ key: 'hot', label: 'Hot', count: 36, rate: 28 },
];
state.channels = [
{ source: 'nfc', label: 'NFC', leadsCount: 64, wonCount: 8, conversionRate: 12.5 },
];
state.errorMessage = '';
const fetchOwnerLeadPage = jest.fn(async () => {
throw new Error('network down');
});
await loadStatsData(state, { fetchOwnerLeadPage });
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(state.errorMessage).toBe('network down');
expect(state.summary.totalLeads).toBe(0);
expect(state.summary.hotLeads).toBe(0);
expect(state.summary.wonLeads).toBe(0);
expect(state.summary.overallRate).toBe(0);
expect(state.funnel).toEqual([]);
expect(state.channels).toEqual([]);
expect(state.loading).toBe(false);
});
it('3.2 响应缺 list 字段时按空数据处理,数字为 0、图表为空', async () => {
const { createStatsState, loadStatsData } = loadUseStats();
const state = createStatsState();
state.summary.totalLeads = 99;
const fetchOwnerLeadPage = jest.fn(async () => ({ pagination: { page: 1, size: 100, total: 0 } }));
await loadStatsData(state, { fetchOwnerLeadPage });
expect(state.errorMessage).toBe('');
expect(state.summary.totalLeads).toBe(0);
expect(state.summary.hotLeads).toBe(0);
expect(state.summary.wonLeads).toBe(0);
expect(state.summary.overallRate).toBe(0);
expect(state.funnel).toEqual([]);
expect(state.channels).toEqual([]);
});
it('3.3 成功时仅使用接口 list 聚合,不写死访客数', async () => {
const { createStatsState, loadStatsData } = loadUseStats();
const state = createStatsState();
const fetchOwnerLeadPage = jest.fn(async () => ({
list: [
buildLead({ id: 1, name: 'A', status: 'new', intentScore: 20, source: 'nfc' }),
buildLead({ id: 2, name: 'B', status: 'won', intentScore: 90, source: 'qr' }),
],
pagination: { page: 1, size: 100, total: 2 },
}));
await loadStatsData(state, { fetchOwnerLeadPage });
expect(state.errorMessage).toBe('');
expect(state.summary.totalLeads).toBe(2);
expect(state.summary.wonLeads).toBe(1);
expect(state.summary.overallRate).toBe(50);
expect(state.funnel.length).toBe(4);
expect(state.channels.length).toBe(4);
expect(state.summary.totalLeads).not.toBe(128);
});
});
describe('4. i18n 文案', () => {
it('4.1 resolveStatsText 走现有词典,空/错/重试非空', () => {
const { resolveStatsText } = loadUseStats();
expect(resolveStatsText('title').length).toBeGreaterThan(0);
expect(resolveStatsText('empty').length).toBeGreaterThan(0);
expect(resolveStatsText('error').length).toBeGreaterThan(0);
expect(resolveStatsText('retry').length).toBeGreaterThan(0);
expect(resolveStatsText('metricTotal').length).toBeGreaterThan(0);
});
});
});
@@ -173,6 +305,7 @@ describe('OmniCard AI 独立统计视图契约 (stats.uvue & pages.json)', () =>
expect(pageSource.includes('stats-funnel')).toBe(true);
expect(pageSource.includes('channel-grid')).toBe(true);
expect(logicSource.includes('resolveStatsText')).toBe(true);
expect(logicSource.includes('fetchOwnerLeadPage')).toBe(true);
});
it('3. stats.uvue 无 emoji、无 any、模板无硬编码中文', () => {
@@ -186,4 +319,49 @@ describe('OmniCard AI 独立统计视图契约 (stats.uvue & pages.json)', () =>
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
});
it('4. R31: 健康度柱与漏斗禁止示例柱/写死访客数填洞', () => {
const pageSource = fs.readFileSync(STATS_PAGE, 'utf8');
const logicSource = fs.readFileSync(STATS_UTS, 'utf8');
const combined = `${pageSource}\n${logicSource}`;
const script = pageSource.includes('<script')
? pageSource.split('<script')[1].split('</script>')[0]
: '';
// 健康度 flex 不得在无数据时回落为 1(示例柱)
expect(script.includes('? state.summary.hotLeads : 1')).toBe(false);
expect(script.includes('warm > 0 ? warm : 1')).toBe(false);
expect(script.includes('healthCoolFlex') && /healthCoolFlex[\s\S]*return 1;/.test(script)).toBe(
false
);
expect(script.includes(': 1') && script.includes('healthHotFlex')).toBe(false);
// 禁止写死访客/示例数
expect(combined.includes('totalLeads: 128')).toBe(false);
expect(combined.includes('访客数')).toBe(false);
expect(logicSource.includes('示例')).toBe(false);
expect(pageSource.includes('示例柱')).toBe(false);
// 失败清零与空图表
expect(logicSource.includes('resetStatsToEmpty') || logicSource.includes('funnel = []')).toBe(
true
);
expect(logicSource.includes('channels = []') || logicSource.includes('funnel: []')).toBe(true);
});
it('5. 模板文案走 resolveStatsText / t(),徽章不硬编码英文装饰字', () => {
const pageSource = fs.readFileSync(STATS_PAGE, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes('{{ t(')).toBe(true);
expect(template.includes('>TOTAL<')).toBe(false);
expect(template.includes('>HOT<')).toBe(false);
expect(template.includes('>WON<')).toBe(false);
expect(template.includes('>CVR<')).toBe(false);
expect(template.includes('% CVR')).toBe(false);
expect(template.includes('4 STAGES')).toBe(false);
expect(template.includes("t('badgeRate')") || template.includes('t("badgeRate")')).toBe(
true
);
});
});

查看文件

@@ -383,7 +383,7 @@ describe('OmniCard AI 展会团队战报 (useOverview.uts)', () => {
const fetchOwnerRadarPage = jest.fn(async () => buildPage([buildTrack()]));
const vibrateMock = jest.fn();
(global as any).uni.vibrateShort = vibrateMock;
(global as { uni?: Record<string, unknown> }).uni!.vibrateShort = vibrateMock;
await refreshOverview(state, { fetchOwnerLeadPage, fetchOwnerRadarPage });
@@ -391,6 +391,48 @@ describe('OmniCard AI 展会团队战报 (useOverview.uts)', () => {
expect(state.loading).toBe(false);
expect(state.leads.length).toBe(1);
});
it('3.2 刷新失败写入错误态,不填迪拜示例数、不假装成功', async () => {
const { refreshOverview, createOverviewState } = loadUseOverview();
const state = createOverviewState();
state.summary = {
totalLeads: 99,
hotCount: 9,
totalTracks: 88,
topCountry: 'United Arab Emirates',
};
state.leads = [buildLead()];
state.tracks = [buildTrack()];
state.errorMessage = '';
const fetchOwnerLeadPage = jest.fn(async (): Promise<OwnerPageData<LeadEntity>> => {
throw new Error('network down');
});
const fetchOwnerRadarPage = jest.fn(
async (): Promise<OwnerPageData<RadarTrack>> => buildPage([buildTrack()])
);
const vibrateMock = jest.fn();
(global as { uni?: Record<string, unknown> }).uni!.vibrateShort = vibrateMock;
const result = await refreshOverview(state, {
fetchOwnerLeadPage,
fetchOwnerRadarPage,
});
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
expect(result.errorMessage).toBe('network down');
expect(state.errorMessage).toBe('network down');
expect(state.leads).toEqual([]);
expect(state.tracks).toEqual([]);
expect(state.summary.totalLeads).toBe(0);
expect(state.summary.hotCount).toBe(0);
expect(state.summary.totalTracks).toBe(0);
expect(state.summary.topCountry).toBe('');
expect(state.summary.topCountry).not.toBe('United Arab Emirates');
expect(vibrateMock).not.toHaveBeenCalled();
expect(state.loading).toBe(false);
});
});
});
@@ -440,6 +482,37 @@ describe('OmniCard AI 展会团队战报页契约 (overview.uvue)', () => {
expect(pageSource.includes('#FFFFFF')).toBe(true);
});
it('P47: 一屏仅一个 primary-cta-btn,点击走 refreshOverview,失败不填示例', () => {
const pageSource = fs.readFileSync(OVERVIEW_PAGE, 'utf8');
const logicSource = fs.readFileSync(OVERVIEW_UTS, 'utf8');
const template = pageSource.split('<script')[0];
const script = pageSource.includes('<script')
? pageSource.split('<script')[1].split('</script>')[0]
: '';
expect((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThanOrEqual(1);
expect((template.match(/class="primary-cta-btn"/g) || []).length).toBe(1);
expect((template.match(/<button/g) || []).length).toBe(1);
expect(template.includes('@click="handleRefresh"')).toBe(true);
expect(script.includes('refreshOverview(state)')).toBe(true);
expect(script.includes('comingSoon')).toBe(false);
expect(logicSource.includes('comingSoon')).toBe(false);
expect(pageSource.includes('comingSoon')).toBe(false);
expect(pageSource.includes('shareSuccess') || logicSource.includes('shareSuccess')).toBe(
false
);
expect(logicSource.includes('refreshOverview')).toBe(true);
expect(logicSource.includes('loadOverview')).toBe(true);
expect(logicSource.includes('emptySummary')).toBe(true);
expect(logicSource.includes("topCountry: 'United Arab Emirates'")).toBe(false);
expect(logicSource.includes('topCountry: "United Arab Emirates"')).toBe(false);
expect(logicSource.includes('Dubai')).toBe(false);
expect(pageSource.includes('state.errorMessage')).toBe(true);
expect(pageSource.includes("t('error')") || pageSource.includes('t("error")')).toBe(
true
);
});
it('无 emoji、无 any,模板无硬编码中文主文案,长列表不用 view v-for', () => {
const pageSource = fs.readFileSync(OVERVIEW_PAGE, 'utf8');
const logicSource = fs.readFileSync(OVERVIEW_UTS, 'utf8');

查看文件

@@ -401,3 +401,232 @@ describe('OmniCard AI 用户端微官网 Bento 首页与 Hero 业务逻辑测试
});
});
describe('OmniCard AI 门户 Cookie 同意条契约 (R33)', () => {
const fs = require('fs') as typeof import('fs');
const path = require('path') as typeof import('path');
const PORTAL_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/index.uvue'
);
const LOCALE_FILE = path.resolve(
__dirname,
'../../../mpuniapp/common/locale.uts'
);
const COOKIE_CONSENT_KEY = 'omni_cookie_consent';
const COOKIE_CONSENT_ACCEPTED = 'accepted';
function readPortalPage(): string {
return fs.readFileSync(PORTAL_PAGE, 'utf8');
}
function extractFunctionBody(source: string, fnName: string): string {
const marker = `function ${fnName}`;
const start = source.indexOf(marker);
expect(start).toBeGreaterThan(-1);
const braceStart = source.indexOf('{', start);
expect(braceStart).toBeGreaterThan(-1);
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
const ch = source.charAt(i);
if (ch === '{') depth++;
if (ch === '}') {
depth--;
if (depth === 0) {
return source.slice(braceStart, i + 1);
}
}
}
return '';
}
it('8.1 无本地同意记录时模板仅渲染一条 Cookie 同意条,文案走 i18n', () => {
const pageSource = readPortalPage();
const localeSource = fs.readFileSync(LOCALE_FILE, 'utf8');
const template = pageSource.split('<script')[0];
const cookieBlockMatch = template.match(
/v-if="showCookieBanner"[\s\S]*?cookie-consent-bar[\s\S]*?<\/view>/
);
const cookieBlock = cookieBlockMatch != null ? cookieBlockMatch[0] : '';
expect(template.includes('cookie-consent-bar')).toBe(true);
expect((template.match(/class="cookie-consent-bar"/g) || []).length).toBe(1);
expect(template.includes('v-if="showCookieBanner"')).toBe(true);
expect(cookieBlock.includes("textOf('portal.cookie_notice')") || pageSource.includes("textOf('portal.cookie_notice')")).toBe(true);
expect(pageSource.includes("textOf('portal.cookie_accept')")).toBe(true);
expect(pageSource.includes("textOf('portal.cookie_reject')")).toBe(true);
expect(/[\u4e00-\u9fff]/.test(cookieBlock)).toBe(false);
expect(localeSource.includes("'portal.cookie_notice'")).toBe(true);
expect(localeSource.includes("'portal.cookie_accept'")).toBe(true);
expect(localeSource.includes("'portal.cookie_reject'")).toBe(true);
expect(pageSource.includes(COOKIE_CONSENT_KEY)).toBe(true);
expect(pageSource.includes(COOKIE_CONSENT_ACCEPTED)).toBe(true);
expect(pageSource.includes('initCookieBanner')).toBe(true);
});
it('8.2 点同意才写入本地同意记录并收起条;无记录时不假装已同意', () => {
const pageSource = readPortalPage();
const acceptBody = extractFunctionBody(pageSource, 'acceptCookieConsent');
const initBody = extractFunctionBody(pageSource, 'initCookieBanner');
expect(acceptBody.includes('setStorageSync')).toBe(true);
expect(
acceptBody.includes(COOKIE_CONSENT_KEY) ||
acceptBody.includes('COOKIE_CONSENT_KEY')
).toBe(true);
expect(
acceptBody.includes(COOKIE_CONSENT_ACCEPTED) ||
acceptBody.includes('COOKIE_CONSENT_ACCEPTED')
).toBe(true);
expect(acceptBody.includes('showCookieBanner.value = false')).toBe(true);
expect(initBody.includes('getStorageSync') || pageSource.includes('readCookieConsentRaw')).toBe(true);
expect(pageSource.includes(`'${COOKIE_CONSENT_KEY}'`) || pageSource.includes(`"${COOKIE_CONSENT_KEY}"`)).toBe(true);
expect(pageSource.includes(`'${COOKIE_CONSENT_ACCEPTED}'`) || pageSource.includes(`"${COOKIE_CONSENT_ACCEPTED}"`)).toBe(true);
expect(initBody.includes('showCookieBanner')).toBe(true);
expect(initBody.includes('hasCookieConsentRecord')).toBe(true);
// 初始化不得写死已同意(不在无检查时直接 false)
expect(initBody.trim() === '{ showCookieBanner.value = false; }').toBe(false);
// 行为模拟:无记录 → 应显示;写入 accepted → 收起
const storage = new Map<string, string>();
const hasRecord = (): boolean =>
storage.get(COOKIE_CONSENT_KEY) === COOKIE_CONSENT_ACCEPTED;
let show = !hasRecord();
expect(show).toBe(true);
storage.set(COOKIE_CONSENT_KEY, COOKIE_CONSENT_ACCEPTED);
show = !hasRecord();
expect(show).toBe(false);
expect(hasRecord()).toBe(true);
});
it('8.3 点拒绝不得写入同意记录,不得把拒绝当成已同意', () => {
const pageSource = readPortalPage();
const rejectBody = extractFunctionBody(pageSource, 'rejectCookieConsent');
expect(rejectBody.includes('setStorageSync')).toBe(false);
expect(rejectBody.includes('COOKIE_CONSENT_ACCEPTED')).toBe(false);
expect(rejectBody.includes(`'${COOKIE_CONSENT_ACCEPTED}'`)).toBe(false);
expect(rejectBody.includes('showCookieBanner.value = false')).toBe(true);
// 行为模拟:拒绝只收起当前会话条,本地仍无同意记录
const storage = new Map<string, string>();
let show = !storage.has(COOKIE_CONSENT_KEY);
expect(show).toBe(true);
// reject: hide only
show = false;
expect(storage.has(COOKIE_CONSENT_KEY)).toBe(false);
expect(storage.get(COOKIE_CONSENT_KEY)).toBeUndefined();
// 再次进入:无同意记录 → 仍应出现同意条
show = storage.get(COOKIE_CONSENT_KEY) !== COOKIE_CONSENT_ACCEPTED;
expect(show).toBe(true);
});
});
describe('OmniCard AI 门户 Line / KakaoTalk 空 URL 不渲染 (R38)', () => {
const fs = require('fs') as typeof import('fs');
const path = require('path') as typeof import('path');
const PORTAL_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/index.uvue'
);
const PORTAL_LOGIC = path.resolve(
__dirname,
'../../../mpuniapp/pages/portal/usePortal.uts'
);
const LOCALE_FILE = path.resolve(
__dirname,
'../../../mpuniapp/common/locale.uts'
);
function readPortalPage(): string {
return fs.readFileSync(PORTAL_PAGE, 'utf8');
}
function readPortalLogic(): string {
return fs.readFileSync(PORTAL_LOGIC, 'utf8');
}
it('9.1 isRenderableHttpUrl:空/缺失/非 http(s) 为 false,合法 http(s) 为 true', () => {
// @ts-ignore
const { isRenderableHttpUrl } = require('../../../mpuniapp/pages/portal/usePortal.uts');
expect(isRenderableHttpUrl(undefined)).toBe(false);
expect(isRenderableHttpUrl(null as any)).toBe(false);
expect(isRenderableHttpUrl('')).toBe(false);
expect(isRenderableHttpUrl(' ')).toBe(false);
expect(isRenderableHttpUrl('line.me/ti/p/@demo')).toBe(false);
expect(isRenderableHttpUrl('ftp://example.com/line')).toBe(false);
expect(isRenderableHttpUrl('javascript:alert(1)')).toBe(false);
expect(isRenderableHttpUrl('https://line.me/ti/p/@real')).toBe(true);
expect(isRenderableHttpUrl('http://open.kakao.com/o/real')).toBe(true);
});
it('9.2 模板仅在 hasQuickAction(line|kakao) 时渲染,无示例链接与演示账号', () => {
const pageSource = readPortalPage();
const logicSource = readPortalLogic();
const localeSource = fs.readFileSync(LOCALE_FILE, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes("hasQuickAction('line')")).toBe(true);
expect(template.includes("hasQuickAction('kakao')")).toBe(true);
expect(template.includes("textOf('portal.line')")).toBe(true);
expect(template.includes("textOf('portal.kakao')")).toBe(true);
// 不得硬编码演示账号 / 示例社交链
expect(/line\.me\/ti\/p\/@demo/i.test(pageSource)).toBe(false);
expect(/open\.kakao\.com\/o\/demo/i.test(pageSource)).toBe(false);
expect(/example\.com\/(line|kakao)/i.test(pageSource)).toBe(false);
expect(pageSource.includes('演示账号')).toBe(false);
// 空 URL 不渲染依赖 isRenderableHttpUrl / hasQuickAction
expect(logicSource.includes('isRenderableHttpUrl')).toBe(true);
expect(pageSource.includes('isRenderableHttpUrl') || pageSource.includes("hasQuickAction('line')")).toBe(true);
expect(localeSource.includes("'portal.line'")).toBe(true);
expect(localeSource.includes("'portal.kakao'")).toBe(true);
});
it('9.3 Line/Kakao 有合法 URL 可触发复制;空值不打开且不填示例链', () => {
let clipboardText = '';
const toastCalls: Array<{ title: string; icon?: string }> = [];
(global as any).uni = {
showToast: (options: { title: string; icon?: string }) => {
toastCalls.push(options);
},
setClipboardData: (options: { data: string; success?: () => void }) => {
clipboardText = options.data;
options.success?.();
},
getStorageSync: () => '',
setStorageSync: () => {},
};
// @ts-ignore
const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts');
const okEmpty = triggerQuickAction('line', '');
expect(okEmpty).toBe(false);
expect(clipboardText).toBe('');
expect(toastCalls.length).toBeGreaterThan(0);
toastCalls.length = 0;
const okBad = triggerQuickAction('kakao', 'not-a-url');
expect(okBad).toBe(false);
expect(clipboardText).toBe('');
const lineUrl = 'https://line.me/ti/p/@real-user';
const okLine = triggerQuickAction('line', lineUrl);
expect(okLine).toBe(true);
expect(clipboardText).toBe(lineUrl);
const kakaoUrl = 'https://open.kakao.com/o/real-user';
const okKakao = triggerQuickAction('kakao', kakaoUrl);
expect(okKakao).toBe(true);
expect(clipboardText).toBe(kakaoUrl);
delete (global as any).uni;
});
});

查看文件

@@ -19,7 +19,7 @@ describe('uni-app 框架自带原生 tabBar 规范测试 (TDD Red -> Green)', ()
// 关键规范:tabBar.list[0].pagePath 必须与 pages[0].path 一致,原生 tabBar 才能在首屏正常加载
expect(config.tabBar.list[0].pagePath).toBe(config.pages[0].path);
expect(config.tabBar.list[0].text).toBe('名片');
expect(['工作台', '名片']).toContain(config.tabBar.list[0].text);
// 校验颜色 Token
expect(config.tabBar.color.toLowerCase()).toBe('#667085');