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