(** Tests for the workout-import operations on {!Sqlite_repo}: a batch's full state — mappings, acknowledgement, revision, and prior promotion — survives a reconnect, a re-promotion after reconnect is {!Already_promoted}, edits persist, promoted records replace by workout id, deleting a batch leaves its records, and trainees stay isolated. *) open Hito_app open Hito_persistence 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 wid = WI.workout_id let trainee n = Trainee.id (Printf.sprintf "t%d" n) let temp_uri () = let path = Filename.temp_file "hito-import-" ".sqlite" in Sys.remove path; (path, "sqlite3:" ^ path) let connect uri = ok (run (Sqlite_repo.connect uri)) let cleanup path = List.iter (fun suffix -> try Sys.remove (path ^ suffix) with Sys_error _ -> ()) [ ""; "-wal"; "-shm" ] let set ~name ~kind ~kg ~reps : WI.source_set = { row = 1; exercise_name = name; set_index = 1; set_type = kind; weight_kg = kg; reps; superset_id = None; } (* A batch with one failure set and one normal set, so promotion needs both a mapping and an acknowledgement. *) let sample_batch () = let sets = [ set ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8; set ~name:"Barbell Squat" ~kind:WI.Normal ~kg:60. ~reps:10; ] in let w = ok (WI.make_workout ~id:(wid "w1") ~title:"Legs" ~started_at:(at 10) ~ended_at:(at 3_610) ~sets) in WI.make_batch ~id:(WI.batch_id "b1") ~source:"raw,source\ntext" ~fingerprint:"fp" ~imported_at:(at 5_000) ~workouts:[ w ] ~warnings:[] (* Fully clean and promote the sample batch. *) let cleaned_and_promoted () = let b = sample_batch () in let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "squats")) in let b = ok (WI.confirm_normal_sets b ~workout:(wid "w1") ~acknowledgement:"all taken to failure") in let b, _ = ok (WI.promote_workout b (wid "w1")) in b let with_file f = let path, uri = temp_uri () in Fun.protect ~finally:(fun () -> cleanup path) (fun () -> f uri) let tests = [ ( "a full batch state survives a reconnect", `Quick, fun () -> with_file (fun uri -> (let repo = connect uri in run (Sqlite_repo.save_import_batch repo (trainee 1) (cleaned_and_promoted ()))); (* Reconnect to the same file with a fresh repo value. *) let repo = connect uri in let b = Option.get (run (Sqlite_repo.find_import_batch repo (trainee 1) (WI.batch_id "b1"))) in let w = Option.get (WI.find_workout b (wid "w1")) in Alcotest.(check string) "source survives" "raw,source\ntext" (WI.batch_source b); Alcotest.(check bool) "mapping survives" true (Option.is_some (WI.workout_mapping w ~source_name:"Barbell Squat")); Alcotest.(check (option string)) "acknowledgement survives" (Some "all taken to failure") (WI.workout_acknowledgement w); Alcotest.(check (list string)) "no blockers" [] (List.map (fun _ -> "blocker") (WI.blockers w))) ); ( "a repeated promotion after reconnect is Already_promoted", `Quick, fun () -> with_file (fun uri -> (let repo = connect uri in run (Sqlite_repo.save_import_batch repo (trainee 1) (cleaned_and_promoted ()))); let repo = connect uri in let b = Option.get (run (Sqlite_repo.find_import_batch repo (trainee 1) (WI.batch_id "b1"))) in let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Already_promoted p -> Alcotest.(check int) "two observations" 2 (List.length p.observations) | WI.Promoted _ -> Alcotest.fail "expected Already_promoted" | WI.Blocked _ -> Alcotest.fail "expected Already_promoted, not Blocked") ); ( "an edited batch (re-mapped, re-promoted) persists across reconnect", `Quick, fun () -> with_file (fun uri -> (let repo = connect uri in let b = cleaned_and_promoted () in (* Edit: re-map to a different exercise, which bumps the revision and makes the stored promotion stale. Re-promote at the new revision, then save. *) let b = ok (WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat" (get "leg-presses")) in let b, result = ok (WI.promote_workout b (wid "w1")) in (match result with | WI.Promoted _ -> () | _ -> Alcotest.fail "expected a fresh promotion after the edit"); run (Sqlite_repo.save_import_batch repo (trainee 1) b)); let repo = connect uri in let b = Option.get (run (Sqlite_repo.find_import_batch repo (trainee 1) (WI.batch_id "b1"))) in let w = Option.get (WI.find_workout b (wid "w1")) in (match WI.workout_mapping w ~source_name:"Barbell Squat" with | Some e -> Alcotest.(check bool) "re-mapped exercise persists" true (Exercise.equal e (get "leg-presses")) | None -> Alcotest.fail "expected the edited mapping to survive"); let _, result = ok (WI.promote_workout b (wid "w1")) in match result with | WI.Already_promoted _ -> () | _ -> Alcotest.fail "expected the edited promotion to be Already_promoted") ); ( "a promoted record persists and is replaced by workout id", `Quick, fun () -> with_file (fun uri -> let mk revision load : Repository.imported_record = let exercise = get "squats" in let effort = Result.get_ok (Evidence.Stimulus.Effort.make ~exercise ~load ~reps:8 ~outcome:Evidence.Stimulus.Positive_failure) in let obs = Evidence.Observation.make ~exercise ~effort ~performed_at:(at 3_610) in { batch_id = WI.batch_id "b1"; workout_id = wid "w1"; revision; title = "Legs"; started_at = at 10; ended_at = at 3_610; observations = [ obs ]; provenance = { WI.batch_id = WI.batch_id "b1"; workout_id = wid "w1"; source_rows = [ 1 ]; normal_confirmation = None; revision; }; } in (let repo = connect uri in run (Sqlite_repo.replace_imported_record repo (trainee 1) (mk 0 100.)); run (Sqlite_repo.replace_imported_record repo (trainee 1) (mk 1 110.))); let repo = connect uri in let records = run (Sqlite_repo.imported_records repo (trainee 1)) in Alcotest.(check int) "one record after replace" 1 (List.length records); Alcotest.(check int) "revision advanced" 1 (List.hd records).Repository.revision; let load = Evidence.Stimulus.Effort.load (Evidence.Observation.effort (List.hd (List.hd records).Repository.observations)) in Alcotest.(check (float 0.001)) "load replaced" 110. load) ); ( "deleting a batch leaves its promoted record", `Quick, fun () -> with_file (fun uri -> let record : Repository.imported_record = let exercise = get "squats" in let effort = Result.get_ok (Evidence.Stimulus.Effort.make ~exercise ~load:100. ~reps:8 ~outcome:Evidence.Stimulus.Positive_failure) in let obs = Evidence.Observation.make ~exercise ~effort ~performed_at:(at 3_610) in { batch_id = WI.batch_id "b1"; workout_id = wid "w1"; revision = 0; title = "Legs"; started_at = at 10; ended_at = at 3_610; observations = [ obs ]; provenance = { WI.batch_id = WI.batch_id "b1"; workout_id = wid "w1"; source_rows = [ 1 ]; normal_confirmation = None; revision = 0; }; } in let repo = connect uri in run (Sqlite_repo.save_import_batch repo (trainee 1) (sample_batch ())); run (Sqlite_repo.replace_imported_record repo (trainee 1) record); let deleted = run (Sqlite_repo.delete_import_batch repo (trainee 1) (WI.batch_id "b1")) in Alcotest.(check bool) "batch deleted" true deleted; Alcotest.(check int) "no batch remains" 0 (List.length (run (Sqlite_repo.import_batches repo (trainee 1)))); Alcotest.(check int) "record survives" 1 (List.length (run (Sqlite_repo.imported_records repo (trainee 1))))) ); ( "batches and records stay isolated per trainee", `Quick, fun () -> with_file (fun uri -> let repo = connect uri in run (Sqlite_repo.save_import_batch repo (trainee 1) (sample_batch ())); Alcotest.(check int) "other trainee has no batches" 0 (List.length (run (Sqlite_repo.import_batches repo (trainee 2)))); Alcotest.(check int) "owner has one batch" 1 (List.length (run (Sqlite_repo.import_batches repo (trainee 1))))) ); ] let suite = [ ("import_sqlite_repo", tests) ]