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

426 行
14 KiB
TypeScript

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

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

/**
* OmniCard AI (CRMmp) - 名片主移动潜客列表测试套件 (TDD Red -> Green)
*
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/leads 设计编写
* R30:筛选只用接口真实 status / intentScore;空态与失败不填示例线索
*/
import * as fs from 'fs';
import * as path from 'path';
const LEADS_PAGE = path.resolve(
__dirname,
'../../../mpuniapp/pages/owner/leads.uvue'
);
const LEADS_UTS = path.resolve(
__dirname,
'../../../mpuniapp/pages/owner/useLeads.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 OwnerPageQuery = {
page: number;
size: number;
keyWord?: string;
status?: LeadStatus;
};
type OwnerPageData<T> = {
list: T[];
pagination: {
page: number;
size: number;
total: number;
};
};
type LoadLeadsQuery = {
page: number;
size: number;
status?: string;
keyWord?: string;
};
type LoadLeadsApi = {
fetchOwnerLeadPage: (
query: OwnerPageQuery
) => Promise<OwnerPageData<LeadEntity>>;
};
type LoadLeadsResult = {
list: LeadEntity[];
pagination: {
page: number;
size: number;
total: number;
};
empty: boolean;
errorMessage: string;
};
type LeadsState = {
list: LeadEntity[];
radarTracks: unknown[];
hotUnread: number;
page: number;
size: number;
total: number;
status: string;
keyWord: string;
loading: boolean;
empty: boolean;
errorMessage: string;
};
type UseLeadsModule = {
parseLeadStatus: (raw: string | null | undefined) => LeadStatus | null;
formatHotUnread: (unread: number) => string;
loadLeads: (
query: LoadLeadsQuery,
api?: LoadLeadsApi
) => Promise<LoadLeadsResult>;
createLeadsState: () => LeadsState;
applyLeadStatusFilter: (state: LeadsState, status: string) => void;
applyLoadResult: (state: LeadsState, result: LoadLeadsResult) => void;
sanitizeLeadQuery: (query: LoadLeadsQuery) => OwnerPageQuery;
};
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
const SAMPLE_NAME_RE = /Alex Vance|Nexus Tech|示例线索|Fatima Al-Rashid|演示潜客/;
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 emptyPage(page: number, size: number): OwnerPageData<LeadEntity> {
return {
list: [],
pagination: { page, size, total: 0 },
};
}
function loadUseLeads(): UseLeadsModule {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(LEADS_UTS) as UseLeadsModule;
}
describe('OmniCard AI 名片主移动潜客列表 (useLeads.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. loadLeads 调用 page API', () => {
it('1.1 将 page / size / status / keyWord 交给 fetchOwnerLeadPage', async () => {
const { loadLeads } = loadUseLeads();
const lead = buildLead({ status: 'hot', intentScore: 91 });
const fetchOwnerLeadPage = jest.fn(
async (query: OwnerPageQuery): Promise<OwnerPageData<LeadEntity>> => {
expect(query.page).toBe(2);
expect(query.size).toBe(20);
expect(query.status).toBe('hot');
expect(query.keyWord).toBe('Vance');
return {
list: [lead],
pagination: { page: 2, size: 20, total: 1 },
};
}
);
const result = await loadLeads(
{ page: 2, size: 20, status: 'hot', keyWord: 'Vance' },
{ fetchOwnerLeadPage }
);
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(result.list).toHaveLength(1);
expect(result.list[0].name).toBe('Alex Vance');
expect(result.list[0].status).toBe('hot');
expect(result.list[0].intentScore).toBe(91);
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('');
expect(result.pagination.total).toBe(1);
});
it('1.2 空列表标记空态,不写错误、不填示例', async () => {
const { loadLeads } = loadUseLeads();
const fetchOwnerLeadPage = jest.fn(
async (): Promise<OwnerPageData<LeadEntity>> => emptyPage(1, 10)
);
const result = await loadLeads(
{ page: 1, size: 10 },
{ fetchOwnerLeadPage }
);
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(result.list).toEqual([]);
expect(result.empty).toBe(true);
expect(result.errorMessage).toBe('');
expect(result.list.some((item) => SAMPLE_NAME_RE.test(item.name))).toBe(
false
);
});
it('1.3 接口失败写入错误,列表清空,不伪装空态成功', async () => {
const { loadLeads } = loadUseLeads();
const fetchOwnerLeadPage = jest.fn(async (): Promise<OwnerPageData<LeadEntity>> => {
throw new Error('lead page unavailable');
});
const result = await loadLeads(
{ page: 1, size: 10, status: 'new' },
{ fetchOwnerLeadPage }
);
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
expect(result.list).toEqual([]);
expect(result.empty).toBe(false);
expect(result.errorMessage).toBe('lead page unavailable');
});
});
describe('2. 非法 status 丢弃', () => {
it('2.1 parseLeadStatus 只放行契约枚举', () => {
const { parseLeadStatus } = loadUseLeads();
expect(parseLeadStatus('new')).toBe('new');
expect(parseLeadStatus('contacted')).toBe('contacted');
expect(parseLeadStatus('hot')).toBe('hot');
expect(parseLeadStatus('won')).toBe('won');
expect(parseLeadStatus('archived')).toBe('archived');
expect(parseLeadStatus('pending')).toBeNull();
expect(parseLeadStatus('warm')).toBeNull();
expect(parseLeadStatus('cold')).toBeNull();
expect(parseLeadStatus('NEW')).toBeNull();
expect(parseLeadStatus('')).toBeNull();
expect(parseLeadStatus(null)).toBeNull();
expect(parseLeadStatus(undefined)).toBeNull();
});
it('2.2 loadLeads 不把非法 status 传给 page API', async () => {
const { loadLeads } = loadUseLeads();
const fetchOwnerLeadPage = jest.fn(
async (query: OwnerPageQuery): Promise<OwnerPageData<LeadEntity>> => {
expect(query.status).toBeUndefined();
return emptyPage(query.page, query.size);
}
);
await loadLeads(
{ page: 1, size: 10, status: 'pending', keyWord: ' ' },
{ fetchOwnerLeadPage }
);
expect(fetchOwnerLeadPage).toHaveBeenCalledTimes(1);
const sent = fetchOwnerLeadPage.mock.calls[0][0] as OwnerPageQuery;
expect(sent.page).toBe(1);
expect(sent.size).toBe(10);
expect(sent.status).toBeUndefined();
expect(sent.keyWord).toBeUndefined();
});
});
describe('3. R30 真实 status / intentScore 筛选与失败清列表', () => {
it('3.1 sanitizeLeadQuery 只透传契约 status,不伪造 intent', () => {
const { sanitizeLeadQuery } = loadUseLeads();
const hot = sanitizeLeadQuery({
page: 1,
size: 20,
status: 'hot',
keyWord: 'score',
});
expect(hot.status).toBe('hot');
expect(hot.keyWord).toBe('score');
expect(Object.prototype.hasOwnProperty.call(hot, 'intentScore')).toBe(
false
);
const cleared = sanitizeLeadQuery({
page: 1,
size: 20,
status: 'warm',
});
expect(cleared.status).toBeUndefined();
});
it('3.2 applyLeadStatusFilter 只写入真实 status 枚举', () => {
const { createLeadsState, applyLeadStatusFilter } = loadUseLeads();
const state = createLeadsState();
applyLeadStatusFilter(state, 'hot');
expect(state.status).toBe('hot');
expect(state.page).toBe(1);
applyLeadStatusFilter(state, 'contacted');
expect(state.status).toBe('contacted');
applyLeadStatusFilter(state, 'warm');
expect(state.status).toBe('');
applyLeadStatusFilter(state, '');
expect(state.status).toBe('');
});
it('3.3 失败结果写回 state 时清空上一批线索名,不保留示例', () => {
const { createLeadsState, applyLoadResult } = loadUseLeads();
const state = createLeadsState();
state.list = [buildLead({ name: 'Alex Vance', intentScore: 91 })];
state.total = 1;
state.empty = false;
state.errorMessage = '';
applyLoadResult(state, {
list: [],
pagination: { page: 1, size: 20, total: 0 },
empty: false,
errorMessage: 'lead page unavailable',
});
expect(state.list).toEqual([]);
expect(state.total).toBe(0);
expect(state.empty).toBe(false);
expect(state.errorMessage).toBe('lead page unavailable');
expect(state.list.some((item) => item.name === 'Alex Vance')).toBe(false);
});
it('3.4 空列表写回 state 标记空态,列表保持空数组', () => {
const { createLeadsState, applyLoadResult } = loadUseLeads();
const state = createLeadsState();
state.list = [buildLead()];
applyLoadResult(state, {
list: [],
pagination: { page: 1, size: 20, total: 0 },
empty: true,
errorMessage: '',
});
expect(state.list).toEqual([]);
expect(state.empty).toBe(true);
expect(state.errorMessage).toBe('');
});
});
it('formatHotUnread 有未读才显示红点文案', () => {
const { formatHotUnread } = loadUseLeads();
expect(formatHotUnread(0)).toBe('');
expect(formatHotUnread(2)).toContain('2');
});
});
describe('OmniCard AI 名片主潜客列表页契约 (leads.uvue)', () => {
it('页面含 list-view,并引用 fetchOwnerLeadPage 或 loadLeads', () => {
expect(fs.existsSync(LEADS_PAGE)).toBe(true);
expect(fs.existsSync(LEADS_UTS)).toBe(true);
const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8');
const logicSource = fs.readFileSync(LEADS_UTS, 'utf8');
const combined = `${pageSource}\n${logicSource}`;
expect(pageSource.includes('<list-view')).toBe(true);
expect(pageSource.includes('<list-item')).toBe(true);
expect(pageSource.includes('v-for')).toBe(true);
expect(
combined.includes('fetchOwnerLeadPage') || combined.includes('loadLeads')
).toBe(true);
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
expect(pageSource.includes('getOmniText')).toBe(true);
expect(combined.includes('crm.leads')).toBe(true);
expect(combined.includes('crm.follow_up')).toBe(true);
expect(pageSource.includes('leads-detail')).toBe(true);
expect(pageSource.includes('navigateTo') || logicSource.includes('navigateTo')).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('primary-cta-btn')).toBe(true);
expect(pageSource.includes('status-chip')).toBe(true);
expect(combined.includes('hotUnread') || combined.includes('hotInbox')).toBe(true);
});
it('无 emoji、无 any,模板无硬编码中文主文案,长列表不用 view v-for', () => {
const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8');
const logicSource = fs.readFileSync(LEADS_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(/[\u4e00-\u9fff]/.test(template)).toBe(false);
expect(/<view[^>]*v-for/.test(template)).toBe(false);
expect(template.includes('<list-item')).toBe(true);
});
it('R30: 展示用真实 status/intentScore,空态无示例,芯片筛选会刷新', () => {
const pageSource = fs.readFileSync(LEADS_PAGE, 'utf8');
const logicSource = fs.readFileSync(LEADS_UTS, 'utf8');
const template = pageSource.split('<script')[0];
const script = pageSource.includes('<script')
? pageSource.split('<script')[1].split('</script>')[0]
: '';
expect(template.includes('item.intentScore')).toBe(true);
expect(template.includes('item.status')).toBe(true);
expect(template.includes('state.empty')).toBe(true);
expect(
template.includes("t('empty')") || template.includes('t("empty")')
).toBe(true);
expect(template.includes('leads-feedback')).toBe(true);
expect(SAMPLE_NAME_RE.test(pageSource)).toBe(false);
expect(SAMPLE_NAME_RE.test(logicSource)).toBe(false);
expect(logicSource.includes('示例')).toBe(false);
expect(pageSource.includes('示例')).toBe(false);
expect(script.includes("'统计'") || script.includes('"统计"')).toBe(false);
expect(script.includes('resolveLeadsText')).toBe(true);
expect(script.includes('applyLeadStatusFilter')).toBe(true);
expect(script.includes('refreshLeads')).toBe(true);
expect(
/function selectStatus[\s\S]*?applyLeadStatusFilter[\s\S]*?refreshLeads/.test(
script
)
).toBe(true);
expect(logicSource.includes('sanitizeLeadQuery')).toBe(true);
expect(logicSource.includes('parseLeadStatus')).toBe(true);
expect(logicSource.includes('intentScore')).toBe(false);
});
});