import { buildInsightDashboard, calculateBpmVariability, calculateHeartRateDrift, calculateLiveMetrics, calculatePostSessionRecovery, filterReliableHeartRateSamples, isReliableHeartRateSample, summarizeRrAvailability, } from '../src/training/trainingMetrics'; import { type CompletedTrainingSession, type HeartRateSample, } from '../src/training/trainingTypes'; function sample( timestamp: number, bpm: number, rrIntervalsMs: readonly number[] = [], ): HeartRateSample { return { timestamp, bpm, contactDetected: null, rrIntervalsMs, deviceName: 'Magene H003', }; } function session( startedAt: number, endedAt: number, peakBpm = 180, ): CompletedTrainingSession { return { id: `session-${startedAt}-${endedAt}`, startedAt, endedAt, durationMs: endedAt - startedAt, sampleCount: 12, averageBpm: 150, peakBpm, minBpm: 112, deviceName: 'Magene H003', }; } describe('sample reliability guards', () => { it('rejects zero BPM and explicit no-contact packets', () => { expect(isReliableHeartRateSample(sample(1_000, 0))).toBe(false); expect( isReliableHeartRateSample({ ...sample(2_000, 128), contactDetected: false, }), ).toBe(false); expect(isReliableHeartRateSample(sample(3_000, 128))).toBe(true); }); it('removes invalid readings before metrics are calculated', () => { const reliable = filterReliableHeartRateSamples([ sample(1_000, 0), { ...sample(2_000, 125), contactDetected: false }, sample(3_000, 130), ]); expect(reliable.map(item => item.bpm)).toEqual([130]); }); it('marks live BPM as unavailable when the latest valid reading is stale', () => { const metrics = calculateLiveMetrics([sample(1_000, 140)], { now: 20_000, staleAfterMs: 12_000, }); expect(metrics.currentBpm).toBeNull(); expect(metrics.averageBpm).toBe(140); }); }); describe('calculateBpmVariability', () => { it('summarizes BPM range, standard deviation and coefficient of variation', () => { const variability = calculateBpmVariability([ sample(1_000, 100), sample(2_000, 120), sample(3_000, 140), ]); expect(variability).toMatchObject({ averageBpm: 120, minBpm: 100, maxBpm: 140, rangeBpm: 40, standardDeviationBpm: 16.3, coefficientOfVariation: 0.136, classification: 'oscilante', }); }); }); describe('calculateHeartRateDrift', () => { it('compares the first and last fifth of a long enough sample window', () => { const start = Date.UTC(2026, 5, 10, 9, 0); const samples = Array.from({ length: 12 }, (_, index) => sample(start + index * 60_000, index < 6 ? 130 + index : 150 + index), ); expect(calculateHeartRateDrift(samples)).toMatchObject({ status: 'ok', driftBpm: 30, driftPercent: 23, initialAverageBpm: 131, finalAverageBpm: 161, }); }); it('does not over-interpret short windows as cardiovascular drift', () => { expect( calculateHeartRateDrift([sample(1_000, 130), sample(60_000, 140)]), ).toMatchObject({ status: 'short-window' }); }); }); describe('calculatePostSessionRecovery', () => { it('calculates one and two minute recovery from post-window continuous samples', () => { const startedAt = Date.UTC(2026, 5, 10, 10, 0); const endedAt = startedAt + 10 * 60_000; expect( calculatePostSessionRecovery(session(startedAt, endedAt, 182), [ sample(endedAt + 5_000, 176), sample(endedAt + 61_000, 160), sample(endedAt + 119_000, 148), ]), ).toMatchObject({ status: 'ok', oneMinuteDropBpm: 22, twoMinuteDropBpm: 34, }); }); }); describe('summarizeRrAvailability', () => { it('reports RR availability without calculating fake HRV', () => { expect( summarizeRrAvailability([ sample(1_000, 120, [1000, 980]), sample(2_000, 121, [990]), ]), ).toMatchObject({ status: 'ok', intervalCount: 3, sampleCount: 2 }); expect(summarizeRrAvailability([sample(1_000, 120)])).toMatchObject({ status: 'needs-rr', intervalCount: 0, }); }); }); describe('buildInsightDashboard', () => { it('builds compact scientific rows using persisted samples before live fallback', () => { const start = Date.UTC(2026, 5, 10, 9, 0); const persisted = [ sample(start, 120), sample(start + 60_000, 140), sample(start + 11 * 60_000, 160), ]; const dashboard = buildInsightDashboard({ liveSamples: [sample(start + 12 * 60_000, 170)], continuousSamples: persisted, sessions: [], }); expect(dashboard.sampleCount).toBe(3); expect(dashboard.rows.map(row => row.key)).toEqual([ 'current-bpm', 'average-bpm', 'bpm-range', 'bpm-variability', 'zones', 'load', 'drift', 'recovery', 'profile', 'hrv', ]); expect(dashboard.rows.find(row => row.key === 'hrv')).toMatchObject({ status: 'needs-rr', }); }); });