260 lines
7.8 KiB
TypeScript
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');
|
|
});
|
|
});
|