[OCaml] High Intensity Training Online
feat Model provisional workout imports
Add the application-owned import lifecycle. Batches retain immutable source and warnings. Workouts require explicit exercise mappings and one workout-level normal-set acknowledgement. Drop sets block the whole workout. Promotion is atomic per workout and partial per batch, producing confirmed canonical Evidence.Observation values with provenance and revision data.
Changed files
lib/app/workout_import.ml
@@ -0,0 +1,307 @@
1
Added:
(** See {!module:Workout_import} for the contract and the invariant this module
2
Added:
upholds on behalf of the core. *)
3
Added:
4
Added:
type batch_id = string
5
Added:
type workout_id = string
6
Added:
7
Added:
let batch_id s = s
8
Added:
let workout_id s = s
9
Added:
let batch_id_to_string s = s
10
Added:
let workout_id_to_string s = s
11
Added:
12
Added:
type source_set_type = Normal | Failure | Dropset
13
Added:
14
Added:
type source_set = {
15
Added:
row : int;
16
Added:
exercise_name : string;
17
Added:
set_index : int;
18
Added:
set_type : source_set_type;
19
Added:
weight_kg : float;
20
Added:
reps : int;
21
Added:
superset_id : int option;
22
Added:
}
23
Added:
24
Added:
type warning_kind = Warmup_dropped | Unsupported_row of string
25
Added:
type warning = { row : int; exercise_name : string option; kind : warning_kind }
26
Added:
27
Added:
type error =
28
Added:
| Unknown_workout of workout_id
29
Added:
| Empty_acknowledgement
30
Added:
| Invalid_time
31
Added:
32
Added:
(* A mapping is confirmed per source exercise name and applies to every matching
33
Added:
set. The acknowledgement is the one workout-level confirmation that promotes
34
Added:
the workout's [Normal] sets. The revision advances whenever either changes,
35
Added:
so a stored promotion made against an earlier revision is stale. *)
36
Added:
type workout = {
37
Added:
id : workout_id;
38
Added:
title : string;
39
Added:
started_at : Recovery.timestamp;
40
Added:
ended_at : Recovery.timestamp;
41
Added:
sets : source_set list;
42
Added:
mappings : (string * Exercise.t) list;
43
Added:
acknowledgement : string option;
44
Added:
revision : int;
45
Added:
}
46
Added:
47
Added:
type batch = {
48
Added:
batch_identity : batch_id;
49
Added:
source : string;
50
Added:
fingerprint : string;
51
Added:
imported_at : Recovery.timestamp;
52
Added:
workouts : workout list;
53
Added:
warnings : warning list;
54
Added:
promotions : (workout_id * promoted) list;
55
Added:
}
56
Added:
57
Added:
and provenance = {
58
Added:
batch_id : batch_id;
59
Added:
workout_id : workout_id;
60
Added:
source_rows : int list;
61
Added:
normal_confirmation : string option;
62
Added:
revision : int;
63
Added:
}
64
Added:
65
Added:
and promoted = {
66
Added:
title : string;
67
Added:
started_at : Recovery.timestamp;
68
Added:
ended_at : Recovery.timestamp;
69
Added:
observations : Evidence.Observation.t list;
70
Added:
provenance : provenance;
71
Added:
}
72
Added:
73
Added:
let make_workout ~id ~title ~started_at ~ended_at ~sets =
74
Added:
if
75
Added:
Recovery.timestamp_to_unix_seconds ended_at
76
Added:
< Recovery.timestamp_to_unix_seconds started_at
77
Added:
then Error Invalid_time
78
Added:
else
79
Added:
Ok
80
Added:
{
81
Added:
id;
82
Added:
title;
83
Added:
started_at;
84
Added:
ended_at;
85
Added:
sets;
86
Added:
mappings = [];
87
Added:
acknowledgement = None;
88
Added:
revision = 0;
89
Added:
}
90
Added:
91
Added:
let make_batch ~id ~source ~fingerprint ~imported_at ~workouts ~warnings =
92
Added:
{
93
Added:
batch_identity = id;
94
Added:
source;
95
Added:
fingerprint;
96
Added:
imported_at;
97
Added:
workouts;
98
Added:
warnings;
99
Added:
promotions = [];
100
Added:
}
101
Added:
102
Added:
(* {1 Accessors} *)
103
Added:
104
Added:
let batch_identity b = b.batch_identity
105
Added:
let batch_source b = b.source
106
Added:
let batch_fingerprint b = b.fingerprint
107
Added:
let batch_imported_at b = b.imported_at
108
Added:
let batch_workouts b = b.workouts
109
Added:
let batch_warnings b = b.warnings
110
Added:
let find_workout b id = List.find_opt (fun w -> String.equal w.id id) b.workouts
111
Added:
let workout_identity (w : workout) = w.id
112
Added:
let workout_title (w : workout) = w.title
113
Added:
let workout_started_at (w : workout) = w.started_at
114
Added:
let workout_ended_at (w : workout) = w.ended_at
115
Added:
let workout_sets (w : workout) = w.sets
116
Added:
let workout_revision (w : workout) = w.revision
117
Added:
let workout_acknowledgement (w : workout) = w.acknowledgement
118
Added:
119
Added:
let workout_mapping (w : workout) ~source_name =
120
Added:
List.assoc_opt source_name w.mappings
121
Added:
122
Added:
(* Replaces the workout that shares [w]'s id, leaving every other workout and
123
Added:
the batch's own fields untouched. *)
124
Added:
let replace_workout b w =
125
Added:
{
126
Added:
b with
127
Added:
workouts =
128
Added:
List.map (fun x -> if String.equal x.id w.id then w else x) b.workouts;
129
Added:
}
130
Added:
131
Added:
(* {1 Cleaning} *)
132
Added:
133
Added:
let map_exercise b ~workout ~source_name exercise =
134
Added:
match find_workout b workout with
135
Added:
| None -> Error (Unknown_workout workout)
136
Added:
| Some w ->
137
Added:
let changed =
138
Added:
match List.assoc_opt source_name w.mappings with
139
Added:
| Some current -> not (Exercise.equal current exercise)
140
Added:
| None -> true
141
Added:
in
142
Added:
if not changed then Ok b
143
Added:
else
144
Added:
let mappings =
145
Added:
(source_name, exercise) :: List.remove_assoc source_name w.mappings
146
Added:
in
147
Added:
let w = { w with mappings; revision = w.revision + 1 } in
148
Added:
Ok (replace_workout b w)
149
Added:
150
Added:
let confirm_normal_sets b ~workout ~acknowledgement =
151
Added:
match find_workout b workout with
152
Added:
| None -> Error (Unknown_workout workout)
153
Added:
| Some w ->
154
Added:
if String.equal (String.trim acknowledgement) "" then
155
Added:
Error Empty_acknowledgement
156
Added:
else
157
Added:
let changed =
158
Added:
match w.acknowledgement with
159
Added:
| Some current -> not (String.equal current acknowledgement)
160
Added:
| None -> true
161
Added:
in
162
Added:
if not changed then Ok b
163
Added:
else
164
Added:
let w =
165
Added:
{
166
Added:
w with
167
Added:
acknowledgement = Some acknowledgement;
168
Added:
revision = w.revision + 1;
169
Added:
}
170
Added:
in
171
Added:
Ok (replace_workout b w)
172
Added:
173
Added:
(* {1 Blockers} *)
174
Added:
175
Added:
type blocker =
176
Added:
| No_sets
177
Added:
| Dropset_present of int list
178
Added:
| Unmapped_exercise of string
179
Added:
| Normal_sets_unconfirmed
180
Added:
181
Added:
(* Distinct source exercise names in first-appearance order. *)
182
Added:
let distinct_names (sets : source_set list) =
183
Added:
List.fold_left
184
Added:
(fun acc (s : source_set) ->
185
Added:
if List.mem s.exercise_name acc then acc else acc @ [ s.exercise_name ])
186
Added:
[] sets
187
Added:
188
Added:
let blockers w =
189
Added:
if w.sets = [] then [ No_sets ]
190
Added:
else
191
Added:
let dropset_rows =
192
Added:
List.filter_map
193
Added:
(fun (s : source_set) ->
194
Added:
if s.set_type = Dropset then Some s.row else None)
195
Added:
w.sets
196
Added:
in
197
Added:
let dropset_blocker =
198
Added:
if dropset_rows = [] then [] else [ Dropset_present dropset_rows ]
199
Added:
in
200
Added:
let unmapped =
201
Added:
distinct_names w.sets
202
Added:
|> List.filter (fun name -> not (List.mem_assoc name w.mappings))
203
Added:
|> List.map (fun name -> Unmapped_exercise name)
204
Added:
in
205
Added:
let normal_blocker =
206
Added:
if
207
Added:
List.exists (fun (s : source_set) -> s.set_type = Normal) w.sets
208
Added:
&& w.acknowledgement = None
209
Added:
then [ Normal_sets_unconfirmed ]
210
Added:
else []
211
Added:
in
212
Added:
dropset_blocker @ unmapped @ normal_blocker
213
Added:
214
Added:
(* {1 Promotion} *)
215
Added:
216
Added:
type workout_result =
217
Added:
| Promoted of promoted
218
Added:
| Already_promoted of promoted
219
Added:
| Blocked of blocker list
220
Added:
221
Added:
type batch_result = {
222
Added:
promoted : (workout_id * promoted) list;
223
Added:
already_promoted : (workout_id * promoted) list;
224
Added:
blocked : (workout_id * blocker list) list;
225
Added:
}
226
Added:
227
Added:
(* Every promoted set becomes one canonical observation, taken to failure and
228
Added:
performed at the workout's end. The export's set type gated whether the
229
Added:
workout could promote; it never colours the recorded outcome. The mapping is
230
Added:
present for every set, guaranteed by [blockers] returning empty. *)
231
Added:
let observe w (s : source_set) =
232
Added:
let exercise =
233
Added:
match List.assoc_opt s.exercise_name w.mappings with
234
Added:
| Some e -> e
235
Added:
| None ->
236
Added:
(* Unreachable: promotion only runs with no blockers, so every set is
237
Added:
mapped. *)
238
Added:
assert false
239
Added:
in
240
Added:
let effort =
241
Added:
Evidence.Stimulus.Effort.make ~exercise ~load:s.weight_kg ~reps:s.reps
242
Added:
~outcome:Evidence.Stimulus.Positive_failure
243
Added:
in
244
Added:
Evidence.Observation.make ~exercise ~effort ~performed_at:w.ended_at
245
Added:
246
Added:
let promote_of_workout b (w : workout) =
247
Added:
{
248
Added:
title = w.title;
249
Added:
started_at = w.started_at;
250
Added:
ended_at = w.ended_at;
251
Added:
observations = List.map (observe w) w.sets;
252
Added:
provenance =
253
Added:
{
254
Added:
batch_id = b.batch_identity;
255
Added:
workout_id = w.id;
256
Added:
source_rows = List.map (fun (s : source_set) -> s.row) w.sets;
257
Added:
normal_confirmation =
258
Added:
(if List.exists (fun (s : source_set) -> s.set_type = Normal) w.sets
259
Added:
then w.acknowledgement
260
Added:
else None);
261
Added:
revision = w.revision;
262
Added:
};
263
Added:
}
264
Added:
265
Added:
let store_promotion b workout_id promoted =
266
Added:
{
267
Added:
b with
268
Added:
promotions =
269
Added:
(workout_id, promoted) :: List.remove_assoc workout_id b.promotions;
270
Added:
}
271
Added:
272
Added:
(* Promotes one workout against its current state, returning the result and the
273
Added:
batch with any stored promotion refreshed. A blocked workout leaves the batch
274
Added:
untouched. *)
275
Added:
let promote_workout_internal b w =
276
Added:
match blockers w with
277
Added:
| _ :: _ as bs -> (b, Blocked bs)
278
Added:
| [] -> (
279
Added:
match List.assoc_opt w.id b.promotions with
280
Added:
| Some prior when prior.provenance.revision = w.revision ->
281
Added:
(b, Already_promoted prior)
282
Added:
| _ ->
283
Added:
let promoted = promote_of_workout b w in
284
Added:
(store_promotion b w.id promoted, Promoted promoted))
285
Added:
286
Added:
let promote_workout b workout =
287
Added:
match find_workout b workout with
288
Added:
| None -> Error (Unknown_workout workout)
289
Added:
| Some w -> Ok (promote_workout_internal b w)
290
Added:
291
Added:
let promote_batch b =
292
Added:
let b, promoted_rev, already_rev, blocked_rev =
293
Added:
List.fold_left
294
Added:
(fun (b, promoted, already, blocked) w ->
295
Added:
let b, result = promote_workout_internal b w in
296
Added:
match result with
297
Added:
| Promoted p -> (b, (w.id, p) :: promoted, already, blocked)
298
Added:
| Already_promoted p -> (b, promoted, (w.id, p) :: already, blocked)
299
Added:
| Blocked bs -> (b, promoted, already, (w.id, bs) :: blocked))
300
Added:
(b, [], [], []) b.workouts
301
Added:
in
302
Added:
( b,
303
Added:
{
304
Added:
promoted = List.rev promoted_rev;
305
Added:
already_promoted = List.rev already_rev;
306
Added:
blocked = List.rev blocked_rev;
307
Added:
} )
lib/app/workout_import.mli
@@ -0,0 +1,186 @@
1
Added:
(** Importing a third-party workout export into confirmed evidence.
2
Added:
3
Added:
This is an application use case, not a core entity. A batch holds the
4
Added:
immutable raw source and the workouts parsed from it. Cleaning operations
5
Added:
(exercise mapping, acknowledgement) return a revised batch. Promotion turns
6
Added:
a fully cleaned workout into canonical {!Evidence.Observation} values.
7
Added:
8
Added:
The module upholds one invariant on behalf of the core: the core receives
9
Added:
only confirmed, catalogued, failure-defined evidence. A row reaches the core
10
Added:
only after its exercise is mapped to a catalog entry, its workout-level
11
Added:
acknowledgement is present when required, and no dropset blocks the workout.
12
Added:
Every promoted set becomes one {!Evidence.Observation} recorded at
13
Added:
{!Positive_failure}: the export's set type calibrates what confirmation the
14
Added:
import demands, never the recorded outcome. *)
15
Added:
16
Added:
type batch_id = private string
17
Added:
type workout_id = private string
18
Added:
19
Added:
val batch_id : string -> batch_id
20
Added:
val workout_id : string -> workout_id
21
Added:
val batch_id_to_string : batch_id -> string
22
Added:
val workout_id_to_string : workout_id -> string
23
Added:
24
Added:
(** How the export classified a set. Warmups and unsupported rows never reach
25
Added:
here — an adapter turns them into {!warning} values instead. *)
26
Added:
type source_set_type =
27
Added:
| Normal (** A working set the export did not mark as taken to failure. *)
28
Added:
| Failure (** A set the export marked as taken to failure. *)
29
Added:
| Dropset (** A drop set. Not representable as one drive to failure. *)
30
Added:
31
Added:
type source_set = {
32
Added:
row : int; (** The source row, retained for provenance. *)
33
Added:
exercise_name : string; (** The export's exercise name, before mapping. *)
34
Added:
set_index : int; (** The set's position within its exercise. *)
35
Added:
set_type : source_set_type;
36
Added:
weight_kg : float;
37
Added:
reps : int;
38
Added:
superset_id : int option;
39
Added:
(** The export's superset grouping. Provenance only: it never changes how
40
Added:
a set is promoted. *)
41
Added:
}
42
Added:
43
Added:
(** A row the adapter dropped, with the reason. Warmups and unsupported rows are
44
Added:
reported here, never as provisional sets. *)
45
Added:
type warning_kind = Warmup_dropped | Unsupported_row of string
46
Added:
47
Added:
type warning = { row : int; exercise_name : string option; kind : warning_kind }
48
Added:
type workout
49
Added:
type batch
50
Added:
51
Added:
type error =
52
Added:
| Unknown_workout of workout_id
53
Added:
| Empty_acknowledgement
54
Added:
| Invalid_time (** [ended_at] precedes [started_at]. *)
55
Added:
56
Added:
val make_workout :
57
Added:
id:workout_id ->
58
Added:
title:string ->
59
Added:
started_at:Recovery.timestamp ->
60
Added:
ended_at:Recovery.timestamp ->
61
Added:
sets:source_set list ->
62
Added:
(workout, error) result
63
Added:
(** Builds one imported workout. Returns [Error Invalid_time] when [ended_at]
64
Added:
precedes [started_at]. The sets are retained in the given order. *)
65
Added:
66
Added:
val make_batch :
67
Added:
id:batch_id ->
68
Added:
source:string ->
69
Added:
fingerprint:string ->
70
Added:
imported_at:Recovery.timestamp ->
71
Added:
workouts:workout list ->
72
Added:
warnings:warning list ->
73
Added:
batch
74
Added:
(** Builds a batch. [source] is the complete immutable export text. *)
75
Added:
76
Added:
(** {1 Accessors} *)
77
Added:
78
Added:
val batch_identity : batch -> batch_id
79
Added:
80
Added:
val batch_source : batch -> string
81
Added:
(** The complete immutable export text, exactly as imported. *)
82
Added:
83
Added:
val batch_fingerprint : batch -> string
84
Added:
val batch_imported_at : batch -> Recovery.timestamp
85
Added:
val batch_workouts : batch -> workout list
86
Added:
val batch_warnings : batch -> warning list
87
Added:
val find_workout : batch -> workout_id -> workout option
88
Added:
val workout_identity : workout -> workout_id
89
Added:
val workout_title : workout -> string
90
Added:
val workout_started_at : workout -> Recovery.timestamp
91
Added:
val workout_ended_at : workout -> Recovery.timestamp
92
Added:
93
Added:
val workout_sets : workout -> source_set list
94
Added:
(** The source sets in source order. *)
95
Added:
96
Added:
val workout_revision : workout -> int
97
Added:
(** Starts at zero. Each change to a mapping or the acknowledgement increments
98
Added:
it. Promotion of an earlier revision is stale. *)
99
Added:
100
Added:
val workout_acknowledgement : workout -> string option
101
Added:
(** The retained workout-level acknowledgement, when present. *)
102
Added:
103
Added:
val workout_mapping : workout -> source_name:string -> Exercise.t option
104
Added:
(** The catalog entry a source exercise name is mapped to, when confirmed. *)
105
Added:
106
Added:
(** {1 Cleaning} *)
107
Added:
108
Added:
val map_exercise :
109
Added:
batch ->
110
Added:
workout:workout_id ->
111
Added:
source_name:string ->
112
Added:
Exercise.t ->
113
Added:
(batch, error) result
114
Added:
(** Confirms that [source_name] denotes [Exercise.t] for every matching set in
115
Added:
the workout. Increments the workout revision only when the mapping changed.
116
Added:
Returns [Error (Unknown_workout _)] for an unknown workout. *)
117
Added:
118
Added:
val confirm_normal_sets :
119
Added:
batch -> workout:workout_id -> acknowledgement:string -> (batch, error) result
120
Added:
(** Records the one workout-level acknowledgement that promotes the workout's
121
Added:
[Normal] sets. The exact acknowledgement is retained. Whitespace-only
122
Added:
acknowledgements are rejected with [Error Empty_acknowledgement]. Increments
123
Added:
the workout revision only when the acknowledgement changed. *)
124
Added:
125
Added:
(** {1 Blockers} *)
126
Added:
127
Added:
(** Why a workout cannot promote yet. *)
128
Added:
type blocker =
129
Added:
| No_sets
130
Added:
| Dropset_present of int list (** The source rows of the dropsets. *)
131
Added:
| Unmapped_exercise of string
132
Added:
(** An export exercise name without a mapping. *)
133
Added:
| Normal_sets_unconfirmed
134
Added:
135
Added:
val blockers : workout -> blocker list
136
Added:
(** The workout's blockers, in a deterministic order: [No_sets] first, then one
137
Added:
[Dropset_present] with every dropset row, then one [Unmapped_exercise] per
138
Added:
distinct unmapped name in first-appearance order, then
139
Added:
[Normal_sets_unconfirmed]. An empty list means the workout can promote. *)
140
Added:
141
Added:
(** {1 Promotion} *)
142
Added:
143
Added:
type provenance = {
144
Added:
batch_id : batch_id;
145
Added:
workout_id : workout_id;
146
Added:
source_rows : int list; (** The promoted rows, in source order. *)
147
Added:
normal_confirmation : string option;
148
Added:
(** The acknowledgement, present when the workout had [Normal] sets. *)
149
Added:
revision : int; (** The workout revision this promotion reflects. *)
150
Added:
}
151
Added:
152
Added:
type promoted = {
153
Added:
title : string;
154
Added:
started_at : Recovery.timestamp;
155
Added:
ended_at : Recovery.timestamp;
156
Added:
observations : Evidence.Observation.t list;
157
Added:
(** One per promoted source set, in source order. *)
158
Added:
provenance : provenance;
159
Added:
}
160
Added:
161
Added:
type workout_result =
162
Added:
| Promoted of promoted
163
Added:
| Already_promoted of promoted
164
Added:
(** The workout was already promoted at its current revision. *)
165
Added:
| Blocked of blocker list
166
Added:
167
Added:
type batch_result = {
168
Added:
promoted : (workout_id * promoted) list;
169
Added:
already_promoted : (workout_id * promoted) list;
170
Added:
blocked : (workout_id * blocker list) list;
171
Added:
}
172
Added:
173
Added:
val promote_workout :
174
Added:
batch -> workout_id -> (batch * workout_result, error) result
175
Added:
(** Promotes one workout, atomically. When the workout has no blockers, every
176
Added:
source set becomes one {!Evidence.Observation} at {!Positive_failure},
177
Added:
performed at the workout's [ended_at], and the promotion is stored on the
178
Added:
returned batch. A workout already promoted at its current revision yields
179
Added:
[Already_promoted]; a later revision re-promotes and replaces the stored
180
Added:
data. A blocked workout yields [Blocked] and the batch is unchanged. Returns
181
Added:
[Error (Unknown_workout _)] for an unknown workout. *)
182
Added:
183
Added:
val promote_batch : batch -> batch * batch_result
184
Added:
(** Promotes every workout in the batch. Promotion is partial: each workout is
185
Added:
promoted, already promoted, or blocked independently, and a blocked workout
186
Added:
never prevents another from promoting. *)
test/dune
@@ -9,6 +9,7 @@
9
9
test_evidence
10
10
test_progression
11
11
test_service
12
Added:
test_workout_import
12
13
test_codec
13
14
test_bcrypt_hash
14
15
test_sqlite_repo
test/test_hito.ml
@@ -9,5 +9,6 @@
9
9
Alcotest.run "hito"
10
10
(Test_architecture.suite @ Test_exercise.suite @ Test_recovery.suite
11
11
@ Test_prescription.suite @ Test_evidence.suite @ Test_progression.suite
12
Removed:
@ Test_service.suite @ Test_codec.suite @ Test_bcrypt_hash.suite
13
Removed:
@ Test_sqlite_repo.suite @ Test_decode.suite @ Test_web.suite)
12
Added:
@ Test_service.suite @ Test_workout_import.suite @ Test_codec.suite
13
Added:
@ Test_bcrypt_hash.suite @ Test_sqlite_repo.suite @ Test_decode.suite
14
Added:
@ Test_web.suite)
test/test_workout_import.ml
@@ -0,0 +1,531 @@
1
Added:
(** Unit tests for {!Workout_import}, authored against the workout_import.mli
2
Added:
contract.
3
Added:
4
Added:
The import use case must feed the core only confirmed, catalogued,
5
Added:
failure-defined evidence. These tests exercise that gate: failure sets
6
Added:
promote after mapping, normal sets wait for one acknowledgement, dropsets
7
Added:
block the whole workout, blockers are deterministic, promotion is atomic per
8
Added:
workout and partial per batch, and a promoted workout has its own revisioned
9
Added:
lifecycle. *)
10
Added:
11
Added:
open Hito_app
12
Added:
module WI = Workout_import
13
Added:
module Observation = Evidence.Observation
14
Added:
module Effort = Evidence.Stimulus.Effort
15
Added:
16
Added:
let get id =
17
Added:
match Exercise.find_id id with
18
Added:
| Some e -> e
19
Added:
| None -> Alcotest.failf "catalog is missing %S" id
20
Added:
21
Added:
let at s = Recovery.timestamp_of_unix_seconds s
22
Added:
let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
23
Added:
24
Added:
let set ?(row = 0) ?(set_index = 1) ?(superset_id = None) ~name ~kind ~kg ~reps
25
Added:
() : WI.source_set =
26
Added:
{
27
Added:
row;
28
Added:
exercise_name = name;
29
Added:
set_index;
30
Added:
set_type = kind;
31
Added:
weight_kg = kg;
32
Added:
reps;
33
Added:
superset_id;
34
Added:
}
35
Added:
36
Added:
let workout ~id ~sets =
37
Added:
ok
38
Added:
(WI.make_workout ~id:(WI.workout_id id) ~title:"Chest"
39
Added:
~started_at:(at 1_000) ~ended_at:(at 4_600) ~sets)
40
Added:
41
Added:
let batch ?(warnings = []) workouts =
42
Added:
WI.make_batch ~id:(WI.batch_id "b1") ~source:"raw,csv,text" ~fingerprint:"fp"
43
Added:
~imported_at:(at 5_000) ~workouts ~warnings
44
Added:
45
Added:
(* A blocker rendered as a stable string, so tests can assert order and
46
Added:
content without depending on structural equality. *)
47
Added:
let show_blocker : WI.blocker -> string = function
48
Added:
| No_sets -> "no_sets"
49
Added:
| Dropset_present rows ->
50
Added:
"dropset:" ^ String.concat "," (List.map string_of_int rows)
51
Added:
| Unmapped_exercise name -> "unmapped:" ^ name
52
Added:
| Normal_sets_unconfirmed -> "normal_unconfirmed"
53
Added:
54
Added:
let blocker_list = Alcotest.(list string)
55
Added:
let blockers_of w = List.map show_blocker (WI.blockers w)
56
Added:
let wid = WI.workout_id
57
Added:
58
Added:
(* {1 Failure sets promote after mapping, without acknowledgement} *)
59
Added:
60
Added:
let failure_promotes_without_acknowledgement =
61
Added:
( "a failure set promotes after mapping, no acknowledgement needed",
62
Added:
`Quick,
63
Added:
fun () ->
64
Added:
let s =
65
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
66
Added:
in
67
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
68
Added:
let b =
69
Added:
ok
70
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
71
Added:
(get "squats"))
72
Added:
in
73
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
74
Added:
Alcotest.(check blocker_list) "no blockers" [] (blockers_of w);
75
Added:
let _, result = ok (WI.promote_workout b (wid "w1")) in
76
Added:
match result with
77
Added:
| WI.Promoted p ->
78
Added:
Alcotest.(check int) "one observation" 1 (List.length p.observations);
79
Added:
Alcotest.(check (option string))
80
Added:
"no normal confirmation" None p.provenance.normal_confirmation
81
Added:
| _ -> Alcotest.fail "expected Promoted" )
82
Added:
83
Added:
(* {1 Normal sets block until a workout-level acknowledgement} *)
84
Added:
85
Added:
let normal_blocks_until_confirmed =
86
Added:
( "a normal set blocks until the workout-level acknowledgement",
87
Added:
`Quick,
88
Added:
fun () ->
89
Added:
let s =
90
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 ()
91
Added:
in
92
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
93
Added:
let b =
94
Added:
ok
95
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
96
Added:
(get "squats"))
97
Added:
in
98
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
99
Added:
Alcotest.(check blocker_list)
100
Added:
"blocked on confirmation" [ "normal_unconfirmed" ] (blockers_of w);
101
Added:
(match WI.promote_workout b (wid "w1") with
102
Added:
| Ok (_, WI.Blocked _) -> ()
103
Added:
| _ -> Alcotest.fail "expected Blocked before confirmation");
104
Added:
let b =
105
Added:
ok
106
Added:
(WI.confirm_normal_sets b ~workout:(wid "w1")
107
Added:
~acknowledgement:"I confirm these were to failure")
108
Added:
in
109
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
110
Added:
Alcotest.(check blocker_list)
111
Added:
"no blockers after confirmation" [] (blockers_of w);
112
Added:
Alcotest.(check (option string))
113
Added:
"exact acknowledgement retained"
114
Added:
(Some "I confirm these were to failure")
115
Added:
(WI.workout_acknowledgement w);
116
Added:
let _, result = ok (WI.promote_workout b (wid "w1")) in
117
Added:
match result with
118
Added:
| WI.Promoted p ->
119
Added:
Alcotest.(check (option string))
120
Added:
"confirmation recorded in provenance"
121
Added:
(Some "I confirm these were to failure")
122
Added:
p.provenance.normal_confirmation
123
Added:
| _ -> Alcotest.fail "expected Promoted after confirmation" )
124
Added:
125
Added:
let empty_acknowledgement_rejected =
126
Added:
( "a whitespace-only acknowledgement is rejected",
127
Added:
`Quick,
128
Added:
fun () ->
129
Added:
let s = set ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 () in
130
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
131
Added:
match
132
Added:
WI.confirm_normal_sets b ~workout:(wid "w1") ~acknowledgement:" "
133
Added:
with
134
Added:
| Error WI.Empty_acknowledgement -> ()
135
Added:
| _ -> Alcotest.fail "expected Empty_acknowledgement" )
136
Added:
137
Added:
(* {1 A dropset blocks the whole workout} *)
138
Added:
139
Added:
let dropset_blocks_whole_workout =
140
Added:
( "a dropset blocks the workout even beside a promotable failure set",
141
Added:
`Quick,
142
Added:
fun () ->
143
Added:
let good =
144
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
145
Added:
in
146
Added:
let drop =
147
Added:
set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 ()
148
Added:
in
149
Added:
let b = batch [ workout ~id:"w1" ~sets:[ good; drop ] ] in
150
Added:
let b =
151
Added:
ok
152
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
153
Added:
(get "squats"))
154
Added:
in
155
Added:
let b =
156
Added:
ok
157
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Leg Press"
158
Added:
(get "leg-presses"))
159
Added:
in
160
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
161
Added:
Alcotest.(check bool)
162
Added:
"dropset blocker present" true
163
Added:
(List.exists
164
Added:
(function WI.Dropset_present _ -> true | _ -> false)
165
Added:
(WI.blockers w));
166
Added:
match WI.promote_workout b (wid "w1") with
167
Added:
| Ok (_, WI.Blocked _) -> ()
168
Added:
| _ -> Alcotest.fail "expected Blocked while a dropset is present" )
169
Added:
170
Added:
(* {1 No sets is its own blocker} *)
171
Added:
172
Added:
let no_sets_blocker =
173
Added:
( "a workout with no sets reports No_sets",
174
Added:
`Quick,
175
Added:
fun () ->
176
Added:
let w = workout ~id:"w1" ~sets:[] in
177
Added:
Alcotest.(check blocker_list) "no_sets" [ "no_sets" ] (blockers_of w) )
178
Added:
179
Added:
(* {1 Mapping is by name and covers all matching sets} *)
180
Added:
181
Added:
let mapping_covers_all_matching_sets =
182
Added:
( "one mapping covers every set that shares the source name",
183
Added:
`Quick,
184
Added:
fun () ->
185
Added:
let s1 =
186
Added:
set ~row:1 ~set_index:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100.
187
Added:
~reps:8 ()
188
Added:
in
189
Added:
let s2 =
190
Added:
set ~row:2 ~set_index:2 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100.
191
Added:
~reps:6 ()
192
Added:
in
193
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s1; s2 ] ] in
194
Added:
(* Before mapping, the single distinct name is the only blocker. *)
195
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
196
Added:
Alcotest.(check blocker_list)
197
Added:
"one unmapped name"
198
Added:
[ "unmapped:Barbell Squat" ]
199
Added:
(blockers_of w);
200
Added:
let b =
201
Added:
ok
202
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
203
Added:
(get "squats"))
204
Added:
in
205
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
206
Added:
Alcotest.(check blocker_list)
207
Added:
"both sets covered by one mapping" [] (blockers_of w);
208
Added:
let _, result = ok (WI.promote_workout b (wid "w1")) in
209
Added:
match result with
210
Added:
| WI.Promoted p ->
211
Added:
Alcotest.(check int)
212
Added:
"two observations from two sets" 2
213
Added:
(List.length p.observations)
214
Added:
| _ -> Alcotest.fail "expected Promoted" )
215
Added:
216
Added:
let every_distinct_name_must_be_mapped =
217
Added:
( "every distinct source name must be mapped before promotion",
218
Added:
`Quick,
219
Added:
fun () ->
220
Added:
let s1 =
221
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
222
Added:
in
223
Added:
let s2 =
224
Added:
set ~row:2 ~name:"Leg Press" ~kind:WI.Failure ~kg:200. ~reps:10 ()
225
Added:
in
226
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s1; s2 ] ] in
227
Added:
let b =
228
Added:
ok
229
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
230
Added:
(get "squats"))
231
Added:
in
232
Added:
let w = Option.get (WI.find_workout b (wid "w1")) in
233
Added:
Alcotest.(check blocker_list)
234
Added:
"still blocked on the unmapped name" [ "unmapped:Leg Press" ]
235
Added:
(blockers_of w) )
236
Added:
237
Added:
(* {1 Deterministic blocker order} *)
238
Added:
239
Added:
let deterministic_blocker_order =
240
Added:
( "blockers appear in dropset, unmapped (first appearance), normal order",
241
Added:
`Quick,
242
Added:
fun () ->
243
Added:
let drop =
244
Added:
set ~row:3 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 ()
245
Added:
in
246
Added:
let normal =
247
Added:
set ~row:1 ~name:"Bench Press" ~kind:WI.Normal ~kg:80. ~reps:8 ()
248
Added:
in
249
Added:
let other =
250
Added:
set ~row:2 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:6 ()
251
Added:
in
252
Added:
(* Order of sets fixes first-appearance order of names. *)
253
Added:
let w = workout ~id:"w1" ~sets:[ normal; other; drop ] in
254
Added:
Alcotest.(check blocker_list)
255
Added:
"dropset, then names by first appearance, then normal"
256
Added:
[
257
Added:
"dropset:3";
258
Added:
"unmapped:Bench Press";
259
Added:
"unmapped:Barbell Squat";
260
Added:
"unmapped:Leg Press";
261
Added:
"normal_unconfirmed";
262
Added:
]
263
Added:
(blockers_of w) )
264
Added:
265
Added:
(* {1 Promotion is atomic per workout and partial per batch} *)
266
Added:
267
Added:
let batch_promotes_partially =
268
Added:
( "a blocked workout does not prevent another from promoting",
269
Added:
`Quick,
270
Added:
fun () ->
271
Added:
let good =
272
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
273
Added:
in
274
Added:
let drop =
275
Added:
set ~row:2 ~name:"Leg Press" ~kind:WI.Dropset ~kg:200. ~reps:12 ()
276
Added:
in
277
Added:
let b =
278
Added:
batch
279
Added:
[ workout ~id:"w1" ~sets:[ good ]; workout ~id:"w2" ~sets:[ drop ] ]
280
Added:
in
281
Added:
let b =
282
Added:
ok
283
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
284
Added:
(get "squats"))
285
Added:
in
286
Added:
let b =
287
Added:
ok
288
Added:
(WI.map_exercise b ~workout:(wid "w2") ~source_name:"Leg Press"
289
Added:
(get "leg-presses"))
290
Added:
in
291
Added:
let _, result = WI.promote_batch b in
292
Added:
Alcotest.(check int) "one promoted" 1 (List.length result.promoted);
293
Added:
Alcotest.(check int) "one blocked" 1 (List.length result.blocked);
294
Added:
Alcotest.(check string)
295
Added:
"w1 promoted" "w1"
296
Added:
(WI.workout_id_to_string (fst (List.hd result.promoted)));
297
Added:
Alcotest.(check string)
298
Added:
"w2 blocked" "w2"
299
Added:
(WI.workout_id_to_string (fst (List.hd result.blocked))) )
300
Added:
301
Added:
let blocked_workout_promotes_after_cleaning =
302
Added:
( "a blocked workout stays provisional and promotes after later cleaning",
303
Added:
`Quick,
304
Added:
fun () ->
305
Added:
let s =
306
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Normal ~kg:100. ~reps:8 ()
307
Added:
in
308
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
309
Added:
let b =
310
Added:
ok
311
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
312
Added:
(get "squats"))
313
Added:
in
314
Added:
(* Still blocked: the normal set is unconfirmed. *)
315
Added:
let b, result = WI.promote_batch b in
316
Added:
Alcotest.(check int)
317
Added:
"blocked before confirmation" 1
318
Added:
(List.length result.blocked);
319
Added:
let b =
320
Added:
ok
321
Added:
(WI.confirm_normal_sets b ~workout:(wid "w1")
322
Added:
~acknowledgement:"confirmed")
323
Added:
in
324
Added:
let _, result = WI.promote_batch b in
325
Added:
Alcotest.(check int)
326
Added:
"promoted after confirmation" 1
327
Added:
(List.length result.promoted) )
328
Added:
329
Added:
(* {1 A promoted workout has its own revisioned lifecycle} *)
330
Added:
331
Added:
let repeated_promotion_is_already_promoted =
332
Added:
( "promoting an unchanged workout twice yields Already_promoted",
333
Added:
`Quick,
334
Added:
fun () ->
335
Added:
let s =
336
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
337
Added:
in
338
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
339
Added:
let b =
340
Added:
ok
341
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
342
Added:
(get "squats"))
343
Added:
in
344
Added:
let b, first = ok (WI.promote_workout b (wid "w1")) in
345
Added:
(match first with
346
Added:
| WI.Promoted _ -> ()
347
Added:
| _ -> Alcotest.fail "expected Promoted on first promotion");
348
Added:
let _, second = ok (WI.promote_workout b (wid "w1")) in
349
Added:
match second with
350
Added:
| WI.Already_promoted _ -> ()
351
Added:
| _ -> Alcotest.fail "expected Already_promoted on repeat" )
352
Added:
353
Added:
let editing_increments_revision_and_replaces =
354
Added:
( "editing a mapping increments the revision and re-promotes a replacement",
355
Added:
`Quick,
356
Added:
fun () ->
357
Added:
let s =
358
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
359
Added:
in
360
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
361
Added:
let b =
362
Added:
ok
363
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
364
Added:
(get "squats"))
365
Added:
in
366
Added:
let rev0 =
367
Added:
WI.workout_revision (Option.get (WI.find_workout b (wid "w1")))
368
Added:
in
369
Added:
let b, _ = ok (WI.promote_workout b (wid "w1")) in
370
Added:
(* Re-map the same name to a different exercise: a real change. *)
371
Added:
let b =
372
Added:
ok
373
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
374
Added:
(get "leg-presses"))
375
Added:
in
376
Added:
let rev1 =
377
Added:
WI.workout_revision (Option.get (WI.find_workout b (wid "w1")))
378
Added:
in
379
Added:
Alcotest.(check bool) "revision advanced" true (rev1 > rev0);
380
Added:
let _, result = ok (WI.promote_workout b (wid "w1")) in
381
Added:
match result with
382
Added:
| WI.Promoted p ->
383
Added:
Alcotest.(check int)
384
Added:
"replacement carries the new revision" rev1 p.provenance.revision;
385
Added:
Alcotest.(check string)
386
Added:
"replacement observation uses the new exercise" "Leg Presses"
387
Added:
(Exercise.name (Observation.exercise (List.hd p.observations)))
388
Added:
| _ -> Alcotest.fail "expected a re-promoted replacement" )
389
Added:
390
Added:
let idempotent_mapping_keeps_revision =
391
Added:
( "re-confirming the same mapping does not increment the revision",
392
Added:
`Quick,
393
Added:
fun () ->
394
Added:
let s =
395
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
396
Added:
in
397
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
398
Added:
let b =
399
Added:
ok
400
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
401
Added:
(get "squats"))
402
Added:
in
403
Added:
let rev0 =
404
Added:
WI.workout_revision (Option.get (WI.find_workout b (wid "w1")))
405
Added:
in
406
Added:
let b =
407
Added:
ok
408
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
409
Added:
(get "squats"))
410
Added:
in
411
Added:
let rev1 =
412
Added:
WI.workout_revision (Option.get (WI.find_workout b (wid "w1")))
413
Added:
in
414
Added:
Alcotest.(check int) "revision unchanged" rev0 rev1 )
415
Added:
416
Added:
(* {1 Promoted observations carry the expected effort} *)
417
Added:
418
Added:
let observations_carry_expected_effort =
419
Added:
( "each observation carries the mapped exercise, load, reps, failure, and end",
420
Added:
`Quick,
421
Added:
fun () ->
422
Added:
let s =
423
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:102.5 ~reps:9 ()
424
Added:
in
425
Added:
let b = batch [ workout ~id:"w1" ~sets:[ s ] ] in
426
Added:
let b =
427
Added:
ok
428
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
429
Added:
(get "squats"))
430
Added:
in
431
Added:
let _, result = ok (WI.promote_workout b (wid "w1")) in
432
Added:
match result with
433
Added:
| WI.Promoted p ->
434
Added:
let obs = List.hd p.observations in
435
Added:
let effort = Observation.effort obs in
436
Added:
Alcotest.(check string)
437
Added:
"exercise" "Squats"
438
Added:
(Exercise.name (Observation.exercise obs));
439
Added:
Alcotest.(check (float 0.001)) "load" 102.5 (Effort.load effort);
440
Added:
Alcotest.(check int) "reps" 9 (Effort.reps effort);
441
Added:
Alcotest.(check bool)
442
Added:
"outcome is positive failure" true
443
Added:
(match Effort.outcome effort with
444
Added:
| Evidence.Stimulus.Positive_failure -> true
445
Added:
| _ -> false);
446
Added:
Alcotest.(check int)
447
Added:
"performed at the workout end"
448
Added:
(Recovery.timestamp_to_unix_seconds (at 4_600))
449
Added:
(Recovery.timestamp_to_unix_seconds (Observation.performed_at obs))
450
Added:
| _ -> Alcotest.fail "expected Promoted" )
451
Added:
452
Added:
(* {1 The raw source and warnings are immutable and retained} *)
453
Added:
454
Added:
let source_and_warnings_retained =
455
Added:
( "the raw source and warnings survive cleaning unchanged",
456
Added:
`Quick,
457
Added:
fun () ->
458
Added:
let warnings : WI.warning list =
459
Added:
[
460
Added:
{ row = 5; exercise_name = Some "Warmup"; kind = WI.Warmup_dropped };
461
Added:
{
462
Added:
row = 6;
463
Added:
exercise_name = None;
464
Added:
kind = WI.Unsupported_row "blank line";
465
Added:
};
466
Added:
]
467
Added:
in
468
Added:
let s =
469
Added:
set ~row:1 ~name:"Barbell Squat" ~kind:WI.Failure ~kg:100. ~reps:8 ()
470
Added:
in
471
Added:
let b = batch ~warnings [ workout ~id:"w1" ~sets:[ s ] ] in
472
Added:
let b =
473
Added:
ok
474
Added:
(WI.map_exercise b ~workout:(wid "w1") ~source_name:"Barbell Squat"
475
Added:
(get "squats"))
476
Added:
in
477
Added:
let b, _ = WI.promote_batch b in
478
Added:
Alcotest.(check string)
479
Added:
"raw source unchanged" "raw,csv,text" (WI.batch_source b);
480
Added:
Alcotest.(check int)
481
Added:
"warnings retained" 2
482
Added:
(List.length (WI.batch_warnings b));
483
Added:
Alcotest.(check bool)
484
Added:
"a warmup warning is present" true
485
Added:
(List.exists
486
Added:
(fun (w : WI.warning) -> w.kind = WI.Warmup_dropped)
487
Added:
(WI.batch_warnings b)) )
488
Added:
489
Added:
let unknown_workout_is_an_error =
490
Added:
( "operations on an unknown workout return Unknown_workout",
491
Added:
`Quick,
492
Added:
fun () ->
493
Added:
let b = batch [] in
494
Added:
match WI.promote_workout b (wid "nope") with
495
Added:
| Error (WI.Unknown_workout _) -> ()
496
Added:
| _ -> Alcotest.fail "expected Unknown_workout" )
497
Added:
498
Added:
let invalid_time_rejected =
499
Added:
( "a workout whose end precedes its start is rejected",
500
Added:
`Quick,
501
Added:
fun () ->
502
Added:
match
503
Added:
WI.make_workout ~id:(wid "w1") ~title:"Chest" ~started_at:(at 4_600)
504
Added:
~ended_at:(at 1_000) ~sets:[]
505
Added:
with
506
Added:
| Error WI.Invalid_time -> ()
507
Added:
| _ -> Alcotest.fail "expected Invalid_time" )
508
Added:
509
Added:
let suite =
510
Added:
[
511
Added:
( "Workout_import",
512
Added:
[
513
Added:
failure_promotes_without_acknowledgement;
514
Added:
normal_blocks_until_confirmed;
515
Added:
empty_acknowledgement_rejected;
516
Added:
dropset_blocks_whole_workout;
517
Added:
no_sets_blocker;
518
Added:
mapping_covers_all_matching_sets;
519
Added:
every_distinct_name_must_be_mapped;
520
Added:
deterministic_blocker_order;
521
Added:
batch_promotes_partially;
522
Added:
blocked_workout_promotes_after_cleaning;
523
Added:
repeated_promotion_is_already_promoted;
524
Added:
editing_increments_revision_and_replaces;
525
Added:
idempotent_mapping_keeps_revision;
526
Added:
observations_carry_expected_effort;
527
Added:
source_and_warnings_retained;
528
Added:
unknown_workout_is_an_error;
529
Added:
invalid_time_rejected;
530
Added:
] );
531
Added:
]