hevy-shared 1.0.974 → 1.0.975
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/built/hevyTrainer.d.ts +31 -3
- package/built/hevyTrainer.js +80 -86
- package/built/index.d.ts +0 -1
- package/built/tests/hevyTrainer.test.d.ts +1 -0
- package/built/tests/hevyTrainer.test.js +1423 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const __1 = require("..");
|
|
4
|
+
const hevyTrainer_1 = require("../hevyTrainer");
|
|
5
|
+
const sortGranular = (arr) => [...arr].sort();
|
|
6
|
+
describe('trainerEquipmentToGranularEquipments', () => {
|
|
7
|
+
it('returns empty array when no equipments are provided', () => {
|
|
8
|
+
expect((0, hevyTrainer_1.trainerEquipmentToGranularEquipments)([])).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
it('returns expected granular equipments for barbell only', () => {
|
|
11
|
+
const result = (0, hevyTrainer_1.trainerEquipmentToGranularEquipments)(['barbell']);
|
|
12
|
+
expect(sortGranular(result)).toEqual(sortGranular([
|
|
13
|
+
'adjustable_bench',
|
|
14
|
+
'barbell',
|
|
15
|
+
'ez_bar',
|
|
16
|
+
'flat_bench',
|
|
17
|
+
'landmine',
|
|
18
|
+
'plate',
|
|
19
|
+
'squat_rack',
|
|
20
|
+
't_bar',
|
|
21
|
+
]));
|
|
22
|
+
});
|
|
23
|
+
it('returns expected granular equipments for dumbbell only', () => {
|
|
24
|
+
const result = (0, hevyTrainer_1.trainerEquipmentToGranularEquipments)(['dumbbell']);
|
|
25
|
+
expect(sortGranular(result)).toEqual(sortGranular(['adjustable_bench', 'dumbbell', 'flat_bench']));
|
|
26
|
+
});
|
|
27
|
+
it('returns expected granular equipments for machine only', () => {
|
|
28
|
+
const result = (0, hevyTrainer_1.trainerEquipmentToGranularEquipments)(['machine']);
|
|
29
|
+
expect(sortGranular(result)).toEqual(sortGranular([
|
|
30
|
+
'adjustable_bench',
|
|
31
|
+
'dip_bar',
|
|
32
|
+
'dual_cable_machine',
|
|
33
|
+
'flat_bench',
|
|
34
|
+
'lat_pulldown_cable',
|
|
35
|
+
'leg_press_machine',
|
|
36
|
+
'plate',
|
|
37
|
+
'plate_machines',
|
|
38
|
+
'pullup_bar',
|
|
39
|
+
'single_cable_machine',
|
|
40
|
+
'smith_machine',
|
|
41
|
+
'stack_machines',
|
|
42
|
+
]));
|
|
43
|
+
});
|
|
44
|
+
it('returns deduped union for multiple equipments', () => {
|
|
45
|
+
const equipments = ['barbell', 'dumbbell'];
|
|
46
|
+
const result = (0, hevyTrainer_1.trainerEquipmentToGranularEquipments)(equipments);
|
|
47
|
+
const barbellOnly = (0, hevyTrainer_1.trainerEquipmentToGranularEquipments)(['barbell']);
|
|
48
|
+
const dumbbellOnly = (0, hevyTrainer_1.trainerEquipmentToGranularEquipments)(['dumbbell']);
|
|
49
|
+
const combined = sortGranular(Array.from(new Set([...barbellOnly, ...dumbbellOnly])));
|
|
50
|
+
expect(sortGranular(result)).toEqual(combined);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const createExercise = (granularEquipments) => ({
|
|
54
|
+
id: 'test-exercise',
|
|
55
|
+
title: 'Test Exercise',
|
|
56
|
+
granular_equipments: granularEquipments,
|
|
57
|
+
});
|
|
58
|
+
describe('isEquipmentCompatible', () => {
|
|
59
|
+
it('returns true when exercise has no granular equipments', () => {
|
|
60
|
+
const exercise = createExercise(undefined);
|
|
61
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['barbell'])).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('returns true when exercise has empty granular equipments', () => {
|
|
64
|
+
const exercise = createExercise([]);
|
|
65
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['barbell'])).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
it('returns true when user has all required equipments', () => {
|
|
68
|
+
const exercise = createExercise(['barbell', 'squat_rack']);
|
|
69
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['barbell', 'squat_rack'])).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('returns false when user is missing required equipment', () => {
|
|
72
|
+
const exercise = createExercise(['barbell', 'squat_rack']);
|
|
73
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['barbell'])).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('returns true when user has more equipment than required', () => {
|
|
76
|
+
const exercise = createExercise(['barbell']);
|
|
77
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['barbell', 'squat_rack', 'dumbbell'])).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
describe('special case: adjustable_bench / flat_bench substitution', () => {
|
|
80
|
+
it('allows adjustable_bench to substitute for flat_bench when exercise requires flat_bench', () => {
|
|
81
|
+
const exercise = createExercise(['flat_bench']);
|
|
82
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['adjustable_bench'])).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it('does NOT allow flat_bench to substitute for adjustable_bench when exercise requires adjustable_bench', () => {
|
|
85
|
+
const exercise = createExercise(['adjustable_bench']);
|
|
86
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['flat_bench'])).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('special case: dual_cable_machine / single_cable_machine substitution', () => {
|
|
90
|
+
it('allows dual_cable_machine to substitute for single_cable_machine when exercise requires single_cable_machine', () => {
|
|
91
|
+
const exercise = createExercise(['single_cable_machine']);
|
|
92
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['dual_cable_machine'])).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('does NOT allow single_cable_machine to substitute for dual_cable_machine when exercise requires dual_cable_machine', () => {
|
|
95
|
+
const exercise = createExercise(['dual_cable_machine']);
|
|
96
|
+
expect((0, hevyTrainer_1.isEquipmentCompatible)(exercise, ['single_cable_machine'])).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('granularEquipmentsToTrainerEquipments', () => {
|
|
101
|
+
it('returns empty array when no granular equipments are provided', () => {
|
|
102
|
+
expect((0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)([])).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
it('returns only "barbell" when a barbell is present', () => {
|
|
105
|
+
expect((0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)(['barbell'])).toEqual([
|
|
106
|
+
'barbell',
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
it('returns only "dumbbell" when a dumbbell is present', () => {
|
|
110
|
+
expect((0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)(['dumbbell'])).toEqual([
|
|
111
|
+
'dumbbell',
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
it('returns "machine" when any machine-specific equipment is present', () => {
|
|
115
|
+
const machineSpecifics = [
|
|
116
|
+
'smith_machine',
|
|
117
|
+
'leg_press_machine',
|
|
118
|
+
'dual_cable_machine',
|
|
119
|
+
'single_cable_machine',
|
|
120
|
+
'lat_pulldown_cable',
|
|
121
|
+
'plate_machines',
|
|
122
|
+
'stack_machines',
|
|
123
|
+
'dip_bar',
|
|
124
|
+
'pullup_bar',
|
|
125
|
+
];
|
|
126
|
+
machineSpecifics.forEach((eq) => {
|
|
127
|
+
expect((0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)([eq])).toEqual(['machine']);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
it('returns all three categories when all are present', () => {
|
|
131
|
+
const result = (0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)([
|
|
132
|
+
'barbell',
|
|
133
|
+
'dumbbell',
|
|
134
|
+
'smith_machine',
|
|
135
|
+
]);
|
|
136
|
+
expect(result).toEqual(['barbell', 'dumbbell', 'machine']);
|
|
137
|
+
});
|
|
138
|
+
it('returns empty array for granular equipment that does not map to any category', () => {
|
|
139
|
+
expect((0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)(['kettlebell'])).toEqual([]);
|
|
140
|
+
expect((0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)(['jump_rope'])).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
it('does not include "machine" when only non-machine-specific granular equipment is present', () => {
|
|
143
|
+
// `flat_bench` is granular equipment but is not considered machine-specific.
|
|
144
|
+
const result = (0, hevyTrainer_1.granularEquipmentsToTrainerEquipments)([
|
|
145
|
+
'barbell',
|
|
146
|
+
'flat_bench',
|
|
147
|
+
]);
|
|
148
|
+
expect(result).toEqual(['barbell']);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe('normalizeExerciseCategory', () => {
|
|
152
|
+
it('returns "compound" for a custom exercise regardless of category', () => {
|
|
153
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({ is_custom: true, category: 'isolation' })).toBe('compound');
|
|
154
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({ is_custom: true, category: 'compound' })).toBe('compound');
|
|
155
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({
|
|
156
|
+
is_custom: true,
|
|
157
|
+
category: 'assistance-compound',
|
|
158
|
+
})).toBe('compound');
|
|
159
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({ is_custom: true })).toBe('compound');
|
|
160
|
+
});
|
|
161
|
+
it('returns "isolation" only when category is explicitly "isolation"', () => {
|
|
162
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({ is_custom: false, category: 'isolation' })).toBe('isolation');
|
|
163
|
+
});
|
|
164
|
+
it('returns "compound" for "compound" or "assistance-compound" categories', () => {
|
|
165
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({ is_custom: false, category: 'compound' })).toBe('compound');
|
|
166
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({
|
|
167
|
+
is_custom: false,
|
|
168
|
+
category: 'assistance-compound',
|
|
169
|
+
})).toBe('compound');
|
|
170
|
+
});
|
|
171
|
+
it('defaults to "compound" when category is undefined', () => {
|
|
172
|
+
expect((0, hevyTrainer_1.normalizeExerciseCategory)({ is_custom: false })).toBe('compound');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
const emptyExercisePriorities = () => __1.muscleGroups.reduce((acc, mg) => {
|
|
176
|
+
acc[mg] = [];
|
|
177
|
+
return acc;
|
|
178
|
+
}, {});
|
|
179
|
+
/**
|
|
180
|
+
* Builds a minimal but fully typed `TrainerAlgorithmSettings` instance.
|
|
181
|
+
*
|
|
182
|
+
* All numeric values are distinct so that lookup tests can assert that
|
|
183
|
+
* `goal`, `frequency`, `rest timer length`, and `category` are routed to the
|
|
184
|
+
* correct cell of each lookup table.
|
|
185
|
+
*/
|
|
186
|
+
const makeSettings = (overrides = {}) => {
|
|
187
|
+
const frequencyStrings = Object.values(hevyTrainer_1.frequencyMap);
|
|
188
|
+
const goals = ['strength', 'build_muscle', 'fat_loss'];
|
|
189
|
+
const goalOffset = {
|
|
190
|
+
strength: 0,
|
|
191
|
+
build_muscle: 100,
|
|
192
|
+
fat_loss: 200,
|
|
193
|
+
};
|
|
194
|
+
const freqOffset = {
|
|
195
|
+
one_day: 1,
|
|
196
|
+
two_days: 2,
|
|
197
|
+
three_days: 3,
|
|
198
|
+
four_days: 4,
|
|
199
|
+
five_days: 5,
|
|
200
|
+
six_days: 6,
|
|
201
|
+
};
|
|
202
|
+
const sets = frequencyStrings.reduce((acc, freqKey) => {
|
|
203
|
+
acc[freqKey] = goals.reduce((gAcc, goal) => {
|
|
204
|
+
gAcc[goal] = freqOffset[freqKey] + goalOffset[goal];
|
|
205
|
+
return gAcc;
|
|
206
|
+
}, {});
|
|
207
|
+
return acc;
|
|
208
|
+
}, {});
|
|
209
|
+
const rep_ranges = goals.reduce((acc, goal) => {
|
|
210
|
+
acc[goal] = {
|
|
211
|
+
compound: {
|
|
212
|
+
rep_range_start: 1 + goalOffset[goal],
|
|
213
|
+
rep_range_end: 5 + goalOffset[goal],
|
|
214
|
+
},
|
|
215
|
+
isolation: {
|
|
216
|
+
rep_range_start: 8 + goalOffset[goal],
|
|
217
|
+
rep_range_end: 12 + goalOffset[goal],
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
return acc;
|
|
221
|
+
}, {});
|
|
222
|
+
const rest_timers = goals.reduce((acc, goal) => {
|
|
223
|
+
acc[goal] = {
|
|
224
|
+
short: {
|
|
225
|
+
compound: 30 + goalOffset[goal],
|
|
226
|
+
isolation: 15 + goalOffset[goal],
|
|
227
|
+
},
|
|
228
|
+
medium: {
|
|
229
|
+
compound: 60 + goalOffset[goal],
|
|
230
|
+
isolation: 45 + goalOffset[goal],
|
|
231
|
+
},
|
|
232
|
+
long: {
|
|
233
|
+
compound: 120 + goalOffset[goal],
|
|
234
|
+
isolation: 90 + goalOffset[goal],
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
return acc;
|
|
238
|
+
}, {});
|
|
239
|
+
const templates = hevyTrainer_1.routineNames.reduce((acc, name) => {
|
|
240
|
+
acc[name] = { exercises: [] };
|
|
241
|
+
return acc;
|
|
242
|
+
}, {});
|
|
243
|
+
return Object.assign({ sets,
|
|
244
|
+
rep_ranges,
|
|
245
|
+
rest_timers,
|
|
246
|
+
templates, exercise_priorities: emptyExercisePriorities(), exercise_notes: {}, exercise_replacements: {} }, overrides);
|
|
247
|
+
};
|
|
248
|
+
describe('getTrainerSetCount / getTrainerRepRange / getTrainerRestTimerSeconds', () => {
|
|
249
|
+
const settings = makeSettings();
|
|
250
|
+
it('looks up set count by frequency and goal', () => {
|
|
251
|
+
// strength offset = 0, frequency 1 -> one_day -> 1
|
|
252
|
+
expect((0, hevyTrainer_1.getTrainerSetCount)(settings, 'strength', 1)).toBe(1);
|
|
253
|
+
// build_muscle offset = 100, frequency 3 -> three_days -> 103
|
|
254
|
+
expect((0, hevyTrainer_1.getTrainerSetCount)(settings, 'build_muscle', 3)).toBe(103);
|
|
255
|
+
// fat_loss offset = 200, frequency 6 -> six_days -> 206
|
|
256
|
+
expect((0, hevyTrainer_1.getTrainerSetCount)(settings, 'fat_loss', 6)).toBe(206);
|
|
257
|
+
});
|
|
258
|
+
it('looks up rep range by goal and exercise category', () => {
|
|
259
|
+
expect((0, hevyTrainer_1.getTrainerRepRange)(settings, 'strength', 'compound')).toEqual({
|
|
260
|
+
rep_range_start: 1,
|
|
261
|
+
rep_range_end: 5,
|
|
262
|
+
});
|
|
263
|
+
expect((0, hevyTrainer_1.getTrainerRepRange)(settings, 'strength', 'isolation')).toEqual({
|
|
264
|
+
rep_range_start: 8,
|
|
265
|
+
rep_range_end: 12,
|
|
266
|
+
});
|
|
267
|
+
expect((0, hevyTrainer_1.getTrainerRepRange)(settings, 'build_muscle', 'compound')).toEqual({
|
|
268
|
+
rep_range_start: 101,
|
|
269
|
+
rep_range_end: 105,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
it('looks up rest timer by goal, length, and category', () => {
|
|
273
|
+
expect((0, hevyTrainer_1.getTrainerRestTimerSeconds)(settings, 'strength', 'short', 'compound')).toBe(30);
|
|
274
|
+
expect((0, hevyTrainer_1.getTrainerRestTimerSeconds)(settings, 'strength', 'medium', 'isolation')).toBe(45);
|
|
275
|
+
expect((0, hevyTrainer_1.getTrainerRestTimerSeconds)(settings, 'fat_loss', 'long', 'compound')).toBe(320);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
describe('workout duration defaults', () => {
|
|
279
|
+
it('exposes a non-empty set of workout duration options', () => {
|
|
280
|
+
expect(hevyTrainer_1.workoutDurationOptions.length).toBeGreaterThan(0);
|
|
281
|
+
hevyTrainer_1.workoutDurationOptions.forEach((duration) => {
|
|
282
|
+
expect(typeof duration).toBe('number');
|
|
283
|
+
expect(duration).toBeGreaterThan(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
it('defines a default duration for every weekly training frequency', () => {
|
|
287
|
+
__1.weeklyTrainingFrequencies.forEach((frequency) => {
|
|
288
|
+
expect(hevyTrainer_1.defaultDurationPerFrequency[frequency]).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
it('only uses values from workoutDurationOptions as defaults', () => {
|
|
292
|
+
const allowed = new Set(hevyTrainer_1.workoutDurationOptions);
|
|
293
|
+
Object.values(hevyTrainer_1.defaultDurationPerFrequency).forEach((duration) => {
|
|
294
|
+
expect(allowed.has(duration)).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
const makeExercise = (overrides = {}) => (Object.assign({ id: 'ex', title: 'Exercise', priority: 0, muscle_group: 'chest', other_muscles: [], exercise_type: 'weight_reps', equipment_category: 'barbell', category: 'compound', level: ['beginner', 'intermediate', 'advanced'], goal: ['strength', 'build_muscle', 'fat_loss'], granular_equipments: undefined }, overrides));
|
|
299
|
+
describe('getPrioritySortedExercises', () => {
|
|
300
|
+
it('returns an entry for every muscle group, even when none are provided', () => {
|
|
301
|
+
const result = (0, hevyTrainer_1.getPrioritySortedExercises)(emptyExercisePriorities(), []);
|
|
302
|
+
__1.muscleGroups.forEach((mg) => {
|
|
303
|
+
expect(Array.isArray(result[mg])).toBe(true);
|
|
304
|
+
expect(result[mg]).toEqual([]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
it('orders exercises by the provided priority list', () => {
|
|
308
|
+
const a = makeExercise({ id: 'a', muscle_group: 'chest', priority: 1 });
|
|
309
|
+
const b = makeExercise({ id: 'b', muscle_group: 'chest', priority: 2 });
|
|
310
|
+
const c = makeExercise({ id: 'c', muscle_group: 'chest', priority: 3 });
|
|
311
|
+
const priorities = emptyExercisePriorities();
|
|
312
|
+
priorities.chest = ['c', 'a', 'b'];
|
|
313
|
+
const result = (0, hevyTrainer_1.getPrioritySortedExercises)(priorities, [a, b, c]);
|
|
314
|
+
expect(result.chest.map((e) => e.id)).toEqual(['c', 'a', 'b']);
|
|
315
|
+
});
|
|
316
|
+
it('appends exercises missing from the priority list, sorted by descending priority', () => {
|
|
317
|
+
const prioritized = makeExercise({
|
|
318
|
+
id: 'p',
|
|
319
|
+
muscle_group: 'chest',
|
|
320
|
+
priority: 0,
|
|
321
|
+
});
|
|
322
|
+
const highPriorityLeftover = makeExercise({
|
|
323
|
+
id: 'hi',
|
|
324
|
+
muscle_group: 'chest',
|
|
325
|
+
priority: 10,
|
|
326
|
+
});
|
|
327
|
+
const lowPriorityLeftover = makeExercise({
|
|
328
|
+
id: 'lo',
|
|
329
|
+
muscle_group: 'chest',
|
|
330
|
+
priority: 1,
|
|
331
|
+
});
|
|
332
|
+
const priorities = emptyExercisePriorities();
|
|
333
|
+
priorities.chest = ['p'];
|
|
334
|
+
const result = (0, hevyTrainer_1.getPrioritySortedExercises)(priorities, [
|
|
335
|
+
prioritized,
|
|
336
|
+
lowPriorityLeftover,
|
|
337
|
+
highPriorityLeftover,
|
|
338
|
+
]);
|
|
339
|
+
expect(result.chest.map((e) => e.id)).toEqual(['p', 'hi', 'lo']);
|
|
340
|
+
});
|
|
341
|
+
it('skips priority ids that are missing from the exercise store', () => {
|
|
342
|
+
const a = makeExercise({ id: 'a', muscle_group: 'chest', priority: 1 });
|
|
343
|
+
const priorities = emptyExercisePriorities();
|
|
344
|
+
priorities.chest = ['missing', 'a'];
|
|
345
|
+
// The implementation `console.log`s missing ids as a debug aid; silence
|
|
346
|
+
// the output here so it doesn't pollute the jest report. We intentionally
|
|
347
|
+
// don't assert on the log because it's a debug artifact, not part of the
|
|
348
|
+
// contract we want to freeze.
|
|
349
|
+
const logSpy = jest
|
|
350
|
+
.spyOn(console, 'log')
|
|
351
|
+
.mockImplementation(() => undefined);
|
|
352
|
+
const result = (0, hevyTrainer_1.getPrioritySortedExercises)(priorities, [a]);
|
|
353
|
+
expect(result.chest.map((e) => e.id)).toEqual(['a']);
|
|
354
|
+
logSpy.mockRestore();
|
|
355
|
+
});
|
|
356
|
+
it('only places an exercise under its own muscle_group bucket', () => {
|
|
357
|
+
const chestExercise = makeExercise({
|
|
358
|
+
id: 'chest-ex',
|
|
359
|
+
muscle_group: 'chest',
|
|
360
|
+
priority: 1,
|
|
361
|
+
});
|
|
362
|
+
const legExercise = makeExercise({
|
|
363
|
+
id: 'leg-ex',
|
|
364
|
+
muscle_group: 'quadriceps',
|
|
365
|
+
priority: 1,
|
|
366
|
+
});
|
|
367
|
+
const result = (0, hevyTrainer_1.getPrioritySortedExercises)(emptyExercisePriorities(), [
|
|
368
|
+
chestExercise,
|
|
369
|
+
legExercise,
|
|
370
|
+
]);
|
|
371
|
+
expect(result.chest.map((e) => e.id)).toEqual(['chest-ex']);
|
|
372
|
+
expect(result.quadriceps.map((e) => e.id)).toEqual(['leg-ex']);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
const baseCriteria = (overrides = {}) => (Object.assign({ exerciseCategory: 'compound', equipments: [], routineBarbellExerciseCount: 0, level: 'beginner', goal: 'strength', muscleGroup: 'chest', frequency: 3 }, overrides));
|
|
376
|
+
/** Produces a `sortedExercises` record where only `chest` is populated. */
|
|
377
|
+
const chestOnlySorted = (exercises) => {
|
|
378
|
+
const empty = __1.muscleGroups.reduce((acc, mg) => {
|
|
379
|
+
acc[mg] = [];
|
|
380
|
+
return acc;
|
|
381
|
+
}, {});
|
|
382
|
+
return Object.assign(Object.assign({}, empty), { chest: exercises });
|
|
383
|
+
};
|
|
384
|
+
describe('pickExerciseForPrescription', () => {
|
|
385
|
+
it('pass 1: returns the first matching exercise that is not used in the program', () => {
|
|
386
|
+
const used = makeExercise({ id: 'used' });
|
|
387
|
+
const fresh = makeExercise({ id: 'fresh' });
|
|
388
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
389
|
+
sortedExercises: chestOnlySorted([used, fresh]),
|
|
390
|
+
criteria: baseCriteria(),
|
|
391
|
+
context: {
|
|
392
|
+
programUsedExerciseIds: new Set(['used']),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('fresh');
|
|
396
|
+
});
|
|
397
|
+
it('pass 2: falls back to exercises not used in the current routine when all are used in the program', () => {
|
|
398
|
+
const onlyExercise = makeExercise({ id: 'only' });
|
|
399
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
400
|
+
sortedExercises: chestOnlySorted([onlyExercise]),
|
|
401
|
+
criteria: baseCriteria(),
|
|
402
|
+
context: {
|
|
403
|
+
// Marked as used in the whole program already.
|
|
404
|
+
programUsedExerciseIds: new Set(['only']),
|
|
405
|
+
// But not used in this routine, so pass 2 should succeed.
|
|
406
|
+
routineUsedExerciseIds: new Set(),
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('only');
|
|
410
|
+
});
|
|
411
|
+
it('excludes exercises in `excludedExerciseIds` across all passes', () => {
|
|
412
|
+
const excluded = makeExercise({ id: 'excluded' });
|
|
413
|
+
const alternative = makeExercise({ id: 'alt' });
|
|
414
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
415
|
+
sortedExercises: chestOnlySorted([excluded, alternative]),
|
|
416
|
+
criteria: baseCriteria(),
|
|
417
|
+
context: {
|
|
418
|
+
excludedExerciseIds: new Set(['excluded']),
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('alt');
|
|
422
|
+
});
|
|
423
|
+
it('returns undefined when no exercise satisfies any pass', () => {
|
|
424
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
425
|
+
sortedExercises: chestOnlySorted([]),
|
|
426
|
+
criteria: baseCriteria(),
|
|
427
|
+
context: {},
|
|
428
|
+
});
|
|
429
|
+
expect(result).toBeUndefined();
|
|
430
|
+
});
|
|
431
|
+
it('respects the equipment filter (skips exercises that need unavailable equipment)', () => {
|
|
432
|
+
const needsBarbell = makeExercise({
|
|
433
|
+
id: 'barbell-ex',
|
|
434
|
+
granular_equipments: ['barbell'],
|
|
435
|
+
});
|
|
436
|
+
const bodyweight = makeExercise({
|
|
437
|
+
id: 'bw',
|
|
438
|
+
granular_equipments: [],
|
|
439
|
+
});
|
|
440
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
441
|
+
sortedExercises: chestOnlySorted([needsBarbell, bodyweight]),
|
|
442
|
+
criteria: baseCriteria({ equipments: [] }),
|
|
443
|
+
context: {},
|
|
444
|
+
});
|
|
445
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('bw');
|
|
446
|
+
});
|
|
447
|
+
it('respects the level and goal filters', () => {
|
|
448
|
+
const wrongLevel = makeExercise({ id: 'wrong', level: ['advanced'] });
|
|
449
|
+
const wrongGoal = makeExercise({ id: 'wrongGoal', goal: ['fat_loss'] });
|
|
450
|
+
const rightOne = makeExercise({ id: 'right' });
|
|
451
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
452
|
+
sortedExercises: chestOnlySorted([wrongLevel, wrongGoal, rightOne]),
|
|
453
|
+
criteria: baseCriteria({ level: 'beginner', goal: 'strength' }),
|
|
454
|
+
context: {},
|
|
455
|
+
});
|
|
456
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('right');
|
|
457
|
+
});
|
|
458
|
+
it('considers handpicked exercises before sortedExercises', () => {
|
|
459
|
+
const hand = makeExercise({ id: 'hand' });
|
|
460
|
+
const sorted = makeExercise({ id: 'sorted' });
|
|
461
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
462
|
+
sortedExercises: chestOnlySorted([sorted]),
|
|
463
|
+
handPickedExercises: [hand],
|
|
464
|
+
criteria: baseCriteria(),
|
|
465
|
+
context: {},
|
|
466
|
+
});
|
|
467
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('hand');
|
|
468
|
+
});
|
|
469
|
+
it('pass 5: relaxes the category filter when no isolation matches are available', () => {
|
|
470
|
+
// Ask for isolation, provide only compound candidates. Pass 5 should
|
|
471
|
+
// re-run the search with exerciseCategory: 'all' and return the compound.
|
|
472
|
+
const compoundOnly = makeExercise({
|
|
473
|
+
id: 'compound',
|
|
474
|
+
category: 'compound',
|
|
475
|
+
});
|
|
476
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
477
|
+
sortedExercises: chestOnlySorted([compoundOnly]),
|
|
478
|
+
criteria: baseCriteria({ exerciseCategory: 'isolation' }),
|
|
479
|
+
context: {},
|
|
480
|
+
});
|
|
481
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('compound');
|
|
482
|
+
});
|
|
483
|
+
it('pass 6: falls back to exercises whose other_muscles include the target muscle', () => {
|
|
484
|
+
const secondaryChest = makeExercise({
|
|
485
|
+
id: 'secondary',
|
|
486
|
+
muscle_group: 'shoulders',
|
|
487
|
+
other_muscles: ['chest'],
|
|
488
|
+
});
|
|
489
|
+
const sorted = __1.muscleGroups.reduce((acc, mg) => {
|
|
490
|
+
acc[mg] = [];
|
|
491
|
+
return acc;
|
|
492
|
+
}, {});
|
|
493
|
+
sorted.shoulders = [secondaryChest];
|
|
494
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
495
|
+
sortedExercises: sorted,
|
|
496
|
+
criteria: baseCriteria({ muscleGroup: 'chest' }),
|
|
497
|
+
context: {},
|
|
498
|
+
});
|
|
499
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('secondary');
|
|
500
|
+
});
|
|
501
|
+
describe('barbell cap at frequency === 1', () => {
|
|
502
|
+
const barbellExercise = (id) => makeExercise({ id, equipment_category: 'barbell' });
|
|
503
|
+
it('allows barbell exercises regardless of count when frequency > 1', () => {
|
|
504
|
+
const ex = barbellExercise('bench');
|
|
505
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
506
|
+
sortedExercises: chestOnlySorted([ex]),
|
|
507
|
+
criteria: baseCriteria({
|
|
508
|
+
frequency: 3,
|
|
509
|
+
routineBarbellExerciseCount: 10,
|
|
510
|
+
equipments: ['dumbbell'],
|
|
511
|
+
}),
|
|
512
|
+
context: {},
|
|
513
|
+
});
|
|
514
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('bench');
|
|
515
|
+
});
|
|
516
|
+
it('allows barbell exercises under the cap when frequency === 1', () => {
|
|
517
|
+
const ex = barbellExercise('bench');
|
|
518
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
519
|
+
sortedExercises: chestOnlySorted([ex]),
|
|
520
|
+
criteria: baseCriteria({
|
|
521
|
+
frequency: 1,
|
|
522
|
+
routineBarbellExerciseCount: 2,
|
|
523
|
+
equipments: ['dumbbell'],
|
|
524
|
+
}),
|
|
525
|
+
context: {},
|
|
526
|
+
});
|
|
527
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('bench');
|
|
528
|
+
});
|
|
529
|
+
it('rejects barbell exercises at or over the cap when a substitute is available', () => {
|
|
530
|
+
const ex = barbellExercise('bench');
|
|
531
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
532
|
+
sortedExercises: chestOnlySorted([ex]),
|
|
533
|
+
criteria: baseCriteria({
|
|
534
|
+
frequency: 1,
|
|
535
|
+
routineBarbellExerciseCount: 3,
|
|
536
|
+
// Dumbbell is listed as a barbell substitute, so the cap applies.
|
|
537
|
+
equipments: ['dumbbell'],
|
|
538
|
+
}),
|
|
539
|
+
context: {},
|
|
540
|
+
});
|
|
541
|
+
expect(result).toBeUndefined();
|
|
542
|
+
});
|
|
543
|
+
it('still allows barbell exercises over the cap when the user has no substitutes', () => {
|
|
544
|
+
const ex = barbellExercise('bench');
|
|
545
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
546
|
+
sortedExercises: chestOnlySorted([ex]),
|
|
547
|
+
criteria: baseCriteria({
|
|
548
|
+
frequency: 1,
|
|
549
|
+
routineBarbellExerciseCount: 5,
|
|
550
|
+
// No substitutes → avoid dead-ends by allowing the barbell.
|
|
551
|
+
equipments: [],
|
|
552
|
+
}),
|
|
553
|
+
context: {},
|
|
554
|
+
});
|
|
555
|
+
expect(result === null || result === void 0 ? void 0 : result.id).toBe('bench');
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
it('returns a trace when `withTrace: true` and records the winning pass', () => {
|
|
559
|
+
var _a;
|
|
560
|
+
const fresh = makeExercise({ id: 'fresh' });
|
|
561
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
562
|
+
sortedExercises: chestOnlySorted([fresh]),
|
|
563
|
+
criteria: baseCriteria(),
|
|
564
|
+
context: {},
|
|
565
|
+
withTrace: true,
|
|
566
|
+
});
|
|
567
|
+
expect((_a = result.exercise) === null || _a === void 0 ? void 0 : _a.id).toBe('fresh');
|
|
568
|
+
expect(result.trace.selectedPass).toBe(1);
|
|
569
|
+
expect(result.trace.entries[0]).toMatchObject({
|
|
570
|
+
pass: 1,
|
|
571
|
+
selectedExerciseId: 'fresh',
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
it('returns a trace with no selected pass when nothing matches', () => {
|
|
575
|
+
const result = (0, hevyTrainer_1.pickExerciseForPrescription)({
|
|
576
|
+
sortedExercises: chestOnlySorted([]),
|
|
577
|
+
criteria: baseCriteria(),
|
|
578
|
+
context: {},
|
|
579
|
+
withTrace: true,
|
|
580
|
+
});
|
|
581
|
+
expect(result.exercise).toBeUndefined();
|
|
582
|
+
expect(result.trace.selectedPass).toBeUndefined();
|
|
583
|
+
// Six passes should have been attempted.
|
|
584
|
+
expect(result.trace.entries).toHaveLength(6);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
describe('generateProgram', () => {
|
|
588
|
+
const buildSettingsForChestProgram = () => {
|
|
589
|
+
const settings = makeSettings();
|
|
590
|
+
// Put a single chest compound prescription into the 1-day full body routine.
|
|
591
|
+
settings.templates.full_body_1 = {
|
|
592
|
+
exercises: [
|
|
593
|
+
{ muscle_group: 'chest', category: 'compound', warmup_set_count: 2 },
|
|
594
|
+
],
|
|
595
|
+
notes: 'Hit the chest hard.',
|
|
596
|
+
};
|
|
597
|
+
return settings;
|
|
598
|
+
};
|
|
599
|
+
it('produces one routine per programSplit and populates exercise fields from the settings', () => {
|
|
600
|
+
const settings = buildSettingsForChestProgram();
|
|
601
|
+
const chestExercise = makeExercise({ id: 'bench', muscle_group: 'chest' });
|
|
602
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
603
|
+
trainerAlgorithmSettings: settings,
|
|
604
|
+
frequency: 1,
|
|
605
|
+
goal: 'strength',
|
|
606
|
+
level: 'beginner',
|
|
607
|
+
equipments: [],
|
|
608
|
+
workoutDurationMinutes: 60,
|
|
609
|
+
restTimerLength: 'medium',
|
|
610
|
+
exerciseStore: [chestExercise],
|
|
611
|
+
cardioPreference: undefined,
|
|
612
|
+
});
|
|
613
|
+
expect(result.success).toBe(true);
|
|
614
|
+
if (!result.success)
|
|
615
|
+
return;
|
|
616
|
+
expect(result.program.name).toBe(1);
|
|
617
|
+
expect(result.program.routines).toHaveLength(hevyTrainer_1.programSplits[1].length);
|
|
618
|
+
const [routine] = result.program.routines;
|
|
619
|
+
expect(routine.name).toBe('full_body_1');
|
|
620
|
+
expect(routine.notes).toBe('Hit the chest hard.');
|
|
621
|
+
expect(routine.exercises).toHaveLength(1);
|
|
622
|
+
const [exercise] = routine.exercises;
|
|
623
|
+
expect(exercise.kind).toBe('resistance');
|
|
624
|
+
if (exercise.kind !== 'resistance')
|
|
625
|
+
return;
|
|
626
|
+
expect(exercise.exerciseTemplate.id).toBe('bench');
|
|
627
|
+
expect(exercise.category).toBe('compound');
|
|
628
|
+
expect(exercise.muscleGroup).toBe('chest');
|
|
629
|
+
expect(exercise.warmupSetCount).toBe(2);
|
|
630
|
+
// strength + one_day -> 1 (see `makeSettings`)
|
|
631
|
+
expect(exercise.sets).toBe(1);
|
|
632
|
+
// strength + compound -> 1..5
|
|
633
|
+
expect(exercise.repRangeStart).toBe(1);
|
|
634
|
+
expect(exercise.repRangeEnd).toBe(5);
|
|
635
|
+
// strength + medium + compound -> 60
|
|
636
|
+
expect(exercise.restTimerSeconds).toBe(60);
|
|
637
|
+
});
|
|
638
|
+
it('returns a partial program and errors when no matching exercise can be found', () => {
|
|
639
|
+
const settings = buildSettingsForChestProgram();
|
|
640
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
641
|
+
trainerAlgorithmSettings: settings,
|
|
642
|
+
frequency: 1,
|
|
643
|
+
goal: 'strength',
|
|
644
|
+
level: 'beginner',
|
|
645
|
+
equipments: [],
|
|
646
|
+
workoutDurationMinutes: 60,
|
|
647
|
+
restTimerLength: 'medium',
|
|
648
|
+
exerciseStore: [],
|
|
649
|
+
cardioPreference: undefined,
|
|
650
|
+
});
|
|
651
|
+
expect(result.success).toBe(false);
|
|
652
|
+
if (result.success)
|
|
653
|
+
return;
|
|
654
|
+
expect(result.errors).toHaveLength(1);
|
|
655
|
+
expect(result.errors[0]).toMatchObject({
|
|
656
|
+
type: 'exercise_not_found',
|
|
657
|
+
muscleGroup: 'chest',
|
|
658
|
+
});
|
|
659
|
+
// The routine structure still exists in the partial program, just without
|
|
660
|
+
// any resolved exercises.
|
|
661
|
+
expect(result.partialProgram.routines).toHaveLength(1);
|
|
662
|
+
expect(result.partialProgram.routines[0].exercises).toEqual([]);
|
|
663
|
+
});
|
|
664
|
+
describe('min_workout_duration_limit filtering', () => {
|
|
665
|
+
/**
|
|
666
|
+
* Runs a 1-day program containing a single chest compound prescription
|
|
667
|
+
* with the provided `min_workout_duration_limit` against the provided
|
|
668
|
+
* `workoutDurationMinutes`, and returns whether the prescription was
|
|
669
|
+
* placed.
|
|
670
|
+
*/
|
|
671
|
+
const runDurationProbe = ({ limit, workoutDurationMinutes, }) => {
|
|
672
|
+
const settings = makeSettings();
|
|
673
|
+
settings.templates.full_body_1 = {
|
|
674
|
+
exercises: [
|
|
675
|
+
{
|
|
676
|
+
muscle_group: 'chest',
|
|
677
|
+
category: 'compound',
|
|
678
|
+
min_workout_duration_limit: limit,
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
};
|
|
682
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
683
|
+
trainerAlgorithmSettings: settings,
|
|
684
|
+
frequency: 1,
|
|
685
|
+
goal: 'strength',
|
|
686
|
+
level: 'beginner',
|
|
687
|
+
equipments: [],
|
|
688
|
+
workoutDurationMinutes,
|
|
689
|
+
restTimerLength: 'medium',
|
|
690
|
+
exerciseStore: [makeExercise({ id: 'bench' })],
|
|
691
|
+
cardioPreference: undefined,
|
|
692
|
+
});
|
|
693
|
+
const routine = (result.success ? result.program : result.partialProgram)
|
|
694
|
+
.routines[0];
|
|
695
|
+
return routine.exercises.length === 1;
|
|
696
|
+
};
|
|
697
|
+
it('skips prescriptions whose limit is strictly greater than the selected duration', () => {
|
|
698
|
+
expect(runDurationProbe({ limit: 80, workoutDurationMinutes: 40 })).toBe(false);
|
|
699
|
+
expect(runDurationProbe({ limit: 80, workoutDurationMinutes: 60 })).toBe(false);
|
|
700
|
+
expect(runDurationProbe({ limit: 60, workoutDurationMinutes: 40 })).toBe(false);
|
|
701
|
+
});
|
|
702
|
+
it('includes prescriptions whose limit equals the selected duration (boundary is inclusive)', () => {
|
|
703
|
+
// The check is `limit > selected`, so equality must pass through.
|
|
704
|
+
expect(runDurationProbe({ limit: 40, workoutDurationMinutes: 40 })).toBe(true);
|
|
705
|
+
expect(runDurationProbe({ limit: 60, workoutDurationMinutes: 60 })).toBe(true);
|
|
706
|
+
expect(runDurationProbe({ limit: 80, workoutDurationMinutes: 80 })).toBe(true);
|
|
707
|
+
});
|
|
708
|
+
it('includes prescriptions whose limit is below the selected duration', () => {
|
|
709
|
+
expect(runDurationProbe({ limit: 40, workoutDurationMinutes: 60 })).toBe(true);
|
|
710
|
+
expect(runDurationProbe({ limit: 40, workoutDurationMinutes: 80 })).toBe(true);
|
|
711
|
+
expect(runDurationProbe({ limit: 60, workoutDurationMinutes: 80 })).toBe(true);
|
|
712
|
+
});
|
|
713
|
+
it('includes prescriptions with no min_workout_duration_limit regardless of the selected duration', () => {
|
|
714
|
+
hevyTrainer_1.workoutDurationOptions.forEach((duration) => {
|
|
715
|
+
expect(runDurationProbe({
|
|
716
|
+
limit: undefined,
|
|
717
|
+
workoutDurationMinutes: duration,
|
|
718
|
+
})).toBe(true);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
it('filters prescriptions per-exercise: kept ones remain, skipped ones drop out of order', () => {
|
|
722
|
+
const settings = makeSettings();
|
|
723
|
+
// Three chest compound prescriptions at increasing duration gates, with
|
|
724
|
+
// distinct warmup counts so we can identify which survived.
|
|
725
|
+
settings.templates.full_body_1 = {
|
|
726
|
+
exercises: [
|
|
727
|
+
{
|
|
728
|
+
muscle_group: 'chest',
|
|
729
|
+
category: 'compound',
|
|
730
|
+
min_workout_duration_limit: 40,
|
|
731
|
+
warmup_set_count: 1,
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
muscle_group: 'chest',
|
|
735
|
+
category: 'compound',
|
|
736
|
+
min_workout_duration_limit: 60,
|
|
737
|
+
warmup_set_count: 2,
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
muscle_group: 'chest',
|
|
741
|
+
category: 'compound',
|
|
742
|
+
min_workout_duration_limit: 80,
|
|
743
|
+
warmup_set_count: 3,
|
|
744
|
+
},
|
|
745
|
+
],
|
|
746
|
+
};
|
|
747
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
748
|
+
trainerAlgorithmSettings: settings,
|
|
749
|
+
frequency: 1,
|
|
750
|
+
goal: 'strength',
|
|
751
|
+
level: 'beginner',
|
|
752
|
+
equipments: [],
|
|
753
|
+
workoutDurationMinutes: 60,
|
|
754
|
+
restTimerLength: 'medium',
|
|
755
|
+
exerciseStore: [
|
|
756
|
+
makeExercise({ id: 'bench-1', priority: 3 }),
|
|
757
|
+
makeExercise({ id: 'bench-2', priority: 2 }),
|
|
758
|
+
makeExercise({ id: 'bench-3', priority: 1 }),
|
|
759
|
+
],
|
|
760
|
+
cardioPreference: undefined,
|
|
761
|
+
});
|
|
762
|
+
expect(result.success).toBe(true);
|
|
763
|
+
if (!result.success)
|
|
764
|
+
return;
|
|
765
|
+
// Only the 40- and 60-minute prescriptions survive; the 80-minute one
|
|
766
|
+
// is filtered out. Order is preserved.
|
|
767
|
+
const placed = result.program.routines[0].exercises;
|
|
768
|
+
expect(placed.every((e) => e.kind === 'resistance')).toBe(true);
|
|
769
|
+
expect(placed
|
|
770
|
+
.filter(hevyTrainer_1.isTrainerProgramResistanceExercise)
|
|
771
|
+
.map((e) => e.warmupSetCount)).toEqual([1, 2]);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
it('skips focus_muscle prescriptions when no focusMuscle is provided', () => {
|
|
775
|
+
const settings = makeSettings();
|
|
776
|
+
settings.templates.full_body_1 = {
|
|
777
|
+
exercises: [{ muscle_group: 'focus_muscle', category: 'isolation' }],
|
|
778
|
+
};
|
|
779
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
780
|
+
trainerAlgorithmSettings: settings,
|
|
781
|
+
frequency: 1,
|
|
782
|
+
goal: 'strength',
|
|
783
|
+
level: 'beginner',
|
|
784
|
+
equipments: [],
|
|
785
|
+
workoutDurationMinutes: 60,
|
|
786
|
+
restTimerLength: 'medium',
|
|
787
|
+
exerciseStore: [
|
|
788
|
+
makeExercise({ id: 'biceps-curl', muscle_group: 'biceps' }),
|
|
789
|
+
],
|
|
790
|
+
cardioPreference: undefined,
|
|
791
|
+
});
|
|
792
|
+
expect(result.success).toBe(true);
|
|
793
|
+
if (!result.success)
|
|
794
|
+
return;
|
|
795
|
+
expect(result.program.routines[0].exercises).toEqual([]);
|
|
796
|
+
});
|
|
797
|
+
it('routes focus_muscle prescriptions to the requested SimplifiedMuscleGroup', () => {
|
|
798
|
+
const settings = makeSettings();
|
|
799
|
+
settings.templates.full_body_1 = {
|
|
800
|
+
exercises: [{ muscle_group: 'focus_muscle', category: 'isolation' }],
|
|
801
|
+
};
|
|
802
|
+
const bicepsCurl = makeExercise({
|
|
803
|
+
id: 'biceps-curl',
|
|
804
|
+
muscle_group: 'biceps',
|
|
805
|
+
category: 'isolation',
|
|
806
|
+
});
|
|
807
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
808
|
+
trainerAlgorithmSettings: settings,
|
|
809
|
+
frequency: 1,
|
|
810
|
+
goal: 'strength',
|
|
811
|
+
level: 'beginner',
|
|
812
|
+
equipments: [],
|
|
813
|
+
workoutDurationMinutes: 60,
|
|
814
|
+
restTimerLength: 'medium',
|
|
815
|
+
exerciseStore: [bicepsCurl],
|
|
816
|
+
focusMuscle: 'arms',
|
|
817
|
+
cardioPreference: undefined,
|
|
818
|
+
});
|
|
819
|
+
expect(result.success).toBe(true);
|
|
820
|
+
if (!result.success)
|
|
821
|
+
return;
|
|
822
|
+
const [exercise] = result.program.routines[0].exercises;
|
|
823
|
+
expect(exercise.kind).toBe('resistance');
|
|
824
|
+
if (exercise.kind !== 'resistance')
|
|
825
|
+
return;
|
|
826
|
+
expect(exercise.exerciseTemplate.id).toBe('biceps-curl');
|
|
827
|
+
expect(exercise.muscleGroup).toBe('focus_muscle');
|
|
828
|
+
});
|
|
829
|
+
it('excludes exercises listed in `excludedExerciseIds`', () => {
|
|
830
|
+
const settings = buildSettingsForChestProgram();
|
|
831
|
+
const excluded = makeExercise({ id: 'excluded', priority: 10 });
|
|
832
|
+
const alt = makeExercise({ id: 'alt', priority: 1 });
|
|
833
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
834
|
+
trainerAlgorithmSettings: settings,
|
|
835
|
+
frequency: 1,
|
|
836
|
+
goal: 'strength',
|
|
837
|
+
level: 'beginner',
|
|
838
|
+
equipments: [],
|
|
839
|
+
workoutDurationMinutes: 60,
|
|
840
|
+
restTimerLength: 'medium',
|
|
841
|
+
exerciseStore: [excluded, alt],
|
|
842
|
+
excludedExerciseIds: new Set(['excluded']),
|
|
843
|
+
cardioPreference: undefined,
|
|
844
|
+
});
|
|
845
|
+
expect(result.success).toBe(true);
|
|
846
|
+
if (!result.success)
|
|
847
|
+
return;
|
|
848
|
+
expect(result.program.routines[0].exercises[0].exerciseTemplate.id).toBe('alt');
|
|
849
|
+
});
|
|
850
|
+
it('attaches exercise selection traces when debugExerciseSelectionTrace is true', () => {
|
|
851
|
+
const settings = buildSettingsForChestProgram();
|
|
852
|
+
const chest = makeExercise({ id: 'bench' });
|
|
853
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
854
|
+
trainerAlgorithmSettings: settings,
|
|
855
|
+
frequency: 1,
|
|
856
|
+
goal: 'strength',
|
|
857
|
+
level: 'beginner',
|
|
858
|
+
equipments: [],
|
|
859
|
+
workoutDurationMinutes: 60,
|
|
860
|
+
restTimerLength: 'medium',
|
|
861
|
+
exerciseStore: [chest],
|
|
862
|
+
debugExerciseSelectionTrace: true,
|
|
863
|
+
cardioPreference: undefined,
|
|
864
|
+
});
|
|
865
|
+
expect(result.success).toBe(true);
|
|
866
|
+
expect(result.exerciseSelectionTraces).toHaveLength(1);
|
|
867
|
+
const [trace] = result.exerciseSelectionTraces;
|
|
868
|
+
expect(trace.traceKind).toBe('template');
|
|
869
|
+
expect(trace.routine).toBe('full_body_1');
|
|
870
|
+
expect(trace.selectedExerciseId).toBe('bench');
|
|
871
|
+
expect(trace.trace.selectedPass).toBe(1);
|
|
872
|
+
});
|
|
873
|
+
it('reports the correct prescriptionIndex even when the same prescription object is reused in the template', () => {
|
|
874
|
+
const settings = makeSettings();
|
|
875
|
+
// Deliberately reuse the same reference so `indexOf`-based indexing
|
|
876
|
+
// would collapse both traces to index 0.
|
|
877
|
+
const sharedPrescription = {
|
|
878
|
+
muscle_group: 'chest',
|
|
879
|
+
category: 'compound',
|
|
880
|
+
};
|
|
881
|
+
settings.templates.full_body_1 = {
|
|
882
|
+
exercises: [sharedPrescription, sharedPrescription],
|
|
883
|
+
};
|
|
884
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
885
|
+
trainerAlgorithmSettings: settings,
|
|
886
|
+
frequency: 1,
|
|
887
|
+
goal: 'strength',
|
|
888
|
+
level: 'beginner',
|
|
889
|
+
equipments: [],
|
|
890
|
+
workoutDurationMinutes: 60,
|
|
891
|
+
restTimerLength: 'medium',
|
|
892
|
+
exerciseStore: [
|
|
893
|
+
makeExercise({ id: 'bench-1' }),
|
|
894
|
+
makeExercise({ id: 'bench-2' }),
|
|
895
|
+
],
|
|
896
|
+
debugExerciseSelectionTrace: true,
|
|
897
|
+
cardioPreference: undefined,
|
|
898
|
+
});
|
|
899
|
+
expect(result.success).toBe(true);
|
|
900
|
+
expect(result.exerciseSelectionTraces.map((t) => t.prescriptionIndex)).toEqual([0, 1]);
|
|
901
|
+
});
|
|
902
|
+
it('records a single cardio trace entry (no multi-pass picker)', () => {
|
|
903
|
+
const settings = makeSettings();
|
|
904
|
+
settings.templates.full_body_1 = {
|
|
905
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
906
|
+
};
|
|
907
|
+
settings.exercise_priorities.chest = ['bench'];
|
|
908
|
+
settings.exercise_priorities.cardio = ['run'];
|
|
909
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
910
|
+
trainerAlgorithmSettings: settings,
|
|
911
|
+
frequency: 1,
|
|
912
|
+
goal: 'strength',
|
|
913
|
+
level: 'beginner',
|
|
914
|
+
equipments: [],
|
|
915
|
+
workoutDurationMinutes: 60,
|
|
916
|
+
restTimerLength: 'medium',
|
|
917
|
+
exerciseStore: [
|
|
918
|
+
makeExercise({ id: 'bench', muscle_group: 'chest' }),
|
|
919
|
+
makeExercise({
|
|
920
|
+
id: 'run',
|
|
921
|
+
muscle_group: 'cardio',
|
|
922
|
+
category: 'compound',
|
|
923
|
+
equipment_category: 'other',
|
|
924
|
+
}),
|
|
925
|
+
],
|
|
926
|
+
debugExerciseSelectionTrace: true,
|
|
927
|
+
cardioPreference: 'beginning',
|
|
928
|
+
});
|
|
929
|
+
expect(result.success).toBe(true);
|
|
930
|
+
if (!result.success)
|
|
931
|
+
return;
|
|
932
|
+
const cardioTrace = result.exerciseSelectionTraces.find((t) => t.traceKind === 'cardio');
|
|
933
|
+
expect(cardioTrace).toBeDefined();
|
|
934
|
+
expect(cardioTrace.prescriptionIndex).toBe(-1);
|
|
935
|
+
expect(cardioTrace.trace.entries).toHaveLength(1);
|
|
936
|
+
expect(cardioTrace.trace.selectedPass).toBe(1);
|
|
937
|
+
expect(cardioTrace.trace.entries[0].selectedExerciseId).toBe('run');
|
|
938
|
+
expect(cardioTrace.equipments).toEqual([]);
|
|
939
|
+
expect(cardioTrace.level).toBe('beginner');
|
|
940
|
+
});
|
|
941
|
+
it('uses the same prioritized cardio exercise on every routine (not blocked by program reuse)', () => {
|
|
942
|
+
const settings = makeSettings();
|
|
943
|
+
settings.templates.full_body_2_a = {
|
|
944
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
945
|
+
};
|
|
946
|
+
settings.templates.full_body_2_b = {
|
|
947
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
948
|
+
};
|
|
949
|
+
settings.exercise_priorities.chest = ['bench-a', 'bench-b'];
|
|
950
|
+
settings.exercise_priorities.cardio = ['run'];
|
|
951
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
952
|
+
trainerAlgorithmSettings: settings,
|
|
953
|
+
frequency: 2,
|
|
954
|
+
goal: 'strength',
|
|
955
|
+
level: 'beginner',
|
|
956
|
+
equipments: [],
|
|
957
|
+
workoutDurationMinutes: 60,
|
|
958
|
+
restTimerLength: 'medium',
|
|
959
|
+
exerciseStore: [
|
|
960
|
+
makeExercise({ id: 'bench-a', muscle_group: 'chest' }),
|
|
961
|
+
makeExercise({ id: 'bench-b', muscle_group: 'chest' }),
|
|
962
|
+
makeExercise({
|
|
963
|
+
id: 'run',
|
|
964
|
+
muscle_group: 'cardio',
|
|
965
|
+
category: 'compound',
|
|
966
|
+
equipment_category: 'other',
|
|
967
|
+
}),
|
|
968
|
+
],
|
|
969
|
+
cardioPreference: 'beginning',
|
|
970
|
+
});
|
|
971
|
+
expect(result.success).toBe(true);
|
|
972
|
+
if (!result.success)
|
|
973
|
+
return;
|
|
974
|
+
expect(result.program.routines[0].exercises[0]).toMatchObject({
|
|
975
|
+
kind: 'cardio',
|
|
976
|
+
exerciseTemplate: { id: 'run' },
|
|
977
|
+
});
|
|
978
|
+
expect(result.program.routines[1].exercises[0]).toMatchObject({
|
|
979
|
+
kind: 'cardio',
|
|
980
|
+
exerciseTemplate: { id: 'run' },
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
it('skips cardio exercises that need equipment the user does not have', () => {
|
|
984
|
+
const settings = makeSettings();
|
|
985
|
+
settings.templates.full_body_1 = {
|
|
986
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
987
|
+
};
|
|
988
|
+
settings.exercise_priorities.chest = ['bench'];
|
|
989
|
+
settings.exercise_priorities.cardio = ['treadmill-run', 'body-run'];
|
|
990
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
991
|
+
trainerAlgorithmSettings: settings,
|
|
992
|
+
frequency: 1,
|
|
993
|
+
goal: 'strength',
|
|
994
|
+
level: 'beginner',
|
|
995
|
+
equipments: [],
|
|
996
|
+
workoutDurationMinutes: 60,
|
|
997
|
+
restTimerLength: 'medium',
|
|
998
|
+
exerciseStore: [
|
|
999
|
+
makeExercise({ id: 'bench', muscle_group: 'chest' }),
|
|
1000
|
+
makeExercise({
|
|
1001
|
+
id: 'treadmill-run',
|
|
1002
|
+
muscle_group: 'cardio',
|
|
1003
|
+
category: 'compound',
|
|
1004
|
+
equipment_category: 'other',
|
|
1005
|
+
granular_equipments: ['treadmill'],
|
|
1006
|
+
}),
|
|
1007
|
+
makeExercise({
|
|
1008
|
+
id: 'body-run',
|
|
1009
|
+
muscle_group: 'cardio',
|
|
1010
|
+
category: 'compound',
|
|
1011
|
+
equipment_category: 'other',
|
|
1012
|
+
}),
|
|
1013
|
+
],
|
|
1014
|
+
cardioPreference: 'beginning',
|
|
1015
|
+
});
|
|
1016
|
+
expect(result.success).toBe(true);
|
|
1017
|
+
if (!result.success)
|
|
1018
|
+
return;
|
|
1019
|
+
expect(result.program.routines[0].exercises[0]).toMatchObject({
|
|
1020
|
+
kind: 'cardio',
|
|
1021
|
+
exerciseTemplate: { id: 'body-run' },
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
it('skips globally excluded cardio and uses the next priority exercise', () => {
|
|
1025
|
+
const settings = makeSettings();
|
|
1026
|
+
settings.templates.full_body_1 = {
|
|
1027
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
1028
|
+
};
|
|
1029
|
+
settings.exercise_priorities.chest = ['bench'];
|
|
1030
|
+
settings.exercise_priorities.cardio = ['run-a', 'run-b'];
|
|
1031
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1032
|
+
trainerAlgorithmSettings: settings,
|
|
1033
|
+
frequency: 1,
|
|
1034
|
+
goal: 'strength',
|
|
1035
|
+
level: 'beginner',
|
|
1036
|
+
equipments: [],
|
|
1037
|
+
workoutDurationMinutes: 60,
|
|
1038
|
+
restTimerLength: 'medium',
|
|
1039
|
+
exerciseStore: [
|
|
1040
|
+
makeExercise({ id: 'bench', muscle_group: 'chest' }),
|
|
1041
|
+
makeExercise({
|
|
1042
|
+
id: 'run-a',
|
|
1043
|
+
muscle_group: 'cardio',
|
|
1044
|
+
category: 'compound',
|
|
1045
|
+
equipment_category: 'other',
|
|
1046
|
+
}),
|
|
1047
|
+
makeExercise({
|
|
1048
|
+
id: 'run-b',
|
|
1049
|
+
muscle_group: 'cardio',
|
|
1050
|
+
category: 'compound',
|
|
1051
|
+
equipment_category: 'other',
|
|
1052
|
+
}),
|
|
1053
|
+
],
|
|
1054
|
+
excludedExerciseIds: new Set(['run-a']),
|
|
1055
|
+
cardioPreference: 'beginning',
|
|
1056
|
+
});
|
|
1057
|
+
expect(result.success).toBe(true);
|
|
1058
|
+
if (!result.success)
|
|
1059
|
+
return;
|
|
1060
|
+
expect(result.program.routines[0].exercises[0]).toMatchObject({
|
|
1061
|
+
kind: 'cardio',
|
|
1062
|
+
exerciseTemplate: { id: 'run-b' },
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
it('keeps global trace order aligned with routine generation (no cross-routine unshift)', () => {
|
|
1066
|
+
const settings = makeSettings();
|
|
1067
|
+
settings.templates.full_body_2_a = {
|
|
1068
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
1069
|
+
};
|
|
1070
|
+
settings.templates.full_body_2_b = {
|
|
1071
|
+
exercises: [{ muscle_group: 'chest', category: 'compound' }],
|
|
1072
|
+
};
|
|
1073
|
+
settings.exercise_priorities.chest = ['bench-a', 'bench-b'];
|
|
1074
|
+
settings.exercise_priorities.cardio = ['run'];
|
|
1075
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1076
|
+
trainerAlgorithmSettings: settings,
|
|
1077
|
+
frequency: 2,
|
|
1078
|
+
goal: 'strength',
|
|
1079
|
+
level: 'beginner',
|
|
1080
|
+
equipments: [],
|
|
1081
|
+
workoutDurationMinutes: 60,
|
|
1082
|
+
restTimerLength: 'medium',
|
|
1083
|
+
exerciseStore: [
|
|
1084
|
+
makeExercise({ id: 'bench-a', muscle_group: 'chest' }),
|
|
1085
|
+
makeExercise({ id: 'bench-b', muscle_group: 'chest' }),
|
|
1086
|
+
makeExercise({
|
|
1087
|
+
id: 'run',
|
|
1088
|
+
muscle_group: 'cardio',
|
|
1089
|
+
category: 'compound',
|
|
1090
|
+
equipment_category: 'other',
|
|
1091
|
+
}),
|
|
1092
|
+
],
|
|
1093
|
+
debugExerciseSelectionTrace: true,
|
|
1094
|
+
cardioPreference: 'beginning',
|
|
1095
|
+
});
|
|
1096
|
+
expect(result.success).toBe(true);
|
|
1097
|
+
if (!result.success)
|
|
1098
|
+
return;
|
|
1099
|
+
const idxA = result.exerciseSelectionTraces.findIndex((t) => t.routine === 'full_body_2_a');
|
|
1100
|
+
const idxB = result.exerciseSelectionTraces.findIndex((t) => t.routine === 'full_body_2_b');
|
|
1101
|
+
expect(idxA).toBeGreaterThanOrEqual(0);
|
|
1102
|
+
expect(idxB).toBeGreaterThan(idxA);
|
|
1103
|
+
});
|
|
1104
|
+
describe('barbell cap at frequency === 1 (end-to-end)', () => {
|
|
1105
|
+
it('drops the 4th+ barbell prescription when the user has a substitute', () => {
|
|
1106
|
+
const settings = makeSettings();
|
|
1107
|
+
// 4 identical barbell compound prescriptions in the 1-day routine.
|
|
1108
|
+
settings.templates.full_body_1 = {
|
|
1109
|
+
exercises: Array.from({ length: 4 }, () => ({
|
|
1110
|
+
muscle_group: 'chest',
|
|
1111
|
+
category: 'compound',
|
|
1112
|
+
})),
|
|
1113
|
+
};
|
|
1114
|
+
// Provide 4 distinct barbell chest exercises so "already used in program"
|
|
1115
|
+
// cannot interfere with the cap we're trying to observe.
|
|
1116
|
+
const exercises = ['a', 'b', 'c', 'd'].map((id) => makeExercise({
|
|
1117
|
+
id,
|
|
1118
|
+
equipment_category: 'barbell',
|
|
1119
|
+
granular_equipments: ['barbell'],
|
|
1120
|
+
}));
|
|
1121
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1122
|
+
trainerAlgorithmSettings: settings,
|
|
1123
|
+
frequency: 1,
|
|
1124
|
+
goal: 'strength',
|
|
1125
|
+
level: 'beginner',
|
|
1126
|
+
// Include a substitute (dumbbell) so the cap is enforced.
|
|
1127
|
+
equipments: ['barbell', 'dumbbell'],
|
|
1128
|
+
workoutDurationMinutes: 60,
|
|
1129
|
+
restTimerLength: 'medium',
|
|
1130
|
+
exerciseStore: exercises,
|
|
1131
|
+
cardioPreference: undefined,
|
|
1132
|
+
});
|
|
1133
|
+
expect(result.success).toBe(false);
|
|
1134
|
+
if (result.success)
|
|
1135
|
+
return;
|
|
1136
|
+
expect(result.partialProgram.routines[0].exercises).toHaveLength(3);
|
|
1137
|
+
expect(result.errors).toHaveLength(1);
|
|
1138
|
+
});
|
|
1139
|
+
it('allows 4+ barbell prescriptions at frequency 1 when no substitute is present', () => {
|
|
1140
|
+
const settings = makeSettings();
|
|
1141
|
+
settings.templates.full_body_1 = {
|
|
1142
|
+
exercises: Array.from({ length: 4 }, () => ({
|
|
1143
|
+
muscle_group: 'chest',
|
|
1144
|
+
category: 'compound',
|
|
1145
|
+
})),
|
|
1146
|
+
};
|
|
1147
|
+
const exercises = ['a', 'b', 'c', 'd'].map((id) => makeExercise({
|
|
1148
|
+
id,
|
|
1149
|
+
equipment_category: 'barbell',
|
|
1150
|
+
granular_equipments: ['barbell'],
|
|
1151
|
+
}));
|
|
1152
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1153
|
+
trainerAlgorithmSettings: settings,
|
|
1154
|
+
frequency: 1,
|
|
1155
|
+
goal: 'strength',
|
|
1156
|
+
level: 'beginner',
|
|
1157
|
+
// No substitutes → cap is not enforced.
|
|
1158
|
+
equipments: ['barbell'],
|
|
1159
|
+
workoutDurationMinutes: 60,
|
|
1160
|
+
restTimerLength: 'medium',
|
|
1161
|
+
exerciseStore: exercises,
|
|
1162
|
+
cardioPreference: undefined,
|
|
1163
|
+
});
|
|
1164
|
+
expect(result.success).toBe(true);
|
|
1165
|
+
if (!result.success)
|
|
1166
|
+
return;
|
|
1167
|
+
expect(result.program.routines[0].exercises).toHaveLength(4);
|
|
1168
|
+
});
|
|
1169
|
+
});
|
|
1170
|
+
describe('focus_muscle cycling', () => {
|
|
1171
|
+
it('cycles through simplifiedMuscleGroupToMuscleGroups as focus_muscle prescriptions are placed', () => {
|
|
1172
|
+
// Use a 2-day split so we can place 4 focus_muscle exercises across two
|
|
1173
|
+
// routines and confirm the counter survives between routines.
|
|
1174
|
+
const settings = makeSettings();
|
|
1175
|
+
const focusPrescription = {
|
|
1176
|
+
muscle_group: 'focus_muscle',
|
|
1177
|
+
category: 'isolation',
|
|
1178
|
+
};
|
|
1179
|
+
// full_body routines allow any focus muscle. Place 2 per routine so the
|
|
1180
|
+
// combined count across the program is 4.
|
|
1181
|
+
settings.templates.full_body_2_a = {
|
|
1182
|
+
exercises: [focusPrescription, focusPrescription],
|
|
1183
|
+
};
|
|
1184
|
+
settings.templates.full_body_2_b = {
|
|
1185
|
+
exercises: [focusPrescription, focusPrescription],
|
|
1186
|
+
};
|
|
1187
|
+
// `arms` cycles through ['biceps', 'triceps', 'forearms'] and then wraps.
|
|
1188
|
+
const exercises = [
|
|
1189
|
+
makeExercise({
|
|
1190
|
+
id: 'biceps-ex',
|
|
1191
|
+
muscle_group: 'biceps',
|
|
1192
|
+
category: 'isolation',
|
|
1193
|
+
}),
|
|
1194
|
+
makeExercise({
|
|
1195
|
+
id: 'triceps-ex',
|
|
1196
|
+
muscle_group: 'triceps',
|
|
1197
|
+
category: 'isolation',
|
|
1198
|
+
}),
|
|
1199
|
+
makeExercise({
|
|
1200
|
+
id: 'forearms-ex',
|
|
1201
|
+
muscle_group: 'forearms',
|
|
1202
|
+
category: 'isolation',
|
|
1203
|
+
}),
|
|
1204
|
+
];
|
|
1205
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1206
|
+
trainerAlgorithmSettings: settings,
|
|
1207
|
+
frequency: 2,
|
|
1208
|
+
goal: 'strength',
|
|
1209
|
+
level: 'beginner',
|
|
1210
|
+
equipments: [],
|
|
1211
|
+
workoutDurationMinutes: 60,
|
|
1212
|
+
restTimerLength: 'medium',
|
|
1213
|
+
exerciseStore: exercises,
|
|
1214
|
+
focusMuscle: 'arms',
|
|
1215
|
+
debugExerciseSelectionTrace: true,
|
|
1216
|
+
cardioPreference: undefined,
|
|
1217
|
+
});
|
|
1218
|
+
const traces = result.exerciseSelectionTraces;
|
|
1219
|
+
// Counter increments once per placed focus_muscle prescription, so the
|
|
1220
|
+
// 4th prescription wraps back to the first entry of
|
|
1221
|
+
// simplifiedMuscleGroupToMuscleGroups.arms ('biceps').
|
|
1222
|
+
expect(traces
|
|
1223
|
+
.filter((t) => t.traceKind === 'template')
|
|
1224
|
+
.map((t) => t.resolvedMuscleGroup)).toEqual(['biceps', 'triceps', 'forearms', 'biceps']);
|
|
1225
|
+
// The first three passes should land on the priority-matching exercise
|
|
1226
|
+
// (each muscle's only exercise is picked by pass 1).
|
|
1227
|
+
expect(traces
|
|
1228
|
+
.filter((t) => t.traceKind === 'template')
|
|
1229
|
+
.slice(0, 3)
|
|
1230
|
+
.map((t) => t.selectedExerciseId)).toEqual(['biceps-ex', 'triceps-ex', 'forearms-ex']);
|
|
1231
|
+
});
|
|
1232
|
+
it('does not advance the focus_muscle counter when a prescription is skipped for template mismatch', () => {
|
|
1233
|
+
// 5-day split: push_1, pull_1, legs_1, upper_2, lower_2.
|
|
1234
|
+
// `legs` focus muscle is only allowed for lower/legs templates, so the
|
|
1235
|
+
// push/pull/upper templates skip the focus_muscle prescription
|
|
1236
|
+
// entirely. The counter must only tick for templates where the
|
|
1237
|
+
// prescription actually runs.
|
|
1238
|
+
const settings = makeSettings();
|
|
1239
|
+
const focusPrescription = {
|
|
1240
|
+
muscle_group: 'focus_muscle',
|
|
1241
|
+
category: 'isolation',
|
|
1242
|
+
};
|
|
1243
|
+
settings.templates.push_1 = { exercises: [focusPrescription] };
|
|
1244
|
+
settings.templates.pull_1 = { exercises: [focusPrescription] };
|
|
1245
|
+
settings.templates.legs_1 = { exercises: [focusPrescription] };
|
|
1246
|
+
settings.templates.upper_2 = { exercises: [focusPrescription] };
|
|
1247
|
+
settings.templates.lower_2 = { exercises: [focusPrescription] };
|
|
1248
|
+
// `legs` cycles through ['quadriceps', 'hamstrings', 'calves', 'glutes',
|
|
1249
|
+
// 'abductors', 'adductors']. The first two allowed routines (legs_1 and
|
|
1250
|
+
// lower_2) should land on quadriceps and hamstrings respectively.
|
|
1251
|
+
const exercises = [
|
|
1252
|
+
makeExercise({
|
|
1253
|
+
id: 'quad-ex',
|
|
1254
|
+
muscle_group: 'quadriceps',
|
|
1255
|
+
category: 'isolation',
|
|
1256
|
+
}),
|
|
1257
|
+
makeExercise({
|
|
1258
|
+
id: 'ham-ex',
|
|
1259
|
+
muscle_group: 'hamstrings',
|
|
1260
|
+
category: 'isolation',
|
|
1261
|
+
}),
|
|
1262
|
+
];
|
|
1263
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1264
|
+
trainerAlgorithmSettings: settings,
|
|
1265
|
+
frequency: 5,
|
|
1266
|
+
goal: 'strength',
|
|
1267
|
+
level: 'beginner',
|
|
1268
|
+
equipments: [],
|
|
1269
|
+
workoutDurationMinutes: 60,
|
|
1270
|
+
restTimerLength: 'medium',
|
|
1271
|
+
exerciseStore: exercises,
|
|
1272
|
+
focusMuscle: 'legs',
|
|
1273
|
+
debugExerciseSelectionTrace: true,
|
|
1274
|
+
cardioPreference: undefined,
|
|
1275
|
+
});
|
|
1276
|
+
expect(result.success).toBe(true);
|
|
1277
|
+
if (!result.success)
|
|
1278
|
+
return;
|
|
1279
|
+
// Only legs_1 and lower_2 run the focus_muscle prescription. Both
|
|
1280
|
+
// succeed and should have selected quadriceps then hamstrings in order.
|
|
1281
|
+
expect(result.exerciseSelectionTraces).toHaveLength(2);
|
|
1282
|
+
expect(result.exerciseSelectionTraces.map((t) => t.routine)).toEqual([
|
|
1283
|
+
'legs_1',
|
|
1284
|
+
'lower_2',
|
|
1285
|
+
]);
|
|
1286
|
+
expect(result.exerciseSelectionTraces
|
|
1287
|
+
.filter((t) => t.traceKind === 'template')
|
|
1288
|
+
.map((t) => t.resolvedMuscleGroup)).toEqual(['quadriceps', 'hamstrings']);
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
describe('isFocusMuscleExtraExerciseAllowed template compatibility', () => {
|
|
1292
|
+
/**
|
|
1293
|
+
* Runs a single-routine program that places one focus_muscle
|
|
1294
|
+
* prescription into the requested template and returns whether the
|
|
1295
|
+
* prescription produced an exercise or was skipped.
|
|
1296
|
+
*/
|
|
1297
|
+
const runFocusMuscleProbe = (routineName, focusMuscle, frequency, candidateExercise) => {
|
|
1298
|
+
var _a;
|
|
1299
|
+
const settings = makeSettings();
|
|
1300
|
+
settings.templates[routineName] = {
|
|
1301
|
+
exercises: [{ muscle_group: 'focus_muscle', category: 'isolation' }],
|
|
1302
|
+
};
|
|
1303
|
+
const result = (0, hevyTrainer_1.generateProgram)({
|
|
1304
|
+
trainerAlgorithmSettings: settings,
|
|
1305
|
+
frequency,
|
|
1306
|
+
goal: 'strength',
|
|
1307
|
+
level: 'beginner',
|
|
1308
|
+
equipments: [],
|
|
1309
|
+
workoutDurationMinutes: 60,
|
|
1310
|
+
restTimerLength: 'medium',
|
|
1311
|
+
exerciseStore: [candidateExercise],
|
|
1312
|
+
focusMuscle,
|
|
1313
|
+
debugExerciseSelectionTrace: true,
|
|
1314
|
+
cardioPreference: undefined,
|
|
1315
|
+
});
|
|
1316
|
+
// Find the routine we targeted (it may not be first in the split).
|
|
1317
|
+
const routine = (result.success ? result.program : result.partialProgram).routines.find((r) => r.name === routineName);
|
|
1318
|
+
return {
|
|
1319
|
+
placed: ((_a = routine === null || routine === void 0 ? void 0 : routine.exercises.length) !== null && _a !== void 0 ? _a : 0) > 0,
|
|
1320
|
+
};
|
|
1321
|
+
};
|
|
1322
|
+
it('allows every focus muscle on full_body routines', () => {
|
|
1323
|
+
const muscles = ['chest', 'shoulders', 'arms', 'legs', 'back'];
|
|
1324
|
+
muscles.forEach((focusMuscle) => {
|
|
1325
|
+
// Use a muscle that's in the simplified group: biceps for arms, chest
|
|
1326
|
+
// for chest, quadriceps for legs, etc. We just pick the first entry
|
|
1327
|
+
// of each simplified group.
|
|
1328
|
+
const firstUnderlyingMuscle = {
|
|
1329
|
+
chest: 'chest',
|
|
1330
|
+
shoulders: 'shoulders',
|
|
1331
|
+
arms: 'biceps',
|
|
1332
|
+
legs: 'quadriceps',
|
|
1333
|
+
back: 'lats',
|
|
1334
|
+
};
|
|
1335
|
+
const ex = makeExercise({
|
|
1336
|
+
id: `${focusMuscle}-ex`,
|
|
1337
|
+
muscle_group: firstUnderlyingMuscle[focusMuscle],
|
|
1338
|
+
category: 'isolation',
|
|
1339
|
+
});
|
|
1340
|
+
const { placed } = runFocusMuscleProbe('full_body_1', focusMuscle, 1, ex);
|
|
1341
|
+
expect(placed).toBe(true);
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
it('allows "core" focus muscle on any template', () => {
|
|
1345
|
+
const ex = makeExercise({
|
|
1346
|
+
id: 'core-ex',
|
|
1347
|
+
muscle_group: 'abdominals',
|
|
1348
|
+
category: 'isolation',
|
|
1349
|
+
});
|
|
1350
|
+
// Probe a non-full-body, non-legs template. Core should still be placed.
|
|
1351
|
+
const { placed } = runFocusMuscleProbe('push_1', 'core', 5, ex);
|
|
1352
|
+
expect(placed).toBe(true);
|
|
1353
|
+
});
|
|
1354
|
+
it('skips "chest" and "shoulders" focus muscle on pull/legs/lower templates', () => {
|
|
1355
|
+
const chestEx = makeExercise({
|
|
1356
|
+
id: 'chest-ex',
|
|
1357
|
+
muscle_group: 'chest',
|
|
1358
|
+
category: 'isolation',
|
|
1359
|
+
});
|
|
1360
|
+
const shoulderEx = makeExercise({
|
|
1361
|
+
id: 'shoulder-ex',
|
|
1362
|
+
muscle_group: 'shoulders',
|
|
1363
|
+
category: 'isolation',
|
|
1364
|
+
});
|
|
1365
|
+
expect(runFocusMuscleProbe('pull_1', 'chest', 5, chestEx).placed).toBe(false);
|
|
1366
|
+
expect(runFocusMuscleProbe('legs_1', 'shoulders', 5, shoulderEx).placed).toBe(false);
|
|
1367
|
+
});
|
|
1368
|
+
it('allows "arms" focus muscle on upper, push, and pull templates but not on legs/lower', () => {
|
|
1369
|
+
const bicepsEx = makeExercise({
|
|
1370
|
+
id: 'biceps-ex',
|
|
1371
|
+
muscle_group: 'biceps',
|
|
1372
|
+
category: 'isolation',
|
|
1373
|
+
});
|
|
1374
|
+
expect(runFocusMuscleProbe('upper_1_a', 'arms', 4, bicepsEx).placed).toBe(true);
|
|
1375
|
+
expect(runFocusMuscleProbe('push_1', 'arms', 5, bicepsEx).placed).toBe(true);
|
|
1376
|
+
expect(runFocusMuscleProbe('pull_1', 'arms', 5, bicepsEx).placed).toBe(true);
|
|
1377
|
+
expect(runFocusMuscleProbe('legs_1', 'arms', 5, bicepsEx).placed).toBe(false);
|
|
1378
|
+
expect(runFocusMuscleProbe('lower_1_a', 'arms', 4, bicepsEx).placed).toBe(false);
|
|
1379
|
+
});
|
|
1380
|
+
it('allows "legs" focus muscle only on legs/lower templates', () => {
|
|
1381
|
+
const quadEx = makeExercise({
|
|
1382
|
+
id: 'quad-ex',
|
|
1383
|
+
muscle_group: 'quadriceps',
|
|
1384
|
+
category: 'isolation',
|
|
1385
|
+
});
|
|
1386
|
+
expect(runFocusMuscleProbe('legs_1', 'legs', 5, quadEx).placed).toBe(true);
|
|
1387
|
+
expect(runFocusMuscleProbe('lower_1_a', 'legs', 4, quadEx).placed).toBe(true);
|
|
1388
|
+
expect(runFocusMuscleProbe('push_1', 'legs', 5, quadEx).placed).toBe(false);
|
|
1389
|
+
expect(runFocusMuscleProbe('upper_1_a', 'legs', 4, quadEx).placed).toBe(false);
|
|
1390
|
+
});
|
|
1391
|
+
it('allows "back" focus muscle only on upper/pull templates', () => {
|
|
1392
|
+
const latsEx = makeExercise({
|
|
1393
|
+
id: 'lats-ex',
|
|
1394
|
+
muscle_group: 'lats',
|
|
1395
|
+
category: 'isolation',
|
|
1396
|
+
});
|
|
1397
|
+
expect(runFocusMuscleProbe('upper_1_a', 'back', 4, latsEx).placed).toBe(true);
|
|
1398
|
+
expect(runFocusMuscleProbe('pull_1', 'back', 5, latsEx).placed).toBe(true);
|
|
1399
|
+
expect(runFocusMuscleProbe('push_1', 'back', 5, latsEx).placed).toBe(false);
|
|
1400
|
+
expect(runFocusMuscleProbe('legs_1', 'back', 5, latsEx).placed).toBe(false);
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
const __2 = require("..");
|
|
1405
|
+
describe('hevyTrainer gym types and defaults', () => {
|
|
1406
|
+
it('defines granular equipment defaults for every trainer gym type', () => {
|
|
1407
|
+
__2.trainerGymTypes.forEach((gymType) => {
|
|
1408
|
+
const defaults = __2.granularEquipmentDefaults[gymType];
|
|
1409
|
+
expect(Array.isArray(defaults)).toBe(true);
|
|
1410
|
+
expect(defaults.length).toBeGreaterThan(0);
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
it('granular equipment defaults only contain granular equipment values', () => {
|
|
1414
|
+
Object.keys(__2.granularEquipmentDefaults).forEach((gymType) => {
|
|
1415
|
+
const defaults = __2.granularEquipmentDefaults[gymType];
|
|
1416
|
+
defaults.forEach((equipment) => {
|
|
1417
|
+
// Type assertion ensures the mapping stays within the GranularEquipment union.
|
|
1418
|
+
const typed = equipment;
|
|
1419
|
+
expect(typeof typed).toBe('string');
|
|
1420
|
+
});
|
|
1421
|
+
});
|
|
1422
|
+
});
|
|
1423
|
+
});
|