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.

Commit
2a189459ce1b73363fd4008e388d384b5757066b
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/core/units.ml
index 4da6ef7b..9116ca3f 100644..100644
@@ -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
index ac26c979..28e9a7a9 100644..100644
@@ -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
index 7017e4c8..a226ee01 100644..100644
@@ -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
index 31a5f810..6d11cda7 100644..100644
@@ -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
index cb024c34..27f93ccd 100644..100644
@@ -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 =