文件
OmniCard/backend/test/mpuniapp/owner-radar.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

591 行
22 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* 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<OwnerPageDataFixture>;
};
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<LoadTracksResult>;
createRadarState: () => RadarStateFixture;
refreshTracks: (
state: RadarStateFixture,
api?: RadarFetchApi
) => Promise<LoadTracksResult>;
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<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('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('<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);
});
it('高意向行用 error token 标出,不用品牌蓝当警告', () => {
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
const styleBlock = pageSource.includes('<style')
? pageSource.slice(pageSource.indexOf('<style'))
: '';
expect(logicSource.includes('180000')).toBe(true);
expect(pageSource.includes('isHighIntent')).toBe(true);
expect(pageSource.includes('radar-row-hot')).toBe(true);
expect(pageSource.includes(`'--oc-error'`) || pageSource.includes(`"--oc-error"`) || pageSource.includes(ERROR_CSS_VAR)).toBe(
true
);
expect(pageSource.includes(ERROR_TOKEN) || pageSource.includes('utsLightColors.error')).toBe(true);
const hotRuleMatch = styleBlock.match(/\.radar-row-hot\s*\{[^}]+\}/);
expect(hotRuleMatch).not.toBeNull();
const hotRule = hotRuleMatch != null ? hotRuleMatch[0] : '';
expect(hotRule.includes('#E3E7ED')).toBe(true);
expect(hotRule.includes(ERROR_TOKEN)).toBe(false);
expect(hotRule.includes('#F04438')).toBe(false);
expect(hotRule.includes('#FEF3F2')).toBe(false);
expect(hotRule.includes('#0088FF')).toBe(false);
expect(hotRule.includes('#0B5CFF')).toBe(false);
expect(hotRule.includes('#3894FF')).toBe(false);
expect(hotRule.includes('var(--oc-primary')).toBe(false);
const intentHighMatch = styleBlock.match(/\.intent-high\s*\{[^}]+\}/);
expect(intentHighMatch).not.toBeNull();
const intentHighRule = intentHighMatch != null ? intentHighMatch[0] : '';
expect(intentHighRule.includes('#0088FF')).toBe(false);
expect(intentHighRule.includes('#0B5CFF')).toBe(false);
const intentHighTextMatch = styleBlock.match(/\.intent-high\s+\.intent-text\s*\{[^}]+\}/);
expect(intentHighTextMatch).not.toBeNull();
const intentHighTextRule = intentHighTextMatch != null ? intentHighTextMatch[0] : '';
expect(
intentHighTextRule.includes(ERROR_CSS_VAR) || intentHighTextRule.includes(ERROR_TOKEN)
).toBe(true);
expect(intentHighTextRule.includes('#0088FF')).toBe(false);
expect(intentHighTextRule.includes('#0B5CFF')).toBe(false);
});
it('R42: 足迹空国家显示未知;源码不以 Dubai/阿联酋为默认;失败清空列表', () => {
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
const template = pageSource.split('<script')[0];
expect(logicSource.includes("'unknown'") || logicSource.includes('"unknown"')).toBe(true);
expect(logicSource.includes('resolveRadarText(') && logicSource.includes('unknown')).toBe(
true
);
expect(logicSource.includes('formatLocation')).toBe(true);
expect(pageSource.includes('formatLocation')).toBe(true);
expect(template.includes('formatLocation(item.country, item.city)')).toBe(true);
expect(logicSource.includes('Dubai')).toBe(false);
expect(logicSource.includes('United Arab Emirates')).toBe(false);
expect(logicSource.includes('阿联酋')).toBe(false);
expect(pageSource.includes('Dubai')).toBe(false);
expect(pageSource.includes('United Arab Emirates')).toBe(false);
expect(pageSource.includes('阿联酋')).toBe(false);
expect(logicSource.includes('list: []')).toBe(true);
expect(logicSource.includes('tracks: []')).toBe(true);
expect(logicSource.includes('state.tracks = result.list')).toBe(true);
});
it('监控行展示头像、地址、时刻与浏览内容', () => {
const pageSource = fs.readFileSync(RADAR_PAGE, 'utf8');
const logicSource = fs.readFileSync(RADAR_UTS, 'utf8');
const template = pageSource.split('<script')[0];
expect(template.includes('formatLocation(item.country, item.city)')).toBe(true);
expect(template.includes('avatar-img')).toBe(true);
expect(template.includes('mini-avatar-img')).toBe(true);
expect(template.includes('avatar-online')).toBe(true);
expect(template.includes('formatBlocks(item)')).toBe(true);
expect(logicSource.includes('formatMonitorTime')).toBe(true);
expect(logicSource.includes('formatViewedLine')).toBe(true);
expect(logicSource.includes('isVisitorOnline')).toBe(true);
expect(logicSource.includes('consolidateRadarTracks')).toBe(true);
expect(logicSource.includes('saved your contact card')).toBe(false);
expect(logicSource.includes('? 32')).toBe(false);
});
});