import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const listPagePath = path.join(adminRoot, 'src/pages/card/list/index.tsx'); const routesPath = path.join(adminRoot, 'src/routes.ts'); const localePath = path.join(adminRoot, 'src/locale/index.ts'); test('1. routes.ts 必须包含 key card/list,供 layout glob 加载', () => { const content = fs.readFileSync(routesPath, 'utf-8'); assert.ok(content.includes("key: 'card'"), '应存在一级菜单 key: card'); assert.ok(content.includes("key: 'card/list'"), '应存在路由 key: card/list'); assert.ok(content.includes("name: 'menu.card'"), '应存在 menu.card 分组'); assert.ok(content.includes("name: 'menu.card.list'"), 'card 分组下应有 menu.card.list'); for (const key of ['visualization', 'form', 'profile', 'user', 'result']) { assert.ok(!content.includes(`key: '${key}'`), `Arco Demo 路由必须移除: ${key}`); } }); test('2. locale 必须包含 menu.card / menu.card.list 中英文案', () => { const content = fs.readFileSync(localePath, 'utf-8'); assert.ok(content.includes("'menu.card': 'Cards'"), '英文 menu.card'); assert.ok(content.includes("'menu.card.list': 'All Cards'"), '英文 menu.card.list'); assert.ok(content.includes("'menu.card': '数字名片'"), '中文 menu.card'); assert.ok(content.includes("'menu.card.list': '名片资产库'"), '中文 menu.card.list'); }); test('3. list/index.tsx 必须请求 admin/card/info/page 并使用 Arco Table', () => { assert.ok(fs.existsSync(listPagePath), '名片资产库页面必须存在'); const content = fs.readFileSync(listPagePath, 'utf-8'); assert.ok( content.includes('admin/card/info/page'), '必须请求 POST /api/admin/card/info/page' ); assert.ok(content.includes('Table'), '必须使用 Arco Table'); assert.ok( content.includes("from '@arco-design/web-react'"), '必须从 @arco-design/web-react 引入组件' ); assert.ok(content.includes('code === 1000') || content.includes('code ===1000'), '必须按 Cool-Admin code === 1000 判断成功'); assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error'); assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止用 Mock.js 假数据冒充成功'); assert.ok(!/\bany\b/.test(content), '禁止使用 any'); }); test('4. list/index.tsx 必须支持复制短链与发布/撤回', () => { const content = fs.readFileSync(listPagePath, 'utf-8'); assert.ok( content.includes('/admin/card/info/update'), '必须请求 POST /api/admin/card/info/update' ); assert.ok(content.includes('published'), '发布必须提交 status: published'); assert.ok(content.includes('Popconfirm'), '发布必须使用 Arco Popconfirm'); assert.ok(content.includes('Message.success'), '复制短链成功必须 Message.success'); assert.ok( content.includes('https://card.omnicard.ai/'), '复制短链必须使用公开域名 https://card.omnicard.ai/{slug}' ); assert.ok(!/\bany\b/.test(content), '禁止使用 any'); }); test('5. list 必须有导出 vCard 动作:有接口则下载,无接口则禁用并 Message,禁止伪造 vcf', () => { const content = fs.readFileSync(listPagePath, 'utf-8'); const pageLocalePath = path.join(adminRoot, 'src/pages/card/list/locale/index.ts'); const localeContent = fs.readFileSync(pageLocalePath, 'utf-8'); assert.ok( content.includes("t['cardList.columns.exportVcard']") || content.includes('cardList.columns.exportVcard'), '操作列必须有导出 vCard 文案 key' ); assert.ok( localeContent.includes("'cardList.columns.exportVcard'"), 'locale 必须包含 cardList.columns.exportVcard' ); // 禁止前端拼假 vcf / 假装已下载 assert.ok(!content.includes('BEGIN:VCARD'), '禁止前端硬编码 BEGIN:VCARD'); assert.ok(!content.includes('END:VCARD'), '禁止前端硬编码 END:VCARD'); assert.ok(!content.includes('generateVCard'), '禁止在列表页直接调用 generateVCard 拼假内容'); const hasExportApi = /\/api\/(?:admin|open)\/card\/[^'"`\s]*vcard/i.test(content) || /card\/[^'"`\s]*\/vcard/i.test(content) || /VCARD_EXPORT_API|EXPORT_VCARD_API|vcardExport/i.test(content); if (hasExportApi) { assert.ok( content.includes('blob') || content.includes('responseType') || content.includes('download') || content.includes('createObjectURL'), '有导出接口时应下载返回的 vcf' ); assert.ok( !content.includes('disabled') || !/exportVcard[\s\S]{0,120}disabled/i.test(content), '有导出接口时导出按钮不得 disabled' ); assert.ok( localeContent.includes("'cardList.export.selectFirst'"), 'locale 必须包含 cardList.export.selectFirst' ); assert.ok( content.includes("t['cardList.export.selectFirst']") || content.includes('cardList.export.selectFirst'), '未选中时必须 Message 提示先选名片' ); assert.ok( content.includes('selectedRowKeys') || content.includes('rowSelection'), '必须支持选中一行后再导出' ); } else { assert.ok( localeContent.includes("'cardList.export.unavailable'"), 'locale 必须包含 cardList.export.unavailable' ); assert.ok(!content.includes('text/vcard'), '禁止前端伪造 text/vcard Blob'); assert.ok(!content.includes('text/x-vcard'), '禁止前端伪造 text/x-vcard Blob'); assert.ok(!/\.vcf['"`]/.test(content), '无真实接口时禁止假装下载 .vcf 文件名'); assert.ok(!content.includes('createObjectURL'), '无真实接口时禁止 createObjectURL 伪造下载'); assert.ok( content.includes('disabled') || content.includes('Disabled'), '无 vCard 导出接口时导出按钮必须禁用' ); assert.ok( content.includes("t['cardList.export.unavailable']") || content.includes('cardList.export.unavailable'), '无接口时必须 Message 说明暂不可导出' ); assert.ok( content.includes('Message.warning') || content.includes('Message.info') || content.includes('Message.error'), '无接口时必须通过 Message 提示不可导出' ); } }); test('6. list 必须支持下载选中名片 SVG 二维码:走 open/card/qrcode/svg,失败只提示不下载空文件', () => { const content = fs.readFileSync(listPagePath, 'utf-8'); const pageLocalePath = path.join(adminRoot, 'src/pages/card/list/locale/index.ts'); const localeContent = fs.readFileSync(pageLocalePath, 'utf-8'); assert.ok( content.includes('/open/card/qrcode/svg') || content.includes('open/card/qrcode/svg'), '必须请求已有 GET /open/card/qrcode/svg' ); assert.ok( /QRCODE_SVG|SVG_QR|downloadQr|qrcode\/svg/i.test(content), '必须有 SVG 二维码下载相关常量或处理逻辑' ); assert.ok( content.includes("t['cardList.columns.downloadQrSvg']") || content.includes('cardList.columns.downloadQrSvg'), '操作列必须有下载 SVG 二维码文案 key' ); assert.ok( localeContent.includes("'cardList.columns.downloadQrSvg'"), 'locale 必须包含 cardList.columns.downloadQrSvg' ); assert.ok( localeContent.includes("'cardList.qr.selectFirst'"), 'locale 必须包含 cardList.qr.selectFirst' ); assert.ok( localeContent.includes("'cardList.qr.success'"), 'locale 必须包含 cardList.qr.success' ); assert.ok( localeContent.includes("'cardList.qr.fail'"), 'locale 必须包含 cardList.qr.fail' ); assert.ok( content.includes("t['cardList.qr.selectFirst']") || content.includes('cardList.qr.selectFirst'), '未选中时必须 Message 提示先选名片' ); assert.ok( content.includes("t['cardList.qr.fail']") || content.includes('cardList.qr.fail'), '接口失败或空 svg 时必须 Message 提示失败' ); assert.ok( content.includes('selectedRowKeys') || content.includes('rowSelection'), '必须支持选中一行后再下载二维码' ); assert.ok( content.includes('image/svg+xml') || content.includes('.svg') || content.includes('createObjectURL'), '成功时应下载接口返回的 SVG' ); // 空 svg / 失败时不得下载空文件:须校验 svg 内容存在 assert.ok( content.includes('payload.data?.svg') || content.includes('data?.svg') || content.includes('.svg?.trim'), '必须读取接口返回的 svg 字段' ); assert.ok( content.includes('!svg') || content.includes('!payload.data?.svg') || /code !== 1000[\s\S]{0,80}!svg|!svg[\s\S]{0,80}code !== 1000/.test( content ), '空 svg 或非成功码时不得下载,须校验后 Message' ); assert.ok( !/new Blob\(\s*\[\s*['"`]\s*['"`]\s*\]/.test(content), '禁止用空字符串 Blob 假装下载' ); // 禁止伪造二维码 / 假 PNG / 假 PDF assert.ok(!content.includes('generateQr'), '禁止前端自行 generateQr 拼假二维码'); assert.ok(!content.includes('qrcode.toDataURL'), '禁止前端库生成假二维码'); assert.ok(!content.includes('QRCode.toString'), '禁止前端库生成假 SVG'); assert.ok( !/\/open\/card\/qrcode\/png/i.test(content) && !/qrcode\/png/i.test(content), '无产品需求时不要提供 PNG 下载入口' ); assert.ok( !/\.pdf['"`]/i.test(content) && !/application\/pdf/i.test(content), '无 PDF 接口时禁止提供 PDF 下载' ); assert.ok(!/image\/png/i.test(content), '不得伪造 PNG Blob 冒充二维码下载'); }); test('7. list 表格必须包含固定列宽、横向滚动与防折行配置 (nowrap),严禁单元格折行', () => { const content = fs.readFileSync(listPagePath, 'utf-8'); assert.ok( content.includes('scroll={{ x:') || content.includes('scroll={{') || content.includes('scroll='), 'Table 必须配置 scroll 横向滚动防折行' ); assert.ok( content.includes('white-space: nowrap') || content.includes("whiteSpace: 'nowrap'"), 'Table 必须配置 white-space: nowrap 防折行' ); assert.ok( content.includes("fixed: 'right'"), '操作列必须配置 fixed: right 固定右侧' ); assert.ok( content.includes('width: 170') || content.includes('width: 240'), '各列必须显式配置合理宽度防止挤压折行' ); });