View raw

1 (** Tests for the workout-import operations on {!Sqlite_repo}: a batch's full 2 state — mappings, acknowledgement, revision, and prior promotion — survives 3 a reconnect, a re-promotion after reconnect is {!Already_promoted}, edits 4 persist, promoted records replace by workout id, deleting a batch leaves its 5 records, and trainees stay isolated. *) 6 7 open Hito_app 8 open Hito_persistence 9 module WI = Workout_import 10 11 let run = Lwt_main.run 12 let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok" 13 14 let get id = 15 match Exercise.find_id id with 16 | Some e -> e 17 | None -> Alcotest.failf "catalog is missing %S" id 18 19 let at s = Recovery.timestamp_of_unix_seconds s 20 let wid = WI.workout_id 21 let trainee n = Trainee.id (Printf.sprintf "t%d" n) 22 23 let temp_uri () = 24 let path = Filename.temp_file "hito-import-" ".sqlite" in 25 Sys.remove path; 26 (path, "sqlite3:" ^ path) 27 28 let connect uri = ok (run (Sqlite_repo.connect uri)) 29 30 let cleanup path = 31 List.iter 32 (fun suffix -> try Sys.remove (path ^ suffix) with Sys_error _ -> ()) 33 [ ""; "-wal"; "-shm" ] 34 35 let set ~name ~kind ~kg ~reps : WI.source_set = 36 { 37 row = 1; 38 exercise_name = name; 39 set_index = 1; 40 set_type = kind; 41 weight_kg = kg; 42 reps; 43 superset_id = None; 44 } 45 46 (* A batch with one failure set and one normal set, so promotion needs both a 47 mapping and an acknowledgement. *) 48 let sample_batch () = 49 let sets = 50 [ 51 set ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8; 52 set ~name:"Barbell Squat" ~kind:WI.Normal ~kg:60. ~reps:10; 53 ] 54 in 55 let w = 56 ok 57 (WI.make_workout ~id:(wid "w1") ~title:"Legs" ~started_at:(at 10) 58 ~ended_at:(at 3_610) ~sets) 59 in 60 WI.make_batch ~id:(WI.batch_id "b1") ~source:"raw,source\ntext" 61 ~fingerprint:"fp" ~imported_at:(at 5_000) ~workouts:[ w ] ~warnings:[] 62 63 (* Fully clean and promote the sample batch. *) 64 let cleaned_and_promoted () = 65 let b = sample_batch () 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 b = 72 ok 73 (WI.confirm_normal_sets b ~workout:(wid "w1") 74 ~acknowledgement:"all taken to failure") 75 in 76 let b, _ = ok (WI.promote_workout b (wid "w1")) in 77 b 78 79 let with_file f = 80 let path, uri = temp_uri () in 81 Fun.protect ~finally:(fun () -> cleanup path) (fun () -> f uri) 82 83 let tests = 84 [ 85 ( "a full batch state survives a reconnect", 86 `Quick, 87 fun () -> 88 with_file (fun uri -> 89 (let repo = connect uri in 90 run 91 (Sqlite_repo.save_import_batch repo (trainee 1) 92 (cleaned_and_promoted ()))); 93 (* Reconnect to the same file with a fresh repo value. *) 94 let repo = connect uri in 95 let b = 96 Option.get 97 (run 98 (Sqlite_repo.find_import_batch repo (trainee 1) 99 (WI.batch_id "b1"))) 100 in 101 let w = Option.get (WI.find_workout b (wid "w1")) in 102 Alcotest.(check string) 103 "source survives" "raw,source\ntext" (WI.batch_source b); 104 Alcotest.(check bool) 105 "mapping survives" true 106 (Option.is_some 107 (WI.workout_mapping w ~source_name:"Barbell Squat")); 108 Alcotest.(check (option string)) 109 "acknowledgement survives" (Some "all taken to failure") 110 (WI.workout_acknowledgement w); 111 Alcotest.(check (list string)) 112 "no blockers" [] 113 (List.map (fun _ -> "blocker") (WI.blockers w))) ); 114 ( "a repeated promotion after reconnect is Already_promoted", 115 `Quick, 116 fun () -> 117 with_file (fun uri -> 118 (let repo = connect uri in 119 run 120 (Sqlite_repo.save_import_batch repo (trainee 1) 121 (cleaned_and_promoted ()))); 122 let repo = connect uri in 123 let b = 124 Option.get 125 (run 126 (Sqlite_repo.find_import_batch repo (trainee 1) 127 (WI.batch_id "b1"))) 128 in 129 let _, result = ok (WI.promote_workout b (wid "w1")) in 130 match result with 131 | WI.Already_promoted p -> 132 Alcotest.(check int) 133 "two observations" 2 134 (List.length p.observations) 135 | WI.Promoted _ -> Alcotest.fail "expected Already_promoted" 136 | WI.Blocked _ -> 137 Alcotest.fail "expected Already_promoted, not Blocked") ); 138 ( "an edited batch (re-mapped, re-promoted) persists across reconnect", 139 `Quick, 140 fun () -> 141 with_file (fun uri -> 142 (let repo = connect uri in 143 let b = cleaned_and_promoted () in 144 (* Edit: re-map to a different exercise, which bumps the revision 145 and makes the stored promotion stale. Re-promote at the new 146 revision, then save. *) 147 let b = 148 ok 149 (WI.map_exercise b ~workout:(wid "w1") 150 ~source_name:"Barbell Squat" (get "leg-presses")) 151 in 152 let b, result = ok (WI.promote_workout b (wid "w1")) in 153 (match result with 154 | WI.Promoted _ -> () 155 | _ -> Alcotest.fail "expected a fresh promotion after the edit"); 156 run (Sqlite_repo.save_import_batch repo (trainee 1) b)); 157 let repo = connect uri in 158 let b = 159 Option.get 160 (run 161 (Sqlite_repo.find_import_batch repo (trainee 1) 162 (WI.batch_id "b1"))) 163 in 164 let w = Option.get (WI.find_workout b (wid "w1")) in 165 (match WI.workout_mapping w ~source_name:"Barbell Squat" with 166 | Some e -> 167 Alcotest.(check bool) 168 "re-mapped exercise persists" true 169 (Exercise.equal e (get "leg-presses")) 170 | None -> Alcotest.fail "expected the edited mapping to survive"); 171 let _, result = ok (WI.promote_workout b (wid "w1")) in 172 match result with 173 | WI.Already_promoted _ -> () 174 | _ -> 175 Alcotest.fail 176 "expected the edited promotion to be Already_promoted") ); 177 ( "a promoted record persists and is replaced by workout id", 178 `Quick, 179 fun () -> 180 with_file (fun uri -> 181 let mk revision load : Repository.imported_record = 182 let exercise = get "squats" in 183 let effort = 184 Result.get_ok 185 (Evidence.Stimulus.Effort.make ~exercise ~load ~reps:8 186 ~outcome:Evidence.Stimulus.Positive_failure) 187 in 188 let obs = 189 Evidence.Observation.make ~exercise ~effort 190 ~performed_at:(at 3_610) 191 in 192 { 193 batch_id = WI.batch_id "b1"; 194 workout_id = wid "w1"; 195 revision; 196 title = "Legs"; 197 started_at = at 10; 198 ended_at = at 3_610; 199 observations = [ obs ]; 200 provenance = 201 { 202 WI.batch_id = WI.batch_id "b1"; 203 workout_id = wid "w1"; 204 source_rows = [ 1 ]; 205 normal_confirmation = None; 206 revision; 207 }; 208 } 209 in 210 (let repo = connect uri in 211 run 212 (Sqlite_repo.replace_imported_record repo (trainee 1) (mk 0 100.)); 213 run 214 (Sqlite_repo.replace_imported_record repo (trainee 1) (mk 1 110.))); 215 let repo = connect uri in 216 let records = run (Sqlite_repo.imported_records repo (trainee 1)) in 217 Alcotest.(check int) 218 "one record after replace" 1 (List.length records); 219 Alcotest.(check int) 220 "revision advanced" 1 (List.hd records).Repository.revision; 221 let load = 222 Evidence.Stimulus.Effort.load 223 (Evidence.Observation.effort 224 (List.hd (List.hd records).Repository.observations)) 225 in 226 Alcotest.(check (float 0.001)) "load replaced" 110. load) ); 227 ( "deleting a batch leaves its promoted record", 228 `Quick, 229 fun () -> 230 with_file (fun uri -> 231 let record : Repository.imported_record = 232 let exercise = get "squats" in 233 let effort = 234 Result.get_ok 235 (Evidence.Stimulus.Effort.make ~exercise ~load:100. ~reps:8 236 ~outcome:Evidence.Stimulus.Positive_failure) 237 in 238 let obs = 239 Evidence.Observation.make ~exercise ~effort 240 ~performed_at:(at 3_610) 241 in 242 { 243 batch_id = WI.batch_id "b1"; 244 workout_id = wid "w1"; 245 revision = 0; 246 title = "Legs"; 247 started_at = at 10; 248 ended_at = at 3_610; 249 observations = [ obs ]; 250 provenance = 251 { 252 WI.batch_id = WI.batch_id "b1"; 253 workout_id = wid "w1"; 254 source_rows = [ 1 ]; 255 normal_confirmation = None; 256 revision = 0; 257 }; 258 } 259 in 260 let repo = connect uri in 261 run 262 (Sqlite_repo.save_import_batch repo (trainee 1) (sample_batch ())); 263 run (Sqlite_repo.replace_imported_record repo (trainee 1) record); 264 let deleted = 265 run 266 (Sqlite_repo.delete_import_batch repo (trainee 1) 267 (WI.batch_id "b1")) 268 in 269 Alcotest.(check bool) "batch deleted" true deleted; 270 Alcotest.(check int) 271 "no batch remains" 0 272 (List.length (run (Sqlite_repo.import_batches repo (trainee 1)))); 273 Alcotest.(check int) 274 "record survives" 1 275 (List.length 276 (run (Sqlite_repo.imported_records repo (trainee 1))))) ); 277 ( "batches and records stay isolated per trainee", 278 `Quick, 279 fun () -> 280 with_file (fun uri -> 281 let repo = connect uri in 282 run 283 (Sqlite_repo.save_import_batch repo (trainee 1) (sample_batch ())); 284 Alcotest.(check int) 285 "other trainee has no batches" 0 286 (List.length (run (Sqlite_repo.import_batches repo (trainee 2)))); 287 Alcotest.(check int) 288 "owner has one batch" 1 289 (List.length (run (Sqlite_repo.import_batches repo (trainee 1))))) 290 ); 291 ] 292 293 let suite = [ ("import_sqlite_repo", tests) ] 294