View raw

1 (** Unit tests for {!Evidence}, authored against the evidence.mli contract. *) 2 3 module Stimulus = Evidence.Stimulus 4 module Workout = Evidence.Workout 5 module Feedback = Evidence.Feedback 6 module Log = Evidence.Log 7 8 let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok" 9 10 let get id = 11 match Exercise.find_id id with 12 | Some e -> e 13 | None -> Alcotest.failf "catalog is missing %S" id 14 15 let load n = n 16 let reps n = n 17 let at s = Recovery.timestamp_of_unix_seconds s 18 let day n = at (n * 86_400) 19 20 let move ?(outcome = Stimulus.Positive_failure) id load_kg rep_count = 21 Stimulus.Effort.make ~exercise:(get id) ~load:(load load_kg) 22 ~reps:(reps rep_count) ~outcome 23 24 let single id load r = Stimulus.make (Stimulus.Single (move id load r)) 25 let pair first second = Stimulus.make (Stimulus.Pair { first; second }) 26 27 let invalid_error f = 28 try 29 let _ = f () in 30 None 31 with Workout.Invalid error -> Some error 32 33 (* {1 One stimulus} *) 34 35 let outcome_tests = 36 [ 37 ( "positive failure used no extension", 38 `Quick, 39 fun () -> 40 Alcotest.(check (list string)) 41 "none" [] 42 (List.map 43 (Format.asprintf "%a" Stimulus.pp_extension) 44 (Stimulus.extensions_of_outcome Stimulus.Positive_failure)) ); 45 ( "extensions stack in the order applied", 46 `Quick, 47 fun () -> 48 let o = 49 Stimulus.Beyond_failure (Stimulus.Forced_reps, [ Stimulus.Negatives ]) 50 in 51 Alcotest.(check (list string)) 52 "forced reps then negatives" 53 [ "forced reps"; "negatives" ] 54 (List.map 55 (Format.asprintf "%a" Stimulus.pp_extension) 56 (Stimulus.extensions_of_outcome o)) ); 57 ] 58 59 let stimulus_delivery_tests = 60 [ 61 ( "a single movement is one stimulus", 62 `Quick, 63 fun () -> 64 let s = single "curls" 40. 8 in 65 Alcotest.(check int) "one effort" 1 (List.length (Stimulus.efforts s)); 66 Alcotest.(check bool) "not extended" false (Stimulus.is_extended s) ); 67 ( "a pre-exhaust pair is one stimulus, isolation first", 68 `Quick, 69 fun () -> 70 let s = 71 pair (move "dumbbell-flyes" 20. 9) (move "incline-press" 60. 7) 72 in 73 Alcotest.(check int) 74 "two movements" 2 75 (List.length (Stimulus.efforts s)); 76 Alcotest.(check (list string)) 77 "isolation leads" 78 [ "Dumbbell Flyes"; "Incline Presses" ] 79 (List.map Exercise.name (Stimulus.exercises s)) ); 80 ( "an unrelated pair is recorded as performed", 81 `Quick, 82 fun () -> 83 let s = 84 Stimulus.make 85 (Stimulus.Pair 86 { 87 first = move "dumbbell-flyes" 20. 9; 88 second = move "squats" 100. 8; 89 }) 90 in 91 Alcotest.(check (list string)) 92 "both movements" 93 [ "Dumbbell Flyes"; "Squats" ] 94 (List.map Exercise.name (Stimulus.exercises s)) ); 95 ( "extensions are gathered across a pre-exhaust's movements", 96 `Quick, 97 fun () -> 98 let s = 99 pair 100 (move "lying-french-press" 30. 9) 101 (move 102 ~outcome: 103 (Stimulus.Beyond_failure 104 (Stimulus.Forced_reps, [ Stimulus.Negatives ])) 105 "dips" 0. 6) 106 in 107 Alcotest.(check bool) "extended" true (Stimulus.is_extended s); 108 Alcotest.(check int) 109 "two extensions" 2 110 (List.length (Stimulus.extensions s)) ); 111 ] 112 113 (* {1 One performed workout} *) 114 115 let routine = Prescription.Routine.ideal 116 let prescribed n = List.nth (Prescription.Routine.workouts routine) n 117 let day_one = prescribed 0 118 let cleared = Option.get (Recovery.clear Recovery.Ready) 119 let fresh () = Workout.start day_one ~clearance:cleared ~started_at:(at 0) 120 121 (* HD1's Day 1, in the order it lists. *) 122 let day_one_stimuli = 123 [ 124 pair (move "dumbbell-flyes" 20. 9) (move "incline-press" 60. 7); 125 single "laterals" 12. 8; 126 single "bent-over-laterals" 10. 9; 127 pair (move "lying-french-press" 30. 8) (move "dips" 0. 6); 128 ] 129 130 let perform stimuli = 131 List.fold_left (fun w s -> Workout.add_stimulus w s) (fresh ()) stimuli 132 133 let lifecycle_tests = 134 [ 135 ( "a fresh workout has performed nothing and answers its prescription", 136 `Quick, 137 fun () -> 138 let w = fresh () in 139 Alcotest.(check string) 140 "prescription" "Day 1" 141 (Prescription.Workout.name (Workout.prescription w)); 142 Alcotest.(check int) "no stimuli" 0 (List.length (Workout.stimuli w)); 143 Alcotest.(check int) 144 "four slots outstanding" 4 145 (List.length (Workout.unperformed w)); 146 Alcotest.(check bool) "unfinished" false (Workout.is_finished w); 147 Alcotest.(check bool) 148 "no duration" true 149 (Option.is_none (Workout.duration w)) ); 150 ( "HD1's Day 1 can be logged end to end", 151 `Quick, 152 fun () -> 153 let w = perform day_one_stimuli in 154 Alcotest.(check int) "four stimuli" 4 (List.length (Workout.stimuli w)); 155 Alcotest.(check int) 156 "nothing outstanding" 0 157 (List.length (Workout.unperformed w)); 158 let finished = Workout.finish w ~ended_at:(at 2400) in 159 Alcotest.(check bool) "finished" true (Workout.is_finished finished); 160 Alcotest.(check (option int)) 161 "40 minutes" (Some 2400) 162 (Option.map Recovery.duration_to_seconds (Workout.duration finished)) 163 ); 164 ( "stimuli come back in the order performed", 165 `Quick, 166 fun () -> 167 let w = 168 perform [ single "laterals" 12. 8; single "bent-over-laterals" 10. 9 ] 169 in 170 Alcotest.(check (list string)) 171 "as performed" 172 [ "Laterals"; "Bent-over Dumbbell Laterals" ] 173 (List.map 174 (fun s -> Exercise.name (List.hd (Stimulus.exercises s))) 175 (Workout.stimuli w)) ); 176 ( "a finished workout remains editable and retains its end time", 177 `Quick, 178 fun () -> 179 let w = Workout.finish (fresh ()) ~ended_at:(at 60) in 180 let w = Workout.add_stimulus w (single "laterals" 12. 8) in 181 let w = Workout.finish w ~ended_at:(at 120) in 182 Alcotest.(check int) 183 "original end" 60 184 (Recovery.timestamp_to_unix_seconds (Option.get (Workout.ended_at w))); 185 Alcotest.(check int) "one recorded" 1 (List.length (Workout.stimuli w)) 186 ); 187 ] 188 189 let conformance_tests = 190 [ 191 ( "an unprescribed movement is refused", 192 `Quick, 193 fun () -> 194 match 195 invalid_error (fun () -> 196 Workout.add_stimulus (fresh ()) (single "shrugs" 80. 10)) 197 with 198 | Some (Workout.Not_prescribed id) -> 199 Alcotest.(check string) "shrugs" "shrugs" (id :> string) 200 | _ -> Alcotest.fail "expected Not_prescribed" ); 201 ( "a lone set where a pre-exhaust was prescribed is refused", 202 `Quick, 203 fun () -> 204 match 205 invalid_error (fun () -> 206 Workout.add_stimulus (fresh ()) (single "dumbbell-flyes" 20. 9)) 207 with 208 | Some 209 (Workout.Delivery_mismatch 210 { prescribed = Workout.As_pair; logged = Workout.As_single; _ }) 211 -> 212 () 213 | _ -> Alcotest.fail "expected Delivery_mismatch" ); 214 ( "the prescribed pair, delivered as prescribed, is accepted", 215 `Quick, 216 fun () -> 217 ignore 218 (Workout.add_stimulus (fresh ()) 219 (pair (move "dumbbell-flyes" 20. 9) (move "incline-press" 60. 7))) 220 ); 221 ( "an allowed substitute is accepted in its own role", 222 `Quick, 223 fun () -> 224 let w = 225 Workout.add_stimulus (fresh ()) 226 (pair (move "pec-deck" 45. 9) (move "incline-press" 60. 7)) 227 in 228 Alcotest.(check int) "recorded" 1 (List.length (Workout.stimuli w)); 229 Alcotest.(check int) 230 "slot filled" 3 231 (List.length (Workout.unperformed w)) ); 232 ( "a movement off the prescription's substitute list is refused", 233 `Quick, 234 fun () -> 235 (* Cable crossovers substitute for flyes in the catalog, but Day 1 236 permits only crossovers and pec deck — dips is never the pec 237 compound. *) 238 match 239 invalid_error (fun () -> 240 Workout.add_stimulus (fresh ()) 241 (pair (move "dumbbell-flyes" 20. 9) (move "dips" 0. 7))) 242 with 243 | Some _ -> () 244 | None -> Alcotest.fail "dips is not the prescribed pec compound" ); 245 ] 246 247 let volume_tests = 248 [ 249 ( "repeated work is recorded, and shows as more stimuli than slots", 250 `Quick, 251 fun () -> 252 (* HD1 forbids extra volume, but the log must still say what happened; 253 diagnosing it is Progression's job. *) 254 let w = perform [ single "laterals" 12. 8; single "laterals" 12. 6 ] in 255 Alcotest.(check int) "two stimuli" 2 (List.length (Workout.stimuli w)); 256 Alcotest.(check int) 257 "still three slots outstanding" 3 258 (List.length (Workout.unperformed w)) ); 259 ( "unperformed shrinks as slots are answered", 260 `Quick, 261 fun () -> 262 let w = perform [ single "laterals" 12. 8 ] in 263 Alcotest.(check int) 264 "three left" 3 265 (List.length (Workout.unperformed w)); 266 let w = Workout.add_stimulus w (single "bent-over-laterals" 10. 9) in 267 Alcotest.(check int) "two left" 2 (List.length (Workout.unperformed w)) 268 ); 269 ] 270 271 let editing_tests = 272 [ 273 ( "replace_stimulus corrects a slot in place without adding volume", 274 `Quick, 275 fun () -> 276 let w = perform [ single "laterals" 12. 8 ] in 277 Alcotest.(check int) "one filled slot" 1 (Workout.filled_slots w); 278 let w = Workout.replace_stimulus w ~slot:1 (single "laterals" 14. 7) in 279 Alcotest.(check int) "still one filled slot" 1 (Workout.filled_slots w); 280 Alcotest.(check int) 281 "still one stimulus" 1 282 (List.length (Workout.stimuli w)); 283 match Workout.stimuli w with 284 | [ s ] -> 285 Alcotest.(check (float 0.001)) 286 "corrected load" 14. 287 (Stimulus.Effort.load (List.hd (Stimulus.efforts s))) 288 | _ -> Alcotest.fail "expected one stimulus" ); 289 ( "replace_stimulus fills an empty slot as its first record", 290 `Quick, 291 fun () -> 292 let w = 293 Workout.replace_stimulus (fresh ()) ~slot:1 (single "laterals" 12. 8) 294 in 295 Alcotest.(check int) "one filled slot" 1 (Workout.filled_slots w); 296 Alcotest.(check int) 297 "three slots still outstanding" 3 298 (List.length (Workout.unperformed w)) ); 299 ( "replace_stimulus rejects an unknown slot", 300 `Quick, 301 fun () -> 302 match 303 invalid_error (fun () -> 304 Workout.replace_stimulus (fresh ()) ~slot:9 305 (single "laterals" 12. 8)) 306 with 307 | Some (Workout.No_such_slot 9) -> () 308 | _ -> Alcotest.fail "expected No_such_slot" ); 309 ( "replace_stimulus rejects a stimulus the slot does not call for", 310 `Quick, 311 fun () -> 312 (* Slot 0 is the pre-exhaust pair; a lone set does not fit its shape. *) 313 match 314 invalid_error (fun () -> 315 Workout.replace_stimulus (fresh ()) ~slot:0 316 (single "dumbbell-flyes" 20. 9)) 317 with 318 | Some (Workout.Delivery_mismatch _) -> () 319 | Some _ | None -> Alcotest.fail "expected Delivery_mismatch" ); 320 ( "filled_slots never exceeds the prescription despite extra volume", 321 `Quick, 322 fun () -> 323 let w = perform [ single "laterals" 12. 8; single "laterals" 12. 6 ] in 324 Alcotest.(check int) 325 "two stimuli recorded" 2 326 (List.length (Workout.stimuli w)); 327 Alcotest.(check int) "but one slot filled" 1 (Workout.filled_slots w) ); 328 ( "record_at appends at a slot, preserving prior fills", 329 `Quick, 330 fun () -> 331 let w = 332 Workout.record_at (fresh ()) ~slot:1 (single "laterals" 12. 8) 333 in 334 let w = Workout.record_at w ~slot:1 (single "laterals" 12. 6) in 335 Alcotest.(check int) 336 "two records at the slot" 2 337 (List.length (Workout.performed w)); 338 Alcotest.(check int) 339 "still one distinct slot" 1 (Workout.filled_slots w) ); 340 ( "performed pairs each stimulus with its slot", 341 `Quick, 342 fun () -> 343 let w = 344 perform [ single "laterals" 12. 8; single "bent-over-laterals" 10. 9 ] 345 in 346 Alcotest.(check (list int)) 347 "slots 1 then 2" [ 1; 2 ] 348 (List.map fst (Workout.performed w)) ); 349 ] 350 351 let clearance_tests = 352 [ 353 ( "a workout keeps the basis on which it was begun", 354 `Quick, 355 fun () -> 356 let recovering = 357 Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12) 358 ~recommended:Prescription.Routine.training_interval 359 in 360 let w = 361 Workout.start day_one 362 ~clearance:(Recovery.override recovering) 363 ~started_at:(at 0) 364 in 365 match Recovery.basis (Workout.clearance w) with 366 | Recovery.Overridden _ -> () 367 | Recovery.Recovered -> Alcotest.fail "expected Overridden" ); 368 ] 369 370 (* {1 The log} *) 371 372 (* A finished workout: Day n of the Ideal Routine, with stimuli logged. *) 373 let logged ~workout:p ~on ~stimuli = 374 let w = 375 List.fold_left 376 (fun w s -> Workout.add_stimulus w s) 377 (Workout.start p ~clearance:cleared ~started_at:on) 378 stimuli 379 in 380 Workout.finish w ~ended_at:on 381 382 let laterals load r = single "laterals" load r 383 384 let log_basic_tests = 385 [ 386 ( "an empty log knows nothing", 387 `Quick, 388 fun () -> 389 Alcotest.(check int) 390 "no workouts" 0 391 (List.length (Log.workouts Log.empty)); 392 Alcotest.(check bool) 393 "no last prescription" true 394 (Option.is_none (Log.last_prescription Log.empty)) ); 395 ( "workouts come back most recent first, however they were added", 396 `Quick, 397 fun () -> 398 let book = 399 ( Log.empty |> fun b -> 400 Log.add b (logged ~workout:(prescribed 1) ~on:(day 3) ~stimuli:[]) 401 ) 402 |> fun b -> 403 Log.add b (logged ~workout:(prescribed 0) ~on:(day 1) ~stimuli:[]) 404 in 405 Alcotest.(check (list string)) 406 "newest first" [ "Day 2"; "Day 1" ] 407 (List.map 408 (fun w -> Prescription.Workout.name (Workout.prescription w)) 409 (Log.workouts book)) ); 410 ( "the last prescription is what the cycle should advance from", 411 `Quick, 412 fun () -> 413 let book = 414 Log.add Log.empty 415 (logged ~workout:(prescribed 1) ~on:(day 1) ~stimuli:[]) 416 in 417 let last = Option.get (Log.last_prescription book) in 418 Alcotest.(check string) 419 "performed Day 2" "Day 2" 420 (Prescription.Workout.name last); 421 Alcotest.(check string) 422 "so Day 3 is next" "Day 3" 423 (Prescription.Workout.name 424 (Prescription.Routine.workout_after routine last)) ); 425 ] 426 427 let observation_tests = 428 [ 429 ( "observations for a movement come back oldest first", 430 `Quick, 431 fun () -> 432 let book = 433 ( Log.empty |> fun b -> 434 Log.add b 435 (logged ~workout:(prescribed 0) ~on:(day 5) 436 ~stimuli:[ laterals 14. 7 ]) ) 437 |> fun b -> 438 Log.add b 439 (logged ~workout:(prescribed 0) ~on:(day 1) 440 ~stimuli:[ laterals 12. 8 ]) 441 in 442 let history = Log.observations book (get "laterals") in 443 Alcotest.(check int) "two observations" 2 (List.length history); 444 Alcotest.(check (list (float 0.001))) 445 "12kg then 14kg" [ 12.; 14. ] 446 (List.map 447 (fun (o : Log.observation) -> Stimulus.Effort.load o.effort) 448 history) ); 449 ( "observations are dated, so a stall can be measured", 450 `Quick, 451 fun () -> 452 let book = 453 Log.add Log.empty 454 (logged ~workout:(prescribed 0) ~on:(day 2) 455 ~stimuli:[ laterals 12. 8 ]) 456 in 457 match Log.observations book (get "laterals") with 458 | [ o ] -> 459 Alcotest.(check int) 460 "day 2" 172_800 461 (Recovery.timestamp_to_unix_seconds o.performed_at) 462 | _ -> Alcotest.fail "expected one observation" ); 463 ( "a movement never performed has no observations", 464 `Quick, 465 fun () -> 466 let book = 467 Log.add Log.empty 468 (logged ~workout:(prescribed 0) ~on:(day 1) 469 ~stimuli:[ laterals 12. 8 ]) 470 in 471 Alcotest.(check int) 472 "none" 0 473 (List.length (Log.observations book (get "squats"))) ); 474 ( "both halves of a pre-exhaust are recorded separately", 475 `Quick, 476 fun () -> 477 let pair = 478 pair (move "dumbbell-flyes" 20. 9) (move "incline-press" 60. 7) 479 in 480 let book = 481 Log.add Log.empty 482 (logged ~workout:(prescribed 0) ~on:(day 1) ~stimuli:[ pair ]) 483 in 484 Alcotest.(check int) 485 "isolation" 1 486 (List.length (Log.observations book (get "dumbbell-flyes"))); 487 Alcotest.(check int) 488 "compound" 1 489 (List.length (Log.observations book (get "incline-press"))) ); 490 ] 491 492 let readiness_tests = 493 [ 494 ( "an empty log is ready: nothing to recover from", 495 `Quick, 496 fun () -> 497 Alcotest.(check bool) 498 "ready" true 499 (Recovery.is_ready 500 (Log.readiness Log.empty ~now:(day 1) 501 ~recommended:Prescription.Routine.training_interval)) ); 502 ( "readiness is measured from the last finished workout", 503 `Quick, 504 fun () -> 505 let book = 506 Log.add Log.empty 507 (logged ~workout:(prescribed 0) ~on:(day 1) ~stimuli:[]) 508 in 509 Alcotest.(check bool) 510 "one day later, still recovering" false 511 (Recovery.is_ready 512 (Log.readiness book ~now:(day 2) 513 ~recommended:Prescription.Routine.training_interval)); 514 Alcotest.(check bool) 515 "two days later, ready" true 516 (Recovery.is_ready 517 (Log.readiness book ~now:(day 3) 518 ~recommended:Prescription.Routine.training_interval)) ); 519 ( "an unfinished workout leaves nothing to recover from", 520 `Quick, 521 fun () -> 522 let unfinished = 523 Workout.start (prescribed 0) ~clearance:cleared ~started_at:(day 1) 524 in 525 let book = Log.add Log.empty unfinished in 526 Alcotest.(check bool) 527 "ready" true 528 (Recovery.is_ready 529 (Log.readiness book ~now:(day 1) 530 ~recommended:Prescription.Routine.training_interval)) ); 531 ] 532 533 let completion_feedback_tests = 534 [ 535 ( "feedback rejects duplicate signal categories", 536 `Quick, 537 fun () -> 538 let open Feedback in 539 match 540 try 541 ignore (make ~reported_at:(at 60) [ Sleep Poor; Sleep Good ]); 542 None 543 with Invalid error -> Some error 544 with 545 | Some (Duplicate_signal (Sleep Good)) -> () 546 | _ -> Alcotest.fail "expected duplicate sleep rejection" ); 547 ( "feedback retains its report time and signals", 548 `Quick, 549 fun () -> 550 let open Feedback in 551 let feedback = 552 make ~reported_at:(at 60) 553 [ 554 Sleep Good; 555 Appetite Fair; 556 Readiness Good; 557 Motivation Good; 558 Difficulty Fair; 559 Preparation_insufficient; 560 ] 561 in 562 Alcotest.(check int) 563 "report time" 60 564 (Recovery.timestamp_to_unix_seconds (reported_at feedback)); 565 Alcotest.(check int) "six signals" 6 (List.length (signals feedback)) ); 566 ] 567 568 let suite = 569 [ 570 ("evidence.stimulus.outcome", outcome_tests); 571 ("evidence.stimulus.delivery", stimulus_delivery_tests); 572 ("evidence.workout.lifecycle", lifecycle_tests); 573 ("evidence.workout.conformance", conformance_tests); 574 ("evidence.workout.volume", volume_tests); 575 ("evidence.workout.editing", editing_tests); 576 ("evidence.workout.clearance", clearance_tests); 577 ("evidence.workout.completion_feedback", completion_feedback_tests); 578 ("evidence.log.basics", log_basic_tests); 579 ("evidence.log.observations", observation_tests); 580 ("evidence.log.readiness", readiness_tests); 581 ] 582