(** Unit tests for {!Workout_import}, authored against the workout_import.mli contract. The import use case must feed the core only confirmed, catalogued, failure-defined evidence. These tests exercise that gate: failure sets promote after mapping, normal sets wait for one acknowledgement, dropsets block the whole workout, blockers are deterministic, promotion is atomic per workout and partial per batch, and a promoted workout has its own revisioned lifecycle. *) open Hito_app module WI = Workout_import module Observation = Evidence.Observation module Effort = Evidence.Stimulus.Effort let get id = match Exercise.find_id id with | Some e -> e | None -> Alcotest.failf "catalog is missing %S" id let at s = Recovery.timestamp_of_unix_seconds s let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok" let set ?(row = 0) ?(set_index = 1) ?(superset_id = None) ~name ~kind ~kg ~reps () : WI.source_set = { row; exercise_name = name; set_index; set_type = kind; weight_kg = kg; reps; superset_id; } let workout ~id ~sets = ok (WI.make_workout ~id:(WI.workout_id id) ~title:"Chest" ~started_at:(at 1_000) ~ended_at:(at 4_600) ~sets) let batch ?(warnings = []) workouts = WI.make_batch ~id:(WI.batch_id "b1") ~source:"raw,csv,text" ~fingerprint:"fp" ~imported_at:(at 5_000) ~workouts ~warnings (* A blocker rendered as a stable string, so tests can assert order and content without depending on structural equality. *) let show_blocker : WI.blocker -> string = function | No_sets -> "no_sets" | Unmapped_exercise name -> "unmapped:" ^ name | Normal_sets_unconfirmed -> "normal_unconfirmed" let blocker_list = Alcotest.(list string) let blockers_of w = List.map show_blocker (WI.blockers w) let wid = WI.workout_id (* {1 Failure sets promote after mapping, without acknowledgement} *) let failure_promotes_without_acknowledgement = ( "a failure set promotes after mapping, no acknowledgement needed", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "no blockers" [] (blockers_of w); let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Promoted p -> Alcotest.(check int) "one observation" 1 (List.length p.observations); Alcotest.(check (option string)) "no normal confirmation" None p.provenance.normal_confirmation | _ -> Alcotest.fail "expected Promoted" ) (* {1 Normal sets block until a workout-level acknowledgement} *) let normal_blocks_until_confirmed = ( "a normal set blocks until the workout-level acknowledgement", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "blocked on confirmation" [ "normal_unconfirmed" ] (blockers_of w); (match WI.promote_workout b (wid "w1") with | Ok (_, WI.Blocked _) -> () | _ -> Alcotest.fail "expected Blocked before confirmation"); let b = ok (WI.confirm_normal_sets b ~workout:(wid "w1") ~acknowledgement:"I confirm these were to failure") in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "no blockers after confirmation" [] (blockers_of w); Alcotest.(check (option string)) "exact acknowledgement retained" (Some "I confirm these were to failure") (WI.workout_acknowledgement w); let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Promoted p -> Alcotest.(check (option string)) "confirmation recorded in provenance" (Some "I confirm these were to failure") p.provenance.normal_confirmation | _ -> Alcotest.fail "expected Promoted after confirmation" ) let empty_acknowledgement_rejected = ( "a whitespace-only acknowledgement is rejected", `Quick, fun () -> let s = set ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in match WI.confirm_normal_sets b ~workout:(wid "w1") ~acknowledgement:" " with | Error WI.Empty_acknowledgement -> () | _ -> Alcotest.fail "expected Empty_acknowledgement" ) (* {1 A dropset is discarded, never a blocker} *) let dropset_discarded_failure_promotes = ( "a dropset is discarded; the failure set beside it promotes", `Quick, fun () -> let good = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in (* A drop set for a second, deliberately unmapped exercise. It must not block: it is discarded like a warmup, so its exercise never needs a mapping. *) let drop = set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 () in let b = batch [ workout ~id:"w1" ~sets:[ good; drop ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "no blocker from a discarded dropset" [] (blockers_of w); match WI.promote_workout b (wid "w1") with | Ok (_, WI.Promoted p) -> Alcotest.(check int) "only the failure set is observed" 1 (List.length p.observations); Alcotest.(check (list int)) "provenance keeps only the failure row" [ 1 ] p.provenance.source_rows | _ -> Alcotest.fail "expected Promoted with the dropset discarded" ) let dropset_only_workout_has_no_sets = ( "a workout of only drop sets reduces to No_sets", `Quick, fun () -> let drop = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Dropset ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ drop ] ] in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "no_sets" [ "no_sets" ] (blockers_of w) ) (* {1 No sets is its own blocker} *) let no_sets_blocker = ( "a workout with no sets reports No_sets", `Quick, fun () -> let w = workout ~id:"w1" ~sets:[] in Alcotest.(check blocker_list) "no_sets" [ "no_sets" ] (blockers_of w) ) (* {1 Mapping is by name and covers all matching sets} *) let mapping_covers_all_matching_sets = ( "one mapping covers every set that shares the source name", `Quick, fun () -> let s1 = set ~row:1 ~set_index:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let s2 = set ~row:2 ~set_index:2 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:6 () in let b = batch [ workout ~id:"w1" ~sets:[ s1; s2 ] ] in (* Before mapping, the single distinct name is the only blocker. *) let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "one unmapped name" [ "unmapped:Barbell Squat" ] (blockers_of w); let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "both sets covered by one mapping" [] (blockers_of w); let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Promoted p -> Alcotest.(check int) "two observations from two sets" 2 (List.length p.observations) | _ -> Alcotest.fail "expected Promoted" ) let every_distinct_name_must_be_mapped = ( "every distinct source name must be mapped before promotion", `Quick, fun () -> let s1 = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let s2 = set ~row:2 ~name:"Leg Press" ~kind:WI.Failure ~kg:200. ~reps:10 () in let b = batch [ workout ~id:"w1" ~sets:[ s1; s2 ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check blocker_list) "still blocked on the unmapped name" [ "unmapped:Leg Press" ] (blockers_of w) ) (* {1 Deterministic blocker order} *) let deterministic_blocker_order = ( "blockers appear in unmapped (first appearance) then normal order", `Quick, fun () -> (* A discarded dropset contributes no blocker, not even an unmapped one for its exercise. *) let drop = set ~row:3 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 () in let normal = set ~row:1 ~name:"Bench Press" ~kind:WI.Normal ~kg:80. ~reps:8 () in let other = set ~row:2 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:6 () in (* Order of sets fixes first-appearance order of names. *) let w = workout ~id:"w1" ~sets:[ normal; other; drop ] in Alcotest.(check blocker_list) "names by first appearance, then normal; the dropset is absent" [ "unmapped:Bench Press"; "unmapped:Barbell Squat"; "normal_unconfirmed"; ] (blockers_of w) ) (* {1 Promotion is atomic per workout and partial per batch} *) let batch_promotes_partially = ( "a blocked workout does not prevent another from promoting", `Quick, fun () -> let good = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let drop = set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 () in let b = batch [ workout ~id:"w1" ~sets:[ good ]; workout ~id:"w2" ~sets:[ drop ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let b = ok (WI.map_exercise b ~workout:(wid "w2") ~source_name:"Leg Press" (get "leg-presses")) in let _, result = WI.promote_batch b in Alcotest.(check int) "one promoted" 1 (List.length result.promoted); Alcotest.(check int) "one blocked" 1 (List.length result.blocked); Alcotest.(check string) "w1 promoted" "w1" (WI.workout_id_to_string (fst (List.hd result.promoted))); Alcotest.(check string) "w2 blocked" "w2" (WI.workout_id_to_string (fst (List.hd result.blocked))) ) let blocked_workout_promotes_after_cleaning = ( "a blocked workout stays provisional and promotes after later cleaning", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in (* Still blocked: the normal set is unconfirmed. *) let b, result = WI.promote_batch b in Alcotest.(check int) "blocked before confirmation" 1 (List.length result.blocked); let b = ok (WI.confirm_normal_sets b ~workout:(wid "w1") ~acknowledgement:"confirmed") in let _, result = WI.promote_batch b in Alcotest.(check int) "promoted after confirmation" 1 (List.length result.promoted) ) (* {1 A promoted workout has its own revisioned lifecycle} *) let repeated_promotion_is_already_promoted = ( "promoting an unchanged workout twice yields Already_promoted", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let b, first = ok (WI.promote_workout b (wid "w1")) in (match first with | WI.Promoted _ -> () | _ -> Alcotest.fail "expected Promoted on first promotion"); let _, second = ok (WI.promote_workout b (wid "w1")) in match second with | WI.Already_promoted _ -> () | _ -> Alcotest.fail "expected Already_promoted on repeat" ) let editing_increments_revision_and_replaces = ( "editing a mapping increments the revision and re-promotes a replacement", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let rev0 = WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) in let b, _ = ok (WI.promote_workout b (wid "w1")) in (* Re-map the same name to a different exercise: a real change. *) let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "leg-presses")) in let rev1 = WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) in Alcotest.(check bool) "revision advanced" true (rev1 > rev0); let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Promoted p -> Alcotest.(check int) "replacement carries the new revision" rev1 p.provenance.revision; Alcotest.(check string) "replacement observation uses the new exercise" "Leg Presses" (Exercise.name (Observation.exercise (List.hd p.observations))) | _ -> Alcotest.fail "expected a re-promoted replacement" ) let idempotent_mapping_keeps_revision = ( "re-confirming the same mapping does not increment the revision", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let rev0 = WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let rev1 = WI.workout_revision (Option.get (WI.find_workout b (wid "w1"))) in Alcotest.(check int) "revision unchanged" rev0 rev1 ) (* {1 Promoted observations carry the expected effort} *) let observations_carry_expected_effort = ( "each observation carries the mapped exercise, load, reps, failure, and end", `Quick, fun () -> let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:102.5 ~reps:9 () in let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Promoted p -> let obs = List.hd p.observations in let effort = Observation.effort obs in Alcotest.(check string) "exercise" "Squats" (Exercise.name (Observation.exercise obs)); Alcotest.(check (float 0.001)) "load" 102.5 (Effort.load effort); Alcotest.(check int) "reps" 9 (Effort.reps effort); Alcotest.(check bool) "outcome is positive failure" true (match Effort.outcome effort with | Evidence.Stimulus.Positive_failure -> true | _ -> false); Alcotest.(check int) "performed at the workout end" (Recovery.timestamp_to_unix_seconds (at 4_600)) (Recovery.timestamp_to_unix_seconds (Observation.performed_at obs)) | _ -> Alcotest.fail "expected Promoted" ) (* {1 The raw source and warnings are immutable and retained} *) let source_and_warnings_retained = ( "the raw source and warnings survive cleaning unchanged", `Quick, fun () -> let warnings : WI.warning list = [ { row = 5; exercise_name = Some "Warmup"; kind = WI.Warmup_dropped }; { row = 6; exercise_name = None; kind = WI.Unsupported_row "blank line"; }; ] in let s = set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 () in let b = batch ~warnings [ workout ~id:"w1" ~sets:[ s ] ] in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let b, _ = WI.promote_batch b in Alcotest.(check string) "raw source unchanged" "raw,csv,text" (WI.batch_source b); Alcotest.(check int) "warnings retained" 2 (List.length (WI.batch_warnings b)); Alcotest.(check bool) "a warmup warning is present" true (List.exists (fun (w : WI.warning) -> w.kind = WI.Warmup_dropped) (WI.batch_warnings b)) ) let unknown_workout_is_an_error = ( "operations on an unknown workout return Unknown_workout", `Quick, fun () -> let b = batch [] in match WI.promote_workout b (wid "nope") with | Error (WI.Unknown_workout _) -> () | _ -> Alcotest.fail "expected Unknown_workout" ) let invalid_time_rejected = ( "a workout whose end precedes its start is rejected", `Quick, fun () -> match WI.make_workout ~id:(wid "w1") ~title:"Chest" ~started_at:(at 4_600) ~ended_at:(at 1_000) ~sets:[] with | Error WI.Invalid_time -> () | _ -> Alcotest.fail "expected Invalid_time" ) let suite = [ ( "Workout_import", [ failure_promotes_without_acknowledgement; normal_blocks_until_confirmed; empty_acknowledgement_rejected; dropset_discarded_failure_promotes; dropset_only_workout_has_no_sets; no_sets_blocker; mapping_covers_all_matching_sets; every_distinct_name_must_be_mapped; deterministic_blocker_order; batch_promotes_partially; blocked_workout_promotes_after_cleaning; repeated_promotion_is_already_promoted; editing_increments_revision_and_replaces; idempotent_mapping_keeps_revision; observations_carry_expected_effort; source_and_warnings_retained; unknown_workout_is_an_error; invalid_time_rejected; ] ); ]