/** * 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[]; viewedContents?: { key: string; title?: string }[]; visitorName?: string; visitorCompany?: string; avatarUrl?: string; createdAt?: string | Date; createTime?: 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 RadarStateFixture = { cardIdRaw: string; page: number; size: number; total: number; loading: boolean; empty: boolean; errorMessage: string; tracks: RadarTrackFixture[]; }; type UseRadarModule = { formatStay: (ms: number | null | undefined) => string; formatDepth: (depth: number | null | undefined) => string; formatLocation: (country?: string, city?: string) => string; isHighIntent: (stayDurationMs: number, scrollDepth: number) => boolean; loadTracks: (input: LoadTracksInput, api?: RadarFetchApi) => Promise; createRadarState: () => RadarStateFixture; refreshTracks: ( state: RadarStateFixture, api?: RadarFetchApi ) => Promise; resolveRadarText: (key: string) => string; }; /** 与后端热线索 / 页面既有阈值一致:停留 ≥ 3 分钟 */ const HIGH_INTENT_STAY_MS = 180000; /** tokens.uts light error */ const ERROR_TOKEN = '#F04438'; const ERROR_CSS_VAR = '--oc-error'; 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('3. isHighIntent 高意向阈值(停留 ≥ 3 分钟)', () => { it('3.1 停留达到 180000ms 判定为高意向,未达阈值且深度不足则为否', () => { const { isHighIntent } = loadUseRadar(); expect(isHighIntent(HIGH_INTENT_STAY_MS, 0)).toBe(true); expect(isHighIntent(HIGH_INTENT_STAY_MS - 1, 0)).toBe(false); expect(isHighIntent(0, 0.6)).toBe(true); expect(isHighIntent(0, 60)).toBe(true); expect(isHighIntent(29999, 0.2)).toBe(false); }); }); describe('4. formatLocation 空国家显示未知,禁止默认示例城市', () => { it('4.1 无国家或国家为空时走 i18n 未知,不回退迪拜/阿联酋', () => { const { formatLocation, resolveRadarText } = loadUseRadar(); const unknownText = resolveRadarText('unknown'); expect(unknownText).toBeTruthy(); expect(unknownText).toBe('未知'); expect(formatLocation(undefined, undefined)).toBe(unknownText); expect(formatLocation(null as unknown as string, null as unknown as string)).toBe( unknownText ); expect(formatLocation('', '')).toBe(unknownText); expect(formatLocation(' ', ' ')).toBe(unknownText); expect(formatLocation('', 'Paris')).toBe(`${unknownText} / Paris`); expect(formatLocation('China', 'Shanghai')).toBe('China / Shanghai'); expect(formatLocation('Japan', '')).toBe('Japan'); expect(formatLocation(undefined, undefined)).not.toContain('Dubai'); expect(formatLocation('', '')).not.toContain('United Arab Emirates'); expect(formatLocation('', '')).not.toContain('阿联酋'); }); it('4.2 接口失败时 refreshTracks 清空既有列表,不保留示例国家', async () => { const { createRadarState, refreshTracks } = loadUseRadar(); const state = createRadarState(); state.tracks = [ buildTrack({ id: 1, country: 'United Arab Emirates', city: 'Dubai' }), buildTrack({ id: 2, country: 'China', city: 'Shanghai' }), ]; state.empty = false; state.total = 2; const fetchOwnerRadarPage = jest.fn(async () => { throw new Error('雷达服务暂不可用'); }); const result = await refreshTracks(state, { fetchOwnerRadarPage }); expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1); expect(result.list).toEqual([]); expect(state.tracks).toEqual([]); expect(state.empty).toBe(true); expect(state.total).toBe(0); expect(state.errorMessage).toBe('雷达服务暂不可用'); expect(JSON.stringify(state.tracks)).not.toContain('Dubai'); expect(JSON.stringify(state.tracks)).not.toContain('United Arab Emirates'); }); }); }); describe('OmniCard AI 雷达监控准确性 (useRadar.uts)', () => { function loadRadar(): { formatLocation: (country?: string, city?: string) => string; formatPublicIp: (ip?: string) => string; formatViewedLabels: (item: RadarTrackFixture) => string[]; consolidateRadarTracks: (tracks: RadarTrackFixture[]) => RadarTrackFixture[]; isVisitorOnline: ( item: RadarTrackFixture, now?: number ) => boolean; formatVisitorAction: (item: RadarTrackFixture) => string; formatClockTime: (createdAt?: string | Date) => string; RADAR_ONLINE_WINDOW_MS: number; } { return require('../../../mpuniapp/pages/owner/useRadar.uts'); } it('内网与 Unknown 不当成真实城市,公网 IP 才保留', () => { const { formatLocation, formatPublicIp } = loadRadar(); expect(formatLocation('Local', 'Local')).toBe('本地网络'); expect(formatLocation('Unknown', 'Unknown')).toBe('未知'); expect(formatPublicIp('127.0.0.1')).toBe(''); expect(formatPublicIp('192.168.1.8')).toBe(''); expect(formatPublicIp('8.8.8.8')).toBe('8.8.8.8'); }); it('同一访客的进入与离开合并,停留、深度和浏览模块取完整会话', () => { const { consolidateRadarTracks, formatViewedLabels, formatVisitorAction } = loadRadar(); const rows = consolidateRadarTracks([ buildTrack({ id: 1, stayDurationMs: 0, scrollDepth: 0, viewedBlocks: ['hero'], createdAt: '2026-09-23T08:00:00.000Z', }), buildTrack({ id: 2, stayDurationMs: 45000, scrollDepth: 80, viewedBlocks: ['case', 'article'], visitorName: 'Alex', avatarUrl: 'https://cdn.example.com/a.png', visitorCompany: 'Nexus', createdAt: '2026-09-23T08:02:00.000Z', viewedContents: [ { key: 'case', title: 'Middle East Hub' }, { key: 'article', title: '' }, ], }), ]); expect(rows).toHaveLength(1); expect(rows[0].stayDurationMs).toBe(45000); expect(rows[0].scrollDepth).toBe(80); expect(rows[0].visitorName).toBe('Alex'); expect(rows[0].avatarUrl).toBe('https://cdn.example.com/a.png'); expect(formatViewedLabels(rows[0])).toEqual(['Middle East Hub', '文章']); expect(formatVisitorAction(rows[0])).toBe('通过 NFC 碰一碰打开名片'); expect(formatVisitorAction(buildTrack({ touchType: 'link' }))).not.toContain('留资'); }); it('5 分钟内算在线,更早的足迹不算在线', () => { const { isVisitorOnline, formatClockTime, RADAR_ONLINE_WINDOW_MS } = loadRadar(); const now = Date.parse('2026-09-23T10:00:00.000Z'); const recent = buildTrack({ createdAt: new Date(now - 60 * 1000).toISOString() }); const stale = buildTrack({ createdAt: new Date(now - RADAR_ONLINE_WINDOW_MS - 1000).toISOString() }); expect(isVisitorOnline(recent, now)).toBe(true); expect(isVisitorOnline(stale, now)).toBe(false); expect(formatClockTime('2026-09-23T10:05:00.000Z')).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/); }); }); 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); }); it('高意向行用 error token 标出,不用品牌蓝当警告', () => { const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8'); const logicSource = fs.readFileSync(RADAR_UTS, 'utf8'); const styleBlock = pageSource.includes(' { const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8'); const logicSource = fs.readFileSync(RADAR_UTS, 'utf8'); const template = pageSource.split(' { const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8'); const logicSource = fs.readFileSync(RADAR_UTS, 'utf8'); const template = pageSource.split('