/** * 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; copyArticleShareLink: (state: ArticleDetailState, pageUrl?: string) => boolean; }; type UniToastOption = { title: string; icon?: string; }; type UniRequestSuccessRes = { statusCode: number; data: unknown; header: Record; }; 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: '

Cross-border trade is shifting toward trusted digital identity.

', 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; 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 }).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 }).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('

Insight body

')).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 = [ '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); }); }); });