239 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|