文件
OmniCard/backend/test/mpuniapp/owner-team-overview.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

534 行
18 KiB
TypeScript

/**
* OmniCard AI (CRMmp) - 展会团队战报测试套件 (TDD Red -> Green)
*
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/team/overview 设计编写
*/
import * as fs from 'fs';
import * as path from 'path';
const OVERVIEW_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/owner/team/overview.uvue'
);
const OVERVIEW_UTS = path.resolve(
__dirname,
'../../../mpuniapp/pages/owner/team/useOverview.uts'
);
type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived';
type LeadEntity = {
id: string | number;
cardId: string | number;
name: string;
company?: string;
status: LeadStatus;
intentScore: number;
source: 'nfc' | 'qr' | 'link' | 'ai_chat';
};
type RadarTrack = {
id: string | number;
cardId: string | number;
visitorId: string;
touchType: 'nfc' | 'qr' | 'link';
stayDurationMs: number;
scrollDepth: number;
viewedBlocks: string[];
country?: string;
city?: string;
};
type OwnerPageQuery = {
page: number;
size: number;
cardId?: string | number;
};
type OwnerPageData<T> = {
list: T[];
pagination: {
page: number;
size: number;
total: number;
};
};
type OverviewSummary = {
totalLeads: number;
hotCount: number;
totalTracks: number;
topCountry: string;
};
type TeamRankItem = {
memberId: number;
memberName: string;
leads: number;
touches: number;
score: number;
rank: number;
};
type LoadOverviewApi = {
fetchOwnerLeadPage?: (
query: OwnerPageQuery
) => Promise<OwnerPageData<LeadEntity>>;
fetchOwnerRadarPage?: (
query: OwnerPageQuery
) => Promise<OwnerPageData<RadarTrack>>;
fetchTeamLiveRank?: (eventId?: string) => Promise<TeamRankItem[]>;
fetchCurrentEvent?: () => Promise<{ code?: string } | null>;
};
type LoadOverviewResult = {
leads: LeadEntity[];
tracks: RadarTrack[];
ranks: TeamRankItem[];
summary: OverviewSummary;
empty: boolean;
errorMessage: string;
};
type LoadOverviewInput = {
page?: number;
size?: number;
cardId?: string | number;
};
type OverviewState = {
leads: LeadEntity[];
tracks: RadarTrack[];
summary: OverviewSummary;
loading: boolean;
empty: boolean;
errorMessage: string;
};
type UseOverviewModule = {
summarize: (leads: LeadEntity[], tracks: RadarTrack[]) => OverviewSummary;
loadOverview: (
input?: LoadOverviewInput,
api?: LoadOverviewApi
) => Promise<LoadOverviewResult>;
createOverviewState: () => OverviewState;
refreshOverview: (
state: OverviewState,
api?: LoadOverviewApi
) => Promise<LoadOverviewResult>;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
function buildLead(overrides?: Partial<LeadEntity>): LeadEntity {
return {
id: 21,
cardId: 8,
name: 'Alex Vance',
company: 'Nexus Tech',
status: 'new',
intentScore: 72,
source: 'nfc',
...overrides,
};
}
function buildTrack(overrides?: Partial<RadarTrack>): RadarTrack {
return {
id: 81,
cardId: 8,
visitorId: 'vis_ae_1201',
country: 'United Arab Emirates',
city: 'Dubai',
touchType: 'nfc',
stayDurationMs: 45000,
scrollDepth: 72,
viewedBlocks: ['hero', 'case'],
...overrides,
};
}
function buildPage<T>(
list: T[],
pagination?: Partial<OwnerPageData<T>['pagination']>
): OwnerPageData<T> {
return {
list,
pagination: {
page: 1,
size: 20,
total: list.length,
...pagination,
},
};
}
function loadUseOverview(): UseOverviewModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(OVERVIEW_UTS) as UseOverviewModule;
}
describe('OmniCard AI 展会团队战报 (useOverview.uts)', () => {
beforeEach(() => {
jest.resetModules();
(global as { uni?: Record<string, unknown> }).uni = {
getLocale: () => 'zh-CN',
showToast: jest.fn(),
navigateTo: jest.fn(),
};
});
afterEach(() => {
delete (global as { uni?: Record<string, unknown> }).uni;
});
describe('1. summarize 统计正确', () => {
it('1.1 汇总线索总数、高意向数、足迹数与主要国家', () => {
const { summarize } = loadUseOverview();
const leads: LeadEntity[] = [
buildLead({ id: 1, status: 'hot' }),
buildLead({ id: 2, status: 'new' }),
buildLead({ id: 3, status: 'hot' }),
buildLead({ id: 4, status: 'won' }),
];
const tracks: RadarTrack[] = [
buildTrack({ id: 11, country: 'Germany' }),
buildTrack({ id: 12, country: 'United Arab Emirates' }),
buildTrack({ id: 13, country: 'United Arab Emirates' }),
buildTrack({ id: 14, country: 'Germany' }),
buildTrack({ id: 15, country: 'United Arab Emirates' }),
];
const summary = summarize(leads, tracks);
expect(summary.totalLeads).toBe(4);
expect(summary.hotCount).toBe(2);
expect(summary.totalTracks).toBe(5);
expect(summary.topCountry).toBe('United Arab Emirates');
});
it('1.2 空列表归零,忽略空白国家,平局取先出现的国家', () => {
const { summarize } = loadUseOverview();
const empty = summarize([], []);
expect(empty.totalLeads).toBe(0);
expect(empty.hotCount).toBe(0);
expect(empty.totalTracks).toBe(0);
expect(empty.topCountry).toBe('');
const tied = summarize(
[buildLead({ id: 9, status: 'contacted' })],
[
buildTrack({ id: 21, country: ' ' }),
buildTrack({ id: 22, country: 'Japan' }),
buildTrack({ id: 23, country: 'France' }),
buildTrack({ id: 24, country: undefined }),
buildTrack({ id: 25, country: 'France' }),
buildTrack({ id: 26, country: 'Japan' }),
]
);
expect(tied.totalLeads).toBe(1);
expect(tied.hotCount).toBe(0);
expect(tied.totalTracks).toBe(6);
expect(tied.topCountry).toBe('Japan');
});
});
describe('2. loadOverview 调两个 API', () => {
it('2.1 并行注入 fetchOwnerLeadPage 与 fetchOwnerRadarPage,默认 size 为 20', async () => {
const { loadOverview } = loadUseOverview();
const leads = [
buildLead({ id: 1, status: 'hot', name: 'Mina Cole' }),
buildLead({ id: 2, status: 'new', name: 'Omar Said' }),
];
const tracks = [
buildTrack({ id: 31, country: 'Singapore' }),
buildTrack({ id: 32, country: 'Singapore' }),
buildTrack({ id: 33, country: 'Spain' }),
];
let leadStarted = false;
let radarStarted = false;
let leadSawRadar = false;
let radarSawLead = false;
const fetchOwnerLeadPage = jest.fn(
async (query: OwnerPageQuery): Promise<OwnerPageData<LeadEntity>> => {
leadStarted = true;
leadSawRadar = radarStarted;
expect(query.page).toBe(1);
expect(query.size).toBe(20);
return buildPage(leads, { page: 1, size: 20, total: 2 });
}
);
const fetchOwnerRadarPage = jest.fn(
async (query: OwnerPageQuery): Promise<OwnerPageData<RadarTrack>> => {
radarStarted = true;
radarSawLead = leadStarted;
expect(query.page).toBe(1);
expect(query.size).toBe(20);
return buildPage(tracks, { page: 1, size: 20, total: 3 });
}
);
const result = await loadOverview(
{ page: 1, size: 20 },
{ fetchOwnerLeadPage, fetchOwnerRadarPage }
);
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
expect(leadSawRadar || radarSawLead).toBe(true);
expect(result.leads).toHaveLength(2);
expect(result.tracks).toHaveLength(3);
expect(result.summary.totalLeads).toBe(2);
expect(result.summary.hotCount).toBe(1);
expect(result.summary.totalTracks).toBe(3);
expect(result.summary.topCountry).toBe('Singapore');
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('');
});
it('2.2 无入参时仍调用两个 API,且无注入依赖时走默认实现', async () => {
const { loadOverview } = loadUseOverview();
const fetchOwnerLeadPage = jest.fn(
async (): Promise<OwnerPageData<LeadEntity>> => buildPage([])
);
const fetchOwnerRadarPage = jest.fn(
async (): Promise<OwnerPageData<RadarTrack>> => buildPage([])
);
const result = await loadOverview(undefined, {
fetchOwnerLeadPage,
fetchOwnerRadarPage,
});
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
expect(fetchOwnerLeadPage).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, size: 20 })
);
expect(fetchOwnerRadarPage).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, size: 20 })
);
expect(result.empty).toBe(true);
expect(result.errorMessage).toBe('');
expect(result.summary.totalLeads).toBe(0);
expect(result.summary.totalTracks).toBe(0);
});
it('2.3 任一接口失败写入错误,不伪装成功汇总', async () => {
const { loadOverview } = loadUseOverview();
const fetchOwnerLeadPage = jest.fn(
async (): Promise<OwnerPageData<LeadEntity>> => {
throw new Error('lead page unavailable');
}
);
const fetchOwnerRadarPage = jest.fn(
async (): Promise<OwnerPageData<RadarTrack>> =>
buildPage([buildTrack()])
);
const result = await loadOverview(
{ size: 20 },
{ fetchOwnerLeadPage, fetchOwnerRadarPage }
);
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
expect(result.leads).toEqual([]);
expect(result.tracks).toEqual([]);
expect(result.summary.totalLeads).toBe(0);
expect(result.summary.hotCount).toBe(0);
expect(result.summary.totalTracks).toBe(0);
expect(result.summary.topCountry).toBe('');
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('lead page unavailable');
});
it('2.4 注入 liveRank 时写入现场排行', async () => {
const { loadOverview } = loadUseOverview();
const result = await loadOverview(
{ page: 1, size: 20 },
{
fetchOwnerLeadPage: async () => buildPage([]),
fetchOwnerRadarPage: async () => buildPage([]),
fetchCurrentEvent: async () => ({ code: 'CES26A' }),
fetchTeamLiveRank: async (eventId?: string) => {
expect(eventId).toBe('CES26A');
return [
{
memberId: 11,
memberName: 'Alex Rivera',
leads: 3,
touches: 12,
score: 42,
rank: 1,
},
];
},
}
);
expect(result.ranks).toHaveLength(1);
expect(result.ranks[0].memberName).toBe('Alex Rivera');
});
});
describe('3. refreshOverview 触觉震动与状态刷新', () => {
it('3.1 刷新成功时触发 uni.vibrateShort 触觉震动反馈', async () => {
const { refreshOverview, createOverviewState } = loadUseOverview();
const state = createOverviewState();
const fetchOwnerLeadPage = jest.fn(async () => buildPage([buildLead()]));
const fetchOwnerRadarPage = jest.fn(async () => buildPage([buildTrack()]));
const vibrateMock = jest.fn();
(global as { uni?: Record<string, unknown> }).uni!.vibrateShort = vibrateMock;
await refreshOverview(state, { fetchOwnerLeadPage, fetchOwnerRadarPage });
expect(vibrateMock).toHaveBeenCalledTimes(1);
expect(state.loading).toBe(false);
expect(state.leads.length).toBe(1);
});
it('3.2 刷新失败写入错误态,不填迪拜示例数、不假装成功', async () => {
const { refreshOverview, createOverviewState } = loadUseOverview();
const state = createOverviewState();
state.summary = {
totalLeads: 99,
hotCount: 9,
totalTracks: 88,
topCountry: 'United Arab Emirates',
};
state.leads = [buildLead()];
state.tracks = [buildTrack()];
state.errorMessage = '';
const fetchOwnerLeadPage = jest.fn(async (): Promise<OwnerPageData<LeadEntity>> => {
throw new Error('network down');
});
const fetchOwnerRadarPage = jest.fn(
async (): Promise<OwnerPageData<RadarTrack>> => buildPage([buildTrack()])
);
const vibrateMock = jest.fn();
(global as { uni?: Record<string, unknown> }).uni!.vibrateShort = vibrateMock;
const result = await refreshOverview(state, {
fetchOwnerLeadPage,
fetchOwnerRadarPage,
});
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(fetchOwnerRadarPage).toHaveBeenCalledTimes(1);
expect(result.errorMessage).toBe('network down');
expect(state.errorMessage).toBe('network down');
expect(state.leads).toEqual([]);
expect(state.tracks).toEqual([]);
expect(state.summary.totalLeads).toBe(0);
expect(state.summary.hotCount).toBe(0);
expect(state.summary.totalTracks).toBe(0);
expect(state.summary.topCountry).toBe('');
expect(state.summary.topCountry).not.toBe('United Arab Emirates');
expect(vibrateMock).not.toHaveBeenCalled();
expect(state.loading).toBe(false);
});
});
});
describe('OmniCard AI 展会团队战报页契约 (overview.uvue)', () => {
it('页面存在且含数字摘要、list-view 与刷新主 CTA', () => {
expect(fs.existsSync(OVERVIEW_PAGE)).toBe(true);
expect(fs.existsSync(OVERVIEW_UTS)).toBe(true);
const pageSource = fs.readFileSync(OVERVIEW_PAGE, 'utf8');
const logicSource = fs.readFileSync(OVERVIEW_UTS, 'utf8');
const combined = `${pageSource}\n${logicSource}`;
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('length > 5') || logicSource.includes('length > 5')).toBe(
false
);
expect((pageSource.match(/<list-view/g) || []).length).toBe(2);
expect(pageSource.includes('gap: 12px')).toBe(true);
expect(pageSource.includes('48%')).toBe(false);
expect(pageSource.includes('statusLabel')).toBe(true);
expect(pageSource.includes('lead-status-hot')).toBe(true);
expect(pageSource.includes(' · ')).toBe(true);
expect(pageSource.includes('isRtl')).toBe(true);
expect(pageSource.includes('overview-rtl')).toBe(true);
expect(pageSource.includes('--oc-error')).toBe(true);
expect(pageSource.includes('metric-country-long')).toBe(true);
expect(pageSource.includes('chart') || pageSource.includes('bar-chart')).toBe(false);
expect(combined.includes('summarize')).toBe(true);
expect(combined.includes('loadOverview')).toBe(true);
expect(combined.includes('fetchOwnerLeadPage')).toBe(true);
expect(combined.includes('fetchOwnerRadarPage')).toBe(true);
expect(combined.includes('fetchTeamLiveRank') || combined.includes('live-rank')).toBe(
true
);
expect(logicSource.includes('Promise.all')).toBe(true);
expect(pageSource.includes('getOmniText')).toBe(true);
expect(combined.includes('crm.leads')).toBe(true);
expect(combined.includes('radar.visitor_trace')).toBe(true);
expect(pageSource.includes('primary-cta-btn')).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);
expect(pageSource.includes('#FFFFFF')).toBe(true);
});
it('P47: 一屏仅一个 primary-cta-btn,点击走 refreshOverview,失败不填示例', () => {
const pageSource = fs.readFileSync(OVERVIEW_PAGE, 'utf8');
const logicSource = fs.readFileSync(OVERVIEW_UTS, 'utf8');
const template = pageSource.split('<script')[0];
const script = pageSource.includes('<script')
? pageSource.split('<script')[1].split('</script>')[0]
: '';
expect((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThanOrEqual(1);
expect((template.match(/class="primary-cta-btn"/g) || []).length).toBe(1);
expect((template.match(/<button/g) || []).length).toBe(1);
expect(template.includes('@click="handleRefresh"')).toBe(true);
expect(script.includes('refreshOverview(state)')).toBe(true);
expect(script.includes('comingSoon')).toBe(false);
expect(logicSource.includes('comingSoon')).toBe(false);
expect(pageSource.includes('comingSoon')).toBe(false);
expect(pageSource.includes('shareSuccess') || logicSource.includes('shareSuccess')).toBe(
false
);
expect(logicSource.includes('refreshOverview')).toBe(true);
expect(logicSource.includes('loadOverview')).toBe(true);
expect(logicSource.includes('emptySummary')).toBe(true);
expect(logicSource.includes("topCountry: 'United Arab Emirates'")).toBe(false);
expect(logicSource.includes('topCountry: "United Arab Emirates"')).toBe(false);
expect(logicSource.includes('Dubai')).toBe(false);
expect(pageSource.includes('state.errorMessage')).toBe(true);
expect(pageSource.includes("t('error')") || pageSource.includes('t("error")')).toBe(
true
);
});
it('无 emoji、无 any,模板无硬编码中文主文案,长列表不用 view v-for', () => {
const pageSource = fs.readFileSync(OVERVIEW_PAGE, 'utf8');
const logicSource = fs.readFileSync(OVERVIEW_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(template)).toBe(false);
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
expect(pageSource.includes('innerAudioContext') || pageSource.includes('autoplay')).toBe(
false
);
});
});