1use crate::{
2 answer::Answer,
3 quiz::{EmptyError, QuizError},
4 xml_util::{write_named_formatted_scope, write_text_tag},
5};
6use std::fs::File;
7use xml::writer::{EventWriter, XmlEvent};
8
9pub trait Question {
11 fn get_name(&self) -> &str;
13 fn get_description(&self) -> &str;
15 fn set_text_format(&mut self, format: TextFormat);
17 fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError>;
20 fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError>;
22}
23
24#[derive(Debug, Default, Copy, Clone)]
26pub enum TextFormat {
27 #[default]
28 HTML,
29 Moodle,
30 Markdown,
31 PlainText,
32}
33impl TextFormat {
34 pub fn name(&self) -> &'static str {
35 match self {
36 TextFormat::HTML => "html",
37 TextFormat::Moodle => "moodle_auto_format",
38 TextFormat::Markdown => "markdown",
39 TextFormat::PlainText => "plain_text",
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
54struct QuestionBase {
55 pub name: String,
56 pub description: String,
57 pub question_text_format: TextFormat,
58 pub answers: Vec<Answer>,
59}
60impl QuestionBase {
61 fn new(name: String, description: String) -> Self {
62 Self {
63 name,
64 description,
65 question_text_format: TextFormat::default(),
66 answers: Vec::new(),
67 }
68 }
69 fn check_answer_fraction(&mut self) -> Result<(), QuizError> {
72 let mut total_fraction = 0usize;
73 for answer in &self.answers {
74 total_fraction += answer.fraction as usize;
75 }
76 if total_fraction < 100 {
77 self.answers.clear();
78 return Err(QuizError::AnswerFractionError(
79 "The total fraction of answers must be at least 100".to_string(),
80 ));
81 }
82 Ok(())
83 }
84}
85
86impl Question for QuestionBase {
87 fn get_name(&self) -> &str {
88 self.name.as_str()
89 }
90 fn get_description(&self) -> &str {
91 self.description.as_str()
92 }
93 fn set_text_format(&mut self, format: TextFormat) {
94 self.question_text_format = format;
95 }
96 fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
97 self.answers.extend(answers);
98 self.check_answer_fraction()?;
99 Ok(())
100 }
101 fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
103 writer.write(XmlEvent::start_element("name"))?;
104 write_text_tag(writer, self.name.as_str(), false)?;
105 writer.write(XmlEvent::end_element())?;
106 writer.write(
107 XmlEvent::start_element("questiontext")
108 .attr("format", self.question_text_format.name()),
109 )?;
110 write_text_tag(writer, self.description.as_str(), true)?;
112 writer.write(XmlEvent::end_element())?;
113 if self.answers.is_empty() {
114 return Err(EmptyError.into());
115 }
116 for answer in &self.answers {
117 answer.to_xml(writer)?;
118 }
119 Ok(())
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct MultiChoiceQuestion {
126 base: QuestionBase,
127 pub single: bool,
128 pub shuffleanswers: bool, pub correctfeedback: String,
130 pub partiallycorrectfeedback: String,
131 pub incorrectfeedback: String,
132 pub answernumbering: String,
134}
135
136impl MultiChoiceQuestion {
137 #[allow(clippy::too_many_arguments)]
139 pub fn new(
140 name: String,
141 description: String,
142 single: Option<bool>,
143 shuffleanswers: Option<bool>,
144 correctfeedback: Option<String>,
145 partiallycorrectfeedback: Option<String>,
146 incorrectfeedback: Option<String>,
147 answernumbering: Option<String>,
148 ) -> Self {
149 Self {
150 base: QuestionBase::new(name, description),
151 single: single.unwrap_or(true),
152 shuffleanswers: shuffleanswers.unwrap_or(true),
153 correctfeedback: correctfeedback.unwrap_or_default(),
154 partiallycorrectfeedback: partiallycorrectfeedback.unwrap_or_default(),
155 incorrectfeedback: incorrectfeedback.unwrap_or_default(),
156 answernumbering: answernumbering.unwrap_or_default(),
157 }
158 }
159}
160
161impl Question for MultiChoiceQuestion {
162 fn get_name(&self) -> &str {
163 self.base.get_name()
164 }
165 fn get_description(&self) -> &str {
166 self.base.get_description()
167 }
168 fn set_text_format(&mut self, format: TextFormat) {
169 self.base.question_text_format = format;
170 }
171 fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
172 self.base.add_answers(answers)
173 }
174 fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
175 writer.write(XmlEvent::start_element("question").attr("type", "multichoice"))?;
177 self.base.to_xml(writer)?;
179
180 write_named_formatted_scope(writer, "single", None, |writer| {
181 writer.write(XmlEvent::characters(&self.single.to_string()))?;
182 Ok(())
183 })?;
184 write_named_formatted_scope(writer, "shuffleanswers", None, |writer| {
185 writer.write(XmlEvent::characters(
186 &(self.shuffleanswers as u8).to_string(),
187 ))?;
188 Ok(())
189 })?;
190 write_named_formatted_scope(
191 writer,
192 "correctfeedback",
193 TextFormat::default().into(),
194 |writer| write_text_tag(writer, &self.correctfeedback, true),
195 )?;
196 write_named_formatted_scope(
197 writer,
198 "partiallycorrectfeedback",
199 TextFormat::default().into(),
200 |writer| write_text_tag(writer, &self.partiallycorrectfeedback, true),
201 )?;
202 write_named_formatted_scope(
203 writer,
204 "incorrectfeedback",
205 TextFormat::default().into(),
206 |writer| write_text_tag(writer, &self.incorrectfeedback, true),
207 )?;
208 write_named_formatted_scope(writer, "answernumbering", None, |writer| {
209 writer.write(XmlEvent::characters(&self.answernumbering.to_string()))?;
210 Ok(())
211 })?;
212 writer.write(XmlEvent::end_element())?;
214 Ok(())
215 }
216}
217
218#[derive(Debug, Clone)]
220pub struct TrueFalseQuestion {
221 base: QuestionBase,
222}
223impl TrueFalseQuestion {
224 pub fn new(name: String, description: String) -> Self {
225 Self {
226 base: QuestionBase::new(name, description),
227 }
228 }
229}
230
231impl Question for TrueFalseQuestion {
232 fn get_name(&self) -> &str {
233 self.base.get_name()
234 }
235 fn get_description(&self) -> &str {
236 self.base.get_description()
237 }
238 fn set_text_format(&mut self, format: TextFormat) {
239 self.base.question_text_format = format;
240 }
241 fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
242 if answers.len() != 2 {
243 return Err(QuizError::AnswerCountError(
244 "True/False questions must have exactly 2 answers".to_string(),
245 ));
246 }
247 if answers[0].fraction == 100 {
248 if answers[1].fraction == 0 {
249 } else {
251 return Err(QuizError::AnswerFractionError(
252 "Only fractions 100 and 0 are allowed in True/False questions".to_string(),
253 ));
254 }
255 } else if answers[1].fraction == 100 {
256 if answers[0].fraction == 0 {
257 } else {
259 return Err(QuizError::AnswerFractionError(
260 "Only fractions 100 and 0 are allowed in True/False questions".to_string(),
261 ));
262 }
263 } else {
264 return Err(QuizError::AnswerFractionError(
265 "Only fractions 100 and 0 are allowed in True/False questions".to_string(),
266 ));
267 }
268 self.base.add_answers(answers)
269 }
270 fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
271 writer.write(XmlEvent::start_element("question").attr("type", "truefalse"))?;
273 self.base.to_xml(writer)?;
275 writer.write(XmlEvent::end_element())?;
277 Ok(())
278 }
279}
280
281#[derive(Debug, Clone)]
283pub struct ShortAnswerQuestion {
284 base: QuestionBase,
285 pub usecase: bool,
287}
288
289impl ShortAnswerQuestion {
290 pub fn new(name: String, description: String, usecase: Option<bool>) -> Self {
291 Self {
292 base: QuestionBase::new(name, description),
293 usecase: usecase.unwrap_or_default(),
294 }
295 }
296}
297
298impl Question for ShortAnswerQuestion {
299 fn get_name(&self) -> &str {
300 self.base.get_name()
301 }
302 fn get_description(&self) -> &str {
303 self.base.get_description()
304 }
305 fn set_text_format(&mut self, format: TextFormat) {
306 self.base.question_text_format = format;
307 }
308 fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
309 self.base.add_answers(answers)
310 }
311 fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
312 writer.write(XmlEvent::start_element("question").attr("type", "shortanswer"))?;
314 self.base.to_xml(writer)?;
316 write_named_formatted_scope(writer, "usecase", None, |writer| {
317 writer.write(XmlEvent::characters(&(self.usecase as u8).to_string()))?;
318 Ok(())
319 })?;
320 writer.write(XmlEvent::end_element())?;
322 Ok(())
323 }
324}
325
326#[derive(Debug, Clone)]
328pub struct EssayQuestion {
329 base: QuestionBase,
330}
331
332impl EssayQuestion {
333 pub fn new(name: String, description: String) -> Self {
334 Self {
335 base: QuestionBase::new(name, description),
336 }
337 }
338}
339
340impl Question for EssayQuestion {
341 fn get_name(&self) -> &str {
342 self.base.get_name()
343 }
344 fn get_description(&self) -> &str {
345 self.base.get_description()
346 }
347 fn set_text_format(&mut self, format: TextFormat) {
348 self.base.question_text_format = format;
349 }
350 fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
351 if !answers.is_empty() {
352 return Err(QuizError::AnswerCountError(
353 "Essay questions must not have any answers".to_string(),
354 ));
355 }
356 Ok(())
357 }
358 fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
359 writer.write(XmlEvent::start_element("question").attr("type", "essay"))?;
361 self.base.to_xml(writer)?;
363 writer.write(XmlEvent::end_element())?;
365 Ok(())
366 }
367}
368
369pub enum QuestionType {
380 Multichoice(MultiChoiceQuestion),
381 TrueFalse(TrueFalseQuestion),
382 ShortAnswer(ShortAnswerQuestion),
383 Essay(EssayQuestion),
386 }
389impl QuestionType {
390 pub fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
391 match self {
392 QuestionType::Multichoice(q) => q.to_xml(writer),
393 QuestionType::TrueFalse(q) => q.to_xml(writer),
394 QuestionType::ShortAnswer(q) => q.to_xml(writer),
395 QuestionType::Essay(q) => q.to_xml(writer),
396 }
397 }
398}
399
400macro_rules! impl_from_question {
402 ($(($question_type:ty, $variant:ident)),+) => {
403 $(
404 impl<Q> From<$question_type> for Vec<Q>
405 where
406 Q: Question,
407 $question_type: Into<Q>,
408 {
409 fn from(question: $question_type) -> Self {
410 vec![question.into()]
411 }
412 }
413
414 impl From<$question_type> for Vec<Box<dyn Question>>
415 where
416 $question_type: Question + 'static,
417 {
418 fn from(question: $question_type) -> Self {
419 vec![Box::new(question)]
420 }
421 }
422
423 impl From<$question_type> for QuestionType {
424 fn from(question: $question_type) -> Self {
425 QuestionType::$variant(question)
426 }
427 }
428
429 impl From<$question_type> for Vec<QuestionType> {
430 fn from(question: $question_type) -> Self {
431 vec![QuestionType::$variant(question)]
432 }
433 }
434 )+
435 };
436}
437
438impl_from_question!(
439 (MultiChoiceQuestion, Multichoice),
440 (TrueFalseQuestion, TrueFalse),
441 (ShortAnswerQuestion, ShortAnswer),
442 (EssayQuestion, Essay)
443);
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use std::io::{Read, Seek};
449 use xml::writer::EmitterConfig;
450
451 #[test]
452 fn test_multichoice_question_xml() {
453 let mut tmp_file = tempfile::tempfile().unwrap();
454 let mut writer = EmitterConfig::new()
455 .perform_indent(true)
456 .create_writer(&tmp_file);
457 let multichoice_question = MultiChoiceQuestion {
458 base: QuestionBase {
459 name: "Name of question".to_string(),
460 description: "What is the answer to this question?".to_string(),
461 question_text_format: TextFormat::HTML,
462 answers: vec![
463 Answer {
464 fraction: 100,
465 text: "The correct answer".to_string(),
466 feedback: "Correct!".to_string().into(),
467 text_format: TextFormat::HTML,
468 },
469 Answer {
470 fraction: 0,
471 text: "A distractor".to_string(),
472 feedback: "Ooops!".to_string().into(),
473 text_format: TextFormat::HTML,
474 },
475 Answer {
476 fraction: 0,
477 text: "Another distractor".to_string(),
478 feedback: "Ooops!".to_string().into(),
479 text_format: TextFormat::HTML,
480 },
481 ],
482 },
483 single: true,
484 shuffleanswers: true,
485 correctfeedback: "Correct!".to_string(),
486 partiallycorrectfeedback: "Partially correct!".to_string(),
487 incorrectfeedback: "Incorrect!".to_string(),
488 answernumbering: "abc".to_string(),
489 };
490 multichoice_question.to_xml(&mut writer).unwrap();
491
492 let mut buf = String::new();
493 tmp_file.seek(std::io::SeekFrom::Start(0)).unwrap();
494 tmp_file.read_to_string(&mut buf).unwrap();
495 print!("{buf}");
496 let expected = r#"<?xml version="1.0" encoding="utf-8"?>
497<question type="multichoice">
498 <name>
499 <text>Name of question</text>
500 </name>
501 <questiontext format="html">
502 <text><![CDATA[What is the answer to this question?]]></text>
503 </questiontext>
504 <answer fraction="100" format="html">
505 <text>The correct answer</text>
506 <feedback format="html">
507 <text>Correct!</text>
508 </feedback>
509 </answer>
510 <answer fraction="0" format="html">
511 <text>A distractor</text>
512 <feedback format="html">
513 <text>Ooops!</text>
514 </feedback>
515 </answer>
516 <answer fraction="0" format="html">
517 <text>Another distractor</text>
518 <feedback format="html">
519 <text>Ooops!</text>
520 </feedback>
521 </answer>
522 <single>true</single>
523 <shuffleanswers>1</shuffleanswers>
524 <correctfeedback format="html">
525 <text><![CDATA[Correct!]]></text>
526 </correctfeedback>
527 <partiallycorrectfeedback format="html">
528 <text><![CDATA[Partially correct!]]></text>
529 </partiallycorrectfeedback>
530 <incorrectfeedback format="html">
531 <text><![CDATA[Incorrect!]]></text>
532 </incorrectfeedback>
533 <answernumbering>abc</answernumbering>
534</question>"#;
535 assert_eq!(expected, buf);
536 }
537 #[test]
538 fn test_truefalse_question_xml() {
539 let mut tmp_file = tempfile::tempfile().unwrap();
540 let mut writer = EmitterConfig::new()
541 .perform_indent(true)
542 .create_writer(&tmp_file);
543 let truefalse_question = TrueFalseQuestion {
544 base: QuestionBase {
545 name: "Name of question".to_string(),
546 description: "What is the answer to this question?".to_string(),
547 question_text_format: TextFormat::HTML,
548 answers: vec![
549 Answer {
550 fraction: 100,
551 text: "True".to_string(),
552 feedback: "Correct!".to_string().into(),
553 text_format: TextFormat::HTML,
554 },
555 Answer {
556 fraction: 0,
557 text: "False".to_string(),
558 feedback: "Ooops!".to_string().into(),
559 text_format: TextFormat::HTML,
560 },
561 ],
562 },
563 };
564 truefalse_question.to_xml(&mut writer).unwrap();
565
566 let mut buf = String::new();
567 tmp_file.seek(std::io::SeekFrom::Start(0)).unwrap();
568 tmp_file.read_to_string(&mut buf).unwrap();
569 let expected = r#"<?xml version="1.0" encoding="utf-8"?>
570<question type="truefalse">
571 <name>
572 <text>Name of question</text>
573 </name>
574 <questiontext format="html">
575 <text><![CDATA[What is the answer to this question?]]></text>
576 </questiontext>
577 <answer fraction="100" format="html">
578 <text>True</text>
579 <feedback format="html">
580 <text>Correct!</text>
581 </feedback>
582 </answer>
583 <answer fraction="0" format="html">
584 <text>False</text>
585 <feedback format="html">
586 <text>Ooops!</text>
587 </feedback>
588 </answer>
589</question>"#;
590 assert_eq!(expected, buf);
591 }
592}