- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
457 行
13 KiB
TypeScript
457 行
13 KiB
TypeScript
/**
|
|
* OmniCard AI (CRMmp) - mpuniapp 文章详情第一版测试套件 (TDD Red -> Green)
|
|
*
|
|
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD pages/portal/article-detail 设计编写
|
|
*/
|
|
|
|
type BentoBlockFixture = {
|
|
id: number | string;
|
|
cardId: number | string;
|
|
type: string;
|
|
colSpan: number;
|
|
rowSpan: number;
|
|
sortOrder: number;
|
|
title?: string;
|
|
content?: string;
|
|
mediaUrl?: string;
|
|
linkUrl?: string;
|
|
};
|
|
|
|
type ArticleDetailState = {
|
|
cardId: string;
|
|
blockId: string;
|
|
loading: boolean;
|
|
article: BentoBlockFixture | null;
|
|
title: string;
|
|
content: string;
|
|
mediaUrl: string;
|
|
linkUrl: string;
|
|
isHtmlContent: boolean;
|
|
empty: boolean;
|
|
errorMessage: string;
|
|
openGraph: {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
};
|
|
};
|
|
|
|
type ArticleShareFields = {
|
|
ctaLabel: string;
|
|
copiedToast: string;
|
|
shareUrl: string;
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
};
|
|
|
|
type ArticleDetailModule = {
|
|
createArticleDetailState: () => ArticleDetailState;
|
|
applyArticleDetailOptions: (
|
|
state: ArticleDetailState,
|
|
options: { cardId?: string; blockId?: string; id?: string } | null
|
|
) => boolean;
|
|
pickArticleBlock: (
|
|
blocks: BentoBlockFixture[] | null | undefined,
|
|
blockId: string
|
|
) => BentoBlockFixture | null;
|
|
looksLikeHtml: (content: string) => boolean;
|
|
buildArticleOpenGraph: (article: BentoBlockFixture | null) => {
|
|
title: string;
|
|
description: string;
|
|
image: string;
|
|
};
|
|
resolveShareUrl: (linkUrl: string, pageUrl: string) => string;
|
|
getArticleShareFields: (state: ArticleDetailState, pageUrl: string) => ArticleShareFields;
|
|
loadArticleDetail: (state: ArticleDetailState) => Promise<BentoBlockFixture | null>;
|
|
copyArticleShareLink: (state: ArticleDetailState, pageUrl?: string) => boolean;
|
|
};
|
|
|
|
type UniToastOption = {
|
|
title: string;
|
|
icon?: string;
|
|
};
|
|
|
|
type UniRequestSuccessRes = {
|
|
statusCode: number;
|
|
data: unknown;
|
|
header: Record<string, string>;
|
|
};
|
|
|
|
type UniRequestOption = {
|
|
url: string;
|
|
method?: string;
|
|
data?: unknown;
|
|
success?: (res: UniRequestSuccessRes) => void;
|
|
fail?: (err: { errMsg: string }) => void;
|
|
complete?: () => void;
|
|
};
|
|
|
|
function buildArticleBlock(): BentoBlockFixture {
|
|
return {
|
|
id: 3,
|
|
cardId: 'card-101',
|
|
type: 'article',
|
|
colSpan: 1,
|
|
rowSpan: 1,
|
|
sortOrder: 2,
|
|
title: 'The Future of Cross-Border Trade',
|
|
content: '<p>Cross-border trade is shifting toward trusted digital identity.</p>',
|
|
mediaUrl: 'https://cdn.omnicard.ai/media/article-cover.jpg',
|
|
linkUrl: 'https://omnicard.ai/p/article-3',
|
|
};
|
|
}
|
|
|
|
function buildPortalPayload(blocks: BentoBlockFixture[]) {
|
|
return {
|
|
code: 1000,
|
|
message: 'success',
|
|
data: {
|
|
id: 'card-101',
|
|
name: 'Alex Vance',
|
|
title: 'Managing Director & Global Partner',
|
|
company: 'Nexus Tech Global Ltd',
|
|
status: 'published',
|
|
bentoBlocks: blocks,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('OmniCard AI 用户端文章详情业务逻辑测试 (useArticleDetail.uts)', () => {
|
|
let storageMap: Map<string, string>;
|
|
let toastCalls: UniToastOption[];
|
|
let clipboardText: string;
|
|
let lastRequestOptions: UniRequestOption | null;
|
|
let requestCallCount: number;
|
|
let mockRequestResponse: {
|
|
statusCode?: number;
|
|
data?: {
|
|
code?: number;
|
|
message?: string;
|
|
data?: unknown;
|
|
};
|
|
failError?: { errMsg: string };
|
|
};
|
|
|
|
beforeEach(() => {
|
|
storageMap = new Map();
|
|
toastCalls = [];
|
|
clipboardText = '';
|
|
lastRequestOptions = null;
|
|
requestCallCount = 0;
|
|
mockRequestResponse = {
|
|
statusCode: 200,
|
|
data: buildPortalPayload([
|
|
{
|
|
id: 1,
|
|
cardId: 'card-101',
|
|
type: 'hero',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 0,
|
|
title: 'Hero Block',
|
|
},
|
|
{
|
|
id: 2,
|
|
cardId: 'card-101',
|
|
type: 'case',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 1,
|
|
title: 'Middle East Energy Hub',
|
|
content: 'Built scalable renewable energy microgrid in UAE.',
|
|
},
|
|
buildArticleBlock(),
|
|
{
|
|
id: 4,
|
|
cardId: 'card-101',
|
|
type: 'social',
|
|
colSpan: 1,
|
|
rowSpan: 1,
|
|
sortOrder: 3,
|
|
title: 'Global Social Matrix',
|
|
},
|
|
]),
|
|
};
|
|
|
|
(global as { uni?: Record<string, unknown> }).uni = {
|
|
getStorageSync: (key: string) => storageMap.get(key) || '',
|
|
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
|
|
removeStorageSync: (key: string) => storageMap.delete(key),
|
|
getLocale: () => 'zh-CN',
|
|
getSystemInfoSync: () => ({ language: 'zh-CN' }),
|
|
showToast: (options: UniToastOption) => {
|
|
toastCalls.push(options);
|
|
},
|
|
reLaunch: jest.fn(),
|
|
navigateBack: jest.fn(),
|
|
getCurrentPages: () => [],
|
|
setClipboardData: (options: { data: string; success?: () => void }) => {
|
|
clipboardText = options.data;
|
|
options.success?.();
|
|
},
|
|
request: (options: UniRequestOption) => {
|
|
requestCallCount += 1;
|
|
lastRequestOptions = options;
|
|
setTimeout(() => {
|
|
if (mockRequestResponse.failError) {
|
|
options.fail?.(mockRequestResponse.failError);
|
|
} else {
|
|
options.success?.({
|
|
statusCode: mockRequestResponse.statusCode ?? 200,
|
|
data: mockRequestResponse.data,
|
|
header: {},
|
|
});
|
|
}
|
|
options.complete?.();
|
|
}, 0);
|
|
return {
|
|
abort: jest.fn(),
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (global as { uni?: Record<string, unknown> }).uni;
|
|
jest.resetModules();
|
|
});
|
|
|
|
function loadUseArticleDetail(): ArticleDetailModule {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
return require('../../../mpuniapp/pages/portal/useArticleDetail.uts') as ArticleDetailModule;
|
|
}
|
|
|
|
describe('1. 能选出 article 块', () => {
|
|
it('1.1 pickArticleBlock 按 blockId 选出 type 为 article 的块', () => {
|
|
const { pickArticleBlock } = loadUseArticleDetail();
|
|
const article = buildArticleBlock();
|
|
const picked = pickArticleBlock(
|
|
[
|
|
{
|
|
id: 2,
|
|
cardId: 'card-101',
|
|
type: 'case',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 1,
|
|
title: 'Case',
|
|
},
|
|
article,
|
|
{
|
|
id: 4,
|
|
cardId: 'card-101',
|
|
type: 'social',
|
|
colSpan: 1,
|
|
rowSpan: 1,
|
|
sortOrder: 3,
|
|
title: 'Social',
|
|
},
|
|
],
|
|
'3'
|
|
);
|
|
|
|
expect(picked).not.toBeNull();
|
|
expect(picked?.type).toBe('article');
|
|
expect(String(picked?.id)).toBe('3');
|
|
expect(picked?.title).toBe('The Future of Cross-Border Trade');
|
|
});
|
|
|
|
it('1.2 loadArticleDetail 拉取门户后按 cardId/blockId 绑定文章字段', async () => {
|
|
const {
|
|
createArticleDetailState,
|
|
applyArticleDetailOptions,
|
|
loadArticleDetail,
|
|
} = loadUseArticleDetail();
|
|
|
|
const state = createArticleDetailState();
|
|
const ready = applyArticleDetailOptions(state, {
|
|
cardId: 'card-101',
|
|
blockId: '3',
|
|
});
|
|
expect(ready).toBe(true);
|
|
|
|
const result = await loadArticleDetail(state);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(requestCallCount).toBe(1);
|
|
expect(lastRequestOptions?.url).toContain('/open/card/portal/detail');
|
|
expect(state.empty).toBe(false);
|
|
expect(state.article?.type).toBe('article');
|
|
expect(state.title).toBe('The Future of Cross-Border Trade');
|
|
expect(state.content).toContain('Cross-border trade');
|
|
expect(state.mediaUrl).toBe('https://cdn.omnicard.ai/media/article-cover.jpg');
|
|
expect(state.linkUrl).toBe('https://omnicard.ai/p/article-3');
|
|
expect(state.isHtmlContent).toBe(true);
|
|
expect(state.openGraph.title).toBe('The Future of Cross-Border Trade');
|
|
expect(state.openGraph.image).toBe('https://cdn.omnicard.ai/media/article-cover.jpg');
|
|
expect(state.openGraph.description.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('1.3 正文像 HTML 时 looksLikeHtml 为真,纯文本为假', () => {
|
|
const { looksLikeHtml } = loadUseArticleDetail();
|
|
expect(looksLikeHtml('<p>Insight body</p>')).toBe(true);
|
|
expect(looksLikeHtml('Plain magazine paragraph without markup')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('2. 缺失返空', () => {
|
|
it('2.1 缺少 cardId 或 blockId 时不请求并标记空状态', async () => {
|
|
const {
|
|
createArticleDetailState,
|
|
applyArticleDetailOptions,
|
|
loadArticleDetail,
|
|
} = loadUseArticleDetail();
|
|
|
|
const state = createArticleDetailState();
|
|
const ready = applyArticleDetailOptions(state, {});
|
|
expect(ready).toBe(false);
|
|
expect(state.empty).toBe(true);
|
|
|
|
const result = await loadArticleDetail(state);
|
|
expect(result).toBeNull();
|
|
expect(requestCallCount).toBe(0);
|
|
expect(state.empty).toBe(true);
|
|
expect(state.article).toBeNull();
|
|
expect(state.title).toBe('');
|
|
});
|
|
|
|
it('2.2 blockId 命中非 article 或块不存在时返空', async () => {
|
|
const {
|
|
pickArticleBlock,
|
|
createArticleDetailState,
|
|
applyArticleDetailOptions,
|
|
loadArticleDetail,
|
|
} = loadUseArticleDetail();
|
|
|
|
expect(
|
|
pickArticleBlock(
|
|
[
|
|
{
|
|
id: 2,
|
|
cardId: 'card-101',
|
|
type: 'case',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 1,
|
|
title: 'Case',
|
|
},
|
|
],
|
|
'2'
|
|
)
|
|
).toBeNull();
|
|
expect(pickArticleBlock([], '3')).toBeNull();
|
|
expect(pickArticleBlock(null, '3')).toBeNull();
|
|
|
|
const state = createArticleDetailState();
|
|
applyArticleDetailOptions(state, { cardId: 'card-101', blockId: '2' });
|
|
const result = await loadArticleDetail(state);
|
|
|
|
expect(result).toBeNull();
|
|
expect(state.empty).toBe(true);
|
|
expect(state.article).toBeNull();
|
|
expect(state.openGraph.title).toBe('');
|
|
expect(state.openGraph.description).toBe('');
|
|
expect(state.openGraph.image).toBe('');
|
|
});
|
|
|
|
it('2.3 拉取失败同样返空', async () => {
|
|
const {
|
|
createArticleDetailState,
|
|
applyArticleDetailOptions,
|
|
loadArticleDetail,
|
|
} = loadUseArticleDetail();
|
|
|
|
mockRequestResponse = {
|
|
statusCode: 404,
|
|
data: {
|
|
code: 404,
|
|
message: '名片不存在或未公开',
|
|
},
|
|
};
|
|
|
|
const state = createArticleDetailState();
|
|
applyArticleDetailOptions(state, { cardId: 'missing', blockId: '3' });
|
|
const result = await loadArticleDetail(state);
|
|
|
|
expect(result).toBeNull();
|
|
expect(state.empty).toBe(true);
|
|
expect(state.loading).toBe(false);
|
|
expect(state.article).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('3. 分享文案字段齐全', () => {
|
|
it('3.1 分享字段包含 CTA、Toast、链接与 OpenGraph title/description/image', async () => {
|
|
const {
|
|
createArticleDetailState,
|
|
applyArticleDetailOptions,
|
|
loadArticleDetail,
|
|
getArticleShareFields,
|
|
} = loadUseArticleDetail();
|
|
|
|
const state = createArticleDetailState();
|
|
applyArticleDetailOptions(state, { cardId: 'card-101', blockId: '3' });
|
|
await loadArticleDetail(state);
|
|
|
|
const share = getArticleShareFields(state, 'https://omnicard.ai/h5/article');
|
|
const keys: Array<keyof ArticleShareFields> = [
|
|
'ctaLabel',
|
|
'copiedToast',
|
|
'shareUrl',
|
|
'title',
|
|
'description',
|
|
'image',
|
|
];
|
|
for (const key of keys) {
|
|
expect(typeof share[key]).toBe('string');
|
|
expect(share[key].length).toBeGreaterThan(0);
|
|
}
|
|
expect(share.ctaLabel).toBe('复制链接');
|
|
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');
|
|
});
|
|
|
|
it('3.2 无 linkUrl 时回退当前页 URL,复制写入剪贴板', async () => {
|
|
const article = buildArticleBlock();
|
|
article.linkUrl = '';
|
|
mockRequestResponse = {
|
|
statusCode: 200,
|
|
data: buildPortalPayload([article]),
|
|
};
|
|
|
|
const {
|
|
createArticleDetailState,
|
|
applyArticleDetailOptions,
|
|
loadArticleDetail,
|
|
resolveShareUrl,
|
|
getArticleShareFields,
|
|
copyArticleShareLink,
|
|
} = loadUseArticleDetail();
|
|
|
|
expect(resolveShareUrl('', 'https://omnicard.ai/h5/article-fallback')).toBe(
|
|
'https://omnicard.ai/h5/article-fallback'
|
|
);
|
|
expect(resolveShareUrl('https://omnicard.ai/p/article-3', 'https://fallback')).toBe(
|
|
'https://omnicard.ai/p/article-3'
|
|
);
|
|
|
|
const state = createArticleDetailState();
|
|
applyArticleDetailOptions(state, { cardId: 'card-101', blockId: '3' });
|
|
await loadArticleDetail(state);
|
|
|
|
const pageUrl = 'https://omnicard.ai/h5/article-fallback';
|
|
const share = getArticleShareFields(state, pageUrl);
|
|
expect(share.shareUrl).toBe(pageUrl);
|
|
expect(share.ctaLabel).toBe('复制链接');
|
|
expect(share.copiedToast.length).toBeGreaterThan(0);
|
|
|
|
const copied = copyArticleShareLink(state, pageUrl);
|
|
expect(copied).toBe(true);
|
|
expect(clipboardText).toBe(pageUrl);
|
|
expect(toastCalls.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|