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.

Commit
1ff7131dc6f4909d7fdf47f26bb22669c8f61a46
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/app/workout_import.ml
index 00000000..4876b12a 000000..100644
@@ -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
index 00000000..da701267 000000..100644
@@ -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
index 90dae6bd..8d3c529d 100644..100644
@@ -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
index e122d9a4..bee3eab1 100644..100644
@@ -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
index 00000000..835b37cf 000000..100644
@@ -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: ]