(** Tests for {!Hito_app.Service}'s workout-import integration: storing and cleaning batches, promoting them into canonical {!Repository.imported_record} evidence, and how that evidence feeds progression and recovery without ever advancing the routine cycle. Authored against service.mli. *) open Hito_app module S = Service.Make (Test_repo) (Test_hash) module WI = Workout_import let run = Lwt_main.run 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 at s = Recovery.timestamp_of_unix_seconds s let day n = at (n * 86_400) let hours n = n * 3_600 let ideal = Repository.routine_id "ideal" let fixture () = let s = S.make ~repo:(Test_repo.create ()) in let trainee = ok (run (S.register s ~username:"lifter" ~password:"heavyduty1")) in (s, trainee.Trainee.id) (* An imported source set. Defaults to a failure set so promotion needs no acknowledgement unless a test asks for a normal set. *) let set ?(row = 0) ?(set_index = 1) ?(kind = WI.Failure) ~name ~kg ~reps () : WI.source_set = { row; exercise_name = name; set_index; set_type = kind; weight_kg = kg; reps; superset_id = None; } let workout ~id ~title ~started_at ~ended_at ~sets = ok (WI.make_workout ~id:(WI.workout_id id) ~title ~started_at ~ended_at ~sets) (* A batch whose identity, fingerprint, and imported time are all explicit, so duplicate detection can be exercised precisely. *) let batch ?(source = "raw,csv") ?(fingerprint = "fp") ?(imported_at = at 5_000) ~id workouts = WI.make_batch ~id:(WI.batch_id id) ~source ~fingerprint ~imported_at ~workouts ~warnings:[] (* Map [source_name] to [exercise] for a workout in a stored batch. *) let map s t ~batch:bid ~workout:wid ~name exercise = ok (run (S.map_import_exercise s t ~batch:(WI.batch_id bid) ~workout:(WI.workout_id wid) ~source_name:name exercise)) (* Store a batch with a single mapped squats failure set, ready to promote. *) let ready_batch s t ~id ~ended_at = let w = workout ~id:(id ^ "-w") ~title:"Legs" ~started_at:(at 0) ~ended_at ~sets:[ set ~row:1 ~name:"Barbell Squat" ~kg:100. ~reps:8 () ] in ok (run (S.store_import_batch s t (batch ~id [ w ]))); ignore (map s t ~batch:id ~workout:(id ^ "-w") ~name:"Barbell Squat" (get "squats")); id let store_tests = [ ( "a duplicate fingerprint is refused, override accepts it", `Quick, fun () -> let s, t = fixture () in let w id = workout ~id ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600) ~sets:[ set ~name:"Barbell Squat" ~kg:100. ~reps:8 () ] in ok (run (S.store_import_batch s t (batch ~id:"b1" ~fingerprint:"same" [ w "w1" ]))); (match run (S.store_import_batch s t (batch ~id:"b2" ~fingerprint:"same" [ w "w2" ])) with | Error S.Duplicate_source -> () | _ -> Alcotest.fail "expected Duplicate_source"); ok (run (S.store_import_batch s t ~allow_duplicate:() (batch ~id:"b2" ~fingerprint:"same" [ w "w2" ]))); Alcotest.(check int) "both batches stored" 2 (List.length (run (S.import_batches s t))) ); ( "re-storing the same batch id is a replacement, not a duplicate", `Quick, fun () -> let s, t = fixture () in let w = workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600) ~sets:[ set ~name:"Barbell Squat" ~kg:100. ~reps:8 () ] in ok (run (S.store_import_batch s t (batch ~id:"b1" ~fingerprint:"same" [ w ]))); ok (run (S.store_import_batch s t (batch ~id:"b1" ~fingerprint:"same" [ w ]))); Alcotest.(check int) "still one batch" 1 (List.length (run (S.import_batches s t))) ); ( "mapping and confirmation persist on the stored batch", `Quick, fun () -> let s, t = fixture () in let w = workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600) ~sets: [ set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 (); ] in ok (run (S.store_import_batch s t (batch ~id:"b1" [ w ]))); ignore (map s t ~batch:"b1" ~workout:"w1" ~name:"Barbell Squat" (get "squats")); ignore (ok (run (S.confirm_import_normal_sets s t ~batch:(WI.batch_id "b1") ~workout:(WI.workout_id "w1") ~acknowledgement:"to failure"))); let reloaded = Option.get (run (S.find_import_batch s t (WI.batch_id "b1"))) in let w' = Option.get (WI.find_workout reloaded (WI.workout_id "w1")) in Alcotest.(check bool) "mapping persisted" true (Option.is_some (WI.workout_mapping w' ~source_name:"Barbell Squat")); Alcotest.(check (option string)) "acknowledgement persisted" (Some "to failure") (WI.workout_acknowledgement w'); Alcotest.(check int) "no blockers remain" 0 (List.length (WI.blockers w')) ); ] let promotion_tests = [ ( "a blocked workout stays stored; an eligible sibling promotes via batch", `Quick, fun () -> let s, t = fixture () in let w1 = workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600) ~sets:[ set ~row:1 ~name:"Barbell Squat" ~kg:100. ~reps:8 () ] in let w2 = workout ~id:"w2" ~title:"Delts" ~started_at:(at 0) ~ended_at:(at 3_600) ~sets:[ set ~row:2 ~name:"Side Raise" ~kg:12. ~reps:9 () ] in ok (run (S.store_import_batch s t (batch ~id:"b1" [ w1; w2 ]))); ignore (map s t ~batch:"b1" ~workout:"w1" ~name:"Barbell Squat" (get "squats")); let result = ok (run (S.promote_import_batch s t ~batch:(WI.batch_id "b1"))) in Alcotest.(check int) "one workout promoted" 1 (List.length result.WI.promoted); Alcotest.(check int) "one workout blocked" 1 (List.length result.WI.blocked); Alcotest.(check int) "one imported record written" 1 (List.length (run (S.imported_records s t))); let reloaded = Option.get (run (S.find_import_batch s t (WI.batch_id "b1"))) in Alcotest.(check bool) "blocked workout retained" true (Option.is_some (WI.find_workout reloaded (WI.workout_id "w2"))) ); ( "promoting a workout writes one record with all its observations", `Quick, fun () -> let s, t = fixture () in let w = workout ~id:"w1" ~title:"Legs" ~started_at:(at 0) ~ended_at:(at 3_600) ~sets: [ set ~row:1 ~name:"Barbell Squat" ~kg:100. ~reps:8 (); set ~row:2 ~set_index:2 ~name:"Barbell Squat" ~kg:100. ~reps:6 (); ] in ok (run (S.store_import_batch s t (batch ~id:"b1" [ w ]))); ignore (map s t ~batch:"b1" ~workout:"w1" ~name:"Barbell Squat" (get "squats")); let _ = ok (run (S.promote_import_workout s t ~batch:(WI.batch_id "b1") ~workout:(WI.workout_id "w1"))) in (match run (S.imported_records s t) with | [ record ] -> Alcotest.(check int) "both observations in one record" 2 (List.length record.Repository.observations) | records -> Alcotest.failf "expected one record, got %d" (List.length records)); let again = ok (run (S.promote_import_workout s t ~batch:(WI.batch_id "b1") ~workout:(WI.workout_id "w1"))) in (match again with | WI.Already_promoted _ -> () | _ -> Alcotest.fail "expected Already_promoted on repeat"); Alcotest.(check int) "still one record, no duplication" 1 (List.length (run (S.imported_records s t))); match run (S.imported_records s t) with | [ record ] -> Alcotest.(check int) "still two observations" 2 (List.length record.Repository.observations) | _ -> Alcotest.fail "expected one record" ); ( "deleting a batch leaves its promoted record", `Quick, fun () -> let s, t = fixture () in let _ = ready_batch s t ~id:"b1" ~ended_at:(at 3_600) in let _ = ok (run (S.promote_import_workout s t ~batch:(WI.batch_id "b1") ~workout:(WI.workout_id "b1-w"))) in Alcotest.(check bool) "batch deleted" true (run (S.delete_import_batch s t (WI.batch_id "b1"))); Alcotest.(check bool) "batch gone" true (Option.is_none (run (S.find_import_batch s t (WI.batch_id "b1")))); Alcotest.(check int) "record survives" 1 (List.length (run (S.imported_records s t))) ); ] (* Build, map, and promote a one-set squats workout at [ended_at]. *) let promote_squats s t ~id ~ended_at ~kg ~reps = let w = workout ~id:(id ^ "-w") ~title:"Legs" ~started_at:ended_at ~ended_at ~sets:[ set ~row:1 ~name:"Barbell Squat" ~kg ~reps () ] in ok (run (S.store_import_batch s t (batch ~id ~fingerprint:id [ w ]))); ignore (map s t ~batch:id ~workout:(id ^ "-w") ~name:"Barbell Squat" (get "squats")); ignore (run (S.promote_import_workout s t ~batch:(WI.batch_id id) ~workout:(WI.workout_id (id ^ "-w")))) let progression_tests = [ ( "two imported observations turn insufficient data into a verdict", `Quick, fun () -> let s, t = fixture () in promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8; (match run (S.progress s t (get "squats")) with | Error Progress.Insufficient_data -> () | _ -> Alcotest.fail "one observation should be insufficient"); promote_squats s t ~id:"b2" ~ended_at:(day 3) ~kg:105. ~reps:8; Alcotest.(check bool) "progressing on two imports" true (run (S.progress s t (get "squats")) = Ok Progress.Progressing) ); ( "a flat fortnight of imports reads as a stall", `Quick, fun () -> let s, t = fixture () in promote_squats s t ~id:"b1" ~ended_at:(day 10) ~kg:110. ~reps:8; (match run (S.progress s t (get "squats")) with | Error Progress.Insufficient_data -> () | _ -> Alcotest.fail "one observation should be insufficient"); promote_squats s t ~id:"b2" ~ended_at:(day 25) ~kg:110. ~reps:8; Alcotest.(check bool) "stalled after a flat fortnight" true (run (S.progress s t (get "squats")) = Ok Progress.Stalled) ); ( "imported records for another exercise are excluded", `Quick, fun () -> let s, t = fixture () in promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8; promote_squats s t ~id:"b2" ~ended_at:(day 3) ~kg:105. ~reps:8; match run (S.progress s t (get "laterals")) with | Error Progress.Insufficient_data -> () | _ -> Alcotest.fail "laterals should have no evidence" ); ] let recovery_tests = [ ( "the latest promoted import gates recovery for 48h, then ready", `Quick, fun () -> let s, t = fixture () in promote_squats s t ~id:"b1" ~ended_at:(day 10) ~kg:100. ~reps:8; let early = ok (run (S.readiness s t ~routine:ideal ~now:(at ((10 * 86_400) + hours 24)))) in Alcotest.(check bool) "recovering at 24h" false (Recovery.is_ready early); let late = ok (run (S.readiness s t ~routine:ideal ~now:(at ((10 * 86_400) + hours 48)))) in Alcotest.(check bool) "ready at 48h" true (Recovery.is_ready late) ); ( "an imported promotion never advances the routine cycle", `Quick, fun () -> let s, t = fixture () in let before = ok (run (S.next_workout s t ~routine:ideal)) in promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8; let after = ok (run (S.next_workout s t ~routine:ideal)) in Alcotest.(check string) "next workout unchanged by an import" (Prescription.Workout.name before) (Prescription.Workout.name after) ); ( "native later than imported controls recovery", `Quick, fun () -> let s, t = fixture () in promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8; let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 10) ())) in let _ = run (S.finish s t ~ended_at:(day 10)) in let r = ok (run (S.readiness s t ~routine:ideal ~now:(at ((10 * 86_400) + hours 24)))) in Alcotest.(check bool) "native end governs, still recovering" false (Recovery.is_ready r) ); ( "imported later than native controls recovery", `Quick, fun () -> let s, t = fixture () in let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in let _ = run (S.finish s t ~ended_at:(day 1)) in promote_squats s t ~id:"b1" ~ended_at:(day 10) ~kg:100. ~reps:8; let recovering = ok (run (S.readiness s t ~routine:ideal ~now:(at ((10 * 86_400) + hours 24)))) in Alcotest.(check bool) "import end governs, still recovering" false (Recovery.is_ready recovering); let ready = ok (run (S.readiness s t ~routine:ideal ~now:(at ((10 * 86_400) + hours 48)))) in Alcotest.(check bool) "ready 48h after the governing import" true (Recovery.is_ready ready) ); ] let diagnostics_tests = [ ( "diagnostics exclude imports", `Quick, fun () -> let s, t = fixture () in promote_squats s t ~id:"b1" ~ended_at:(day 1) ~kg:100. ~reps:8; promote_squats s t ~id:"b2" ~ended_at:(day 3) ~kg:105. ~reps:8; Alcotest.(check int) "no diagnostics from imports alone" 0 (List.length (run (S.diagnostics s t))) ); ] let suite = [ ("import_service.store", store_tests); ("import_service.promotion", promotion_tests); ("import_service.progression", progression_tests); ("import_service.recovery", recovery_tests); ("import_service.diagnostics", diagnostics_tests); ]