1pub mod i18n;
7mod questions;
8mod session;
9
10pub use session::QuizSession;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
14pub enum Category {
15 EulumdatFormat,
17 IesFormat,
19 Symmetry,
21 CoordinateSystems,
23 PhotometricCalc,
25 BugRating,
27 UgrGlare,
29 ColorScience,
31 Horticultural,
33 BimIntegration,
35 ModernFormats,
37 Validation,
39 Units,
41 DiagramTypes,
43 DiagramReading,
45 Standards,
47}
48
49impl Category {
50 pub fn key(&self) -> &'static str {
52 match self {
53 Self::EulumdatFormat => "eulumdat_format",
54 Self::IesFormat => "ies_format",
55 Self::Symmetry => "symmetry",
56 Self::CoordinateSystems => "coordinate_systems",
57 Self::PhotometricCalc => "photometric_calc",
58 Self::BugRating => "bug_rating",
59 Self::UgrGlare => "ugr_glare",
60 Self::ColorScience => "color_science",
61 Self::Horticultural => "horticultural",
62 Self::BimIntegration => "bim_integration",
63 Self::ModernFormats => "modern_formats",
64 Self::Validation => "validation",
65 Self::Units => "units",
66 Self::DiagramTypes => "diagram_types",
67 Self::DiagramReading => "diagram_reading",
68 Self::Standards => "standards",
69 }
70 }
71
72 pub fn label(&self) -> &'static str {
74 match self {
75 Self::EulumdatFormat => "EULUMDAT Format",
76 Self::IesFormat => "IES Format",
77 Self::Symmetry => "Symmetry",
78 Self::CoordinateSystems => "Coordinate Systems",
79 Self::PhotometricCalc => "Photometric Calculations",
80 Self::BugRating => "BUG Rating",
81 Self::UgrGlare => "UGR & Glare",
82 Self::ColorScience => "Color Science",
83 Self::Horticultural => "Horticultural Lighting",
84 Self::BimIntegration => "BIM Integration",
85 Self::ModernFormats => "Modern Formats",
86 Self::Validation => "Validation",
87 Self::Units => "Units & Conversions",
88 Self::DiagramTypes => "Diagram Types",
89 Self::DiagramReading => "Diagram Reading",
90 Self::Standards => "Standards & Compliance",
91 }
92 }
93
94 pub fn all() -> Vec<Category> {
96 vec![
97 Self::EulumdatFormat,
98 Self::IesFormat,
99 Self::Symmetry,
100 Self::CoordinateSystems,
101 Self::PhotometricCalc,
102 Self::BugRating,
103 Self::UgrGlare,
104 Self::ColorScience,
105 Self::Horticultural,
106 Self::BimIntegration,
107 Self::ModernFormats,
108 Self::Validation,
109 Self::Units,
110 Self::DiagramTypes,
111 Self::DiagramReading,
112 Self::Standards,
113 ]
114 }
115}
116
117#[derive(
119 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
120)]
121pub enum Difficulty {
122 Beginner,
124 Intermediate,
126 Expert,
128}
129
130impl Difficulty {
131 pub fn key(&self) -> &'static str {
133 match self {
134 Self::Beginner => "beginner",
135 Self::Intermediate => "intermediate",
136 Self::Expert => "expert",
137 }
138 }
139
140 pub fn label(&self) -> &'static str {
141 match self {
142 Self::Beginner => "Beginner",
143 Self::Intermediate => "Intermediate",
144 Self::Expert => "Expert",
145 }
146 }
147}
148
149#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
151pub struct Question {
152 pub id: u32,
153 pub category: Category,
154 pub difficulty: Difficulty,
155 pub text: String,
156 pub options: Vec<String>,
158 pub correct_index: u8,
160 pub explanation: String,
162 pub reference: Option<String>,
164}
165
166#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
168pub struct QuizConfig {
169 pub categories: Vec<Category>,
171 pub difficulty: Option<Difficulty>,
173 pub num_questions: u32,
175 pub shuffle: bool,
177 pub seed: Option<u64>,
179}
180
181impl Default for QuizConfig {
182 fn default() -> Self {
183 Self {
184 categories: vec![],
185 difficulty: None,
186 num_questions: 10,
187 shuffle: true,
188 seed: None,
189 }
190 }
191}
192
193#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
195pub struct CategoryScore {
196 pub category: Category,
197 pub correct: u32,
198 pub total: u32,
199}
200
201#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
203pub struct DifficultyScore {
204 pub difficulty: Difficulty,
205 pub correct: u32,
206 pub total: u32,
207}
208
209#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
211pub struct QuizScore {
212 pub correct: u32,
213 pub wrong: u32,
214 pub skipped: u32,
215 pub total: u32,
216 pub by_category: Vec<CategoryScore>,
217 pub by_difficulty: Vec<DifficultyScore>,
218}
219
220impl QuizScore {
221 pub fn percentage(&self) -> f64 {
223 if self.total == 0 {
224 0.0
225 } else {
226 self.correct as f64 / self.total as f64 * 100.0
227 }
228 }
229}
230
231#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
233pub struct AnswerResult {
234 pub is_correct: bool,
235 pub correct_index: u8,
236 pub explanation: String,
237 pub reference: Option<String>,
238}
239
240pub struct QuizBank;
242
243impl QuizBank {
244 pub fn all_questions() -> Vec<Question> {
246 questions::all_questions()
247 }
248
249 pub fn categories() -> Vec<(Category, u32)> {
251 let questions = Self::all_questions();
252 Category::all()
253 .into_iter()
254 .map(|cat| {
255 let count = questions.iter().filter(|q| q.category == cat).count() as u32;
256 (cat, count)
257 })
258 .filter(|(_, count)| *count > 0)
259 .collect()
260 }
261
262 pub fn total_count() -> u32 {
264 Self::all_questions().len() as u32
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_all_questions_valid() {
274 let questions = QuizBank::all_questions();
275 assert!(
276 questions.len() >= 100,
277 "Expected at least 100 questions, got {}",
278 questions.len()
279 );
280
281 for q in &questions {
282 assert_eq!(q.options.len(), 4, "Question {} must have 4 options", q.id);
283 assert!(
284 q.correct_index < 4,
285 "Question {} has invalid correct_index {}",
286 q.id,
287 q.correct_index
288 );
289 assert!(!q.text.is_empty(), "Question {} has empty text", q.id);
290 assert!(
291 !q.explanation.is_empty(),
292 "Question {} has empty explanation",
293 q.id
294 );
295 }
296 }
297
298 #[test]
299 fn test_no_duplicate_ids() {
300 let questions = QuizBank::all_questions();
301 let mut ids: Vec<u32> = questions.iter().map(|q| q.id).collect();
302 ids.sort();
303 ids.dedup();
304 assert_eq!(ids.len(), questions.len(), "Duplicate question IDs found");
305 }
306
307 #[test]
308 fn test_all_categories_have_questions() {
309 let questions = QuizBank::all_questions();
310 for cat in Category::all() {
311 let count = questions.iter().filter(|q| q.category == cat).count();
312 assert!(
313 count >= 5,
314 "Category {:?} has only {} questions (need >= 5)",
315 cat,
316 count
317 );
318 }
319 }
320
321 #[test]
322 fn test_all_difficulties_have_questions() {
323 let questions = QuizBank::all_questions();
324 for diff in [
325 Difficulty::Beginner,
326 Difficulty::Intermediate,
327 Difficulty::Expert,
328 ] {
329 let count = questions.iter().filter(|q| q.difficulty == diff).count();
330 assert!(
331 count >= 20,
332 "Difficulty {:?} has only {} questions (need >= 20)",
333 diff,
334 count
335 );
336 }
337 }
338
339 #[test]
340 fn test_quiz_session_basic() {
341 let config = QuizConfig {
342 num_questions: 5,
343 shuffle: false,
344 ..Default::default()
345 };
346 let mut session = QuizSession::new(config);
347 assert!(!session.is_finished());
348
349 let (idx, total) = session.progress();
350 assert_eq!(idx, 0);
351 assert_eq!(total, 5);
352
353 let q = session.current_question().unwrap();
354 let result = session.answer(q.correct_index);
355 assert!(result.is_correct);
356
357 let score = session.score();
358 assert_eq!(score.correct, 1);
359 assert_eq!(score.wrong, 0);
360 }
361
362 #[test]
363 fn test_quiz_session_skip() {
364 let config = QuizConfig {
365 num_questions: 3,
366 shuffle: false,
367 ..Default::default()
368 };
369 let mut session = QuizSession::new(config);
370 session.skip();
371 let score = session.score();
372 assert_eq!(score.skipped, 1);
373 }
374
375 #[test]
376 fn test_quiz_config_filter_category() {
377 let config = QuizConfig {
378 categories: vec![Category::BugRating],
379 num_questions: 0,
380 shuffle: false,
381 ..Default::default()
382 };
383 let session = QuizSession::new(config);
384 let (_, total) = session.progress();
385 assert!(total > 0);
386 for i in 0..total {
388 let q = &session.questions[i];
389 assert_eq!(q.category, Category::BugRating);
390 }
391 }
392
393 #[test]
394 fn test_quiz_config_filter_difficulty() {
395 let config = QuizConfig {
396 difficulty: Some(Difficulty::Expert),
397 num_questions: 0,
398 shuffle: false,
399 ..Default::default()
400 };
401 let session = QuizSession::new(config);
402 for q in &session.questions {
403 assert_eq!(q.difficulty, Difficulty::Expert);
404 }
405 }
406
407 #[test]
408 fn test_score_percentage() {
409 let score = QuizScore {
410 correct: 7,
411 wrong: 3,
412 skipped: 0,
413 total: 10,
414 ..Default::default()
415 };
416 assert!((score.percentage() - 70.0).abs() < f64::EPSILON);
417 }
418}