/** * OmniCard AI (CRMmp) - mpuniapp 案例作品集详情测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD pages/portal/case-detail 设计编写 */ type CaseKpi = { label: string; value: string; }; type CaseDetailState = { cardId: string; blockId: string; loading: boolean; empty: boolean; errorMessage: string; title: string; content: string; images: string[]; kpis: CaseKpi[]; pdfUrl: string; youtubeUrl: string; videoUrl?: string; blockType?: string; }; type CaseDetailPageOptions = { cardId?: string; blockId?: string; id?: string; }; type MockBentoBlock = { id: number | string; cardId: string; type: string; colSpan: number; rowSpan: number; sortOrder: number; title?: string; content?: string; mediaUrl?: string; linkUrl?: string; metaJson?: | string | { images?: string[]; kpis?: CaseKpi[]; metrics?: CaseKpi[]; pdfUrl?: string; youtubeUrl?: string; client?: string; industry?: string; region?: string; challenge?: string; solution?: string; [key: string]: unknown; }; }; type MockPortalCard = { id: string; name: string; title: string; company: string; status: string; bentoBlocks: MockBentoBlock[]; }; type UniRequestSuccessRes = { statusCode: number; data: unknown; header: Record; }; type UniRequestOption = { url: string; method?: string; data?: Record; success?: (res: UniRequestSuccessRes) => void; fail?: (err: { errMsg: string }) => void; complete?: () => void; }; type UseCaseDetailModule = { createCaseDetailState: () => CaseDetailState; applyCaseDetailOptions: ( state: CaseDetailState, options: CaseDetailPageOptions | null ) => boolean; pickCaseBlock: ( card: MockPortalCard | null, blockId: string ) => MockBentoBlock | null; applyPortalCase: (state: CaseDetailState, card: MockPortalCard | null) => boolean; loadCaseDetail: (state: CaseDetailState) => Promise; buildExchangePath: (cardId: string) => string; openCaseExchange: (cardId: string) => void; goCaseBack: () => void; openCaseExternalLink: (url: string) => boolean; resolveCaseDetailText: (key: string) => string; }; function loadUseCaseDetail(): UseCaseDetailModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../mpuniapp/pages/portal/useCaseDetail.uts') as UseCaseDetailModule; } function buildMockPortal(): MockPortalCard { return { id: 'card-101', name: 'Alex Vance', title: 'Managing Director & Global Partner', company: 'Nexus Tech Global Ltd', status: 'published', bentoBlocks: [ { 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.', mediaUrl: 'https://cdn.omnicard.ai/case/cover.jpg', metaJson: { images: [ 'https://cdn.omnicard.ai/case/1.jpg', 'https://cdn.omnicard.ai/case/2.jpg', ], kpis: [{ label: 'ROI', value: '32%' }], pdfUrl: 'https://cdn.omnicard.ai/case/brief.pdf', youtubeUrl: 'https://www.youtube.com/watch?v=abc123', }, }, { id: 3, cardId: 'card-101', type: 'article', colSpan: 1, rowSpan: 1, 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' }], }, }, ], }; } describe('OmniCard AI 用户端案例详情业务逻辑测试 (useCaseDetail.uts)', () => { let lastRequestOptions: UniRequestOption | null; let navigateToUrl: string; let navigateBackCount: number; let openUrlCalls: string[]; let mockRequestResponse: { statusCode?: number; data?: { code?: number; message?: string; data?: MockPortalCard | null; }; failError?: { errMsg: string }; }; let originalWindow: (Window & typeof globalThis) | undefined; beforeEach(() => { lastRequestOptions = null; navigateToUrl = ''; navigateBackCount = 0; openUrlCalls = []; mockRequestResponse = { statusCode: 200, data: { code: 1000, message: 'success', data: buildMockPortal(), }, }; 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 }).uni = { getStorageSync: () => '', setStorageSync: () => undefined, showToast: () => undefined, setClipboardData: () => undefined, navigateTo: (options: { url: string }) => { navigateToUrl = options.url; }, navigateBack: () => { navigateBackCount += 1; }, request: (options: UniRequestOption) => { 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: () => undefined, }; }, }; }); 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 块', () => { it('1.1 能从 mock portal 数据选出 id 匹配且 type 为 case 的块', () => { const { pickCaseBlock } = loadUseCaseDetail(); const card = buildMockPortal(); const block = pickCaseBlock(card, '2'); expect(block).not.toBeNull(); expect(block?.type).toBe('case'); expect(block?.title).toBe('Middle East Energy Hub'); expect(block?.content).toContain('renewable'); }); it('1.2 applyPortalCase 写入标题、正文、多图、KPI 与外链', () => { const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } = loadUseCaseDetail(); const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '2' }); const found = applyPortalCase(state, buildMockPortal()); expect(found).toBe(true); expect(state.empty).toBe(false); expect(state.title).toBe('Middle East Energy Hub'); expect(state.content).toBe( 'Built scalable renewable energy microgrid in UAE.' ); expect(state.images).toEqual([ 'https://cdn.omnicard.ai/case/1.jpg', 'https://cdn.omnicard.ai/case/2.jpg', ]); expect(state.kpis).toEqual([{ label: 'ROI', value: '32%' }]); expect(state.pdfUrl).toBe('https://cdn.omnicard.ai/case/brief.pdf'); expect(state.youtubeUrl).toBe('https://www.youtube.com/watch?v=abc123'); }); it('1.3 无 images 时回退 mediaUrl 单图,metrics 可作为 KPI', () => { const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } = loadUseCaseDetail(); const card = buildMockPortal(); card.bentoBlocks[1].mediaUrl = 'https://cdn.omnicard.ai/case/cover.jpg'; card.bentoBlocks[1].metaJson = { metrics: [{ label: 'Capacity', value: '120MW' }], }; const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '2' }); applyPortalCase(state, card); expect(state.images).toEqual(['https://cdn.omnicard.ai/case/cover.jpg']); expect(state.kpis).toEqual([{ label: 'Capacity', value: '120MW' }]); }); it('1.4 metaJson 为字符串且 metrics 使用 name 时仍能解析', () => { const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } = loadUseCaseDetail(); const card = buildMockPortal(); card.bentoBlocks[1].metaJson = JSON.stringify({ metrics: [{ name: 'Uplift', value: 18 }], }); const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '2' }); applyPortalCase(state, card); expect(state.kpis).toEqual([{ label: 'Uplift', value: '18' }]); }); }); describe('2. 缺 block 返空', () => { it('2.1 找不到 blockId 时 pickCaseBlock 返回 null', () => { const { pickCaseBlock } = loadUseCaseDetail(); const block = pickCaseBlock(buildMockPortal(), '999'); expect(block).toBeNull(); }); it('2.2 id 匹配但 type 不是 case 时同样返空', () => { const { pickCaseBlock } = loadUseCaseDetail(); const article = pickCaseBlock(buildMockPortal(), '3'); const hero = pickCaseBlock(buildMockPortal(), '1'); expect(article).toBeNull(); expect(hero).toBeNull(); }); it('2.3 applyPortalCase 缺块时 empty=true 且展示字段清空', () => { const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } = loadUseCaseDetail(); const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '999' }); const found = applyPortalCase(state, buildMockPortal()); expect(found).toBe(false); expect(state.empty).toBe(true); expect(state.loading).toBe(false); expect(state.title).toBe(''); expect(state.content).toBe(''); expect(state.images).toEqual([]); expect(state.kpis).toEqual([]); }); }); describe('3. onLoad 拉取门户详情', () => { it('3.1 loadCaseDetail 通过 fetchPortalCard 选出 case 块', async () => { const { createCaseDetailState, applyCaseDetailOptions, loadCaseDetail } = loadUseCaseDetail(); const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '2' }); const found = await loadCaseDetail(state); expect(found).toBe(true); expect(lastRequestOptions).not.toBeNull(); expect(lastRequestOptions?.url).toContain('/open/card/portal/detail'); expect(state.empty).toBe(false); expect(state.title).toBe('Middle East Energy Hub'); }); it('3.2 缺 block 时 loadCaseDetail 返空', async () => { const { createCaseDetailState, applyCaseDetailOptions, loadCaseDetail } = loadUseCaseDetail(); const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '888' }); const found = await loadCaseDetail(state); expect(found).toBe(false); expect(state.empty).toBe(true); expect(state.title).toBe(''); }); it('3.3 缺少 cardId 或 blockId 时不请求并返空', async () => { const { createCaseDetailState, applyCaseDetailOptions, loadCaseDetail } = loadUseCaseDetail(); const state = createCaseDetailState(); applyCaseDetailOptions(state, {}); const found = await loadCaseDetail(state); expect(found).toBe(false); expect(state.empty).toBe(true); expect(lastRequestOptions).toBeNull(); }); }); describe('4. 咨询互换 CTA', () => { it('4.1 主 CTA 跳转 /pages/portal/exchange?cardId=', () => { const { buildExchangePath, openCaseExchange } = loadUseCaseDetail(); expect(buildExchangePath('card-101')).toBe( '/pages/portal/exchange?cardId=card-101' ); openCaseExchange('card-101'); expect(navigateToUrl).toBe('/pages/portal/exchange?cardId=card-101'); }); it('4.2 空状态返回调用 navigateBack', () => { const { goCaseBack } = loadUseCaseDetail(); goCaseBack(); expect(navigateBackCount).toBe(1); }); }); describe('5. Bento Grid 模块化结构化数据与国际化解析', () => { it('5.1 支持从 metaJson 解析 client, industry, region, challenge, solution', () => { const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } = loadUseCaseDetail(); const card = buildMockPortal(); card.bentoBlocks[1].metaJson = { client: 'Global Logistics Corp', industry: 'Supply Chain IoT', region: 'Middle East & North Africa', challenge: 'High latency in cross-border tracking and data silos.', solution: 'Deployed dual-frequency NFC sensors and cloud-native mesh.', kpis: [{ label: 'On-time Delivery', value: '99.8%' }], }; const state = createCaseDetailState(); applyCaseDetailOptions(state, { cardId: 'card-101', blockId: '2' }); applyPortalCase(state, card); expect((state as any).client).toBe('Global Logistics Corp'); expect((state as any).industry).toBe('Supply Chain IoT'); expect((state as any).region).toBe('Middle East & North Africa'); expect((state as any).challenge).toBe( 'High latency in cross-border tracking and data silos.' ); expect((state as any).solution).toBe( 'Deployed dual-frequency NFC sensors and cloud-native mesh.' ); expect(state.kpis).toEqual([{ label: 'On-time Delivery', value: '99.8%' }]); }); it('5.2 resolveCaseDetailText 外链行动作为打开语义(中英,非裸复制)', () => { const { resolveCaseDetailText } = loadUseCaseDetail(); expect(resolveCaseDetailText('badge')).toBeDefined(); expect(resolveCaseDetailText('overview')).toBeDefined(); expect(resolveCaseDetailText('challenge')).toBeDefined(); expect(resolveCaseDetailText('solution')).toBeDefined(); expect(resolveCaseDetailText('kpis')).toBeDefined(); expect(resolveCaseDetailText('resources')).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 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 }).uni = { ...(global as { uni?: Record }).uni, setClipboardData: () => { clipboardCalls += 1; }, }; const { openCaseExternalLink } = loadUseCaseDetail(); expect(openCaseExternalLink('')).toBe(false); expect(openCaseExternalLink(' ')).toBe(false); expect(openUrlCalls).toEqual([]); expect(clipboardCalls).toBe(0); }); it('5.5 缺 pdf/youtube 时 applyPortalCase 外链字段为空(页面应隐藏行)', () => { const { createCaseDetailState, applyCaseDetailOptions, applyPortalCase } = loadUseCaseDetail(); const card = buildMockPortal(); card.bentoBlocks[1].metaJson = { kpis: [{ label: 'ROI', value: '32%' }], }; 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'); }); }); });