[OCaml] High Intensity Training Online
1
(** Tests for {!Hito_app.Service}'s workout-import integration: storing and
2
cleaning batches, promoting them into canonical
3
{!Repository.imported_record} evidence, and how that evidence feeds
4
progression and recovery without ever advancing the routine cycle. Authored
5
against service.mli. *)
6
7
open Hito_app
8
module S = Service.Make (Test_repo) (Test_hash)
9
module WI = Workout_import
10
11
let run = Lwt_main.run
12
let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
13
14
let get id =
15
match Exercise.find_id id with
16
| Some e -> e
17
| None -> Alcotest.failf "catalog is missing %S" id
18
19
let at s = Recovery.timestamp_of_unix_seconds s
20
let day n = at (n * 86_400)
21
let hours n = n * 3_600
22
let ideal = Repository.routine_id "ideal"
23
24
let fixture () =
25
let s = S.make ~repo:(Test_repo.create ()) in
26
let trainee =
27
ok (run (S.register s ~username:"lifter" ~password:"heavyduty1"))
28
in
29
(s, trainee.Trainee.id)
30
31
(* An imported source set. Defaults to a failure set so promotion needs no
32
acknowledgement unless a test asks for a normal set. *)
33
let set ?(row = 0) ?(set_index = 1) ?(kind = WI.Failure) ~name ~kg ~reps () :
34
WI.source_set =
35
{
36
row;
37
exercise_name = name;
38
set_index;
39
set_type = kind;
40
weight_kg = kg;
41
reps;
42
superset_id = None;
43
}
44
45
let workout ~id ~title ~started_at ~ended_at ~sets =
46
ok (WI.make_workout ~id:(WI.workout_id id) ~title ~started_at ~ended_at ~sets)
47
48
(* A batch whose identity, fingerprint, and imported time are all explicit, so
49
duplicate detection can be exercised precisely. *)
50
let batch ?(source = "raw,csv") ?(fingerprint = "fp") ?(imported_at = at 5_000)
51
~id workouts =
52
WI.make_batch ~id:(WI.batch_id id) ~source ~fingerprint ~imported_at ~workouts
53
~warnings:[]
54
55
(* Map [source_name] to [exercise] for a workout in a stored batch. *)
56
let map s t ~batch:bid ~workout:wid ~name exercise =
57
ok
58
(run
59
(S.map_import_exercise s t ~batch:(WI.batch_id bid)
60
~workout:(WI.workout_id wid) ~source_name:name exercise))
61
62
(* Store a batch with a single mapped squats failure set, ready to promote. *)
63
let ready_batch s t ~id ~ended_at =
64
let w =
65
workout ~id:(id ^ "-w") ~title:"Legs" ~started_at:(at 0) ~ended_at
66
~sets:[ set ~row:1 ~name:"Barbell Squat" ~kg:100. ~reps:8 () ]
67
in
68
ok (run (S.store_import_batch s t (batch ~id [ w ])));
69
ignore
70
(map s t ~batch:id ~workout:(id ^ "-w") ~name:"Barbell Squat" (get "squats"));
71
id
72
73
let store_tests =
74
[
75
( "a duplicate fingerprint is refused, override accepts it",
76
`Quick,
77
fun () ->
78
let s, t = fixture () in
79
let w id =
80
workout ~id ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600)
81
~sets:[ set ~name:"Barbell Squat" ~kg:100. ~reps:8 () ]
82
in
83
ok
84
(run
85
(S.store_import_batch s t
86
(batch ~id:"b1" ~fingerprint:"same" [ w "w1" ])));
87
(match
88
run
89
(S.store_import_batch s t
90
(batch ~id:"b2" ~fingerprint:"same" [ w "w2" ]))
91
with
92
| Error S.Duplicate_source -> ()
93
| _ -> Alcotest.fail "expected Duplicate_source");
94
ok
95
(run
96
(S.store_import_batch s t ~allow_duplicate:()
97
(batch ~id:"b2" ~fingerprint:"same" [ w "w2" ])));
98
Alcotest.(check int)
99
"both batches stored" 2
100
(List.length (run (S.import_batches s t))) );
101
( "re-storing the same batch id is a replacement, not a duplicate",
102
`Quick,
103
fun () ->
104
let s, t = fixture () in
105
let w =
106
workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600)
107
~sets:[ set ~name:"Barbell Squat" ~kg:100. ~reps:8 () ]
108
in
109
ok
110
(run
111
(S.store_import_batch s t
112
(batch ~id:"b1" ~fingerprint:"same" [ w ])));
113
ok
114
(run
115
(S.store_import_batch s t
116
(batch ~id:"b1" ~fingerprint:"same" [ w ])));
117
Alcotest.(check int)
118
"still one batch" 1
119
(List.length (run (S.import_batches s t))) );
120
( "mapping and confirmation persist on the stored batch",
121
`Quick,
122
fun () ->
123
let s, t = fixture () in
124
let w =
125
workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600)
126
~sets:
127
[
128
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100.
129
~reps:8 ();
130
]
131
in
132
ok (run (S.store_import_batch s t (batch ~id:"b1" [ w ])));
133
ignore
134
(map s t ~batch:"b1" ~workout:"w1" ~name:"Barbell Squat"
135
(get "squats"));
136
ignore
137
(ok
138
(run
139
(S.confirm_import_normal_sets s t ~batch:(WI.batch_id "b1")
140
~workout:(WI.workout_id "w1") ~acknowledgement:"to failure")));
141
let reloaded =
142
Option.get (run (S.find_import_batch s t (WI.batch_id "b1")))
143
in
144
let w' = Option.get (WI.find_workout reloaded (WI.workout_id "w1")) in
145
Alcotest.(check bool)
146
"mapping persisted" true
147
(Option.is_some (WI.workout_mapping w' ~source_name:"Barbell Squat"));
148
Alcotest.(check (option string))
149
"acknowledgement persisted" (Some "to failure")
150
(WI.workout_acknowledgement w');
151
Alcotest.(check int)
152
"no blockers remain" 0
153
(List.length (WI.blockers w')) );
154
]
155
156
let promotion_tests =
157
[
158
( "a blocked workout stays stored; an eligible sibling promotes via batch",
159
`Quick,
160
fun () ->
161
let s, t = fixture () in
162
let w1 =
163
workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600)
164
~sets:[ set ~row:1 ~name:"Barbell Squat" ~kg:100. ~reps:8 () ]
165
in
166
let w2 =
167
workout ~id:"w2" ~title:"Delts" ~started_at:(at 0)
168
~ended_at:(at 3_600)
169
~sets:[ set ~row:2 ~name:"Side Raise" ~kg:12. ~reps:9 () ]
170
in
171
ok (run (S.store_import_batch s t (batch ~id:"b1" [ w1; w2 ])));
172
ignore
173
(map s t ~batch:"b1" ~workout:"w1" ~name:"Barbell Squat"
174
(get "squats"));
175
let result =
176
ok (run (S.promote_import_batch s t ~batch:(WI.batch_id "b1")))
177
in
178
Alcotest.(check int)
179
"one workout promoted" 1
180
(List.length result.WI.promoted);
181
Alcotest.(check int)
182
"one workout blocked" 1
183
(List.length result.WI.blocked);
184
Alcotest.(check int)
185
"one imported record written" 1
186
(List.length (run (S.imported_records s t)));
187
let reloaded =
188
Option.get (run (S.find_import_batch s t (WI.batch_id "b1")))
189
in
190
Alcotest.(check bool)
191
"blocked workout retained" true
192
(Option.is_some (WI.find_workout reloaded (WI.workout_id "w2"))) );
193
( "promoting a workout writes one record with all its observations",
194
`Quick,
195
fun () ->
196
let s, t = fixture () in
197
let w =
198
workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600)
199
~sets:
200
[
201
set ~row:1 ~name:"Barbell Squat" ~kg:100. ~reps:8 ();
202
set ~row:2 ~set_index:2 ~name:"Barbell Squat" ~kg:100. ~reps:6
203
();
204
]
205
in
206
ok (run (S.store_import_batch s t (batch ~id:"b1" [ w ])));
207
ignore
208
(map s t ~batch:"b1" ~workout:"w1" ~name:"Barbell Squat"
209
(get "squats"));
210
let _ =
211
ok
212
(run
213
(S.promote_import_workout s t ~batch:(WI.batch_id "b1")
214
~workout:(WI.workout_id "w1")))
215
in
216
(match run (S.imported_records s t) with
217
| [ record ] ->
218
Alcotest.(check int)
219
"both observations in one record" 2
220
(List.length record.Repository.observations)
221
| records ->
222
Alcotest.failf "expected one record, got %d" (List.length records));
223
let again =
224
ok
225
(run
226
(S.promote_import_workout s t ~batch:(WI.batch_id "b1")
227
~workout:(WI.workout_id "w1")))
228
in
229
(match again with
230
| WI.Already_promoted _ -> ()
231
| _ -> Alcotest.fail "expected Already_promoted on repeat");
232
Alcotest.(check int)
233
"still one record, no duplication" 1
234
(List.length (run (S.imported_records s t)));
235
match run (S.imported_records s t) with
236
| [ record ] ->
237
Alcotest.(check int)
238
"still two observations" 2
239
(List.length record.Repository.observations)
240
| _ -> Alcotest.fail "expected one record" );
241
( "deleting a batch leaves its promoted record",
242
`Quick,
243
fun () ->
244
let s, t = fixture () in
245
let _ = ready_batch s t ~id:"b1" ~ended_at:(at 3_600) in
246
let _ =
247
ok
248
(run
249
(S.promote_import_workout s t ~batch:(WI.batch_id "b1")
250
~workout:(WI.workout_id "b1-w")))
251
in
252
Alcotest.(check bool)
253
"batch deleted" true
254
(run (S.delete_import_batch s t (WI.batch_id "b1")));
255
Alcotest.(check bool)
256
"batch gone" true
257
(Option.is_none (run (S.find_import_batch s t (WI.batch_id "b1"))));
258
Alcotest.(check int)
259
"record survives" 1
260
(List.length (run (S.imported_records s t))) );
261
]
262
263
(* Build, map, and promote a one-set squats workout at [ended_at]. *)
264
let promote_squats s t ~id ~ended_at ~kg ~reps =
265
let w =
266
workout ~id:(id ^ "-w") ~title:"Legs" ~started_at:ended_at ~ended_at
267
~sets:[ set ~row:1 ~name:"Barbell Squat" ~kg ~reps () ]
268
in
269
ok (run (S.store_import_batch s t (batch ~id ~fingerprint:id [ w ])));
270
ignore
271
(map s t ~batch:id ~workout:(id ^ "-w") ~name:"Barbell Squat" (get "squats"));
272
ignore
273
(run
274
(S.promote_import_workout s t ~batch:(WI.batch_id id)
275
~workout:(WI.workout_id (id ^ "-w"))))
276
277
let progression_tests =
278
[
279
( "two imported observations turn insufficient data into a verdict",
280
`Quick,
281
fun () ->
282
let s, t = fixture () in
283
promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8;
284
(match run (S.progress s t (get "squats")) with
285
| Error Progress.Insufficient_data -> ()
286
| _ -> Alcotest.fail "one observation should be insufficient");
287
promote_squats s t ~id:"b2" ~ended_at:(day 3) ~kg:105. ~reps:8;
288
Alcotest.(check bool)
289
"progressing on two imports" true
290
(run (S.progress s t (get "squats")) = Ok Progress.Progressing) );
291
( "a flat fortnight of imports reads as a stall",
292
`Quick,
293
fun () ->
294
let s, t = fixture () in
295
promote_squats s t ~id:"b1" ~ended_at:(day 10) ~kg:110. ~reps:8;
296
(match run (S.progress s t (get "squats")) with
297
| Error Progress.Insufficient_data -> ()
298
| _ -> Alcotest.fail "one observation should be insufficient");
299
promote_squats s t ~id:"b2" ~ended_at:(day 25) ~kg:110. ~reps:8;
300
Alcotest.(check bool)
301
"stalled after a flat fortnight" true
302
(run (S.progress s t (get "squats")) = Ok Progress.Stalled) );
303
( "imported records for another exercise are excluded",
304
`Quick,
305
fun () ->
306
let s, t = fixture () in
307
promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8;
308
promote_squats s t ~id:"b2" ~ended_at:(day 3) ~kg:105. ~reps:8;
309
match run (S.progress s t (get "laterals")) with
310
| Error Progress.Insufficient_data -> ()
311
| _ -> Alcotest.fail "laterals should have no evidence" );
312
]
313
314
let recovery_tests =
315
[
316
( "the latest promoted import gates recovery for 48h, then ready",
317
`Quick,
318
fun () ->
319
let s, t = fixture () in
320
promote_squats s t ~id:"b1" ~ended_at:(day 10) ~kg:100. ~reps:8;
321
let early =
322
ok
323
(run
324
(S.readiness s t ~routine:ideal
325
~now:(at ((10 * 86_400) + hours 24))))
326
in
327
Alcotest.(check bool)
328
"recovering at 24h" false (Recovery.is_ready early);
329
let late =
330
ok
331
(run
332
(S.readiness s t ~routine:ideal
333
~now:(at ((10 * 86_400) + hours 48))))
334
in
335
Alcotest.(check bool) "ready at 48h" true (Recovery.is_ready late) );
336
( "an imported promotion never advances the routine cycle",
337
`Quick,
338
fun () ->
339
let s, t = fixture () in
340
let before = ok (run (S.next_workout s t ~routine:ideal)) in
341
promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8;
342
let after = ok (run (S.next_workout s t ~routine:ideal)) in
343
Alcotest.(check string)
344
"next workout unchanged by an import"
345
(Prescription.Workout.name before)
346
(Prescription.Workout.name after) );
347
( "native later than imported controls recovery",
348
`Quick,
349
fun () ->
350
let s, t = fixture () in
351
promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8;
352
let _ =
353
ok (run (S.begin_workout s t ~routine:ideal ~now:(day 10) ()))
354
in
355
let _ = run (S.finish s t ~ended_at:(day 10)) in
356
let r =
357
ok
358
(run
359
(S.readiness s t ~routine:ideal
360
~now:(at ((10 * 86_400) + hours 24))))
361
in
362
Alcotest.(check bool)
363
"native end governs, still recovering" false (Recovery.is_ready r) );
364
( "imported later than native controls recovery",
365
`Quick,
366
fun () ->
367
let s, t = fixture () in
368
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
369
let _ = run (S.finish s t ~ended_at:(day 1)) in
370
promote_squats s t ~id:"b1" ~ended_at:(day 10) ~kg:100. ~reps:8;
371
let recovering =
372
ok
373
(run
374
(S.readiness s t ~routine:ideal
375
~now:(at ((10 * 86_400) + hours 24))))
376
in
377
Alcotest.(check bool)
378
"import end governs, still recovering" false
379
(Recovery.is_ready recovering);
380
let ready =
381
ok
382
(run
383
(S.readiness s t ~routine:ideal
384
~now:(at ((10 * 86_400) + hours 48))))
385
in
386
Alcotest.(check bool)
387
"ready 48h after the governing import" true (Recovery.is_ready ready)
388
);
389
]
390
391
let diagnostics_tests =
392
[
393
( "diagnostics exclude imports",
394
`Quick,
395
fun () ->
396
let s, t = fixture () in
397
promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8;
398
promote_squats s t ~id:"b2" ~ended_at:(day 3) ~kg:105. ~reps:8;
399
Alcotest.(check int)
400
"no diagnostics from imports alone" 0
401
(List.length (run (S.diagnostics s t))) );
402
]
403
404
let suite =
405
[
406
("import_service.store", store_tests);
407
("import_service.promotion", promotion_tests);
408
("import_service.progression", progression_tests);
409
("import_service.recovery", recovery_tests);
410
("import_service.diagnostics", diagnostics_tests);
411
]
412