konnektoren_core/challenges/gap_fill/
mod.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, JsonSchema)]
5pub struct GapFill {
6    /// Unique identifier for the challenge
7    pub id: String,
8    /// Display name of the challenge
9    pub name: String,
10    /// Description of the challenge
11    pub description: String,
12    /// Language code
13    pub lang: String,
14    /// List of gap-fill questions
15    pub questions: Vec<GapFillQuestion>,
16}
17
18impl Default for GapFill {
19    fn default() -> Self {
20        let data = include_str!("../../../assets/gap_fill_default.yml");
21        serde_yaml::from_str(data).unwrap()
22    }
23}
24
25#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default, JsonSchema)]
26pub struct GapFillQuestion {
27    /// The sentence with gaps
28    pub sentence: String,
29    /// Gaps to be filled
30    pub gaps: Vec<Gap>,
31    /// Helpful hints
32    pub hints: Vec<String>,
33    /// Translation of the sentence
34    pub translation: String,
35    /// Explanation of the grammar rule
36    pub explanation: String,
37}
38
39#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default, JsonSchema)]
40pub struct Gap {
41    /// Position of the gap in the sentence
42    pub position: usize,
43    /// Available options for this gap
44    pub options: Vec<String>,
45    /// The correct answer
46    pub correct: String,
47}
48
49#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default, JsonSchema)]
50pub struct GapFillAnswer {
51    /// Index of the question being answered
52    pub question_index: usize,
53    /// Answers for each gap
54    pub answers: Vec<String>,
55}
56
57impl GapFill {
58    pub fn check_answer(&self, answer: &GapFillAnswer) -> bool {
59        if let Some(question) = self.questions.get(answer.question_index) {
60            if question.gaps.len() != answer.answers.len() {
61                return false;
62            }
63
64            question
65                .gaps
66                .iter()
67                .zip(answer.answers.iter())
68                .all(|(gap, ans)| gap.correct == *ans)
69        } else {
70            false
71        }
72    }
73
74    pub fn get_feedback(&self, answer: &GapFillAnswer) -> String {
75        if let Some(question) = self.questions.get(answer.question_index) {
76            if self.check_answer(answer) {
77                format!("Correct! {}", question.explanation)
78            } else {
79                let mut feedback = String::from("Incorrect. Hints:\n");
80                for hint in &question.hints {
81                    feedback.push_str(&format!("- {}\n", hint));
82                }
83                feedback
84            }
85        } else {
86            "Invalid question index".to_string()
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_gap_fill_deserialization() {
97        let yaml = r#"
98        id: "past-tense"
99        name: "Past Tense Exercise"
100        description: "Fill in the correct past tense forms"
101        lang: "de"
102        questions:
103          - sentence: "Ich __ nach Berlin __ (fahren)."
104            gaps:
105              - position: 0
106                options: ["bin", "habe", "war"]
107                correct: "bin"
108              - position: 1
109                options: ["gefahren", "gefahrt", "fuhr"]
110                correct: "gefahren"
111            hints:
112              - "Movement verbs use 'sein' as auxiliary"
113              - "The past participle of 'fahren' is 'gefahren'"
114            translation: "I went to Berlin"
115            explanation: "We use 'sein' with verbs of movement and the past participle form"
116        "#;
117
118        let gap_fill: GapFill = serde_yaml::from_str(yaml).unwrap();
119        assert_eq!(gap_fill.id, "past-tense");
120        assert_eq!(gap_fill.questions.len(), 1);
121
122        let question = &gap_fill.questions[0];
123        assert_eq!(question.gaps.len(), 2);
124        assert_eq!(question.gaps[0].correct, "bin");
125    }
126
127    #[test]
128    fn test_check_answer() {
129        let gap_fill = GapFill {
130            id: "test".to_string(),
131            name: "Test".to_string(),
132            description: "Test".to_string(),
133            lang: "de".to_string(),
134            questions: vec![GapFillQuestion {
135                sentence: "Ich __ nach Berlin __ (fahren).".to_string(),
136                gaps: vec![
137                    Gap {
138                        position: 0,
139                        options: vec!["bin".to_string(), "habe".to_string()],
140                        correct: "bin".to_string(),
141                    },
142                    Gap {
143                        position: 1,
144                        options: vec!["gefahren".to_string(), "gefahrt".to_string()],
145                        correct: "gefahren".to_string(),
146                    },
147                ],
148                hints: vec!["Test hint".to_string()],
149                translation: "Test translation".to_string(),
150                explanation: "Test explanation".to_string(),
151            }],
152        };
153
154        let correct_answer = GapFillAnswer {
155            question_index: 0,
156            answers: vec!["bin".to_string(), "gefahren".to_string()],
157        };
158
159        let wrong_answer = GapFillAnswer {
160            question_index: 0,
161            answers: vec!["habe".to_string(), "gefahren".to_string()],
162        };
163
164        assert!(gap_fill.check_answer(&correct_answer));
165        assert!(!gap_fill.check_answer(&wrong_answer));
166    }
167
168    #[test]
169    fn test_get_feedback_correct_and_incorrect() {
170        let gap_fill = GapFill {
171            id: "test".to_string(),
172            name: "Test".to_string(),
173            description: "Test".to_string(),
174            lang: "de".to_string(),
175            questions: vec![GapFillQuestion {
176                sentence: "Ich __ nach Berlin __ (fahren).".to_string(),
177                gaps: vec![
178                    Gap {
179                        position: 0,
180                        options: vec!["bin".to_string(), "habe".to_string()],
181                        correct: "bin".to_string(),
182                    },
183                    Gap {
184                        position: 1,
185                        options: vec!["gefahren".to_string(), "gefahrt".to_string()],
186                        correct: "gefahren".to_string(),
187                    },
188                ],
189                hints: vec!["Test hint".to_string()],
190                translation: "Test translation".to_string(),
191                explanation: "Test explanation".to_string(),
192            }],
193        };
194
195        let correct_answer = GapFillAnswer {
196            question_index: 0,
197            answers: vec!["bin".to_string(), "gefahren".to_string()],
198        };
199        let wrong_answer = GapFillAnswer {
200            question_index: 0,
201            answers: vec!["habe".to_string(), "gefahren".to_string()],
202        };
203        let invalid_answer = GapFillAnswer {
204            question_index: 99,
205            answers: vec![],
206        };
207
208        assert!(
209            gap_fill
210                .get_feedback(&correct_answer)
211                .starts_with("Correct!")
212        );
213        assert!(
214            gap_fill
215                .get_feedback(&wrong_answer)
216                .starts_with("Incorrect.")
217        );
218        assert_eq!(
219            gap_fill.get_feedback(&invalid_answer),
220            "Invalid question index"
221        );
222    }
223}