(** Unit tests for {!Hevy_csv}, authored against the hevy_csv.mli contract. The adapter turns untrusted native Hevy CSV text into a {!Workout_import.batch}. These tests exercise the strict native schema: the exact 14-column header in any order, RFC4180 quoting, workout grouping by the datetime triple, deterministic workout ids, the local-to-UTC offset, and the full family of dropped-row warnings. They also confirm the source text is retained byte for byte and that a dropset parses to a set for provenance, which the app layer then discards rather than promoting. *) open Hito_app module WI = Workout_import module Hevy_csv = Hito_import.Hevy_csv (* The verified native header, 14 columns in canonical order. *) let header = "title,start_time,end_time,description,exercise_title,superset_id,exercise_notes,set_index,set_type,weight_kg,reps,distance_km,duration_seconds,rpe" let bid = WI.batch_id "b1" let at s = Recovery.timestamp_of_unix_seconds s let parse ?(utc_offset_seconds = 0) source = match Hevy_csv.parse ~batch_id:bid ~fingerprint:"fp" ~imported_at:(at 5_000) ~utc_offset_seconds source with | Ok b -> b | Error e -> Alcotest.failf "expected Ok, got %a" Hevy_csv.pp_error e let parse_err ?(utc_offset_seconds = 0) source = match Hevy_csv.parse ~batch_id:bid ~fingerprint:"fp" ~imported_at:(at 5_000) ~utc_offset_seconds source with | Ok _ -> Alcotest.fail "expected Error" | Error e -> e (* Howard Hinnant's civil-to-days, mirrored here so the timezone test can state its own expected UTC second independently of the adapter. *) let days_from_civil y m d = let y = if m <= 2 then y - 1 else y in let era = (if y >= 0 then y else y - 399) / 400 in let yoe = y - (era * 400) in let doy = (((153 * if m > 2 then m - 3 else m + 9) + 2) / 5) + d - 1 in let doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy in (era * 146097) + doe - 719468 let utc_of ~y ~m ~d ~hh ~mm ~offset = (days_from_civil y m d * 86_400) + (hh * 3_600) + (mm * 60) - offset let show_blocker : WI.blocker -> string = function | No_sets -> "no_sets" | Unmapped_exercise name -> "unmapped:" ^ name | Normal_sets_unconfirmed -> "normal_unconfirmed" let warning_reason (w : WI.warning) = match w.kind with | WI.Warmup_dropped -> "warmup" | WI.Unsupported_row reason -> reason (* A workout row using quoted datetimes, so the embedded comma in the Hevy datetime does not split a field. Callers override any field. *) let mk ?(title = "Push A") ?(start_time = "01 Jan 2025, 08:00") ?(end_time = "01 Jan 2025, 09:00") ?(description = "") ?(exercise_title = "Bench Press") ?(superset_id = "") ?(exercise_notes = "") ?(set_index = "0") ?(set_type = "normal") ?(weight_kg = "60") ?(reps = "8") ?(distance_km = "") ?(duration_seconds = "") ?(rpe = "") () = let q s = "\"" ^ s ^ "\"" in String.concat "," [ title; q start_time; q end_time; description; exercise_title; superset_id; exercise_notes; set_index; set_type; weight_kg; reps; distance_km; duration_seconds; rpe; ] let doc rows = header ^ "\n" ^ String.concat "\n" rows ^ "\n" (* {1 A real sample header and row parse into one set} *) let real_sample_parses = ( "the verified header and a real data row yield one workout with one set", `Quick, fun () -> let b = doc [ mk () ] |> parse in let workouts = WI.batch_workouts b in Alcotest.(check int) "one workout" 1 (List.length workouts); let w = List.hd workouts in Alcotest.(check string) "title" "Push A" (WI.workout_title w); let sets = WI.workout_sets w in Alcotest.(check int) "one set" 1 (List.length sets); let s = List.hd sets in Alcotest.(check int) "set row is 2" 2 s.row; Alcotest.(check string) "exercise name" "Bench Press" s.exercise_name; Alcotest.(check (float 0.001)) "weight" 60. s.weight_kg; Alcotest.(check int) "reps" 8 s.reps; Alcotest.(check bool) "no warnings" true (WI.batch_warnings b = []) ) (* {1 RFC4180 quoting: commas and newlines inside fields} *) let quoted_comma_and_newline = ( "quoted commas and newlines are preserved within fields", `Quick, fun () -> let data = mk ~title:"\"Legs, heavy\"" ~exercise_notes:"\"line one\nline two\"" ~exercise_title:"Squat" () in let b = doc [ data ] |> parse in let w = List.hd (WI.batch_workouts b) in Alcotest.(check string) "comma kept in title" "Legs, heavy" (WI.workout_title w); Alcotest.(check int) "one set despite embedded newline" 1 (List.length (WI.workout_sets w)) ) (* {1 Header order may vary} *) let header_reorder = ( "columns are mapped by name, so a reordered header still parses", `Quick, fun () -> let reordered = "set_type,reps,weight_kg,set_index,exercise_title,title,start_time,end_time,description,superset_id,exercise_notes,distance_km,duration_seconds,rpe" in let q s = "\"" ^ s ^ "\"" in let data = String.concat "," [ "normal"; "8"; "60"; "0"; "Bench Press"; "Push A"; q "01 Jan 2025, 08:00"; q "01 Jan 2025, 09:00"; ""; ""; ""; ""; ""; ""; ] in let b = reordered ^ "\n" ^ data ^ "\n" |> parse in let w = List.hd (WI.batch_workouts b) in Alcotest.(check string) "title resolved by name" "Push A" (WI.workout_title w); let s = List.hd (WI.workout_sets w) in Alcotest.(check int) "reps resolved by name" 8 s.reps ) (* {1 Missing header column} *) let missing_column = ( "a header missing a required column fails with the first missing name", `Quick, fun () -> (* Drop [weight_kg] and [reps]; [weight_kg] is earlier in canonical order, so it is the reported one. *) let short_header = "title,start_time,end_time,description,exercise_title,superset_id,exercise_notes,set_index,set_type,distance_km,duration_seconds,rpe" in match parse_err (short_header ^ "\n") with | Hevy_csv.Missing_column "weight_kg" -> () | e -> Alcotest.failf "expected Missing_column weight_kg, got %a" Hevy_csv.pp_error e ) (* {1 Empty file} *) let empty_file = ( "empty input is Empty_file", `Quick, fun () -> match parse_err "" with | Hevy_csv.Empty_file -> () | e -> Alcotest.failf "expected Empty_file, got %a" Hevy_csv.pp_error e ) (* {1 Grouping and order} *) let grouping_and_order = ( "rows group into workouts by the triple, in first-appearance order", `Quick, fun () -> let body = [ mk ~title:"Push A" ~set_index:"0" (); mk ~title:"Legs A" ~start_time:"03 Jan 2025, 08:00" ~end_time:"03 Jan 2025, 09:00" ~exercise_title:"Squat" ~set_index:"0" (); mk ~title:"Push A" ~set_index:"1" (); ] in let b = doc body |> parse in let workouts = WI.batch_workouts b in Alcotest.(check int) "two workouts" 2 (List.length workouts); let w1 = List.nth workouts 0 in let w2 = List.nth workouts 1 in Alcotest.(check string) "first workout is Push A" "Push A" (WI.workout_title w1); Alcotest.(check string) "second workout is Legs A" "Legs A" (WI.workout_title w2); Alcotest.(check int) "Push A has two sets" 2 (List.length (WI.workout_sets w1)); Alcotest.(check (list int)) "rows in source order" [ 2; 4 ] (List.map (fun (s : WI.source_set) -> s.row) (WI.workout_sets w1)); Alcotest.(check string) "first id" "b1:w1" (WI.workout_id_to_string (WI.workout_identity w1)); Alcotest.(check string) "second id" "b1:w2" (WI.workout_id_to_string (WI.workout_identity w2)) ) (* {1 Timezone offset} *) let timezone_offset = ( "wall time is interpreted at the given offset: UTC = civil - offset", `Quick, fun () -> let offset = 2 * 3_600 in let data = mk ~start_time:"01 Jan 2025, 08:00" ~end_time:"01 Jan 2025, 09:30" () in let b = doc [ data ] |> parse ~utc_offset_seconds:offset in let w = List.hd (WI.batch_workouts b) in let expected_start = utc_of ~y:2025 ~m:1 ~d:1 ~hh:8 ~mm:0 ~offset in let expected_end = utc_of ~y:2025 ~m:1 ~d:1 ~hh:9 ~mm:30 ~offset in Alcotest.(check int) "start at offset" expected_start (Recovery.timestamp_to_unix_seconds (WI.workout_started_at w)); Alcotest.(check int) "end at offset" expected_end (Recovery.timestamp_to_unix_seconds (WI.workout_ended_at w)) ) (* {1 Warmup dropped} *) let warmup_dropped = ( "a warmup set creates no set and one Warmup_dropped warning", `Quick, fun () -> let b = doc [ mk ~set_type:"warmup" () ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let warnings = WI.batch_warnings b in Alcotest.(check int) "one warning" 1 (List.length warnings); let w = List.hd warnings in Alcotest.(check int) "warning row is 2" 2 w.row; Alcotest.(check bool) "warmup kind" true (w.kind = WI.Warmup_dropped); Alcotest.(check (option string)) "exercise name retained" (Some "Bench Press") w.exercise_name ) (* {1 Unsupported: missing load / reps / distance-only rows} *) let unsupported_missing_load = ( "a row with empty weight becomes an Unsupported_row warning", `Quick, fun () -> let b = doc [ mk ~weight_kg:"" () ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let w = List.hd (WI.batch_warnings b) in Alcotest.(check string) "reason names weight" "missing, negative, or non-finite weight_kg" (warning_reason w) ) let unsupported_negative_load = ( "a row with a negative weight becomes an Unsupported_row warning", `Quick, fun () -> (* The adapter applies the effort rule at the trust boundary: a load is finite and nonnegative. A negative load is not evidence. *) let b = doc [ mk ~weight_kg:"-20" () ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let w = List.hd (WI.batch_warnings b) in Alcotest.(check string) "reason names weight" "missing, negative, or non-finite weight_kg" (warning_reason w) ) let unsupported_missing_reps = ( "a row with empty reps becomes an Unsupported_row warning", `Quick, fun () -> let b = doc [ mk ~reps:"" () ] |> parse in let w = List.hd (WI.batch_warnings b) in Alcotest.(check string) "reason names reps" "missing reps or fewer than one" (warning_reason w) ) let unsupported_zero_reps = ( "a row with zero reps becomes an Unsupported_row warning", `Quick, fun () -> (* A set taken to failure has at least one rep. Zero reps is not a performance, so it is not evidence. *) let b = doc [ mk ~reps:"0" () ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let w = List.hd (WI.batch_warnings b) in Alcotest.(check string) "reason names reps" "missing reps or fewer than one" (warning_reason w) ) let unsupported_distance_only = ( "a distance/time row with empty weight and reps is an Unsupported_row", `Quick, fun () -> let b = doc [ mk ~weight_kg:"" ~reps:"" ~distance_km:"5.0" ~duration_seconds:"1800" ~exercise_title:"Running" (); ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); Alcotest.(check int) "one warning" 1 (List.length (WI.batch_warnings b)) ) (* {1 Unknown set type} *) let unknown_set_type = ( "an unknown set type is a warning, not fatal", `Quick, fun () -> let b = doc [ mk ~set_type:"myorep" () ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let w = List.hd (WI.batch_warnings b) in let reason = warning_reason w in Alcotest.(check bool) "reason mentions unknown set type" true (String.length reason >= 12 && String.sub reason 0 12 = "unknown set ") ) (* {1 Dropset parses to a set, but is discarded on promotion} *) let dropset_parsed_then_discarded = ( "a dropset parses to a set for provenance, but never promotes or blocks", `Quick, fun () -> let b = doc [ mk ~set_type:"dropset" () ] |> parse in let w = List.hd (WI.batch_workouts b) in (* The parser keeps the row verbatim: the set survives for provenance. *) Alcotest.(check int) "one set retained" 1 (List.length (WI.workout_sets w)); (* The app layer discards it, so a dropset-only workout has nothing to promote and reduces to No_sets — not a dropset blocker. *) Alcotest.(check (list string)) "discarded dropset leaves only No_sets" [ "no_sets" ] (List.map show_blocker (WI.blockers w)) ) (* {1 Superset provenance} *) let superset_provenance = ( "superset_id maps empty to None, an int to Some, and rejects a bad value", `Quick, fun () -> let b_none = doc [ mk ~superset_id:"" () ] |> parse in let s_none = List.hd (WI.workout_sets (List.hd (WI.batch_workouts b_none))) in Alcotest.(check (option int)) "empty superset is None" None s_none.superset_id; let b_some = doc [ mk ~superset_id:"3" () ] |> parse in let s_some = List.hd (WI.workout_sets (List.hd (WI.batch_workouts b_some))) in Alcotest.(check (option int)) "int superset is Some" (Some 3) s_some.superset_id; let b_bad = doc [ mk ~superset_id:"x" () ] |> parse in Alcotest.(check int) "bad superset drops the row" 0 (List.length (WI.batch_workouts b_bad)); Alcotest.(check int) "and warns" 1 (List.length (WI.batch_warnings b_bad)) ) (* {1 Raw source is retained exactly} *) let raw_source_exact = ( "the batch retains the source text byte for byte", `Quick, fun () -> let source = doc [ mk () ] in let b = parse source in Alcotest.(check string) "source unchanged" source (WI.batch_source b) ) (* {1 Malformed CSV} *) let malformed_csv = ( "a badly quoted field yields Malformed_csv", `Quick, fun () -> (* A quote opened then followed by stray content is malformed under strict RFC4180 parsing. *) let data = "Push A,\"unterminated\" trailing,x" in match parse_err (header ^ "\n" ^ data) with | Hevy_csv.Malformed_csv _ -> () | e -> Alcotest.failf "expected Malformed_csv, got %a" Hevy_csv.pp_error e ) (* {1 Invalid calendar date and end-before-start} *) let invalid_calendar_date = ( "an impossible calendar date drops the row", `Quick, fun () -> let data = mk ~start_time:"31 Feb 2025, 08:00" ~end_time:"31 Feb 2025, 09:00" () in let b = doc [ data ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let w = List.hd (WI.batch_warnings b) in Alcotest.(check string) "reason names start_time" "invalid start_time" (warning_reason w) ) let end_before_start = ( "a row whose end precedes its start drops the row", `Quick, fun () -> let data = mk ~start_time:"01 Jan 2025, 09:00" ~end_time:"01 Jan 2025, 08:00" () in let b = doc [ data ] |> parse in Alcotest.(check int) "no workouts" 0 (List.length (WI.batch_workouts b)); let w = List.hd (WI.batch_warnings b) in Alcotest.(check string) "reason names order" "end_time precedes start_time" (warning_reason w) ) (* {1 Mixed rows for one workout: valid rows still group} *) let mixed_rows_group = ( "a valid row groups even when a sibling row was dropped", `Quick, fun () -> let good = mk ~set_index:"0" () in let bad = mk ~set_index:"1" ~weight_kg:"" () in let b = doc [ good; bad ] |> parse in let w = List.hd (WI.batch_workouts b) in Alcotest.(check int) "the good row groups" 1 (List.length (WI.workout_sets w)); Alcotest.(check int) "the bad row warns" 1 (List.length (WI.batch_warnings b)) ) let suite = [ ( "Hevy_csv", [ real_sample_parses; quoted_comma_and_newline; header_reorder; missing_column; empty_file; grouping_and_order; timezone_offset; warmup_dropped; unsupported_missing_load; unsupported_negative_load; unsupported_missing_reps; unsupported_zero_reps; unsupported_distance_only; unknown_set_type; dropset_parsed_then_discarded; superset_provenance; raw_source_exact; malformed_csv; invalid_calendar_date; end_before_start; mixed_rows_group; ] ); ]