/** * 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; }; 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; }; type UniToastOption = { title: string; icon?: string; }; type UniRequestSuccessRes = { statusCode: number; data: unknown; header: Record; }; type UniRequestOption = { url: string; method?: string; data?: Record; 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 { 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 { return { list, pagination: { page: 1, size: 20, total: list.length, ...pagination, }, }; } describe('OmniCard AI 名片主移动实时雷达业务逻辑测试 (useRadar.uts)', () => { let storageMap: Map; 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 }).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 }).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(' { const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8'); const logicSource = fs.readFileSync(RADAR_UTS, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); const template = pageSource.split(']*v-for=/.test(pageSource)).toBe(false); expect(/[\u4e00-\u9fff]/.test(template)).toBe(false); expect(pageSource.includes('innerAudioContext') || pageSource.includes('autoplay')).toBe(false); }); });