View raw

1 (** Unit tests for {!Workout_import}, authored against the workout_import.mli 2 contract. 3 4 The import use case must feed the core only confirmed, catalogued, 5 failure-defined evidence. These tests exercise that gate: failure sets 6 promote after mapping, normal sets wait for one acknowledgement, dropsets 7 block the whole workout, blockers are deterministic, promotion is atomic per 8 workout and partial per batch, and a promoted workout has its own revisioned 9 lifecycle. *) 10 11 open Hito_app 12 module WI = Workout_import 13 module Observation = Evidence.Observation 14 module Effort = Evidence.Stimulus.Effort 15 16 let get id = 17 match Exercise.find_id id with 18 | Some e -> e 19 | None -> Alcotest.failf "catalog is missing %S" id 20 21 let at s = Recovery.timestamp_of_unix_seconds s 22 let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok" 23 24 let set ?(row = 0) ?(set_index = 1) ?(superset_id = None) ~name ~kind ~kg ~reps 25 () : WI.source_set = 26 { 27 row; 28 exercise_name = name; 29 set_index; 30 set_type = kind; 31 weight_kg = kg; 32 reps; 33 superset_id; 34 } 35 36 let workout ~id ~sets = 37 ok 38 (WI.make_workout ~id:(WI.workout_id id) ~title:"Chest" 39 ~started_at:(at 1_000) ~ended_at:(at 4_600) ~sets) 40 41 let batch ?(warnings = []) workouts = 42 WI.make_batch ~id:(WI.batch_id "b1") ~source:"raw,csv,text" ~fingerprint:"fp" 43 ~imported_at:(at 5_000) ~workouts ~warnings 44 45 (* A blocker rendered as a stable string, so tests can assert order and 46 content without depending on structural equality. *) 47 let show_blocker : WI.blocker -> string = function 48 | No_sets -> "no_sets" 49 | Unmapped_exercise name -> "unmapped:" ^ name 50 | Normal_sets_unconfirmed -> "normal_unconfirmed" 51 52 let blocker_list = Alcotest.(list string) 53 let blockers_of w = List.map show_blocker (WI.blockers w) 54 let wid = WI.workout_id 55 56 (* {1 Failure sets promote after mapping, without acknowledgement} *) 57 58 let failure_promotes_without_acknowledgement = 59 ( "a failure set promotes after mapping, no acknowledgement needed", 60 `Quick, 61 fun () -> 62 let s = 63 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 64 in 65 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 66 let b = 67 ok 68 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 69 (get "squats")) 70 in 71 let w = Option.get (WI.find_workout b (wid "w1")) in 72 Alcotest.(check blocker_list) "no blockers" [] (blockers_of w); 73 let _, result = ok (WI.promote_workout b (wid "w1")) in 74 match result with 75 | WI.Promoted p -> 76 Alcotest.(check int) "one observation" 1 (List.length p.observations); 77 Alcotest.(check (option string)) 78 "no normal confirmation" None p.provenance.normal_confirmation 79 | _ -> Alcotest.fail "expected Promoted" ) 80 81 (* {1 Normal sets block until a workout-level acknowledgement} *) 82 83 let normal_blocks_until_confirmed = 84 ( "a normal set blocks until the workout-level acknowledgement", 85 `Quick, 86 fun () -> 87 let s = 88 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () 89 in 90 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 91 let b = 92 ok 93 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 94 (get "squats")) 95 in 96 let w = Option.get (WI.find_workout b (wid "w1")) in 97 Alcotest.(check blocker_list) 98 "blocked on confirmation" [ "normal_unconfirmed" ] (blockers_of w); 99 (match WI.promote_workout b (wid "w1") with 100 | Ok (_, WI.Blocked _) -> () 101 | _ -> Alcotest.fail "expected Blocked before confirmation"); 102 let b = 103 ok 104 (WI.confirm_normal_sets b ~workout:(wid "w1") 105 ~acknowledgement:"I confirm these were to failure") 106 in 107 let w = Option.get (WI.find_workout b (wid "w1")) in 108 Alcotest.(check blocker_list) 109 "no blockers after confirmation" [] (blockers_of w); 110 Alcotest.(check (option string)) 111 "exact acknowledgement retained" 112 (Some "I confirm these were to failure") 113 (WI.workout_acknowledgement w); 114 let _, result = ok (WI.promote_workout b (wid "w1")) in 115 match result with 116 | WI.Promoted p -> 117 Alcotest.(check (option string)) 118 "confirmation recorded in provenance" 119 (Some "I confirm these were to failure") 120 p.provenance.normal_confirmation 121 | _ -> Alcotest.fail "expected Promoted after confirmation" ) 122 123 let empty_acknowledgement_rejected = 124 ( "a whitespace-only acknowledgement is rejected", 125 `Quick, 126 fun () -> 127 let s = set ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () in 128 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 129 match 130 WI.confirm_normal_sets b ~workout:(wid "w1") ~acknowledgement:" " 131 with 132 | Error WI.Empty_acknowledgement -> () 133 | _ -> Alcotest.fail "expected Empty_acknowledgement" ) 134 135 (* {1 A dropset is discarded, never a blocker} *) 136 137 let dropset_discarded_failure_promotes = 138 ( "a dropset is discarded; the failure set beside it promotes", 139 `Quick, 140 fun () -> 141 let good = 142 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 143 in 144 (* A drop set for a second, deliberately unmapped exercise. It must not 145 block: it is discarded like a warmup, so its exercise never needs a 146 mapping. *) 147 let drop = 148 set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 () 149 in 150 let b = batch [ workout ~id:"w1" ~sets:[ good; drop ] ] in 151 let b = 152 ok 153 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 154 (get "squats")) 155 in 156 let w = Option.get (WI.find_workout b (wid "w1")) in 157 Alcotest.(check blocker_list) 158 "no blocker from a discarded dropset" [] (blockers_of w); 159 match WI.promote_workout b (wid "w1") with 160 | Ok (_, WI.Promoted p) -> 161 Alcotest.(check int) 162 "only the failure set is observed" 1 163 (List.length p.observations); 164 Alcotest.(check (list int)) 165 "provenance keeps only the failure row" [ 1 ] 166 p.provenance.source_rows 167 | _ -> Alcotest.fail "expected Promoted with the dropset discarded" ) 168 169 let dropset_only_workout_has_no_sets = 170 ( "a workout of only drop sets reduces to No_sets", 171 `Quick, 172 fun () -> 173 let drop = 174 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Dropset ~kg:100. ~reps:8 () 175 in 176 let b = batch [ workout ~id:"w1" ~sets:[ drop ] ] in 177 let w = Option.get (WI.find_workout b (wid "w1")) in 178 Alcotest.(check blocker_list) "no_sets" [ "no_sets" ] (blockers_of w) ) 179 180 (* {1 No sets is its own blocker} *) 181 182 let no_sets_blocker = 183 ( "a workout with no sets reports No_sets", 184 `Quick, 185 fun () -> 186 let w = workout ~id:"w1" ~sets:[] in 187 Alcotest.(check blocker_list) "no_sets" [ "no_sets" ] (blockers_of w) ) 188 189 (* {1 Mapping is by name and covers all matching sets} *) 190 191 let mapping_covers_all_matching_sets = 192 ( "one mapping covers every set that shares the source name", 193 `Quick, 194 fun () -> 195 let s1 = 196 set ~row:1 ~set_index:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. 197 ~reps:8 () 198 in 199 let s2 = 200 set ~row:2 ~set_index:2 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. 201 ~reps:6 () 202 in 203 let b = batch [ workout ~id:"w1" ~sets:[ s1; s2 ] ] in 204 (* Before mapping, the single distinct name is the only blocker. *) 205 let w = Option.get (WI.find_workout b (wid "w1")) in 206 Alcotest.(check blocker_list) 207 "one unmapped name" 208 [ "unmapped:Barbell Squat" ] 209 (blockers_of w); 210 let b = 211 ok 212 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 213 (get "squats")) 214 in 215 let w = Option.get (WI.find_workout b (wid "w1")) in 216 Alcotest.(check blocker_list) 217 "both sets covered by one mapping" [] (blockers_of w); 218 let _, result = ok (WI.promote_workout b (wid "w1")) in 219 match result with 220 | WI.Promoted p -> 221 Alcotest.(check int) 222 "two observations from two sets" 2 223 (List.length p.observations) 224 | _ -> Alcotest.fail "expected Promoted" ) 225 226 let every_distinct_name_must_be_mapped = 227 ( "every distinct source name must be mapped before promotion", 228 `Quick, 229 fun () -> 230 let s1 = 231 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 232 in 233 let s2 = 234 set ~row:2 ~name:"Leg Press" ~kind:WI.Failure ~kg:200. ~reps:10 () 235 in 236 let b = batch [ workout ~id:"w1" ~sets:[ s1; s2 ] ] in 237 let b = 238 ok 239 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 240 (get "squats")) 241 in 242 let w = Option.get (WI.find_workout b (wid "w1")) in 243 Alcotest.(check blocker_list) 244 "still blocked on the unmapped name" [ "unmapped:Leg Press" ] 245 (blockers_of w) ) 246 247 (* {1 Deterministic blocker order} *) 248 249 let deterministic_blocker_order = 250 ( "blockers appear in unmapped (first appearance) then normal order", 251 `Quick, 252 fun () -> 253 (* A discarded dropset contributes no blocker, not even an unmapped one 254 for its exercise. *) 255 let drop = 256 set ~row:3 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 () 257 in 258 let normal = 259 set ~row:1 ~name:"Bench Press" ~kind:WI.Normal ~kg:80. ~reps:8 () 260 in 261 let other = 262 set ~row:2 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:6 () 263 in 264 (* Order of sets fixes first-appearance order of names. *) 265 let w = workout ~id:"w1" ~sets:[ normal; other; drop ] in 266 Alcotest.(check blocker_list) 267 "names by first appearance, then normal; the dropset is absent" 268 [ 269 "unmapped:Bench Press"; "unmapped:Barbell Squat"; "normal_unconfirmed"; 270 ] 271 (blockers_of w) ) 272 273 (* {1 Promotion is atomic per workout and partial per batch} *) 274 275 let batch_promotes_partially = 276 ( "a blocked workout does not prevent another from promoting", 277 `Quick, 278 fun () -> 279 let good = 280 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 281 in 282 let drop = 283 set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 () 284 in 285 let b = 286 batch 287 [ workout ~id:"w1" ~sets:[ good ]; workout ~id:"w2" ~sets:[ drop ] ] 288 in 289 let b = 290 ok 291 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 292 (get "squats")) 293 in 294 let b = 295 ok 296 (WI.map_exercise b ~workout:(wid "w2") ~source_name:"Leg Press" 297 (get "leg-presses")) 298 in 299 let _, result = WI.promote_batch b in 300 Alcotest.(check int) "one promoted" 1 (List.length result.promoted); 301 Alcotest.(check int) "one blocked" 1 (List.length result.blocked); 302 Alcotest.(check string) 303 "w1 promoted" "w1" 304 (WI.workout_id_to_string (fst (List.hd result.promoted))); 305 Alcotest.(check string) 306 "w2 blocked" "w2" 307 (WI.workout_id_to_string (fst (List.hd result.blocked))) ) 308 309 let blocked_workout_promotes_after_cleaning = 310 ( "a blocked workout stays provisional and promotes after later cleaning", 311 `Quick, 312 fun () -> 313 let s = 314 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () 315 in 316 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 317 let b = 318 ok 319 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 320 (get "squats")) 321 in 322 (* Still blocked: the normal set is unconfirmed. *) 323 let b, result = WI.promote_batch b in 324 Alcotest.(check int) 325 "blocked before confirmation" 1 326 (List.length result.blocked); 327 let b = 328 ok 329 (WI.confirm_normal_sets b ~workout:(wid "w1") 330 ~acknowledgement:"confirmed") 331 in 332 let _, result = WI.promote_batch b in 333 Alcotest.(check int) 334 "promoted after confirmation" 1 335 (List.length result.promoted) ) 336 337 (* {1 A promoted workout has its own revisioned lifecycle} *) 338 339 let repeated_promotion_is_already_promoted = 340 ( "promoting an unchanged workout twice yields Already_promoted", 341 `Quick, 342 fun () -> 343 let s = 344 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 345 in 346 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 347 let b = 348 ok 349 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 350 (get "squats")) 351 in 352 let b, first = ok (WI.promote_workout b (wid "w1")) in 353 (match first with 354 | WI.Promoted _ -> () 355 | _ -> Alcotest.fail "expected Promoted on first promotion"); 356 let _, second = ok (WI.promote_workout b (wid "w1")) in 357 match second with 358 | WI.Already_promoted _ -> () 359 | _ -> Alcotest.fail "expected Already_promoted on repeat" ) 360 361 let editing_increments_revision_and_replaces = 362 ( "editing a mapping increments the revision and re-promotes a replacement", 363 `Quick, 364 fun () -> 365 let s = 366 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 367 in 368 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 369 let b = 370 ok 371 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 372 (get "squats")) 373 in 374 let rev0 = 375 WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) 376 in 377 let b, _ = ok (WI.promote_workout b (wid "w1")) in 378 (* Re-map the same name to a different exercise: a real change. *) 379 let b = 380 ok 381 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 382 (get "leg-presses")) 383 in 384 let rev1 = 385 WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) 386 in 387 Alcotest.(check bool) "revision advanced" true (rev1 > rev0); 388 let _, result = ok (WI.promote_workout b (wid "w1")) in 389 match result with 390 | WI.Promoted p -> 391 Alcotest.(check int) 392 "replacement carries the new revision" rev1 p.provenance.revision; 393 Alcotest.(check string) 394 "replacement observation uses the new exercise" "Leg Presses" 395 (Exercise.name (Observation.exercise (List.hd p.observations))) 396 | _ -> Alcotest.fail "expected a re-promoted replacement" ) 397 398 let idempotent_mapping_keeps_revision = 399 ( "re-confirming the same mapping does not increment the revision", 400 `Quick, 401 fun () -> 402 let s = 403 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 404 in 405 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 406 let b = 407 ok 408 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 409 (get "squats")) 410 in 411 let rev0 = 412 WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) 413 in 414 let b = 415 ok 416 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 417 (get "squats")) 418 in 419 let rev1 = 420 WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) 421 in 422 Alcotest.(check int) "revision unchanged" rev0 rev1 ) 423 424 (* {1 Promoted observations carry the expected effort} *) 425 426 let observations_carry_expected_effort = 427 ( "each observation carries the mapped exercise, load, reps, failure, and end", 428 `Quick, 429 fun () -> 430 let s = 431 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:102.5 ~reps:9 () 432 in 433 let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in 434 let b = 435 ok 436 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 437 (get "squats")) 438 in 439 let _, result = ok (WI.promote_workout b (wid "w1")) in 440 match result with 441 | WI.Promoted p -> 442 let obs = List.hd p.observations in 443 let effort = Observation.effort obs in 444 Alcotest.(check string) 445 "exercise" "Squats" 446 (Exercise.name (Observation.exercise obs)); 447 Alcotest.(check (float 0.001)) "load" 102.5 (Effort.load effort); 448 Alcotest.(check int) "reps" 9 (Effort.reps effort); 449 Alcotest.(check bool) 450 "outcome is positive failure" true 451 (match Effort.outcome effort with 452 | Evidence.Stimulus.Positive_failure -> true 453 | _ -> false); 454 Alcotest.(check int) 455 "performed at the workout end" 456 (Recovery.timestamp_to_unix_seconds (at 4_600)) 457 (Recovery.timestamp_to_unix_seconds (Observation.performed_at obs)) 458 | _ -> Alcotest.fail "expected Promoted" ) 459 460 (* {1 The raw source and warnings are immutable and retained} *) 461 462 let source_and_warnings_retained = 463 ( "the raw source and warnings survive cleaning unchanged", 464 `Quick, 465 fun () -> 466 let warnings : WI.warning list = 467 [ 468 { row = 5; exercise_name = Some "Warmup"; kind = WI.Warmup_dropped }; 469 { 470 row = 6; 471 exercise_name = None; 472 kind = WI.Unsupported_row "blank line"; 473 }; 474 ] 475 in 476 let s = 477 set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () 478 in 479 let b = batch ~warnings [ workout ~id:"w1" ~sets:[ s ] ] in 480 let b = 481 ok 482 (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" 483 (get "squats")) 484 in 485 let b, _ = WI.promote_batch b in 486 Alcotest.(check string) 487 "raw source unchanged" "raw,csv,text" (WI.batch_source b); 488 Alcotest.(check int) 489 "warnings retained" 2 490 (List.length (WI.batch_warnings b)); 491 Alcotest.(check bool) 492 "a warmup warning is present" true 493 (List.exists 494 (fun (w : WI.warning) -> w.kind = WI.Warmup_dropped) 495 (WI.batch_warnings b)) ) 496 497 let unknown_workout_is_an_error = 498 ( "operations on an unknown workout return Unknown_workout", 499 `Quick, 500 fun () -> 501 let b = batch [] in 502 match WI.promote_workout b (wid "nope") with 503 | Error (WI.Unknown_workout _) -> () 504 | _ -> Alcotest.fail "expected Unknown_workout" ) 505 506 let invalid_time_rejected = 507 ( "a workout whose end precedes its start is rejected", 508 `Quick, 509 fun () -> 510 match 511 WI.make_workout ~id:(wid "w1") ~title:"Chest" ~started_at:(at 4_600) 512 ~ended_at:(at 1_000) ~sets:[] 513 with 514 | Error WI.Invalid_time -> () 515 | _ -> Alcotest.fail "expected Invalid_time" ) 516 517 let suite = 518 [ 519 ( "Workout_import", 520 [ 521 failure_promotes_without_acknowledgement; 522 normal_blocks_until_confirmed; 523 empty_acknowledgement_rejected; 524 dropset_discarded_failure_promotes; 525 dropset_only_workout_has_no_sets; 526 no_sets_blocker; 527 mapping_covers_all_matching_sets; 528 every_distinct_name_must_be_mapped; 529 deterministic_blocker_order; 530 batch_promotes_partially; 531 blocked_workout_promotes_after_cleaning; 532 repeated_promotion_is_already_promoted; 533 editing_increments_revision_and_replaces; 534 idempotent_mapping_keeps_revision; 535 observations_carry_expected_effort; 536 source_and_warnings_retained; 537 unknown_workout_is_an_error; 538 invalid_time_rejected; 539 ] ); 540 ] 541