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#[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#[derive(Debug, Clone)]
26pub enum ExerciseBuilder {
27 ExampleExercise {
29 ids: ExerciseIds,
30 assignment_blocks: Vec<GutenbergBlock>,
31 name: String,
32 options: Value,
33 },
34 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 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}