@dgpholdings/greatoak-shared 1.2.54 → 1.2.56
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/dist/__mocks__/exercises.mock.d.ts +35 -0
- package/dist/__mocks__/exercises.mock.js +144 -0
- package/dist/__mocks__/templateExercises.mock.d.ts +90 -0
- package/dist/__mocks__/templateExercises.mock.js +258 -0
- package/dist/__mocks__/user.mock.d.ts +2 -0
- package/dist/__mocks__/user.mock.js +36 -0
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.d.ts +30 -0
- package/dist/utils/exerciseRecord/__mocks__/exercises.mock.js +138 -0
- package/dist/utils/exerciseRecord/recordValidator.d.ts +12 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/recordValidator.integration.test.js +51 -0
- package/dist/utils/exerciseRecord/recordValidator.js +85 -0
- package/dist/utils/exerciseRecord/recordValidator.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/recordValidator.test.js +165 -0
- package/dist/utils/exerciseRecord/workoutMath.d.ts +28 -0
- package/dist/utils/exerciseRecord/workoutMath.js +116 -0
- package/dist/utils/exerciseRecord/workoutMath.test.d.ts +1 -0
- package/dist/utils/exerciseRecord/workoutMath.test.js +238 -0
- package/dist/utils/index.d.ts +4 -1
- package/dist/utils/index.js +7 -4
- package/dist/utils/scoringWorkout/calculateCalories.d.ts +67 -0
- package/dist/utils/scoringWorkout/calculateCalories.js +351 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.d.ts +67 -0
- package/dist/utils/scoringWorkout/calculateMuscleFatiue.js +330 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.d.ts +73 -0
- package/dist/utils/scoringWorkout/calculateQualityScore.js +357 -0
- package/dist/utils/scoringWorkout/calculateTotalVolume.d.ts +15 -0
- package/dist/utils/scoringWorkout/calculateTotalVolume.js +73 -0
- package/dist/utils/scoringWorkout/constants.d.ts +211 -0
- package/dist/utils/scoringWorkout/constants.js +247 -0
- package/dist/utils/scoringWorkout/helpers.d.ts +127 -0
- package/dist/utils/scoringWorkout/helpers.js +245 -0
- package/dist/utils/scoringWorkout/index.d.ts +30 -0
- package/dist/utils/scoringWorkout/index.js +57 -0
- package/dist/utils/scoringWorkout/parseRecords.d.ts +68 -0
- package/dist/utils/scoringWorkout/parseRecords.js +281 -0
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.d.ts +1 -0
- package/dist/utils/scoringWorkout/scoringWorkout.integration.test.js +439 -0
- package/dist/utils/scoringWorkout/types.d.ts +104 -0
- package/dist/utils/scoringWorkout/types.js +11 -0
- package/package.json +7 -4
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
3
|
+
var t = {};
|
|
4
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
5
|
+
t[p] = s[p];
|
|
6
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
7
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
8
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
9
|
+
t[p[i]] = s[p[i]];
|
|
10
|
+
}
|
|
11
|
+
return t;
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
const vitest_1 = require("vitest");
|
|
15
|
+
const index_1 = require("./index");
|
|
16
|
+
const parseRecords_1 = require("./parseRecords");
|
|
17
|
+
const templateExercises_mock_1 = require("../../__mocks__/templateExercises.mock");
|
|
18
|
+
const exercises_mock_1 = require("../../__mocks__/exercises.mock");
|
|
19
|
+
const user_mock_1 = require("../../__mocks__/user.mock");
|
|
20
|
+
(0, vitest_1.describe)("Scoring Engine Phase 1 Integration Tests", () => {
|
|
21
|
+
(0, vitest_1.it)("P1-1 proper proof: Partial workout with undefined rest on last done set + strict mode", () => {
|
|
22
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsPartial;
|
|
23
|
+
const exercise = exercises_mock_1.mockExerciseWeightReps;
|
|
24
|
+
// Modify to prove P1-1: the LAST done set (index 1) must have NO rest data.
|
|
25
|
+
// Set 0 has good rest, Set 1 has no rest. If P1-1 is fixed, Set 1 gets null rest
|
|
26
|
+
// and is excluded from the rest average, meaning only Set 0's perfect rest is scored.
|
|
27
|
+
const strictRecords = template.initialRecords.map((r, i) => {
|
|
28
|
+
if (i === 1) {
|
|
29
|
+
const { restDurationSecs } = r, rest = __rest(r, ["restDurationSecs"]); // remove rest data
|
|
30
|
+
return Object.assign(Object.assign({}, rest), { isStrictMode: true });
|
|
31
|
+
}
|
|
32
|
+
return Object.assign(Object.assign({}, r), { isStrictMode: true, restDurationSecs: 120 }); // 120 is optimal
|
|
33
|
+
});
|
|
34
|
+
// Direct parseRecords assertion — the real proof of P1-1.
|
|
35
|
+
// With fix (totalSets=doneRecords.length=2): Set1 index=1 === 2-1 → null rest.
|
|
36
|
+
// Without fix (totalSets=records.length=4): Set1 index=1 !== 3 → gets typical (120s).
|
|
37
|
+
const parsed = (0, parseRecords_1.parseRecords)(strictRecords, exercise.timingGuardrails);
|
|
38
|
+
(0, vitest_1.expect)(parsed[1].restDurationSecs).toBeNull();
|
|
39
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
40
|
+
exercise,
|
|
41
|
+
record: strictRecords,
|
|
42
|
+
user: user_mock_1.mockUser,
|
|
43
|
+
});
|
|
44
|
+
(0, vitest_1.expect)(qualityBreakdown.completion).toBe(50); // 2/4 done
|
|
45
|
+
// Only Set0 (120s, optimal) contributes to rest — Set1 is correctly excluded
|
|
46
|
+
(0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBeGreaterThan(95);
|
|
47
|
+
});
|
|
48
|
+
(0, vitest_1.it)("P1-2: mockTemplateWeightRepsFailedSet - zero rep set still counts towards completion if isDone is true", () => {
|
|
49
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsFailedSet;
|
|
50
|
+
const exercise = exercises_mock_1.mockExerciseWeightReps;
|
|
51
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
52
|
+
exercise,
|
|
53
|
+
record: template.initialRecords,
|
|
54
|
+
user: user_mock_1.mockUser,
|
|
55
|
+
});
|
|
56
|
+
(0, vitest_1.expect)(qualityBreakdown.completion).toBe(67); // 2/3 done
|
|
57
|
+
});
|
|
58
|
+
(0, vitest_1.it)("P1-1 + P1-3: mockTemplateLazyLogger - handles missing data gracefully using fallbacks", () => {
|
|
59
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.lazyLogger;
|
|
60
|
+
const exercise = exercises_mock_1.mockExerciseWeightReps;
|
|
61
|
+
const { score, qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
62
|
+
exercise,
|
|
63
|
+
record: template.initialRecords,
|
|
64
|
+
user: user_mock_1.mockUser,
|
|
65
|
+
});
|
|
66
|
+
(0, vitest_1.expect)(score).toBeGreaterThan(50);
|
|
67
|
+
(0, vitest_1.expect)(qualityBreakdown.completion).toBe(100);
|
|
68
|
+
});
|
|
69
|
+
(0, vitest_1.it)("Distracted logger: clamping path (Original 600s rest → clamped to typical → good rest score)", () => {
|
|
70
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.distractedLogger;
|
|
71
|
+
const exercise = exercises_mock_1.mockExerciseWeightReps;
|
|
72
|
+
// Use 600s rest. 600 > 300 * 1.5, so it gets clamped to typical (120s)
|
|
73
|
+
// 120s is in the optimal range (90-180), so rest score should be 100.
|
|
74
|
+
const strictRecords = template.initialRecords.map((r, i) => (Object.assign(Object.assign({}, r), { isStrictMode: true, restDurationSecs: i === 0 ? 600 : 120 })));
|
|
75
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
76
|
+
exercise,
|
|
77
|
+
record: strictRecords,
|
|
78
|
+
user: user_mock_1.mockUser,
|
|
79
|
+
});
|
|
80
|
+
// Both sets end up evaluating as 120s (optimal) due to clamping + explicit
|
|
81
|
+
(0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(100);
|
|
82
|
+
});
|
|
83
|
+
(0, vitest_1.it)("Distracted logger: penalty path (350s rest → outside acceptableMax → score 50)", () => {
|
|
84
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.distractedLogger;
|
|
85
|
+
const exercise = exercises_mock_1.mockExerciseWeightReps;
|
|
86
|
+
// 350s is within grace bounds (450s) but outside acceptableMax (300s).
|
|
87
|
+
const strictRecords = template.initialRecords.map((r, i) => (Object.assign(Object.assign({}, r), { isStrictMode: true, restDurationSecs: i === 0 ? 350 : 120 })));
|
|
88
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
89
|
+
exercise,
|
|
90
|
+
record: strictRecords,
|
|
91
|
+
user: user_mock_1.mockUser,
|
|
92
|
+
});
|
|
93
|
+
// Set 1 (350s) -> 50. Set 2 (120s) -> 100. Avg = 75. Plus 5 point bonus = 80.
|
|
94
|
+
(0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(80);
|
|
95
|
+
});
|
|
96
|
+
(0, vitest_1.it)("mockTemplateAllUndone - Score = 0, no errors", () => {
|
|
97
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.allUndone;
|
|
98
|
+
const exercise = exercises_mock_1.mockExerciseWeightReps;
|
|
99
|
+
const { score, qualityBreakdown, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
|
|
100
|
+
exercise,
|
|
101
|
+
record: template.initialRecords,
|
|
102
|
+
user: user_mock_1.mockUser,
|
|
103
|
+
});
|
|
104
|
+
(0, vitest_1.expect)(score).toBe(0);
|
|
105
|
+
(0, vitest_1.expect)(qualityBreakdown.completion).toBe(0);
|
|
106
|
+
(0, vitest_1.expect)(qualityBreakdown.consistency).toBe(0);
|
|
107
|
+
(0, vitest_1.expect)(qualityBreakdown.effortAdequacy).toBe(0);
|
|
108
|
+
(0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(0);
|
|
109
|
+
(0, vitest_1.expect)(Object.keys(muscleScores).length).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
(0, vitest_1.it)("Trivial workout: low muscleScores (3s duration → primary muscle value < 5)", () => {
|
|
112
|
+
const exercise = exercises_mock_1.mockExerciseDuration;
|
|
113
|
+
const trivialRecord = [
|
|
114
|
+
{
|
|
115
|
+
type: "duration",
|
|
116
|
+
isDone: true,
|
|
117
|
+
isStrictMode: false,
|
|
118
|
+
durationMmSs: "00:03", // 3 seconds
|
|
119
|
+
auxWeightKg: "0",
|
|
120
|
+
}
|
|
121
|
+
];
|
|
122
|
+
const { score, qualityBreakdown, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
|
|
123
|
+
exercise,
|
|
124
|
+
record: trivialRecord,
|
|
125
|
+
user: user_mock_1.mockUser,
|
|
126
|
+
});
|
|
127
|
+
(0, vitest_1.expect)(qualityBreakdown.completion).toBe(100);
|
|
128
|
+
(0, vitest_1.expect)(score).toBe(87);
|
|
129
|
+
// Check fatigue pillar correctness
|
|
130
|
+
const primaryMuscle = exercise.primaryMuscles[0];
|
|
131
|
+
(0, vitest_1.expect)(muscleScores[primaryMuscle]).toBeLessThan(5);
|
|
132
|
+
});
|
|
133
|
+
(0, vitest_1.it)("P1-3 direct proof: parseRecords includes setupTypicalSecs in activeDurationSecs", () => {
|
|
134
|
+
var _a, _b, _c;
|
|
135
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
136
|
+
const parsed = (0, parseRecords_1.parseRecords)(template.initialRecords, exercises_mock_1.mockExerciseWeightReps.timingGuardrails);
|
|
137
|
+
const record = template.initialRecords[0];
|
|
138
|
+
const reps = parseFloat(record.type === "weight-reps" ? record.reps : "0");
|
|
139
|
+
const guardrails = exercises_mock_1.mockExerciseWeightReps.timingGuardrails;
|
|
140
|
+
const typicalSecs = (guardrails === null || guardrails === void 0 ? void 0 : guardrails.type) === "weight-reps" ? ((_b = (_a = guardrails.singleRep) === null || _a === void 0 ? void 0 : _a.typical) !== null && _b !== void 0 ? _b : 0) : 0;
|
|
141
|
+
const setupSecs = (_c = guardrails === null || guardrails === void 0 ? void 0 : guardrails.setupTypicalSecs) !== null && _c !== void 0 ? _c : 0;
|
|
142
|
+
const expectedDuration = reps * typicalSecs + setupSecs; // 10 * 3.5 + 10 = 45
|
|
143
|
+
(0, vitest_1.expect)(parsed[0].activeDurationSecs).toBe(expectedDuration);
|
|
144
|
+
});
|
|
145
|
+
(0, vitest_1.it)("Muscle fatigue shape: quadriceps and glutes-maximus present in [0, 100]", () => {
|
|
146
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
147
|
+
const { muscleScores } = (0, index_1.calculateExerciseScoreV2)({
|
|
148
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
149
|
+
record: template.initialRecords,
|
|
150
|
+
user: user_mock_1.mockUser,
|
|
151
|
+
});
|
|
152
|
+
(0, vitest_1.expect)(muscleScores).toHaveProperty("quadriceps");
|
|
153
|
+
(0, vitest_1.expect)(muscleScores).toHaveProperty("glutes-maximus");
|
|
154
|
+
(0, vitest_1.expect)(muscleScores["quadriceps"]).toBeGreaterThanOrEqual(0);
|
|
155
|
+
(0, vitest_1.expect)(muscleScores["quadriceps"]).toBeLessThanOrEqual(100);
|
|
156
|
+
});
|
|
157
|
+
(0, vitest_1.it)("stretch-mobility = zero fatigue: muscleScores should be empty", () => {
|
|
158
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.duration;
|
|
159
|
+
const exercise = Object.assign(Object.assign({}, exercises_mock_1.mockExerciseDuration), { scoringSpecialHandling: "stretch-mobility" });
|
|
160
|
+
const { muscleScores } = (0, index_1.calculateExerciseScoreV2)({
|
|
161
|
+
exercise,
|
|
162
|
+
record: template.initialRecords,
|
|
163
|
+
user: user_mock_1.mockUser,
|
|
164
|
+
});
|
|
165
|
+
(0, vitest_1.expect)(Object.keys(muscleScores).length).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
(0, vitest_1.it)("no guardrails doesn't crash: mockExerciseNoGuardrails scores > 0", () => {
|
|
168
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
169
|
+
const { score } = (0, index_1.calculateExerciseScoreV2)({
|
|
170
|
+
exercise: exercises_mock_1.mockExerciseNoGuardrails,
|
|
171
|
+
record: template.initialRecords,
|
|
172
|
+
user: user_mock_1.mockUser,
|
|
173
|
+
});
|
|
174
|
+
(0, vitest_1.expect)(score).toBeGreaterThan(0);
|
|
175
|
+
});
|
|
176
|
+
(0, vitest_1.it)("RPE/RIR scoring: effortAdequacy calculates correctly for various inputs", () => {
|
|
177
|
+
const records = [
|
|
178
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10", rpe: "8" }, // 100
|
|
179
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10", rpe: "4" }, // 60 (Distance = 2 -> 100 - 2*20 = 60)
|
|
180
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10", rir: "0" }, // 80 (Distance = 1 -> 100 - 1*20 = 80)
|
|
181
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "10" } // 70 (No data)
|
|
182
|
+
];
|
|
183
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
184
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
185
|
+
record: records,
|
|
186
|
+
user: user_mock_1.mockUser,
|
|
187
|
+
});
|
|
188
|
+
// Average: (100 + 60 + 80 + 70) / 4 = 77.5 -> 78
|
|
189
|
+
(0, vitest_1.expect)(qualityBreakdown.effortAdequacy).toBe(78);
|
|
190
|
+
});
|
|
191
|
+
(0, vitest_1.it)("Progressive overload detection: mockTemplateWeightRepsMicroLoaded -> consistency >= 75", () => {
|
|
192
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsMicroLoaded;
|
|
193
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
194
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
195
|
+
record: template.initialRecords,
|
|
196
|
+
user: user_mock_1.mockUser,
|
|
197
|
+
});
|
|
198
|
+
(0, vitest_1.expect)(qualityBreakdown.consistency).toBeGreaterThanOrEqual(75);
|
|
199
|
+
});
|
|
200
|
+
(0, vitest_1.it)("Single-set rest is neutral: mockTemplateSingleSet -> restDiscipline = 75", () => {
|
|
201
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.singleSet;
|
|
202
|
+
const strictRecords = template.initialRecords.map(r => {
|
|
203
|
+
const { restDurationSecs } = r, rest = __rest(r, ["restDurationSecs"]); // remove rest data so it becomes the null case
|
|
204
|
+
return Object.assign(Object.assign({}, rest), { isStrictMode: true });
|
|
205
|
+
});
|
|
206
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
207
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
208
|
+
record: strictRecords,
|
|
209
|
+
user: user_mock_1.mockUser,
|
|
210
|
+
});
|
|
211
|
+
// Single set → last set gets null rest → setsWithRest=[] → REST_NO_DATA_SCORE (75)
|
|
212
|
+
(0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(75);
|
|
213
|
+
});
|
|
214
|
+
(0, vitest_1.it)("Non-sequential completion: mockTemplateNonSequential -> completion = 67%", () => {
|
|
215
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.nonSequential;
|
|
216
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
217
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
218
|
+
record: template.initialRecords,
|
|
219
|
+
user: user_mock_1.mockUser,
|
|
220
|
+
});
|
|
221
|
+
(0, vitest_1.expect)(qualityBreakdown.completion).toBe(67);
|
|
222
|
+
});
|
|
223
|
+
(0, vitest_1.it)("IScoreResult has 4 fields: calorieBurn and qualityBreakdown are present", () => {
|
|
224
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
225
|
+
const result = (0, index_1.calculateExerciseScoreV2)({
|
|
226
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
227
|
+
record: template.initialRecords,
|
|
228
|
+
user: user_mock_1.mockUser,
|
|
229
|
+
});
|
|
230
|
+
(0, vitest_1.expect)(result).toHaveProperty("score");
|
|
231
|
+
(0, vitest_1.expect)(result).toHaveProperty("muscleScores");
|
|
232
|
+
(0, vitest_1.expect)(result).toHaveProperty("calorieBurn");
|
|
233
|
+
(0, vitest_1.expect)(result).toHaveProperty("qualityBreakdown");
|
|
234
|
+
});
|
|
235
|
+
(0, vitest_1.it)("Cardio exercises: mockTemplateCardioMachine/Free score > 0 and use correct muscles", () => {
|
|
236
|
+
const machineResult = (0, index_1.calculateExerciseScoreV2)({
|
|
237
|
+
exercise: exercises_mock_1.mockExerciseCardioMachine,
|
|
238
|
+
record: templateExercises_mock_1.mockTemplateExercisesDictionary.cardioMachine.initialRecords,
|
|
239
|
+
user: user_mock_1.mockUser,
|
|
240
|
+
});
|
|
241
|
+
(0, vitest_1.expect)(machineResult.score).toBeGreaterThan(0);
|
|
242
|
+
(0, vitest_1.expect)(machineResult.muscleScores).toHaveProperty("quadriceps");
|
|
243
|
+
const freeResult = (0, index_1.calculateExerciseScoreV2)({
|
|
244
|
+
exercise: exercises_mock_1.mockExerciseCardioFree,
|
|
245
|
+
record: templateExercises_mock_1.mockTemplateExercisesDictionary.cardioFree.initialRecords,
|
|
246
|
+
user: user_mock_1.mockUser,
|
|
247
|
+
});
|
|
248
|
+
(0, vitest_1.expect)(freeResult.score).toBeGreaterThan(0);
|
|
249
|
+
(0, vitest_1.expect)(freeResult.muscleScores).toHaveProperty("quadriceps");
|
|
250
|
+
});
|
|
251
|
+
(0, vitest_1.it)("reps-only with aux weight: mockTemplateRepsOnly -> score > 0, pectoralis-major mapped", () => {
|
|
252
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.repsOnly;
|
|
253
|
+
const { score, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
|
|
254
|
+
exercise: exercises_mock_1.mockExerciseRepsOnly,
|
|
255
|
+
record: template.initialRecords,
|
|
256
|
+
user: user_mock_1.mockUser,
|
|
257
|
+
});
|
|
258
|
+
(0, vitest_1.expect)(score).toBeGreaterThan(0);
|
|
259
|
+
(0, vitest_1.expect)(muscleScores).toHaveProperty("pectoralis-major");
|
|
260
|
+
});
|
|
261
|
+
(0, vitest_1.it)("Duration exercise: mockTemplateDuration -> abs-lower/abs-upper in muscleScores", () => {
|
|
262
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.duration;
|
|
263
|
+
const { score, muscleScores } = (0, index_1.calculateExerciseScoreV2)({
|
|
264
|
+
exercise: exercises_mock_1.mockExerciseDuration,
|
|
265
|
+
record: template.initialRecords,
|
|
266
|
+
user: user_mock_1.mockUser,
|
|
267
|
+
});
|
|
268
|
+
(0, vitest_1.expect)(score).toBeGreaterThan(0);
|
|
269
|
+
(0, vitest_1.expect)(muscleScores).toHaveProperty("abs-lower");
|
|
270
|
+
(0, vitest_1.expect)(muscleScores).toHaveProperty("abs-upper");
|
|
271
|
+
});
|
|
272
|
+
(0, vitest_1.it)("Default restDiscipline (non-strict): Any non-strict call -> restDiscipline === 75", () => {
|
|
273
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
274
|
+
// Standard mock is `isStrictMode: false`
|
|
275
|
+
const { qualityBreakdown } = (0, index_1.calculateExerciseScoreV2)({
|
|
276
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
277
|
+
record: template.initialRecords,
|
|
278
|
+
user: user_mock_1.mockUser,
|
|
279
|
+
});
|
|
280
|
+
(0, vitest_1.expect)(qualityBreakdown.restDiscipline).toBe(75); // REST_NO_DATA_SCORE from constants.ts
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
(0, vitest_1.describe)("Scoring Engine Phase 2 Integration Tests", () => {
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// P2-1: fitnessLevel scales referenceMax
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
(0, vitest_1.it)("P2-1: sedentary user scores higher muscle fatigue than very-active for identical work", () => {
|
|
288
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
289
|
+
const sedentaryUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessLevel: "sedentary" });
|
|
290
|
+
const veryActiveUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessLevel: "very-active" });
|
|
291
|
+
const sedentaryResult = (0, index_1.calculateExerciseScoreV2)({
|
|
292
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
293
|
+
record: template.initialRecords,
|
|
294
|
+
user: sedentaryUser,
|
|
295
|
+
});
|
|
296
|
+
const veryActiveResult = (0, index_1.calculateExerciseScoreV2)({
|
|
297
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
298
|
+
record: template.initialRecords,
|
|
299
|
+
user: veryActiveUser,
|
|
300
|
+
});
|
|
301
|
+
// sedentary: activityScale 0.70 → lower referenceMax denominator → higher normalised score
|
|
302
|
+
// very-active: activityScale 1.20 → higher referenceMax denominator → lower normalised score
|
|
303
|
+
(0, vitest_1.expect)(sedentaryResult.muscleScores["quadriceps"]).toBeGreaterThan(veryActiveResult.muscleScores["quadriceps"]);
|
|
304
|
+
});
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// P2-3: fitnessGoal-aware quality weights
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
(0, vitest_1.it)("P2-3: strength goal scores higher than hypertrophy when effort is good but completion is 50%", () => {
|
|
309
|
+
// 2 of 4 sets done (50% completion) with RPE 8 (effort in productive zone)
|
|
310
|
+
// strength weights effortAdequacy 0.45 vs hypertrophy 0.30 — effort matters more
|
|
311
|
+
const partialWithEffort = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsPartial.initialRecords.map((r) => (Object.assign(Object.assign({}, r), { rpe: "8" })));
|
|
312
|
+
const strengthUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessGoal: "strength" });
|
|
313
|
+
const hypertrophyUser = Object.assign(Object.assign({}, user_mock_1.mockUser), { fitnessGoal: "hypertrophy" });
|
|
314
|
+
const strengthResult = (0, index_1.calculateExerciseScoreV2)({
|
|
315
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
316
|
+
record: partialWithEffort,
|
|
317
|
+
user: strengthUser,
|
|
318
|
+
});
|
|
319
|
+
const hypertrophyResult = (0, index_1.calculateExerciseScoreV2)({
|
|
320
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
321
|
+
record: partialWithEffort,
|
|
322
|
+
user: hypertrophyUser,
|
|
323
|
+
});
|
|
324
|
+
// completion=50, consistency=100, effortAdequacy=100, restDiscipline=75
|
|
325
|
+
// strength: 50×0.15 + 100×0.25 + 100×0.45 + 75×0.15 = 88.75 → 89
|
|
326
|
+
// hypertrophy: 50×0.20 + 100×0.35 + 100×0.30 + 75×0.15 = 86.25 → 86
|
|
327
|
+
(0, vitest_1.expect)(strengthResult.score).toBe(89);
|
|
328
|
+
(0, vitest_1.expect)(hypertrophyResult.score).toBe(86);
|
|
329
|
+
(0, vitest_1.expect)(strengthResult.score).toBeGreaterThan(hypertrophyResult.score);
|
|
330
|
+
});
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// P2-5: estimatedOneRepMax in referenceMax
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
(0, vitest_1.it)("P2-5: user near their 1RM shows higher muscle fatigue than a strong user doing the same weight", () => {
|
|
335
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
336
|
+
// Weak user: 1RM=60kg — the session's top set (85kg) exceeds their 1RM,
|
|
337
|
+
// so referenceMax is low → same absolute volume normalises to a higher score
|
|
338
|
+
const weakUserResult = (0, index_1.calculateExerciseScoreV2)({
|
|
339
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
340
|
+
record: template.initialRecords,
|
|
341
|
+
user: user_mock_1.mockUser,
|
|
342
|
+
historicalContext: { estimatedOneRepMax: 60 },
|
|
343
|
+
});
|
|
344
|
+
// Strong user: 1RM=200kg — 85kg is only ~43% of max → low relative effort
|
|
345
|
+
// referenceMax is high → same absolute volume normalises to a lower score
|
|
346
|
+
const strongUserResult = (0, index_1.calculateExerciseScoreV2)({
|
|
347
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
348
|
+
record: template.initialRecords,
|
|
349
|
+
user: user_mock_1.mockUser,
|
|
350
|
+
historicalContext: { estimatedOneRepMax: 200 },
|
|
351
|
+
});
|
|
352
|
+
(0, vitest_1.expect)(weakUserResult.muscleScores["quadriceps"]).toBeGreaterThan(strongUserResult.muscleScores["quadriceps"]);
|
|
353
|
+
});
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// P2-6: previousSessionVolume cross-session progressive overload
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
(0, vitest_1.it)("P2-6: beating previous session volume floors consistency at 75 even with high CV", () => {
|
|
358
|
+
// Highly inconsistent sets: [100kg×10, 50kg×5, 90kg×8]
|
|
359
|
+
// volume metric: [1000, 250, 720]
|
|
360
|
+
// CV ≈ 0.47 → base consistency score = clamp(100 - 0.47×200, 20, 100) = 20
|
|
361
|
+
const inconsistentRecords = [
|
|
362
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "100", reps: "10" },
|
|
363
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "5" },
|
|
364
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "90", reps: "8" },
|
|
365
|
+
];
|
|
366
|
+
const withoutContext = (0, index_1.calculateExerciseScoreV2)({
|
|
367
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
368
|
+
record: inconsistentRecords,
|
|
369
|
+
user: user_mock_1.mockUser,
|
|
370
|
+
});
|
|
371
|
+
// currentSessionVolume = 1970, previousSessionVolume = 1000 → 1970 >= 1000 → progressive
|
|
372
|
+
const withContext = (0, index_1.calculateExerciseScoreV2)({
|
|
373
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
374
|
+
record: inconsistentRecords,
|
|
375
|
+
user: user_mock_1.mockUser,
|
|
376
|
+
historicalContext: { previousSessionVolume: 1000 },
|
|
377
|
+
});
|
|
378
|
+
(0, vitest_1.expect)(withoutContext.qualityBreakdown.consistency).toBe(20); // CONSISTENCY_MIN_SCORE
|
|
379
|
+
(0, vitest_1.expect)(withContext.qualityBreakdown.consistency).toBe(75); // PROGRESSIVE_OVERLOAD_FLOOR
|
|
380
|
+
});
|
|
381
|
+
(0, vitest_1.it)("P2-6: not beating previous session volume does NOT trigger the floor", () => {
|
|
382
|
+
const inconsistentRecords = [
|
|
383
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "100", reps: "10" },
|
|
384
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "50", reps: "5" },
|
|
385
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "90", reps: "8" },
|
|
386
|
+
];
|
|
387
|
+
// currentSessionVolume = 1970, previousSessionVolume = 3000 → 1970 < 3000 → no floor
|
|
388
|
+
const result = (0, index_1.calculateExerciseScoreV2)({
|
|
389
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
390
|
+
record: inconsistentRecords,
|
|
391
|
+
user: user_mock_1.mockUser,
|
|
392
|
+
historicalContext: { previousSessionVolume: 3000 },
|
|
393
|
+
});
|
|
394
|
+
(0, vitest_1.expect)(result.qualityBreakdown.consistency).toBe(20); // no floor applied
|
|
395
|
+
});
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// P2-10: trainingAgeBracket RIR attenuation + referenceMax scaling
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
(0, vitest_1.it)("P2-10: beginner bracket attenuates near-failure effort → lower muscle fatigue than no bracket", () => {
|
|
400
|
+
// RIR 0 = near-failure: effortFraction ≈ 1.3 without attenuation
|
|
401
|
+
// With beginner: effortFraction = 0.6 + (1.3 - 0.6) × 0.5 = 0.95
|
|
402
|
+
const nearFailureRecords = [
|
|
403
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "80", reps: "5", rir: "0" },
|
|
404
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "80", reps: "5", rir: "0" },
|
|
405
|
+
{ type: "weight-reps", isDone: true, isStrictMode: false, kg: "80", reps: "5", rir: "0" },
|
|
406
|
+
];
|
|
407
|
+
const noContext = (0, index_1.calculateExerciseScoreV2)({
|
|
408
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
409
|
+
record: nearFailureRecords,
|
|
410
|
+
user: user_mock_1.mockUser,
|
|
411
|
+
});
|
|
412
|
+
const beginnerContext = (0, index_1.calculateExerciseScoreV2)({
|
|
413
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
414
|
+
record: nearFailureRecords,
|
|
415
|
+
user: user_mock_1.mockUser,
|
|
416
|
+
historicalContext: { trainingAgeBracket: "beginner" },
|
|
417
|
+
});
|
|
418
|
+
// Beginner: lower effort (attenuated) + lower referenceMax (0.80 scale)
|
|
419
|
+
// Net effect: ~9% lower score. Verified: noContext ≈ 20, beginnerContext ≈ 18
|
|
420
|
+
(0, vitest_1.expect)(beginnerContext.muscleScores["quadriceps"]).toBeLessThan(noContext.muscleScores["quadriceps"]);
|
|
421
|
+
});
|
|
422
|
+
(0, vitest_1.it)("P2-10: advanced bracket raises referenceMax → lower fatigue score for same work than intermediate", () => {
|
|
423
|
+
const template = templateExercises_mock_1.mockTemplateExercisesDictionary.weightRepsStandard;
|
|
424
|
+
const intermediateResult = (0, index_1.calculateExerciseScoreV2)({
|
|
425
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
426
|
+
record: template.initialRecords,
|
|
427
|
+
user: user_mock_1.mockUser,
|
|
428
|
+
historicalContext: { trainingAgeBracket: "intermediate" },
|
|
429
|
+
});
|
|
430
|
+
const advancedResult = (0, index_1.calculateExerciseScoreV2)({
|
|
431
|
+
exercise: exercises_mock_1.mockExerciseWeightReps,
|
|
432
|
+
record: template.initialRecords,
|
|
433
|
+
user: user_mock_1.mockUser,
|
|
434
|
+
historicalContext: { trainingAgeBracket: "advanced" },
|
|
435
|
+
});
|
|
436
|
+
// advanced: referenceMax × 1.15 → higher denominator → lower normalised fatigue
|
|
437
|
+
(0, vitest_1.expect)(advancedResult.muscleScores["quadriceps"]).toBeLessThan(intermediateResult.muscleScores["quadriceps"]);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX EXERCISE SCORING SYSTEM — Types & Interfaces
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Central type definitions for the scoring algorithm.
|
|
7
|
+
* This file defines the output shape and internal types used across all three
|
|
8
|
+
* scoring pillars (Calories, Muscle Fatigue, Quality).
|
|
9
|
+
*/
|
|
10
|
+
import { TActivityLevel, TFitnessGoal, TGender, TRecord } from "../../types";
|
|
11
|
+
/**
|
|
12
|
+
* Quality score breakdown — lets the UI show users WHY they got their score.
|
|
13
|
+
* Each sub-score is 0–100.
|
|
14
|
+
*/
|
|
15
|
+
export interface IQualityBreakdown {
|
|
16
|
+
/** Did the user complete all sets? */
|
|
17
|
+
completion: number;
|
|
18
|
+
/** Were sets consistent in output (or intentionally progressive)? */
|
|
19
|
+
consistency: number;
|
|
20
|
+
/** Was effort in the productive RPE/RIR zone? */
|
|
21
|
+
effortAdequacy: number;
|
|
22
|
+
/** Were rest periods within the exercise's optimal range? */
|
|
23
|
+
restDiscipline: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The final result returned by calculateExerciseScore.
|
|
27
|
+
*
|
|
28
|
+
* - score: 0–100 overall quality of execution
|
|
29
|
+
* - muscleScores: per-muscle fatigue map (keyed by EBodyParts key, value 0–100)
|
|
30
|
+
* - calorieBurn: estimated kilocalories burned (gross, including EPOC)
|
|
31
|
+
* - qualityBreakdown: transparent sub-scores for the UI
|
|
32
|
+
*/
|
|
33
|
+
export interface IScoreResult {
|
|
34
|
+
score: number;
|
|
35
|
+
muscleScores: Record<string, number>;
|
|
36
|
+
calorieBurn: number;
|
|
37
|
+
qualityBreakdown: IQualityBreakdown;
|
|
38
|
+
}
|
|
39
|
+
export type TTrainingAgeBracket = "beginner" | "intermediate" | "advanced";
|
|
40
|
+
/**
|
|
41
|
+
* Historical context derived from previous sessions, passed from the app
|
|
42
|
+
* at save time to provide a cross-session perspective for scoring.
|
|
43
|
+
*/
|
|
44
|
+
export interface IHistoricalContext {
|
|
45
|
+
/** Best Epley 1RM from previous sessions (weight-reps only) */
|
|
46
|
+
estimatedOneRepMax?: number;
|
|
47
|
+
/** Last session total volume for this exercise */
|
|
48
|
+
previousSessionVolume?: number;
|
|
49
|
+
/** Account age and workout frequency mapping */
|
|
50
|
+
trainingAgeBracket?: TTrainingAgeBracket;
|
|
51
|
+
/** Pre-workout readiness rating (1-5) (P4-2 planned) */
|
|
52
|
+
readiness?: 1 | 2 | 3 | 4 | 5;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A cleaned, parsed version of a single TRecord.
|
|
56
|
+
* All string fields are parsed to numbers, unreliable timings are replaced
|
|
57
|
+
* with fallbacks, and skipped sets are filtered out before this stage.
|
|
58
|
+
*
|
|
59
|
+
* This is the ONLY representation the scoring pillars work with — they never
|
|
60
|
+
* touch raw TRecord directly.
|
|
61
|
+
*/
|
|
62
|
+
export interface IParsedSet {
|
|
63
|
+
type: TRecord["type"];
|
|
64
|
+
/** Active work duration for this set in seconds (estimated or measured) */
|
|
65
|
+
activeDurationSecs: number;
|
|
66
|
+
/** Rest duration after this set in seconds (validated or fallback) */
|
|
67
|
+
restDurationSecs: number | null;
|
|
68
|
+
/**
|
|
69
|
+
* Effort fraction: 0.0 (no effort) to 1.0 (max effort).
|
|
70
|
+
* Derived from RPE, RIR, or fallback (0.6).
|
|
71
|
+
*/
|
|
72
|
+
effortFraction: number;
|
|
73
|
+
/** weight-reps: weight in kg */
|
|
74
|
+
kg?: number;
|
|
75
|
+
/** weight-reps / reps-only: rep count */
|
|
76
|
+
reps?: number;
|
|
77
|
+
/** reps-only / duration: auxiliary weight in kg */
|
|
78
|
+
auxWeightKg?: number;
|
|
79
|
+
/** duration: hold time in seconds */
|
|
80
|
+
durationSecs?: number;
|
|
81
|
+
/** cardio-machine: speed */
|
|
82
|
+
speed?: number;
|
|
83
|
+
/** cardio-machine: incline percentage (Treadmill) */
|
|
84
|
+
inclinePercentage?: number;
|
|
85
|
+
/** cardio-machine: resistance level (Elliptical, Cycling) */
|
|
86
|
+
resistanceLevel?: number;
|
|
87
|
+
/** cardio-machine / cardio-free: distance (km) */
|
|
88
|
+
distance?: number;
|
|
89
|
+
/** cardio-free / cardio-machine: session duration in seconds */
|
|
90
|
+
cardioDurationSecs?: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Validated user context with guaranteed fallbacks.
|
|
94
|
+
* No optional fields — everything has a sensible default.
|
|
95
|
+
*/
|
|
96
|
+
export interface IUserContext {
|
|
97
|
+
weightKg: number;
|
|
98
|
+
heightCm: number;
|
|
99
|
+
gender: TGender;
|
|
100
|
+
age: number;
|
|
101
|
+
fitnessLevel: TActivityLevel;
|
|
102
|
+
fitnessGoal: TFitnessGoal;
|
|
103
|
+
bodyFatPercentage: number;
|
|
104
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* FITFRIX EXERCISE SCORING SYSTEM — Types & Interfaces
|
|
5
|
+
* ============================================================================
|
|
6
|
+
*
|
|
7
|
+
* Central type definitions for the scoring algorithm.
|
|
8
|
+
* This file defines the output shape and internal types used across all three
|
|
9
|
+
* scoring pillars (Calories, Muscle Fatigue, Quality).
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dgpholdings/greatoak-shared",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.56",
|
|
4
4
|
"description": "Shared TypeScript types and utilities for @dgpholdings projects",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
],
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
12
|
-
"pub": "npm run build && npm version patch && npm publish",
|
|
12
|
+
"pub": "npm run test && npm run build && npm version patch && npm publish",
|
|
13
13
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
|
14
|
-
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
|
|
14
|
+
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
|
15
|
+
"test": "vitest run src",
|
|
16
|
+
"test:watch": "vitest src"
|
|
15
17
|
},
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
|
@@ -20,7 +22,8 @@
|
|
|
20
22
|
"author": "Siddhartha Chowdhury",
|
|
21
23
|
"license": "MIT",
|
|
22
24
|
"devDependencies": {
|
|
23
|
-
"typescript": "^5.8.2"
|
|
25
|
+
"typescript": "^5.8.2",
|
|
26
|
+
"vitest": "^4.1.3"
|
|
24
27
|
},
|
|
25
28
|
"dependencies": {
|
|
26
29
|
"react-native-uuid": "^2.0.3"
|