[OCaml] High Intensity Training Online
1
(** Tests for {!Hito_app.Codec} — the serialization boundary for stored facts. A
2
stored workout must rebuild identically, since the logbook is the only
3
source of evidence. *)
4
5
open Hito_app
6
module Stimulus = Evidence.Stimulus
7
module Workout = Evidence.Workout
8
9
let get id =
10
match Exercise.find_id id with
11
| Some e -> e
12
| None -> Alcotest.failf "catalog is missing %S" id
13
14
let at s = Recovery.timestamp_of_unix_seconds s
15
let find_routine = Catalog.find_by_name
16
17
let move ?(outcome = Stimulus.Positive_failure) id load reps =
18
Stimulus.Effort.make ~exercise:(get id) ~load ~reps ~outcome
19
20
let single id load reps = Stimulus.make (Stimulus.Single (move id load reps))
21
let pair ~first ~second = Stimulus.make (Stimulus.Pair { first; second })
22
23
(* A performed Day 1, including an extension, to exercise every branch. *)
24
let sample_workout ?(finished = true) () =
25
let clearance = Option.get (Recovery.clear Recovery.Ready) in
26
let day_one =
27
List.hd (Prescription.Routine.workouts Prescription.Routine.ideal)
28
in
29
let w = Workout.start day_one ~clearance ~started_at:(at 100) in
30
let w =
31
Workout.add_stimulus w
32
(pair
33
~first:(move "dumbbell-flyes" 20. 9)
34
~second:(move "incline-press" 60. 7))
35
in
36
let w =
37
Workout.add_stimulus w
38
( single "laterals" 12. 8 |> fun _ ->
39
Stimulus.make
40
(Stimulus.Single
41
(move "laterals" 12. 8
42
~outcome:
43
(Stimulus.Beyond_failure
44
(Stimulus.Forced_reps, [ Stimulus.Negatives ])))) )
45
in
46
if finished then Workout.finish w ~ended_at:(at 3700) else w
47
48
let describe w =
49
( Prescription.Workout.name (Workout.prescription w),
50
Recovery.timestamp_to_unix_seconds (Workout.started_at w),
51
Option.map Recovery.timestamp_to_unix_seconds (Workout.ended_at w),
52
List.length (Workout.stimuli w),
53
Workout.is_finished w )
54
55
let roundtrip w =
56
let encoded = Codec.encode_workout ~routine_name:"Ideal Routine" w in
57
match Codec.decode_workout ~find_routine encoded with
58
| Ok decoded -> decoded
59
| Error e -> Alcotest.failf "decode failed: %a" Codec.pp_error e
60
61
let timestamp_tests =
62
[
63
( "encodes Unix epoch in UTC Zulu form",
64
`Quick,
65
fun () ->
66
Alcotest.(check string)
67
"epoch" "1970-01-01T00:00:00Z"
68
(Timestamp.encode (at 0)) );
69
( "round-trips an ISO timestamp",
70
`Quick,
71
fun () ->
72
let original = at 1_700_000_000 in
73
match Timestamp.decode (Timestamp.encode original) with
74
| Ok decoded ->
75
Alcotest.(check int)
76
"same seconds"
77
(Recovery.timestamp_to_unix_seconds original)
78
(Recovery.timestamp_to_unix_seconds decoded)
79
| Error _ -> Alcotest.fail "expected a valid timestamp" );
80
( "rejects an invalid calendar date",
81
`Quick,
82
fun () ->
83
match Timestamp.decode "2024-02-30T00:00:00Z" with
84
| Error _ -> ()
85
| Ok _ -> Alcotest.fail "expected an invalid date" );
86
]
87
88
let codec_tests =
89
[
90
( "a finished workout round-trips faithfully",
91
`Quick,
92
fun () ->
93
let w = sample_workout () in
94
let d = roundtrip w in
95
Alcotest.(check (list string))
96
"same rendered stimuli"
97
(List.map (Format.asprintf "%a" Stimulus.pp) (Workout.stimuli w))
98
(List.map (Format.asprintf "%a" Stimulus.pp) (Workout.stimuli d));
99
let name, started, ended, count, finished = describe w in
100
let name', started', ended', count', finished' = describe d in
101
Alcotest.(check string) "name" name name';
102
Alcotest.(check int) "started" started started';
103
Alcotest.(check (option int)) "ended" ended ended';
104
Alcotest.(check int) "count" count count';
105
Alcotest.(check bool) "finished" finished finished' );
106
( "an in-progress workout has no end after round-trip",
107
`Quick,
108
fun () ->
109
let w = sample_workout ~finished:false () in
110
let d = roundtrip w in
111
Alcotest.(check bool) "still open" false (Workout.is_finished d) );
112
( "an overridden clearance round-trips as an override",
113
`Quick,
114
fun () ->
115
let readiness =
116
Recovery.Recovering
117
{ rested = Recovery.hours 10; recommended = Recovery.hours 48 }
118
in
119
let clearance = Recovery.override readiness in
120
let day_one =
121
List.hd (Prescription.Routine.workouts Prescription.Routine.ideal)
122
in
123
let w = Workout.start day_one ~clearance ~started_at:(at 100) in
124
let d = roundtrip w in
125
match Recovery.basis (Workout.clearance d) with
126
| Recovery.Overridden { rested; recommended } ->
127
Alcotest.(check int)
128
"rested seconds"
129
(Recovery.duration_to_seconds (Recovery.hours 10))
130
(Recovery.duration_to_seconds rested);
131
Alcotest.(check int)
132
"recommended seconds"
133
(Recovery.duration_to_seconds (Recovery.hours 48))
134
(Recovery.duration_to_seconds recommended)
135
| Recovery.Recovered -> Alcotest.fail "expected Overridden" );
136
( "an unknown routine is reported, not raised",
137
`Quick,
138
fun () ->
139
let w = sample_workout () in
140
let encoded = Codec.encode_workout ~routine_name:"Nonexistent" w in
141
match Codec.decode_workout ~find_routine encoded with
142
| Error (Codec.Unknown_routine "Nonexistent") -> ()
143
| _ -> Alcotest.fail "expected Unknown_routine" );
144
( "a malformed line is reported as malformed",
145
`Quick,
146
fun () ->
147
let encoded =
148
"routine\tIdeal Routine\nprescription\tDay 1\ngibberish\tvalue\n"
149
in
150
match Codec.decode_workout ~find_routine encoded with
151
| Error (Codec.Malformed _) -> ()
152
| _ -> Alcotest.fail "expected Malformed" );
153
( "missing header fields are reported, not replayed",
154
`Quick,
155
fun () ->
156
(* A well-formed line, but no started/clearance: parse must refuse
157
before replay rather than build a partial workout. *)
158
let encoded = "routine\tIdeal Routine\nprescription\tDay 1\n" in
159
match Codec.decode_workout ~find_routine encoded with
160
| Error (Codec.Malformed _) -> ()
161
| _ -> Alcotest.fail "expected Malformed for missing fields" );
162
( "a corrected slot round-trips, preserving the slot mapping",
163
`Quick,
164
fun () ->
165
(* Fill slot 1, then correct it: replace, not append. The encoding
166
carries the slot, so replay reproduces one filled slot, not two. *)
167
let day_one =
168
List.hd (Prescription.Routine.workouts Prescription.Routine.ideal)
169
in
170
let clearance = Option.get (Recovery.clear Recovery.Ready) in
171
let w = Workout.start day_one ~clearance ~started_at:(at 100) in
172
let w = Workout.add_stimulus w (single "laterals" 12. 8) in
173
let w = Workout.replace_stimulus w ~slot:1 (single "laterals" 14. 7) in
174
let d = roundtrip w in
175
Alcotest.(check int)
176
"one filled slot survives" 1 (Workout.filled_slots d);
177
Alcotest.(check (list int))
178
"same slot mapping"
179
(List.map fst (Workout.performed w))
180
(List.map fst (Workout.performed d));
181
Alcotest.(check (float 0.001))
182
"corrected load survives" 14.
183
(Stimulus.Effort.load
184
(List.hd (Stimulus.efforts (List.hd (Workout.stimuli d))))) );
185
]
186
187
let feedback_tests =
188
[
189
( "five-point feedback round-trips every score",
190
`Quick,
191
fun () ->
192
let open Evidence.Feedback in
193
let report =
194
make ~reported_at:(at 100)
195
[
196
Sleep Very_poor;
197
Appetite Poor;
198
Readiness Fair;
199
Motivation Good;
200
Difficulty Very_good;
201
Pain;
202
]
203
in
204
match Codec.decode_feedback (Codec.encode_feedback report) with
205
| Ok decoded ->
206
let show s =
207
let lvl l = string_of_int (level_to_score l) in
208
match s with
209
| Sleep l -> "sleep:" ^ lvl l
210
| Appetite l -> "appetite:" ^ lvl l
211
| Readiness l -> "readiness:" ^ lvl l
212
| Motivation l -> "motivation:" ^ lvl l
213
| Difficulty l -> "difficulty:" ^ lvl l
214
| Pain -> "pain"
215
| Injury -> "injury"
216
| Preparation_insufficient -> "preparation"
217
in
218
Alcotest.(check int)
219
"report time" 100
220
(Recovery.timestamp_to_unix_seconds (reported_at decoded));
221
Alcotest.(check (list string))
222
"same signals"
223
(List.map show (signals report))
224
(List.map show (signals decoded))
225
| Error e -> Alcotest.failf "decode failed: %a" Codec.pp_error e );
226
( "an out-of-range score is reported as malformed",
227
`Quick,
228
fun () ->
229
match Codec.decode_feedback "1970-01-01T00:00:00Z\tsleep:6" with
230
| Error (Codec.Malformed _) -> ()
231
| _ -> Alcotest.fail "expected Malformed for score 6" );
232
]
233
234
let suite =
235
[
236
("timestamp", timestamp_tests);
237
("codec", codec_tests);
238
("feedback", feedback_tests);
239
]
240