- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
342 行
11 KiB
TypeScript
342 行
11 KiB
TypeScript
/**
|
||
* OmniCard AI (CRMmp) - 名片主移动实时雷达列表测试套件 (TDD Red -> Green)
|
||
*
|
||
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/radar 设计编写
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
const RADAR_PAGE = path.resolve(__dirname, '../../../mpuniapp/pages/owner/radar.uvue');
|
||
const RADAR_UTS = path.resolve(__dirname, '../../../mpuniapp/pages/owner/useRadar.uts');
|
||
|
||
type TouchType = 'nfc' | 'qr' | 'link';
|
||
|
||
type RadarTrackFixture = {
|
||
id: string | number;
|
||
tenantId?: string | number;
|
||
cardId: string | number;
|
||
visitorId: string;
|
||
ip?: string;
|
||
country?: string;
|
||
city?: string;
|
||
touchType: TouchType;
|
||
stayDurationMs: number;
|
||
scrollDepth: number;
|
||
viewedBlocks: string[];
|
||
createdAt?: string | Date;
|
||
};
|
||
|
||
type OwnerPageQueryFixture = {
|
||
page: number;
|
||
size: number;
|
||
cardId?: string | number;
|
||
};
|
||
|
||
type OwnerPageDataFixture = {
|
||
list: RadarTrackFixture[];
|
||
pagination: {
|
||
page: number;
|
||
size: number;
|
||
total: number;
|
||
};
|
||
};
|
||
|
||
type LoadTracksInput = {
|
||
cardId?: string | number | null;
|
||
page?: number;
|
||
size?: number;
|
||
};
|
||
|
||
type RadarFetchApi = {
|
||
fetchOwnerRadarPage: (query: OwnerPageQueryFixture) => Promise<OwnerPageDataFixture>;
|
||
};
|
||
|
||
type LoadTracksResult = {
|
||
list: RadarTrackFixture[];
|
||
empty: boolean;
|
||
errorMessage: string;
|
||
page: number;
|
||
size: number;
|
||
total: number;
|
||
};
|
||
|
||
type UseRadarModule = {
|
||
formatStay: (ms: number | null | undefined) => string;
|
||
formatDepth: (depth: number | null | undefined) => string;
|
||
loadTracks: (input: LoadTracksInput, api?: RadarFetchApi) => Promise<LoadTracksResult>;
|
||
};
|
||
|
||
type UniToastOption = {
|
||
title: string;
|
||
icon?: string;
|
||
};
|
||
|
||
type UniRequestSuccessRes = {
|
||
statusCode: number;
|
||
data: unknown;
|
||
header: Record<string, string>;
|
||
};
|
||
|
||
type UniRequestOption = {
|
||
url: string;
|
||
method?: string;
|
||
data?: Record<string, unknown>;
|
||
success?: (res: UniRequestSuccessRes) => void;
|
||
fail?: (err: { errMsg: string }) => void;
|
||
complete?: () => void;
|
||
};
|
||
|
||
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
|
||
|
||
function buildTrack(overrides?: Partial<RadarTrackFixture>): RadarTrackFixture {
|
||
return {
|
||
id: 81,
|
||
tenantId: 8,
|
||
cardId: 12,
|
||
visitorId: 'vis_ae_1201',
|
||
country: 'United Arab Emirates',
|
||
city: 'Dubai',
|
||
touchType: 'nfc',
|
||
stayDurationMs: 45000,
|
||
scrollDepth: 72,
|
||
viewedBlocks: ['hero', 'case'],
|
||
createdAt: '2026-09-20T10:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function buildPageData(
|
||
list: RadarTrackFixture[],
|
||
pagination?: Partial<OwnerPageDataFixture['pagination']>
|
||
): OwnerPageDataFixture {
|
||
return {
|
||
list,
|
||
pagination: {
|
||
page: 1,
|
||
size: 20,
|
||
total: list.length,
|
||
...pagination,
|
||
},
|
||
};
|
||
}
|
||
|
||
describe('OmniCard AI 名片主移动实时雷达业务逻辑测试 (useRadar.uts)', () => {
|
||
let storageMap: Map<string, string>;
|
||
let toastCalls: UniToastOption[];
|
||
let lastRequestOptions: UniRequestOption | null;
|
||
let requestCallCount: number;
|
||
let mockRequestResponse: {
|
||
statusCode?: number;
|
||
data?: {
|
||
code?: number;
|
||
message?: string;
|
||
data?: unknown;
|
||
};
|
||
failError?: { errMsg: string };
|
||
};
|
||
|
||
beforeEach(() => {
|
||
storageMap = new Map();
|
||
toastCalls = [];
|
||
lastRequestOptions = null;
|
||
requestCallCount = 0;
|
||
mockRequestResponse = {
|
||
statusCode: 200,
|
||
data: {
|
||
code: 1000,
|
||
message: 'success',
|
||
data: buildPageData([buildTrack()]),
|
||
},
|
||
};
|
||
jest.resetModules();
|
||
|
||
(global as { uni?: Record<string, unknown> }).uni = {
|
||
getStorageSync: (key: string) => storageMap.get(key) || '',
|
||
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
|
||
removeStorageSync: (key: string) => storageMap.delete(key),
|
||
showToast: (options: UniToastOption) => {
|
||
toastCalls.push(options);
|
||
},
|
||
reLaunch: jest.fn(),
|
||
getLocale: () => 'zh-CN',
|
||
request: (options: UniRequestOption) => {
|
||
requestCallCount += 1;
|
||
lastRequestOptions = options;
|
||
setTimeout(() => {
|
||
if (mockRequestResponse.failError) {
|
||
options.fail?.(mockRequestResponse.failError);
|
||
} else {
|
||
options.success?.({
|
||
statusCode:
|
||
mockRequestResponse.statusCode != null ? mockRequestResponse.statusCode : 200,
|
||
data: mockRequestResponse.data,
|
||
header: {},
|
||
});
|
||
}
|
||
options.complete?.();
|
||
}, 0);
|
||
return {
|
||
abort: jest.fn(),
|
||
};
|
||
},
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete (global as { uni?: Record<string, unknown> }).uni;
|
||
});
|
||
|
||
function loadUseRadar(): UseRadarModule {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
return require('../../../mpuniapp/pages/owner/useRadar.uts') as UseRadarModule;
|
||
}
|
||
|
||
describe('1. loadTracks 调用 fetchOwnerRadarPage', () => {
|
||
it('1.1 注入 fetchOwnerRadarPage 时按 cardId / page / size 请求并解析列表', async () => {
|
||
const { loadTracks } = loadUseRadar();
|
||
const fetchOwnerRadarPage = jest.fn(async (query: OwnerPageQueryFixture) => {
|
||
expect(query.cardId).toBe(12);
|
||
expect(query.page).toBe(2);
|
||
expect(query.size).toBe(10);
|
||
return buildPageData([buildTrack({ id: 99, cardId: 12 })], {
|
||
page: 2,
|
||
size: 10,
|
||
total: 1,
|
||
});
|
||
});
|
||
|
||
const result = await loadTracks(
|
||
{ cardId: 12, page: 2, size: 10 },
|
||
{ fetchOwnerRadarPage }
|
||
);
|
||
|
||
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
|
||
expect(result.list).toHaveLength(1);
|
||
expect(result.list[0].id).toBe(99);
|
||
expect(result.empty).toBe(false);
|
||
expect(result.errorMessage).toBe('');
|
||
expect(result.page).toBe(2);
|
||
expect(result.size).toBe(10);
|
||
expect(result.total).toBe(1);
|
||
expect(requestCallCount).toBe(0);
|
||
});
|
||
|
||
it('1.2 未注入时走默认 fetchOwnerRadarPage(POST /admin/crm/radar/page)', async () => {
|
||
const { loadTracks } = loadUseRadar();
|
||
|
||
const result = await loadTracks({ cardId: '12', page: 1, size: 20 });
|
||
|
||
expect(requestCallCount).toBe(1);
|
||
expect(lastRequestOptions).not.toBeNull();
|
||
expect(lastRequestOptions?.method).toBe('POST');
|
||
expect(lastRequestOptions?.url).toContain('/admin/crm/radar/page');
|
||
expect(lastRequestOptions?.data?.cardId).toBe(12);
|
||
expect(lastRequestOptions?.data?.page).toBe(1);
|
||
expect(lastRequestOptions?.data?.size).toBe(20);
|
||
expect(result.list).toHaveLength(1);
|
||
expect(result.empty).toBe(false);
|
||
expect(result.errorMessage).toBe('');
|
||
});
|
||
|
||
it('1.3 接口失败时写入错误文案且列表为空', async () => {
|
||
const { loadTracks } = loadUseRadar();
|
||
const fetchOwnerRadarPage = jest.fn(async () => {
|
||
throw new Error('雷达服务暂不可用');
|
||
});
|
||
|
||
const result = await loadTracks({ cardId: 12, page: 1, size: 20 }, { fetchOwnerRadarPage });
|
||
|
||
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
|
||
expect(result.list).toEqual([]);
|
||
expect(result.empty).toBe(true);
|
||
expect(result.errorMessage).toBe('雷达服务暂不可用');
|
||
});
|
||
});
|
||
|
||
describe('2. formatStay / 空列表', () => {
|
||
it('2.1 formatStay 按毫秒与秒格式化,非法值回退 0ms', () => {
|
||
const { formatStay } = loadUseRadar();
|
||
|
||
expect(formatStay(0)).toBe('0ms');
|
||
expect(formatStay(480)).toBe('480ms');
|
||
expect(formatStay(1500)).toBe('1.5s');
|
||
expect(formatStay(45000)).toBe('45.0s');
|
||
expect(formatStay(undefined)).toBe('0ms');
|
||
expect(formatStay(null)).toBe('0ms');
|
||
expect(formatStay(Number.NaN)).toBe('0ms');
|
||
});
|
||
|
||
it('2.2 formatDepth 以百分比展示,越界纠偏到 0~100', () => {
|
||
const { formatDepth } = loadUseRadar();
|
||
|
||
expect(formatDepth(0)).toBe('0%');
|
||
expect(formatDepth(72)).toBe('72%');
|
||
expect(formatDepth(100)).toBe('100%');
|
||
expect(formatDepth(150)).toBe('100%');
|
||
expect(formatDepth(-8)).toBe('0%');
|
||
expect(formatDepth(undefined)).toBe('0%');
|
||
expect(formatDepth(null)).toBe('0%');
|
||
});
|
||
|
||
it('2.3 空列表解析为空态且无错误文案', async () => {
|
||
const { loadTracks } = loadUseRadar();
|
||
const fetchOwnerRadarPage = jest.fn(async (query: OwnerPageQueryFixture) => {
|
||
expect(query.page).toBe(1);
|
||
expect(query.size).toBe(20);
|
||
return buildPageData([], { page: 1, size: 20, total: 0 });
|
||
});
|
||
|
||
const result = await loadTracks({ cardId: 12 }, { fetchOwnerRadarPage });
|
||
|
||
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
|
||
expect(result.list).toEqual([]);
|
||
expect(result.empty).toBe(true);
|
||
expect(result.errorMessage).toBe('');
|
||
expect(result.total).toBe(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('OmniCard AI 名片主移动实时雷达页契约 (radar.uvue)', () => {
|
||
it('页面存在且含 list-view', () => {
|
||
expect(fs.existsSync(RADAR_PAGE)).toBe(true);
|
||
expect(fs.existsSync(RADAR_UTS)).toBe(true);
|
||
|
||
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
|
||
|
||
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
|
||
expect(pageSource.includes('<list-view')).toBe(true);
|
||
expect(pageSource.includes('<list-item')).toBe(true);
|
||
expect(pageSource.includes('loadTracks') || logicSource.includes('loadTracks')).toBe(true);
|
||
expect(pageSource.includes('fetchOwnerRadarPage') || logicSource.includes('fetchOwnerRadarPage')).toBe(
|
||
true
|
||
);
|
||
expect(pageSource.includes('getOmniText')).toBe(true);
|
||
expect(pageSource.includes('radar.online_visitor')).toBe(true);
|
||
expect(pageSource.includes('radar.visitor_trace')).toBe(true);
|
||
expect(pageSource.includes('#0B5CFF')).toBe(true);
|
||
expect(pageSource.includes('#F7F8FA')).toBe(true);
|
||
expect(pageSource.includes('#101828')).toBe(true);
|
||
expect(pageSource.includes('#667085')).toBe(true);
|
||
expect(pageSource.includes('#E3E7ED')).toBe(true);
|
||
});
|
||
|
||
it('无 emoji、无 any,长列表不在 view 上 v-for,模板无硬编码中文主文案', () => {
|
||
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
|
||
const testSource = fs.readFileSync(__filename, 'utf8');
|
||
const template = pageSource.split('<script')[0];
|
||
|
||
expect(EMOJI_RE.test(pageSource)).toBe(false);
|
||
expect(EMOJI_RE.test(logicSource)).toBe(false);
|
||
expect(EMOJI_RE.test(testSource)).toBe(false);
|
||
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
|
||
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
|
||
expect(/<view[^>]*v-for=/.test(pageSource)).toBe(false);
|
||
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
|
||
expect(pageSource.includes('innerAudioContext') || pageSource.includes('autoplay')).toBe(false);
|
||
});
|
||
});
|