(** Unit tests for {!Progression}. *) module Stimulus = Evidence.Stimulus module Workout = Evidence.Workout let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok" let get id = match Exercise.find_id id with | Some e -> e | None -> Alcotest.failf "catalog is missing %S" id let load n = n let reps n = n let at s = Recovery.timestamp_of_unix_seconds s let day n = at (n * 86_400) let secs = Recovery.duration_to_seconds let six_to_ten = Prescription.Rep_range.make ~min:6 ~max:10 let rep_range min max = Prescription.Rep_range.make ~min ~max let move ?(outcome = Stimulus.Positive_failure) ?(id = "laterals") load_kg rep_count = Stimulus.Effort.make ~exercise:(get id) ~load:(load load_kg) ~reps:(reps rep_count) ~outcome (* An observation is a public record, so evidence can be written directly. *) let seen ~on load r : Evidence.Log.observation = { exercise = get "laterals"; effort = move load r; performed_at = day on } let raises_insufficient_data f = try let _ = f () in false with Progression.Invalid Progression.Insufficient_data -> true let assess_tests = [ ( "the stall window is two weeks", `Quick, fun () -> Alcotest.(check int) "14 days" 1_209_600 (secs Progression.stall_window) ); ( "too little record to judge", `Quick, fun () -> Alcotest.(check bool) "nothing" true (raises_insufficient_data (fun () -> Progression.assess [])); Alcotest.(check bool) "a single session" true (raises_insufficient_data (fun () -> Progression.assess [ seen ~on:1 12. 8 ])); Alcotest.(check bool) "flat but recent" true (raises_insufficient_data (fun () -> Progression.assess [ seen ~on:1 12. 8; seen ~on:3 12. 8 ])) ); ( "a recent advance is progress", `Quick, fun () -> Alcotest.(check bool) "progressing" true (Progression.assess [ seen ~on:1 12. 8; seen ~on:3 12. 9; seen ~on:5 12. 9 ] = Progression.Progressing) ); ( "no advance for two weeks is a stall", `Quick, fun () -> Alcotest.(check bool) "stalled" true (Progression.assess [ seen ~on:1 12. 8; seen ~on:8 12. 8; seen ~on:16 12. 8 ] = Progression.Stalled) ); ( "an advance more than two weeks ago is also a stall", `Quick, fun () -> Alcotest.(check bool) "stalled" true (Progression.assess [ seen ~on:1 12. 8; seen ~on:3 12. 9; seen ~on:20 12. 9 ] = Progression.Stalled) ); ] let remedy_tests = [ ( "progress calls for no change", `Quick, fun () -> Alcotest.(check bool) "none" true (Option.is_none (Progression.remedy Progression.Progressing)) ); ( "a stall calls for a lay-off, then less work and more rest", `Quick, fun () -> match Progression.remedy Progression.Stalled with | Some (Progression.Lay_off_then_reduce { lay_off; drop_stimuli_per_workout; extra_rest }) -> Alcotest.(check int) "one week off" 604_800 (secs lay_off); Alcotest.(check int) "one fewer stimulus per workout" 1 drop_stimuli_per_workout; Alcotest.(check int) "one extra rest day" 86_400 (secs extra_rest) | None -> Alcotest.fail "a stall must have a remedy" ); ( "no remedy ever adds work", `Quick, fun () -> (* Structural: the only remedy subtracts. This asserts the intent. *) match Progression.remedy Progression.Stalled with | Some (Progression.Lay_off_then_reduce { drop_stimuli_per_workout; _ }) -> Alcotest.(check bool) "subtracts volume" true (drop_stimuli_per_workout > 0) | None -> Alcotest.fail "expected a remedy" ); ] let load_tests = [ ( "the load rises at twelve reps", `Quick, fun () -> Alcotest.(check int) "trigger" 12 Progression.load_increase_trigger ); ( "the increase is a 10-20% window", `Quick, fun () -> let low, high = Progression.load_increase ~current:(load 100.) in Alcotest.(check (float 0.001)) "110kg" 110. low; Alcotest.(check (float 0.001)) "120kg" 120. high ); ( "inside the window the load holds", `Quick, fun () -> Alcotest.(check bool) "8 reps of 6-10" true (Progression.judge_load ~rep_range:six_to_ten (move 12. 8) = Progression.Hold) ); ( "eleven reps is slack, not yet a load increase", `Quick, fun () -> (* HD1 raises the load at twelve, not merely above the window. *) Alcotest.(check bool) "11 reps of 6-10 holds" true (Progression.judge_load ~rep_range:six_to_ten (move 12. 11) = Progression.Hold) ); ( "twelve reps calls for more load", `Quick, fun () -> match Progression.judge_load ~rep_range:six_to_ten (move 100. 12) with | Progression.Increase (low, high) -> Alcotest.(check (float 0.001)) "110kg" 110. low; Alcotest.(check (float 0.001)) "120kg" 120. high | _ -> Alcotest.fail "expected Increase" ); ( "failing below the window means the load is too heavy", `Quick, fun () -> Alcotest.(check bool) "4 reps of 6-10" true (Progression.judge_load ~rep_range:six_to_ten (move 12. 4) = Progression.Too_heavy) ); (* The window's ceiling never gates the verdict: HD1's trigger is the absolute twelve, and overshooting a narrower band is the slack it allows. Only the floor decides Too_heavy. *) ( "a band's ceiling does not gate the verdict", `Quick, fun () -> Alcotest.(check bool) "10 reps of 6-8 still holds" true (Progression.judge_load ~rep_range:(rep_range 6 8) (move 12. 10) = Progression.Hold) ); ( "the twelve-rep trigger is absolute, whatever the ceiling", `Quick, fun () -> List.iter (fun (lo, hi) -> match Progression.judge_load ~rep_range:(rep_range lo hi) (move 100. 12) with | Progression.Increase _ -> () | _ -> Alcotest.failf "%d-%d at twelve reps must call for more load" lo hi) [ (6, 8); (6, 10); (6, 12); (8, 12); (12, 12) ] ); ( "the floor moves with the band", `Quick, fun () -> Alcotest.(check bool) "7 reps of 8-12 is too heavy" true (Progression.judge_load ~rep_range:(rep_range 8 12) (move 12. 7) = Progression.Too_heavy); Alcotest.(check bool) "7 reps of 6-10 holds" true (Progression.judge_load ~rep_range:six_to_ten (move 12. 7) = Progression.Hold) ); ] (* Performed workouts, for the diagnostics. *) let cleared = Option.get (Recovery.clear Recovery.Ready) let prescribed = List.hd (Prescription.Routine.workouts Prescription.Routine.ideal) let performed ~clearance ~stimuli = List.fold_left (fun w s -> Workout.add_stimulus w s) (Workout.start prescribed ~clearance ~started_at:(day 1)) stimuli let stimulus ?outcome id load r = Stimulus.make (Stimulus.Single (move ?outcome ~id load r)) let laterals ?outcome load r = stimulus ?outcome "laterals" load r let extended = Stimulus.Beyond_failure (Stimulus.Forced_reps, [ Stimulus.Negatives ]) let diagnostic_tests = [ ( "a clean record yields no diagnostics", `Quick, fun () -> let w = performed ~clearance:cleared ~stimuli:[ laterals 12. 8 ] in Alcotest.(check int) "none" 0 (List.length (Progression.diagnose (Evidence.Log.add Evidence.Log.empty w))) ); ( "extra recorded volume is flagged", `Quick, fun () -> let w = performed ~clearance:cleared ~stimuli:[ laterals 12. 8; laterals 12. 6 ] in match Progression.diagnose (Evidence.Log.add Evidence.Log.empty w) with | [ Progression.Excess_volume n ] -> Alcotest.(check int) "one workout" 1 n | _ -> Alcotest.fail "expected the excess-volume diagnostic" ); ( "extending every stimulus is flagged", `Quick, fun () -> let w = performed ~clearance:cleared ~stimuli:[ laterals ~outcome:extended 12. 8 ] in match Progression.diagnose (Evidence.Log.add Evidence.Log.empty w) with | [ Progression.Extensions_on_every_stimulus n ] -> Alcotest.(check int) "one workout" 1 n | _ -> Alcotest.fail "expected the extension diagnostic" ); ( "extending only some stimuli is not flagged", `Quick, fun () -> let w = performed ~clearance:cleared ~stimuli: [ laterals ~outcome:extended 12. 8; stimulus "bent-over-laterals" 10. 9; ] in Alcotest.(check int) "none" 0 (List.length (Progression.diagnose (Evidence.Log.add Evidence.Log.empty w))) ); ( "training on an override is flagged", `Quick, fun () -> let recovering = Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12) ~recommended:Prescription.Routine.training_interval in let w = performed ~clearance:(Recovery.override recovering) ~stimuli:[ laterals 12. 8 ] in match Progression.diagnose (Evidence.Log.add Evidence.Log.empty w) with | [ Progression.Trained_under_recovered n ] -> Alcotest.(check int) "one workout" 1 n | _ -> Alcotest.fail "expected the under-recovery diagnostic" ); ( "a stall alongside both habits names both as suspects", `Quick, fun () -> let recovering = Recovery.evaluate_readiness ~elapsed:(Recovery.hours 12) ~recommended:Prescription.Routine.training_interval in let w = performed ~clearance:(Recovery.override recovering) ~stimuli:[ laterals ~outcome:extended 12. 8 ] in Alcotest.(check int) "both" 2 (List.length (Progression.diagnose (Evidence.Log.add Evidence.Log.empty w))); Alcotest.(check bool) "and the routine is stalled" true (Progression.assess [ seen ~on:1 12. 8; seen ~on:8 12. 8; seen ~on:16 12. 8 ] = Progression.Stalled) ); ] let suite = [ ("progression.assess", assess_tests); ("progression.remedy", remedy_tests); ("progression.load", load_tests); ("progression.diagnostics", diagnostic_tests); ]