补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
349 行
12 KiB
TypeScript
349 行
12 KiB
TypeScript
/**
|
|
* OmniCard AI (CRMmp) - 名片主移动名片中心测试套件 (TDD Red -> Green)
|
|
*
|
|
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/card 设计编写
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const CARD_PAGE = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages/owner/card/index.uvue'
|
|
);
|
|
const CARD_UTS = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages/owner/card/useCardHome.uts'
|
|
);
|
|
|
|
type CardStatus = 'draft' | 'published' | 'archived';
|
|
|
|
type CardProfile = {
|
|
id?: string | number;
|
|
name: string;
|
|
title?: string;
|
|
company?: string;
|
|
status?: CardStatus;
|
|
slug?: string;
|
|
};
|
|
|
|
type OwnerPageQuery = {
|
|
page: number;
|
|
size: number;
|
|
keyWord?: string;
|
|
};
|
|
|
|
type OwnerPageData<T> = {
|
|
list: T[];
|
|
pagination: {
|
|
page: number;
|
|
size: number;
|
|
total: number;
|
|
};
|
|
};
|
|
|
|
type LoadCardsQuery = {
|
|
page: number;
|
|
size: number;
|
|
keyWord?: string;
|
|
};
|
|
|
|
type LoadCardsApi = {
|
|
fetchOwnerCardPage: (
|
|
query: OwnerPageQuery
|
|
) => Promise<OwnerPageData<CardProfile>>;
|
|
};
|
|
|
|
type LoadCardsResult = {
|
|
list: CardProfile[];
|
|
pagination: {
|
|
page: number;
|
|
size: number;
|
|
total: number;
|
|
};
|
|
empty: boolean;
|
|
errorMessage: string;
|
|
};
|
|
|
|
type UseCardHomeModule = {
|
|
loadCards: (
|
|
query: LoadCardsQuery,
|
|
api?: LoadCardsApi
|
|
) => Promise<LoadCardsResult>;
|
|
buildEditUrl: (id: string | number) => string;
|
|
buildQrcodeUrl: (card: CardProfile) => string;
|
|
};
|
|
|
|
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
|
|
|
|
function buildCard(overrides?: Partial<CardProfile>): CardProfile {
|
|
return {
|
|
id: 8,
|
|
name: 'Alex Vance',
|
|
title: 'Partner',
|
|
company: 'Nexus Tech',
|
|
status: 'published',
|
|
slug: 'alex-vance',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function emptyPage(page: number, size: number): OwnerPageData<CardProfile> {
|
|
return {
|
|
list: [],
|
|
pagination: { page, size, total: 0 },
|
|
};
|
|
}
|
|
|
|
function loadUseCardHome(): UseCardHomeModule {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
return require(CARD_UTS) as UseCardHomeModule;
|
|
}
|
|
|
|
describe('OmniCard AI 名片主移动名片中心 (useCardHome.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. loadCards 调用 page API', () => {
|
|
it('1.1 将 page / size / keyWord 交给 fetchOwnerCardPage', async () => {
|
|
const { loadCards } = loadUseCardHome();
|
|
const card = buildCard({ status: 'published' });
|
|
const fetchOwnerCardPage = jest.fn(
|
|
async (query: OwnerPageQuery): Promise<OwnerPageData<CardProfile>> => {
|
|
expect(query.page).toBe(2);
|
|
expect(query.size).toBe(20);
|
|
expect(query.keyWord).toBe('Vance');
|
|
return {
|
|
list: [card],
|
|
pagination: { page: 2, size: 20, total: 1 },
|
|
};
|
|
}
|
|
);
|
|
|
|
const result = await loadCards(
|
|
{ page: 2, size: 20, keyWord: 'Vance' },
|
|
{ fetchOwnerCardPage }
|
|
);
|
|
|
|
expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1);
|
|
expect(result.list).toHaveLength(1);
|
|
expect(result.list[0].name).toBe('Alex Vance');
|
|
expect(result.list[0].title).toBe('Partner');
|
|
expect(result.list[0].company).toBe('Nexus Tech');
|
|
expect(result.list[0].status).toBe('published');
|
|
expect(result.empty).toBe(false);
|
|
expect(result.errorMessage).toBe('');
|
|
expect(result.pagination.total).toBe(1);
|
|
});
|
|
|
|
it('1.2 空列表标记空态,不写错误', async () => {
|
|
const { loadCards } = loadUseCardHome();
|
|
const fetchOwnerCardPage = jest.fn(
|
|
async (): Promise<OwnerPageData<CardProfile>> => emptyPage(1, 10)
|
|
);
|
|
|
|
const result = await loadCards(
|
|
{ page: 1, size: 10 },
|
|
{ fetchOwnerCardPage }
|
|
);
|
|
|
|
expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1);
|
|
expect(result.list).toEqual([]);
|
|
expect(result.empty).toBe(true);
|
|
expect(result.errorMessage).toBe('');
|
|
});
|
|
|
|
it('1.3 接口失败写入错误,不伪装空态成功', async () => {
|
|
const { loadCards } = loadUseCardHome();
|
|
const fetchOwnerCardPage = jest.fn(
|
|
async (): Promise<OwnerPageData<CardProfile>> => {
|
|
throw new Error('card page unavailable');
|
|
}
|
|
);
|
|
|
|
const result = await loadCards(
|
|
{ page: 1, size: 10, keyWord: 'Nexus' },
|
|
{ fetchOwnerCardPage }
|
|
);
|
|
|
|
expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1);
|
|
expect(result.list).toEqual([]);
|
|
expect(result.empty).toBe(false);
|
|
expect(result.errorMessage).toBe('card page unavailable');
|
|
});
|
|
|
|
it('1.4 空白 keyWord 不传给 page API', async () => {
|
|
const { loadCards } = loadUseCardHome();
|
|
const fetchOwnerCardPage = jest.fn(
|
|
async (query: OwnerPageQuery): Promise<OwnerPageData<CardProfile>> => {
|
|
expect(query.keyWord).toBeUndefined();
|
|
return emptyPage(query.page, query.size);
|
|
}
|
|
);
|
|
|
|
await loadCards(
|
|
{ page: 1, size: 10, keyWord: ' ' },
|
|
{ fetchOwnerCardPage }
|
|
);
|
|
|
|
expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1);
|
|
const sent = fetchOwnerCardPage.mock.calls[0][0] as OwnerPageQuery;
|
|
expect(sent.page).toBe(1);
|
|
expect(sent.size).toBe(10);
|
|
expect(sent.keyWord).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('2. 编辑与海报跳转契约', () => {
|
|
it('2.1 行点击走 edit?id=,海报优先 slug', () => {
|
|
const { buildEditUrl, buildQrcodeUrl } = loadUseCardHome();
|
|
|
|
expect(buildEditUrl(8)).toBe('/pages/owner/card/edit?id=8');
|
|
expect(buildQrcodeUrl(buildCard({ slug: 'alex-vance' }))).toBe(
|
|
'/pages/owner/card/qrcode?slug=alex-vance'
|
|
);
|
|
expect(buildQrcodeUrl(buildCard({ slug: '', id: 12 }))).toBe(
|
|
'/pages/owner/card/qrcode?id=12'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('3. formatSvgDataUri 转换契约', () => {
|
|
it('3.1 遇到 <svg 开头的字符串转为 data:image/svg+xml;utf8,...', () => {
|
|
const { formatSvgDataUri } = loadUseCardHome() as unknown as {
|
|
formatSvgDataUri: (svg: string) => string;
|
|
};
|
|
|
|
const raw = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31 31"><path d="M0 0h31v31H0z"/></svg>';
|
|
const result = formatSvgDataUri(raw);
|
|
expect(result.startsWith('data:image/svg+xml;utf8,')).toBe(true);
|
|
expect(result).toContain(encodeURIComponent('<path d="M0 0h31v31H0z"/>'));
|
|
});
|
|
|
|
it('3.2 遇到已经是 http/https 或 data: 开头的链接保持原样', () => {
|
|
const { formatSvgDataUri } = loadUseCardHome() as unknown as {
|
|
formatSvgDataUri: (svg: string) => string;
|
|
};
|
|
|
|
expect(formatSvgDataUri('https://cdn.example.com/qr.png')).toBe('https://cdn.example.com/qr.png');
|
|
expect(formatSvgDataUri('data:image/png;base64,ABC')).toBe('data:image/png;base64,ABC');
|
|
});
|
|
|
|
it('3.3 遇到空串或无效值返回空串', () => {
|
|
const { formatSvgDataUri } = loadUseCardHome() as unknown as {
|
|
formatSvgDataUri: (svg: string) => string;
|
|
};
|
|
|
|
expect(formatSvgDataUri('')).toBe('');
|
|
expect(formatSvgDataUri(' ')).toBe('');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OmniCard AI 名片主名片中心页契约 (index.uvue)', () => {
|
|
it('页面含 list-view,并引用 fetchOwnerCardPage 或 loadCards', () => {
|
|
expect(fs.existsSync(CARD_PAGE)).toBe(true);
|
|
expect(fs.existsSync(CARD_UTS)).toBe(true);
|
|
|
|
const pageSource = fs.readFileSync(CARD_PAGE, 'utf8');
|
|
const logicSource = fs.readFileSync(CARD_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('fetchOwnerCardPage') || combined.includes('loadCards')
|
|
).toBe(true);
|
|
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
|
|
expect(pageSource.includes('getOmniText')).toBe(true);
|
|
expect(pageSource.includes('item.name')).toBe(true);
|
|
expect(pageSource.includes('item.title')).toBe(true);
|
|
expect(pageSource.includes('item.company')).toBe(true);
|
|
expect(pageSource.includes('item.status') || pageSource.includes('statusLabel')).toBe(
|
|
true
|
|
);
|
|
expect(combined.includes('/pages/owner/card/edit?id=')).toBe(true);
|
|
expect(
|
|
combined.includes('/pages/owner/card/qrcode?id=') ||
|
|
combined.includes('/pages/owner/card/qrcode?slug=')
|
|
).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);
|
|
});
|
|
|
|
it('无 emoji、无 any,模板无硬编码中文主文案,长列表不用 view v-for', () => {
|
|
const pageSource = fs.readFileSync(CARD_PAGE, 'utf8');
|
|
const logicSource = fs.readFileSync(CARD_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('有真实 svg 时用 image 或 rich-text 渲染二维码,不把 URL 当码图', () => {
|
|
const pageSource = fs.readFileSync(CARD_PAGE, 'utf8');
|
|
const template = pageSource.split('<script')[0];
|
|
const qrBoxMatch = template.match(
|
|
/class="showcase-qr-box"[\s\S]*?<\/view>\s*<view class="showcase-glow-guide"/
|
|
);
|
|
const qrBoxBlock = qrBoxMatch != null ? qrBoxMatch[0] : '';
|
|
|
|
expect(pageSource.includes('fetchCardQrcodeSvg')).toBe(true);
|
|
expect(pageSource.includes('activeQrSvg')).toBe(true);
|
|
expect(template.includes('<image')).toBe(true);
|
|
expect(template.includes(':src="activeQrSvg"')).toBe(true);
|
|
expect(template.includes('<rich-text')).toBe(true);
|
|
expect(template.includes(':nodes="activeQrSvg"')).toBe(true);
|
|
expect(qrBoxBlock.includes(':src="activeQrUrl"')).toBe(false);
|
|
expect(qrBoxBlock.includes('{{ activeQrUrl }}')).toBe(false);
|
|
expect(qrBoxBlock.includes('{{ currentDisplayLink }}')).toBe(false);
|
|
});
|
|
|
|
it('无 svg 或请求失败时显示空态文案,不画假码占位块', () => {
|
|
const pageSource = fs.readFileSync(CARD_PAGE, 'utf8');
|
|
const template = pageSource.split('<script')[0];
|
|
|
|
expect(template.includes('showcase-qr-empty')).toBe(true);
|
|
expect(
|
|
pageSource.includes("resolveCardHomeText('empty')") ||
|
|
(pageSource.includes('getOmniText(') &&
|
|
pageSource.includes("'common.empty'"))
|
|
).toBe(true);
|
|
expect(pageSource.includes('activeQrSvg.value = \'\'')).toBe(true);
|
|
expect(template.includes('showcase-qr-placeholder')).toBe(false);
|
|
expect(template.includes('showcase-qr-skeleton')).toBe(false);
|
|
expect(template.includes('showcase-qr-pulse')).toBe(false);
|
|
expect(pageSource.includes('fake-qr')).toBe(false);
|
|
expect(pageSource.includes('decorative-qr')).toBe(false);
|
|
expect(pageSource.includes('placeholder-qr')).toBe(false);
|
|
expect(/:src="activeQrUrl"/.test(template)).toBe(false);
|
|
expect((template.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0);
|
|
expect((template.match(/class="primary-cta-btn"/g) || []).length).toBe(1);
|
|
});
|
|
});
|