headless_lms_models/library/credit_registration/
grade_mapping.rs1use crate::credit_registrations::CreditRegistrationErrorCode;
6
7pub const PASS_FAIL_GRADE_SCALE_ID: &str = "sis-hyl-hyv";
10pub const PASS_FAIL_GRADE_SCALE_ID_ALT: &str = "sis-hyv-hyl";
12pub const NUMERIC_GRADE_SCALE_ID: &str = "sis-0-5";
13
14pub const PASS_GRADE_ID: &str = "1";
15pub const FAIL_GRADE_ID: &str = "0";
16pub const MAX_NUMERIC_GRADE: i32 = 5;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum GradeScaleFamily {
20 PassFail,
21 Numeric,
22}
23
24pub fn grade_scale_family(grade_scale_id: &str) -> Option<GradeScaleFamily> {
25 match grade_scale_id {
26 PASS_FAIL_GRADE_SCALE_ID | PASS_FAIL_GRADE_SCALE_ID_ALT => Some(GradeScaleFamily::PassFail),
27 NUMERIC_GRADE_SCALE_ID => Some(GradeScaleFamily::Numeric),
28 _ => None,
29 }
30}
31
32pub fn same_grade_scale(left: &str, right: &str) -> bool {
35 match (grade_scale_family(left), grade_scale_family(right)) {
36 (Some(left), Some(right)) => left == right,
37 _ => left == right,
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct MappedGrade {
43 pub grade_scale_id: String,
44 pub grade_id: String,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct GradeSource<'a> {
50 pub passed: bool,
51 pub grade: Option<i32>,
53 pub configured_grade_scale_id: Option<&'a str>,
55 pub enrolment_grade_scale_id: Option<&'a str>,
57}
58
59pub fn map_grade(source: GradeSource<'_>) -> Result<MappedGrade, CreditRegistrationErrorCode> {
61 let scale_id = source
62 .configured_grade_scale_id
63 .or(source.enrolment_grade_scale_id)
64 .unwrap_or(if source.grade.is_some() {
65 NUMERIC_GRADE_SCALE_ID
66 } else {
67 PASS_FAIL_GRADE_SCALE_ID
68 });
69 let family =
70 grade_scale_family(scale_id).ok_or(CreditRegistrationErrorCode::NoGradeScaleMapping)?;
71 let grade_id = match family {
72 GradeScaleFamily::PassFail => if source.passed {
73 PASS_GRADE_ID
74 } else {
75 FAIL_GRADE_ID
76 }
77 .to_string(),
78 GradeScaleFamily::Numeric => {
79 let grade = source
81 .grade
82 .ok_or(CreditRegistrationErrorCode::NoGradeScaleMapping)?;
83 if !(0..=MAX_NUMERIC_GRADE).contains(&grade) {
84 return Err(CreditRegistrationErrorCode::NoGradeScaleMapping);
85 }
86 grade.to_string()
87 }
88 };
89 Ok(MappedGrade {
90 grade_scale_id: scale_id.to_string(),
91 grade_id,
92 })
93}
94
95pub fn is_known_grade(grade_scale_id: &str, grade_id: &str) -> bool {
98 match grade_scale_family(grade_scale_id) {
99 Some(GradeScaleFamily::PassFail) => grade_id == PASS_GRADE_ID || grade_id == FAIL_GRADE_ID,
100 Some(GradeScaleFamily::Numeric) => grade_id
101 .parse::<i32>()
102 .is_ok_and(|grade| (0..=MAX_NUMERIC_GRADE).contains(&grade)),
103 None => false,
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum GradeComparison {
110 Better,
111 NotBetter,
113 NotComparable,
116}
117
118pub fn compare_grades(
124 registered_grade_scale_id: &str,
125 registered_grade_id: &str,
126 candidate: &MappedGrade,
127) -> GradeComparison {
128 if !same_grade_scale(registered_grade_scale_id, &candidate.grade_scale_id) {
129 return GradeComparison::NotComparable;
130 }
131 let Some(family) = grade_scale_family(&candidate.grade_scale_id) else {
132 return GradeComparison::NotComparable;
133 };
134 match (
135 grade_rank(family, registered_grade_id),
136 grade_rank(family, &candidate.grade_id),
137 ) {
138 (Some(registered), Some(candidate)) if candidate > registered => GradeComparison::Better,
139 (Some(_), Some(_)) => GradeComparison::NotBetter,
140 _ => GradeComparison::NotComparable,
141 }
142}
143
144fn grade_rank(family: GradeScaleFamily, grade_id: &str) -> Option<i32> {
146 match family {
147 GradeScaleFamily::PassFail => match grade_id {
148 PASS_GRADE_ID => Some(1),
149 FAIL_GRADE_ID => Some(0),
150 _ => None,
151 },
152 GradeScaleFamily::Numeric => grade_id
153 .parse::<i32>()
154 .ok()
155 .filter(|grade| (0..=MAX_NUMERIC_GRADE).contains(grade)),
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn source(passed: bool, grade: Option<i32>) -> GradeSource<'static> {
164 GradeSource {
165 passed,
166 grade,
167 configured_grade_scale_id: None,
168 enrolment_grade_scale_id: None,
169 }
170 }
171
172 #[test]
173 fn a_completion_with_no_number_maps_to_the_pass_fail_scale() {
174 assert_eq!(
175 map_grade(source(true, None)),
176 Ok(MappedGrade {
177 grade_scale_id: PASS_FAIL_GRADE_SCALE_ID.to_string(),
178 grade_id: PASS_GRADE_ID.to_string(),
179 })
180 );
181 }
182
183 #[test]
184 fn a_graded_completion_maps_to_the_numeric_scale() {
185 assert_eq!(
186 map_grade(source(true, Some(4))),
187 Ok(MappedGrade {
188 grade_scale_id: NUMERIC_GRADE_SCALE_ID.to_string(),
189 grade_id: "4".to_string(),
190 })
191 );
192 }
193
194 #[test]
195 fn the_module_override_wins_over_the_enrolment_and_the_enrolment_over_the_guess() {
196 let with_enrolment = GradeSource {
197 enrolment_grade_scale_id: Some(PASS_FAIL_GRADE_SCALE_ID_ALT),
198 ..source(true, Some(4))
199 };
200 assert_eq!(
201 map_grade(with_enrolment).unwrap().grade_scale_id,
202 PASS_FAIL_GRADE_SCALE_ID_ALT
203 );
204 assert_eq!(map_grade(with_enrolment).unwrap().grade_id, PASS_GRADE_ID);
205
206 let overridden = GradeSource {
207 configured_grade_scale_id: Some(NUMERIC_GRADE_SCALE_ID),
208 ..with_enrolment
209 };
210 assert_eq!(
211 map_grade(overridden).unwrap().grade_scale_id,
212 NUMERIC_GRADE_SCALE_ID
213 );
214 assert_eq!(map_grade(overridden).unwrap().grade_id, "4");
215 }
216
217 #[test]
218 fn an_unrecognised_scale_fails_before_anything_is_sent() {
219 let source = GradeSource {
220 configured_grade_scale_id: Some("sis-something-else"),
221 ..source(true, Some(4))
222 };
223 assert_eq!(
224 map_grade(source),
225 Err(CreditRegistrationErrorCode::NoGradeScaleMapping)
226 );
227 }
228
229 #[test]
230 fn a_pass_fail_completion_cannot_be_pushed_into_a_numeric_scale() {
231 let source = GradeSource {
232 configured_grade_scale_id: Some(NUMERIC_GRADE_SCALE_ID),
233 ..source(true, None)
234 };
235 assert_eq!(
236 map_grade(source),
237 Err(CreditRegistrationErrorCode::NoGradeScaleMapping)
238 );
239 }
240
241 #[test]
242 fn a_number_outside_the_scale_does_not_map() {
243 assert_eq!(
244 map_grade(source(true, Some(7))),
245 Err(CreditRegistrationErrorCode::NoGradeScaleMapping)
246 );
247 }
248
249 #[test]
250 fn both_spellings_of_the_pass_fail_scale_are_the_same_scale() {
251 assert!(same_grade_scale(
252 PASS_FAIL_GRADE_SCALE_ID,
253 PASS_FAIL_GRADE_SCALE_ID_ALT
254 ));
255 assert!(!same_grade_scale(
256 PASS_FAIL_GRADE_SCALE_ID,
257 NUMERIC_GRADE_SCALE_ID
258 ));
259 }
260
261 #[test]
262 fn only_pairs_the_registry_knows_pass_the_pre_flight() {
263 assert!(is_known_grade(PASS_FAIL_GRADE_SCALE_ID, "1"));
264 assert!(is_known_grade(PASS_FAIL_GRADE_SCALE_ID_ALT, "0"));
265 assert!(is_known_grade(NUMERIC_GRADE_SCALE_ID, "5"));
266 assert!(!is_known_grade(NUMERIC_GRADE_SCALE_ID, "6"));
267 assert!(!is_known_grade(PASS_FAIL_GRADE_SCALE_ID, "3"));
268 assert!(!is_known_grade("sis-something-else", "1"));
269 }
270
271 fn mapped(grade_scale_id: &str, grade_id: &str) -> MappedGrade {
272 MappedGrade {
273 grade_scale_id: grade_scale_id.to_string(),
274 grade_id: grade_id.to_string(),
275 }
276 }
277
278 #[test]
279 fn only_a_higher_grade_on_the_same_scale_is_better() {
280 use GradeComparison::*;
281 let numeric = |grade: &str| mapped(NUMERIC_GRADE_SCALE_ID, grade);
282 assert_eq!(
283 compare_grades(NUMERIC_GRADE_SCALE_ID, "3", &numeric("4")),
284 Better
285 );
286 assert_eq!(
287 compare_grades(NUMERIC_GRADE_SCALE_ID, "4", &numeric("4")),
288 NotBetter
289 );
290 assert_eq!(
291 compare_grades(NUMERIC_GRADE_SCALE_ID, "4", &numeric("3")),
292 NotBetter
293 );
294 assert_eq!(
295 compare_grades(
296 PASS_FAIL_GRADE_SCALE_ID,
297 FAIL_GRADE_ID,
298 &mapped(PASS_FAIL_GRADE_SCALE_ID_ALT, PASS_GRADE_ID)
299 ),
300 Better
301 );
302 assert_eq!(
303 compare_grades(
304 PASS_FAIL_GRADE_SCALE_ID,
305 PASS_GRADE_ID,
306 &mapped(PASS_FAIL_GRADE_SCALE_ID, PASS_GRADE_ID)
307 ),
308 NotBetter
309 );
310 }
311
312 #[test]
313 fn a_grade_on_another_scale_is_never_an_improvement() {
314 use GradeComparison::*;
315 assert_eq!(
316 compare_grades(
317 NUMERIC_GRADE_SCALE_ID,
318 "3",
319 &mapped(PASS_FAIL_GRADE_SCALE_ID, PASS_GRADE_ID)
320 ),
321 NotComparable
322 );
323 assert_eq!(
324 compare_grades(
325 PASS_FAIL_GRADE_SCALE_ID,
326 PASS_GRADE_ID,
327 &mapped(NUMERIC_GRADE_SCALE_ID, "5")
328 ),
329 NotComparable
330 );
331 assert_eq!(
332 compare_grades(
333 "sis-something-else",
334 "3",
335 &mapped("sis-something-else", "4")
336 ),
337 NotComparable
338 );
339 }
340
341 #[test]
344 fn an_unreadable_grade_on_a_known_scale_is_not_comparable() {
345 assert_eq!(
346 compare_grades(
347 NUMERIC_GRADE_SCALE_ID,
348 "excellent",
349 &mapped(NUMERIC_GRADE_SCALE_ID, "5")
350 ),
351 GradeComparison::NotComparable
352 );
353 }
354
355 #[test]
356 fn everything_the_mapping_produces_passes_the_pre_flight() {
357 let mut mapped = vec![map_grade(source(true, None)).unwrap()];
358 for grade in 0..=MAX_NUMERIC_GRADE {
359 mapped.push(map_grade(source(grade > 0, Some(grade))).unwrap());
360 }
361 for grade in mapped {
362 assert!(
363 is_known_grade(&grade.grade_scale_id, &grade.grade_id),
364 "{grade:?}"
365 );
366 }
367 }
368}