- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
411 行
14 KiB
TypeScript
411 行
14 KiB
TypeScript
/**
|
|
* OmniCard AI (CRMmp) - 名片主个人中心与消息通知配置测试套件 (TDD Red -> Green)
|
|
*
|
|
* 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/profile 设计编写
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const PROFILE_PAGE = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages/owner/profile/index.uvue'
|
|
);
|
|
const PROFILE_UTS = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages/owner/profile/useProfile.uts'
|
|
);
|
|
const PAGES_JSON = path.resolve(
|
|
__dirname,
|
|
'../../../mpuniapp/pages.json'
|
|
);
|
|
|
|
type OmniLocale =
|
|
| 'zh-CN'
|
|
| 'en-US'
|
|
| 'ar-SA'
|
|
| 'es-ES'
|
|
| 'de-DE'
|
|
| 'fr-FR'
|
|
| 'ja-JP';
|
|
|
|
type UserProfileAccount = {
|
|
name: string;
|
|
company: string;
|
|
department: string;
|
|
avatar: string;
|
|
};
|
|
|
|
type NotificationSettings = {
|
|
radarPush: boolean;
|
|
leadNotice: boolean;
|
|
};
|
|
|
|
type ProfileState = {
|
|
account: UserProfileAccount;
|
|
notifications: NotificationSettings;
|
|
locale: OmniLocale;
|
|
saving: boolean;
|
|
loading: boolean;
|
|
errorMessage: string;
|
|
};
|
|
|
|
type StorageApi = {
|
|
getStorageSync: (key: string) => unknown;
|
|
setStorageSync: (key: string, value: unknown) => void;
|
|
};
|
|
|
|
type UseProfileModule = {
|
|
PROFILE_ACCOUNT_STORAGE_KEY: string;
|
|
PROFILE_NOTIFICATIONS_STORAGE_KEY: string;
|
|
PORTAL_LOCALE_STORAGE_KEY: string;
|
|
getDefaultAccount: () => UserProfileAccount;
|
|
getDefaultNotifications: () => NotificationSettings;
|
|
createProfileState: (locale?: OmniLocale) => ProfileState;
|
|
loadProfileSettings: (storage?: StorageApi) => ProfileState;
|
|
saveProfileSettings: (state: ProfileState, storage?: StorageApi) => boolean;
|
|
updateAccount: (state: ProfileState, patch: Partial<UserProfileAccount>) => void;
|
|
toggleRadarPush: (state: ProfileState, value?: boolean, storage?: StorageApi) => boolean;
|
|
toggleLeadNotice: (state: ProfileState, value?: boolean, storage?: StorageApi) => boolean;
|
|
setPreferredLocale: (state: ProfileState, locale: string, storage?: StorageApi) => OmniLocale;
|
|
resolveProfileText: (key: string) => string;
|
|
};
|
|
|
|
const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/;
|
|
|
|
function loadUseProfile(): UseProfileModule {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
return require(PROFILE_UTS) as UseProfileModule;
|
|
}
|
|
|
|
describe('OmniCard AI 名片主个人中心与消息通知配置 (useProfile.uts)', () => {
|
|
let mockStorage: Record<string, unknown> = {};
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
mockStorage = {};
|
|
(global as { uni?: Record<string, unknown> }).uni = {
|
|
getLocale: () => 'zh-CN',
|
|
showToast: jest.fn(),
|
|
navigateTo: jest.fn(),
|
|
navigateBack: jest.fn(),
|
|
getStorageSync: jest.fn((key: string) => mockStorage[key] ?? ''),
|
|
setStorageSync: jest.fn((key: string, val: unknown) => {
|
|
mockStorage[key] = val;
|
|
}),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (global as { uni?: Record<string, unknown> }).uni;
|
|
});
|
|
|
|
describe('1. 配置读取与初始化 (loadProfileSettings)', () => {
|
|
it('1.1 首次无缓存时,返回默认账户资料与通知开关开启', () => {
|
|
const { loadProfileSettings } = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
|
|
expect(state.account.name).toBeTruthy();
|
|
expect(state.account.company).toBeTruthy();
|
|
expect(state.account.department).toBeTruthy();
|
|
expect(state.account.avatar).toBeTruthy();
|
|
expect(state.notifications.radarPush).toBe(true);
|
|
expect(state.notifications.leadNotice).toBe(true);
|
|
expect(state.locale).toBe('zh-CN');
|
|
expect(state.errorMessage).toBe('');
|
|
});
|
|
|
|
it('1.2 本地有缓存时,正确恢复已保存的账户与通知开关', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
PROFILE_ACCOUNT_STORAGE_KEY,
|
|
PROFILE_NOTIFICATIONS_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
|
|
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = JSON.stringify({
|
|
name: 'Sarah Connor',
|
|
company: 'Cyberdyne Systems',
|
|
department: 'Security Division',
|
|
avatar: 'https://example.com/sarah.jpg',
|
|
});
|
|
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = JSON.stringify({
|
|
radarPush: false,
|
|
leadNotice: true,
|
|
});
|
|
|
|
const state = loadProfileSettings();
|
|
expect(state.account.name).toBe('Sarah Connor');
|
|
expect(state.account.company).toBe('Cyberdyne Systems');
|
|
expect(state.account.department).toBe('Security Division');
|
|
expect(state.account.avatar).toBe('https://example.com/sarah.jpg');
|
|
expect(state.notifications.radarPush).toBe(false);
|
|
expect(state.notifications.leadNotice).toBe(true);
|
|
});
|
|
|
|
it('1.3 防御保护:缓存数据损坏时不抛错,优雅回退默认值', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
PROFILE_ACCOUNT_STORAGE_KEY,
|
|
PROFILE_NOTIFICATIONS_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
|
|
// 非法 JSON 字符串
|
|
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = '{invalid_json--';
|
|
// 错误的通知配置类型
|
|
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = 'not-an-object';
|
|
|
|
const state = loadProfileSettings();
|
|
expect(state.account.name).toBeTruthy();
|
|
expect(state.account.company).toBeTruthy();
|
|
expect(state.notifications.radarPush).toBe(true);
|
|
expect(state.notifications.leadNotice).toBe(true);
|
|
expect(state.errorMessage).toBe('');
|
|
});
|
|
|
|
it('1.4 防御保护:缓存字段部分缺失时自动补充缺省字段', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
PROFILE_ACCOUNT_STORAGE_KEY,
|
|
PROFILE_NOTIFICATIONS_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
|
|
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] = JSON.stringify({
|
|
name: 'John Doe',
|
|
});
|
|
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] = JSON.stringify({
|
|
radarPush: false,
|
|
});
|
|
|
|
const state = loadProfileSettings();
|
|
expect(state.account.name).toBe('John Doe');
|
|
expect(state.account.company).toBeTruthy();
|
|
expect(state.account.department).toBeTruthy();
|
|
expect(state.notifications.radarPush).toBe(false);
|
|
expect(state.notifications.leadNotice).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('2. 通知开关切换与持久化同步 (toggleRadarPush & toggleLeadNotice)', () => {
|
|
it('2.1 toggleRadarPush 支持反转与显式赋值,并同步写入存储', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
toggleRadarPush,
|
|
PROFILE_NOTIFICATIONS_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
expect(state.notifications.radarPush).toBe(true);
|
|
|
|
// 反转切换
|
|
const res1 = toggleRadarPush(state);
|
|
expect(res1).toBe(false);
|
|
expect(state.notifications.radarPush).toBe(false);
|
|
|
|
// 验证本地存储已同步
|
|
const rawStored1 = mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] as string;
|
|
const parsed1 = JSON.parse(rawStored1) as NotificationSettings;
|
|
expect(parsed1.radarPush).toBe(false);
|
|
|
|
// 显式赋值为 true
|
|
const res2 = toggleRadarPush(state, true);
|
|
expect(res2).toBe(true);
|
|
expect(state.notifications.radarPush).toBe(true);
|
|
|
|
const rawStored2 = mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] as string;
|
|
const parsed2 = JSON.parse(rawStored2) as NotificationSettings;
|
|
expect(parsed2.radarPush).toBe(true);
|
|
});
|
|
|
|
it('2.2 toggleLeadNotice 支持反转与显式赋值,并同步写入存储', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
toggleLeadNotice,
|
|
PROFILE_NOTIFICATIONS_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
expect(state.notifications.leadNotice).toBe(true);
|
|
|
|
const res1 = toggleLeadNotice(state, false);
|
|
expect(res1).toBe(false);
|
|
expect(state.notifications.leadNotice).toBe(false);
|
|
|
|
const parsed1 = JSON.parse(
|
|
mockStorage[PROFILE_NOTIFICATIONS_STORAGE_KEY] as string
|
|
) as NotificationSettings;
|
|
expect(parsed1.leadNotice).toBe(false);
|
|
|
|
const res2 = toggleLeadNotice(state);
|
|
expect(res2).toBe(true);
|
|
expect(state.notifications.leadNotice).toBe(true);
|
|
});
|
|
|
|
it('2.3 防御保护:存储写入异常时不崩溃,安全更新 state 并记录错误', () => {
|
|
const { loadProfileSettings, toggleRadarPush } = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
|
|
const faultyStorage: StorageApi = {
|
|
getStorageSync: jest.fn(),
|
|
setStorageSync: jest.fn(() => {
|
|
throw new Error('QuotaExceededError: LocalStorage limit reached');
|
|
}),
|
|
};
|
|
|
|
const result = toggleRadarPush(state, false, faultyStorage);
|
|
expect(result).toBe(false);
|
|
expect(state.notifications.radarPush).toBe(false);
|
|
expect(state.errorMessage).toContain('QuotaExceededError');
|
|
});
|
|
});
|
|
|
|
describe('3. 首选语言选择与记忆持久化 (setPreferredLocale)', () => {
|
|
it('3.1 切换首选语言,正确持久化并调用全局 setAppLocale', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
setPreferredLocale,
|
|
PORTAL_LOCALE_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
|
|
const nextLocale = setPreferredLocale(state, 'en-US');
|
|
expect(nextLocale).toBe('en-US');
|
|
expect(state.locale).toBe('en-US');
|
|
|
|
// 验证是否已记忆持久化到本地存储
|
|
const rawStored = mockStorage[PORTAL_LOCALE_STORAGE_KEY];
|
|
expect(rawStored).toBe('en-US');
|
|
});
|
|
|
|
it('3.2 防御保护:非法语言代码安全回退并归一化', () => {
|
|
const { loadProfileSettings, setPreferredLocale } = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
|
|
const safeLocale = setPreferredLocale(state, 'invalid_code');
|
|
expect(['zh-CN', 'en-US']).toContain(safeLocale);
|
|
expect(state.locale).toBe(safeLocale);
|
|
});
|
|
});
|
|
|
|
describe('4. 账户资料更新与防御保护 (updateAccount & saveProfileSettings)', () => {
|
|
it('4.1 更新名片主账户信息并持久化', () => {
|
|
const {
|
|
loadProfileSettings,
|
|
updateAccount,
|
|
saveProfileSettings,
|
|
PROFILE_ACCOUNT_STORAGE_KEY,
|
|
} = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
|
|
updateAccount(state, {
|
|
name: ' Elena Rostova ',
|
|
company: 'Global Trade Co.',
|
|
department: 'Marketing',
|
|
});
|
|
|
|
expect(state.account.name).toBe('Elena Rostova');
|
|
expect(state.account.company).toBe('Global Trade Co.');
|
|
expect(state.account.department).toBe('Marketing');
|
|
|
|
const saved = saveProfileSettings(state);
|
|
expect(saved).toBe(true);
|
|
|
|
const parsed = JSON.parse(
|
|
mockStorage[PROFILE_ACCOUNT_STORAGE_KEY] as string
|
|
) as UserProfileAccount;
|
|
expect(parsed.name).toBe('Elena Rostova');
|
|
expect(parsed.company).toBe('Global Trade Co.');
|
|
});
|
|
|
|
it('4.2 防御保护:空值或非法输入不覆盖已有有效信息', () => {
|
|
const { loadProfileSettings, updateAccount } = loadUseProfile();
|
|
const state = loadProfileSettings();
|
|
const originalName = state.account.name;
|
|
|
|
updateAccount(state, {
|
|
name: ' ',
|
|
company: '',
|
|
});
|
|
|
|
// 空白字符串不应覆盖已有名称与企业
|
|
expect(state.account.name).toBe(originalName);
|
|
expect(state.account.company).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OmniCard AI 名片主个人中心视图契约 (index.uvue & pages.json)', () => {
|
|
it('页面文件与逻辑 Composable 文件均存在', () => {
|
|
expect(fs.existsSync(PROFILE_PAGE)).toBe(true);
|
|
expect(fs.existsSync(PROFILE_UTS)).toBe(true);
|
|
});
|
|
|
|
it('页面视图包含 Switch 开关,触控区 >= 44x44,且引用 Token 与 useProfile', () => {
|
|
const pageSource = fs.readFileSync(PROFILE_PAGE, 'utf8');
|
|
const logicSource = fs.readFileSync(PROFILE_UTS, 'utf8');
|
|
const combined = `${pageSource}\n${logicSource}`;
|
|
|
|
expect(pageSource.includes('script setup lang="uts"')).toBe(true);
|
|
expect(pageSource.includes('<switch')).toBe(true);
|
|
expect(pageSource.includes('@change')).toBe(true);
|
|
expect(
|
|
pageSource.includes('radarPush') || logicSource.includes('radarPush')
|
|
).toBe(true);
|
|
expect(
|
|
pageSource.includes('leadNotice') || logicSource.includes('leadNotice')
|
|
).toBe(true);
|
|
expect(combined.includes('loadProfileSettings')).toBe(true);
|
|
expect(combined.includes('toggleRadarPush')).toBe(true);
|
|
expect(combined.includes('toggleLeadNotice')).toBe(true);
|
|
expect(combined.includes('setPreferredLocale')).toBe(true);
|
|
|
|
// 瑞士国际主义风格 Token 引用
|
|
expect(pageSource.includes('#0B5CFF')).toBe(true); // primary
|
|
expect(pageSource.includes('#F7F8FA')).toBe(true); // surface
|
|
expect(pageSource.includes('#101828')).toBe(true); // text
|
|
expect(pageSource.includes('#667085')).toBe(true); // text-2
|
|
expect(pageSource.includes('#E3E7ED')).toBe(true); // border
|
|
|
|
// Switch 触控区 >= 44x44 规范验证 (min-height >= 44px 或 height >= 44px)
|
|
expect(
|
|
pageSource.includes('min-height: 44px') ||
|
|
pageSource.includes('min-height: 48px') ||
|
|
pageSource.includes('height: 48px') ||
|
|
pageSource.includes('height: 44px') ||
|
|
pageSource.includes('switch-touch-area')
|
|
).toBe(true);
|
|
});
|
|
|
|
it('零 emoji、无 any,无硬编码中文主文案,符合瑞士国际主义约束', () => {
|
|
const pageSource = fs.readFileSync(PROFILE_PAGE, 'utf8');
|
|
const logicSource = fs.readFileSync(PROFILE_UTS, 'utf8');
|
|
const testSource = fs.readFileSync(__filename, 'utf8');
|
|
const template = pageSource.split('<script')[0];
|
|
|
|
expect(EMOJI_RE.test(pageSource)).toBe(false);
|
|
expect(EMOJI_RE.test(logicSource)).toBe(false);
|
|
expect(EMOJI_RE.test(testSource)).toBe(false);
|
|
|
|
expect(pageSource.includes(': any') || pageSource.includes(' as any')).toBe(false);
|
|
expect(logicSource.includes(': any') || logicSource.includes(' as any')).toBe(false);
|
|
|
|
// 模板中主文案走词典或 resolve 函数,无硬编码中文汉字
|
|
expect(/[\u4e00-\u9fff]/.test(template)).toBe(false);
|
|
});
|
|
|
|
it('mpuniapp/pages.json 中已成功注册 pages/owner/profile/index', () => {
|
|
expect(fs.existsSync(PAGES_JSON)).toBe(true);
|
|
const pagesJsonContent = fs.readFileSync(PAGES_JSON, 'utf8');
|
|
const cleanJson = pagesJsonContent
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/[^\n\r]*/g, '');
|
|
let found = false;
|
|
try {
|
|
const pagesConfig = JSON.parse(cleanJson);
|
|
found = pagesConfig.pages.some(
|
|
(p: { path: string }) => p.path === 'pages/owner/profile/index'
|
|
);
|
|
} catch (_e) {
|
|
found = pagesJsonContent.includes('"path": "pages/owner/profile/index"');
|
|
}
|
|
expect(found).toBe(true);
|
|
});
|
|
});
|