/** * OmniCard AI (CRMmp) - mpuniapp 微官网 Bento 首页与 Hero 身份区测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD 3.1、5.1 设计编写 */ describe('OmniCard AI 用户端微官网 Bento 首页与 Hero 业务逻辑测试 (usePortal.uts)', () => { let storageMap: Map; let toastCalls: Array<{ title: string; icon?: string }>; let makePhoneCallParam: { phoneNumber: string } | null; let clipboardText: string; let audioContextMock: any; let mockRequestResponse: { statusCode?: number; data?: any; failError?: any; }; beforeEach(() => { storageMap = new Map(); toastCalls = []; makePhoneCallParam = null; clipboardText = ''; audioContextMock = { src: '', play: jest.fn(), pause: jest.fn(), stop: jest.fn(), destroy: jest.fn(), onPlay: jest.fn((cb: () => void) => { audioContextMock._onPlayCb = cb; }), onEnded: jest.fn((cb: () => void) => { audioContextMock._onEndedCb = cb; }), onError: jest.fn((cb: (err: any) => void) => { audioContextMock._onErrorCb = cb; }), _onPlayCb: null, _onEndedCb: null, _onErrorCb: null, }; mockRequestResponse = { statusCode: 200, data: { code: 1000, message: 'success', data: { id: 'card-101', name: 'Alex Vance', title: 'Managing Director & Global Partner', company: 'Nexus Tech Global Ltd', timezone: 'Dubai UTC+4', audioPronunciationUrl: 'https://cdn.omnicard.ai/audio/alex.mp3', phone: '+971501234567', email: 'alex.vance@nexustech.global', whatsapp: '+971501234567', linkedin: 'https://linkedin.com/in/alexvance', 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.', }, { 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: 'social', colSpan: 1, rowSpan: 1, sortOrder: 3, title: 'Global Social Matrix', }, ], }, }, }; // 构造全局 uni 运行时 Mock 环境 (global as any).uni = { getStorageSync: (key: string) => storageMap.get(key) || '', setStorageSync: (key: string, val: string) => storageMap.set(key, val), removeStorageSync: (key: string) => storageMap.delete(key), showToast: (options: { title: string; icon?: string }) => { toastCalls.push(options); }, reLaunch: jest.fn(), makePhoneCall: (options: { phoneNumber: string }) => { makePhoneCallParam = options; }, setClipboardData: (options: { data: string; success?: () => void }) => { clipboardText = options.data; options.success?.(); }, createInnerAudioContext: () => audioContextMock, request: (options: any) => { 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 any).uni; }); describe('1. 访客标识识别与生成 (getOrCreateVisitorId)', () => { it('1.1 若本地无 visitorId,则自动生成持久化并返回', () => { // @ts-ignore const { getOrCreateVisitorId } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const id = getOrCreateVisitorId(); expect(id).toBeDefined(); expect(id.startsWith('vis_')).toBe(true); expect(storageMap.get('omni_visitor_id')).toBe(id); }); it('1.2 若本地已有 visitorId,则直接复用', () => { // @ts-ignore const { getOrCreateVisitorId } = require('../../../mpuniapp/pages/portal/usePortal.uts'); storageMap.set('omni_visitor_id', 'vis_existing_7788'); const id = getOrCreateVisitorId(); expect(id).toBe('vis_existing_7788'); }); }); describe('2. 微官网数据拉取与雷达初次触碰上报 (loadCardPortal)', () => { it('2.1 成功拉取名片聚合详情并自动上报首次雷达轨迹', async () => { // @ts-ignore const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const state = createPortalState(); expect(state.loading).toBe(false); expect(state.card).toBeNull(); const loadPromise = loadCardPortal(state, 'card-101', 'qr'); expect(state.loading).toBe(true); const result = await loadPromise; expect(state.loading).toBe(false); expect(state.errorMessage).toBe(''); expect(result).not.toBeNull(); expect(state.card?.id).toBe('card-101'); expect(state.card?.name).toBe('Alex Vance'); expect(state.touchType).toBe('qr'); }); it('2.2 网络异常或404时正确设置错误提示并结束 loading', async () => { // @ts-ignore const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts'); mockRequestResponse = { statusCode: 404, data: { code: 404, message: '名片不存在或未公开', }, }; const state = createPortalState(); const result = await loadCardPortal(state, 'non-existing-id'); expect(state.loading).toBe(false); expect(result).toBeNull(); expect(state.card).toBeNull(); expect(state.errorMessage).toContain('名片不存在'); }); }); describe('3. 访客雷达停留时长与深度上报 (reportPortalStay)', () => { it('3.1 页面停留后上报有效停留毫秒数与滚动深度', async () => { // @ts-ignore const { createPortalState, loadCardPortal, reportPortalStay } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const state = createPortalState(); await loadCardPortal(state, 'card-101', 'link'); // 模拟停留 500ms state.enterTime = Date.now() - 500; const track = await reportPortalStay(state, 85); expect(track).toBeDefined(); }); it('3.2 若名片尚未成功加载,上报应安全返回 null 而不抛出异常', async () => { // @ts-ignore const { createPortalState, reportPortalStay } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const state = createPortalState(); const track = await reportPortalStay(state, 0); expect(track).toBeNull(); }); }); describe('4. 名字发音试听控制 (toggleAudioPronunciation)', () => { it('4.1 成功创建音频上下文并播放,更新 isPlayingAudio 状态', () => { // @ts-ignore const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const state = createPortalState(); const audioUrl = 'https://cdn.omnicard.ai/audio/alex.mp3'; toggleAudioPronunciation(state, audioUrl); expect(audioContextMock.src).toBe(audioUrl); expect(audioContextMock.play).toHaveBeenCalled(); expect(state.isPlayingAudio).toBe(true); // 再次点击应触发暂停/停止 toggleAudioPronunciation(state, audioUrl); expect(audioContextMock.stop).toHaveBeenCalled(); expect(state.isPlayingAudio).toBe(false); }); it('4.2 音频播放完毕或异常回调时自动复位 isPlayingAudio 状态', () => { // @ts-ignore const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const state = createPortalState(); toggleAudioPronunciation(state, 'https://cdn.omnicard.ai/audio/alex.mp3'); expect(state.isPlayingAudio).toBe(true); // 触发 onEnded 回调 audioContextMock._onEndedCb?.(); expect(state.isPlayingAudio).toBe(false); }); it('4.3 若未提供 audioUrl,安全退出不创建上下文', () => { // @ts-ignore const { createPortalState, toggleAudioPronunciation } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const state = createPortalState(); toggleAudioPronunciation(state, ''); expect(audioContextMock.play).not.toHaveBeenCalled(); expect(state.isPlayingAudio).toBe(false); }); }); describe('5. 快捷动作条触发与呼出交互 (triggerQuickAction)', () => { it('5.1 点击电话直接调用原生 makePhoneCall', () => { // @ts-ignore const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const ok = triggerQuickAction('phone', '+971501234567'); expect(ok).toBe(true); expect(makePhoneCallParam?.phoneNumber).toBe('+971501234567'); }); it('5.2 点击 WhatsApp 或 LinkedIn 将安全处理并提示', () => { // @ts-ignore const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const okWa = triggerQuickAction('whatsapp', '+971501234567'); expect(okWa).toBe(true); const okIn = triggerQuickAction('linkedin', 'https://linkedin.com/in/alexvance'); expect(okIn).toBe(true); }); it('5.3 字段为空时给出清晰 Toast 提示而不报错', () => { // @ts-ignore const { triggerQuickAction } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const ok = triggerQuickAction('phone', ''); expect(ok).toBe(false); expect(toastCalls.length).toBeGreaterThan(0); expect(toastCalls[0].title).toContain('未设置'); }); }); describe('6. Bento Grid 跨列与数据过滤规则 (getBentoColSpanClass & filterVisibleBentoBlocks)', () => { it('6.1 colSpan 计算:1 为半宽,2 为全宽', () => { // @ts-ignore const { getBentoColSpanClass } = require('../../../mpuniapp/pages/portal/usePortal.uts'); expect(getBentoColSpanClass(1)).toBe('bento-col-1'); expect(getBentoColSpanClass(2)).toBe('bento-col-2'); // 默认/兜底 expect(getBentoColSpanClass(0 as any)).toBe('bento-col-1'); }); it('6.2 过滤 Hero 类型的块,并按 sortOrder 升序排列其余 Bento 块', () => { // @ts-ignore const { filterVisibleBentoBlocks } = require('../../../mpuniapp/pages/portal/usePortal.uts'); const rawBlocks = [ { id: 1, type: 'hero', sortOrder: 0, title: 'Hero' }, { id: 4, type: 'social', sortOrder: 3, title: 'Social' }, { id: 2, type: 'case', sortOrder: 1, title: 'Case' }, { id: 3, type: 'article', sortOrder: 2, title: 'Article' }, ]; const visible = filterVisibleBentoBlocks(rawBlocks as any); expect(visible.length).toBe(3); expect(visible.map((b: any) => b.type)).toEqual(['case', 'article', 'social']); }); }); });