Skip to main content

headless_lms_credit_registration/use_cases/resolve_enrolments/enrolments/
answer.rs

1//! What the study registry's answer to an enrolment lookup means for the row: the enrolment it
2//! lists, or the error it gave, and the grade scale our grade would go out on.
3
4use headless_lms_models::credit_registrations::CreditRegistrationErrorCode;
5use headless_lms_models::library::credit_registration::classification::is_enrolment_error;
6use headless_lms_models::library::credit_registration::enrolment_selection::{
7    NoUsableEnrolment, attained_candidates, preferred_attainment,
8};
9use headless_lms_models::library::credit_registration::grade_mapping::{
10    GradeSource, improves_on_all,
11};
12use headless_lms_models::library::credit_registration::study_registry::{
13    RegistryAttainment, RegistryEnrolment,
14};
15use headless_lms_models::library::credit_registration::submission_context::SubmissionContext;
16
17use super::Resolvable;
18use crate::registry::{EnrolmentAnswer, EnrolmentReading};
19use crate::workflow::ClaimedRegistration;
20
21/// What an answered lookup said about the row's enrolment.
22#[derive(Clone, Copy)]
23pub(super) enum EnrolmentLookupResult<'a> {
24    /// Suotar answered with an error, which for an enrolment error still lists the attainments.
25    Refused {
26        code: CreditRegistrationErrorCode,
27        error_message: Option<&'a str>,
28    },
29    /// Suotar listed the enrolments; `chosen` is what `select_enrolment` made of them.
30    Listed {
31        enrolments: &'a [RegistryEnrolment],
32        chosen: Result<&'a RegistryEnrolment, NoUsableEnrolment>,
33    },
34}
35
36impl<'a> EnrolmentLookupResult<'a> {
37    /// `chosen` is what `select_enrolment` made of the answer's enrolments.
38    pub(super) fn read(
39        answer: &'a EnrolmentAnswer,
40        chosen: Result<&'a RegistryEnrolment, NoUsableEnrolment>,
41    ) -> Self {
42        match &answer.reading {
43            EnrolmentReading::Refused {
44                code,
45                error_message,
46            } => Self::Refused {
47                code: *code,
48                error_message: error_message.as_deref(),
49            },
50            EnrolmentReading::Listed => Self::Listed {
51                enrolments: &answer.enrolments,
52                chosen,
53            },
54        }
55    }
56
57    /// The scale the grade would go out on: the chosen enrolment's, or that of any listed one, since
58    /// all enrolments on one course code share it in practice. With no enrolment to say, the held
59    /// attainment's own scale is the best evidence of it.
60    pub(super) fn grade_scale_id(self, existing: &'a [RegistryAttainment]) -> Option<&'a str> {
61        match self {
62            Self::Listed { enrolments, chosen } => chosen
63                .ok()
64                .and_then(|enrolment| enrolment.grade_scale_id.as_deref())
65                .or_else(|| {
66                    enrolments
67                        .iter()
68                        .find_map(|enrolment| enrolment.grade_scale_id.as_deref())
69                }),
70            Self::Refused { .. } => preferred_attainment(&attained_candidates(existing))
71                .and_then(|attained| attained.grade_scale_id.as_deref()),
72        }
73    }
74
75    /// Whether the answer would send the student off to enrol.
76    pub(super) fn is_enrolment_error(self) -> bool {
77        match self {
78            Self::Refused { code, .. } => is_enrolment_error(code),
79            Self::Listed {
80                chosen: Err(reason),
81                ..
82            } => is_enrolment_error(reason.error_code()),
83            Self::Listed { chosen: Ok(_), .. } => false,
84        }
85    }
86}
87
88/// An answered lookup together with the row it was asked for. The grade scale is read from the
89/// answer once, so every question asked of it weighs our grade on the same scale. What it comes to
90/// for the row, once Sisu is known not to hold the credit, is [`super::resolution`]'s.
91pub(super) struct AnsweredLookup<'a> {
92    claim: &'a ClaimedRegistration,
93    submission: &'a SubmissionContext,
94    result: EnrolmentLookupResult<'a>,
95    existing: &'a [RegistryAttainment],
96    grade_scale_id: Option<&'a str>,
97}
98
99impl<'a> AnsweredLookup<'a> {
100    /// `existing` are the attainments Sisu holds for the course, as the answer listed them.
101    pub(super) fn new(
102        resolvable: &'a Resolvable,
103        result: EnrolmentLookupResult<'a>,
104        existing: &'a [RegistryAttainment],
105    ) -> Self {
106        Self {
107            claim: &resolvable.claim,
108            submission: &resolvable.extra.submission,
109            result,
110            existing,
111            grade_scale_id: result.grade_scale_id(existing),
112        }
113    }
114
115    pub(super) fn claim(&self) -> &'a ClaimedRegistration {
116        self.claim
117    }
118
119    pub(super) fn submission(&self) -> &'a SubmissionContext {
120        self.submission
121    }
122
123    pub(super) fn result(&self) -> EnrolmentLookupResult<'a> {
124        self.result
125    }
126
127    /// The grade we would send, on the scale it would go out on.
128    pub(super) fn our_grade(&self) -> GradeSource<'a> {
129        GradeSource {
130            passed: self.submission.completion.passed,
131            grade: self.submission.completion.grade,
132            enrolment_grade_scale_id: self.grade_scale_id,
133        }
134    }
135
136    /// The attainment that settles the row as `duplicate` before anything is sent: the preferred
137    /// one of those Sisu holds for the course, when our grade does not beat them. Only a listed
138    /// answer or an enrolment error says what Sisu holds.
139    pub(super) fn held_in_sisu(&self) -> Option<&'a RegistryAttainment> {
140        let may_be_held = match self.result {
141            EnrolmentLookupResult::Listed { .. } => true,
142            EnrolmentLookupResult::Refused { .. } => self.result.is_enrolment_error(),
143        };
144        if !may_be_held {
145            return None;
146        }
147        let candidates = attained_candidates(self.existing);
148        let attained = preferred_attainment(&candidates)?;
149        let held: Vec<_> = candidates
150            .iter()
151            .map(|attained| attained.held_grade())
152            .collect();
153        (!improves_on_all(&held, self.our_grade())).then_some(attained)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
160
161    use super::super::fixtures::{enrolment, refused};
162    use super::*;
163    use crate::test_fixtures::{attainment, date};
164
165    #[test]
166    fn a_refused_lookup_reads_as_refused_whatever_it_lists() {
167        let answer = EnrolmentAnswer {
168            reading: EnrolmentReading::Refused {
169                code: Code::EnrolmentNotFound,
170                error_message: Some("no enrolment".to_string()),
171            },
172            enrolments: vec![enrolment("ENROLLED")],
173            existing_attainments: Vec::new(),
174        };
175        let lookup_result = EnrolmentLookupResult::read(&answer, Ok(&answer.enrolments[0]));
176        assert!(matches!(
177            lookup_result,
178            EnrolmentLookupResult::Refused {
179                code: Code::EnrolmentNotFound,
180                error_message: Some("no enrolment"),
181            }
182        ));
183    }
184
185    #[test]
186    fn only_a_missing_or_unaccepted_enrolment_sends_the_student_to_enrol() {
187        let listed = [enrolment("ENROLLED")];
188        let cases = [
189            (refused(Code::EnrolmentNotFound), true),
190            (refused(Code::EnrolmentNotAccepted), true),
191            (refused(Code::StudyRightNotValid), false),
192            (
193                EnrolmentLookupResult::Listed {
194                    enrolments: &[],
195                    chosen: Err(NoUsableEnrolment::None),
196                },
197                true,
198            ),
199            (
200                EnrolmentLookupResult::Listed {
201                    enrolments: &listed,
202                    chosen: Err(NoUsableEnrolment::CreditsOutOfRange),
203                },
204                false,
205            ),
206            (
207                EnrolmentLookupResult::Listed {
208                    enrolments: &listed,
209                    chosen: Ok(&listed[0]),
210                },
211                false,
212            ),
213        ];
214        for (lookup_result, expected) in cases {
215            assert_eq!(lookup_result.is_enrolment_error(), expected);
216        }
217    }
218
219    #[test]
220    fn the_grade_scale_comes_from_the_enrolments_then_from_a_held_attainment() {
221        let chosen = RegistryEnrolment {
222            grade_scale_id: Some("sis-hyl-hyv".to_string()),
223            ..enrolment("ENROLLED")
224        };
225        let other = enrolment("ENROLLED");
226        let held = [attainment("sis-hyv-hyl", "1", date(2026, 8, 1))];
227        let listed = [other.clone(), chosen.clone()];
228        let with_chosen = EnrolmentLookupResult::Listed {
229            enrolments: &listed,
230            chosen: Ok(&chosen),
231        };
232        assert_eq!(with_chosen.grade_scale_id(&held), Some("sis-hyl-hyv"));
233        let without_chosen = EnrolmentLookupResult::Listed {
234            enrolments: &listed,
235            chosen: Err(NoUsableEnrolment::NotAccepted),
236        };
237        assert_eq!(without_chosen.grade_scale_id(&held), Some("sis-0-5"));
238        assert_eq!(
239            refused(Code::EnrolmentNotFound).grade_scale_id(&held),
240            Some("sis-hyv-hyl")
241        );
242    }
243}