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

239 lines
7.4 KiB
TypeScript

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);
});
});