import React from 'react'; import {AppState} from 'react-native'; import ReactTestRenderer from 'react-test-renderer'; import { haptikaPPG, type PPGFrameUpdate, } from 'react-native-haptika-ppg'; import { usePPGMeasurement, type PPGMeasurementController, } from '../measurement/usePPGMeasurement'; jest.mock('react-native-haptika-ppg', () => ({ haptikaPPG: { startMeasurement: jest.fn(), processFrame: jest.fn(), finishMeasurement: jest.fn(), cancelMeasurement: jest.fn(), getThermalState: jest.fn(() => 'nominal'), }, haptikaHealthKit: { isAvailable: jest.fn(() => false), requestReadAuthorization: jest.fn(async () => false), }, })); const nativePPG = haptikaPPG as jest.Mocked; let controller: PPGMeasurementController; let renderer: ReactTestRenderer.ReactTestRenderer | null = null; let memoryWarningHandler: (() => void) | null = null; function Harness(): null { controller = usePPGMeasurement(); return null; } const frameUpdate = ( thermalState: PPGFrameUpdate['thermalState'], overrides: Partial = {}, ): PPGFrameUpdate => ({ timestampSeconds: 1, contactState: 'absent', contactConfidence: 0, signalLevel: 0, saturationRatio: 0, motionArtifactScore: 0, collectedSampleCount: 0, usableDurationSeconds: 0, droppedFrameCount: 0, thermalState, waveformValue: 0, beatSequence: 0, acceptedBeats: [], ...overrides, }); async function startCamera(): Promise { await ReactTestRenderer.act(async () => { await controller.start({ includeHealthKitContext: false, sensitiveContact: true, requestCameraPermission: async () => true, }); }); ReactTestRenderer.act(() => controller.onCameraStarted()); } describe('usePPGMeasurement safety coordination', () => { beforeEach(async () => { jest.clearAllMocks(); memoryWarningHandler = null; jest.spyOn(AppState, 'addEventListener').mockImplementation( ((eventType: string, listener: () => void) => { if (eventType === 'memoryWarning') memoryWarningHandler = listener; return {remove: jest.fn()}; }) as unknown as typeof AppState.addEventListener, ); nativePPG.getThermalState.mockReturnValue('nominal'); await ReactTestRenderer.act(async () => { renderer = ReactTestRenderer.create(); }); }); afterEach(() => { if (renderer !== null) { ReactTestRenderer.act(() => renderer?.unmount()); renderer = null; } jest.restoreAllMocks(); }); test('cancels the native session synchronously before publishing a camera failure', async () => { await startCamera(); expect(nativePPG.startMeasurement).toHaveBeenCalledWith( expect.objectContaining({minimumContactConfidence: 0.45}), ); expect(controller.snapshot.value).toBe('stabilizing'); ReactTestRenderer.act(() => { controller.onCameraError({ code: 'cameraRuntime', message: 'Frame processing failed', }); expect(nativePPG.cancelMeasurement).toHaveBeenCalledTimes(1); }); expect(controller.snapshot.value).toBe('error'); expect(controller.snapshot.context.failure?.code).toBe('cameraRuntime'); }); test('re-reads native thermal state before leaving thermalStop', async () => { await startCamera(); ReactTestRenderer.act(() => controller.onFrameUpdate(frameUpdate('serious'))); expect(controller.snapshot.value).toBe('thermalStop'); nativePPG.getThermalState.mockReturnValueOnce('serious'); ReactTestRenderer.act(() => controller.retry()); expect(controller.snapshot.value).toBe('thermalStop'); nativePPG.getThermalState.mockReturnValueOnce('fair'); ReactTestRenderer.act(() => controller.retry()); expect(controller.snapshot.value).toBe('preparingCamera'); expect(nativePPG.getThermalState).toHaveBeenCalledTimes(2); }); test('pauses useful progress without stopping capture when contact is unusable', async () => { await startCamera(); ReactTestRenderer.act(() => controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: 10, contactState: 'valid', contactConfidence: 0.95, usableDurationSeconds: 0, }), ), ); expect(controller.snapshot.value).toBe('stabilizing'); ReactTestRenderer.act(() => controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: 12, contactState: 'unstable', contactConfidence: 0.15, usableDurationSeconds: 0, motionArtifactScore: 1, }), ), ); expect(controller.snapshot.value).toBe('stabilizing'); expect(controller.phaseProgress).toBe(0); ReactTestRenderer.act(() => controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: 15.1, contactState: 'absent', usableDurationSeconds: 0, }), ), ); expect(controller.snapshot.value).toBe('stabilizing'); ReactTestRenderer.act(() => { for (let timestamp = 16; timestamp <= 21.5; timestamp += 0.5) { controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: timestamp, contactState: 'valid', contactConfidence: 0.9, }), ); } }); expect(controller.snapshot.value).toBe('measuring'); ReactTestRenderer.act(() => controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: 32.1, contactState: 'overexposed', contactConfidence: 0.05, usableDurationSeconds: 0, saturationRatio: 1, }), ), ); const firstAcquisitionProgress = controller.phaseProgress; expect(firstAcquisitionProgress).toBe(0); expect(controller.snapshot.value).toBe('measuring'); ReactTestRenderer.act(() => controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: 54.1, contactState: 'tooMuchPressure', contactConfidence: 0.1, usableDurationSeconds: 0, }), ), ); expect(controller.snapshot.value).toBe('measuring'); expect(controller.phaseProgress).toBe(firstAcquisitionProgress); expect(nativePPG.cancelMeasurement).not.toHaveBeenCalled(); }); test('finishes after fifty detected beats and the minimum acquisition time', async () => { nativePPG.finishMeasurement.mockImplementationOnce(() => { throw new Error('synthetic processing stop'); }); await startCamera(); ReactTestRenderer.act(() => { for (let timestamp = 10; timestamp <= 15.5; timestamp += 0.5) { controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: timestamp, contactState: 'valid', contactConfidence: 0.95, beatSequence: 4, usableDurationSeconds: timestamp - 10, }), ); } }); expect(controller.snapshot.value).toBe('measuring'); ReactTestRenderer.act(() => controller.onFrameUpdate( frameUpdate('nominal', { timestampSeconds: 60.7, contactState: 'unstable', beatSequence: 49, usableDurationSeconds: 50.7, collectedSampleCount: 1_500, }), ), ); expect(controller.phaseProgress).toBe(1); expect(nativePPG.finishMeasurement).toHaveBeenCalledTimes(1); expect(controller.snapshot.value).not.toBe('measuring'); }); test('stops and releases the native camera session on memory pressure', async () => { await startCamera(); expect(controller.snapshot.value).toBe('stabilizing'); expect(memoryWarningHandler).not.toBeNull(); ReactTestRenderer.act(() => memoryWarningHandler?.()); expect(nativePPG.cancelMeasurement).toHaveBeenCalledTimes(1); expect(controller.snapshot.value).toBe('error'); expect(controller.snapshot.context.failure?.code).toBe('memoryPressure'); }); });