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

494 行
17 KiB
TypeScript

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

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

/**
* 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);
});
});