/** * OmniCard AI (CRMmp) - 名片主 NFC 贴卡激活测试套件 (TDD Red -> Green) * * 严格按照 AGENTS.md 3.3、4.3、5.1 规范与 PRD pages/owner/nfc/scan 设计编写 */ import * as fs from 'fs'; import * as path from 'path'; const SCAN_PAGE = path.resolve(__dirname, '../../../mpuniapp/pages/owner/nfc/scan.uvue'); const SCAN_UTS = path.resolve(__dirname, '../../../mpuniapp/pages/owner/nfc/useScan.uts'); type NFCRecordFixture = { id: string | number; uid: string; tenantId: string | number; cardId?: string | number | null; batchNo: string; material: 'wood' | 'metal' | 'pvc'; status: 'active' | 'suspended' | 'unbound'; sceneMode: 'business' | 'social'; }; type ActivateByTapInput = { uid: string; cardId?: string | number | null; deviceCardId?: string | number | null; }; type ScanState = { uid: string; cardIdRaw: string; deviceCardId: number | null; submitting: boolean; scanning: boolean; manualInput: boolean; errorMessage: string; empty: boolean; record: NFCRecordFixture | null; }; type UseScanModule = { parseUid: (raw: string | number | null | undefined) => string; parseCardId: (raw: string | number | null | undefined) => number | null; activateByTap: ( input: ActivateByTapInput, existing?: { cardId?: string | number | null } | null ) => Promise; startScan: (state: ScanState) => boolean; createScanState: () => ScanState; }; type UniToastOption = { title: string; icon?: string; }; type UniRequestSuccessRes = { statusCode: number; data: unknown; header: Record; }; type UniRequestOption = { url: string; method?: string; data?: Record; success?: (res: UniRequestSuccessRes) => void; fail?: (err: { errMsg: string }) => void; complete?: () => void; }; const EMOJI_RE = /[\u2600-\u27BF\uFE0F]|[\uD83C-\uDBFF][\uDC00-\uDFFF]/; function buildNfcRecord(overrides?: Partial): NFCRecordFixture { return { id: 41, uid: '04AABBCCDD', tenantId: 8, cardId: 12, batchNo: 'B202609', material: 'pvc', status: 'active', sceneMode: 'business', ...overrides, }; } describe('OmniCard AI 名片主 NFC 贴卡激活业务逻辑测试 (useScan.uts)', () => { let storageMap: Map; let toastCalls: UniToastOption[]; let lastRequestOptions: UniRequestOption | null; let requestCallCount: number; let mockRequestResponse: { statusCode?: number; data?: { code?: number; message?: string; data?: unknown; }; failError?: { errMsg: string }; }; beforeEach(() => { storageMap = new Map(); toastCalls = []; lastRequestOptions = null; requestCallCount = 0; mockRequestResponse = { statusCode: 200, data: { code: 1000, message: 'success', data: buildNfcRecord(), }, }; jest.resetModules(); (global as { uni?: Record }).uni = { getStorageSync: (key: string) => storageMap.get(key) || '', setStorageSync: (key: string, val: string) => storageMap.set(key, val), removeStorageSync: (key: string) => storageMap.delete(key), showToast: (options: UniToastOption) => { toastCalls.push(options); }, reLaunch: jest.fn(), getLocale: () => 'zh-CN', request: (options: UniRequestOption) => { requestCallCount += 1; lastRequestOptions = options; setTimeout(() => { if (mockRequestResponse.failError) { options.fail?.(mockRequestResponse.failError); } else { options.success?.({ statusCode: mockRequestOptionsStatus(), data: mockRequestResponse.data, header: {}, }); } options.complete?.(); }, 0); return { abort: jest.fn(), }; }, }; }); afterEach(() => { delete (global as { uni?: Record }).uni; }); function mockRequestOptionsStatus(): number { return mockRequestResponse.statusCode != null ? mockRequestResponse.statusCode : 200; } function loadUseScan(): UseScanModule { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../mpuniapp/pages/owner/nfc/useScan.uts') as UseScanModule; } describe('1. parseUid 去空且非空才合法', () => { it('1.1 空白与空串解析为空,视为非法', () => { const { parseUid } = loadUseScan(); expect(parseUid('')).toBe(''); expect(parseUid(' ')).toBe(''); expect(parseUid(null)).toBe(''); expect(parseUid(undefined)).toBe(''); }); it('1.2 两端空白被去掉,非空结果合法', () => { const { parseUid } = loadUseScan(); expect(parseUid(' 04:AA:BB:CC ')).toBe('04:AA:BB:CC'); expect(parseUid('04AABBCCDD')).toBe('04AABBCCDD'); expect(parseUid(4012)).toBe('4012'); }); }); describe('2. parseCardId 仅正整数合法', () => { it('2.1 空值、零、负数、小数、非数字均为非法', () => { const { parseCardId } = loadUseScan(); expect(parseCardId('')).toBeNull(); expect(parseCardId(' ')).toBeNull(); expect(parseCardId(null)).toBeNull(); expect(parseCardId(undefined)).toBeNull(); expect(parseCardId(0)).toBeNull(); expect(parseCardId('0')).toBeNull(); expect(parseCardId(-1)).toBeNull(); expect(parseCardId('-8')).toBeNull(); expect(parseCardId(1.5)).toBeNull(); expect(parseCardId('12.3')).toBeNull(); expect(parseCardId('abc')).toBeNull(); }); it('2.2 正整数字符串与数字解析为 number', () => { const { parseCardId } = loadUseScan(); expect(parseCardId(12)).toBe(12); expect(parseCardId('12')).toBe(12); expect(parseCardId(' 8 ')).toBe(8); }); }); describe('3. activateByTap 调用 bind 或 activate', () => { it('3.1 入参 cardId 合法时调用 bindNfcDevice', async () => { const { activateByTap } = loadUseScan(); const record = await activateByTap({ uid: ' 04AABBCCDD ', cardId: '12' }); expect(requestCallCount).toBe(1); expect(lastRequestOptions).not.toBeNull(); expect(lastRequestOptions?.method).toBe('POST'); expect(lastRequestOptions?.url).toContain('/admin/nfc/bind/bind'); expect(lastRequestOptions?.data?.uid).toBe('04AABBCCDD'); expect(lastRequestOptions?.data?.cardId).toBe(12); expect(record.uid).toBe('04AABBCCDD'); expect(record.cardId).toBe(12); expect(record.status).toBe('active'); }); it('3.2 设备已有 cardId 且入参 cardId 空时调用 activateNfcDevice', async () => { const { activateByTap } = loadUseScan(); mockRequestResponse.data = { code: 1000, message: 'success', data: buildNfcRecord({ cardId: 88 }), }; const record = await activateByTap( { uid: '04AABBCCDD', cardId: '' }, { cardId: 88 } ); expect(requestCallCount).toBe(1); expect(lastRequestOptions).not.toBeNull(); expect(lastRequestOptions?.method).toBe('POST'); expect(lastRequestOptions?.url).toContain('/admin/nfc/bind/activate'); expect(lastRequestOptions?.data?.uid).toBe('04AABBCCDD'); expect(lastRequestOptions?.data?.cardId).toBeUndefined(); expect(record.cardId).toBe(88); }); it('3.3 入参带 deviceCardId 且 cardId 空时走 activate', async () => { const { activateByTap } = loadUseScan(); await activateByTap({ uid: '04AABBCCDD', cardId: null, deviceCardId: 5 }); expect(requestCallCount).toBe(1); expect(lastRequestOptions?.url).toContain('/admin/nfc/bind/activate'); }); it('3.4 空 UID 不发请求并抛出错误信息', async () => { const { activateByTap } = loadUseScan(); await expect(activateByTap({ uid: ' ', cardId: 12 })).rejects.toThrow(); expect(requestCallCount).toBe(0); }); it('3.5 无入参 cardId 且设备亦无 cardId 时不发请求并抛错', async () => { const { activateByTap } = loadUseScan(); await expect(activateByTap({ uid: '04AABBCCDD', cardId: '' })).rejects.toThrow(); expect(requestCallCount).toBe(0); }); it('3.6 接口失败时抛出服务端错误信息', async () => { const { activateByTap } = loadUseScan(); mockRequestResponse.data = { code: 1001, message: '设备未在台账中', data: null, }; await expect(activateByTap({ uid: '04AABBCCDD', cardId: 12 })).rejects.toThrow( '设备未在台账中' ); }); }); describe('4. startScan 不伪造成功', () => { it('4.1 调不到原生 NFC 时回退手动输入且不发激活请求', () => { const { createScanState, startScan } = loadUseScan(); const state = createScanState(); const ok = startScan(state); expect(ok).toBe(false); expect(state.manualInput).toBe(true); expect(state.scanning).toBe(false); expect(state.record).toBeNull(); expect(requestCallCount).toBe(0); }); }); }); describe('OmniCard AI 名片主 NFC 贴卡激活页契约 (scan.uvue)', () => { it('页面存在且引用 bindNfcDevice 或 activateByTap', () => { expect(fs.existsSync(SCAN_PAGE)).toBe(true); expect(fs.existsSync(SCAN_UTS)).toBe(true); const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8'); const logicSource = fs.readFileSync(SCAN_UTS, 'utf8'); const combined = `${pageSource}\n${logicSource}`; expect(combined.includes('bindNfcDevice') || combined.includes('activateByTap')).toBe( true ); expect(pageSource.includes('script setup lang="uts"')).toBe(true); expect(pageSource.includes(' { const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8'); const logicSource = fs.readFileSync(SCAN_UTS, 'utf8'); const testSource = fs.readFileSync(__filename, 'utf8'); const template = pageSource.split('