/** * 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; }; 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; copyPosterUrl: (url: string | null | undefined) => boolean; savePosterToAlbum: ( imageSource: string | null | undefined ) => Promise; }; 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 { return { 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 }).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 }).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 => { 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 => { 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 => 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( '' ); 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(' { const pageSource = fs.readFileSync(QRCODE_PAGE, 'utf8'); const logicSource = fs.readFileSync(QRCODE_UTS, 'utf8'); const template = pageSource.split('\s*