feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
createInitialMeasurementMachine,
|
||||
transitionMeasurement,
|
||||
} from '../measurement/measurementMachine';
|
||||
import type {
|
||||
MeasurementEvent,
|
||||
MeasurementMachineSnapshot,
|
||||
} from '../measurement/measurementTypes';
|
||||
import {makeProcessingResult} from './syntheticMeasurements';
|
||||
|
||||
const send = (
|
||||
snapshot: MeasurementMachineSnapshot,
|
||||
...events: readonly MeasurementEvent[]
|
||||
): MeasurementMachineSnapshot =>
|
||||
events.reduce(transitionMeasurement, snapshot);
|
||||
|
||||
describe('measurement machine', () => {
|
||||
test('runs the complete camera-only lifecycle', () => {
|
||||
const result = makeProcessingResult();
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
{type: 'ACQUISITION_COMPLETE'},
|
||||
{type: 'PROCESSING_SUCCEEDED', result},
|
||||
);
|
||||
|
||||
expect(snapshot.value).toBe('completed');
|
||||
expect(snapshot.context.result).toEqual(result);
|
||||
expect(snapshot.context.attempt).toBe(1);
|
||||
});
|
||||
|
||||
test('continues when the optional HealthKit request fails', () => {
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START', requestHealthKit: true},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
);
|
||||
expect(snapshot.value).toBe('requestingHealthPermission');
|
||||
|
||||
const afterFailure = transitionMeasurement(snapshot, {
|
||||
type: 'HEALTH_PERMISSION_RESOLVED',
|
||||
status: 'requestFailed',
|
||||
});
|
||||
expect(afterFailure.value).toBe('preparingCamera');
|
||||
expect(afterFailure.context.healthPermission).toBe('requestFailed');
|
||||
});
|
||||
|
||||
test('a rejected camera permission retry returns through idle before requesting again', () => {
|
||||
const denied = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_DENIED'},
|
||||
);
|
||||
expect(denied.value).toBe('error');
|
||||
|
||||
const retrying = transitionMeasurement(denied, {type: 'RETRY'});
|
||||
expect(retrying.value).toBe('idle');
|
||||
expect(retrying.context.attempt).toBe(1);
|
||||
|
||||
const requestingAgain = transitionMeasurement(retrying, {type: 'START'});
|
||||
expect(requestingAgain.value).toBe('requestingCameraPermission');
|
||||
expect(requestingAgain.context.attempt).toBe(2);
|
||||
});
|
||||
|
||||
test.each(['waitingForFinger', 'measuring'] as const)(
|
||||
'routes a camera failure from %s to the global error state',
|
||||
activeState => {
|
||||
let active = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
);
|
||||
if (activeState === 'measuring') {
|
||||
active = send(
|
||||
active,
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
);
|
||||
}
|
||||
|
||||
const failed = transitionMeasurement(active, {
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'cameraRuntime',
|
||||
message: 'Camera runtime failure',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(failed.value).toBe('error');
|
||||
expect(failed.context.failure?.code).toBe('cameraRuntime');
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps stabilization and acquisition running when contact fluctuates', () => {
|
||||
const measuring = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
);
|
||||
expect(measuring.value).toBe('measuring');
|
||||
|
||||
const waiting = transitionMeasurement(measuring, {
|
||||
type: 'CONTACT_CHANGED',
|
||||
contactState: 'unstable',
|
||||
});
|
||||
expect(waiting.value).toBe('measuring');
|
||||
|
||||
const stillMeasuring = transitionMeasurement(waiting, {
|
||||
type: 'CONTACT_CHANGED',
|
||||
contactState: 'valid',
|
||||
});
|
||||
expect(stillMeasuring.value).toBe('measuring');
|
||||
});
|
||||
|
||||
test('allows stabilization to complete even when the latest frame is unstable', () => {
|
||||
const stabilizing = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'unstable'},
|
||||
);
|
||||
expect(stabilizing.value).toBe('stabilizing');
|
||||
expect(
|
||||
transitionMeasurement(stabilizing, {type: 'STABILIZATION_COMPLETE'}).value,
|
||||
).toBe('measuring');
|
||||
});
|
||||
|
||||
test.each(['serious', 'critical'] as const)(
|
||||
'stops immediately for a %s thermal state',
|
||||
thermalState => {
|
||||
const measuring = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
);
|
||||
const stopped = transitionMeasurement(measuring, {
|
||||
type: 'THERMAL_STATE_CHANGED',
|
||||
thermalState,
|
||||
});
|
||||
|
||||
expect(stopped.value).toBe('thermalStop');
|
||||
expect(stopped.context.failure?.code).toBe(
|
||||
thermalState === 'critical' ? 'thermalCritical' : 'thermalSerious',
|
||||
);
|
||||
expect(stopped.context.failure?.message).toMatch(/iPhone|chaud/);
|
||||
},
|
||||
);
|
||||
|
||||
test('cannot reset past an unsafe thermal state', () => {
|
||||
const stopped = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'THERMAL_STATE_CHANGED', thermalState: 'serious'},
|
||||
);
|
||||
|
||||
const reset = transitionMeasurement(stopped, {type: 'RESET'});
|
||||
expect(reset.value).toBe('thermalStop');
|
||||
|
||||
const cooled = transitionMeasurement(reset, {
|
||||
type: 'THERMAL_STATE_CHANGED',
|
||||
thermalState: 'fair',
|
||||
});
|
||||
expect(transitionMeasurement(cooled, {type: 'RETRY'}).value).toBe(
|
||||
'preparingCamera',
|
||||
);
|
||||
});
|
||||
|
||||
test('never completes an invalid native result', () => {
|
||||
const invalidResult = makeProcessingResult({
|
||||
valid: false,
|
||||
invalidReason: 'Signal insuffisant',
|
||||
quality: {
|
||||
...makeProcessingResult().quality,
|
||||
score: 0.4,
|
||||
level: 'invalid',
|
||||
},
|
||||
});
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
{type: 'ACQUISITION_COMPLETE'},
|
||||
{type: 'PROCESSING_SUCCEEDED', result: invalidResult},
|
||||
);
|
||||
|
||||
expect(snapshot.value).toBe('invalid');
|
||||
expect(snapshot.context.result?.valid).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps a conservative BPM when PRV quality is poor', () => {
|
||||
const partialResult = makeProcessingResult({
|
||||
valid: true,
|
||||
bpm: {
|
||||
...makeProcessingResult().bpm,
|
||||
bpm: 68,
|
||||
},
|
||||
quality: {
|
||||
...makeProcessingResult().quality,
|
||||
score: 0.52,
|
||||
level: 'poor',
|
||||
},
|
||||
prv: {
|
||||
...makeProcessingResult().prv,
|
||||
reliable: false,
|
||||
},
|
||||
});
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
{type: 'ACQUISITION_COMPLETE'},
|
||||
{type: 'PROCESSING_SUCCEEDED', result: partialResult},
|
||||
);
|
||||
|
||||
expect(snapshot.value).toBe('completed');
|
||||
expect(snapshot.context.result?.bpm.bpm).toBe(68);
|
||||
expect(snapshot.context.result?.prv.reliable).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user