headless_lms_server/programs/seed/builder/
exercise.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use headless_lms_utils::document_schema_processor::GutenbergBlock;
4use serde_json::Value;
5use uuid::Uuid;
6
7use headless_lms_models::pages::{CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask};
8
9use crate::programs::seed::{
10    builder::{context::SeedContext, json_source::JsonSource},
11    seed_helpers::example_exercise_flexible,
12};
13use headless_lms_utils::attributes;
14
15/// Required IDs for creating exercises.
16#[derive(Debug, Clone)]
17pub struct ExerciseIds {
18    pub exercise_id: Uuid,
19    pub slide_id: Uuid,
20    pub task_id: Uuid,
21    pub block_id: Uuid,
22}
23
24/// Builder for different types of exercises.
25#[derive(Debug, Clone)]
26pub enum ExerciseBuilder {
27    /// Multiple choice exercise with custom options
28    ExampleExercise {
29        ids: ExerciseIds,
30        assignment_blocks: Vec<GutenbergBlock>,
31        name: String,
32        options: Value,
33    },
34    /// Quizzes service exercise with JSON specification
35    Quizzes {
36        ids: ExerciseIds,
37        name: String,
38        needs_peer_review: bool,
39        deadline: Option<DateTime<Utc>>,
40        spec: JsonSource,
41        assignment_blocks: Vec<GutenbergBlock>,
42    },
43    /// Test My Code exercise
44    Tmc {
45        ids: ExerciseIds,
46        name: String,
47        deadline: Option<DateTime<Utc>>,
48        spec: JsonSource,
49        assignment_blocks: Vec<GutenbergBlock>,
50    },
51}
52
53impl ExerciseBuilder {
54    pub fn quizzes(
55        name: impl Into<String>,
56        ids: ExerciseIds,
57        needs_pr: bool,
58        deadline: Option<DateTime<Utc>>,
59        spec: JsonSource,
60        assignment_blocks: Vec<GutenbergBlock>,
61    ) -> Self {
62        Self::Quizzes {
63            ids,
64            name: name.into(),
65            needs_peer_review: needs_pr,
66            deadline,
67            spec,
68            assignment_blocks,
69        }
70    }
71
72    pub fn tmc(
73        name: impl Into<String>,
74        ids: ExerciseIds,
75        deadline: Option<DateTime<Utc>>,
76        spec: JsonSource,
77        assignment_blocks: Vec<GutenbergBlock>,
78    ) -> Self {
79        Self::Tmc {
80            ids,
81            name: name.into(),
82            deadline,
83            spec,
84            assignment_blocks,
85        }
86    }
87
88    pub fn example_exercise(
89        name: impl Into<String>,
90        ids: ExerciseIds,
91        assignment_blocks: Vec<GutenbergBlock>,
92        options: Value,
93    ) -> Self {
94        Self::ExampleExercise {
95            ids,
96            assignment_blocks,
97            name: name.into(),
98            options,
99        }
100    }
101
102    pub(crate) fn to_cms(
103        &self,
104        _cx: &SeedContext<'_>,
105    ) -> Result<(
106        GutenbergBlock,
107        CmsPageExercise,
108        CmsPageExerciseSlide,
109        CmsPageExerciseTask,
110    )> {
111        Ok(match self {
112            ExerciseBuilder::Quizzes {
113                ids,
114                name,
115                needs_peer_review: _,
116                deadline: _,
117                spec,
118                assignment_blocks,
119            } => {
120                let spec_v = spec.load()?;
121                let assignment_json = serde_json::to_value(assignment_blocks)?;
122                let (block, exercise, mut slides, mut tasks) = example_exercise_flexible(
123                    ids.exercise_id,
124                    name.clone(),
125                    vec![(
126                        ids.slide_id,
127                        vec![(ids.task_id, "quizzes".to_string(), assignment_json, spec_v)],
128                    )],
129                    ids.block_id,
130                );
131                let slide = slides.swap_remove(0);
132                let task = tasks.swap_remove(0);
133                (block, exercise, slide, task)
134            }
135
136            ExerciseBuilder::Tmc {
137                ids,
138                name,
139                deadline: _,
140                spec,
141                assignment_blocks,
142            } => {
143                let spec_v = spec.load()?;
144                let assignment_json = serde_json::to_value(assignment_blocks)?;
145                let (block, exercise, mut slides, mut tasks) = example_exercise_flexible(
146                    ids.exercise_id,
147                    name.clone(),
148                    vec![(
149                        ids.slide_id,
150                        vec![(ids.task_id, "tmc".to_string(), assignment_json, spec_v)],
151                    )],
152                    ids.block_id,
153                );
154                let slide = slides.swap_remove(0);
155                let task = tasks.swap_remove(0);
156                (block, exercise, slide, task)
157            }
158
159            ExerciseBuilder::ExampleExercise {
160                ids,
161                assignment_blocks,
162                name,
163                options,
164            } => {
165                let assignment_json = serde_json::to_value(assignment_blocks)?;
166                let (_block, exercise, slides, tasks) = example_exercise_flexible(
167                    ids.exercise_id,
168                    name.clone(),
169                    vec![(
170                        ids.slide_id,
171                        vec![(
172                            ids.task_id,
173                            "example-exercise".to_string(),
174                            assignment_json,
175                            options.clone(),
176                        )],
177                    )],
178                    ids.block_id,
179                );
180
181                let slide = slides
182                    .into_iter()
183                    .next()
184                    .expect("example exercise must produce one slide");
185                let task = tasks
186                    .into_iter()
187                    .next()
188                    .expect("example exercise must produce one task");
189
190                let b = GutenbergBlock {
191                    client_id: ids.block_id,
192                    name: "moocfi/exercise".to_string(),
193                    is_valid: true,
194                    attributes: attributes! {
195                        "id": ids.exercise_id,
196                        "name": exercise.name,
197                        "dropCap": false,
198                    },
199                    inner_blocks: vec![],
200                };
201
202                (b, exercise, slide, task)
203            }
204        })
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::programs::seed::seed_helpers::paragraph;
212    use chrono::Utc;
213    use serde_json::json;
214    use uuid::Uuid;
215
216    fn create_test_ids() -> ExerciseIds {
217        ExerciseIds {
218            exercise_id: Uuid::new_v4(),
219            slide_id: Uuid::new_v4(),
220            task_id: Uuid::new_v4(),
221            block_id: Uuid::new_v4(),
222        }
223    }
224
225    #[test]
226    fn test_quizzes_builder_creation() {
227        let ids = create_test_ids();
228        let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
229        let spec = JsonSource::Inline(json!({"type": "multiple_choice"}));
230        let deadline = Some(Utc::now());
231
232        let builder = ExerciseBuilder::quizzes(
233            "Test Quiz",
234            ids.clone(),
235            true,
236            deadline,
237            spec,
238            assignment_blocks.clone(),
239        );
240
241        match builder {
242            ExerciseBuilder::Quizzes {
243                ids: builder_ids,
244                name,
245                needs_peer_review,
246                deadline: builder_deadline,
247                spec: _,
248                assignment_blocks: builder_blocks,
249            } => {
250                assert_eq!(name, "Test Quiz");
251                assert!(needs_peer_review);
252                assert_eq!(builder_deadline, deadline);
253                assert_eq!(builder_ids.exercise_id, ids.exercise_id);
254                assert_eq!(builder_blocks.len(), 1);
255                assert_eq!(builder_blocks[0].name, "core/paragraph");
256            }
257            _ => panic!("Expected Quizzes variant"),
258        }
259    }
260
261    #[test]
262    fn test_tmc_builder_creation() {
263        let ids = create_test_ids();
264        let assignment_blocks = vec![paragraph("Write an `add` function.", Uuid::new_v4())];
265        let spec = JsonSource::Inline(json!({"type": "tmc"}));
266        let deadline = Some(Utc::now());
267
268        let builder = ExerciseBuilder::tmc(
269            "Test TMC",
270            ids.clone(),
271            deadline,
272            spec,
273            assignment_blocks.clone(),
274        );
275
276        match builder {
277            ExerciseBuilder::Tmc {
278                ids: builder_ids,
279                name,
280                deadline: builder_deadline,
281                spec: _,
282                assignment_blocks: builder_blocks,
283            } => {
284                assert_eq!(name, "Test TMC");
285                assert_eq!(builder_deadline, deadline);
286                assert_eq!(builder_ids.exercise_id, ids.exercise_id);
287                assert_eq!(builder_blocks.len(), 1);
288                assert_eq!(builder_blocks[0].name, "core/paragraph");
289            }
290            _ => panic!("Expected Tmc variant"),
291        }
292    }
293
294    #[test]
295    fn test_example_exercise_builder_creation() {
296        let ids = create_test_ids();
297        let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
298        let options = json!({
299            "options": [
300                {"text": "Option 1", "correct": true},
301                {"text": "Option 2", "correct": false}
302            ]
303        });
304
305        let builder = ExerciseBuilder::example_exercise(
306            "Test Example Exercise",
307            ids.clone(),
308            assignment_blocks.clone(),
309            options.clone(),
310        );
311
312        match builder {
313            ExerciseBuilder::ExampleExercise {
314                ids: builder_ids,
315                assignment_blocks: builder_blocks,
316                name,
317                options: builder_options,
318            } => {
319                assert_eq!(name, "Test Example Exercise");
320                assert_eq!(builder_ids.exercise_id, ids.exercise_id);
321                assert_eq!(builder_blocks.len(), 1);
322                assert_eq!(builder_blocks[0].name, "core/paragraph");
323                assert_eq!(builder_options, options);
324            }
325            _ => panic!("Expected ExampleExercise variant"),
326        }
327    }
328
329    #[test]
330    fn test_exercise_ids_structure() {
331        let ids = create_test_ids();
332
333        assert_ne!(ids.exercise_id, ids.slide_id);
334        assert_ne!(ids.exercise_id, ids.task_id);
335        assert_ne!(ids.exercise_id, ids.block_id);
336        assert_ne!(ids.slide_id, ids.task_id);
337        assert_ne!(ids.slide_id, ids.block_id);
338        assert_ne!(ids.task_id, ids.block_id);
339    }
340
341    #[test]
342    fn test_builder_without_deadline() {
343        let ids = create_test_ids();
344        let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
345        let spec = JsonSource::Inline(json!({"type": "multiple_choice"}));
346
347        let builder = ExerciseBuilder::quizzes(
348            "No Deadline Quiz",
349            ids,
350            false,
351            None,
352            spec,
353            assignment_blocks,
354        );
355
356        match builder {
357            ExerciseBuilder::Quizzes {
358                deadline,
359                needs_peer_review,
360                ..
361            } => {
362                assert_eq!(deadline, None);
363                assert!(!needs_peer_review);
364            }
365            _ => panic!("Expected Quizzes variant"),
366        }
367    }
368
369    #[test]
370    fn test_string_conversion_for_names() {
371        let ids = create_test_ids();
372        let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
373        let spec = JsonSource::Inline(json!({"type": "multiple_choice"}));
374
375        let builder1 = ExerciseBuilder::quizzes(
376            "String name".to_string(),
377            ids.clone(),
378            false,
379            None,
380            spec.clone(),
381            assignment_blocks.clone(),
382        );
383
384        let builder2 =
385            ExerciseBuilder::quizzes("&str name", ids, false, None, spec, assignment_blocks);
386
387        match (builder1, builder2) {
388            (
389                ExerciseBuilder::Quizzes { name: name1, .. },
390                ExerciseBuilder::Quizzes { name: name2, .. },
391            ) => {
392                assert_eq!(name1, "String name");
393                assert_eq!(name2, "&str name");
394            }
395            _ => panic!("Expected Quizzes variants"),
396        }
397    }
398}