open Hito_app open Support let suite = [ ( "web.logbook", [ ( "the logbook provides history, progress, and feedback pages", `Quick, fun () -> let c = client () in let _ = sign_in_new c in Alcotest.(check int) "the calendar view is served" 200 (status (get c "/logbook")); Alcotest.(check int) "the progress view is served" 200 (status (get c "/logbook?tab=progress")); Alcotest.(check int) "the old progress endpoint is gone" 404 (status (get c "/progress")); Alcotest.(check int) "the feedback modal stays available" 200 (status (get c "/logbook?feedback=start")) ); ( "the calendar marks the next routine workout after recovery", `Quick, fun () -> let c, now, _repo = clocked_client () in now := day 10; let _ = sign_in_new c in let _ = start_day_one c in record_all_day_one_slots c; now := day 10 + 3600; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in let page = body (get c "/logbook?year=1970&month=1&day=13") in Alcotest.(check bool) "the next workout is planned on the selected date" true (Option.is_some (index_from ~needle:"Planned on 13th January 1970" page 0)); Alcotest.(check bool) "the planned workout is Day 2" true (Option.is_some (index_from ~needle:"Day 2" page 0)) ); ( "the Logbook keeps an overdue next workout tentative", `Quick, fun () -> let c, now, _repo = clocked_client () in now := day 10; let _ = sign_in_new c in let _ = start_day_one c in record_all_day_one_slots c; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in (* Day 2 was due after recovery, but was not performed. The Logbook must keep Day 2 visible today. It must not advance to Day 3. *) now := day 20; let page = body (get c "/logbook") in Alcotest.(check bool) "the overdue workout is planned today" true (Option.is_some (index_from ~needle:"Planned on 21st January 1970" page 0)); Alcotest.(check bool) "the overdue workout is still Day 2" true (Option.is_some (index_from ~needle:"Day 2" page 0)); Alcotest.(check bool) "the routine does not advance silently" true (Option.is_none (index_from ~needle:"Day 3" page 0)) ); ( "a faster repeat of the identical workout shows a favourable \ duration delta", `Quick, fun () -> let c, now, repo = clocked_client () in now := day 10; let _ = sign_in_new c in let trainee = the_trainee repo ~username:"lifter" in let _ = start_day_one c in record_all_day_one_slots c; (* The first Day 1 takes a full hour. *) now := day 10 + 3600; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in (* Advance through the cycle until Day 1 comes round again, resting past the 48-hour gate before each begin, exactly as Web.Workout's "the record to beat" test does. Intermediate days are recorded and finished promptly; only the Day 1 repeat's own duration is timed. *) let rec reach_day_one attempts d = if attempts = 0 then Alcotest.failf "Day 1 did not recur" else begin now := day d; let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/workout" [ ("dream.csrf", token); ("override", "false") ] in match Lwt_main.run (Test_repo.in_progress repo trainee.Trainee.id) with | Some workout when String.equal (Prescription.Workout.name (Evidence.Workout.prescription workout)) "Day 1" -> () | Some _ -> record_all_day_one_slots c; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in reach_day_one (attempts - 1) (d + 3) | None -> Alcotest.failf "expected a workout in progress" end in reach_day_one 6 13; (* The Day 1 repeat finishes in forty minutes — faster, at the same effort. *) record_all_day_one_slots c; let finished_at = !now + 2400 in now := finished_at; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in let page = body (get c "/logbook?year=1970&month=1&day=20") in Alcotest.(check bool) "the faster workout shows its own duration" true (Option.is_some (index_from ~needle:"40 min" page 0)); Alcotest.(check bool) "the delta states twenty minutes faster" true (Option.is_some (index_from ~needle:"-20.0 min" page 0)) ); ( "the logbook shows exact Heavy Duty progress evidence", `Quick, fun () -> let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let day_one = List.hd (Prescription.Routine.workouts Routines.ideal) in let laterals = Option.get (Exercise.find { equipment = Unspecified; movement = Lateral_raise; variation = Standard; }) in let saved ~started ~load ~reps = let workout = Evidence.Workout.start day_one ~clearance:(Option.get (Recovery.clear Recovery.Ready)) ~started_at:(Recovery.timestamp_of_unix_seconds started) in let effort = Result.get_ok (Evidence.Stimulus.Effort.make ~exercise:laterals ~load ~reps ~outcome:Evidence.Stimulus.Positive_failure) in let workout = Evidence.Workout.add_stimulus workout (Evidence.Stimulus.make (Evidence.Stimulus.Single effort)) in Evidence.Workout.finish workout ~ended_at:(Recovery.timestamp_of_unix_seconds (started + 1080)) in ignore (Lwt_main.run (Test_repo.finish_workout repo trainee.Trainee.id (saved ~started:0 ~load:10. ~reps:8))); ignore (Lwt_main.run (Test_repo.finish_workout repo trainee.Trainee.id (saved ~started:172_800 ~load:12. ~reps:9))); (* The later workout progressed, so its history reports the exact observed values and deltas. *) let page = body (get c "/logbook?year=1970&month=1&day=3") in List.iter (fun needle -> Alcotest.(check bool) ("history reports " ^ needle) true (Option.is_some (index_from ~needle page 0))) [ "Laterals"; "12"; "+2"; "9"; "+1" ] ); ( "each saved workout has an edit and confirmation-gated delete action", `Quick, fun () -> let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let _ = start_day_one c in record_all_day_one_slots c; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in let confirm = body (get c "/logbook?delete=t1:w1") in let confirm_token = Option.get (csrf_token confirm) in let deleted = post c "/logbook/t1:w1/delete" [ ("dream.csrf", confirm_token) ] in Alcotest.(check int) "delete redirects" 303 (status deleted); Alcotest.(check int) "record removed from history" 0 (List.length (Lwt_main.run (Test_repo.history repo trainee.Trainee.id))); Alcotest.(check bool) "record cannot be found" true (Option.is_none (Lwt_main.run (Test_repo.find repo trainee.Trainee.id (Repository.workout_id "t1:w1")))) ); ( "a saved workout's slot can be corrected from the logbook view", `Quick, fun () -> let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/workout" [ ("dream.csrf", token); ("override", "false") ] in let token = Option.get (csrf_token (body (get c "/workout?slot=1"))) in let _ = post c "/workout/slots/1" [ ("dream.csrf", token); ("load", "12"); ("reps", "8"); ("extension", ""); ] in let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in (* The one saved workout is t1:w1 for this trainee. Correct its slot through the logbook path, then confirm the store still has one filled slot — a correction, not extra volume. *) let record_page = body (get c "/logbook/t1:w1?slot=1") in let token = Option.get (csrf_token record_page) in let corrected = post c "/logbook/t1:w1/slots/1/edit" [ ("dream.csrf", token); ("load", "20"); ("reps", "7"); ("extension", ""); ] in Alcotest.(check int) "corrected" 303 (status corrected); let record = Lwt_main.run (Test_repo.find repo trainee.Trainee.id (Repository.workout_id "t1:w1")) in Alcotest.(check int) "still one filled slot" 1 (match record with | Some r -> Evidence.Workout.filled_slots r.Repository.workout | None -> Alcotest.failf "expected the saved workout t1:w1") ); ( "an outstanding slot of a saved workout can be recorded, not only \ corrected", `Quick, fun () -> (* Finish a workout with a single slot recorded, so the saved record still has outstanding slots. Then record one of those through the plain add path [POST /logbook/:id/slots/:d], which the edit tests never exercise. *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/workout" [ ("dream.csrf", token); ("override", "false") ] in (* Record only slot 1, then finish. Slots 0, 2, 3 stay outstanding. *) let token = Option.get (csrf_token (body (get c "/workout?slot=1"))) in let _ = post c "/workout/slots/1" [ ("dream.csrf", token); ("load", "12"); ("reps", "8"); ("extension", ""); ] in let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in let filled_of_record () = match Lwt_main.run (Test_repo.find repo trainee.Trainee.id (Repository.workout_id "t1:w1")) with | Some r -> Evidence.Workout.filled_slots r.Repository.workout | None -> Alcotest.failf "expected the saved workout t1:w1" in Alcotest.(check int) "saved workout starts with one slot" 1 (filled_of_record ()); (* Add slot 2 through the non-edit path. *) let token = Option.get (csrf_token (body (get c "/logbook/t1:w1?slot=2"))) in let added = post c "/logbook/t1:w1/slots/2" [ ("dream.csrf", token); ("load", "10"); ("reps", "9"); ("extension", ""); ] in Alcotest.(check int) "recorded a saved-workout slot" 303 (status added); Alcotest.(check int) "now two filled slots" 2 (filled_of_record ()) ); ( "recording an already-filled slot through the add path is refused", `Quick, fun () -> (* The add path lists only outstanding slots. A filled slot is not awaiting a record, so posting to it is a 404, not a silent second entry that would add volume. *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in let token = Option.get (csrf_token (body (get c "/"))) in let _ = post c "/workout" [ ("dream.csrf", token); ("override", "false") ] in let token = Option.get (csrf_token (body (get c "/workout?slot=1"))) in let _ = post c "/workout/slots/1" [ ("dream.csrf", token); ("load", "12"); ("reps", "8"); ("extension", ""); ] in let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in (* Slot 1 is filled. Re-posting to its add path is refused. *) let token = Option.get (csrf_token (body (get c "/logbook/t1:w1?slot=2"))) in let refused = post c "/logbook/t1:w1/slots/1" [ ("dream.csrf", token); ("load", "20"); ("reps", "6"); ("extension", ""); ] in Alcotest.(check int) "filled slot is not awaiting" 404 (status refused); (* The refused post added no volume: still one filled slot. *) Alcotest.(check int) "still one filled slot" 1 (match Lwt_main.run (Test_repo.find repo trainee.Trainee.id (Repository.workout_id "t1:w1")) with | Some r -> Evidence.Workout.filled_slots r.Repository.workout | None -> Alcotest.failf "expected the saved workout t1:w1") ); ( "submitting one factor stores only that signal", `Quick, fun () -> let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/logbook"))) in let saved = post c "/feedback" [ ("dream.csrf", token); ("motivation", "better") ] in Alcotest.(check int) "submit redirects" 303 (status saved); Alcotest.(check string) "back to the logbook" "/logbook" (location saved); let reports = Lwt_main.run (Test_repo.feedback repo trainee.Trainee.id) in Alcotest.(check int) "one report stored" 1 (List.length reports); Alcotest.(check bool) "only motivation, better than usual" true (Feedback.signals (List.hd reports) = [ Feedback.Motivation Feedback.Better_than_usual ]) ); ( "submitting every factor stores exactly one signal per category", `Quick, fun () -> let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/logbook"))) in let saved = post c "/feedback" [ ("dream.csrf", token); ("motivation", "worse"); ("difficulty", "usual"); ] in Alcotest.(check int) "submit redirects" 303 (status saved); let reports = Lwt_main.run (Test_repo.feedback repo trainee.Trainee.id) in Alcotest.(check int) "one report stored" 1 (List.length reports); let signals = Feedback.signals (List.hd reports) in let count p = List.length (List.filter p signals) in let open Feedback in Alcotest.(check int) "one motivation signal" 1 (count (function Motivation _ -> true | _ -> false)); Alcotest.(check int) "one difficulty signal" 1 (count (function Difficulty _ -> true | _ -> false)) ); ( "submitting with nothing selected stores nothing", `Quick, fun () -> let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/logbook"))) in let empty = post c "/feedback" [ ("dream.csrf", token) ] in Alcotest.(check int) "empty submit redirects" 303 (status empty); Alcotest.(check string) "back to the logbook" "/logbook" (location empty); Alcotest.(check int) "no report stored" 0 (List.length (Lwt_main.run (Test_repo.feedback repo trainee.Trainee.id))) ); ( "a value outside the three choices is ignored", `Quick, fun () -> (* A crafted or stale value for a factor is treated as unselected, so it can neither store a bad level nor reach the domain. *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let token = Option.get (csrf_token (body (get c "/logbook"))) in let bad = post c "/feedback" [ ("dream.csrf", token); ("motivation", "7"); ("difficulty", "better"); ] in Alcotest.(check int) "submit redirects" 303 (status bad); let reports = Lwt_main.run (Test_repo.feedback repo trainee.Trainee.id) in Alcotest.(check int) "one report stored" 1 (List.length reports); Alcotest.(check bool) "only the valid factor survives" true (Feedback.signals (List.hd reports) = [ Feedback.Difficulty Feedback.Better_than_usual ]) ); ( "a promoted import is available in the logbook history", `Quick, fun () -> (* Upload one failure set, map its exercise, promote it, then read the logbook. The promoted import shows tagged and read-only, and its title interleaves with native records. *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in 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\r\n" in let source = header ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \ Squat,,,0,failure,100,8,,,\r\n" in let token = Option.get (csrf_token (body (get c "/profile"))) in let _ = post_multipart c "/profile/import" ~file:(Some ("csv", "workouts.csv", source)) ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] in let batch = List.hd (Lwt_main.run (Test_repo.import_batches repo trainee.Trainee.id)) in let bid = Workout_import.batch_id_to_string (Workout_import.batch_identity batch) in let wid = Workout_import.workout_id_to_string (Workout_import.workout_identity (List.hd (Workout_import.batch_workouts batch))) in let review = "/profile/import/" ^ bid in let t = Option.get (csrf_token (body (get c review))) in let _ = post c (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid) [ ("dream.csrf", t); ("source_name", "Barbell Squat"); ("exercise_id", "squats"); ] in let t = Option.get (csrf_token (body (get c review))) in let _ = post c (Printf.sprintf "/profile/import/%s/workouts/%s/promote" bid wid) [ ("dream.csrf", t) ] in Alcotest.(check int) "the import is promoted" 1 (List.length (Lwt_main.run (Test_repo.imported_records repo trainee.Trainee.id))); Alcotest.(check int) "imported history is available" 200 (status (get c "/logbook?year=2024&month=1")) ); ( "an imported exercise shows progress since it was last performed", `Quick, fun () -> (* Two imports of the same exercise on different days. The later one's menu reports the load gain since the earlier performance, not "first performance". *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in 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\r\n" in let source = header ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \ Squat,,,0,failure,100,8,,,\r\n" ^ "Legs,\"08 Jan 2024, 10:00\",\"08 Jan 2024, 11:00\",,Barbell \ Squat,,,0,failure,110,8,,,\r\n" in let token = Option.get (csrf_token (body (get c "/profile"))) in let _ = post_multipart c "/profile/import" ~file:(Some ("csv", "workouts.csv", source)) ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] in let batch = List.hd (Lwt_main.run (Test_repo.import_batches repo trainee.Trainee.id)) in let bid = Workout_import.batch_id_to_string (Workout_import.batch_identity batch) in let review = "/profile/import/" ^ bid in (* Map the exercise in both workouts, then promote the whole batch. *) List.iter (fun w -> let wid = Workout_import.workout_id_to_string (Workout_import.workout_identity w) in let t = Option.get (csrf_token (body (get c review))) in ignore (post c (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid) [ ("dream.csrf", t); ("source_name", "Barbell Squat"); ("exercise_id", "squats"); ])) (Workout_import.batch_workouts batch); let t = Option.get (csrf_token (body (get c review))) in let _ = post c (Printf.sprintf "/profile/import/%s/promote" bid) [ ("dream.csrf", t) ] in Alcotest.(check int) "both imports promoted" 2 (List.length (Lwt_main.run (Test_repo.imported_records repo trainee.Trainee.id))); let page = body (get c "/logbook?year=2024&month=1&day=8") in (* The later import gained 10 kg over the earlier one, shown as a green weight delta without repeating the unit in the value. *) Alcotest.(check bool) "the load gain is reported" true (Option.is_some (index_from ~needle:"+10" page 0)) ); ( "the progress page charts an exercise with enough history", `Quick, fun () -> (* Two promoted imports of the same exercise give two points, enough to draw the dual-axis line chart. *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in 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\r\n" in let source = header ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \ Squat,,,0,failure,100,8,,,\r\n" ^ "Legs,\"08 Jan 2024, 10:00\",\"08 Jan 2024, 11:00\",,Barbell \ Squat,,,0,failure,110,8,,,\r\n" in let token = Option.get (csrf_token (body (get c "/profile"))) in let _ = post_multipart c "/profile/import" ~file:(Some ("csv", "workouts.csv", source)) ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] in let batch = List.hd (Lwt_main.run (Test_repo.import_batches repo trainee.Trainee.id)) in let bid = Workout_import.batch_id_to_string (Workout_import.batch_identity batch) in let review = "/profile/import/" ^ bid in List.iter (fun w -> let wid = Workout_import.workout_id_to_string (Workout_import.workout_identity w) in let t = Option.get (csrf_token (body (get c review))) in ignore (post c (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid) [ ("dream.csrf", t); ("source_name", "Barbell Squat"); ("exercise_id", "squats"); ])) (Workout_import.batch_workouts batch); let t = Option.get (csrf_token (body (get c review))) in let _ = post c (Printf.sprintf "/profile/import/%s/promote" bid) [ ("dream.csrf", t) ] in Alcotest.(check int) "progress is available after imported history" 200 (status (get c "/logbook?tab=progress")) ); ( "the progress page reports too little history to chart", `Quick, fun () -> (* A single performance cannot draw a line. *) let c, repo = client_with_repo () in let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in 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\r\n" in let source = header ^ "Legs,\"01 Jan 2024, 10:00\",\"01 Jan 2024, 11:00\",,Barbell \ Squat,,,0,failure,100,8,,,\r\n" in let token = Option.get (csrf_token (body (get c "/profile"))) in let _ = post_multipart c "/profile/import" ~file:(Some ("csv", "workouts.csv", source)) ~fields:[ ("dream.csrf", token); ("utc_offset_seconds", "0") ] in let batch = List.hd (Lwt_main.run (Test_repo.import_batches repo trainee.Trainee.id)) in let bid = Workout_import.batch_id_to_string (Workout_import.batch_identity batch) in let wid = Workout_import.workout_id_to_string (Workout_import.workout_identity (List.hd (Workout_import.batch_workouts batch))) in let review = "/profile/import/" ^ bid in let t = Option.get (csrf_token (body (get c review))) in let _ = post c (Printf.sprintf "/profile/import/%s/workouts/%s/map" bid wid) [ ("dream.csrf", t); ("source_name", "Barbell Squat"); ("exercise_id", "squats"); ] in let t = Option.get (csrf_token (body (get c review))) in let _ = post c (Printf.sprintf "/profile/import/%s/workouts/%s/promote" bid wid) [ ("dream.csrf", t) ] in Alcotest.(check int) "progress remains available with one observation" 200 (status (get c "/logbook?tab=progress")) ); ( "the logbook supports browsing recorded and empty months", `Quick, fun () -> let c, now, repo = clocked_client () in now := day 10; let _ = register c ~username:"lifter" ~password:"heavyduty1" in let trainee = the_trainee repo ~username:"lifter" in let _ = start_day_one c in record_all_day_one_slots c; let token = Option.get (csrf_token (body (get c "/workout"))) in let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in Alcotest.(check int) "the recorded month is available" 200 (status (get c "/logbook?year=1970&month=1")); Alcotest.(check int) "an empty month is available" 200 (status (get c "/logbook?year=1970&month=3")); Alcotest.(check int) "the workout remains in history" 1 (List.length (Lwt_main.run (Test_repo.history repo trainee.Trainee.id))) ); ] ); ]