feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user