@holoscript/plugin-fitness-wellness 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +13 -0
- package/src/__tests__/fitnesssolver.test.ts +413 -0
- package/src/__tests__/runtime-integration.test.ts +130 -0
- package/src/fitnesssolver.ts +431 -0
- package/src/index.ts +29 -0
- package/src/runtime.ts +154 -0
- package/src/traits/ExerciseLibraryTrait.ts +20 -0
- package/src/traits/ProgressTrackerTrait.ts +30 -0
- package/src/traits/RepCounterTrait.ts +26 -0
- package/src/traits/WorkoutTrait.ts +23 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +22 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-fitness-wellness",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"@holoscript/core": "8.0.6"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run --passWithNoTests",
|
|
11
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fitness & wellness solver tests — fitness-wellness-plugin
|
|
3
|
+
*
|
|
4
|
+
* Reference values verified against:
|
|
5
|
+
* - ACSM Guidelines for Exercise Testing & Prescription (10th ed.)
|
|
6
|
+
* - Epley B (1985) poundage chart worked examples
|
|
7
|
+
* - Jackson & Pollock (1978) Br.J.Nutr 40:497-504
|
|
8
|
+
* - Karvonen M et al. (1957) Ann.Med.Exp.Biol.Fenn 35:307-315
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
oneRepMax,
|
|
14
|
+
vo2MaxCooper,
|
|
15
|
+
vo2MaxNonExercise,
|
|
16
|
+
heartRateZones,
|
|
17
|
+
calorieBurn,
|
|
18
|
+
jacksonPollockSkinfold,
|
|
19
|
+
acuteChronicWorkloadRatio,
|
|
20
|
+
analyzeFitness,
|
|
21
|
+
buildFitnessReceipt,
|
|
22
|
+
ACTIVITY_METS,
|
|
23
|
+
type TrainingLoadResult,
|
|
24
|
+
} from '../fitnesssolver';
|
|
25
|
+
|
|
26
|
+
// ─── 1-Rep Max ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe('oneRepMax', () => {
|
|
29
|
+
/**
|
|
30
|
+
* 100 kg for 5 reps:
|
|
31
|
+
* Epley: 100 × (1 + 5/30) = 116.67 kg
|
|
32
|
+
* Brzycki: 100 × 36/(37−5) = 112.50 kg
|
|
33
|
+
* Lander: 100×100/(101.3−13.356) ≈ 113.8 kg
|
|
34
|
+
* Lombardi: 100 × 5^0.10 ≈ 117.0 kg
|
|
35
|
+
*/
|
|
36
|
+
it('1RM with 1 rep = weight itself', () => {
|
|
37
|
+
const r = oneRepMax(100, 1);
|
|
38
|
+
expect(r.average).toBe(100);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('Epley formula: 100 kg × 5 reps ≈ 116.67 kg', () => {
|
|
42
|
+
const r = oneRepMax(100, 5);
|
|
43
|
+
expect(r.epley).toBeCloseTo(100 * (1 + 5 / 30), 4);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('Brzycki formula: 100 kg × 5 reps ≈ 112.5 kg', () => {
|
|
47
|
+
const r = oneRepMax(100, 5);
|
|
48
|
+
expect(r.brzycki).toBeCloseTo(100 * 36 / 32, 3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('higher reps → higher 1RM estimate', () => {
|
|
52
|
+
const r5 = oneRepMax(100, 5);
|
|
53
|
+
const r10 = oneRepMax(100, 10);
|
|
54
|
+
expect(r10.average).toBeGreaterThan(r5.average);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('all four formulas and average are positive', () => {
|
|
58
|
+
const r = oneRepMax(80, 8);
|
|
59
|
+
expect(r.epley).toBeGreaterThan(0);
|
|
60
|
+
expect(r.brzycki).toBeGreaterThan(0);
|
|
61
|
+
expect(r.lander).toBeGreaterThan(0);
|
|
62
|
+
expect(r.lombardi).toBeGreaterThan(0);
|
|
63
|
+
expect(r.average).toBeGreaterThan(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('average equals mean of four formulas', () => {
|
|
67
|
+
const r = oneRepMax(75, 6);
|
|
68
|
+
const manual = (r.epley + r.brzycki + r.lander + r.lombardi) / 4;
|
|
69
|
+
expect(r.average).toBeCloseTo(manual, 8);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws for zero weight', () => {
|
|
73
|
+
expect(() => oneRepMax(0, 5)).toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws for > 30 reps', () => {
|
|
77
|
+
expect(() => oneRepMax(50, 31)).toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ─── VO2max ───────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('vo2MaxCooper', () => {
|
|
84
|
+
/**
|
|
85
|
+
* Cooper (1968): VO2max = (distance − 504.9) / 44.73
|
|
86
|
+
* At 2400 m: VO2 = (2400 − 504.9) / 44.73 ≈ 42.4 ml/kg/min → "good"
|
|
87
|
+
*/
|
|
88
|
+
it('2400 m Cooper distance → VO2 ≈ 42.4 ml/kg/min', () => {
|
|
89
|
+
const r = vo2MaxCooper(2400);
|
|
90
|
+
expect(r.vo2MaxMlKgMin).toBeCloseTo((2400 - 504.9) / 44.73, 2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('higher distance → higher VO2max', () => {
|
|
94
|
+
const r2000 = vo2MaxCooper(2000);
|
|
95
|
+
const r3000 = vo2MaxCooper(3000);
|
|
96
|
+
expect(r3000.vo2MaxMlKgMin).toBeGreaterThan(r2000.vo2MaxMlKgMin);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('method is cooper-12min', () => {
|
|
100
|
+
expect(vo2MaxCooper(2000).method).toBe('cooper-12min');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('VO2max is non-negative even for very short distance', () => {
|
|
104
|
+
const r = vo2MaxCooper(100);
|
|
105
|
+
expect(r.vo2MaxMlKgMin).toBeGreaterThanOrEqual(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws for zero distance', () => {
|
|
109
|
+
expect(() => vo2MaxCooper(0)).toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('vo2MaxNonExercise', () => {
|
|
114
|
+
it('higher PA rating → higher VO2max (active person fitter)', () => {
|
|
115
|
+
const r3 = vo2MaxNonExercise(30, 25, 3, true);
|
|
116
|
+
const r8 = vo2MaxNonExercise(30, 25, 8, true);
|
|
117
|
+
expect(r8.vo2MaxMlKgMin).toBeGreaterThan(r3.vo2MaxMlKgMin);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('male has higher VO2max than female (same age/BMI/PA)', () => {
|
|
121
|
+
const rM = vo2MaxNonExercise(30, 25, 5, true);
|
|
122
|
+
const rF = vo2MaxNonExercise(30, 25, 5, false);
|
|
123
|
+
expect(rM.vo2MaxMlKgMin).toBeGreaterThan(rF.vo2MaxMlKgMin);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('older age → lower VO2max (all else equal)', () => {
|
|
127
|
+
const rYoung = vo2MaxNonExercise(25, 25, 5, true);
|
|
128
|
+
const rOld = vo2MaxNonExercise(55, 25, 5, true);
|
|
129
|
+
expect(rOld.vo2MaxMlKgMin).toBeLessThan(rYoung.vo2MaxMlKgMin);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws for age out of range', () => {
|
|
133
|
+
expect(() => vo2MaxNonExercise(5, 25, 5, true)).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws for PA rating > 10', () => {
|
|
137
|
+
expect(() => vo2MaxNonExercise(30, 25, 11, true)).toThrow();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─── Heart rate zones ─────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe('heartRateZones', () => {
|
|
144
|
+
/**
|
|
145
|
+
* Karvonen: HRzone = hrRest + fraction × (hrMax − hrRest)
|
|
146
|
+
* hrRest=60, hrMax=190 → HRR=130
|
|
147
|
+
* Zone 1 (50-60%): 60 + 65 = 125, 60 + 78 = 138
|
|
148
|
+
*/
|
|
149
|
+
const r = heartRateZones(60, 190);
|
|
150
|
+
|
|
151
|
+
it('HRR = hrMax - hrRest', () => {
|
|
152
|
+
expect(r.hrr).toBe(130);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('zone 1 lower = hrRest + 0.50 × HRR', () => {
|
|
156
|
+
expect(r.zones.zone1.lo).toBe(60 + Math.round(0.5 * 130));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('zone 5 upper = hrMax', () => {
|
|
160
|
+
expect(r.zones.zone5.hi).toBe(190);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('zone boundaries are strictly increasing', () => {
|
|
164
|
+
const zones = [r.zones.zone1, r.zones.zone2, r.zones.zone3, r.zones.zone4, r.zones.zone5];
|
|
165
|
+
for (let i = 1; i < zones.length; i++) {
|
|
166
|
+
expect(zones[i].lo).toBeGreaterThanOrEqual(zones[i - 1].lo);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('throws when hrMax ≤ hrRest', () => {
|
|
171
|
+
expect(() => heartRateZones(70, 60)).toThrow();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── Calorie burn ─────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('calorieBurn', () => {
|
|
178
|
+
/**
|
|
179
|
+
* Running at 6 mph (MET=9.8), 70 kg, 30 min:
|
|
180
|
+
* Gross = 9.8 × 70 × 0.5 = 343 kcal
|
|
181
|
+
* Net = (9.8 - 1) × 70 × 0.5 = 308 kcal
|
|
182
|
+
*/
|
|
183
|
+
it('gross kcal = MET × bodyMassKg × durationHours', () => {
|
|
184
|
+
const r = calorieBurn(9.8, 70, 30);
|
|
185
|
+
expect(r.grossKcal).toBeCloseTo(9.8 * 70 * 0.5, 3);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('net kcal = (MET - 1) × bodyMassKg × durationHours', () => {
|
|
189
|
+
const r = calorieBurn(9.8, 70, 30);
|
|
190
|
+
expect(r.netKcal).toBeCloseTo(8.8 * 70 * 0.5, 3);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('net kcal < gross kcal', () => {
|
|
194
|
+
const r = calorieBurn(8.0, 80, 60);
|
|
195
|
+
expect(r.netKcal).toBeLessThan(r.grossKcal);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('grossKcal proportional to duration', () => {
|
|
199
|
+
const r30 = calorieBurn(8, 70, 30);
|
|
200
|
+
const r60 = calorieBurn(8, 70, 60);
|
|
201
|
+
expect(r60.grossKcal).toBeCloseTo(r30.grossKcal * 2, 6);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('ACTIVITY_METS contains running_6mph = 9.8', () => {
|
|
205
|
+
expect(ACTIVITY_METS['running_6mph']).toBe(9.8);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('throws for zero MET', () => {
|
|
209
|
+
expect(() => calorieBurn(0, 70, 30)).toThrow();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('throws for zero body mass', () => {
|
|
213
|
+
expect(() => calorieBurn(8, 0, 30)).toThrow();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ─── Jackson-Pollock body composition ────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
describe('jacksonPollockSkinfold', () => {
|
|
220
|
+
/**
|
|
221
|
+
* Male, age 30, skinfolds: chest=10, abdomen=15, thigh=12 → sum=37
|
|
222
|
+
* Density ≈ 1.10938 - 0.0008267×37 + 0.0000016×1369 - 0.0002574×30
|
|
223
|
+
* ≈ 1.10938 - 0.030588 + 0.0021904 - 0.0077220
|
|
224
|
+
* ≈ 1.0740
|
|
225
|
+
* %BF = (4.95/1.0740 - 4.50) × 100 ≈ (4.609 - 4.50) × 100 ≈ 10.9%
|
|
226
|
+
*/
|
|
227
|
+
const male = jacksonPollockSkinfold(10, 15, 12, 30, true, 80);
|
|
228
|
+
|
|
229
|
+
it('male body fat % between 5% and 30% for lean measurements', () => {
|
|
230
|
+
expect(male.bodyFatPct).toBeGreaterThan(5);
|
|
231
|
+
expect(male.bodyFatPct).toBeLessThan(30);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('fat mass + lean mass = body mass', () => {
|
|
235
|
+
expect(male.fatMassKg + male.leanMassKg).toBeCloseTo(80, 5);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('skinfold sum = s1 + s2 + s3', () => {
|
|
239
|
+
expect(male.skinfoldSumMm).toBe(10 + 15 + 12);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('body density ~1.07 (plausible for lean male)', () => {
|
|
243
|
+
expect(male.bodyDensityGcc).toBeGreaterThan(1.04);
|
|
244
|
+
expect(male.bodyDensityGcc).toBeLessThan(1.10);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('higher skinfolds → higher body fat %', () => {
|
|
248
|
+
const lean = jacksonPollockSkinfold(10, 12, 10, 30, true, 80);
|
|
249
|
+
const obese = jacksonPollockSkinfold(35, 50, 40, 30, true, 80);
|
|
250
|
+
expect(obese.bodyFatPct).toBeGreaterThan(lean.bodyFatPct);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('throws for non-positive skinfold', () => {
|
|
254
|
+
expect(() => jacksonPollockSkinfold(0, 15, 12, 30, true, 80)).toThrow();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('throws for age outside [18, 80]', () => {
|
|
258
|
+
expect(() => jacksonPollockSkinfold(10, 15, 12, 15, true, 80)).toThrow();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ─── ACWR training load ───────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
describe('acuteChronicWorkloadRatio', () => {
|
|
265
|
+
/**
|
|
266
|
+
* Stable training: 7 days all at AU=100
|
|
267
|
+
* Acute = 100, Chronic = 100, ACWR = 1.0 → low risk
|
|
268
|
+
*/
|
|
269
|
+
it('stable load ACWR = 1.0 → low risk', () => {
|
|
270
|
+
const loads = Array(28).fill(100);
|
|
271
|
+
const r = acuteChronicWorkloadRatio(loads);
|
|
272
|
+
expect(r.acwr).toBeCloseTo(1.0, 5);
|
|
273
|
+
expect(r.riskCategory).toBe('low');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('spike in acute load → high ACWR', () => {
|
|
277
|
+
const loads = [...Array(21).fill(100), ...Array(7).fill(200)];
|
|
278
|
+
const r = acuteChronicWorkloadRatio(loads);
|
|
279
|
+
expect(r.acwr).toBeGreaterThan(1.3);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('very high spike (>1.5×) → very-high risk', () => {
|
|
283
|
+
const loads = [...Array(21).fill(100), ...Array(7).fill(300)];
|
|
284
|
+
const r = acuteChronicWorkloadRatio(loads);
|
|
285
|
+
expect(r.riskCategory).toBe('very-high');
|
|
286
|
+
expect(r.acwr).toBeGreaterThan(1.5);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('acuteLoad = mean of last 7 days', () => {
|
|
290
|
+
const loads = [...Array(21).fill(80), 100, 120, 140, 160, 180, 200, 220];
|
|
291
|
+
const r = acuteChronicWorkloadRatio(loads);
|
|
292
|
+
const expected = (100 + 120 + 140 + 160 + 180 + 200 + 220) / 7;
|
|
293
|
+
expect(r.acuteLoad).toBeCloseTo(expected, 4);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('throws for fewer than 7 days', () => {
|
|
297
|
+
expect(() => acuteChronicWorkloadRatio([100, 100, 100])).toThrow();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ─── ACWR risk-band classification (Gabbett 2016) ─────────────────────────────
|
|
302
|
+
// Regression for the fixed band→category mapping (was: dead `>1.5` branch +
|
|
303
|
+
// ACWR<0.8 mislabelled 'high' alongside the >1.5 danger zone). Each band must
|
|
304
|
+
// map to exactly one category. Boundaries are derived from the solver, not the
|
|
305
|
+
// old buggy output:
|
|
306
|
+
// high → ACWR < 0.8 | low → 0.8..1.3 | moderate → 1.3..1.5 | very-high → >1.5
|
|
307
|
+
describe('acuteChronicWorkloadRatio — risk-band classification', () => {
|
|
308
|
+
// Build a 14-day series whose ACWR equals `target`. Over a 14-day window the
|
|
309
|
+
// chronic load is the mean of all 14 days and the acute load is the mean of
|
|
310
|
+
// the last 7. With the first 7 days at baseline `c` and the last 7 at `a`:
|
|
311
|
+
// acwr = 2a / (c + a) ⇒ a = c · target / (2 − target)
|
|
312
|
+
const seriesForACWR = (target: number, c = 1000): number[] => {
|
|
313
|
+
const a = (c * target) / (2 - target);
|
|
314
|
+
return [...Array<number>(7).fill(c), ...Array<number>(7).fill(a)];
|
|
315
|
+
};
|
|
316
|
+
const categoryAt = (target: number): TrainingLoadResult['riskCategory'] =>
|
|
317
|
+
acuteChronicWorkloadRatio(seriesForACWR(target)).riskCategory;
|
|
318
|
+
|
|
319
|
+
it('ACWR < 0.8 (under-prepared / detraining) → high', () => {
|
|
320
|
+
const r = acuteChronicWorkloadRatio(seriesForACWR(0.6));
|
|
321
|
+
expect(r.acwr).toBeCloseTo(0.6, 6);
|
|
322
|
+
expect(r.riskCategory).toBe('high');
|
|
323
|
+
expect(categoryAt(0.79)).toBe('high'); // just below the sweet-spot floor
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('0.8 ≤ ACWR ≤ 1.3 (sweet spot) → low', () => {
|
|
327
|
+
const r = acuteChronicWorkloadRatio(seriesForACWR(1.0));
|
|
328
|
+
expect(r.acwr).toBeCloseTo(1.0, 6);
|
|
329
|
+
expect(r.riskCategory).toBe('low');
|
|
330
|
+
expect(categoryAt(0.81)).toBe('low'); // sweet-spot floor (0.8 inclusive)
|
|
331
|
+
expect(categoryAt(1.29)).toBe('low'); // sweet-spot ceiling (1.3 inclusive)
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('1.3 < ACWR ≤ 1.5 (ramp-up) → moderate', () => {
|
|
335
|
+
const r = acuteChronicWorkloadRatio(seriesForACWR(1.4));
|
|
336
|
+
expect(r.acwr).toBeCloseTo(1.4, 6);
|
|
337
|
+
expect(r.riskCategory).toBe('moderate');
|
|
338
|
+
expect(categoryAt(1.31)).toBe('moderate'); // just past the sweet-spot ceiling
|
|
339
|
+
expect(categoryAt(1.49)).toBe('moderate'); // just below the danger-zone floor
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('ACWR > 1.5 (danger zone) → very-high', () => {
|
|
343
|
+
const r = acuteChronicWorkloadRatio(seriesForACWR(1.8));
|
|
344
|
+
expect(r.acwr).toBeCloseTo(1.8, 6);
|
|
345
|
+
expect(r.riskCategory).toBe('very-high');
|
|
346
|
+
expect(categoryAt(1.51)).toBe('very-high'); // just past the danger-zone floor
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('maps the four bands onto exactly the four documented categories', () => {
|
|
350
|
+
const assigned = new Set([0.6, 1.0, 1.4, 1.8].map(categoryAt));
|
|
351
|
+
expect(assigned).toEqual(new Set(['high', 'low', 'moderate', 'very-high']));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ─── analyzeFitness ───────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
describe('analyzeFitness', () => {
|
|
358
|
+
it('returns all sub-results when all inputs provided', () => {
|
|
359
|
+
const r = analyzeFitness({
|
|
360
|
+
oneRM: { weightKg: 100, reps: 5 },
|
|
361
|
+
vo2Max: { method: 'cooper', distanceM: 2400 },
|
|
362
|
+
hrZones: { hrRest: 60, hrMax: 190 },
|
|
363
|
+
calories: { met: 9.8, bodyMassKg: 70, durationMin: 30 },
|
|
364
|
+
bodyComp: { s1: 10, s2: 15, s3: 12, ageYears: 30, isMale: true, bodyMassKg: 80 },
|
|
365
|
+
trainingLoad: { workloads: Array(28).fill(100) },
|
|
366
|
+
});
|
|
367
|
+
expect(r.oneRM).toBeDefined();
|
|
368
|
+
expect(r.vo2Max).toBeDefined();
|
|
369
|
+
expect(r.hrZones).toBeDefined();
|
|
370
|
+
expect(r.calories).toBeDefined();
|
|
371
|
+
expect(r.bodyComp).toBeDefined();
|
|
372
|
+
expect(r.trainingLoad).toBeDefined();
|
|
373
|
+
expect(r.converged).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('returns only oneRM when only oneRM input given', () => {
|
|
377
|
+
const r = analyzeFitness({ oneRM: { weightKg: 80, reps: 3 } });
|
|
378
|
+
expect(r.oneRM).toBeDefined();
|
|
379
|
+
expect(r.vo2Max).toBeUndefined();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ─── Receipt ─────────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
describe('buildFitnessReceipt', () => {
|
|
386
|
+
it('produces receipt with plugin=fitness-wellness and CAEL event', () => {
|
|
387
|
+
const result = analyzeFitness({ oneRM: { weightKg: 100, reps: 5 } });
|
|
388
|
+
const receipt = buildFitnessReceipt(result);
|
|
389
|
+
expect(receipt.plugin).toBe('fitness-wellness');
|
|
390
|
+
expect(receipt.cael.event).toBe('fitness_wellness.fitness_analysis');
|
|
391
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('accepted=true for normal fitness analysis', () => {
|
|
395
|
+
const result = analyzeFitness({ calories: { met: 8, bodyMassKg: 70, durationMin: 45 } });
|
|
396
|
+
const receipt = buildFitnessReceipt(result);
|
|
397
|
+
expect(receipt.acceptance.accepted).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('uses provided runId', () => {
|
|
401
|
+
const result = analyzeFitness({});
|
|
402
|
+
const receipt = buildFitnessReceipt(result, { runId: 'fit-run-99' });
|
|
403
|
+
expect(receipt.runId).toBe('fit-run-99');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('accepted=false when ACWR > 1.5', () => {
|
|
407
|
+
const loads = [...Array(21).fill(100), ...Array(7).fill(350)];
|
|
408
|
+
const result = analyzeFitness({ trainingLoad: { workloads: loads } });
|
|
409
|
+
const receipt = buildFitnessReceipt(result);
|
|
410
|
+
expect(receipt.acceptance.accepted).toBe(false);
|
|
411
|
+
expect(receipt.acceptance.violations.length).toBeGreaterThan(0);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration proof: the fitness-wellness `one_rep_max` trait, once registered
|
|
3
|
+
* via the runtime's real `registerTrait` seam, is dispatched BY THE RUNTIME and
|
|
4
|
+
* runs the deterministic 1-rep-max prediction solver — NOT called directly as a
|
|
5
|
+
* handler object.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors government-civic-plugin's runtime-integration reference
|
|
8
|
+
* (civic_decision). Drives the real path: executeNode(orb) -> orb-executor ->
|
|
9
|
+
* applyDirectives -> traitHandlers.get('one_rep_max').onAttach -> oneRepMax.
|
|
10
|
+
* The negative control proves the registration is load-bearing (without it, the
|
|
11
|
+
* trait is a dead no-op — which is exactly the tier's status quo).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { HoloScriptRuntime } from '@holoscript/core/runtime';
|
|
15
|
+
import { registerFitnessWellnessTraitHandlers } from '../runtime';
|
|
16
|
+
|
|
17
|
+
// Test fixture: 100 kg lifted for 10 reps.
|
|
18
|
+
//
|
|
19
|
+
// HAND-DERIVED from the formulas in fitnesssolver.ts::oneRepMax (NOT copied
|
|
20
|
+
// from solver output):
|
|
21
|
+
// epley = w*(1 + r/30) = 100*(1 + 10/30) = 100*(4/3) = 133.33333…
|
|
22
|
+
// brzycki = w*36/(37 - r) = 100*36/(37 - 10) = 3600/27 = 133.33333…
|
|
23
|
+
// lander = 100w/(101.3 - 2.67123r) = 10000/(101.3 - 26.7123)= 10000/74.5877 = 134.07036…
|
|
24
|
+
// lombardi = w*r^0.10 = 100*10^0.10 = 100*1.258925 = 125.89254…
|
|
25
|
+
// average = (133.33333 + 133.33333 + 134.07036 + 125.89254)/4
|
|
26
|
+
// = 526.62957/4 = 131.65739…
|
|
27
|
+
const TEST_WEIGHT_KG = 100;
|
|
28
|
+
const TEST_REPS = 10;
|
|
29
|
+
const EXPECTED_EPLEY = 133.333333; // 100 * (1 + 10/30)
|
|
30
|
+
const EXPECTED_BRZYCKI = 133.333333; // 3600 / 27
|
|
31
|
+
const EXPECTED_LANDER = 134.070363; // 10000 / 74.5877
|
|
32
|
+
const EXPECTED_LOMBARDI = 125.892541; // 100 * 10^0.1
|
|
33
|
+
const EXPECTED_AVERAGE = 131.657393; // (epley+brzycki+lander+lombardi)/4
|
|
34
|
+
|
|
35
|
+
function oneRepMaxOrb(config: Record<string, unknown>): unknown {
|
|
36
|
+
return {
|
|
37
|
+
type: 'orb',
|
|
38
|
+
name: 'lifter',
|
|
39
|
+
properties: {},
|
|
40
|
+
methods: [],
|
|
41
|
+
position: [0, 0, 0],
|
|
42
|
+
hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
|
|
43
|
+
directives: [{ type: 'trait', name: 'one_rep_max', config }],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Flush the runtime's async emit dispatch so `on` listeners have fired. */
|
|
48
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
49
|
+
|
|
50
|
+
describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', () => {
|
|
51
|
+
it('runtime dispatch runs the 1RM prediction solver for a registered @one_rep_max orb', async () => {
|
|
52
|
+
const runtime = new HoloScriptRuntime();
|
|
53
|
+
registerFitnessWellnessTraitHandlers(runtime);
|
|
54
|
+
|
|
55
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
56
|
+
runtime.on('one_rep_max_solved', (e: unknown) => {
|
|
57
|
+
solved.push(e as Record<string, unknown>);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await runtime.executeNode(
|
|
61
|
+
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
|
|
62
|
+
);
|
|
63
|
+
await flush();
|
|
64
|
+
|
|
65
|
+
expect(solved).toHaveLength(1);
|
|
66
|
+
const summary = solved[0];
|
|
67
|
+
expect(summary.weightKg).toBe(TEST_WEIGHT_KG);
|
|
68
|
+
expect(summary.reps).toBe(TEST_REPS);
|
|
69
|
+
// Hand-checked against the four formulas (see fixture comment above).
|
|
70
|
+
expect(summary.epley as number).toBeCloseTo(EXPECTED_EPLEY, 4);
|
|
71
|
+
expect(summary.brzycki as number).toBeCloseTo(EXPECTED_BRZYCKI, 4);
|
|
72
|
+
expect(summary.lander as number).toBeCloseTo(EXPECTED_LANDER, 4);
|
|
73
|
+
expect(summary.lombardi as number).toBeCloseTo(EXPECTED_LOMBARDI, 4);
|
|
74
|
+
expect(summary.average as number).toBeCloseTo(EXPECTED_AVERAGE, 4);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('NEGATIVE CONTROL: without registration the @one_rep_max trait is a dead no-op', async () => {
|
|
78
|
+
const runtime = new HoloScriptRuntime(); // intentionally NOT registered
|
|
79
|
+
const solved: unknown[] = [];
|
|
80
|
+
runtime.on('one_rep_max_solved', (e: unknown) => solved.push(e));
|
|
81
|
+
|
|
82
|
+
await runtime.executeNode(
|
|
83
|
+
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
|
|
84
|
+
);
|
|
85
|
+
await flush();
|
|
86
|
+
|
|
87
|
+
expect(solved).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('persists the solver result into durable runtime state on ATTACH', async () => {
|
|
91
|
+
const runtime = new HoloScriptRuntime();
|
|
92
|
+
registerFitnessWellnessTraitHandlers(runtime);
|
|
93
|
+
|
|
94
|
+
await runtime.executeNode(
|
|
95
|
+
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
|
|
96
|
+
);
|
|
97
|
+
await flush();
|
|
98
|
+
|
|
99
|
+
const state = runtime.getState() as Record<string, unknown>;
|
|
100
|
+
const persisted = state['one_rep_max:lifter'] as
|
|
101
|
+
| { epley?: number; average?: number; weightKg?: number }
|
|
102
|
+
| undefined;
|
|
103
|
+
expect(persisted).toBeDefined();
|
|
104
|
+
// Hand-checked: epley = 100*(1 + 10/30) = 133.33333…, average = 131.65739…
|
|
105
|
+
expect(persisted?.weightKg).toBe(TEST_WEIGHT_KG);
|
|
106
|
+
expect(persisted?.epley as number).toBeCloseTo(EXPECTED_EPLEY, 4);
|
|
107
|
+
expect(persisted?.average as number).toBeCloseTo(EXPECTED_AVERAGE, 4);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('emits one_rep_max_error (does not throw through the runtime) for invalid config', async () => {
|
|
111
|
+
const runtime = new HoloScriptRuntime();
|
|
112
|
+
registerFitnessWellnessTraitHandlers(runtime);
|
|
113
|
+
|
|
114
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
115
|
+
runtime.on('one_rep_max_error', (e: unknown) => {
|
|
116
|
+
errors.push(e as Record<string, unknown>);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// reps = 0 is invalid — the real solver throws "reps must be a positive
|
|
120
|
+
// integer", which the handler's try/catch turns into a one_rep_max_error
|
|
121
|
+
// rather than a throw through the runtime.
|
|
122
|
+
await runtime.executeNode(
|
|
123
|
+
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: 0 }) as never,
|
|
124
|
+
);
|
|
125
|
+
await flush();
|
|
126
|
+
|
|
127
|
+
expect(errors).toHaveLength(1);
|
|
128
|
+
expect(String(errors[0].error)).toContain('reps');
|
|
129
|
+
});
|
|
130
|
+
});
|