339 行
11 KiB
TypeScript
339 行
11 KiB
TypeScript
/**
|
|
* OmniCard AI (CRMmp) - 移动微官网离线 LocalStorage 极速预加载与骨架屏单测套件 (TDD)
|
|
* 严格遵循 AGENTS.md 规范与高性能架构契约
|
|
*/
|
|
|
|
export interface MockBentoBlock {
|
|
id: number | string;
|
|
cardId: number | string;
|
|
type: string;
|
|
colSpan: number;
|
|
rowSpan: number;
|
|
sortOrder: number;
|
|
title?: string;
|
|
content?: string;
|
|
mediaUrl?: string;
|
|
}
|
|
|
|
export interface CardPortalDetail {
|
|
id: string;
|
|
slug?: string;
|
|
name: string;
|
|
title: string;
|
|
company: string;
|
|
timezone?: string;
|
|
status: string;
|
|
bentoBlocks: MockBentoBlock[];
|
|
}
|
|
|
|
interface MockUniStorage {
|
|
getStorageSync: (key: string) => string;
|
|
setStorageSync: (key: string, val: string) => void;
|
|
removeStorageSync: (key: string) => void;
|
|
showToast: (options: { title: string; icon?: string }) => void;
|
|
createInnerAudioContext: () => unknown;
|
|
request: (options: {
|
|
url: string;
|
|
method?: string;
|
|
data?: unknown;
|
|
header?: Record<string, string>;
|
|
success?: (res: { statusCode: number; data: unknown; header: Record<string, string> }) => void;
|
|
fail?: (err: Error) => void;
|
|
complete?: () => void;
|
|
}) => { abort: () => void };
|
|
}
|
|
|
|
describe('移动微官网离线缓存与极速预加载套件 (useCache.uts & usePortal.uts)', () => {
|
|
let storageMap: Map<string, string>;
|
|
let mockRequestResponse: {
|
|
statusCode?: number;
|
|
data?: unknown;
|
|
failError?: Error;
|
|
};
|
|
|
|
const sampleCardData: CardPortalDetail = {
|
|
id: 'card-dubai-888',
|
|
slug: 'alex-dubai',
|
|
name: 'Alex Vance',
|
|
title: 'Managing Director',
|
|
company: 'Nexus Tech Global Ltd',
|
|
timezone: 'Dubai UTC+4',
|
|
status: 'published',
|
|
bentoBlocks: [
|
|
{
|
|
id: 1,
|
|
cardId: 'card-dubai-888',
|
|
type: 'hero',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 0,
|
|
title: 'Hero Profile',
|
|
},
|
|
{
|
|
id: 2,
|
|
cardId: 'card-dubai-888',
|
|
type: 'case',
|
|
colSpan: 2,
|
|
rowSpan: 1,
|
|
sortOrder: 1,
|
|
title: 'Middle East Solar Project',
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
storageMap = new Map();
|
|
mockRequestResponse = {
|
|
statusCode: 200,
|
|
data: {
|
|
code: 1000,
|
|
message: 'success',
|
|
data: sampleCardData,
|
|
},
|
|
};
|
|
|
|
const mockUni: MockUniStorage = {
|
|
getStorageSync: (key: string): string => storageMap.get(key) || '',
|
|
setStorageSync: (key: string, val: string): void => {
|
|
storageMap.set(key, val);
|
|
},
|
|
removeStorageSync: (key: string): void => {
|
|
storageMap.delete(key);
|
|
},
|
|
showToast: jest.fn(),
|
|
createInnerAudioContext: () => ({
|
|
src: '',
|
|
play: jest.fn(),
|
|
stop: jest.fn(),
|
|
pause: jest.fn(),
|
|
destroy: jest.fn(),
|
|
onPlay: jest.fn(),
|
|
onEnded: jest.fn(),
|
|
onError: jest.fn(),
|
|
}),
|
|
request: (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() };
|
|
},
|
|
};
|
|
|
|
(global as unknown as { uni: MockUniStorage }).uni = mockUni;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (global as unknown as { uni?: MockUniStorage }).uni;
|
|
});
|
|
|
|
describe('1. Composable useCache 基础能力与时效验证', () => {
|
|
it('1.1 成功写入名片快照,并带版本号、时间戳与默认24h TTL', () => {
|
|
// @ts-ignore
|
|
const { setCachedPortal, getCachedPortal } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
const success = setCachedPortal('alex-dubai', sampleCardData);
|
|
expect(success).toBe(true);
|
|
|
|
const cached = getCachedPortal('alex-dubai');
|
|
expect(cached).not.toBeNull();
|
|
expect(cached?.slug).toBe('alex-dubai');
|
|
expect(cached?.version).toBe('1.0.0');
|
|
expect(cached?.data.name).toBe('Alex Vance');
|
|
expect(cached?.ttlMs).toBe(86400000); // 24小时
|
|
expect(typeof cached?.timestamp).toBe('number');
|
|
});
|
|
|
|
it('1.2 isCacheValid 正确校验有效与过期缓存', () => {
|
|
// @ts-ignore
|
|
const { isCacheValid } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
const now = Date.now();
|
|
const validCache = {
|
|
version: '1.0.0',
|
|
slug: 'test-slug',
|
|
timestamp: now - 3600000, // 1小时前
|
|
ttlMs: 86400000,
|
|
data: sampleCardData,
|
|
};
|
|
expect(isCacheValid(validCache)).toBe(true);
|
|
|
|
const expiredCache = {
|
|
version: '1.0.0',
|
|
slug: 'test-slug',
|
|
timestamp: now - 90000000, // 超过24小时
|
|
ttlMs: 86400000,
|
|
data: sampleCardData,
|
|
};
|
|
expect(isCacheValid(expiredCache)).toBe(false);
|
|
|
|
expect(isCacheValid(null)).toBe(false);
|
|
});
|
|
|
|
it('1.3 支持自定义 ttlMs 参数', () => {
|
|
// @ts-ignore
|
|
const { setCachedPortal, getCachedPortal, isCacheValid } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
const shortTtlMs = 1000;
|
|
setCachedPortal('fast-expire', sampleCardData, shortTtlMs);
|
|
|
|
const cached = getCachedPortal('fast-expire');
|
|
expect(cached?.ttlMs).toBe(1000);
|
|
expect(isCacheValid(cached)).toBe(true);
|
|
|
|
// 模拟时间流逝超过 1s
|
|
cached!.timestamp = Date.now() - 1500;
|
|
expect(isCacheValid(cached)).toBe(false);
|
|
});
|
|
|
|
it('1.4 本地损坏数据安全降级,返回 null 而不抛出异常', () => {
|
|
// @ts-ignore
|
|
const { getCachedPortal, setCachedPortal } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
// 模拟 JSON 损坏
|
|
storageMap.set('omni_portal_cache_corrupt-json', '{invalid_json');
|
|
expect(getCachedPortal('corrupt-json')).toBeNull();
|
|
|
|
// 模拟缺失必要字段
|
|
storageMap.set('omni_portal_cache_bad-structure', JSON.stringify({ wrong: 123 }));
|
|
expect(getCachedPortal('bad-structure')).toBeNull();
|
|
|
|
// 空 key 保护
|
|
expect(getCachedPortal('')).toBeNull();
|
|
expect(setCachedPortal('', sampleCardData)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('2. 微官网集成加载策略 (usePortal with Cache)', () => {
|
|
it('2.1 首次无缓存加载:开启骨架屏 loading=true,网络成功后呈现内容并持久化缓存', async () => {
|
|
// @ts-ignore
|
|
const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts');
|
|
// @ts-ignore
|
|
const { getCachedPortal } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
const state = createPortalState();
|
|
expect(state.loading).toBe(false);
|
|
expect(state.card).toBeNull();
|
|
|
|
// 发起首次加载
|
|
const loadPromise = loadCardPortal(state, 'alex-dubai', 'qr');
|
|
// 无缓存,应进入骨架屏 loading 状态
|
|
expect(state.loading).toBe(true);
|
|
|
|
const result = await loadPromise;
|
|
expect(state.loading).toBe(false);
|
|
expect(result).not.toBeNull();
|
|
expect(state.card?.id).toBe('card-dubai-888');
|
|
|
|
// 检查此时本地已被静默写入缓存
|
|
const cached = getCachedPortal('alex-dubai');
|
|
expect(cached).not.toBeNull();
|
|
expect(cached?.data.id).toBe('card-dubai-888');
|
|
});
|
|
|
|
it('2.2 命中有效缓存:首屏 0ms 呈现 (loading=false),并静默在后台更新最新数据', async () => {
|
|
// @ts-ignore
|
|
const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts');
|
|
// @ts-ignore
|
|
const { setCachedPortal } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
const initialCachedCard: CardPortalDetail = {
|
|
...sampleCardData,
|
|
title: 'Old Title from Cache',
|
|
};
|
|
// 先预埋有效缓存
|
|
setCachedPortal('alex-dubai', initialCachedCard);
|
|
|
|
// 准备网络端将返回更新后的数据
|
|
const updatedNetworkCard: CardPortalDetail = {
|
|
...sampleCardData,
|
|
title: 'Newly Updated Title from Network',
|
|
};
|
|
mockRequestResponse.data = {
|
|
code: 1000,
|
|
message: 'success',
|
|
data: updatedNetworkCard,
|
|
};
|
|
|
|
const state = createPortalState();
|
|
|
|
// 执行 loadCardPortal
|
|
const loadPromise = loadCardPortal(state, 'alex-dubai', 'link');
|
|
|
|
// 核心验证:首屏 0ms 秒开,loading 为 false,且立即获得缓存的名片数据
|
|
expect(state.loading).toBe(false);
|
|
expect(state.card).not.toBeNull();
|
|
expect(state.card?.title).toBe('Old Title from Cache');
|
|
|
|
// 等待后台静默网络请求更新完成
|
|
const finalResult = await loadPromise;
|
|
expect(state.loading).toBe(false);
|
|
expect(finalResult?.title).toBe('Newly Updated Title from Network');
|
|
expect(state.card?.title).toBe('Newly Updated Title from Network');
|
|
});
|
|
|
|
it('2.3 缓存过期时:触发正常 loading 骨架屏并在请求完成后刷新缓存', async () => {
|
|
// @ts-ignore
|
|
const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts');
|
|
// @ts-ignore
|
|
const { setCachedPortal, getCachedPortal } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
// 预埋一个已过期的缓存
|
|
setCachedPortal('alex-dubai', sampleCardData, 100);
|
|
const cached = getCachedPortal('alex-dubai');
|
|
cached!.timestamp = Date.now() - 500000;
|
|
storageMap.set('omni_portal_cache_alex-dubai', JSON.stringify(cached));
|
|
|
|
const state = createPortalState();
|
|
const loadPromise = loadCardPortal(state, 'alex-dubai', 'link');
|
|
|
|
// 因为缓存已过期,不能直接作为有效首屏展示,应走骨架屏加载
|
|
expect(state.loading).toBe(true);
|
|
|
|
const result = await loadPromise;
|
|
expect(state.loading).toBe(false);
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it('2.4 网络异常或断网时:若存在有效缓存,平滑降级展示缓存数据而不报错误屏', async () => {
|
|
// @ts-ignore
|
|
const { createPortalState, loadCardPortal } = require('../../../mpuniapp/pages/portal/usePortal.uts');
|
|
// @ts-ignore
|
|
const { setCachedPortal } = require('../../../mpuniapp/pages/portal/useCache.uts');
|
|
|
|
// 预埋有效缓存
|
|
setCachedPortal('alex-dubai', sampleCardData);
|
|
|
|
// 模拟断网/服务故障
|
|
mockRequestResponse = {
|
|
statusCode: 500,
|
|
failError: new Error('Network Connection Lost'),
|
|
};
|
|
|
|
const state = createPortalState();
|
|
const loadPromise = loadCardPortal(state, 'alex-dubai', 'link');
|
|
|
|
// 首屏直接拿到缓存
|
|
expect(state.loading).toBe(false);
|
|
expect(state.card?.name).toBe('Alex Vance');
|
|
|
|
// 等待请求失败
|
|
const result = await loadPromise;
|
|
expect(state.loading).toBe(false);
|
|
// 数据保持不变,不被清空为 null,也不展示阻断性 errorMessage
|
|
expect(state.card).not.toBeNull();
|
|
expect(state.card?.name).toBe('Alex Vance');
|
|
expect(state.errorMessage).toBe('');
|
|
expect(result).not.toBeNull();
|
|
});
|
|
});
|
|
});
|