made_core/value_objects/
score.rs1use std::cmp::Ordering;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::DomainError;
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct Score(f64);
17
18impl Score {
19 pub const MIN: Self = Self(0.0);
20 pub const MAX: Self = Self(1.0);
21
22 pub fn new(value: f64) -> Result<Self, DomainError> {
23 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
24 return Err(DomainError::OutOfRange {
25 field: "score",
26 value,
27 min: 0.0,
28 max: 1.0,
29 });
30 }
31 Ok(Self(value))
32 }
33
34 #[must_use]
35 pub fn get(self) -> f64 {
36 self.0
37 }
38}
39
40impl fmt::Display for Score {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 write!(f, "{:.4}", self.0)
43 }
44}
45
46impl PartialEq for Score {
49 fn eq(&self, other: &Self) -> bool {
50 self.0.to_bits() == other.0.to_bits()
51 }
52}
53impl Eq for Score {}
54
55impl PartialOrd for Score {
56 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
57 Some(self.cmp(other))
58 }
59}
60impl Ord for Score {
61 fn cmp(&self, other: &Self) -> Ordering {
62 self.0.partial_cmp(&other.0).unwrap_or(Ordering::Equal)
64 }
65}
66
67impl TryFrom<f64> for Score {
68 type Error = DomainError;
69 fn try_from(value: f64) -> Result<Self, Self::Error> {
70 Self::new(value)
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn bounds_are_inclusive() {
80 assert_eq!(Score::new(0.0).unwrap().get(), 0.0);
81 assert_eq!(Score::new(1.0).unwrap().get(), 1.0);
82 }
83
84 #[test]
85 fn mid_value_is_accepted() {
86 assert!((Score::new(0.5).unwrap().get() - 0.5).abs() < f64::EPSILON);
87 }
88
89 #[test]
90 fn nan_is_rejected() {
91 assert!(matches!(
92 Score::new(f64::NAN).unwrap_err(),
93 DomainError::OutOfRange { field: "score", .. }
94 ));
95 }
96
97 #[test]
98 fn infinity_is_rejected() {
99 assert!(Score::new(f64::INFINITY).is_err());
100 assert!(Score::new(f64::NEG_INFINITY).is_err());
101 }
102
103 #[test]
104 fn negative_is_rejected() {
105 assert!(Score::new(-0.01).is_err());
106 }
107
108 #[test]
109 fn above_one_is_rejected() {
110 assert!(Score::new(1.01).is_err());
111 }
112
113 #[test]
114 fn ordering_is_total_and_ascending() {
115 let mut scores = [
116 Score::new(0.9).unwrap(),
117 Score::new(0.1).unwrap(),
118 Score::new(0.5).unwrap(),
119 ];
120 scores.sort();
121 assert_eq!(scores[0].get(), 0.1);
122 assert_eq!(scores[1].get(), 0.5);
123 assert_eq!(scores[2].get(), 0.9);
124 }
125
126 #[test]
127 fn equality_is_bitwise_within_valid_domain() {
128 assert_eq!(Score::new(0.25).unwrap(), Score::new(0.25).unwrap());
129 }
130
131 #[test]
132 fn display_is_formatted() {
133 assert_eq!(Score::new(0.5).unwrap().to_string(), "0.5000");
134 }
135
136 #[test]
137 fn serde_is_transparent() {
138 assert_eq!(
139 serde_json::to_string(&Score::new(0.25).unwrap()).unwrap(),
140 "0.25"
141 );
142 }
143}