/** * 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; buildNdefUri: (cardId: string | number | null | undefined) => string; writeNdefToTag: (uri: string | null | undefined) => Promise; resolveScanText: (key: string) => string; }; type NdefWriteOption = { uris?: string[]; records?: unknown[]; success?: () => void; fail?: (err: { errMsg?: string }) => void; }; const WRITE_SUCCESS_TOAST_RE = /已写入(?!失败)|写入成功|Written to tag|Write successful/i; function assertNoWriteSuccessToast(toasts: UniToastOption[]): void { expect(toasts.some(t => WRITE_SUCCESS_TOAST_RE.test(t.title))).toBe(false); } 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 writeNdefCalls: NdefWriteOption[]; let writeMode: 'success' | 'fail' | 'cancel' | 'noAdapter' | 'noWriteApi'; let mockRequestResponse: { statusCode?: number; data?: { code?: number; message?: string; data?: unknown; }; failError?: { errMsg: string }; }; beforeEach(() => { storageMap = new Map(); toastCalls = []; lastRequestOptions = null; requestCallCount = 0; writeNdefCalls = []; writeMode = 'success'; 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', getNFCAdapter: () => { if (writeMode === 'noAdapter') { return null; } return { startDiscovery: (opts: { success?: () => void; fail?: (err: { errMsg?: string }) => void; }) => { opts.fail?.({ errMsg: 'startDiscovery:fail' }); }, onDiscovered: () => {}, getNdef: () => { if (writeMode === 'noWriteApi') { return null; } return { writeNdefMessage: (opts: NdefWriteOption) => { writeNdefCalls.push(opts); if (writeMode === 'success') { opts.success?.(); return; } if (writeMode === 'cancel') { opts.fail?.({ errMsg: 'writeNdefMessage:fail cancel' }); return; } opts.fail?.({ errMsg: 'writeNdefMessage:fail' }); }, }; }, }; }, 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 天线 API 不可用时失败回退手动输入,不伪造成功、不发激活请求', () => { const { createScanState, startScan } = loadUseScan(); const state = createScanState(); const uidBefore = state.uid; const ok = startScan(state); expect(ok).toBe(false); expect(state.manualInput).toBe(true); expect(state.scanning).toBe(false); expect(state.record).toBeNull(); expect(state.uid).toBe(uidBefore); expect(state.errorMessage.length).toBeGreaterThan(0); expect(requestCallCount).toBe(0); }); it('4.2 源码契约:APP 块尝试官方 getNFCAdapter 读 UID,成功仅在拿到合法 UID', () => { const logicSource = fs.readFileSync(SCAN_UTS, 'utf8'); const startScanBlock = logicSource.slice( logicSource.indexOf('export function startScan'), logicSource.indexOf('export function resolveStatusLine') ); expect(logicSource.includes('#ifdef APP')).toBe(true); expect(logicSource.includes('getNFCAdapter')).toBe(true); expect(logicSource.includes('startDiscovery') || logicSource.includes('onDiscovered')).toBe( true ); expect(startScanBlock.includes('parseUid')).toBe(true); expect(startScanBlock.includes('state.uid')).toBe(true); expect(startScanBlock.includes('return true')).toBe(true); expect(startScanBlock.includes('manualInput = true')).toBe(true); expect(startScanBlock.includes('return false')).toBe(true); // 禁止在 startScan 内硬编码伪造芯片 UID expect(/state\.uid\s*=\s*['"][0-9A-Fa-f:]+['"]/.test(startScanBlock)).toBe(false); expect(startScanBlock.includes('Native NFC antenna is not wired')).toBe(false); }); }); describe('5. writeNdefToTag 仅系统写入 API 成功才 toast 已写入', () => { it('5.1 buildNdefUri 无合法 cardId 时返回空串', () => { const { buildNdefUri } = loadUseScan(); expect(buildNdefUri(null)).toBe(''); expect(buildNdefUri(undefined)).toBe(''); expect(buildNdefUri('')).toBe(''); expect(buildNdefUri(0)).toBe(''); expect(buildNdefUri(-1)).toBe(''); expect(buildNdefUri(12)).toBe('https://omnicard.ai/c/12'); expect(buildNdefUri(' 8 ')).toBe('https://omnicard.ai/c/8'); }); it('5.2 写入 API 成功才 toast 已写入', async () => { const { writeNdefToTag, resolveScanText } = loadUseScan(); toastCalls = []; writeMode = 'success'; const ok = await writeNdefToTag('https://omnicard.ai/c/12'); expect(ok).toBe(true); expect(writeNdefCalls.length).toBe(1); expect(writeNdefCalls[0].uris).toEqual(['https://omnicard.ai/c/12']); expect( toastCalls.some(t => t.title === resolveScanText('writeSuccess')) ).toBe(true); expect(WRITE_SUCCESS_TOAST_RE.test(resolveScanText('writeSuccess'))).toBe( true ); }); it('5.3 无可写内容不调写入 API,不 toast 已写入', async () => { const { writeNdefToTag, resolveScanText } = loadUseScan(); toastCalls = []; const ok = await writeNdefToTag(' '); expect(ok).toBe(false); expect(writeNdefCalls.length).toBe(0); assertNoWriteSuccessToast(toastCalls); expect( toastCalls.some(t => t.title === resolveScanText('writeUnavailable')) ).toBe(true); }); it('5.4 天线/Adapter 不可用时不 toast 已写入', async () => { const { writeNdefToTag, resolveScanText } = loadUseScan(); writeMode = 'noAdapter'; toastCalls = []; const ok = await writeNdefToTag('https://omnicard.ai/c/12'); expect(ok).toBe(false); expect(writeNdefCalls.length).toBe(0); assertNoWriteSuccessToast(toastCalls); expect( toastCalls.some(t => t.title === resolveScanText('writeFailed')) ).toBe(true); }); it('5.5 无 writeNdefMessage 能力时不 toast 已写入', async () => { const { writeNdefToTag, resolveScanText } = loadUseScan(); writeMode = 'noWriteApi'; toastCalls = []; const ok = await writeNdefToTag('https://omnicard.ai/c/12'); expect(ok).toBe(false); expect(writeNdefCalls.length).toBe(0); assertNoWriteSuccessToast(toastCalls); expect( toastCalls.some(t => t.title === resolveScanText('writeFailed')) ).toBe(true); }); it('5.6 用户取消写入不 toast 已写入', async () => { const { writeNdefToTag, resolveScanText } = loadUseScan(); writeMode = 'cancel'; toastCalls = []; const ok = await writeNdefToTag('https://omnicard.ai/c/12'); expect(ok).toBe(false); expect(writeNdefCalls.length).toBe(1); assertNoWriteSuccessToast(toastCalls); expect( toastCalls.some(t => t.title === resolveScanText('writeCancelled')) ).toBe(true); }); it('5.7 写入失败不 toast 已写入', async () => { const { writeNdefToTag, resolveScanText } = loadUseScan(); writeMode = 'fail'; toastCalls = []; const ok = await writeNdefToTag('https://omnicard.ai/c/12'); expect(ok).toBe(false); expect(writeNdefCalls.length).toBe(1); assertNoWriteSuccessToast(toastCalls); expect( toastCalls.some(t => t.title === resolveScanText('writeFailed')) ).toBe(true); }); it('5.8 源码契约:writeSuccess 仅出现在 writeNdefMessage 的 success 回调内', () => { const logicSource = fs.readFileSync(SCAN_UTS, 'utf8'); const writeIdx = logicSource.indexOf('ndef.writeNdefMessage('); expect(writeIdx).toBeGreaterThan(-1); const successIdx = logicSource.indexOf('success:', writeIdx); expect(successIdx).toBeGreaterThan(writeIdx); const successBody = logicSource.slice(successIdx, successIdx + 220); expect(successBody.includes('writeSuccess')).toBe(true); const failIdx = logicSource.indexOf('fail:', successIdx); expect(failIdx).toBeGreaterThan(successIdx); const failBody = logicSource.slice(failIdx, failIdx + 360); expect(failBody.includes('writeSuccess')).toBe(false); expect(WRITE_SUCCESS_TOAST_RE.test(failBody)).toBe(false); expect( failBody.includes('writeFailed') || failBody.includes('writeCancelled') || failBody.includes('toastWriteFailed') || failBody.includes('toastWriteCancelled') ).toBe(true); }); }); }); 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(' { const pageSource = fs.readFileSync(SCAN_PAGE, 'utf8'); const logicSource = fs.readFileSync(SCAN_UTS, 'utf8'); expect(logicSource.includes('writeNdefToTag')).toBe(true); expect(logicSource.includes('writeNdefMessage')).toBe(true); expect(pageSource.includes('writeNdefToTag')).toBe(true); expect(pageSource.includes('buildNdefUri')).toBe(true); expect(pageSource.includes('已写入')).toBe(false); expect(pageSource.includes('烧录至')).toBe(false); expect(WRITE_SUCCESS_TOAST_RE.test(pageSource)).toBe(false); const unavailableIdx = logicSource.indexOf("resolveScanText('writeUnavailable')"); expect(unavailableIdx).toBeGreaterThan(-1); const unavailableSlice = logicSource.slice( Math.max(0, unavailableIdx - 80), unavailableIdx + 80 ); expect(WRITE_SUCCESS_TOAST_RE.test(unavailableSlice)).toBe(false); }); });