@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
JavaScript
"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);
});
});