Skip to main content

eulumdat_quiz/
lib.rs

1//! Photometric knowledge quiz engine for lighting professionals.
2//!
3//! Pure Rust library with no UI dependencies. Designed to be FFI-safe
4//! (uniffi/PyO3) for use across TUI, Web, Desktop, iOS, Android, and Python.
5
6pub mod i18n;
7mod questions;
8mod session;
9
10pub use session::QuizSession;
11
12/// Knowledge domain categories.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
14pub enum Category {
15    /// LDT file structure, line numbers, field meanings
16    EulumdatFormat,
17    /// LM-63 versions, keywords, photometric types A/B/C
18    IesFormat,
19    /// 5 symmetry types, data expansion, compression ratios
20    Symmetry,
21    /// C-plane angles, gamma angles, nadir/zenith, Type B↔C
22    CoordinateSystems,
23    /// LOR, DFF, beam/field angles, CIE flux codes, efficacy
24    PhotometricCalc,
25    /// TM-15-11 zones, thresholds, B/U/G 0-5 scale
26    BugRating,
27    /// UGR formula, standard rooms, CIE 117
28    UgrGlare,
29    /// CCT, CRI groups, TM-30 Rf/Rg, Duv, SPD
30    ColorScience,
31    /// PAR, PPF, PPFD, DLI, R:FR ratio, spectral zones
32    Horticultural,
33    /// TM-32-24 parameters, NEMA GUIDs, housing shapes
34    BimIntegration,
35    /// TM-33-23/ATLA S001, XML vs JSON, spectral support
36    ModernFormats,
37    /// Warning codes W001-W046, error codes E001-E006
38    Validation,
39    /// lux/fc, m/ft, mm/in, cd/klm, lm/W
40    Units,
41    /// Polar, cartesian, heatmap, cone, butterfly, isolux
42    DiagramTypes,
43    /// Reading and interpreting polar light distribution diagrams
44    DiagramReading,
45    /// CIE, IES, NEMA, EN 13201, IDA, LEED, Title 24
46    Standards,
47}
48
49impl Category {
50    /// Stable string key for i18n lookup (matches JSON locale keys).
51    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    /// Human-readable label for display.
73    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    /// All category variants.
95    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/// Difficulty level for questions.
118#[derive(
119    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
120)]
121pub enum Difficulty {
122    /// Format basics, unit definitions, simple facts
123    Beginner,
124    /// Calculations, thresholds, standard comparisons
125    Intermediate,
126    /// Cross-standard nuances, edge cases, formulas
127    Expert,
128}
129
130impl Difficulty {
131    /// Stable string key for i18n lookup.
132    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/// A single quiz question with 4 multiple-choice options.
150#[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    /// 4 choices (A-D)
157    pub options: Vec<String>,
158    /// Index of the correct option (0-3)
159    pub correct_index: u8,
160    /// Explanation shown after answering
161    pub explanation: String,
162    /// Reference standard or specification
163    pub reference: Option<String>,
164}
165
166/// Configuration for creating a quiz session.
167#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
168pub struct QuizConfig {
169    /// Filter by categories (empty = all)
170    pub categories: Vec<Category>,
171    /// Filter by difficulty (None = mixed)
172    pub difficulty: Option<Difficulty>,
173    /// Number of questions (0 = all matching)
174    pub num_questions: u32,
175    /// Shuffle question order
176    pub shuffle: bool,
177    /// Seed for reproducible shuffle
178    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/// Score for a specific category.
194#[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/// Score for a specific difficulty level.
202#[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/// Overall quiz score with breakdowns.
210#[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    /// Percentage score (0.0-100.0).
222    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/// Result of answering a question.
232#[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
240/// Static quiz bank with all available questions.
241pub struct QuizBank;
242
243impl QuizBank {
244    /// All questions in the bank.
245    pub fn all_questions() -> Vec<Question> {
246        questions::all_questions()
247    }
248
249    /// Available categories with question counts.
250    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    /// Total number of questions.
263    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        // All questions should be BugRating
387        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}