Files
haptika/src/features/ppg/tests/usePPGMeasurement.test.tsx
T
2026-07-31 16:20:10 +02:00

260 lines
7.8 KiB
TypeScript

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<typeof haptikaPPG>;
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> = {},
): 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<void> {
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(<Harness />);
});
});
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');
});
});