文件
OmniCard/admin/tests/card-list.test.ts

257 行
10 KiB
TypeScript

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'),
'各列必须显式配置合理宽度防止挤压折行'
);
});