Skip to main content

sz_rust_state_facade/
i18n.rs

1//! i18n 国际化模块 — 对齐 PHP `think\facade\Lang`
2//!
3//! 本模块实现多语言支持,对齐 PHP `think\facade\Lang` 的核心 API。
4//!
5//! ## PHP 对齐
6//!
7//! ### 核心 API 映射
8//!
9//! | PHP 方法 | Rust 方法 | 说明 |
10//! |---------|-----------|------|
11//! | `Lang::set($name, $value, $lang = null)` | [`I18n::set`] | 设置语言项 |
12//! | `Lang::get($name, $vars = [], $lang = null)` | [`I18n::get`] | 获取语言项(支持变量插值) |
13//! | `Lang::has($name, $lang = null)` | [`I18n::has`] | 检查语言项是否存在 |
14//! | `Lang::load($file, $lang = null)` | [`I18n::load_from_file`] | 从 JSON 文件加载语言包 |
15//! | `Lang::range($range = null)` | [`I18n::current_lang`] / [`I18n::set_current_lang`] | 获取/设置当前语言 |
16//! | `Lang::defaultLangSet($lang)` | [`I18n::set_default_lang`] | 设置默认语言 |
17//!
18//! ### PHP 行为对齐
19//!
20//! - **变量插值**:PHP 支持两种格式 `:name` 和 `{name}`。Rust 同样支持这两种格式。
21//! - **语言回退**:PHP 当当前语言无对应项时回退到默认语言。Rust 通过 [`I18n::get`] 实现相同回退逻辑。
22//! - **多语言范围**:PHP 通过 `range()` 切换当前语言。Rust 通过 [`I18n::set_current_lang`] 实现。
23//!
24//! ## 语言包文件格式
25//!
26//! 使用 JSON 格式(对齐 PHP 5.0+ 支持的 JSON 语言包):
27//!
28//! ```json
29//! {
30//!     "hello": "Hello, :name!",
31//!     "goodbye": "Goodbye, {name}!"
32//! }
33//! ```
34//!
35//! ## 架构说明
36//!
37//! - **无外部依赖**:不依赖 `fluent` / `rust-i18n` crate,自行实现简单 KV 存储 + 变量插值
38//! - **线程安全**:通过 `Arc<RwLock<>>` 提供并发读、互斥写
39//! - **JSON 解析**:复用 `serde_json`(已是项目依赖)
40
41use parking_lot::RwLock;
42use serde_json::Value;
43use std::collections::HashMap;
44use std::path::Path;
45use std::sync::Arc;
46use thiserror::Error;
47use tokio::fs;
48
49// ============================================================================
50// 错误类型
51// ============================================================================
52
53/// i18n 错误
54#[derive(Debug, Error)]
55pub enum I18nError {
56    /// 语言包文件读取失败
57    #[error("语言包文件读取失败: {path} — {source}")]
58    FileRead {
59        /// 文件路径
60        path: String,
61        /// 底层 IO 错误
62        #[source]
63        source: std::io::Error,
64    },
65    /// 语言包文件解析失败
66    #[error("语言包文件解析失败: {path} — {source}")]
67    Parse {
68        /// 文件路径
69        path: String,
70        /// 底层 JSON 解析错误
71        #[source]
72        source: serde_json::Error,
73    },
74}
75
76// ============================================================================
77// I18n 主体
78// ============================================================================
79
80/// 国际化管理器 — 对齐 PHP `think\facade\Lang`
81///
82/// 管理多语言文案,支持变量插值和语言回退。
83///
84/// # 线程安全
85///
86/// 内部通过 `Arc<RwLock<>>` 保护,支持并发读、互斥写。
87///
88/// # PHP 对齐
89///
90/// ```php
91/// // PHP think\facade\Lang
92/// Lang::set('hello', 'Hello, :name!', 'en-us');
93/// Lang::get('hello', ['name' => 'John']);              // 使用当前语言
94/// Lang::get('hello', ['name' => 'John'], 'en-us');     // 指定语言
95/// Lang::has('hello');
96/// Lang::range('en-us');                                 // 切换当前语言
97/// ```
98#[derive(Debug, Clone)]
99pub struct I18n {
100    /// 多语言数据:lang_code → (key → value)
101    data: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
102    /// 当前语言代码
103    current_lang: Arc<RwLock<String>>,
104    /// 默认语言代码(回退用)
105    default_lang: Arc<RwLock<String>>,
106}
107
108impl Default for I18n {
109    /// 默认实现:当前语言和默认语言均为 `zh-cn`
110    fn default() -> Self {
111        Self {
112            data: Arc::new(RwLock::new(HashMap::new())),
113            current_lang: Arc::new(RwLock::new("zh-cn".to_string())),
114            default_lang: Arc::new(RwLock::new("zh-cn".to_string())),
115        }
116    }
117}
118
119impl I18n {
120    /// 创建新的 i18n 实例
121    ///
122    /// 默认语言和当前语言均为 `zh-cn`。
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// 创建指定默认语言的 i18n 实例
128    ///
129    /// # 参数
130    ///
131    /// - `default_lang`: 默认语言代码(如 `zh-cn`、`en-us`)
132    pub fn with_default_lang(default_lang: &str) -> Self {
133        let i18n = Self::default();
134        *i18n.default_lang.write() = default_lang.to_string();
135        *i18n.current_lang.write() = default_lang.to_string();
136        i18n
137    }
138
139    /// 设置语言项
140    ///
141    /// # 参数
142    ///
143    /// - `lang`: 语言代码(如 `zh-cn`、`en-us`)
144    /// - `key`: 语言项键名
145    /// - `value`: 语言项值(支持 `:var` 和 `{var}` 插值占位符)
146    ///
147    /// # PHP 对齐
148    ///
149    /// ```php
150    /// Lang::set('hello', 'Hello, :name!', 'en-us');
151    /// ```
152    pub fn set(&self, lang: &str, key: &str, value: &str) {
153        let mut data = self.data.write();
154        data.entry(lang.to_string())
155            .or_default()
156            .insert(key.to_string(), value.to_string());
157    }
158
159    /// 获取语言项(支持变量插值)
160    ///
161    /// # 语言回退
162    ///
163    /// 1. 优先查 `lang` 参数指定的语言
164    /// 2. 若未指定 `lang`,查当前语言
165    /// 3. 若当前语言无此项,回退到默认语言
166    ///
167    /// # 变量插值
168    ///
169    /// 支持 PHP 两种占位符格式:
170    /// - `:name` → 用 `vars["name"]` 替换
171    /// - `{name}` → 用 `vars["name"]` 替换
172    ///
173    /// # 参数
174    ///
175    /// - `key`: 语言项键名
176    /// - `vars`: 插值变量(键名不含 `:` 或 `{}`)
177    /// - `lang`: 语言代码,`None` 时使用当前语言
178    ///
179    /// # 返回
180    ///
181    /// 存在返回插值后的字符串,不存在返回 `None`。
182    ///
183    /// # PHP 对齐
184    ///
185    /// ```php
186    /// Lang::get('hello', ['name' => 'John'], 'en-us');
187    /// ```
188    pub fn get(
189        &self,
190        key: &str,
191        vars: &HashMap<String, String>,
192        lang: Option<&str>,
193    ) -> Option<String> {
194        let data = self.data.read();
195
196        // 确定查找语言顺序
197        let current_lang = self.current_lang.read().clone();
198        let default_lang = self.default_lang.read().clone();
199
200        let lookup_langs: Vec<&str> = match lang {
201            Some(l) => vec![l, &default_lang],
202            None => vec![&current_lang, &default_lang],
203        };
204
205        // 按优先级查找
206        for lookup_lang in lookup_langs {
207            if let Some(lang_data) = data.get(lookup_lang) {
208                if let Some(raw_value) = lang_data.get(key) {
209                    return Some(self.interpolate(raw_value, vars));
210                }
211            }
212        }
213
214        None
215    }
216
217    /// 获取语言项(简化版,无变量插值)
218    ///
219    /// # 参数
220    ///
221    /// - `key`: 语言项键名
222    /// - `lang`: 语言代码,`None` 时使用当前语言
223    ///
224    /// # 返回
225    ///
226    /// 存在返回原始字符串,不存在返回 `None`。
227    pub fn get_simple(&self, key: &str, lang: Option<&str>) -> Option<String> {
228        let empty_vars = HashMap::new();
229        self.get(key, &empty_vars, lang)
230    }
231
232    /// 检查语言项是否存在
233    ///
234    /// # 语言回退
235    ///
236    /// 同 [`I18n::get`]:优先指定语言,其次当前语言,最后默认语言。
237    ///
238    /// # 参数
239    ///
240    /// - `key`: 语言项键名
241    /// - `lang`: 语言代码,`None` 时使用当前语言
242    ///
243    /// # 返回
244    ///
245    /// 存在返回 `true`,否则返回 `false`。
246    ///
247    /// # PHP 对齐
248    ///
249    /// ```php
250    /// Lang::has('hello');
251    /// ```
252    pub fn has(&self, key: &str, lang: Option<&str>) -> bool {
253        let data = self.data.read();
254
255        let current_lang = self.current_lang.read().clone();
256        let default_lang = self.default_lang.read().clone();
257
258        let lookup_langs: Vec<&str> = match lang {
259            Some(l) => vec![l, &default_lang],
260            None => vec![&current_lang, &default_lang],
261        };
262
263        for lookup_lang in lookup_langs {
264            if let Some(lang_data) = data.get(lookup_lang) {
265                if lang_data.contains_key(key) {
266                    return true;
267                }
268            }
269        }
270
271        false
272    }
273
274    /// 从 JSON 文件加载语言包
275    ///
276    /// # 文件格式
277    ///
278    /// ```json
279    /// {
280    ///     "hello": "Hello, :name!",
281    ///     "goodbye": "Goodbye, {name}!"
282    /// }
283    /// ```
284    ///
285    /// # 参数
286    ///
287    /// - `path`: JSON 文件路径
288    /// - `lang`: 语言代码
289    ///
290    /// # 返回
291    ///
292    /// 成功返回 `Ok(())`,失败返回 [`I18nError`]。
293    ///
294    /// # PHP 对齐
295    ///
296    /// ```php
297    /// Lang::load('lang/en-us.json', 'en-us');
298    /// ```
299    pub async fn load_from_file(
300        &self,
301        path: impl AsRef<Path>,
302        lang: &str,
303    ) -> Result<(), I18nError> {
304        let path_ref = path.as_ref();
305        let content = fs::read_to_string(path_ref)
306            .await
307            .map_err(|e| I18nError::FileRead {
308                path: path_ref.display().to_string(),
309                source: e,
310            })?;
311
312        self.load_from_json_str(&content, lang, &path_ref.display().to_string())
313    }
314
315    /// 从 JSON 字符串加载语言包
316    ///
317    /// # 参数
318    ///
319    /// - `json_str`: JSON 格式字符串
320    /// - `lang`: 语言代码
321    /// - `path_for_error`: 用于错误信息的路径描述
322    ///
323    /// # 返回
324    ///
325    /// 成功返回 `Ok(())`,失败返回 [`I18nError`]。
326    pub fn load_from_json_str(
327        &self,
328        json_str: &str,
329        lang: &str,
330        path_for_error: &str,
331    ) -> Result<(), I18nError> {
332        let parsed: HashMap<String, Value> =
333            serde_json::from_str(json_str).map_err(|e| I18nError::Parse {
334                path: path_for_error.to_string(),
335                source: e,
336            })?;
337
338        let mut data = self.data.write();
339        let lang_data = data.entry(lang.to_string()).or_default();
340
341        for (key, value) in parsed {
342            // 仅接受字符串值,非字符串值跳过
343            if let Some(s) = value.as_str() {
344                lang_data.insert(key, s.to_string());
345            }
346        }
347
348        Ok(())
349    }
350
351    /// 获取当前语言代码
352    ///
353    /// # PHP 对齐
354    ///
355    /// ```php
356    /// $current = Lang::range();
357    /// ```
358    pub fn current_lang(&self) -> String {
359        self.current_lang.read().clone()
360    }
361
362    /// 设置当前语言代码
363    ///
364    /// # 参数
365    ///
366    /// - `lang`: 语言代码(如 `zh-cn`、`en-us`)
367    ///
368    /// # PHP 对齐
369    ///
370    /// ```php
371    /// Lang::range('en-us');
372    /// ```
373    pub fn set_current_lang(&self, lang: &str) {
374        *self.current_lang.write() = lang.to_string();
375    }
376
377    /// 设置默认语言代码(回退语言)
378    ///
379    /// # 参数
380    ///
381    /// - `lang`: 语言代码
382    ///
383    /// # PHP 对齐
384    ///
385    /// ```php
386    /// Lang::defaultLangSet('zh-cn');
387    /// ```
388    pub fn set_default_lang(&self, lang: &str) {
389        *self.default_lang.write() = lang.to_string();
390    }
391
392    /// 获取默认语言代码
393    pub fn default_lang(&self) -> String {
394        self.default_lang.read().clone()
395    }
396
397    /// 变量插值
398    ///
399    /// 支持 PHP 两种占位符格式:
400    /// - `:name` → 用 `vars["name"]` 替换
401    /// - `{name}` → 用 `vars["name"]` 替换
402    ///
403    /// # 参数
404    ///
405    /// - `template`: 含占位符的模板字符串
406    /// - `vars`: 插值变量(键名不含 `:` 或 `{}`)
407    ///
408    /// # 返回
409    ///
410    /// 插值后的字符串。未找到的变量保留原占位符。
411    fn interpolate(&self, template: &str, vars: &HashMap<String, String>) -> String {
412        let mut result = template.to_string();
413
414        for (key, value) in vars {
415            // 替换 :name 格式
416            let colon_placeholder = format!(":{}", key);
417            result = result.replace(&colon_placeholder, value);
418
419            // 替换 {name} 格式
420            let brace_placeholder = format!("{{{}}}", key);
421            result = result.replace(&brace_placeholder, value);
422        }
423
424        result
425    }
426
427    /// 获取指定语言的所有键值对(快照)
428    ///
429    /// # 参数
430    ///
431    /// - `lang`: 语言代码
432    ///
433    /// # 返回
434    ///
435    /// 指定语言的所有键值对。语言不存在时返回空 HashMap。
436    pub fn all_for_lang(&self, lang: &str) -> HashMap<String, String> {
437        let data = self.data.read();
438        data.get(lang).cloned().unwrap_or_default()
439    }
440
441    /// 获取已加载的所有语言代码列表
442    ///
443    /// # 返回
444    ///
445    /// 已加载的语言代码列表(无序)。
446    pub fn available_langs(&self) -> Vec<String> {
447        let data = self.data.read();
448        data.keys().cloned().collect()
449    }
450}
451
452// ============================================================================
453// 单元测试
454// ============================================================================
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::io::Write;
460
461    /// 测试空 I18n 实例
462    #[test]
463    fn test_new_i18n_default_lang() {
464        let i18n = I18n::new();
465        assert_eq!(i18n.current_lang(), "zh-cn");
466        assert_eq!(i18n.default_lang(), "zh-cn");
467    }
468
469    /// 测试 with_default_lang 构造
470    #[test]
471    fn test_with_default_lang() {
472        let i18n = I18n::with_default_lang("en-us");
473        assert_eq!(i18n.current_lang(), "en-us");
474        assert_eq!(i18n.default_lang(), "en-us");
475    }
476
477    /// 测试 set/get 基本流程
478    #[test]
479    fn test_set_get_basic() {
480        let i18n = I18n::new();
481        i18n.set("zh-cn", "hello", "你好");
482        i18n.set("en-us", "hello", "Hello");
483
484        assert_eq!(
485            i18n.get_simple("hello", Some("zh-cn")),
486            Some("你好".to_string())
487        );
488        assert_eq!(
489            i18n.get_simple("hello", Some("en-us")),
490            Some("Hello".to_string())
491        );
492    }
493
494    /// 测试 get 使用当前语言
495    #[test]
496    fn test_get_uses_current_lang() {
497        let i18n = I18n::with_default_lang("zh-cn");
498        i18n.set("zh-cn", "hello", "你好");
499        i18n.set("en-us", "hello", "Hello");
500
501        // 默认当前语言 zh-cn
502        assert_eq!(i18n.get_simple("hello", None), Some("你好".to_string()));
503
504        // 切换到 en-us
505        i18n.set_current_lang("en-us");
506        assert_eq!(i18n.get_simple("hello", None), Some("Hello".to_string()));
507    }
508
509    /// 测试变量插值(:name 格式)
510    #[test]
511    fn test_interpolate_colon_format() {
512        let i18n = I18n::new();
513        i18n.set("en-us", "greeting", "Hello, :name! Welcome to :place.");
514
515        let mut vars = HashMap::new();
516        vars.insert("name".to_string(), "John".to_string());
517        vars.insert("place".to_string(), "Rust".to_string());
518
519        let result = i18n.get("greeting", &vars, Some("en-us")).unwrap();
520        assert_eq!(result, "Hello, John! Welcome to Rust.");
521    }
522
523    /// 测试变量插值({name} 格式)
524    #[test]
525    fn test_interpolate_brace_format() {
526        let i18n = I18n::new();
527        i18n.set("en-us", "greeting", "Hello, {name}! Welcome to {place}.");
528
529        let mut vars = HashMap::new();
530        vars.insert("name".to_string(), "John".to_string());
531        vars.insert("place".to_string(), "Rust".to_string());
532
533        let result = i18n.get("greeting", &vars, Some("en-us")).unwrap();
534        assert_eq!(result, "Hello, John! Welcome to Rust.");
535    }
536
537    /// 测试混合占位符格式
538    #[test]
539    fn test_interpolate_mixed_format() {
540        let i18n = I18n::new();
541        i18n.set("en-us", "msg", ":name has {count} items in :place.");
542
543        let mut vars = HashMap::new();
544        vars.insert("name".to_string(), "Alice".to_string());
545        vars.insert("count".to_string(), "5".to_string());
546        vars.insert("place".to_string(), "basket".to_string());
547
548        let result = i18n.get("msg", &vars, Some("en-us")).unwrap();
549        assert_eq!(result, "Alice has 5 items in basket.");
550    }
551
552    /// 测试未找到的变量保留原占位符
553    #[test]
554    fn test_interpolate_missing_var_kept() {
555        let i18n = I18n::new();
556        i18n.set("en-us", "msg", "Hello, :name! Count: {count}");
557
558        let mut vars = HashMap::new();
559        vars.insert("name".to_string(), "John".to_string());
560        // 不提供 count 变量
561
562        let result = i18n.get("msg", &vars, Some("en-us")).unwrap();
563        assert_eq!(result, "Hello, John! Count: {count}");
564    }
565
566    /// 测试语言回退到默认语言
567    #[test]
568    fn test_fallback_to_default_lang() {
569        let i18n = I18n::with_default_lang("zh-cn");
570        i18n.set("zh-cn", "only_in_zh", "只有中文");
571        // 不设置 en-us 的对应项
572
573        // 当前语言 en-us(无此项),回退到 zh-cn
574        i18n.set_current_lang("en-us");
575        assert_eq!(
576            i18n.get_simple("only_in_zh", None),
577            Some("只有中文".to_string())
578        );
579    }
580
581    /// 测试 has 检查存在
582    #[test]
583    fn test_has() {
584        let i18n = I18n::new();
585        i18n.set("zh-cn", "existing", "存在");
586
587        assert!(i18n.has("existing", Some("zh-cn")));
588        assert!(!i18n.has("nonexistent", Some("zh-cn")));
589    }
590
591    /// 测试 has 的语言回退
592    #[test]
593    fn test_has_fallback() {
594        let i18n = I18n::with_default_lang("zh-cn");
595        i18n.set("zh-cn", "fallback_key", "回退");
596
597        i18n.set_current_lang("en-us");
598        assert!(i18n.has("fallback_key", None));
599    }
600
601    /// 测试从 JSON 字符串加载
602    #[test]
603    fn test_load_from_json_str() {
604        let i18n = I18n::new();
605        let json = r#"{"hello": "Hello, :name!", "bye": "Goodbye"}"#;
606        i18n.load_from_json_str(json, "en-us", "<test>").unwrap();
607
608        assert_eq!(
609            i18n.get_simple("hello", Some("en-us")),
610            Some("Hello, :name!".to_string())
611        );
612        assert_eq!(
613            i18n.get_simple("bye", Some("en-us")),
614            Some("Goodbye".to_string())
615        );
616    }
617
618    /// 测试从 JSON 文件加载
619    #[tokio::test]
620    async fn test_load_from_file() {
621        let temp_dir = std::env::temp_dir().join("sz_rust_i18n_test");
622        let _ = std::fs::create_dir_all(&temp_dir);
623        let lang_file = temp_dir.join("en-us.json");
624
625        let mut file = std::fs::File::create(&lang_file).unwrap();
626        writeln!(file, r#"{{"hello": "Hello!", "bye": "Goodbye"}}"#).unwrap();
627        drop(file);
628
629        let i18n = I18n::new();
630        i18n.load_from_file(&lang_file, "en-us").await.unwrap();
631
632        assert_eq!(
633            i18n.get_simple("hello", Some("en-us")),
634            Some("Hello!".to_string())
635        );
636        assert_eq!(
637            i18n.get_simple("bye", Some("en-us")),
638            Some("Goodbye".to_string())
639        );
640
641        let _ = std::fs::remove_dir_all(&temp_dir);
642    }
643
644    /// 测试文件不存在时返回错误
645    #[tokio::test]
646    async fn test_load_nonexistent_file_errors() {
647        let i18n = I18n::new();
648        let result = i18n
649            .load_from_file("/nonexistent/path/lang.json", "en-us")
650            .await;
651        assert!(result.is_err());
652        match result {
653            Err(I18nError::FileRead { .. }) => {}
654            _ => panic!("期望 FileRead 错误"),
655        }
656    }
657
658    /// 测试无效 JSON 返回解析错误
659    #[test]
660    fn test_load_invalid_json_errors() {
661        let i18n = I18n::new();
662        let result = i18n.load_from_json_str("{invalid json}", "en-us", "<test>");
663        assert!(result.is_err());
664        match result {
665            Err(I18nError::Parse { .. }) => {}
666            _ => panic!("期望 Parse 错误"),
667        }
668    }
669
670    /// 测试 JSON 中的非字符串值被跳过
671    #[test]
672    fn test_load_json_non_string_values_skipped() {
673        let i18n = I18n::new();
674        let json = r#"{"valid": "string", "number": 123, "bool": true, "null_val": null}"#;
675        i18n.load_from_json_str(json, "en-us", "<test>").unwrap();
676
677        assert_eq!(
678            i18n.get_simple("valid", Some("en-us")),
679            Some("string".to_string())
680        );
681        assert_eq!(i18n.get_simple("number", Some("en-us")), None);
682        assert_eq!(i18n.get_simple("bool", Some("en-us")), None);
683        assert_eq!(i18n.get_simple("null_val", Some("en-us")), None);
684    }
685
686    /// 测试 set_current_lang / current_lang
687    #[test]
688    fn test_set_and_get_current_lang() {
689        let i18n = I18n::new();
690        assert_eq!(i18n.current_lang(), "zh-cn");
691
692        i18n.set_current_lang("en-us");
693        assert_eq!(i18n.current_lang(), "en-us");
694
695        i18n.set_current_lang("ja-jp");
696        assert_eq!(i18n.current_lang(), "ja-jp");
697    }
698
699    /// 测试 set_default_lang / default_lang
700    #[test]
701    fn test_set_and_get_default_lang() {
702        let i18n = I18n::new();
703        assert_eq!(i18n.default_lang(), "zh-cn");
704
705        i18n.set_default_lang("en-us");
706        assert_eq!(i18n.default_lang(), "en-us");
707    }
708
709    /// 测试 all_for_lang 返回指定语言的键值对快照
710    #[test]
711    fn test_all_for_lang() {
712        let i18n = I18n::new();
713        i18n.set("zh-cn", "key1", "值1");
714        i18n.set("zh-cn", "key2", "值2");
715        i18n.set("en-us", "key1", "value1");
716
717        let zh_data = i18n.all_for_lang("zh-cn");
718        assert_eq!(zh_data.len(), 2);
719        assert_eq!(zh_data.get("key1"), Some(&"值1".to_string()));
720        assert_eq!(zh_data.get("key2"), Some(&"值2".to_string()));
721
722        let en_data = i18n.all_for_lang("en-us");
723        assert_eq!(en_data.len(), 1);
724        assert_eq!(en_data.get("key1"), Some(&"value1".to_string()));
725
726        // 不存在的语言返回空
727        let ja_data = i18n.all_for_lang("ja-jp");
728        assert!(ja_data.is_empty());
729    }
730
731    /// 测试 available_langs 返回已加载语言列表
732    #[test]
733    fn test_available_langs() {
734        let i18n = I18n::new();
735        i18n.set("zh-cn", "key", "值");
736        i18n.set("en-us", "key", "value");
737        i18n.set("ja-jp", "key", "値");
738
739        let langs = i18n.available_langs();
740        assert_eq!(langs.len(), 3);
741        assert!(langs.contains(&"zh-cn".to_string()));
742        assert!(langs.contains(&"en-us".to_string()));
743        assert!(langs.contains(&"ja-jp".to_string()));
744    }
745
746    /// 测试多次 load_from_json_str 累加而非覆盖
747    #[test]
748    fn test_multiple_load_accumulates() {
749        let i18n = I18n::new();
750        i18n.load_from_json_str(r#"{"key1": "value1"}"#, "en-us", "<test1>")
751            .unwrap();
752        i18n.load_from_json_str(r#"{"key2": "value2"}"#, "en-us", "<test2>")
753            .unwrap();
754
755        assert_eq!(
756            i18n.get_simple("key1", Some("en-us")),
757            Some("value1".to_string())
758        );
759        assert_eq!(
760            i18n.get_simple("key2", Some("en-us")),
761            Some("value2".to_string())
762        );
763    }
764
765    /// 测试 get 不存在的键返回 None
766    #[test]
767    fn test_get_nonexistent_returns_none() {
768        let i18n = I18n::new();
769        assert_eq!(i18n.get_simple("nonexistent", Some("zh-cn")), None);
770        assert_eq!(i18n.get_simple("nonexistent", None), None);
771    }
772
773    /// 测试空 vars 的插值
774    #[test]
775    fn test_interpolate_empty_vars() {
776        let i18n = I18n::new();
777        i18n.set("en-us", "msg", "Hello, :name!");
778
779        let empty_vars = HashMap::new();
780        let result = i18n.get("msg", &empty_vars, Some("en-us")).unwrap();
781        // 未提供变量,占位符保留
782        assert_eq!(result, "Hello, :name!");
783    }
784}