/** * 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 CardProfileFixture = { id?: string | number; name: string; title?: string; company?: string; department?: string; avatarUrl?: string; }; type OwnerPageQuery = { page: number; size: number; keyWord?: string; }; type OwnerPageData = { list: T[]; pagination: { page: number; size: number; total: number; }; }; type LoadProfileAccountApi = { fetchOwnerCardPage: ( query: OwnerPageQuery ) => Promise>; }; 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; calculateQuotaPercent: (used: number, limit: number) => number; resolveProfileText: (key: string) => string; displayOrgField: (value: string | null | undefined) => string; clearOrgFields: (state: ProfileState) => void; loadOwnerProfileAccount: ( state: ProfileState, api?: LoadProfileAccountApi ) => Promise; }; const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; const SAMPLE_ORG_RE = /Nexus Tech Global|Overseas Business Dept|Cyberdyne Systems|Global Trade Co\.|示例(公司|组织|部门)/; 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, getDefaultAccount } = loadUseProfile(); const defaults = getDefaultAccount(); const state = loadProfileSettings(); expect(defaults.company).toBe(''); expect(defaults.department).toBe(''); expect(SAMPLE_ORG_RE.test(defaults.company)).toBe(false); expect(SAMPLE_ORG_RE.test(defaults.department)).toBe(false); expect(state.account.company).toBe(''); expect(state.account.department).toBe(''); 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: 'Acme Corp', 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('Acme Corp'); 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.company).toBe(''); expect(state.account.department).toBe(''); 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).toBe(''); expect(state.account.department).toBe(''); 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: 'Northwind Ltd', department: 'Marketing', }); expect(state.account.name).toBe('Elena Rostova'); expect(state.account.company).toBe('Northwind Ltd'); 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('Northwind Ltd'); }); it('4.2 防御保护:空值或非法输入不覆盖已有有效信息', () => { const { loadProfileSettings, updateAccount } = loadUseProfile(); const state = loadProfileSettings(); updateAccount(state, { name: 'Elena', company: 'Northwind Ltd', department: 'Sales', }); updateAccount(state, { name: ' ', company: '', department: '', }); // 空白字符串不应覆盖已有名称与企业 expect(state.account.name).toBe('Elena'); expect(state.account.company).toBe('Northwind Ltd'); expect(state.account.department).toBe('Sales'); }); }); describe('5. 商业化配额计算与现代化 UX 词条 (calculateQuotaPercent & resolveProfileText)', () => { it('5.1 calculateQuotaPercent 正确计算百分比并处理极值边界', () => { const { calculateQuotaPercent } = loadUseProfile(); expect(calculateQuotaPercent(0, 5)).toBe(0); expect(calculateQuotaPercent(2, 5)).toBe(40); expect(calculateQuotaPercent(5, 5)).toBe(100); expect(calculateQuotaPercent(6, 5)).toBe(100); // 溢出截断 expect(calculateQuotaPercent(1, 0)).toBe(100); // 防除以0 expect(calculateQuotaPercent(-1, 5)).toBe(0); // 负数防卫 }); it('5.2 resolveProfileText 包含商业化套餐与微指标新词条', () => { const { resolveProfileText } = loadUseProfile(); expect(resolveProfileText('tierPro')).toBeTruthy(); expect(resolveProfileText('previewPortal')).toBeTruthy(); expect(resolveProfileText('metricsCards')).toBeTruthy(); expect(resolveProfileText('metricsVisitors')).toBeTruthy(); expect(resolveProfileText('metricsLeads')).toBeTruthy(); expect(resolveProfileText('upgradeQuota')).toBeTruthy(); expect(resolveProfileText('confirmLogoutTitle')).toBeTruthy(); }); it('5.3 displayOrgField:缺失或空串走 i18n 空态,有值原样返回', () => { const { displayOrgField, resolveProfileText } = loadUseProfile(); const emptyText = resolveProfileText('empty'); expect(emptyText).toBeTruthy(); expect(displayOrgField('')).toBe(emptyText); expect(displayOrgField(' ')).toBe(emptyText); expect(displayOrgField(null)).toBe(emptyText); expect(displayOrgField(undefined)).toBe(emptyText); expect(displayOrgField('Acme Corp')).toBe('Acme Corp'); }); }); describe('6. R39 公司/部门仅来自接口 (loadOwnerProfileAccount)', () => { it('6.1 成功时写入接口 company / department,缺字段为空串', async () => { const { createProfileState, loadOwnerProfileAccount } = loadUseProfile(); const state = createProfileState(); state.account.company = 'stale-company'; state.account.department = 'stale-dept'; const fetchOwnerCardPage = jest.fn( async (): Promise> => ({ list: [ { id: 8, name: 'Elena', company: 'Acme Corp', }, ], pagination: { page: 1, size: 1, total: 1 }, }) ); const ok = await loadOwnerProfileAccount(state, { fetchOwnerCardPage }); expect(ok).toBe(true); expect(fetchOwnerCardPage).toHaveBeenCalledTimes(1); expect(state.account.company).toBe('Acme Corp'); expect(state.account.department).toBe(''); }); it('6.1b 接口同时返回 company 与 department 时原样写入', async () => { const { createProfileState, loadOwnerProfileAccount } = loadUseProfile(); const state = createProfileState(); await loadOwnerProfileAccount(state, { fetchOwnerCardPage: async () => ({ list: [ { id: 3, name: 'Elena', company: 'Acme Corp', department: 'APAC Sales', }, ], pagination: { page: 1, size: 1, total: 1 }, }), }); expect(state.account.company).toBe('Acme Corp'); expect(state.account.department).toBe('APAC Sales'); }); it('6.2 接口返回空串时保持空串,不回填示例组织', async () => { const { createProfileState, loadOwnerProfileAccount, displayOrgField, resolveProfileText } = loadUseProfile(); const state = createProfileState(); state.account.company = 'stale'; state.account.department = 'stale'; await loadOwnerProfileAccount(state, { fetchOwnerCardPage: async () => ({ list: [ { id: 1, name: 'Elena', company: '', department: ' ', }, ], pagination: { page: 1, size: 1, total: 1 }, }), }); expect(state.account.company).toBe(''); expect(state.account.department).toBe(''); expect(displayOrgField(state.account.company)).toBe(resolveProfileText('empty')); expect(displayOrgField(state.account.department)).toBe(resolveProfileText('empty')); expect(resolveProfileText('empty')).toBe('暂无数据'); }); it('6.3 接口失败时清空公司与部门,不保留上次或示例值', async () => { const { createProfileState, loadOwnerProfileAccount, clearOrgFields } = loadUseProfile(); const state = createProfileState(); state.account.company = 'stale-company'; state.account.department = 'stale-dept'; const ok = await loadOwnerProfileAccount(state, { fetchOwnerCardPage: async () => { throw new Error('network down'); }, }); expect(ok).toBe(false); expect(state.account.company).toBe(''); expect(state.account.department).toBe(''); clearOrgFields(state); expect(state.account.company).toBe(''); expect(state.account.department).toBe(''); }); it('6.4 列表为空时同样清空公司与部门', async () => { const { createProfileState, loadOwnerProfileAccount } = loadUseProfile(); const state = createProfileState(); state.account.company = 'stale-company'; state.account.department = 'stale-dept'; const ok = await loadOwnerProfileAccount(state, { fetchOwnerCardPage: async () => ({ list: [], pagination: { page: 1, size: 1, total: 0 }, }), }); expect(ok).toBe(true); expect(state.account.company).toBe(''); expect(state.account.department).toBe(''); }); }); }); 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); }); });