feat: 完成循环1审查补齐 - RFC 6350 vCard引擎、全量自动化单测集成与根目录gitignore规范
这个提交包含在:
33
.gitignore
vendored
普通文件
33
.gitignore
vendored
普通文件
@@ -0,0 +1,33 @@
|
||||
# 依赖与打包产物
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
build/
|
||||
**/build/
|
||||
.temp/
|
||||
.cache/
|
||||
|
||||
# 环境与本地配置
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 日志与临时文件
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
*.log
|
||||
|
||||
# 编辑器与系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9286
admin/pnpm-lock.yaml
自动生成的
普通文件
9286
admin/pnpm-lock.yaml
自动生成的
普通文件
文件差异内容过多而无法显示
加载差异
419
backend/src/comm/vcard/vcard.ts
普通文件
419
backend/src/comm/vcard/vcard.ts
普通文件
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - RFC 6350 国际通用标准 vCard 3.0/4.0 编解码与生成引擎
|
||||
* 遵循 RFC 6350 (vCard 4.0) 与 RFC 2426 (vCard 3.0) 国际标准
|
||||
* 零外部依赖,纯 TypeScript 高效实现
|
||||
*/
|
||||
|
||||
export interface VCardName {
|
||||
familyName?: string;
|
||||
givenName?: string;
|
||||
additionalNames?: string;
|
||||
honorificPrefixes?: string;
|
||||
honorificSuffixes?: string;
|
||||
}
|
||||
|
||||
export interface VCardAddress {
|
||||
postOfficeBox?: string;
|
||||
extendedAddress?: string;
|
||||
street?: string;
|
||||
locality?: string;
|
||||
region?: string;
|
||||
postalCode?: string;
|
||||
countryName?: string;
|
||||
}
|
||||
|
||||
export interface VCardTelephone {
|
||||
number: string;
|
||||
type?: 'WORK' | 'CELL' | 'HOME' | 'MAIN' | 'OTHER' | string;
|
||||
}
|
||||
|
||||
export interface VCardEmail {
|
||||
address: string;
|
||||
type?: 'WORK' | 'HOME' | 'OTHER' | string;
|
||||
}
|
||||
|
||||
export interface VCardSocialProfile {
|
||||
type: 'linkedin' | 'whatsapp' | 'telegram' | 'twitter' | 'wechat' | string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface VCardPhoto {
|
||||
url?: string;
|
||||
base64?: string;
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
export interface VCardData {
|
||||
fn: string;
|
||||
name?: VCardName;
|
||||
title?: string;
|
||||
org?: string | string[];
|
||||
tel?: string | VCardTelephone | (string | VCardTelephone)[];
|
||||
email?: string | VCardEmail | (string | VCardEmail)[];
|
||||
url?: string | string[];
|
||||
adr?: VCardAddress | VCardAddress[];
|
||||
note?: string;
|
||||
socialProfiles?: VCardSocialProfile[];
|
||||
photo?: string | VCardPhoto;
|
||||
}
|
||||
|
||||
export type VCardVersion = '3.0' | '4.0';
|
||||
|
||||
/**
|
||||
* 转义 RFC 6350 TEXT 字段值中的保留特殊字符
|
||||
* 字符顺序:先转义反斜杠,再转义分号、逗号和换行
|
||||
*/
|
||||
export function escapeText(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转义 RFC 6350 TEXT 字段值
|
||||
*/
|
||||
export function unescapeText(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\n/gi, '\n')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格执行 RFC 6350 第 3.2 节长行折叠 (Line Folding) 规范
|
||||
* 每一物理行不超过 75 字符,超长在 75 处插入 CRLF 及一个前置空格进行折叠
|
||||
*/
|
||||
export function foldLine(line: string, maxLen: number = 75): string {
|
||||
if (line.length <= maxLen) return line;
|
||||
|
||||
let result = line.slice(0, maxLen);
|
||||
let remaining = line.slice(maxLen);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// 后续行因为开头有 1 个空格作为折叠标记,故内容取 maxLen - 1 字符
|
||||
result += '\r\n ' + remaining.slice(0, maxLen - 1);
|
||||
remaining = remaining.slice(maxLen - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6350 反折叠 (Line Unfolding):移除所有 CRLF 或 LF 后紧跟的空格/制表符
|
||||
*/
|
||||
export function unfoldLines(vcardString: string): string {
|
||||
return vcardString.replace(/\r?\n[ \t]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RFC 6350 / RFC 2426 合规的 vCard 字符串
|
||||
* @param data 电子名片结构化数据
|
||||
* @param version 版本,默认 '4.0'
|
||||
*/
|
||||
export function generateVCard(data: VCardData, version: VCardVersion = '4.0'): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// 1. 头部标记
|
||||
lines.push('BEGIN:VCARD');
|
||||
lines.push(`VERSION:${version}`);
|
||||
|
||||
// 2. 全名 (FN) - RFC 6350 强制要求
|
||||
lines.push(`FN:${escapeText(data.fn)}`);
|
||||
|
||||
// 3. 结构化姓名 (N:Family;Given;Additional;Prefix;Suffix)
|
||||
if (data.name) {
|
||||
const nParts = [
|
||||
escapeText(data.name.familyName || ''),
|
||||
escapeText(data.name.givenName || ''),
|
||||
escapeText(data.name.additionalNames || ''),
|
||||
escapeText(data.name.honorificPrefixes || ''),
|
||||
escapeText(data.name.honorificSuffixes || ''),
|
||||
];
|
||||
lines.push(`N:${nParts.join(';')}`);
|
||||
}
|
||||
|
||||
// 4. 头衔 (TITLE)
|
||||
if (data.title) {
|
||||
lines.push(`TITLE:${escapeText(data.title)}`);
|
||||
}
|
||||
|
||||
// 5. 组织机构 (ORG)
|
||||
if (data.org) {
|
||||
if (Array.isArray(data.org)) {
|
||||
lines.push(`ORG:${data.org.map((o) => escapeText(o)).join(';')}`);
|
||||
} else {
|
||||
lines.push(`ORG:${escapeText(data.org)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 电话 (TEL)
|
||||
if (data.tel) {
|
||||
const tels = Array.isArray(data.tel) ? data.tel : [data.tel];
|
||||
for (const t of tels) {
|
||||
const num = typeof t === 'string' ? t : t.number;
|
||||
const rawType = typeof t === 'string' ? 'CELL' : t.type || 'CELL';
|
||||
const type = version === '4.0' ? rawType.toUpperCase() : rawType.toUpperCase();
|
||||
lines.push(`TEL;TYPE=${type}:${num}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 邮箱 (EMAIL)
|
||||
if (data.email) {
|
||||
const emails = Array.isArray(data.email) ? data.email : [data.email];
|
||||
for (const e of emails) {
|
||||
const addr = typeof e === 'string' ? e : e.address;
|
||||
const rawType = typeof e === 'string' ? 'WORK' : e.type || 'WORK';
|
||||
const type = version === '3.0' ? `INTERNET,${rawType.toUpperCase()}` : rawType.toUpperCase();
|
||||
lines.push(`EMAIL;TYPE=${type}:${addr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 网址 / 移动微官网 (URL)
|
||||
if (data.url) {
|
||||
const urls = Array.isArray(data.url) ? data.url : [data.url];
|
||||
for (const u of urls) {
|
||||
lines.push(`URL:${u}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 备注 (NOTE)
|
||||
if (data.note) {
|
||||
lines.push(`NOTE:${escapeText(data.note)}`);
|
||||
}
|
||||
|
||||
// 10. 地址 (ADR)
|
||||
if (data.adr) {
|
||||
const adrs = Array.isArray(data.adr) ? data.adr : [data.adr];
|
||||
for (const a of adrs) {
|
||||
const adrParts = [
|
||||
escapeText(a.postOfficeBox || ''),
|
||||
escapeText(a.extendedAddress || ''),
|
||||
escapeText(a.street || ''),
|
||||
escapeText(a.locality || ''),
|
||||
escapeText(a.region || ''),
|
||||
escapeText(a.postalCode || ''),
|
||||
escapeText(a.countryName || ''),
|
||||
];
|
||||
lines.push(`ADR;TYPE=WORK:${adrParts.join(';')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 社交网络主页 (X-SOCIALPROFILE)
|
||||
if (data.socialProfiles && data.socialProfiles.length > 0) {
|
||||
for (const sp of data.socialProfiles) {
|
||||
lines.push(`X-SOCIALPROFILE;TYPE=${sp.type.toLowerCase()}:${sp.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 头像照片 (PHOTO)
|
||||
if (data.photo) {
|
||||
let photoUrl: string | undefined;
|
||||
let photoBase64: string | undefined;
|
||||
let mediaType = 'image/jpeg';
|
||||
|
||||
if (typeof data.photo === 'string') {
|
||||
if (data.photo.startsWith('http://') || data.photo.startsWith('https://')) {
|
||||
photoUrl = data.photo;
|
||||
} else if (data.photo.startsWith('data:')) {
|
||||
const match = data.photo.match(/^data:([^;]+);base64,(.+)$/i);
|
||||
if (match) {
|
||||
mediaType = match[1];
|
||||
photoBase64 = match[2];
|
||||
} else {
|
||||
photoBase64 = data.photo;
|
||||
}
|
||||
} else {
|
||||
photoBase64 = data.photo;
|
||||
}
|
||||
} else {
|
||||
photoUrl = data.photo.url;
|
||||
photoBase64 = data.photo.base64;
|
||||
if (data.photo.mediaType) {
|
||||
mediaType = data.photo.mediaType;
|
||||
}
|
||||
}
|
||||
|
||||
if (photoUrl) {
|
||||
if (version === '3.0') {
|
||||
lines.push(`PHOTO;VALUE=URI:${photoUrl}`);
|
||||
} else {
|
||||
lines.push(`PHOTO:${photoUrl}`);
|
||||
}
|
||||
} else if (photoBase64) {
|
||||
// 清洗 Base64 内部可能包含的空白换行
|
||||
const cleanBase64 = photoBase64.replace(/\s+/g, '');
|
||||
const typeShort = mediaType.split('/')[1]?.toUpperCase() || 'JPEG';
|
||||
|
||||
if (version === '3.0') {
|
||||
lines.push(`PHOTO;ENCODING=b;TYPE=${typeShort}:${cleanBase64}`);
|
||||
} else {
|
||||
lines.push(`PHOTO:data:${mediaType};base64,${cleanBase64}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 13. 尾部标记
|
||||
lines.push('END:VCARD');
|
||||
|
||||
// 对每一行应用 RFC 6350 行折叠,以 CRLF 拼接并以 CRLF 结尾
|
||||
return lines.map((l) => foldLine(l)).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并解析 vCard 字符串为 VCardData 结构
|
||||
* @param vcardString 待解析的原始 vCard 字符串
|
||||
*/
|
||||
export function parseVCard(vcardString: string): Partial<VCardData> {
|
||||
if (!vcardString || !vcardString.includes('BEGIN:VCARD') || !vcardString.includes('END:VCARD')) {
|
||||
throw new Error('Invalid vCard: missing BEGIN:VCARD or END:VCARD');
|
||||
}
|
||||
|
||||
// 第一步:反折叠
|
||||
const unfolded = unfoldLines(vcardString);
|
||||
const rawLines = unfolded.split(/\r?\n/);
|
||||
|
||||
const result: Partial<VCardData> = {};
|
||||
const tels: VCardTelephone[] = [];
|
||||
const emails: VCardEmail[] = [];
|
||||
const urls: string[] = [];
|
||||
const socialProfiles: VCardSocialProfile[] = [];
|
||||
|
||||
for (const rawLine of rawLines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line === 'BEGIN:VCARD' || line === 'END:VCARD' || line.startsWith('VERSION:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const head = line.slice(0, colonIdx);
|
||||
const value = line.slice(colonIdx + 1);
|
||||
|
||||
const headParts = head.split(';');
|
||||
const tag = headParts[0].toUpperCase();
|
||||
const params = headParts.slice(1);
|
||||
|
||||
// 辅助解析 TYPE 参数
|
||||
let typeParam = '';
|
||||
for (const p of params) {
|
||||
const m = p.match(/^TYPE=(.+)$/i);
|
||||
if (m) {
|
||||
// 过滤 INTERNET 等前缀
|
||||
const types = m[1].split(',').map((t) => t.trim().replace(/^"|"$/g, ''));
|
||||
const nonInternet = types.filter((t) => t.toUpperCase() !== 'INTERNET');
|
||||
typeParam = nonInternet.length > 0 ? nonInternet[0] : types[0];
|
||||
}
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case 'FN': {
|
||||
result.fn = unescapeText(value);
|
||||
break;
|
||||
}
|
||||
case 'N': {
|
||||
const parts = value.split(';');
|
||||
const name: VCardName = {};
|
||||
if (parts[0]) name.familyName = unescapeText(parts[0]);
|
||||
if (parts[1]) name.givenName = unescapeText(parts[1]);
|
||||
if (parts[2]) name.additionalNames = unescapeText(parts[2]);
|
||||
if (parts[3]) name.honorificPrefixes = unescapeText(parts[3]);
|
||||
if (parts[4]) name.honorificSuffixes = unescapeText(parts[4]);
|
||||
result.name = name;
|
||||
break;
|
||||
}
|
||||
case 'TITLE': {
|
||||
result.title = unescapeText(value);
|
||||
break;
|
||||
}
|
||||
case 'ORG': {
|
||||
const orgParts = value.split(';').map((p) => unescapeText(p));
|
||||
result.org = orgParts.length > 1 ? orgParts : orgParts[0];
|
||||
break;
|
||||
}
|
||||
case 'TEL': {
|
||||
tels.push({
|
||||
number: value,
|
||||
type: typeParam ? typeParam.toUpperCase() : 'CELL',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'EMAIL': {
|
||||
emails.push({
|
||||
address: value,
|
||||
type: typeParam ? typeParam.toUpperCase() : 'WORK',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'URL': {
|
||||
urls.push(value);
|
||||
break;
|
||||
}
|
||||
case 'NOTE': {
|
||||
result.note = unescapeText(value);
|
||||
break;
|
||||
}
|
||||
case 'ADR': {
|
||||
const parts = value.split(';').map((p) => unescapeText(p));
|
||||
// RFC 6350: [PO Box, Extended, Street, Locality, Region, PostalCode, Country]
|
||||
const adr: VCardAddress = {};
|
||||
if (parts[0]) adr.postOfficeBox = parts[0];
|
||||
if (parts[1]) adr.extendedAddress = parts[1];
|
||||
if (parts[2]) adr.street = parts[2];
|
||||
if (parts[3]) adr.locality = parts[3];
|
||||
if (parts[4]) adr.region = parts[4];
|
||||
if (parts[5]) adr.postalCode = parts[5];
|
||||
if (parts[6]) adr.countryName = parts[6];
|
||||
result.adr = adr;
|
||||
break;
|
||||
}
|
||||
case 'X-SOCIALPROFILE': {
|
||||
socialProfiles.push({
|
||||
type: typeParam.toLowerCase() || 'other',
|
||||
url: value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'PHOTO': {
|
||||
// 判断是 Data URI 还是普通 URL 还是 3.0 Base64
|
||||
if (value.startsWith('data:')) {
|
||||
const m = value.match(/^data:([^;]+);base64,(.+)$/i);
|
||||
if (m) {
|
||||
result.photo = {
|
||||
mediaType: m[1],
|
||||
base64: m[2],
|
||||
};
|
||||
} else {
|
||||
result.photo = { url: value };
|
||||
}
|
||||
} else if (params.some((p) => /ENCODING=b/i.test(p) || /BASE64/i.test(p))) {
|
||||
let mediaType = 'image/jpeg';
|
||||
for (const p of params) {
|
||||
const tm = p.match(/TYPE=([^;]+)/i);
|
||||
if (tm) {
|
||||
mediaType = `image/${tm[1].toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
result.photo = {
|
||||
mediaType,
|
||||
base64: value,
|
||||
};
|
||||
} else {
|
||||
result.photo = { url: value };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tels.length > 0) result.tel = tels;
|
||||
if (emails.length > 0) result.email = emails;
|
||||
if (urls.length > 0) result.url = urls.length === 1 ? urls[0] : urls;
|
||||
if (socialProfiles.length > 0) result.socialProfiles = socialProfiles;
|
||||
|
||||
return result;
|
||||
}
|
||||
240
backend/test/comm/vcard.test.ts
普通文件
240
backend/test/comm/vcard.test.ts
普通文件
@@ -0,0 +1,240 @@
|
||||
import * as assert from 'node:assert';
|
||||
import {
|
||||
generateVCard,
|
||||
parseVCard,
|
||||
type VCardData,
|
||||
} from '../../src/comm/vcard/vcard';
|
||||
|
||||
describe('RFC 6350 vCard 3.0/4.0 编解码与生成引擎', () => {
|
||||
const fullMockData: VCardData = {
|
||||
fn: 'Alex Morgan',
|
||||
name: {
|
||||
familyName: 'Morgan',
|
||||
givenName: 'Alex',
|
||||
additionalNames: 'J',
|
||||
honorificPrefixes: 'Dr.',
|
||||
honorificSuffixes: 'Ph.D.',
|
||||
},
|
||||
title: 'Chief Technology Officer',
|
||||
org: ['OmniCard AI Global Inc.', 'R&D Department'],
|
||||
tel: [
|
||||
{ number: '+1-555-0199', type: 'WORK' },
|
||||
{ number: '+86-13800138000', type: 'CELL' },
|
||||
],
|
||||
email: [
|
||||
{ address: 'alex.morgan@omnicard.ai', type: 'WORK' },
|
||||
{ address: 'alex@personal.me', type: 'HOME' },
|
||||
],
|
||||
url: 'https://omnicard.ai/u/alex-morgan',
|
||||
note: 'Specialist in Global Cross-border SaaS; Speaks English, Chinese and French.\nFocuses on AI and NFC hardware integration.',
|
||||
adr: {
|
||||
street: '100 Innovation Boulevard, Suite 500',
|
||||
locality: 'San Francisco',
|
||||
region: 'CA',
|
||||
postalCode: '94107',
|
||||
countryName: 'USA',
|
||||
},
|
||||
socialProfiles: [
|
||||
{ type: 'linkedin', url: 'https://www.linkedin.com/in/alex-morgan' },
|
||||
{ type: 'whatsapp', url: 'https://wa.me/8613800138000' },
|
||||
],
|
||||
photo: {
|
||||
url: 'https://cdn.omnicard.ai/avatars/alex.jpg',
|
||||
},
|
||||
};
|
||||
|
||||
it('1. 必须生成符合规范的结构 (BEGIN/END, CRLF换行)', () => {
|
||||
const vcard = generateVCard(fullMockData, '4.0');
|
||||
|
||||
// 必须包含标准开始与结束标记
|
||||
assert.ok(vcard.startsWith('BEGIN:VCARD\r\n'));
|
||||
assert.ok(vcard.endsWith('END:VCARD\r\n'));
|
||||
|
||||
// 严格遵循 RFC 6350 换行规则:每行必须以 \r\n 结尾
|
||||
const lines = vcard.split('\r\n');
|
||||
// 去掉最后一个因以 \r\n 结尾分割出的空字符串
|
||||
const contentLines = lines.slice(0, -1);
|
||||
for (const line of contentLines) {
|
||||
assert.ok(!line.includes('\n'), '单行内不得含有未处理的原始 LF');
|
||||
assert.ok(!line.includes('\r'), '单行内不得含有未处理的原始 CR');
|
||||
}
|
||||
});
|
||||
|
||||
it('2. 严格遵循 RFC 6350 折叠规则 (每行物理长度不超过 75 字符,折叠行以空格开头)', () => {
|
||||
// 构造超长备注数据,触发折叠
|
||||
const longNoteData: VCardData = {
|
||||
fn: 'Zhang San',
|
||||
note: 'This is an exceptionally long biography and note designed specifically to test the RFC 6350 line folding mechanism. It should be wrapped into multiple physical lines without exceeding 75 characters per line, and unfolded back losslessly.',
|
||||
};
|
||||
|
||||
const vcard = generateVCard(longNoteData, '4.0');
|
||||
const physicalLines = vcard.split('\r\n').slice(0, -1);
|
||||
|
||||
// 检查每一物理行的字符数不超过 75
|
||||
for (const line of physicalLines) {
|
||||
assert.ok(
|
||||
line.length <= 75,
|
||||
`物理行超过了 75 字符限制 (${line.length} 字符): "${line}"`
|
||||
);
|
||||
}
|
||||
|
||||
// 检查存在折叠行(即以空格开头)
|
||||
const foldedLines = physicalLines.filter((l) => l.startsWith(' '));
|
||||
assert.ok(foldedLines.length > 0, '超长行必须触发折叠并以空格开头');
|
||||
|
||||
// 解析后能无损还原超长 NOTE
|
||||
const parsed = parseVCard(vcard);
|
||||
assert.strictEqual(parsed.note, longNoteData.note);
|
||||
});
|
||||
|
||||
it('3. 版本支持 (3.0 与 4.0 差异化属性生成与识别)', () => {
|
||||
const vcard4 = generateVCard(fullMockData, '4.0');
|
||||
assert.ok(vcard4.includes('VERSION:4.0\r\n'));
|
||||
|
||||
const vcard3 = generateVCard(fullMockData, '3.0');
|
||||
assert.ok(vcard3.includes('VERSION:3.0\r\n'));
|
||||
|
||||
// 3.0 的 PHOTO URI 格式含有 VALUE=URI
|
||||
assert.ok(vcard3.includes('PHOTO;VALUE=URI:https://cdn.omnicard.ai/avatars/alex.jpg'));
|
||||
// 4.0 的 PHOTO 直接输出 URI
|
||||
assert.ok(vcard4.includes('PHOTO:https://cdn.omnicard.ai/avatars/alex.jpg'));
|
||||
});
|
||||
|
||||
it('4. 支持 Base64 头像编码与格式化', () => {
|
||||
const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
const base64Data: VCardData = {
|
||||
fn: 'Base64 User',
|
||||
photo: {
|
||||
base64: rawBase64,
|
||||
mediaType: 'image/png',
|
||||
},
|
||||
};
|
||||
|
||||
// vCard 4.0 应该采用 data: URI 格式
|
||||
const vcard4 = generateVCard(base64Data, '4.0');
|
||||
assert.ok(vcard4.includes('PHOTO:data:image/png;base64,'));
|
||||
assert.ok(vcard4.includes(rawBase64.slice(0, 20)));
|
||||
|
||||
// vCard 3.0 应该采用 ENCODING=b;TYPE=PNG 格式
|
||||
const vcard3 = generateVCard(base64Data, '3.0');
|
||||
assert.ok(vcard3.includes('PHOTO;ENCODING=b;TYPE=PNG:'));
|
||||
|
||||
// Base64 头像行必须被折叠为每行不超过 75 字符
|
||||
const physicalLines = vcard4.split('\r\n').slice(0, -1);
|
||||
for (const line of physicalLines) {
|
||||
assert.ok(line.length <= 75, 'Base64 物理行不得超过 75 字符');
|
||||
}
|
||||
});
|
||||
|
||||
it('5. 特殊字符转义规则 (反斜杠, 分号, 逗号, 换行符)', () => {
|
||||
const specialData: VCardData = {
|
||||
fn: 'Special, Name; Dr.\\CEO',
|
||||
title: 'VP; R&D, Platform',
|
||||
note: 'Line 1: Special\\Escape\nLine 2: Semicolon; and Comma, here.',
|
||||
};
|
||||
|
||||
const vcard = generateVCard(specialData, '4.0');
|
||||
// 特殊字符在生成时被转义
|
||||
assert.ok(vcard.includes('\\;'));
|
||||
assert.ok(vcard.includes('\\,'));
|
||||
assert.ok(vcard.includes('\\\\'));
|
||||
assert.ok(vcard.includes('\\n'));
|
||||
|
||||
// 解析后反转义还原
|
||||
const parsed = parseVCard(vcard);
|
||||
assert.strictEqual(parsed.fn, specialData.fn);
|
||||
assert.strictEqual(parsed.title, specialData.title);
|
||||
assert.strictEqual(parsed.note, specialData.note);
|
||||
});
|
||||
|
||||
it('6. 包含全字段的可逆性验证 (Round-trip reversible)', () => {
|
||||
const vcard = generateVCard(fullMockData, '4.0');
|
||||
const parsed = parseVCard(vcard);
|
||||
|
||||
// 核心身份字段验证
|
||||
assert.strictEqual(parsed.fn, fullMockData.fn);
|
||||
assert.deepStrictEqual(parsed.name, fullMockData.name);
|
||||
assert.strictEqual(parsed.title, fullMockData.title);
|
||||
assert.deepStrictEqual(parsed.org, fullMockData.org);
|
||||
|
||||
// 联系方式
|
||||
assert.ok(Array.isArray(parsed.tel));
|
||||
assert.strictEqual((parsed.tel as any[])?.length, 2);
|
||||
assert.strictEqual((parsed.tel as any[])?.[0].number, '+1-555-0199');
|
||||
assert.strictEqual((parsed.tel as any[])?.[0].type, 'WORK');
|
||||
assert.strictEqual((parsed.tel as any[])?.[1].number, '+86-13800138000');
|
||||
assert.strictEqual((parsed.tel as any[])?.[1].type, 'CELL');
|
||||
|
||||
assert.ok(Array.isArray(parsed.email));
|
||||
assert.strictEqual((parsed.email as any[])?.length, 2);
|
||||
assert.strictEqual((parsed.email as any[])?.[0].address, 'alex.morgan@omnicard.ai');
|
||||
assert.strictEqual((parsed.email as any[])?.[0].type, 'WORK');
|
||||
|
||||
// 微官网 URL 与 备注
|
||||
assert.strictEqual(parsed.url, fullMockData.url);
|
||||
assert.strictEqual(parsed.note, fullMockData.note);
|
||||
|
||||
// 地址
|
||||
assert.deepStrictEqual(parsed.adr, fullMockData.adr);
|
||||
|
||||
// 社交网络 (LinkedIn, WhatsApp)
|
||||
assert.ok(Array.isArray(parsed.socialProfiles));
|
||||
assert.strictEqual(parsed.socialProfiles?.length, 2);
|
||||
assert.strictEqual(parsed.socialProfiles?.[0].type, 'linkedin');
|
||||
assert.strictEqual(parsed.socialProfiles?.[0].url, 'https://www.linkedin.com/in/alex-morgan');
|
||||
assert.strictEqual(parsed.socialProfiles?.[1].type, 'whatsapp');
|
||||
assert.strictEqual(parsed.socialProfiles?.[1].url, 'https://wa.me/8613800138000');
|
||||
|
||||
// 头像
|
||||
assert.deepStrictEqual(parsed.photo, fullMockData.photo);
|
||||
});
|
||||
|
||||
it('7. 极简与边界情况处理 (仅有 FN 最小名片、非法字符串校验)', () => {
|
||||
// 极简名片
|
||||
const minimalData: VCardData = { fn: 'Simple User' };
|
||||
const minimalVCard = generateVCard(minimalData);
|
||||
const parsedMinimal = parseVCard(minimalVCard);
|
||||
assert.strictEqual(parsedMinimal.fn, 'Simple User');
|
||||
|
||||
// 非法字符串
|
||||
assert.throws(() => {
|
||||
parseVCard('INVALID DATA WITHOUT BEGIN AND END');
|
||||
}, /Invalid vCard/);
|
||||
});
|
||||
|
||||
it('8. 中文国际化与字符串简写形式支持 (中文姓名、地址、直接字符串电话/头像URL)', () => {
|
||||
const chineseData: VCardData = {
|
||||
fn: '张小龙',
|
||||
name: {
|
||||
familyName: '张',
|
||||
givenName: '小龙',
|
||||
},
|
||||
title: '产品总监',
|
||||
org: '腾讯科技有限公司',
|
||||
tel: '+86-13900008888',
|
||||
email: 'zhang@tencent.com',
|
||||
url: 'https://omnicard.ai/u/zhang',
|
||||
adr: {
|
||||
locality: '深圳市',
|
||||
region: '广东省',
|
||||
countryName: '中国',
|
||||
},
|
||||
photo: 'https://cdn.omnicard.ai/avatars/zhang.png',
|
||||
};
|
||||
|
||||
const vcard = generateVCard(chineseData, '4.0');
|
||||
assert.ok(vcard.includes('FN:张小龙'));
|
||||
assert.ok(vcard.includes('ORG:腾讯科技有限公司'));
|
||||
assert.ok(vcard.includes('TEL;TYPE=CELL:+86-13900008888'));
|
||||
assert.ok(vcard.includes('EMAIL;TYPE=WORK:zhang@tencent.com'));
|
||||
|
||||
const parsed = parseVCard(vcard);
|
||||
assert.strictEqual(parsed.fn, '张小龙');
|
||||
assert.strictEqual(parsed.name?.familyName, '张');
|
||||
assert.strictEqual(parsed.name?.givenName, '小龙');
|
||||
assert.strictEqual(parsed.org, '腾讯科技有限公司');
|
||||
assert.strictEqual((parsed.adr as any)?.locality, '深圳市');
|
||||
assert.strictEqual((parsed.adr as any)?.countryName, '中国');
|
||||
assert.strictEqual((parsed.photo as any)?.url, 'https://cdn.omnicard.ai/avatars/zhang.png');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户