241 行
9.2 KiB
TypeScript
241 行
9.2 KiB
TypeScript
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');
|
|
});
|
|
});
|