(** Tests for {!Hito_app.Codec} — the serialization boundary for stored facts. A stored workout must rebuild identically, since the logbook is the only source of evidence. *) open Hito_app module Stimulus = Evidence.Stimulus module Workout = Evidence.Workout 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 find_routine = Catalog.find_by_name let move ?(outcome = Stimulus.Positive_failure) id load reps = Stimulus.Effort.make ~exercise:(get id) ~load ~reps ~outcome let single id load reps = Stimulus.make (Stimulus.Single (move id load reps)) let pair ~first ~second = Stimulus.make (Stimulus.Pair { first; second }) (* A performed Day 1, including an extension, to exercise every branch. *) let sample_workout ?(finished = true) () = let clearance = Option.get (Recovery.clear Recovery.Ready) in let day_one = List.hd (Prescription.Routine.workouts Prescription.Routine.ideal) in let w = Workout.start day_one ~clearance ~started_at:(at 100) in let w = Workout.add_stimulus w (pair ~first:(move "dumbbell-flyes" 20. 9) ~second:(move "incline-press" 60. 7)) in let w = Workout.add_stimulus w ( single "laterals" 12. 8 |> fun _ -> Stimulus.make (Stimulus.Single (move "laterals" 12. 8 ~outcome: (Stimulus.Beyond_failure (Stimulus.Forced_reps, [ Stimulus.Negatives ])))) ) in if finished then Workout.finish w ~ended_at:(at 3700) else w let describe w = ( Prescription.Workout.name (Workout.prescription w), Recovery.timestamp_to_unix_seconds (Workout.started_at w), Option.map Recovery.timestamp_to_unix_seconds (Workout.ended_at w), List.length (Workout.stimuli w), Workout.is_finished w ) let roundtrip w = let encoded = Codec.encode_workout ~routine_name:"Ideal Routine" w in match Codec.decode_workout ~find_routine encoded with | Ok decoded -> decoded | Error e -> Alcotest.failf "decode failed: %a" Codec.pp_error e let timestamp_tests = [ ( "encodes Unix epoch in UTC Zulu form", `Quick, fun () -> Alcotest.(check string) "epoch" "1970-01-01T00:00:00Z" (Timestamp.encode (at 0)) ); ( "round-trips an ISO timestamp", `Quick, fun () -> let original = at 1_700_000_000 in match Timestamp.decode (Timestamp.encode original) with | Ok decoded -> Alcotest.(check int) "same seconds" (Recovery.timestamp_to_unix_seconds original) (Recovery.timestamp_to_unix_seconds decoded) | Error _ -> Alcotest.fail "expected a valid timestamp" ); ( "rejects an invalid calendar date", `Quick, fun () -> match Timestamp.decode "2024-02-30T00:00:00Z" with | Error _ -> () | Ok _ -> Alcotest.fail "expected an invalid date" ); ] let codec_tests = [ ( "a finished workout round-trips faithfully", `Quick, fun () -> let w = sample_workout () in let d = roundtrip w in Alcotest.(check (list string)) "same rendered stimuli" (List.map (Format.asprintf "%a" Stimulus.pp) (Workout.stimuli w)) (List.map (Format.asprintf "%a" Stimulus.pp) (Workout.stimuli d)); let name, started, ended, count, finished = describe w in let name', started', ended', count', finished' = describe d in Alcotest.(check string) "name" name name'; Alcotest.(check int) "started" started started'; Alcotest.(check (option int)) "ended" ended ended'; Alcotest.(check int) "count" count count'; Alcotest.(check bool) "finished" finished finished' ); ( "an in-progress workout has no end after round-trip", `Quick, fun () -> let w = sample_workout ~finished:false () in let d = roundtrip w in Alcotest.(check bool) "still open" false (Workout.is_finished d) ); ( "an overridden clearance round-trips as an override", `Quick, fun () -> let readiness = Recovery.Recovering { rested = Recovery.hours 10; recommended = Recovery.hours 48 } in let clearance = Recovery.override readiness in let day_one = List.hd (Prescription.Routine.workouts Prescription.Routine.ideal) in let w = Workout.start day_one ~clearance ~started_at:(at 100) in let d = roundtrip w in match Recovery.basis (Workout.clearance d) with | Recovery.Overridden { rested; recommended } -> Alcotest.(check int) "rested seconds" (Recovery.duration_to_seconds (Recovery.hours 10)) (Recovery.duration_to_seconds rested); Alcotest.(check int) "recommended seconds" (Recovery.duration_to_seconds (Recovery.hours 48)) (Recovery.duration_to_seconds recommended) | Recovery.Recovered -> Alcotest.fail "expected Overridden" ); ( "an unknown routine is reported, not raised", `Quick, fun () -> let w = sample_workout () in let encoded = Codec.encode_workout ~routine_name:"Nonexistent" w in match Codec.decode_workout ~find_routine encoded with | Error (Codec.Unknown_routine "Nonexistent") -> () | _ -> Alcotest.fail "expected Unknown_routine" ); ( "a malformed line is reported as malformed", `Quick, fun () -> let encoded = "routine\tIdeal Routine\nprescription\tDay 1\ngibberish\tvalue\n" in match Codec.decode_workout ~find_routine encoded with | Error (Codec.Malformed _) -> () | _ -> Alcotest.fail "expected Malformed" ); ( "missing header fields are reported, not replayed", `Quick, fun () -> (* A well-formed line, but no started/clearance: parse must refuse before replay rather than build a partial workout. *) let encoded = "routine\tIdeal Routine\nprescription\tDay 1\n" in match Codec.decode_workout ~find_routine encoded with | Error (Codec.Malformed _) -> () | _ -> Alcotest.fail "expected Malformed for missing fields" ); ( "a corrected slot round-trips, preserving the slot mapping", `Quick, fun () -> (* Fill slot 1, then correct it: replace, not append. The encoding carries the slot, so replay reproduces one filled slot, not two. *) let day_one = List.hd (Prescription.Routine.workouts Prescription.Routine.ideal) in let clearance = Option.get (Recovery.clear Recovery.Ready) in let w = Workout.start day_one ~clearance ~started_at:(at 100) in let w = Workout.add_stimulus w (single "laterals" 12. 8) in let w = Workout.replace_stimulus w ~slot:1 (single "laterals" 14. 7) in let d = roundtrip w in Alcotest.(check int) "one filled slot survives" 1 (Workout.filled_slots d); Alcotest.(check (list int)) "same slot mapping" (List.map fst (Workout.performed w)) (List.map fst (Workout.performed d)); Alcotest.(check (float 0.001)) "corrected load survives" 14. (Stimulus.Effort.load (List.hd (Stimulus.efforts (List.hd (Workout.stimuli d))))) ); ] let feedback_tests = [ ( "five-point feedback round-trips every score", `Quick, fun () -> let open Evidence.Feedback in let report = make ~reported_at:(at 100) [ Sleep Very_poor; Appetite Poor; Readiness Fair; Motivation Good; Difficulty Very_good; Pain; ] in match Codec.decode_feedback (Codec.encode_feedback report) with | Ok decoded -> let show s = let lvl l = string_of_int (level_to_score l) in match s with | Sleep l -> "sleep:" ^ lvl l | Appetite l -> "appetite:" ^ lvl l | Readiness l -> "readiness:" ^ lvl l | Motivation l -> "motivation:" ^ lvl l | Difficulty l -> "difficulty:" ^ lvl l | Pain -> "pain" | Injury -> "injury" | Preparation_insufficient -> "preparation" in Alcotest.(check int) "report time" 100 (Recovery.timestamp_to_unix_seconds (reported_at decoded)); Alcotest.(check (list string)) "same signals" (List.map show (signals report)) (List.map show (signals decoded)) | Error e -> Alcotest.failf "decode failed: %a" Codec.pp_error e ); ( "an out-of-range score is reported as malformed", `Quick, fun () -> match Codec.decode_feedback "1970-01-01T00:00:00Z\tsleep:6" with | Error (Codec.Malformed _) -> () | _ -> Alcotest.fail "expected Malformed for score 6" ); ] let suite = [ ("timestamp", timestamp_tests); ("codec", codec_tests); ("feedback", feedback_tests); ]