headless_lms_credit_registration/use_cases/resolve_enrolments/enrolments/
answer.rs1use 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#[derive(Clone, Copy)]
23pub(super) enum EnrolmentLookupResult<'a> {
24 Refused {
26 code: CreditRegistrationErrorCode,
27 error_message: Option<&'a str>,
28 },
29 Listed {
31 enrolments: &'a [RegistryEnrolment],
32 chosen: Result<&'a RegistryEnrolment, NoUsableEnrolment>,
33 },
34}
35
36impl<'a> EnrolmentLookupResult<'a> {
37 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 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 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
88pub(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 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 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 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}