/** * 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) => 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 = {}; beforeEach(() => { jest.resetModules(); mockStorage = {}; (global as { uni?: Record }).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 }).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('= 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(' { 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); }); });