feat(mpuniapp): 实现移动微官网 Bento 首页骨架与 Hero 身份区 (Task 33)

这个提交包含在:
wikiboss
2026-09-20 17:36:33 +08:00
父节点 c07846a263
当前提交 2bc395c384
共有 4 个文件被更改,包括 1173 次插入 和 0 次删除

查看文件

@@ -0,0 +1,335 @@
/**
* 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<string, string>;
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']);
});
});
});