补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
494 行
17 KiB
TypeScript
494 行
17 KiB
TypeScript
/**
|
||
* OmniCard AI (CRMmp) - 名片主参展分享码海报测试套件 (TDD Red -> Green)
|
||
*
|
||
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/card/qrcode 设计编写
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
|
||
const QRCODE_PAGE = path.resolve(
|
||
__dirname,
|
||
'../../../mpuniapp/pages/owner/card/qrcode.uvue'
|
||
);
|
||
const QRCODE_UTS = path.resolve(
|
||
__dirname,
|
||
'../../../mpuniapp/pages/owner/card/useCardQrcode.uts'
|
||
);
|
||
|
||
type QrcodeQuery = {
|
||
slug?: string;
|
||
id?: string;
|
||
};
|
||
|
||
type CardQrcodePoster = {
|
||
svg: string;
|
||
url: string;
|
||
};
|
||
|
||
type LoadPosterApi = {
|
||
fetchCardQrcodeSvg: (slugOrId: string) => Promise<CardQrcodePoster>;
|
||
};
|
||
|
||
type LoadPosterResult = {
|
||
svg: string;
|
||
url: string;
|
||
empty: boolean;
|
||
errorMessage: string;
|
||
};
|
||
|
||
type UseCardQrcodeModule = {
|
||
resolveSlugOrId: (query: QrcodeQuery | null | undefined) => string;
|
||
loadPoster: (
|
||
slugOrId: string | null | undefined,
|
||
api?: LoadPosterApi
|
||
) => Promise<LoadPosterResult>;
|
||
copyPosterUrl: (url: string | null | undefined) => boolean;
|
||
savePosterToAlbum: (
|
||
imageSource: string | null | undefined
|
||
) => Promise<boolean>;
|
||
};
|
||
|
||
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
|
||
const SAVE_SUCCESS_TOAST_RE =
|
||
/已保存到相册|已保存(?!失败)|保存成功|Saved to (Photos|album)|Saved successfully/i;
|
||
|
||
function loadUseCardQrcode(): UseCardQrcodeModule {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
return require(QRCODE_UTS) as UseCardQrcodeModule;
|
||
}
|
||
|
||
function assertNoSaveSuccessToast(
|
||
toastCalls: Array<{ title: string; icon?: string }>
|
||
): void {
|
||
for (const toast of toastCalls) {
|
||
expect(SAVE_SUCCESS_TOAST_RE.test(toast.title)).toBe(false);
|
||
}
|
||
}
|
||
|
||
function buildPoster(overrides?: Partial<CardQrcodePoster>): CardQrcodePoster {
|
||
return {
|
||
svg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||
url: 'https://omnicard.ai/c/alex-vance',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('OmniCard AI 名片主参展分享码海报 (useCardQrcode.uts)', () => {
|
||
let clipboardText: string;
|
||
let toastCalls: Array<{ title: string; icon?: string }>;
|
||
let saveAlbumCalls: Array<{ filePath: string }>;
|
||
let saveAlbumMode: 'success' | 'fail' | 'authDeny';
|
||
let downloadMode: 'success' | 'fail' | 'emptyPath';
|
||
let lastDownloadedUrl: string;
|
||
|
||
beforeEach(() => {
|
||
jest.resetModules();
|
||
clipboardText = '';
|
||
toastCalls = [];
|
||
saveAlbumCalls = [];
|
||
saveAlbumMode = 'success';
|
||
downloadMode = 'success';
|
||
lastDownloadedUrl = '';
|
||
(global as { uni?: Record<string, unknown> }).uni = {
|
||
getLocale: () => 'zh-CN',
|
||
showToast: (options: { title: string; icon?: string }) => {
|
||
toastCalls.push(options);
|
||
},
|
||
setClipboardData: (options: { data: string; success?: () => void }) => {
|
||
clipboardText = options.data;
|
||
options.success?.();
|
||
},
|
||
downloadFile: (options: {
|
||
url: string;
|
||
success?: (res: { statusCode: number; tempFilePath: string }) => void;
|
||
fail?: (err: { errMsg: string }) => void;
|
||
}) => {
|
||
lastDownloadedUrl = options.url;
|
||
if (downloadMode === 'fail') {
|
||
options.fail?.({ errMsg: 'downloadFile:fail' });
|
||
return;
|
||
}
|
||
if (downloadMode === 'emptyPath') {
|
||
options.success?.({ statusCode: 200, tempFilePath: '' });
|
||
return;
|
||
}
|
||
options.success?.({
|
||
statusCode: 200,
|
||
tempFilePath: '/tmp/poster-qr.png',
|
||
});
|
||
},
|
||
saveImageToPhotosAlbum: (options: {
|
||
filePath: string;
|
||
success?: () => void;
|
||
fail?: (err: { errMsg: string }) => void;
|
||
}) => {
|
||
saveAlbumCalls.push({ filePath: options.filePath });
|
||
if (saveAlbumMode === 'success') {
|
||
options.success?.();
|
||
return;
|
||
}
|
||
if (saveAlbumMode === 'authDeny') {
|
||
options.fail?.({ errMsg: 'saveImageToPhotosAlbum:fail auth deny' });
|
||
return;
|
||
}
|
||
options.fail?.({ errMsg: 'saveImageToPhotosAlbum:fail' });
|
||
},
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete (global as { uni?: Record<string, unknown> }).uni;
|
||
});
|
||
|
||
describe('1. resolveSlugOrId 优先 slug,否则 id', () => {
|
||
it('1.1 同时有 slug 与 id 时只取 slug', () => {
|
||
const { resolveSlugOrId } = loadUseCardQrcode();
|
||
|
||
expect(
|
||
resolveSlugOrId({ slug: 'alex-vance', id: '101' })
|
||
).toBe('alex-vance');
|
||
});
|
||
|
||
it('1.2 slug 为空时回退 id,并去掉首尾空白', () => {
|
||
const { resolveSlugOrId } = loadUseCardQrcode();
|
||
|
||
expect(resolveSlugOrId({ slug: ' ', id: ' 101 ' })).toBe('101');
|
||
expect(resolveSlugOrId({ id: 'card-88' })).toBe('card-88');
|
||
expect(resolveSlugOrId({ slug: ' gitex-2026 ' })).toBe('gitex-2026');
|
||
});
|
||
|
||
it('1.3 缺参或全空返回空串', () => {
|
||
const { resolveSlugOrId } = loadUseCardQrcode();
|
||
|
||
expect(resolveSlugOrId({})).toBe('');
|
||
expect(resolveSlugOrId({ slug: '', id: '' })).toBe('');
|
||
expect(resolveSlugOrId(null)).toBe('');
|
||
expect(resolveSlugOrId(undefined)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('2. loadPoster 调用 svg API', () => {
|
||
it('2.1 把 slugOrId 交给 fetchCardQrcodeSvg,并回填 svg / url', async () => {
|
||
const { loadPoster } = loadUseCardQrcode();
|
||
const poster = buildPoster();
|
||
const fetchCardQrcodeSvg = jest.fn(
|
||
async (slugOrId: string): Promise<CardQrcodePoster> => {
|
||
expect(slugOrId).toBe('alex-vance');
|
||
return poster;
|
||
}
|
||
);
|
||
|
||
const result = await loadPoster('alex-vance', { fetchCardQrcodeSvg });
|
||
|
||
expect(fetchCardQrcodeSvg).toHaveBeenCalledTimes(1);
|
||
expect(fetchCardQrcodeSvg).toHaveBeenCalledWith('alex-vance');
|
||
expect(result.svg).toBe(poster.svg);
|
||
expect(result.url).toBe(poster.url);
|
||
expect(result.empty).toBe(false);
|
||
expect(result.errorMessage).toBe('');
|
||
});
|
||
|
||
it('2.2 空 slugOrId 不请求接口,标记空态', async () => {
|
||
const { loadPoster } = loadUseCardQrcode();
|
||
const fetchCardQrcodeSvg = jest.fn(async () => buildPoster());
|
||
|
||
const result = await loadPoster(' ', { fetchCardQrcodeSvg });
|
||
|
||
expect(fetchCardQrcodeSvg).not.toHaveBeenCalled();
|
||
expect(result.svg).toBe('');
|
||
expect(result.url).toBe('');
|
||
expect(result.empty).toBe(true);
|
||
expect(result.errorMessage).toBe('');
|
||
});
|
||
|
||
it('2.3 接口失败写入错误,不伪装空态成功', async () => {
|
||
const { loadPoster } = loadUseCardQrcode();
|
||
const fetchCardQrcodeSvg = jest.fn(async (): Promise<CardQrcodePoster> => {
|
||
throw new Error('qrcode svg unavailable');
|
||
});
|
||
|
||
const result = await loadPoster('alex-vance', { fetchCardQrcodeSvg });
|
||
|
||
expect(fetchCardQrcodeSvg).toHaveBeenCalledTimes(1);
|
||
expect(result.svg).toBe('');
|
||
expect(result.url).toBe('');
|
||
expect(result.empty).toBe(false);
|
||
expect(result.errorMessage).toBe('qrcode svg unavailable');
|
||
});
|
||
|
||
it('2.4 svg 为空时保留空串,不把 url 填进 svg', async () => {
|
||
const { loadPoster } = loadUseCardQrcode();
|
||
const fetchCardQrcodeSvg = jest.fn(async (): Promise<CardQrcodePoster> =>
|
||
buildPoster({
|
||
svg: '',
|
||
url: 'https://omnicard.ai/c/alex-vance',
|
||
})
|
||
);
|
||
|
||
const result = await loadPoster('alex-vance', { fetchCardQrcodeSvg });
|
||
|
||
expect(result.svg).toBe('');
|
||
expect(result.url).toBe('https://omnicard.ai/c/alex-vance');
|
||
expect(result.empty).toBe(false);
|
||
expect(result.errorMessage).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('3. copyPosterUrl 复制短链', () => {
|
||
it('3.1 调用 uni.setClipboardData 并提示已复制', () => {
|
||
const { copyPosterUrl } = loadUseCardQrcode();
|
||
const ok = copyPosterUrl('https://omnicard.ai/c/alex-vance');
|
||
|
||
expect(ok).toBe(true);
|
||
expect(clipboardText).toBe('https://omnicard.ai/c/alex-vance');
|
||
expect(toastCalls.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('3.2 空 url 不写剪贴板', () => {
|
||
const { copyPosterUrl } = loadUseCardQrcode();
|
||
const ok = copyPosterUrl(' ');
|
||
|
||
expect(ok).toBe(false);
|
||
expect(clipboardText).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('4. savePosterToAlbum 仅相册 API 成功才提示已保存', () => {
|
||
it('4.1 本地路径保存成功才 toast 已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum('/tmp/local-poster.png');
|
||
|
||
expect(ok).toBe(true);
|
||
expect(saveAlbumCalls).toEqual([{ filePath: '/tmp/local-poster.png' }]);
|
||
expect(
|
||
toastCalls.some(t => SAVE_SUCCESS_TOAST_RE.test(t.title))
|
||
).toBe(true);
|
||
});
|
||
|
||
it('4.2 空图源不调用相册 API,不 toast 已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum(' ');
|
||
|
||
expect(ok).toBe(false);
|
||
expect(saveAlbumCalls).toEqual([]);
|
||
assertNoSaveSuccessToast(toastCalls);
|
||
});
|
||
|
||
it('4.3 纯 SVG 标记不可直接入库,不 toast 已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum(
|
||
'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||
);
|
||
|
||
expect(ok).toBe(false);
|
||
expect(saveAlbumCalls).toEqual([]);
|
||
assertNoSaveSuccessToast(toastCalls);
|
||
});
|
||
|
||
it('4.4 用户拒绝授权失败不 toast 已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
saveAlbumMode = 'authDeny';
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum('/tmp/local-poster.png');
|
||
|
||
expect(ok).toBe(false);
|
||
expect(saveAlbumCalls.length).toBe(1);
|
||
assertNoSaveSuccessToast(toastCalls);
|
||
});
|
||
|
||
it('4.5 相册 API 失败不 toast 已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
saveAlbumMode = 'fail';
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum('/tmp/local-poster.png');
|
||
|
||
expect(ok).toBe(false);
|
||
expect(saveAlbumCalls.length).toBe(1);
|
||
assertNoSaveSuccessToast(toastCalls);
|
||
});
|
||
|
||
it('4.6 远程图下载无 tempFilePath 时不调用相册且不报已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
downloadMode = 'emptyPath';
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum(
|
||
'https://cdn.omnicard.ai/poster/alex.png'
|
||
);
|
||
|
||
expect(ok).toBe(false);
|
||
expect(lastDownloadedUrl).toBe('https://cdn.omnicard.ai/poster/alex.png');
|
||
expect(saveAlbumCalls).toEqual([]);
|
||
assertNoSaveSuccessToast(toastCalls);
|
||
});
|
||
|
||
it('4.7 远程图下载失败不 toast 已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
downloadMode = 'fail';
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum(
|
||
'https://cdn.omnicard.ai/poster/alex.png'
|
||
);
|
||
|
||
expect(ok).toBe(false);
|
||
expect(saveAlbumCalls).toEqual([]);
|
||
assertNoSaveSuccessToast(toastCalls);
|
||
});
|
||
|
||
it('4.8 远程图下载成功后再调相册,成功才提示已保存', async () => {
|
||
const { savePosterToAlbum } = loadUseCardQrcode();
|
||
toastCalls = [];
|
||
|
||
const ok = await savePosterToAlbum(
|
||
'https://cdn.omnicard.ai/poster/alex.png'
|
||
);
|
||
|
||
expect(ok).toBe(true);
|
||
expect(lastDownloadedUrl).toBe('https://cdn.omnicard.ai/poster/alex.png');
|
||
expect(saveAlbumCalls).toEqual([{ filePath: '/tmp/poster-qr.png' }]);
|
||
expect(
|
||
toastCalls.some(t => SAVE_SUCCESS_TOAST_RE.test(t.title))
|
||
).toBe(true);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('OmniCard AI 参展分享码海报页存在 (qrcode.uvue)', () => {
|
||
it('页面文件存在,展示短链与单一复制 CTA', () => {
|
||
expect(fs.existsSync(QRCODE_PAGE)).toBe(true);
|
||
expect(fs.existsSync(QRCODE_UTS)).toBe(true);
|
||
|
||
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
|
||
const combined = `${pageSource}\n${logicSource}`;
|
||
|
||
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
|
||
expect(pageSource.includes('getOmniText')).toBe(true);
|
||
expect(pageSource.includes('primary-cta-btn')).toBe(true);
|
||
expect(
|
||
combined.includes('fetchCardQrcodeSvg') || combined.includes('loadPoster')
|
||
).toBe(true);
|
||
expect(
|
||
combined.includes('resolveSlugOrId') || pageSource.includes('slug')
|
||
).toBe(true);
|
||
expect(
|
||
combined.includes('setClipboardData') || combined.includes('copyPosterUrl')
|
||
).toBe(true);
|
||
expect(pageSource.includes('state.url') || pageSource.includes('posterUrl')).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('12px')).toBe(true);
|
||
expect(pageSource.includes('16px')).toBe(true);
|
||
});
|
||
|
||
it('无 emoji、无 any,模板无硬编码中文主文案,仅一个主 CTA', () => {
|
||
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(QRCODE_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((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0);
|
||
expect((template.match(/<button/g) || []).length).toBe(1);
|
||
});
|
||
|
||
it('展台中间渲染真实 svg 图形:image 或 rich-text 绑定 state.svg', () => {
|
||
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
|
||
const template = pageSource.split('<script')[0];
|
||
const slotMatch = template.match(
|
||
/class="qrcode-slot"[\s\S]*?<\/view>\s*<!-- Micro Glow/
|
||
);
|
||
const slotBlock = slotMatch != null ? slotMatch[0] : '';
|
||
|
||
expect(logicSource.includes('fetchCardQrcodeSvg')).toBe(true);
|
||
expect(template.includes('class="qrcode-slot"')).toBe(true);
|
||
expect(template.includes('<image')).toBe(true);
|
||
expect(template.includes(':src="state.svg"')).toBe(true);
|
||
expect(template.includes('<rich-text')).toBe(true);
|
||
expect(template.includes(':nodes="state.svg"')).toBe(true);
|
||
expect(slotBlock.includes(':src="state.url"')).toBe(false);
|
||
expect(slotBlock.includes('{{ state.url }}')).toBe(false);
|
||
});
|
||
|
||
it('svg 为空或失败时显示空态提示,不画假码、不把 URL 当码图', () => {
|
||
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
|
||
const template = pageSource.split('<script')[0];
|
||
|
||
expect(template.includes('qrcode-slot-hint')).toBe(true);
|
||
expect(pageSource.includes('codeUnavailable')).toBe(true);
|
||
expect(pageSource.includes('codePending')).toBe(true);
|
||
expect(template.includes('qrcode-feedback') || pageSource.includes('empty')).toBe(
|
||
true
|
||
);
|
||
expect(logicSource.includes("svg: ''")).toBe(true);
|
||
expect(pageSource.includes('fake-qr')).toBe(false);
|
||
expect(pageSource.includes('decorative-qr')).toBe(false);
|
||
expect(pageSource.includes('placeholder-qr')).toBe(false);
|
||
expect(/:src="state\.url"/.test(template)).toBe(false);
|
||
});
|
||
|
||
it('主 CTA 走 savePosterToAlbum,成功依赖 saveImageToPhotosAlbum,失败路径无已保存文案', () => {
|
||
const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8');
|
||
const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8');
|
||
|
||
expect(logicSource.includes('savePosterToAlbum')).toBe(true);
|
||
expect(logicSource.includes('saveImageToPhotosAlbum')).toBe(true);
|
||
expect(pageSource.includes('savePosterToAlbum')).toBe(true);
|
||
expect(pageSource.includes('handlePrimaryCta')).toBe(true);
|
||
expect(logicSource.includes("resolveQrcodeText('saveSuccess')")).toBe(true);
|
||
expect(logicSource.includes("resolveQrcodeText('saveFailed')")).toBe(true);
|
||
expect(logicSource.includes("resolveQrcodeText('saveUnavailable')")).toBe(
|
||
true
|
||
);
|
||
|
||
const successIdx = logicSource.indexOf("resolveQrcodeText('saveSuccess')");
|
||
const albumIdx = logicSource.indexOf('uni.saveImageToPhotosAlbum');
|
||
expect(albumIdx).toBeGreaterThan(-1);
|
||
expect(successIdx).toBeGreaterThan(albumIdx);
|
||
|
||
const albumFailIdx = logicSource.indexOf('fail:', albumIdx);
|
||
expect(albumFailIdx).toBeGreaterThan(successIdx);
|
||
const albumFailBody = logicSource.slice(albumFailIdx, albumFailIdx + 120);
|
||
expect(albumFailBody.includes('toastSaveFailed')).toBe(true);
|
||
expect(albumFailBody.includes('saveSuccess')).toBe(false);
|
||
expect(SAVE_SUCCESS_TOAST_RE.test(albumFailBody)).toBe(false);
|
||
|
||
const downloadIdx = logicSource.indexOf('uni.downloadFile');
|
||
expect(downloadIdx).toBeGreaterThan(-1);
|
||
const downloadFailIdx = logicSource.indexOf('fail:', downloadIdx);
|
||
expect(downloadFailIdx).toBeGreaterThan(downloadIdx);
|
||
const downloadFailBody = logicSource.slice(
|
||
downloadFailIdx,
|
||
downloadFailIdx + 80
|
||
);
|
||
expect(downloadFailBody.includes('saveSuccess')).toBe(false);
|
||
expect(SAVE_SUCCESS_TOAST_RE.test(downloadFailBody)).toBe(false);
|
||
});
|
||
});
|