[OCaml] High Intensity Training Online
feat implement Units
Weight (kg, zero for bodyweight movements, non-finite rejected), Reps (strictly positive) and Rep_range (inclusive, min <= max). All abstract and reachable only through smart constructors, so an invalid measurement cannot be represented. Rep_range is deliberately generic here: the HD1 [6,12] bound belongs to Prescription, the module that employs it, not to the vocabulary. Its doc now records that 6-10 is a load-calibration guideline and that reps are an outcome, never a target — HD1 is explicit that a set never ends just because a rep count was reached. 15 Alcotests.
lib/core/units.ml
@@ -1,37 +1,45 @@
1
Removed:
(* Blank slate: stub only. Interface (units.mli) is the design surface. *)
2
Removed:
3
1
type error = Negative | Not_positive | Inverted_range
4
2
5
Removed:
let pp_error _ _ = failwith "TODO"
3
Added:
let pp_error ppf = function
4
Added:
| Negative -> Format.pp_print_string ppf "must be zero or greater"
5
Added:
| Not_positive -> Format.pp_print_string ppf "must be greater than zero"
6
Added:
| Inverted_range -> Format.pp_print_string ppf "lower bound exceeds upper"
6
7
7
8
module Weight = struct
8
9
type t = float
9
10
10
Removed:
let of_kg _ = failwith "TODO"
11
Removed:
let to_kg _ = failwith "TODO"
12
Removed:
let zero = 0.0
13
Removed:
let compare _ _ = failwith "TODO"
14
Removed:
let equal _ _ = failwith "TODO"
15
Removed:
let pp _ _ = failwith "TODO"
11
Added:
let of_kg kg =
12
Added:
if Float.is_finite kg && kg >= 0. then Ok kg else Error Negative
13
Added:
14
Added:
let to_kg t = t
15
Added:
let zero = 0.
16
Added:
let compare = Float.compare
17
Added:
let equal = Float.equal
18
Added:
let pp ppf t = Format.fprintf ppf "%g kg" t
16
19
end
17
20
18
21
module Reps = struct
19
22
type t = int
20
23
21
Removed:
let of_int _ = failwith "TODO"
22
Removed:
let to_int _ = failwith "TODO"
23
Removed:
let compare _ _ = failwith "TODO"
24
Removed:
let equal _ _ = failwith "TODO"
25
Removed:
let pp _ _ = failwith "TODO"
24
Added:
let of_int n = if n > 0 then Ok n else Error Not_positive
25
Added:
let to_int t = t
26
Added:
let compare = Int.compare
27
Added:
let equal = Int.equal
28
Added:
let pp ppf t = Format.fprintf ppf "%d" t
26
29
end
27
30
28
31
module Rep_range = struct
29
Removed:
type t = Reps.t * Reps.t
32
Added:
type t = { min : Reps.t; max : Reps.t }
30
33
31
Removed:
let make ~min:_ ~max:_ = failwith "TODO"
32
Removed:
let min _ = failwith "TODO"
33
Removed:
let max _ = failwith "TODO"
34
Removed:
let contains _ _ = failwith "TODO"
35
Removed:
let equal _ _ = failwith "TODO"
36
Removed:
let pp _ _ = failwith "TODO"
34
Added:
let make ~min ~max =
35
Added:
if Reps.compare min max <= 0 then Ok { min; max } else Error Inverted_range
36
Added:
37
Added:
let min t = t.min
38
Added:
let max t = t.max
39
Added:
40
Added:
let contains t reps =
41
Added:
Reps.compare reps t.min >= 0 && Reps.compare reps t.max <= 0
42
Added:
43
Added:
let equal a b = Reps.equal a.min b.min && Reps.equal a.max b.max
44
Added:
let pp ppf t = Format.fprintf ppf "%a-%a" Reps.pp t.min Reps.pp t.max
37
45
end
lib/core/units.mli
@@ -33,7 +33,9 @@
33
33
val pp : Format.formatter -> t -> unit
34
34
end
35
35
36
Removed:
(** An inclusive target rep band, such as Mentzer's canonical 6-8. *)
36
Added:
(** An inclusive rep band. Calibrates load selection: HD1's guideline is 6-10.
37
Added:
Reps are an outcome, never a target — a set ends at failure, not at a
38
Added:
number. *)
37
39
module Rep_range : sig
38
40
type t
39
41
test/dune
@@ -3,5 +3,5 @@
3
3
4
4
(test
5
5
(name test_hito)
6
Removed:
(modules test_hito)
6
Added:
(modules test_hito test_units)
7
7
(libraries hito.core alcotest))
test/test_hito.ml
@@ -1,10 +1,4 @@
1
Removed:
(** Test harness entry point.
1
Added:
(** Test harness entry point. Suites are registered here as the HD1 rebuild
2
Added:
lands each module. *)
2
3
3
Removed:
Blank-slate redesign: the per-module suites (test_units.ml, test_set.ml, …)
4
Removed:
are kept in the tree and still compile against the interfaces, but are not
5
Removed:
registered here while the implementations are stubs — each is wired back
6
Removed:
into [Alcotest.run] as its module is re-implemented. Until then a trivial
7
Removed:
smoke test keeps [dune runtest] green. *)
8
Removed:
9
Removed:
let smoke = [ ("blank-slate", `Quick, fun () -> ()) ]
10
Removed:
let () = Alcotest.run "hito" [ ("harness", smoke) ]
4
Added:
let () = Alcotest.run "hito" Test_units.suite
test/test_units.ml
@@ -1,15 +1,10 @@
1
1
(** Unit tests for {!Units}, authored against the units.mli contract.
2
2
3
Removed:
NOTE: These tests exercise real behaviour and therefore only pass once the
4
Removed:
implementation is filled in (Task 8). They are intentionally NOT yet
5
Removed:
registered in the main test runner ([test_hito.ml]) so that [dune runtest]
6
Removed:
stays green during the interface-design phase. The [suite] value below is
7
Removed:
wired into the runner as part of the implementation task.
3
Added:
Per project policy units.mli is the source of truth: if any assertion here
4
Added:
contradicts the interface, the interface wins and the test is fixed. *)
8
5
9
Removed:
Per project policy, units.mli is the source of truth: if any assertion here
10
Removed:
ever contradicts the interface, the interface wins and the test is fixed. *)
11
Removed:
12
6
let ok = function Ok v -> v | Error _ -> Alcotest.fail "expected Ok"
7
Added:
let reps n = ok (Units.Reps.of_int n)
13
8
14
9
let weight_tests =
15
10
[
@@ -19,17 +14,43 @@
19
14
Alcotest.(check bool)
20
15
"negative rejected" true
21
16
(Result.is_error (Units.Weight.of_kg (-1.0))) );
22
Removed:
( "of_kg accepts zero (bodyweight)",
17
Added:
( "of_kg rejects non-finite",
23
18
`Quick,
24
19
fun () ->
25
20
Alcotest.(check bool)
21
Added:
"nan rejected" true
22
Added:
(Result.is_error (Units.Weight.of_kg Float.nan));
23
Added:
Alcotest.(check bool)
24
Added:
"infinity rejected" true
25
Added:
(Result.is_error (Units.Weight.of_kg Float.infinity)) );
26
Added:
( "of_kg accepts zero (bodyweight movements)",
27
Added:
`Quick,
28
Added:
fun () ->
29
Added:
Alcotest.(check bool)
26
30
"zero accepted" true
27
31
(Result.is_ok (Units.Weight.of_kg 0.0)) );
32
Added:
( "zero is zero kilograms",
33
Added:
`Quick,
34
Added:
fun () ->
35
Added:
Alcotest.(check (float 0.0001))
36
Added:
"0kg" 0.0
37
Added:
(Units.Weight.to_kg Units.Weight.zero) );
28
38
( "round-trips kilograms",
29
39
`Quick,
30
40
fun () ->
31
41
let w = ok (Units.Weight.of_kg 60.0) in
32
42
Alcotest.(check (float 0.0001)) "60kg" 60.0 (Units.Weight.to_kg w) );
43
Added:
( "orders by load",
44
Added:
`Quick,
45
Added:
fun () ->
46
Added:
let light = ok (Units.Weight.of_kg 40.0) in
47
Added:
let heavy = ok (Units.Weight.of_kg 60.0) in
48
Added:
Alcotest.(check bool)
49
Added:
"40 < 60" true
50
Added:
(Units.Weight.compare light heavy < 0);
51
Added:
Alcotest.(check bool)
52
Added:
"60 = 60" true
53
Added:
(Units.Weight.equal heavy (ok (Units.Weight.of_kg 60.0))) );
33
54
]
34
55
35
56
let reps_tests =
@@ -52,6 +73,15 @@
52
73
Alcotest.(check bool)
53
74
"positive accepted" true
54
75
(Result.is_ok (Units.Reps.of_int 8)) );
76
Added:
( "round-trips and orders",
77
Added:
`Quick,
78
Added:
fun () ->
79
Added:
Alcotest.(check int) "8" 8 (Units.Reps.to_int (reps 8));
80
Added:
Alcotest.(check bool)
81
Added:
"6 < 10" true
82
Added:
(Units.Reps.compare (reps 6) (reps 10) < 0);
83
Added:
Alcotest.(check bool) "8 = 8" true (Units.Reps.equal (reps 8) (reps 8))
84
Added:
);
55
85
]
56
86
57
87
let rep_range_tests =
@@ -59,21 +89,51 @@
59
89
( "make rejects inverted range",
60
90
`Quick,
61
91
fun () ->
62
Removed:
let lo = ok (Units.Reps.of_int 8) in
63
Removed:
let hi = ok (Units.Reps.of_int 6) in
64
92
Alcotest.(check bool)
65
93
"inverted rejected" true
66
Removed:
(Result.is_error (Units.Rep_range.make ~min:lo ~max:hi)) );
67
Removed:
( "make accepts 6-8 and contains 7",
94
Added:
(Result.is_error (Units.Rep_range.make ~min:(reps 10) ~max:(reps 6)))
95
Added:
);
96
Added:
( "make accepts a single-rep band",
68
97
`Quick,
69
98
fun () ->
70
Removed:
let lo = ok (Units.Reps.of_int 6) in
71
Removed:
let hi = ok (Units.Reps.of_int 8) in
72
Removed:
let seven = ok (Units.Reps.of_int 7) in
73
Removed:
let r = ok (Units.Rep_range.make ~min:lo ~max:hi) in
74
99
Alcotest.(check bool)
75
Removed:
"contains 7" true
76
Removed:
(Units.Rep_range.contains r seven) );
100
Added:
"min = max accepted" true
101
Added:
(Result.is_ok (Units.Rep_range.make ~min:(reps 8) ~max:(reps 8))) );
102
Added:
( "HD1's 6-10 window contains its interior and bounds",
103
Added:
`Quick,
104
Added:
fun () ->
105
Added:
let r = ok (Units.Rep_range.make ~min:(reps 6) ~max:(reps 10)) in
106
Added:
Alcotest.(check bool)
107
Added:
"contains 8" true
108
Added:
(Units.Rep_range.contains r (reps 8));
109
Added:
Alcotest.(check bool)
110
Added:
"contains 6" true
111
Added:
(Units.Rep_range.contains r (reps 6));
112
Added:
Alcotest.(check bool)
113
Added:
"contains 10" true
114
Added:
(Units.Rep_range.contains r (reps 10)) );
115
Added:
( "excludes reps outside the window",
116
Added:
`Quick,
117
Added:
fun () ->
118
Added:
let r = ok (Units.Rep_range.make ~min:(reps 6) ~max:(reps 10)) in
119
Added:
Alcotest.(check bool)
120
Added:
"excludes 5" false
121
Added:
(Units.Rep_range.contains r (reps 5));
122
Added:
Alcotest.(check bool)
123
Added:
"excludes 12" false
124
Added:
(Units.Rep_range.contains r (reps 12)) );
125
Added:
( "exposes its bounds and compares structurally",
126
Added:
`Quick,
127
Added:
fun () ->
128
Added:
let r = ok (Units.Rep_range.make ~min:(reps 6) ~max:(reps 10)) in
129
Added:
Alcotest.(check int) "min" 6 (Units.Reps.to_int (Units.Rep_range.min r));
130
Added:
Alcotest.(check int)
131
Added:
"max" 10
132
Added:
(Units.Reps.to_int (Units.Rep_range.max r));
133
Added:
Alcotest.(check bool)
134
Added:
"equal to itself" true
135
Added:
(Units.Rep_range.equal r
136
Added:
(ok (Units.Rep_range.make ~min:(reps 6) ~max:(reps 10)))) );
77
137
]
78
138
79
139
let suite =