/** * 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']); }); }); describe('7. 顶栏返回入口与智能导航 (executePortalGoBack)', () => { it('7.1 当页面栈深度大于 1 时调用 uni.navigateBack', () => { // @ts-ignore const { executePortalGoBack } = require('../../../mpuniapp/pages/portal/usePortal.uts'); let navigatedBack = false; let switchedTabUrl = ''; (global as any).getCurrentPages = () => [{}, {}]; (global as any).uni = { ...(global as any).uni, navigateBack: () => { navigatedBack = true; }, switchTab: (p: { url: string }) => { switchedTabUrl = p.url; }, }; const result = executePortalGoBack(true); expect(navigatedBack).toBe(true); expect(result.action).toBe('navigateBack'); expect(switchedTabUrl).toBe(''); }); it('7.2 当页面栈为 1 且为名片主 (isOwner) 时安全跳转至名片管理首页', () => { // @ts-ignore const { executePortalGoBack } = require('../../../mpuniapp/pages/portal/usePortal.uts'); let navigatedBack = false; let switchedTabUrl = ''; (global as any).getCurrentPages = () => [{}]; (global as any).uni = { ...(global as any).uni, navigateBack: () => { navigatedBack = true; }, switchTab: (p: { url: string }) => { switchedTabUrl = p.url; }, }; const result = executePortalGoBack(true); expect(navigatedBack).toBe(false); expect(result.action).toBe('switchTab'); expect(result.targetUrl).toBe('/pages/owner/card/index'); expect(switchedTabUrl).toBe('/pages/owner/card/index'); }); it('7.3 当页面栈为 1 且为访客时安全跳转至大厅', () => { // @ts-ignore const { executePortalGoBack } = require('../../../mpuniapp/pages/portal/usePortal.uts'); let switchedTabUrl = ''; (global as any).getCurrentPages = () => [{}]; (global as any).uni = { ...(global as any).uni, switchTab: (p: { url: string }) => { switchedTabUrl = p.url; }, }; const result = executePortalGoBack(false); expect(result.targetUrl).toBe('/pages/owner/hall/index'); expect(switchedTabUrl).toBe('/pages/owner/hall/index'); }); }); }); 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('/ ); 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(); 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(); 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(' { 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; }); });