UNPKG

@sports-alliance/sports-lib

Version:

A Library to for importing / exporting and processing GPX, TCX, FIT and JSON files from services such as Strava, Movescount, Garmin, Polar etc

378 lines (377 loc) 24.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const activity_1 = require("../../activities/activity"); const activity_types_1 = require("../../activities/activity.types"); const data_duration_1 = require("../../data/data.duration"); const data_durability_evidence_1 = require("../../data/data.durability-evidence"); const data_grade_1 = require("../../data/data.grade"); const data_grade_adjusted_speed_1 = require("../../data/data.grade-adjusted-speed"); const data_heart_rate_1 = require("../../data/data.heart-rate"); const data_heart_rate_zone_five_duration_1 = require("../../data/data.heart-rate-zone-five-duration"); const data_heart_rate_zone_four_duration_1 = require("../../data/data.heart-rate-zone-four-duration"); const data_heart_rate_zone_one_duration_1 = require("../../data/data.heart-rate-zone-one-duration"); const data_power_1 = require("../../data/data.power"); const data_speed_1 = require("../../data/data.speed"); const creator_1 = require("../../creators/creator"); const activity_durability_1 = require("./activity-durability"); const activity_utilities_1 = require("./activity.utilities"); function mockActivity(options) { var _a; const durationSeconds = (_a = options.durationSeconds) !== null && _a !== void 0 ? _a : 3600; return { type: options.type, startDate: new Date(0), endDate: new Date(durationSeconds * 1000), getDuration: () => new data_duration_1.DataDuration(durationSeconds), getStreamData: (type) => { var _a; return ((_a = options.streams) === null || _a === void 0 ? void 0 : _a[type]) || []; }, getStat: (type) => { var _a; const value = (_a = options.stats) === null || _a === void 0 ? void 0 : _a[type]; return value === undefined ? undefined : { getValue: () => value }; }, getSwimLengths: () => options.swimLengths || [] }; } function swimLength(index, paceSecondsPer100m, swolf, stroke = 'freestyle') { const speed = 100 / paceSecondsPer100m; return { index, type: 'active', stroke, poolLength: { getValue: () => 25 }, distance: { getValue: () => 25 }, timerTime: { getValue: () => 25 / speed }, elapsedTime: { getValue: () => 25 / speed }, avgSpeed: { getValue: () => speed }, swolf }; } describe('activity durability', () => { it('calculates aerobic efficiency defensively', () => { expect((0, activity_durability_1.calculateAerobicEfficiency)(240, 150)).toBe(1.6); expect((0, activity_durability_1.calculateAerobicEfficiency)(0, 150)).toBeNull(); expect((0, activity_durability_1.calculateAerobicEfficiency)(240, null)).toBeNull(); }); it('builds an eligible cycling timeline and compact early/late evidence', () => { var _a, _b, _c, _d, _e; const power = Array.from({ length: 3600 }, (_, index) => (index < 1800 ? 210 : 195)); const heartRate = Array.from({ length: 3600 }, (_, index) => (index < 1800 ? 135 : 145)); const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Cycling, streams: { [data_power_1.DataPower.type]: power, [data_heart_rate_1.DataHeartRate.type]: heartRate } })); expect(result.timeline).toHaveLength(3600); expect((_a = result.summary) === null || _a === void 0 ? void 0 : _a.discipline).toBe('cycling'); expect((_b = result.summary) === null || _b === void 0 ? void 0 : _b.eligibility).toMatchObject({ eligible: true, reason: 'eligible' }); expect((_c = result.summary) === null || _c === void 0 ? void 0 : _c.eligibility).toMatchObject({ comparisonSegments: 'halves', earlySampleCount: 1350, lateSampleCount: 1350 }); expect((_d = result.summary) === null || _d === void 0 ? void 0 : _d.evidence).toMatchObject({ kind: 'aerobic-efficiency', outputRetentionPercent: expect.any(Number), heartRateDriftBpm: expect.any(Number) }); expect(((_e = result.summary) === null || _e === void 0 ? void 0 : _e.evidence) && 'decouplingPercent' in result.summary.evidence ? result.summary.evidence.decouplingPercent : null).toBeGreaterThan(0); expect(JSON.stringify(result.summary)).not.toContain('timeline'); }); it('requires coverage in both fixed time halves instead of splitting available samples by count', () => { var _a, _b; const power = Array(3600).fill(200); const heartRate = Array(3600).fill(140); power.fill(null, 600, 1500); heartRate.fill(null, 600, 1500); const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Cycling, streams: { [data_power_1.DataPower.type]: power, [data_heart_rate_1.DataHeartRate.type]: heartRate } })); expect((_a = result.summary) === null || _a === void 0 ? void 0 : _a.coverageRatio).toBeCloseTo(2 / 3, 3); expect((_b = result.summary) === null || _b === void 0 ? void 0 : _b.eligibility).toMatchObject({ eligible: false, reason: 'insufficient-halves', earlySampleCount: 450, lateSampleCount: 1350 }); }); it('uses grade-adjusted speed for running and rejects hard-zone-heavy evidence', () => { var _a, _b; const output = Array(3600).fill(3.5); const heartRate = Array(3600).fill(145); const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Running, streams: { [data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type]: output, [data_heart_rate_1.DataHeartRate.type]: heartRate }, stats: { [data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type]: 600, [data_heart_rate_zone_five_duration_1.DataHeartRateZoneFiveDuration.type]: 600 } })); expect((_a = result.summary) === null || _a === void 0 ? void 0 : _a.outputSource).toBe('grade-adjusted-speed'); expect((_b = result.summary) === null || _b === void 0 ? void 0 : _b.eligibility).toMatchObject({ eligible: false, reason: 'too-intense' }); }); it('treats Zone 4 as hard while allowing the exact configured boundary', () => { var _a, _b; const streams = { [data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type]: Array(3600).fill(3.5), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(145) }; const boundary = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Running, streams, stats: { [data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type]: 1200, [data_heart_rate_zone_four_duration_1.DataHeartRateZoneFourDuration.type]: 300 } })); const overBoundary = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Running, streams, stats: { [data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type]: 1200, [data_heart_rate_zone_four_duration_1.DataHeartRateZoneFourDuration.type]: 301 } })); expect((_a = boundary.summary) === null || _a === void 0 ? void 0 : _a.eligibility).toMatchObject({ eligible: true, reason: 'eligible' }); expect((_b = overBoundary.summary) === null || _b === void 0 ? void 0 : _b.eligibility).toMatchObject({ eligible: false, reason: 'too-intense' }); }); it('falls back from sparse GAP to complete raw speed only when grade context is sufficiently covered', () => { const partialGap = Array(3600).fill(null); partialGap.fill(3.5, 1000, 1100); const baseStreams = { [data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type]: partialGap, [data_speed_1.DataSpeed.type]: Array(3600).fill(3.4), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(140) }; const safe = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Running, streams: Object.assign(Object.assign({}, baseStreams), { [data_grade_1.DataGrade.type]: Array(3600).fill(0) }) })); const sparseGrades = Array(3600).fill(null); sparseGrades[1000] = 0; const unsafe = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Running, streams: Object.assign(Object.assign({}, baseStreams), { [data_grade_1.DataGrade.type]: sparseGrades }) })); expect(safe.summary).toMatchObject({ outputSource: 'speed', eligibility: { eligible: true } }); expect(unsafe.summary).toMatchObject({ outputSource: 'grade-adjusted-speed', eligibility: { eligible: false, reason: 'unsupported-context' } }); }); it.each([ [activity_types_1.ActivityTypes.MountainBiking, 'cycling', data_power_1.DataPower.type], [activity_types_1.ActivityTypes.TrailRunning, 'running', data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type], [activity_types_1.ActivityTypes.OpenWaterSwimming, 'open-water-swimming', data_speed_1.DataSpeed.type] ])('groups %s into %s durability', (type, discipline, outputType) => { const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: type, streams: { [outputType]: Array(3600).fill(outputType === data_power_1.DataPower.type ? 200 : 3), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(140) } })); expect(result.summary).toMatchObject({ discipline, eligibility: { eligible: true } }); }); it('keeps unsupported activities out of the persisted metric', () => { const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.WeightTraining, streams: { [data_power_1.DataPower.type]: Array(3600).fill(200), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(130) } })); expect(result).toEqual({ timeline: [], summary: null }); }); it('compares like-for-like active pool lengths', () => { var _a, _b, _c; const lengths = Array.from({ length: 72 }, (_, index) => swimLength(index, index < 24 ? 100 : index >= 48 ? 110 : 105, index < 24 ? 35 : index >= 48 ? 39 : 37)); const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Swimming, durationSeconds: 2700, streams: { [data_speed_1.DataSpeed.type]: [] }, swimLengths: lengths })); expect(result.timeline).toEqual([]); expect((_a = result.summary) === null || _a === void 0 ? void 0 : _a.eligibility).toMatchObject({ eligible: true, reason: 'eligible' }); expect((_b = result.summary) === null || _b === void 0 ? void 0 : _b.evidence).toMatchObject({ kind: 'pool-consistency', poolLengthMeters: 25, stroke: 'freestyle', comparableLengthCount: 72, paceRetentionPercent: 90.909, swolfChange: 4 }); expect((_c = result.summary) === null || _c === void 0 ? void 0 : _c.eligibility).toMatchObject({ comparisonSegments: 'outer-thirds', earlySampleCount: 24, lateSampleCount: 24 }); }); it('keeps short, mixed-context, low-coverage, and hard pool sessions explicit', () => { var _a, _b, _c, _d, _e; const short = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Swimming, durationSeconds: 2700, swimLengths: Array.from({ length: 24 }, (_, index) => swimLength(index, 100, 35)) })); const mixed = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Swimming, durationSeconds: 2700, swimLengths: Array.from({ length: 72 }, (_, index) => swimLength(index, 100, 35, 'mixed')) })); const valid = Array.from({ length: 72 }, (_, index) => swimLength(index, 100, 0)); const missingContextRows = Array.from({ length: 72 }, (_, index) => (Object.assign(Object.assign({}, swimLength(index + 72, 100, 0)), { poolLength: { getValue: () => null }, distance: { getValue: () => null } }))); const lowCoverage = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Swimming, durationSeconds: 3600, swimLengths: [...valid, ...missingContextRows] })); const hard = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Swimming, durationSeconds: 2700, swimLengths: valid, stats: { [data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type]: 1000, [data_heart_rate_zone_four_duration_1.DataHeartRateZoneFourDuration.type]: 500 } })); expect((_a = short.summary) === null || _a === void 0 ? void 0 : _a.eligibility.reason).toBe('insufficient-coverage'); expect((_b = mixed.summary) === null || _b === void 0 ? void 0 : _b.eligibility.reason).toBe('unsupported-context'); expect(lowCoverage.summary).toMatchObject({ coverageRatio: 0.5, eligibility: { reason: 'insufficient-coverage' } }); expect((_c = hard.summary) === null || _c === void 0 ? void 0 : _c.eligibility.reason).toBe('too-intense'); expect((_e = (_d = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Swimming, durationSeconds: 2700, swimLengths: valid })).summary) === null || _d === void 0 ? void 0 : _d.evidence) === null || _e === void 0 ? void 0 : _e.swolfChange).toBeNull(); }); it('supports a summary-only path without allocating a display timeline', () => { var _a; const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Cycling, streams: { [data_power_1.DataPower.type]: Array(3600).fill(200), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(140) } }), { includeTimeline: false }); expect(result.timeline).toEqual([]); expect((_a = result.summary) === null || _a === void 0 ? void 0 : _a.eligibility.eligible).toBe(true); }); it('canonicalizes compact evidence and strips unknown timeline fields', () => { const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Cycling, streams: { [data_power_1.DataPower.type]: Array(3600).fill(200), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(140) } }), { includeTimeline: false }); const stat = new data_durability_evidence_1.DataDurabilityEvidence(Object.assign(Object.assign({}, result.summary), { timeline: [{ output: 999 }] })); expect(JSON.stringify(stat.toJSON())).not.toContain('timeline'); stat.setValue(Object.assign(Object.assign({}, stat.getDurabilityValue()), { timeline: [{ output: 999 }] })); expect(JSON.stringify(stat.toJSON())).not.toContain('timeline'); }); it('round-trips analyzer output while rejecting impossible eligible and inconsistent arithmetic', () => { var _a; const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.Cycling, streams: { [data_power_1.DataPower.type]: Array(3600).fill(200), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(140) } }), { includeTimeline: false }); const summary = result.summary; expect((0, data_durability_evidence_1.normalizeDurabilityEvidenceValue)(summary)).toEqual(summary); expect((0, data_durability_evidence_1.normalizeDurabilityEvidenceValue)(Object.assign(Object.assign({}, summary), { qualifyingDurationSeconds: 0, coverageRatio: 0, eligibility: Object.assign(Object.assign({}, summary.eligibility), { validSampleCount: 0, earlySampleCount: 0, lateSampleCount: 0 }) }))).toBeNull(); expect((0, data_durability_evidence_1.normalizeDurabilityEvidenceValue)(Object.assign(Object.assign({}, summary), { evidence: ((_a = summary.evidence) === null || _a === void 0 ? void 0 : _a.kind) === 'aerobic-efficiency' ? Object.assign(Object.assign({}, summary.evidence), { outputRetentionPercent: summary.evidence.outputRetentionPercent + 1 }) : summary.evidence }))).toBeNull(); }); it('keeps low-speed aerobic evidence canonical after compact rounding', () => { const result = (0, activity_durability_1.analyzeActivityDurability)(mockActivity({ type: activity_types_1.ActivityTypes.OpenWaterSwimming, streams: { [data_speed_1.DataSpeed.type]: Array.from({ length: 3600 }, (_, index) => index < 1800 ? 0.7841418666152612 : 0.7315227118907159), [data_heart_rate_1.DataHeartRate.type]: Array.from({ length: 3600 }, (_, index) => index < 1800 ? 126.27571303159694 : 129.2996705923337) } }), { includeTimeline: false }); expect(result.summary).toMatchObject({ discipline: 'open-water-swimming', eligibility: { eligible: true, reason: 'eligible' } }); expect((0, data_durability_evidence_1.normalizeDurabilityEvidenceValue)(result.summary)).toEqual(result.summary); expect(() => new data_durability_evidence_1.DataDurabilityEvidence(result.summary)).not.toThrow(); }); it('fingerprints protocol inputs deterministically and changes when source evidence changes', () => { const streams = { [data_power_1.DataPower.type]: Array(3600).fill(200), [data_heart_rate_1.DataHeartRate.type]: Array(3600).fill(140) }; const stats = { [data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type]: 1800 }; const activity = mockActivity({ type: activity_types_1.ActivityTypes.Cycling, streams, stats }); const original = (0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(activity); expect((0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(activity)).toBe(original); streams[data_power_1.DataPower.type][1800] = 201; expect((0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(activity)).not.toBe(original); const changedStream = (0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(activity); stats[data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type] = 1799; expect((0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(activity)).not.toBe(changedStream); const lengths = Array.from({ length: 24 }, (_, index) => swimLength(index, 100, 35)); const pool = mockActivity({ type: activity_types_1.ActivityTypes.Swimming, swimLengths: lengths }); const originalPool = (0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(pool); lengths[0].swolf = 36; expect((0, activity_durability_1.calculateActivityDurabilitySourceFingerprint)(pool)).not.toBe(originalPool); }); it('reuses current evidence, regenerates stale evidence, and preserves compact summary-only data', () => { const start = new Date(0); const activity = new activity_1.Activity(start, new Date(3600 * 1000), activity_types_1.ActivityTypes.Cycling, new creator_1.Creator('Test')); activity.addStream(activity.createStream(data_power_1.DataPower.type).setData(Array(3600).fill(200))); activity.addStream(activity.createStream(data_heart_rate_1.DataHeartRate.type).setData(Array(3600).fill(140))); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); const original = activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); expect(activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type)).toBe(original); activity.replaceStreamData(data_power_1.DataPower.type, Array(3600).fill(205)); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); const regenerated = activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type); expect(regenerated).not.toBe(original); expect(regenerated === null || regenerated === void 0 ? void 0 : regenerated.getValue().sourceFingerprint).toMatch(/^durability-v1:[0-9a-f]{16}$/); const compactSummary = regenerated.getValue(); const summaryOnly = new activity_1.Activity(start, new Date(3600 * 1000), activity_types_1.ActivityTypes.Cycling, new creator_1.Creator('Test')); const compactStat = new data_durability_evidence_1.DataDurabilityEvidence(compactSummary); summaryOnly.addStat(compactStat); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(summaryOnly); expect(summaryOnly.getStat(data_durability_evidence_1.DataDurabilityEvidence.type)).toBe(compactStat); expect(JSON.stringify(compactStat.toJSON())).not.toContain('timeline'); }); it('persists only the compact activity stat and does not promote it to an event summary', () => { var _a, _b; const start = new Date(0); const activity = new activity_1.Activity(start, new Date(3600 * 1000), activity_types_1.ActivityTypes.Cycling, new creator_1.Creator('Test')); activity.addStream(activity.createStream(data_power_1.DataPower.type).setData(Array(3600).fill(200))); activity.addStream(activity.createStream(data_heart_rate_1.DataHeartRate.type).setData(Array(3600).fill(140))); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); const stat = activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type); expect(stat).toBeDefined(); expect(stat.getDurabilityValue().protocolVersion).toBe(1); expect(JSON.stringify(stat.toJSON())).not.toContain('timeline'); expect(activity_utilities_1.ActivityUtilities.getSummaryStatsForActivities([activity]).some(summary => summary.getType() === data_durability_evidence_1.DataDurabilityEvidence.type)).toBe(false); const originalValue = stat.getDurabilityValue(); stat.value = Object.assign(Object.assign({}, originalValue), { timeline: [{ output: 999 }] }); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); expect(JSON.stringify((_a = activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type)) === null || _a === void 0 ? void 0 : _a.toJSON())).not.toContain('timeline'); const canonicalStat = activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type); canonicalStat.value = Object.assign(Object.assign({}, canonicalStat.getValue()), { protocolVersion: 0 }); activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity); expect(((_b = activity.getStat(data_durability_evidence_1.DataDurabilityEvidence.type)) === null || _b === void 0 ? void 0 : _b.getValue()).protocolVersion).toBe(1); }); });