Skip to main content

eulumdat_quiz_py/
lib.rs

1//! Python bindings for the eulumdat photometric knowledge quiz engine.
2
3use pyo3::prelude::*;
4
5use ::eulumdat_quiz as quiz_core;
6
7// ---------------------------------------------------------------------------
8// Category enum
9// ---------------------------------------------------------------------------
10
11#[pyclass(eq, eq_int)]
12#[derive(Clone, Copy, PartialEq, Eq)]
13pub enum Category {
14    EulumdatFormat = 0,
15    IesFormat = 1,
16    Symmetry = 2,
17    CoordinateSystems = 3,
18    PhotometricCalc = 4,
19    BugRating = 5,
20    UgrGlare = 6,
21    ColorScience = 7,
22    Horticultural = 8,
23    BimIntegration = 9,
24    ModernFormats = 10,
25    Validation = 11,
26    Units = 12,
27    DiagramTypes = 13,
28    DiagramReading = 14,
29    Standards = 15,
30}
31
32#[pymethods]
33impl Category {
34    /// Stable string key for i18n lookup.
35    fn key(&self) -> &'static str {
36        self.to_core().key()
37    }
38
39    /// Human-readable English label.
40    fn label(&self) -> &'static str {
41        self.to_core().label()
42    }
43
44    fn __repr__(&self) -> String {
45        format!("Category.{}", self.label())
46    }
47}
48
49impl Category {
50    fn to_core(self) -> quiz_core::Category {
51        match self {
52            Self::EulumdatFormat => quiz_core::Category::EulumdatFormat,
53            Self::IesFormat => quiz_core::Category::IesFormat,
54            Self::Symmetry => quiz_core::Category::Symmetry,
55            Self::CoordinateSystems => quiz_core::Category::CoordinateSystems,
56            Self::PhotometricCalc => quiz_core::Category::PhotometricCalc,
57            Self::BugRating => quiz_core::Category::BugRating,
58            Self::UgrGlare => quiz_core::Category::UgrGlare,
59            Self::ColorScience => quiz_core::Category::ColorScience,
60            Self::Horticultural => quiz_core::Category::Horticultural,
61            Self::BimIntegration => quiz_core::Category::BimIntegration,
62            Self::ModernFormats => quiz_core::Category::ModernFormats,
63            Self::Validation => quiz_core::Category::Validation,
64            Self::Units => quiz_core::Category::Units,
65            Self::DiagramTypes => quiz_core::Category::DiagramTypes,
66            Self::DiagramReading => quiz_core::Category::DiagramReading,
67            Self::Standards => quiz_core::Category::Standards,
68        }
69    }
70
71    fn from_core(c: quiz_core::Category) -> Self {
72        match c {
73            quiz_core::Category::EulumdatFormat => Self::EulumdatFormat,
74            quiz_core::Category::IesFormat => Self::IesFormat,
75            quiz_core::Category::Symmetry => Self::Symmetry,
76            quiz_core::Category::CoordinateSystems => Self::CoordinateSystems,
77            quiz_core::Category::PhotometricCalc => Self::PhotometricCalc,
78            quiz_core::Category::BugRating => Self::BugRating,
79            quiz_core::Category::UgrGlare => Self::UgrGlare,
80            quiz_core::Category::ColorScience => Self::ColorScience,
81            quiz_core::Category::Horticultural => Self::Horticultural,
82            quiz_core::Category::BimIntegration => Self::BimIntegration,
83            quiz_core::Category::ModernFormats => Self::ModernFormats,
84            quiz_core::Category::Validation => Self::Validation,
85            quiz_core::Category::Units => Self::Units,
86            quiz_core::Category::DiagramTypes => Self::DiagramTypes,
87            quiz_core::Category::DiagramReading => Self::DiagramReading,
88            quiz_core::Category::Standards => Self::Standards,
89        }
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Difficulty enum
95// ---------------------------------------------------------------------------
96
97#[pyclass(eq, eq_int)]
98#[derive(Clone, Copy, PartialEq, Eq)]
99pub enum Difficulty {
100    Beginner = 0,
101    Intermediate = 1,
102    Expert = 2,
103}
104
105#[pymethods]
106impl Difficulty {
107    fn key(&self) -> &'static str {
108        self.to_core().key()
109    }
110
111    fn label(&self) -> &'static str {
112        self.to_core().label()
113    }
114
115    fn __repr__(&self) -> String {
116        format!("Difficulty.{}", self.label())
117    }
118}
119
120impl Difficulty {
121    fn to_core(self) -> quiz_core::Difficulty {
122        match self {
123            Self::Beginner => quiz_core::Difficulty::Beginner,
124            Self::Intermediate => quiz_core::Difficulty::Intermediate,
125            Self::Expert => quiz_core::Difficulty::Expert,
126        }
127    }
128
129    fn from_core(d: quiz_core::Difficulty) -> Self {
130        match d {
131            quiz_core::Difficulty::Beginner => Self::Beginner,
132            quiz_core::Difficulty::Intermediate => Self::Intermediate,
133            quiz_core::Difficulty::Expert => Self::Expert,
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Question (read-only view)
140// ---------------------------------------------------------------------------
141
142#[pyclass]
143#[derive(Clone)]
144pub struct Question {
145    inner: quiz_core::Question,
146}
147
148#[pymethods]
149impl Question {
150    #[getter]
151    fn id(&self) -> u32 {
152        self.inner.id
153    }
154
155    #[getter]
156    fn category(&self) -> Category {
157        Category::from_core(self.inner.category)
158    }
159
160    #[getter]
161    fn difficulty(&self) -> Difficulty {
162        Difficulty::from_core(self.inner.difficulty)
163    }
164
165    #[getter]
166    fn text(&self) -> &str {
167        &self.inner.text
168    }
169
170    #[getter]
171    fn options(&self) -> Vec<String> {
172        self.inner.options.clone()
173    }
174
175    #[getter]
176    fn correct_index(&self) -> u8 {
177        self.inner.correct_index
178    }
179
180    #[getter]
181    fn explanation(&self) -> &str {
182        &self.inner.explanation
183    }
184
185    #[getter]
186    fn reference(&self) -> Option<String> {
187        self.inner.reference.clone()
188    }
189
190    fn __repr__(&self) -> String {
191        format!(
192            "Question(id={}, category={}, difficulty={})",
193            self.inner.id,
194            self.inner.category.label(),
195            self.inner.difficulty.label()
196        )
197    }
198}
199
200// ---------------------------------------------------------------------------
201// AnswerResult
202// ---------------------------------------------------------------------------
203
204#[pyclass]
205#[derive(Clone)]
206pub struct AnswerResult {
207    inner: quiz_core::AnswerResult,
208}
209
210#[pymethods]
211impl AnswerResult {
212    #[getter]
213    fn is_correct(&self) -> bool {
214        self.inner.is_correct
215    }
216
217    #[getter]
218    fn correct_index(&self) -> u8 {
219        self.inner.correct_index
220    }
221
222    #[getter]
223    fn explanation(&self) -> &str {
224        &self.inner.explanation
225    }
226
227    #[getter]
228    fn reference(&self) -> Option<String> {
229        self.inner.reference.clone()
230    }
231
232    fn __repr__(&self) -> String {
233        if self.inner.is_correct {
234            "AnswerResult(correct=True)".to_string()
235        } else {
236            format!(
237                "AnswerResult(correct=False, correct_index={})",
238                self.inner.correct_index
239            )
240        }
241    }
242}
243
244// ---------------------------------------------------------------------------
245// CategoryScore / DifficultyScore
246// ---------------------------------------------------------------------------
247
248#[pyclass]
249#[derive(Clone)]
250pub struct CategoryScore {
251    inner: quiz_core::CategoryScore,
252}
253
254#[pymethods]
255impl CategoryScore {
256    #[getter]
257    fn category(&self) -> Category {
258        Category::from_core(self.inner.category)
259    }
260
261    #[getter]
262    fn correct(&self) -> u32 {
263        self.inner.correct
264    }
265
266    #[getter]
267    fn total(&self) -> u32 {
268        self.inner.total
269    }
270
271    fn percentage(&self) -> f64 {
272        if self.inner.total == 0 {
273            0.0
274        } else {
275            self.inner.correct as f64 / self.inner.total as f64 * 100.0
276        }
277    }
278
279    fn __repr__(&self) -> String {
280        format!(
281            "CategoryScore({}: {}/{})",
282            self.inner.category.label(),
283            self.inner.correct,
284            self.inner.total
285        )
286    }
287}
288
289#[pyclass]
290#[derive(Clone)]
291pub struct DifficultyScore {
292    inner: quiz_core::DifficultyScore,
293}
294
295#[pymethods]
296impl DifficultyScore {
297    #[getter]
298    fn difficulty(&self) -> Difficulty {
299        Difficulty::from_core(self.inner.difficulty)
300    }
301
302    #[getter]
303    fn correct(&self) -> u32 {
304        self.inner.correct
305    }
306
307    #[getter]
308    fn total(&self) -> u32 {
309        self.inner.total
310    }
311
312    fn percentage(&self) -> f64 {
313        if self.inner.total == 0 {
314            0.0
315        } else {
316            self.inner.correct as f64 / self.inner.total as f64 * 100.0
317        }
318    }
319
320    fn __repr__(&self) -> String {
321        format!(
322            "DifficultyScore({}: {}/{})",
323            self.inner.difficulty.label(),
324            self.inner.correct,
325            self.inner.total
326        )
327    }
328}
329
330// ---------------------------------------------------------------------------
331// QuizScore
332// ---------------------------------------------------------------------------
333
334#[pyclass]
335#[derive(Clone)]
336pub struct QuizScore {
337    inner: quiz_core::QuizScore,
338}
339
340#[pymethods]
341impl QuizScore {
342    #[getter]
343    fn correct(&self) -> u32 {
344        self.inner.correct
345    }
346
347    #[getter]
348    fn wrong(&self) -> u32 {
349        self.inner.wrong
350    }
351
352    #[getter]
353    fn skipped(&self) -> u32 {
354        self.inner.skipped
355    }
356
357    #[getter]
358    fn total(&self) -> u32 {
359        self.inner.total
360    }
361
362    fn percentage(&self) -> f64 {
363        self.inner.percentage()
364    }
365
366    #[getter]
367    fn by_category(&self) -> Vec<CategoryScore> {
368        self.inner
369            .by_category
370            .iter()
371            .map(|s| CategoryScore { inner: s.clone() })
372            .collect()
373    }
374
375    #[getter]
376    fn by_difficulty(&self) -> Vec<DifficultyScore> {
377        self.inner
378            .by_difficulty
379            .iter()
380            .map(|s| DifficultyScore { inner: s.clone() })
381            .collect()
382    }
383
384    fn __repr__(&self) -> String {
385        format!(
386            "QuizScore({}/{} correct, {:.0}%)",
387            self.inner.correct,
388            self.inner.total,
389            self.inner.percentage()
390        )
391    }
392}
393
394// ---------------------------------------------------------------------------
395// QuizConfig
396// ---------------------------------------------------------------------------
397
398#[pyclass]
399#[derive(Clone)]
400pub struct QuizConfig {
401    inner: quiz_core::QuizConfig,
402}
403
404#[pymethods]
405impl QuizConfig {
406    #[new]
407    #[pyo3(signature = (categories=vec![], difficulty=None, num_questions=10, shuffle=true, seed=None))]
408    fn new(
409        categories: Vec<Category>,
410        difficulty: Option<Difficulty>,
411        num_questions: u32,
412        shuffle: bool,
413        seed: Option<u64>,
414    ) -> Self {
415        Self {
416            inner: quiz_core::QuizConfig {
417                categories: categories.into_iter().map(|c| c.to_core()).collect(),
418                difficulty: difficulty.map(|d| d.to_core()),
419                num_questions,
420                shuffle,
421                seed,
422            },
423        }
424    }
425
426    fn __repr__(&self) -> String {
427        format!(
428            "QuizConfig(categories={}, difficulty={:?}, num_questions={})",
429            self.inner.categories.len(),
430            self.inner
431                .difficulty
432                .as_ref()
433                .map(|d| d.label())
434                .unwrap_or("All"),
435            self.inner.num_questions
436        )
437    }
438}
439
440// ---------------------------------------------------------------------------
441// QuizSession
442// ---------------------------------------------------------------------------
443
444#[pyclass]
445pub struct QuizSession {
446    inner: quiz_core::QuizSession,
447}
448
449#[pymethods]
450impl QuizSession {
451    #[new]
452    #[pyo3(signature = (config=None))]
453    fn new(config: Option<QuizConfig>) -> Self {
454        let config = config.map(|c| c.inner).unwrap_or_default();
455        Self {
456            inner: quiz_core::QuizSession::new(config),
457        }
458    }
459
460    /// Get the current question, or None if finished.
461    fn current_question(&self) -> Option<Question> {
462        self.inner.current_question().map(|q| Question { inner: q })
463    }
464
465    /// Submit an answer (0-3) for the current question.
466    fn answer(&mut self, choice: u8) -> AnswerResult {
467        AnswerResult {
468            inner: self.inner.answer(choice),
469        }
470    }
471
472    /// Skip the current question.
473    fn skip(&mut self) -> bool {
474        self.inner.skip()
475    }
476
477    /// Is the quiz finished?
478    fn is_finished(&self) -> bool {
479        self.inner.is_finished()
480    }
481
482    /// Current progress as (current_index, total).
483    fn progress(&self) -> (usize, usize) {
484        self.inner.progress()
485    }
486
487    /// Get the current score.
488    fn score(&self) -> QuizScore {
489        QuizScore {
490            inner: self.inner.score(),
491        }
492    }
493
494    fn __repr__(&self) -> String {
495        let (idx, total) = self.inner.progress();
496        format!("QuizSession(question {}/{})", idx + 1, total)
497    }
498}
499
500// ---------------------------------------------------------------------------
501// QuizBank (static methods)
502// ---------------------------------------------------------------------------
503
504#[pyclass]
505pub struct QuizBank;
506
507#[pymethods]
508impl QuizBank {
509    /// All questions in the bank.
510    #[staticmethod]
511    fn all_questions() -> Vec<Question> {
512        quiz_core::QuizBank::all_questions()
513            .into_iter()
514            .map(|q| Question { inner: q })
515            .collect()
516    }
517
518    /// Categories with question counts as list of (Category, count) tuples.
519    #[staticmethod]
520    fn categories() -> Vec<(Category, u32)> {
521        quiz_core::QuizBank::categories()
522            .into_iter()
523            .map(|(c, n)| (Category::from_core(c), n))
524            .collect()
525    }
526
527    /// Total number of questions.
528    #[staticmethod]
529    fn total_count() -> u32 {
530        quiz_core::QuizBank::total_count()
531    }
532}
533
534// ---------------------------------------------------------------------------
535// QuizLocale (i18n)
536// ---------------------------------------------------------------------------
537
538#[pyclass]
539#[derive(Clone)]
540pub struct QuestionLocale {
541    inner: quiz_core::i18n::QuestionLocale,
542}
543
544#[pymethods]
545impl QuestionLocale {
546    #[getter]
547    fn text(&self) -> &str {
548        &self.inner.text
549    }
550
551    #[getter]
552    fn options(&self) -> Vec<String> {
553        self.inner.options.clone()
554    }
555
556    #[getter]
557    fn explanation(&self) -> &str {
558        &self.inner.explanation
559    }
560
561    fn __repr__(&self) -> String {
562        let preview = if self.inner.text.len() > 50 {
563            format!("{}...", &self.inner.text[..50])
564        } else {
565            self.inner.text.clone()
566        };
567        format!("QuestionLocale(\"{}\")", preview)
568    }
569}
570
571#[pyclass]
572pub struct QuizLocale {
573    inner: quiz_core::i18n::QuizLocale,
574}
575
576#[pymethods]
577impl QuizLocale {
578    /// Load locale for a language code (e.g. "en", "zh", "de").
579    /// Falls back to English for unknown codes.
580    #[staticmethod]
581    fn for_code(code: &str) -> Self {
582        Self {
583            inner: quiz_core::i18n::QuizLocale::for_code(code),
584        }
585    }
586
587    /// Get translated question by numeric ID.
588    fn question(&self, id: u32) -> Option<QuestionLocale> {
589        self.inner
590            .question(id)
591            .map(|q| QuestionLocale { inner: q.clone() })
592    }
593
594    /// Get translated category label.
595    fn category_label(&self, category: Category) -> String {
596        self.inner.category_label(&category.to_core()).to_string()
597    }
598
599    /// Get translated difficulty label.
600    fn difficulty_label(&self, difficulty: Difficulty) -> String {
601        self.inner
602            .difficulty_label(&difficulty.to_core())
603            .to_string()
604    }
605
606    // UI string accessors
607    fn ui_title(&self) -> &str {
608        &self.inner.ui.title
609    }
610
611    fn ui_correct(&self) -> &str {
612        &self.inner.ui.correct
613    }
614
615    fn ui_wrong(&self) -> &str {
616        &self.inner.ui.wrong
617    }
618
619    fn ui_start_quiz(&self) -> &str {
620        &self.inner.ui.start_quiz
621    }
622
623    fn ui_next_question(&self) -> &str {
624        &self.inner.ui.next_question
625    }
626
627    fn ui_see_results(&self) -> &str {
628        &self.inner.ui.see_results
629    }
630
631    fn ui_skip(&self) -> &str {
632        &self.inner.ui.skip
633    }
634
635    fn ui_try_again(&self) -> &str {
636        &self.inner.ui.try_again_btn
637    }
638
639    fn __repr__(&self) -> String {
640        format!("QuizLocale(\"{}\")", self.inner.ui.title)
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Module
646// ---------------------------------------------------------------------------
647
648#[pymodule]
649fn eulumdat_quiz(m: &Bound<'_, PyModule>) -> PyResult<()> {
650    m.add_class::<Category>()?;
651    m.add_class::<Difficulty>()?;
652    m.add_class::<Question>()?;
653    m.add_class::<AnswerResult>()?;
654    m.add_class::<QuizConfig>()?;
655    m.add_class::<QuizSession>()?;
656    m.add_class::<QuizBank>()?;
657    m.add_class::<QuizScore>()?;
658    m.add_class::<CategoryScore>()?;
659    m.add_class::<DifficultyScore>()?;
660    m.add_class::<QuizLocale>()?;
661    m.add_class::<QuestionLocale>()?;
662    Ok(())
663}