Skip to main content

eulumdat_quiz/
i18n.rs

1//! Self-contained i18n for quiz questions and UI strings.
2//!
3//! Quiz translations live separately from the main `eulumdat-i18n` locale files
4//! to avoid bloating them. Each language's JSON is embedded at compile time.
5
6use serde::Deserialize;
7use std::collections::HashMap;
8
9/// All translatable UI strings for the quiz.
10#[derive(Debug, Clone, PartialEq, Deserialize)]
11pub struct QuizUiStrings {
12    pub title: String,
13    pub configure: String,
14    pub categories: String,
15    pub select_all: String,
16    pub select_none: String,
17    pub difficulty: String,
18    pub all_levels: String,
19    pub beginner: String,
20    pub intermediate: String,
21    pub expert: String,
22    pub num_questions: String,
23    pub questions_available: String,
24    pub questions_selected: String,
25    pub start_quiz: String,
26    pub question_of: String,
27    pub correct_count: String,
28    pub correct: String,
29    pub wrong: String,
30    pub reference: String,
31    pub next_question: String,
32    pub see_results: String,
33    pub skip: String,
34    pub excellent: String,
35    pub good_job: String,
36    pub keep_learning: String,
37    pub try_again: String,
38    pub score_detail: String,
39    pub by_category: String,
40    pub by_difficulty: String,
41    pub try_again_btn: String,
42    pub back_to_editor: String,
43    pub powered_by: String,
44    pub questions_across: String,
45    pub polar_diagram: String,
46    pub cartesian_diagram: String,
47    pub symmetric: String,
48    pub asymmetric: String,
49    pub projector_narrow: String,
50    #[serde(default)]
51    pub heatmap: Option<String>,
52}
53
54/// A single translated question.
55#[derive(Debug, Clone, PartialEq, Deserialize)]
56pub struct QuestionLocale {
57    pub text: String,
58    pub options: Vec<String>,
59    pub explanation: String,
60}
61
62/// Complete quiz locale data for one language.
63#[derive(Debug, Clone, PartialEq, Deserialize)]
64pub struct QuizLocale {
65    pub ui: QuizUiStrings,
66    pub categories: HashMap<String, String>,
67    pub questions: HashMap<String, QuestionLocale>,
68}
69
70const EN_JSON: &str = include_str!("../locales/en.json");
71const ZH_JSON: &str = include_str!("../locales/zh.json");
72const DE_JSON: &str = include_str!("../locales/de.json");
73const FR_JSON: &str = include_str!("../locales/fr.json");
74const ES_JSON: &str = include_str!("../locales/es.json");
75const IT_JSON: &str = include_str!("../locales/it.json");
76const RU_JSON: &str = include_str!("../locales/ru.json");
77const PT_BR_JSON: &str = include_str!("../locales/pt-BR.json");
78
79impl QuizLocale {
80    /// Load locale for a language code (e.g. "en", "zh", "de").
81    /// Falls back to English for unknown codes.
82    pub fn for_code(code: &str) -> Self {
83        let json = match code.to_lowercase().as_str() {
84            "en" => EN_JSON,
85            "zh" | "zh-cn" | "zh-hans" => ZH_JSON,
86            "de" => DE_JSON,
87            "fr" => FR_JSON,
88            "es" => ES_JSON,
89            "it" => IT_JSON,
90            "ru" => RU_JSON,
91            "pt" | "pt-br" => PT_BR_JSON,
92            _ => EN_JSON,
93        };
94        serde_json::from_str(json).expect("invalid quiz locale JSON")
95    }
96
97    /// Get translated question by numeric ID, falling back to None if missing.
98    pub fn question(&self, id: u32) -> Option<&QuestionLocale> {
99        self.questions.get(&id.to_string())
100    }
101
102    /// Get translated category label, falling back to the built-in English label.
103    pub fn category_label(&self, cat: &crate::Category) -> &str {
104        self.categories
105            .get(cat.key())
106            .map(|s| s.as_str())
107            .unwrap_or(cat.label())
108    }
109
110    /// Get translated difficulty label.
111    pub fn difficulty_label(&self, diff: &crate::Difficulty) -> &str {
112        match diff {
113            crate::Difficulty::Beginner => &self.ui.beginner,
114            crate::Difficulty::Intermediate => &self.ui.intermediate,
115            crate::Difficulty::Expert => &self.ui.expert,
116        }
117    }
118
119    /// Simple template formatter: replaces {0}, {1}, ... with args.
120    pub fn format(template: &str, args: &[&dyn std::fmt::Display]) -> String {
121        let mut result = template.to_string();
122        for (i, arg) in args.iter().enumerate() {
123            result = result.replace(&format!("{{{}}}", i), &arg.to_string());
124        }
125        result
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_english_locale_loads() {
135        let locale = QuizLocale::for_code("en");
136        assert_eq!(locale.ui.title, "Photometric Knowledge Quiz");
137        assert!(!locale.questions.is_empty());
138    }
139
140    #[test]
141    fn test_all_locales_load() {
142        for code in &["en", "zh", "de", "fr", "es", "it", "ru", "pt-BR"] {
143            let locale = QuizLocale::for_code(code);
144            assert!(
145                !locale.ui.title.is_empty(),
146                "Locale {} has empty title",
147                code
148            );
149            assert!(
150                !locale.categories.is_empty(),
151                "Locale {} has empty categories",
152                code
153            );
154        }
155    }
156
157    #[test]
158    fn test_english_has_all_questions() {
159        let locale = QuizLocale::for_code("en");
160        let all = crate::QuizBank::all_questions();
161        for q in &all {
162            assert!(
163                locale.question(q.id).is_some(),
164                "English locale missing question {}",
165                q.id
166            );
167        }
168    }
169
170    #[test]
171    fn test_format() {
172        let s = QuizLocale::format("{0} of {1} questions", &[&5, &10]);
173        assert_eq!(s, "5 of 10 questions");
174    }
175
176    #[test]
177    fn test_category_label_lookup() {
178        let locale = QuizLocale::for_code("en");
179        let label = locale.category_label(&crate::Category::EulumdatFormat);
180        assert_eq!(label, "EULUMDAT Format");
181    }
182}