[OCaml] High Intensity Training Online
1
(** Tests for {!Hito_app.Service} — the whole HD flow with no web tier, now
2
scoped to a trainee and driven over Lwt against the in-memory repository. *)
3
4
open Hito_app
5
module S = Service.Make (Memory_repo)
6
module Stimulus = Evidence.Stimulus
7
module Workout = Evidence.Workout
8
9
let run = Lwt_main.run
10
let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
11
12
let get id =
13
match Exercise.find_id id with
14
| Some e -> e
15
| None -> Alcotest.failf "catalog is missing %S" id
16
17
let load n = n
18
let reps n = n
19
let at s = Recovery.timestamp_of_unix_seconds s
20
let day n = at (n * 86_400)
21
let ideal = Repository.routine_id "ideal"
22
23
(* A fresh service and a registered trainee to own everything. *)
24
let fixture () =
25
let s = S.make ~repo:(Memory_repo.create ()) in
26
let trainee =
27
ok (run (S.register s ~username:"lifter" ~password:"heavyduty1"))
28
in
29
(s, trainee.Trainee.id)
30
31
let move id load_kg rep_count =
32
Stimulus.Effort.make ~exercise:(get id) ~load:(load load_kg)
33
~reps:(reps rep_count) ~outcome:Stimulus.Positive_failure
34
35
let single id load r = Stimulus.make (Stimulus.Single (move id load r))
36
let pair ~first ~second = Stimulus.make (Stimulus.Pair { first; second })
37
38
(* HD1's Day 1 as performed. *)
39
let day_one_stimuli =
40
[
41
pair
42
~first:(move "dumbbell-flyes" 20. 9)
43
~second:(move "incline-press" 60. 7);
44
single "laterals" 12. 8;
45
single "bent-over-laterals" 10. 9;
46
pair ~first:(move "lying-french-press" 30. 8) ~second:(move "dips" 0. 6);
47
]
48
49
let account_tests =
50
[
51
( "registration then authentication with the same password",
52
`Quick,
53
fun () ->
54
let s = S.make ~repo:(Memory_repo.create ()) in
55
let trainee =
56
ok (run (S.register s ~username:"alice" ~password:"heavyduty1"))
57
in
58
match
59
run (S.authenticate s ~username:"alice" ~password:"heavyduty1")
60
with
61
| Some found ->
62
Alcotest.(check string)
63
"same id"
64
(Trainee.id_to_string trainee.Trainee.id)
65
(Trainee.id_to_string found.Trainee.id)
66
| None -> Alcotest.fail "expected authentication to succeed" );
67
( "the wrong password does not authenticate",
68
`Quick,
69
fun () ->
70
let s = S.make ~repo:(Memory_repo.create ()) in
71
let _ =
72
ok (run (S.register s ~username:"alice" ~password:"heavyduty1"))
73
in
74
Alcotest.(check bool)
75
"rejected" true
76
(Option.is_none
77
(run (S.authenticate s ~username:"alice" ~password:"wrong"))) );
78
( "a duplicate username is refused after normalization",
79
`Quick,
80
fun () ->
81
let s = S.make ~repo:(Memory_repo.create ()) in
82
let _ =
83
ok (run (S.register s ~username:"alice" ~password:"heavyduty1"))
84
in
85
(* Trimming and lowercasing make " ALICE " the same username. *)
86
match
87
run (S.register s ~username:" ALICE " ~password:"another11")
88
with
89
| Error `Username_taken -> ()
90
| _ -> Alcotest.fail "expected Username_taken" );
91
( "a normalized username authenticates regardless of case or spacing",
92
`Quick,
93
fun () ->
94
let s = S.make ~repo:(Memory_repo.create ()) in
95
let _ =
96
ok (run (S.register s ~username:"alice" ~password:"heavyduty1"))
97
in
98
Alcotest.(check bool)
99
"case- and space-insensitive login" true
100
(Option.is_some
101
(run
102
(S.authenticate s ~username:" Alice " ~password:"heavyduty1")))
103
);
104
( "a username under four characters is refused",
105
`Quick,
106
fun () ->
107
let s = S.make ~repo:(Memory_repo.create ()) in
108
match run (S.register s ~username:"abc" ~password:"heavyduty1") with
109
| Error (`Username Trainee.Too_short) -> ()
110
| _ -> Alcotest.fail "expected Too_short" );
111
( "a four-character username is accepted",
112
`Quick,
113
fun () ->
114
let s = S.make ~repo:(Memory_repo.create ()) in
115
match run (S.register s ~username:"abcd" ~password:"heavyduty1") with
116
| Ok _ -> ()
117
| Error _ ->
118
Alcotest.fail "expected the boundary username to be accepted" );
119
( "a twenty-character username is accepted",
120
`Quick,
121
fun () ->
122
let s = S.make ~repo:(Memory_repo.create ()) in
123
match
124
run (S.register s ~username:"abcdefghijklmnopqrst" ~password:"pw")
125
with
126
| Ok _ -> ()
127
| Error _ ->
128
Alcotest.fail "expected the boundary username to be accepted" );
129
( "a username over twenty characters is refused",
130
`Quick,
131
fun () ->
132
let s = S.make ~repo:(Memory_repo.create ()) in
133
match
134
run (S.register s ~username:"abcdefghijklmnopqrstu" ~password:"pw")
135
with
136
| Error (`Username Trainee.Too_long) -> ()
137
| _ -> Alcotest.fail "expected Too_long" );
138
( "surrounding whitespace does not count toward the length bounds",
139
`Quick,
140
fun () ->
141
let s = S.make ~repo:(Memory_repo.create ()) in
142
(* " abcd " normalizes to the four-character "abcd". *)
143
match run (S.register s ~username:" abcd " ~password:"pw") with
144
| Ok trainee ->
145
Alcotest.(check string)
146
"trimmed and lowercased" "abcd"
147
(Trainee.username_to_string trainee.Trainee.username)
148
| Error _ ->
149
Alcotest.fail "expected the trimmed username to be accepted" );
150
( "a short password is accepted",
151
`Quick,
152
fun () ->
153
let s = S.make ~repo:(Memory_repo.create ()) in
154
match run (S.register s ~username:"alice" ~password:"x") with
155
| Ok _ -> ()
156
| Error _ -> Alcotest.fail "expected a short password to be accepted" );
157
( "an empty password is accepted",
158
`Quick,
159
fun () ->
160
let s = S.make ~repo:(Memory_repo.create ()) in
161
match run (S.register s ~username:"bobby" ~password:"") with
162
| Ok _ -> ()
163
| Error _ -> Alcotest.fail "expected an empty password to be accepted"
164
);
165
( "a short password still authenticates",
166
`Quick,
167
fun () ->
168
let s = S.make ~repo:(Memory_repo.create ()) in
169
let _ = ok (run (S.register s ~username:"carol" ~password:"x")) in
170
Alcotest.(check bool)
171
"signs in" true
172
(Option.is_some
173
(run (S.authenticate s ~username:"carol" ~password:"x"))) );
174
( "two trainees keep separate logs",
175
`Quick,
176
fun () ->
177
let s = S.make ~repo:(Memory_repo.create ()) in
178
let a =
179
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
180
.Trainee.id
181
in
182
let b =
183
(ok (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
184
.Trainee.id
185
in
186
let _ = ok (run (S.begin_workout s a ~routine:ideal ~now:(day 1) ())) in
187
let _ = run (S.finish s a ~ended_at:(day 1)) in
188
Alcotest.(check int) "a has one" 1 (List.length (run (S.history s a)));
189
Alcotest.(check int) "b has none" 0 (List.length (run (S.history s b)))
190
);
191
( "changing the username renames the account",
192
`Quick,
193
fun () ->
194
let s = S.make ~repo:(Memory_repo.create ()) in
195
let id =
196
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
197
.Trainee.id
198
in
199
(match run (S.change_username s id ~username:"alicia") with
200
| Ok updated ->
201
Alcotest.(check string)
202
"renamed" "alicia"
203
(Trainee.username_to_string updated.Trainee.username)
204
| Error _ -> Alcotest.fail "expected the rename to succeed");
205
(* The old name is free; the new name resolves the same account. *)
206
match run (S.find_trainee s id) with
207
| Some found ->
208
Alcotest.(check string)
209
"persisted new name" "alicia"
210
(Trainee.username_to_string found.Trainee.username)
211
| None -> Alcotest.fail "account vanished" );
212
( "renaming to the account's own name is a no-op success",
213
`Quick,
214
fun () ->
215
let s = S.make ~repo:(Memory_repo.create ()) in
216
let id =
217
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
218
.Trainee.id
219
in
220
match run (S.change_username s id ~username:" ALICE ") with
221
| Ok updated ->
222
Alcotest.(check string)
223
"kept the normalized name" "alice"
224
(Trainee.username_to_string updated.Trainee.username)
225
| Error _ -> Alcotest.fail "expected a no-op rename to succeed" );
226
( "renaming to another trainee's name is refused",
227
`Quick,
228
fun () ->
229
let s = S.make ~repo:(Memory_repo.create ()) in
230
let a =
231
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
232
.Trainee.id
233
in
234
let _ =
235
ok (run (S.register s ~username:"bobby" ~password:"heavyduty1"))
236
in
237
match run (S.change_username s a ~username:"bobby") with
238
| Error `Username_taken -> ()
239
| _ -> Alcotest.fail "expected Username_taken" );
240
( "an invalid new username is refused",
241
`Quick,
242
fun () ->
243
let s = S.make ~repo:(Memory_repo.create ()) in
244
let id =
245
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
246
.Trainee.id
247
in
248
match run (S.change_username s id ~username:"ab") with
249
| Error (`Username Trainee.Too_short) -> ()
250
| _ -> Alcotest.fail "expected Too_short" );
251
( "changing the password requires the current one",
252
`Quick,
253
fun () ->
254
let s = S.make ~repo:(Memory_repo.create ()) in
255
let id =
256
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
257
.Trainee.id
258
in
259
(match
260
run (S.change_password s id ~current:"wrong" ~next:"newsecret1")
261
with
262
| Error `Incorrect_password -> ()
263
| _ -> Alcotest.fail "expected Incorrect_password");
264
(* The old password still works after a refused change. *)
265
Alcotest.(check bool)
266
"old password still valid" true
267
(Option.is_some
268
(run (S.authenticate s ~username:"alice" ~password:"heavyduty1")))
269
);
270
( "a correct current password changes the password",
271
`Quick,
272
fun () ->
273
let s = S.make ~repo:(Memory_repo.create ()) in
274
let id =
275
(ok (run (S.register s ~username:"alice" ~password:"heavyduty1")))
276
.Trainee.id
277
in
278
(match
279
run (S.change_password s id ~current:"heavyduty1" ~next:"newsecret1")
280
with
281
| Ok _ -> ()
282
| Error _ -> Alcotest.fail "expected the change to succeed");
283
Alcotest.(check bool)
284
"the old password no longer works" true
285
(Option.is_none
286
(run (S.authenticate s ~username:"alice" ~password:"heavyduty1")));
287
Alcotest.(check bool)
288
"the new password works" true
289
(Option.is_some
290
(run (S.authenticate s ~username:"alice" ~password:"newsecret1")))
291
);
292
]
293
294
let routine_tests =
295
[
296
( "the seeded repository offers HD1's Ideal Routine",
297
`Quick,
298
fun () ->
299
let s, _ = fixture () in
300
match S.list_routines s with
301
| [ (_, r) ] ->
302
Alcotest.(check string)
303
"name" "Ideal Routine"
304
(Prescription.Routine.name r)
305
| rs -> Alcotest.failf "expected one routine, got %d" (List.length rs)
306
);
307
( "an unknown routine is refused",
308
`Quick,
309
fun () ->
310
let s, t = fixture () in
311
match
312
run (S.next_workout s t ~routine:(Repository.routine_id "nope"))
313
with
314
| Error S.Unknown_routine -> ()
315
| _ -> Alcotest.fail "expected Unknown_routine" );
316
( "with nothing logged the cycle starts at Day 1",
317
`Quick,
318
fun () ->
319
let s, t = fixture () in
320
let w = ok (run (S.next_workout s t ~routine:ideal)) in
321
Alcotest.(check string) "Day 1" "Day 1" (Prescription.Workout.name w) );
322
]
323
324
let clearance_tests =
325
[
326
( "a first workout needs no recovery: nothing has been done yet",
327
`Quick,
328
fun () ->
329
let s, t = fixture () in
330
Alcotest.(check bool)
331
"ready" true
332
(Recovery.is_ready
333
(ok (run (S.readiness s t ~routine:ideal ~now:(day 1)))));
334
Alcotest.(check bool)
335
"starts" true
336
(Result.is_ok
337
(run (S.begin_workout s t ~routine:ideal ~now:(day 1) ()))) );
338
( "training too soon after a workout is refused",
339
`Quick,
340
fun () ->
341
let s, t = fixture () in
342
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
343
let _ = run (S.finish s t ~ended_at:(day 1)) in
344
match run (S.begin_workout s t ~routine:ideal ~now:(day 2) ()) with
345
| Error (S.Not_recovered readiness) ->
346
Alcotest.(check bool)
347
"and says so" false
348
(Recovery.is_ready readiness)
349
| _ -> Alcotest.fail "expected Not_recovered" );
350
( "once rested, the next workout starts and the cycle has advanced",
351
`Quick,
352
fun () ->
353
let s, t = fixture () in
354
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
355
let _ = run (S.finish s t ~ended_at:(day 1)) in
356
let w = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 3) ())) in
357
Alcotest.(check string)
358
"Day 2" "Day 2"
359
(Prescription.Workout.name (Workout.prescription w)) );
360
( "an override is accepted and recorded",
361
`Quick,
362
fun () ->
363
let s, t = fixture () in
364
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
365
let _ = run (S.finish s t ~ended_at:(day 1)) in
366
let w =
367
ok
368
(run
369
(S.begin_workout s t ~routine:ideal ~now:(day 2) ~override:() ()))
370
in
371
match Recovery.basis (Workout.clearance w) with
372
| Recovery.Overridden _ -> ()
373
| Recovery.Recovered -> Alcotest.fail "expected Overridden" );
374
( "an override while genuinely rested is not recorded as one",
375
`Quick,
376
fun () ->
377
let s, t = fixture () in
378
let w =
379
ok
380
(run
381
(S.begin_workout s t ~routine:ideal ~now:(day 1) ~override:() ()))
382
in
383
match Recovery.basis (Workout.clearance w) with
384
| Recovery.Recovered -> ()
385
| Recovery.Overridden _ ->
386
Alcotest.fail "nothing was outstanding to override" );
387
]
388
389
let logging_tests =
390
[
391
( "logging without a workout in progress is refused",
392
`Quick,
393
fun () ->
394
let s, t = fixture () in
395
match run (S.log s t (single "laterals" 12. 8)) with
396
| Error S.No_workout_in_progress -> ()
397
| _ -> Alcotest.fail "expected No_workout_in_progress" );
398
( "a stimulus the prescription does not call for is refused",
399
`Quick,
400
fun () ->
401
let s, t = fixture () in
402
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
403
match run (S.log s t (single "shrugs" 80. 10)) with
404
| Error (S.Rejected (Workout.Not_prescribed _)) -> ()
405
| _ -> Alcotest.fail "expected Rejected Not_prescribed" );
406
( "Day 1 can be logged in full and finished",
407
`Quick,
408
fun () ->
409
let s, t = fixture () in
410
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
411
List.iter (fun st -> ignore (ok (run (S.log s t st)))) day_one_stimuli;
412
let w = Option.get (run (S.in_progress s t)) in
413
Alcotest.(check int) "four stimuli" 4 (List.length (Workout.stimuli w));
414
Alcotest.(check int)
415
"nothing outstanding" 0
416
(List.length (Workout.unperformed w));
417
let record = Option.get (run (S.finish s t ~ended_at:(at 3600))) in
418
Alcotest.(check bool)
419
"persisted as finished" true
420
(Workout.is_finished record.Repository.workout);
421
Alcotest.(check bool)
422
"slot cleared" true
423
(Option.is_none (run (S.in_progress s t))) );
424
( "history returns the finished workout",
425
`Quick,
426
fun () ->
427
let s, t = fixture () in
428
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
429
let _ = ok (run (S.log s t (single "laterals" 12. 8))) in
430
let _ = run (S.finish s t ~ended_at:(at 3600)) in
431
Alcotest.(check int) "one workout" 1 (List.length (run (S.history s t)))
432
);
433
( "cancelling after recording a set discards it and saves no history",
434
`Quick,
435
fun () ->
436
let s, t = fixture () in
437
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
438
let _ = ok (run (S.log s t (single "laterals" 12. 8))) in
439
Alcotest.(check bool)
440
"a workout was discarded" true
441
(run (S.cancel s t));
442
Alcotest.(check bool)
443
"no workout in progress" true
444
(Option.is_none (run (S.in_progress s t)));
445
Alcotest.(check int)
446
"no history record" 0
447
(List.length (run (S.history s t))) );
448
( "cancelling preserves saved history",
449
`Quick,
450
fun () ->
451
let s, t = fixture () in
452
(* Finish one workout so history is non-empty. *)
453
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
454
let _ = run (S.finish s t ~ended_at:(day 1)) in
455
(* Begin and cancel a second. The saved first workout must remain. *)
456
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 3) ())) in
457
Alcotest.(check bool) "discarded" true (run (S.cancel s t));
458
Alcotest.(check int)
459
"saved history kept" 1
460
(List.length (run (S.history s t)));
461
Alcotest.(check bool)
462
"no workout in progress" true
463
(Option.is_none (run (S.in_progress s t))) );
464
( "cancelling with nothing in progress is a no-op",
465
`Quick,
466
fun () ->
467
let s, t = fixture () in
468
Alcotest.(check bool) "nothing to discard" false (run (S.cancel s t)) );
469
]
470
471
let active_and_edit_tests =
472
[
473
( "routine selection is explicit and rejects unknown IDs",
474
`Quick,
475
fun () ->
476
let s, t = fixture () in
477
Alcotest.(check bool)
478
"no initial selection" true
479
(Option.is_none (run (S.active_routine s t)));
480
ignore (ok (run (S.select_routine s t ideal)));
481
Alcotest.(check string)
482
"selected ideal" "Ideal Routine"
483
(Prescription.Routine.name
484
(snd (Option.get (run (S.active_routine s t)))));
485
match run (S.select_routine s t (Repository.routine_id "missing")) with
486
| Error S.Unknown_routine -> ()
487
| _ -> Alcotest.fail "expected Unknown_routine" );
488
( "a finished record can be completed later without changing its end",
489
`Quick,
490
fun () ->
491
let s, t = fixture () in
492
ignore (ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())));
493
let record = Option.get (run (S.finish s t ~ended_at:(at 120))) in
494
let record =
495
List.fold_left
496
(fun record stimulus ->
497
ok (run (S.add_to_record s t record.Repository.id stimulus)))
498
record day_one_stimuli
499
in
500
Alcotest.(check bool)
501
"complete" true
502
(match Workout.completeness record.Repository.workout with
503
| Workout.Complete -> true
504
| Incomplete -> false);
505
Alcotest.(check int)
506
"original end" 120
507
(Recovery.timestamp_to_unix_seconds
508
(Option.get (Workout.ended_at record.Repository.workout))) );
509
( "replace_current corrects a recorded slot without adding volume",
510
`Quick,
511
fun () ->
512
let s, t = fixture () in
513
ignore (ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())));
514
ignore (ok (run (S.log s t (single "laterals" 12. 8))));
515
let w =
516
ok (run (S.replace_current s t ~slot:1 (single "laterals" 14. 7)))
517
in
518
Alcotest.(check int)
519
"still one stimulus" 1
520
(List.length (Workout.stimuli w));
521
Alcotest.(check int) "one filled slot" 1 (Workout.filled_slots w);
522
Alcotest.(check (float 0.001))
523
"corrected load" 14.
524
(Stimulus.Effort.load
525
(List.hd (Stimulus.efforts (List.hd (Workout.stimuli w))))) );
526
( "replace_current rejects a stimulus the slot does not call for",
527
`Quick,
528
fun () ->
529
let s, t = fixture () in
530
ignore (ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())));
531
match run (S.replace_current s t ~slot:1 (single "shrugs" 80. 10)) with
532
| Error (S.Rejected _) -> ()
533
| _ -> Alcotest.fail "expected Rejected" );
534
( "replace_in_record corrects a saved slot without adding volume",
535
`Quick,
536
fun () ->
537
let s, t = fixture () in
538
ignore (ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())));
539
ignore (ok (run (S.log s t (single "laterals" 12. 8))));
540
let record = Option.get (run (S.finish s t ~ended_at:(at 120))) in
541
let record =
542
ok
543
(run
544
(S.replace_in_record s t record.Repository.id ~slot:1
545
(single "laterals" 16. 6)))
546
in
547
Alcotest.(check int)
548
"still one stimulus" 1
549
(List.length (Workout.stimuli record.Repository.workout));
550
Alcotest.(check (float 0.001))
551
"corrected load" 16.
552
(Stimulus.Effort.load
553
(List.hd
554
(Stimulus.efforts
555
(List.hd (Workout.stimuli record.Repository.workout))))) );
556
( "replace_in_record rejects an unknown slot",
557
`Quick,
558
fun () ->
559
let s, t = fixture () in
560
ignore (ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())));
561
let record = Option.get (run (S.finish s t ~ended_at:(at 120))) in
562
match
563
run
564
(S.replace_in_record s t record.Repository.id ~slot:99
565
(single "laterals" 12. 8))
566
with
567
| Error (S.Rejected_edit (Workout.No_such_slot 99)) -> ()
568
| _ -> Alcotest.fail "expected Rejected_edit No_such_slot" );
569
]
570
571
let assessment_tests =
572
[
573
( "evidence accumulates across cycles and feeds progression",
574
`Quick,
575
fun () ->
576
let s, t = fixture () in
577
let run_workout ~on ~load =
578
let w =
579
ok
580
(run (S.begin_workout s t ~routine:ideal ~now:on ~override:() ()))
581
in
582
if
583
String.equal "Day 1"
584
(Prescription.Workout.name (Workout.prescription w))
585
then ignore (ok (run (S.log s t (single "laterals" load 8))));
586
ignore (run (S.finish s t ~ended_at:on))
587
in
588
List.iter
589
(fun on -> run_workout ~on ~load:12.)
590
[ day 1; day 3; day 5; day 8; day 10; day 12; day 16 ];
591
Alcotest.(check int)
592
"seven workouts logged" 7
593
(List.length (run (S.history s t)));
594
Alcotest.(check bool)
595
"stalled" true
596
(run (S.progress s t (get "laterals")) = Ok Progression.Stalled) );
597
( "training on overrides shows up as a diagnostic",
598
`Quick,
599
fun () ->
600
let s, t = fixture () in
601
let _ = ok (run (S.begin_workout s t ~routine:ideal ~now:(day 1) ())) in
602
let _ = run (S.finish s t ~ended_at:(day 1)) in
603
let _ =
604
ok
605
(run
606
(S.begin_workout s t ~routine:ideal ~now:(day 2) ~override:() ()))
607
in
608
let _ = run (S.finish s t ~ended_at:(day 2)) in
609
match
610
List.filter
611
(function
612
| Progression.Trained_under_recovered _ -> true | _ -> false)
613
(run (S.diagnostics s t))
614
with
615
| [ Progression.Trained_under_recovered n ] ->
616
Alcotest.(check int) "one such workout" 1 n
617
| _ -> Alcotest.fail "expected the under-recovery diagnostic" );
618
]
619
620
let app_feedback_tests =
621
[
622
( "app feedback rejects a blank message",
623
`Quick,
624
fun () ->
625
let s, t = fixture () in
626
match
627
run
628
(S.record_app_feedback s t ~submitted_at:(day 1) ~message:" \n ")
629
with
630
| Error `Empty_message -> ()
631
| Error `Unknown_feedback ->
632
Alcotest.fail "unexpected unknown feedback error"
633
| Ok _ -> Alcotest.fail "expected a blank message to be refused" );
634
( "app feedback is stored newest first and visible globally",
635
`Quick,
636
fun () ->
637
let s, alice = fixture () in
638
let bob =
639
(ok (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
640
.Trainee.id
641
in
642
let _ =
643
ok
644
(run
645
(S.record_app_feedback s alice ~submitted_at:(day 1)
646
~message:"First"))
647
in
648
let _ =
649
ok
650
(run
651
(S.record_app_feedback s alice ~submitted_at:(day 2)
652
~message:"Second"))
653
in
654
let alice_reports = run (S.app_feedback s alice) in
655
Alcotest.(check int) "two reports" 2 (List.length alice_reports);
656
Alcotest.(check string)
657
"newest first" "Second" (List.hd alice_reports).Repository.message;
658
Alcotest.(check int)
659
"bob sees the same reports" 2
660
(List.length (run (S.app_feedback s bob))) );
661
( "application feedback ownership survives an author rename",
662
`Quick,
663
fun () ->
664
let s, alice = fixture () in
665
let _ =
666
ok
667
(run
668
(S.record_app_feedback s alice ~submitted_at:(day 1)
669
~message:"Before rename"))
670
in
671
let _ = ok (run (S.change_username s alice ~username:"athlete")) in
672
match run (S.app_feedback s alice) with
673
| [ report ] ->
674
Alcotest.(check string)
675
"current author name" "athlete" report.author;
676
Alcotest.(check bool)
677
"viewer still owns report" true report.viewer_owns
678
| reports ->
679
Alcotest.failf "expected one report, got %d" (List.length reports)
680
);
681
( "application feedback is ranked and supports cross-user votes",
682
`Quick,
683
fun () ->
684
let s, alice = fixture () in
685
let bob =
686
(ok (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
687
.Trainee.id
688
in
689
let _ =
690
ok
691
(run
692
(S.record_app_feedback s alice ~submitted_at:(day 1)
693
~message:"First"))
694
in
695
let _ =
696
ok
697
(run
698
(S.record_app_feedback s alice ~submitted_at:(day 2)
699
~message:"Second"))
700
in
701
let _ =
702
ok
703
(run
704
(S.record_app_feedback s bob ~submitted_at:(day 3)
705
~message:"From Bob"))
706
in
707
let before_vote = run (S.app_feedback s alice) in
708
let alice_report =
709
List.find
710
(fun report -> String.equal report.Repository.author "lifter")
711
before_vote
712
in
713
let bob_report =
714
List.find
715
(fun report -> String.equal report.Repository.author "bobby")
716
before_vote
717
in
718
Alcotest.(check int)
719
"alice contribution count" 2 alice_report.Repository.contributions;
720
Alcotest.(check int)
721
"bob contribution count" 1 bob_report.Repository.contributions;
722
Alcotest.(check bool)
723
"newest report starts first" true
724
(String.equal (List.hd before_vote).Repository.message "From Bob");
725
Alcotest.(check bool)
726
"cross-user vote is added" true
727
(run
728
(S.upvote_app_feedback s alice bob_report.Repository.feedback_id));
729
Alcotest.(check bool)
730
"own vote is refused" false
731
(run (S.upvote_app_feedback s bob bob_report.Repository.feedback_id));
732
let after_vote = run (S.app_feedback s alice) in
733
let voted_bob =
734
List.find
735
(fun report -> String.equal report.Repository.author "bobby")
736
after_vote
737
in
738
Alcotest.(check int) "upvote count" 1 voted_bob.Repository.upvotes;
739
Alcotest.(check bool)
740
"viewer vote state" true voted_bob.Repository.viewer_upvoted );
741
( "editing application feedback to a blank message is refused",
742
`Quick,
743
fun () ->
744
let s, alice = fixture () in
745
let report =
746
ok
747
(run
748
(S.record_app_feedback s alice ~submitted_at:(day 1)
749
~message:"Original"))
750
in
751
match
752
run
753
(S.edit_app_feedback s alice report.Repository.feedback_id
754
~message:" ")
755
with
756
| Error `Empty_message -> ()
757
| Error `Unknown_feedback -> Alcotest.fail "feedback must still exist"
758
| Ok () -> Alcotest.fail "expected a blank edit to be refused" );
759
( "removed application feedback identities are not reused",
760
`Quick,
761
fun () ->
762
let s, alice = fixture () in
763
let first =
764
ok
765
(run
766
(S.record_app_feedback s alice ~submitted_at:(day 1)
767
~message:"First"))
768
in
769
let second =
770
ok
771
(run
772
(S.record_app_feedback s alice ~submitted_at:(day 2)
773
~message:"Second"))
774
in
775
Alcotest.(check bool)
776
"first removal succeeds" true
777
(run (S.remove_app_feedback s alice first.Repository.feedback_id));
778
let third =
779
ok
780
(run
781
(S.record_app_feedback s alice ~submitted_at:(day 3)
782
~message:"Third"))
783
in
784
Alcotest.(check bool)
785
"new identity differs from the surviving identity" false
786
(String.equal
787
(Repository.app_feedback_id_to_string second.feedback_id)
788
(Repository.app_feedback_id_to_string third.feedback_id));
789
Alcotest.(check string)
790
"sequence does not rewind" "t1:3"
791
(Repository.app_feedback_id_to_string third.feedback_id) );
792
( "application feedback CRUD is owner-scoped",
793
`Quick,
794
fun () ->
795
let s, alice = fixture () in
796
let bob =
797
(ok (run (S.register s ~username:"bobby" ~password:"heavyduty1")))
798
.Trainee.id
799
in
800
let report =
801
ok
802
(run
803
(S.record_app_feedback s alice ~submitted_at:(day 1)
804
~message:"Original"))
805
in
806
(match
807
run
808
(S.edit_app_feedback s bob report.Repository.feedback_id
809
~message:"Not yours")
810
with
811
| Error `Unknown_feedback -> ()
812
| _ -> Alcotest.fail "another trainee edited the report");
813
(match
814
run
815
(S.edit_app_feedback s alice report.Repository.feedback_id
816
~message:"Edited")
817
with
818
| Ok () -> ()
819
| _ -> Alcotest.fail "owner edit failed");
820
let edited =
821
List.find
822
(fun current ->
823
String.equal
824
(Repository.app_feedback_id_to_string
825
current.Repository.feedback_id)
826
(Repository.app_feedback_id_to_string
827
report.Repository.feedback_id))
828
(run (S.app_feedback s bob))
829
in
830
Alcotest.(check string) "edited message" "Edited" edited.message;
831
Alcotest.(check bool)
832
"owner remove succeeds" true
833
(run (S.remove_app_feedback s alice report.Repository.feedback_id));
834
Alcotest.(check int)
835
"removed from the list" 0
836
(List.length (run (S.app_feedback s bob))) );
837
]
838
839
let suite =
840
[
841
("service.accounts", account_tests);
842
("service.routines", routine_tests);
843
("service.clearance", clearance_tests);
844
("service.logging", logging_tests);
845
("service.app_feedback", app_feedback_tests);
846
("service.active_and_edit", active_and_edit_tests);
847
("service.assessment", assessment_tests);
848
]
849