/** * OmniCard AI (CRMmp) - mpuniapp 名片主 NFC 场景模式切换测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 pages/owner/nfc/mode 设计编写 */ import * as fs from 'fs'; import * as path from 'path'; const MODE_PAGE = path.resolve( __dirname, '../../../mpuniapp/pages/owner/nfc/mode.uvue' ); const MODE_UTS = path.resolve( __dirname, '../../../mpuniapp/pages/owner/nfc/useMode.uts' ); type NFCSceneMode = 'business' | 'social'; type NFCStatus = 'active' | 'suspended' | 'unbound'; type NFCMaterial = 'wood' | 'metal' | 'pvc'; type NFCRecord = { id: string | number; uid: string; tenantId: string | number; cardId?: string | number | null; batchNo: string; material: NFCMaterial; status: NFCStatus; sceneMode: NFCSceneMode; lastScannedAt?: string | Date | null; }; type ApplySceneParams = { uid: string; cardId: string | number; sceneMode: string; device: NFCRecord | null; }; type ApplySceneApi = { switchNfcCard: (params: { uid: string; cardId: number }) => Promise; updateNfcDevice: (payload: NFCRecord & { id: string | number }) => Promise; }; type ApplySceneResult = { ok: boolean; error: string; device: NFCRecord | null; }; type OfflineWriteResult = { ok: boolean; error: string; }; type UseModeModule = { parseSceneMode: (raw: string | null | undefined) => NFCSceneMode | null; applyScene: ( params: ApplySceneParams, api?: ApplySceneApi ) => Promise; canWriteOfflineVCard: () => boolean; writeOfflineVCard: (payload?: { uid?: string; cardId?: string | number; vcard?: string; }) => Promise; resolveModeText: (key: string) => string; }; function loadUseMode(): UseModeModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require(MODE_UTS) as UseModeModule; } function makeDevice(overrides: Partial = {}): NFCRecord { return { id: 88, uid: 'UID-NFC-8801', tenantId: 1, cardId: 101, batchNo: 'B2026-01', material: 'pvc', status: 'active', sceneMode: 'business', lastScannedAt: null, ...overrides, }; } describe('OmniCard AI 名片主 NFC 场景模式切换 (useMode.uts)', () => { beforeEach(() => { jest.resetModules(); (global as { uni?: Record }).uni = { getLocale: () => 'zh-CN', showToast: jest.fn(), }; }); afterEach(() => { delete (global as { uni?: Record }).uni; }); describe('1. parseSceneMode 只放行 business | social', () => { it('1.1 合法值原样返回', () => { const { parseSceneMode } = loadUseMode(); expect(parseSceneMode('business')).toBe('business'); expect(parseSceneMode('social')).toBe('social'); }); it('1.2 非法值、空值、大小写变体一律拒绝', () => { const { parseSceneMode } = loadUseMode(); expect(parseSceneMode('')).toBeNull(); expect(parseSceneMode('party')).toBeNull(); expect(parseSceneMode('BUSINESS')).toBeNull(); expect(parseSceneMode('Social')).toBeNull(); expect(parseSceneMode(null)).toBeNull(); expect(parseSceneMode(undefined)).toBeNull(); }); }); describe('2. applyScene 换绑与写 sceneMode', () => { it('2.1 cardId 不同且设备 active 时先 switch 再 update', async () => { const { applyScene } = loadUseMode(); const device = makeDevice({ cardId: 101, status: 'active' }); const calls: string[] = []; const switched = makeDevice({ cardId: 202, status: 'active', sceneMode: 'business', }); const updated = makeDevice({ cardId: 202, status: 'active', sceneMode: 'social', }); const api: ApplySceneApi = { switchNfcCard: jest.fn(async (params: { uid: string; cardId: number }) => { calls.push('switch'); expect(params.uid).toBe('UID-NFC-8801'); expect(params.cardId).toBe(202); return switched; }), updateNfcDevice: jest.fn( async (payload: NFCRecord & { id: string | number }) => { calls.push('update'); expect(payload.id).toBe(88); expect(payload.sceneMode).toBe('social'); return updated; } ), }; const result = await applyScene( { uid: 'UID-NFC-8801', cardId: 202, sceneMode: 'social', device, }, api ); expect(result.ok).toBe(true); expect(result.error).toBe(''); expect(result.device?.sceneMode).toBe('social'); expect(result.device?.cardId).toBe(202); expect(calls).toEqual(['switch', 'update']); expect(api.switchNfcCard).toHaveBeenCalledTimes(1); expect(api.updateNfcDevice).toHaveBeenCalledTimes(1); }); it('2.2 同一 cardId 只 update,不 switch', async () => { const { applyScene } = loadUseMode(); const device = makeDevice({ cardId: 101, status: 'active' }); const updated = makeDevice({ cardId: 101, sceneMode: 'social' }); const api: ApplySceneApi = { switchNfcCard: jest.fn(async () => device), updateNfcDevice: jest.fn(async () => updated), }; const result = await applyScene( { uid: 'UID-NFC-8801', cardId: '101', sceneMode: 'social', device, }, api ); expect(result.ok).toBe(true); expect(api.switchNfcCard).not.toHaveBeenCalled(); expect(api.updateNfcDevice).toHaveBeenCalledTimes(1); }); it('2.3 suspended 设备拒绝切换且不调用任何 API', async () => { const { applyScene } = loadUseMode(); const device = makeDevice({ status: 'suspended', cardId: 101 }); const api: ApplySceneApi = { switchNfcCard: jest.fn(async () => device), updateNfcDevice: jest.fn(async () => device), }; const result = await applyScene( { uid: 'UID-NFC-8801', cardId: 202, sceneMode: 'social', device, }, api ); expect(result.ok).toBe(false); expect(result.error.length).toBeGreaterThan(0); expect(result.error).toMatch(/挂失|暂停|suspended/i); expect(api.switchNfcCard).not.toHaveBeenCalled(); expect(api.updateNfcDevice).not.toHaveBeenCalled(); }); }); describe('3. 离线 vCard 烧录无真实写芯片时必须失败', () => { it('3.1 canWriteOfflineVCard 为 false,无 NDEF 写入能力', () => { const { canWriteOfflineVCard } = loadUseMode(); expect(canWriteOfflineVCard()).toBe(false); }); it('3.2 writeOfflineVCard 不得返回成功,error 走 resolve 中英文案', async () => { const { writeOfflineVCard, resolveModeText } = loadUseMode(); const uniBag = (global as { uni?: Record }).uni as { showToast: jest.Mock; getLocale: () => string; }; const result = await writeOfflineVCard({ uid: 'UID-NFC-8801', cardId: 101, vcard: 'BEGIN:VCARD\nVERSION:3.0\nEND:VCARD', }); expect(result.ok).toBe(false); expect(result.error).toBe(resolveModeText('offlineUnavailable')); expect(result.error).toMatch(/暂不能写入离线名片/); uniBag.getLocale = () => 'en-US'; const enResult = await writeOfflineVCard({ uid: 'UID-NFC-8801' }); expect(enResult.ok).toBe(false); expect(enResult.error).toMatch(/cannot write offline/i); const toastTitles = uniBag.showToast.mock.calls.map( (c: unknown[]) => (c[0] as { title?: string }).title || '' ); expect( toastTitles.some((t: string) => /已烧录|burned|written|success/i.test(t) ) ).toBe(false); }); it('3.3 resolveModeText(offlineUnavailable) 中英均有自然文案', () => { const { resolveModeText } = loadUseMode(); const uniBag = (global as { uni?: Record }).uni as { getLocale: () => string; }; uniBag.getLocale = () => 'zh-CN'; expect(resolveModeText('offlineUnavailable')).toBe( '此设备暂不能写入离线名片' ); uniBag.getLocale = () => 'en-US'; expect(resolveModeText('offlineUnavailable')).toMatch( /cannot write offline/i ); }); }); }); describe('OmniCard AI NFC 场景切换页存在 (mode.uvue)', () => { it('4.1 页面文件存在,且为 uts 单页与单一主 CTA', () => { expect(fs.existsSync(MODE_PAGE)).toBe(true); const source = fs.readFileSync(MODE_PAGE, 'utf8'); expect(source.includes('script setup lang="uts"')).toBe(true); expect(source.includes('getOmniText')).toBe(true); expect(source.includes('应用场景') || source.includes("t('apply')")).toBe( true ); expect(source.includes('#F7F8FA')).toBe(true); expect(source.includes('#0B5CFF')).toBe(true); expect((source.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0); expect(source.includes('v-model="state.uid"')).toBe(true); expect(source.includes('v-model="state.cardId"')).toBe(true); }); it('4.2 页面展示离线不可写说明,且不伪造烧录成功', () => { const source = fs.readFileSync(MODE_PAGE, 'utf8'); expect( source.includes("offlineUnavailable") || source.includes('此设备暂不能写入离线名片') ).toBe(true); expect(source.includes('mode-offline-hint')).toBe(true); expect(source.includes('已烧录')).toBe(false); expect(/NDEF[\s\S]{0,40}success|writeNdef|假写入/i.test(source)).toBe( false ); }); });