Skip to main content

citum_schema_style/locale/
terms.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use crate::citation::LocatorType;
7use crate::template::ContributorRole;
8
9use super::Locale;
10use super::types::{GeneralTerm, GrammaticalGender, MaybeGendered, SimpleTerm, TermForm};
11
12/// Identifies a field in the archive hierarchy for locale term lookup.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ArchiveHierarchyField {
15    /// Named collection or record group.
16    Collection,
17    /// Named series or sub-collection.
18    Series,
19    /// Box or container designation.
20    Box,
21    /// Folder designation.
22    Folder,
23    /// Item, file, or reference-code designation.
24    Item,
25}
26
27impl ArchiveHierarchyField {
28    /// Returns the MF2 message ID for this field's locale label.
29    fn message_id(self) -> &'static str {
30        match self {
31            Self::Collection => "term.archive-collection-label",
32            Self::Series => "term.archive-series-label",
33            Self::Box => "term.archive-box-label",
34            Self::Folder => "term.archive-folder-label",
35            Self::Item => "term.archive-item-label",
36        }
37    }
38}
39
40impl Locale {
41    /// Get a contributor role term.
42    fn resolve_gendered_value(
43        value: &MaybeGendered<String>,
44        requested_gender: Option<GrammaticalGender>,
45    ) -> Option<&str> {
46        value
47            .resolve_with_fallback(requested_gender)
48            .map(String::as_str)
49    }
50
51    fn resolve_gendered_value_neutral(value: &MaybeGendered<String>) -> Option<&str> {
52        value.resolve_neutral().map(String::as_str)
53    }
54
55    fn resolve_no_date_value<'a>(
56        value: &'a SimpleTerm,
57        form: &TermForm,
58        requested_gender: Option<GrammaticalGender>,
59    ) -> Option<&'a str> {
60        match requested_gender {
61            Some(GrammaticalGender::Common) => match *form {
62                TermForm::Long => value
63                    .long
64                    .resolve_strict(Some(GrammaticalGender::Common))
65                    .map(String::as_str),
66                TermForm::Short => value
67                    .short
68                    .resolve_strict(Some(GrammaticalGender::Common))
69                    .map(String::as_str)
70                    .filter(|value| !value.is_empty())
71                    .or_else(|| {
72                        value
73                            .long
74                            .resolve_strict(Some(GrammaticalGender::Common))
75                            .map(String::as_str)
76                    }),
77                _ => value
78                    .long
79                    .resolve_strict(Some(GrammaticalGender::Common))
80                    .map(String::as_str),
81            },
82            _ => match *form {
83                TermForm::Long => Self::resolve_gendered_value(&value.long, requested_gender),
84                TermForm::Short => {
85                    Self::resolve_gendered_value(&value.short, requested_gender.clone())
86                        .filter(|value| !value.is_empty())
87                        .or_else(|| Self::resolve_gendered_value(&value.long, requested_gender))
88                }
89                _ => Self::resolve_gendered_value(&value.long, requested_gender),
90            },
91        }
92    }
93
94    /// Get a contributor role term.
95    pub fn role_term(
96        &self,
97        role: &ContributorRole,
98        plural: bool,
99        form: &TermForm,
100        requested_gender: Option<GrammaticalGender>,
101    ) -> Option<&str> {
102        let term = self.roles.get(role)?;
103        let simple = if plural { &term.plural } else { &term.singular };
104        let term_text = match *form {
105            TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
106            TermForm::Short => {
107                Self::resolve_gendered_value(&simple.short, requested_gender.clone())
108                    .filter(|value| !value.is_empty())
109                    .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
110            }
111            TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
112            TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
113                .filter(|value| !value.is_empty())
114                .or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
115            _ => Self::resolve_gendered_value(&simple.long, requested_gender),
116        };
117
118        match term_text {
119            Some(value) if !value.is_empty() => Some(value),
120            _ => None,
121        }
122    }
123
124    /// Resolve a contributor role term using only neutral/common values.
125    pub fn role_term_neutral(
126        &self,
127        role: &ContributorRole,
128        plural: bool,
129        form: &TermForm,
130    ) -> Option<&str> {
131        let term = self.roles.get(role)?;
132        let simple = if plural { &term.plural } else { &term.singular };
133        let term_text = match *form {
134            TermForm::Long => Self::resolve_gendered_value_neutral(&simple.long),
135            TermForm::Short => Self::resolve_gendered_value_neutral(&simple.short)
136                .filter(|value| !value.is_empty())
137                .or_else(|| Self::resolve_gendered_value_neutral(&simple.long)),
138            TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
139            TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
140                .filter(|value| !value.is_empty())
141                .or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
142            _ => Self::resolve_gendered_value_neutral(&simple.long),
143        };
144
145        match term_text {
146            Some(value) if !value.is_empty() => Some(value),
147            _ => None,
148        }
149    }
150
151    /// Resolve a contributor role term, evaluating MF2 messages when configured.
152    pub fn resolved_role_term(
153        &self,
154        role: &ContributorRole,
155        plural: bool,
156        form: &TermForm,
157        requested_gender: Option<GrammaticalGender>,
158    ) -> Option<String> {
159        if let Some(message_id) = Self::role_message_id(role, form)
160            && let Some(resolved) = self.resolve_message_text(
161                message_id,
162                Some(u64::from(plural) + 1),
163                requested_gender.clone(),
164            )
165        {
166            return Some(resolved);
167        }
168
169        self.role_term(role, plural, form, requested_gender)
170            .map(ToOwned::to_owned)
171    }
172
173    /// Resolve a contributor role term using only neutral/common values.
174    pub fn resolved_role_term_neutral(
175        &self,
176        role: &ContributorRole,
177        plural: bool,
178        form: &TermForm,
179    ) -> Option<String> {
180        if let Some(message_id) = Self::role_message_id(role, form)
181            && let Some(resolved) = self.resolve_message_text(
182                message_id,
183                Some(u64::from(plural) + 1),
184                Some(GrammaticalGender::Common),
185            )
186        {
187            return Some(resolved);
188        }
189
190        self.role_term_neutral(role, plural, form)
191            .map(ToOwned::to_owned)
192    }
193
194    /// Get a locator term.
195    pub fn locator_term(
196        &self,
197        locator: &LocatorType,
198        plural: bool,
199        form: &TermForm,
200        requested_gender: Option<GrammaticalGender>,
201    ) -> Option<&str> {
202        let term = self.locators.get(locator)?;
203        let form_term = match *form {
204            TermForm::Long => &term.long,
205            TermForm::Short => &term.short,
206            TermForm::Symbol => &term.symbol,
207            _ => &term.short, // Fallback
208        };
209
210        if let Some(ft) = form_term {
211            let value = if plural { &ft.plural } else { &ft.singular };
212            Self::resolve_gendered_value(value, requested_gender)
213        } else {
214            None
215        }
216    }
217
218    /// Resolve a locator term, evaluating MF2 messages when configured.
219    pub fn resolved_locator_term(
220        &self,
221        locator: &LocatorType,
222        plural: bool,
223        form: &TermForm,
224        requested_gender: Option<GrammaticalGender>,
225    ) -> Option<String> {
226        if let Some(message_id) = Self::locator_message_id(locator, form)
227            && let Some(resolved) = self.resolve_message_text(
228                message_id,
229                Some(u64::from(plural) + 1),
230                requested_gender.clone(),
231            )
232        {
233            return Some(resolved);
234        }
235
236        self.locator_term(locator, plural, form, requested_gender.clone())
237            .map(ToOwned::to_owned)
238            .or_else(|| {
239                if let LocatorType::Custom(key) = locator {
240                    self.locator_term_any_form(locator, plural, requested_gender)
241                        .map(ToOwned::to_owned)
242                        .or_else(|| Some(key.clone()))
243                } else {
244                    None
245                }
246            })
247    }
248
249    fn locator_term_any_form(
250        &self,
251        locator: &LocatorType,
252        plural: bool,
253        requested_gender: Option<GrammaticalGender>,
254    ) -> Option<&str> {
255        let term = self.locators.get(locator)?;
256        [&term.long, &term.short, &term.symbol]
257            .into_iter()
258            .flatten()
259            .next()
260            .map(|forms| {
261                if plural {
262                    Self::resolve_gendered_value(&forms.plural, requested_gender).unwrap_or("")
263                } else {
264                    Self::resolve_gendered_value(&forms.singular, requested_gender).unwrap_or("")
265                }
266            })
267            .filter(|value| !value.is_empty())
268    }
269
270    /// Resolve a general term to a borrowed string.
271    pub fn general_term(
272        &self,
273        term: &GeneralTerm,
274        form: &TermForm,
275        requested_gender: Option<GrammaticalGender>,
276    ) -> Option<&str> {
277        // Legacy borrowed lookup path: prefer plain v2 messages first, then
278        // alias-backed messages, and finally the v1 term tables. The
279        // candidate id must be form-aware (`general_message_id`) rather than
280        // form-blind, otherwise Long/Short forms collapse onto whichever
281        // message happens to exist for the form-blind id (e.g. `no date`
282        // always resolving to the short "n.d." even when Long was
283        // requested).
284        if let Some(candidate_id) = Self::general_message_id(term, form)
285            && let Some(msg) = self.messages.get(candidate_id)
286        {
287            // Only use plain messages here (no ICU variable syntax)
288            if !msg.contains('{') {
289                return Some(msg.as_str());
290            }
291        }
292        // Check legacy_term_aliases
293        let legacy_key = Self::general_term_to_legacy_key(term);
294        if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
295            && let Some(msg) = self.messages.get(msg_id)
296            && !msg.contains('{')
297        {
298            return Some(msg.as_str());
299        }
300
301        // First try the flattened map
302        if *term != GeneralTerm::NoDate
303            && let Some(simple) = self.terms.general.get(term)
304        {
305            return match *form {
306                TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
307                TermForm::Short => {
308                    Self::resolve_gendered_value(&simple.short, requested_gender.clone())
309                        .filter(|value| !value.is_empty())
310                        .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
311                }
312
313                _ => Self::resolve_gendered_value(&simple.long, requested_gender),
314            };
315        }
316
317        // Fallback to specific fields for common terms
318        match term {
319            GeneralTerm::And => self.terms.and.as_deref(),
320            GeneralTerm::EtAl => self.terms.et_al.as_deref(),
321            GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
322            GeneralTerm::Accessed => self.terms.accessed.as_deref(),
323            GeneralTerm::Ibid => self.terms.ibid.as_deref(),
324            GeneralTerm::In => self.terms.in_.as_deref(),
325            GeneralTerm::NoDate => self
326                .terms
327                .general
328                .get(term)
329                .and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
330                .or(self.terms.no_date.as_deref()),
331            GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
332            GeneralTerm::At => self.terms.at.as_deref(),
333            GeneralTerm::By => self.terms.by.as_deref(),
334            GeneralTerm::From => self.terms.from.as_deref(),
335            GeneralTerm::Of => self
336                .terms
337                .general
338                .get(term)
339                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
340            GeneralTerm::To => self
341                .terms
342                .general
343                .get(term)
344                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
345            GeneralTerm::Anonymous => {
346                Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
347            }
348            GeneralTerm::Circa => {
349                Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
350            }
351            // Fallback to locators for shared terms
352            GeneralTerm::Volume => {
353                self.locator_term(&LocatorType::Volume, false, form, requested_gender)
354            }
355            GeneralTerm::Issue => {
356                self.locator_term(&LocatorType::Issue, false, form, requested_gender)
357            }
358            GeneralTerm::Page => {
359                self.locator_term(&LocatorType::Page, false, form, requested_gender)
360            }
361            GeneralTerm::Chapter => {
362                self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
363            }
364            GeneralTerm::Section => {
365                self.locator_term(&LocatorType::Section, false, form, requested_gender)
366            }
367            GeneralTerm::Here => self
368                .terms
369                .general
370                .get(term)
371                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
372            GeneralTerm::Deposited => self
373                .terms
374                .general
375                .get(term)
376                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
377            _ => None,
378        }
379    }
380
381    /// Resolve a general term, evaluating MF2 messages when configured.
382    pub fn resolved_general_term(
383        &self,
384        term: &GeneralTerm,
385        form: &TermForm,
386        requested_gender: Option<GrammaticalGender>,
387    ) -> Option<String> {
388        if let Some(message_id) = Self::general_message_id(term, form)
389            && let Some(resolved) =
390                self.resolve_message_text(message_id, None, requested_gender.clone())
391        {
392            return Some(resolved);
393        }
394
395        self.general_term(term, form, requested_gender)
396            .map(ToOwned::to_owned)
397    }
398
399    /// Resolve a reference-type description term (e.g. `"dataset"`,
400    /// `"article-journal"`) for the `type-label` template component.
401    ///
402    /// Looks up `ref_type` in [`Locale::type_terms`] directly — unlike
403    /// [`Self::resolved_general_term`], there is no MF2 message path for
404    /// these terms yet, since they are dynamically keyed by an open
405    /// `ref_type` string rather than the closed [`GeneralTerm`] enum.
406    #[must_use]
407    pub fn resolved_type_term(&self, ref_type: &str, form: &TermForm) -> Option<String> {
408        let simple = self.type_terms.get(ref_type)?;
409        match form {
410            TermForm::Short => Self::resolve_gendered_value(&simple.short, None)
411                .filter(|value| !value.is_empty())
412                .or_else(|| Self::resolve_gendered_value(&simple.long, None)),
413            _ => Self::resolve_gendered_value(&simple.long, None),
414        }
415        .map(ToOwned::to_owned)
416    }
417
418    /// Resolve an archive hierarchy label, using MF2 messages.
419    /// Returns singular form (count=1) by default.
420    pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
421        self.resolve_message_text(field.message_id(), Some(1), None)
422    }
423
424    /// Get the "and" term based on style preference.
425    pub fn and_term(&self, use_symbol: bool) -> &str {
426        if use_symbol {
427            self.terms.and_symbol.as_deref().unwrap_or("&")
428        } else {
429            self.terms.and.as_deref().unwrap_or("and")
430        }
431    }
432
433    /// Get the "et al." term.
434    pub fn et_al(&self) -> &str {
435        self.terms.et_al.as_deref().unwrap_or("et al.")
436    }
437
438    /// Get a month name.
439    pub fn month_name(&self, month: u8, short: bool) -> &str {
440        let idx = (month.saturating_sub(1)) as usize;
441        if short {
442            self.dates
443                .months
444                .short
445                .get(idx)
446                .map(|s| s.as_str())
447                .unwrap_or("")
448        } else {
449            self.dates
450                .months
451                .long
452                .get(idx)
453                .map(|s| s.as_str())
454                .unwrap_or("")
455        }
456    }
457}
458
459#[cfg(test)]
460#[allow(
461    clippy::unwrap_used,
462    clippy::expect_used,
463    clippy::panic,
464    clippy::indexing_slicing,
465    clippy::todo,
466    clippy::unimplemented,
467    clippy::unreachable,
468    clippy::get_unwrap,
469    reason = "Panicking is acceptable and often desired in tests."
470)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_en_us_locale_terms() {
476        let locale = Locale::en_us();
477        assert_eq!(locale.and_term(false), "and");
478        assert_eq!(locale.and_term(true), "&");
479        assert_eq!(locale.et_al(), "et al.");
480    }
481
482    #[test]
483    fn test_month_names() {
484        let locale = Locale::en_us();
485        assert_eq!(locale.month_name(1, false), "January");
486        assert_eq!(locale.month_name(1, true), "Jan.");
487        assert_eq!(locale.month_name(12, false), "December");
488    }
489
490    #[test]
491    fn test_role_terms() {
492        let locale = Locale::en_us();
493
494        assert_eq!(
495            locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
496            Some("ed.")
497        );
498        assert_eq!(
499            locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
500            Some("eds.")
501        );
502        assert_eq!(
503            locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
504            Some("translated by")
505        );
506    }
507
508    #[test]
509    fn test_no_date_term_resolves_long_and_short_forms() {
510        let locale = Locale::en_us();
511
512        assert_eq!(
513            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
514            Some("no date")
515        );
516        assert_eq!(
517            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
518            Some("n.d.")
519        );
520    }
521
522    #[test]
523    fn test_no_date_term_falls_back_to_legacy_short_form() {
524        let mut locale = Locale::default();
525        locale.terms.no_date = Some("n.d.".to_string());
526
527        assert_eq!(
528            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
529            Some("n.d.")
530        );
531        assert_eq!(
532            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
533            Some("n.d.")
534        );
535    }
536
537    #[test]
538    fn test_yaml_no_date_term_preserves_long_and_short_forms() {
539        let yaml = r#"
540locale: en-US
541dates:
542  months:
543    long: [January, February, March, April, May, June, July, August, September, October, November, December]
544    short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
545  seasons: [Spring, Summer, Autumn, Winter]
546roles: {}
547terms:
548  no date:
549    long: no date
550    short: n.d.
551"#;
552
553        let locale = Locale::from_yaml_str(yaml).unwrap();
554        assert_eq!(
555            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
556            Some("no date")
557        );
558        assert_eq!(
559            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
560            Some("n.d.")
561        );
562        assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
563    }
564
565    #[test]
566    fn test_resolved_locator_term_evaluates_plural_message() {
567        let locale = Locale::en_us();
568
569        assert_eq!(
570            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
571            Some("p.".to_string())
572        );
573        assert_eq!(
574            locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
575            Some("pp.".to_string())
576        );
577    }
578
579    #[test]
580    fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
581        let locale = Locale::from_yaml_str(
582            r#"
583locale: en-US
584locators:
585  reel:
586    long:
587      singular: "reel"
588      plural: "reels"
589"#,
590        )
591        .expect("custom locale should parse");
592
593        assert_eq!(
594            locale.resolved_locator_term(
595                &LocatorType::Custom("reel".to_string()),
596                false,
597                &TermForm::Short,
598                None,
599            ),
600            Some("reel".to_string())
601        );
602        assert_eq!(
603            locale.resolved_locator_term(
604                &LocatorType::Custom("movement".to_string()),
605                false,
606                &TermForm::Short,
607                None,
608            ),
609            Some("movement".to_string())
610        );
611    }
612
613    #[test]
614    fn test_legacy_locator_terms_under_terms_still_populate_locators() {
615        let locale = Locale::from_yaml_str(
616            r#"
617locale: en-US
618terms:
619  page:
620    short:
621      singular: "pg."
622      plural: "pgs."
623"#,
624        )
625        .expect("legacy locator terms should parse");
626
627        assert_eq!(
628            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
629            Some("pg.".to_string())
630        );
631    }
632
633    #[test]
634    fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
635        let locale = Locale::from_yaml_str(
636            r#"
637locale: en-US
638terms:
639  page:
640    short:
641      singular: "pg."
642      plural: "pgs."
643locators:
644  page:
645    short:
646      singular: "p."
647      plural: "pp."
648"#,
649        )
650        .expect("mixed locator forms should parse");
651
652        assert_eq!(
653            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
654            Some("p.".to_string())
655        );
656    }
657
658    #[test]
659    fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
660        let locale = Locale::from_yaml_str(
661            r#"
662locale: en-US
663terms:
664  and:
665    long: "und"
666"#,
667        )
668        .expect("general terms should parse");
669
670        assert_eq!(locale.terms.and.as_deref(), Some("und"));
671        assert!(
672            !locale
673                .locators
674                .contains_key(&LocatorType::Custom("and".to_string()))
675        );
676    }
677
678    #[test]
679    fn test_resolved_role_term_evaluates_plural_message() {
680        let locale = Locale::en_us();
681
682        assert_eq!(
683            locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
684            Some("editor".to_string())
685        );
686        assert_eq!(
687            locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
688            Some("editors".to_string())
689        );
690    }
691
692    #[test]
693    fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
694        let locale = Locale::from_yaml_str(
695            r#"
696locale: es-ES
697roles:
698  editor:
699    long:
700      singular:
701        masculine: editor
702        feminine: editora
703        common: persona editora
704      plural:
705        masculine: editores
706        feminine: editoras
707        common: equipo editorial
708    short:
709      singular: ed.
710      plural: eds.
711    verb: editado por
712"#,
713        )
714        .expect("gendered locale should parse");
715
716        assert_eq!(
717            locale.role_term(
718                &ContributorRole::Editor,
719                false,
720                &TermForm::Long,
721                Some(GrammaticalGender::Feminine),
722            ),
723            Some("editora")
724        );
725        assert_eq!(
726            locale.role_term(
727                &ContributorRole::Editor,
728                true,
729                &TermForm::Long,
730                Some(GrammaticalGender::Common),
731            ),
732            Some("equipo editorial")
733        );
734    }
735
736    #[test]
737    fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
738        let locale = Locale::from_yaml_str(
739            r#"
740locale: es-ES
741terms:
742  no date:
743    long:
744      masculine: sin fecha
745  no_date: s. f.
746"#,
747        )
748        .expect("locale should parse");
749
750        assert_eq!(
751            locale.general_term(
752                &GeneralTerm::NoDate,
753                &TermForm::Long,
754                Some(GrammaticalGender::Common),
755            ),
756            Some("s. f.")
757        );
758    }
759
760    #[test]
761    fn test_es_es_locale_is_embedded() {
762        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
763        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
764        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
765
766        assert_eq!(locale.locale, "es-ES");
767        assert_eq!(
768            locale.resolved_role_term(
769                &ContributorRole::Editor,
770                false,
771                &TermForm::Long,
772                Some(GrammaticalGender::Feminine),
773            ),
774            Some("editora".to_string())
775        );
776    }
777
778    #[test]
779    fn test_es_es_role_term_resolves_gendered_mf2_message() {
780        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
781        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
782        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
783
784        assert_eq!(
785            locale.resolved_role_term(
786                &ContributorRole::Editor,
787                true,
788                &TermForm::Long,
789                Some(GrammaticalGender::Masculine),
790            ),
791            Some("editores".to_string())
792        );
793        assert_eq!(
794            locale.resolved_role_term(
795                &ContributorRole::Translator,
796                true,
797                &TermForm::Long,
798                Some(GrammaticalGender::Feminine),
799            ),
800            Some("traductoras".to_string())
801        );
802        assert_eq!(
803            locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
804            Some("equipo editorial".to_string())
805        );
806    }
807
808    #[test]
809    fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
810        let locale = Locale::from_yaml_str(
811            r#"
812locale: es-ES
813evaluation:
814  message-syntax: mf2
815messages:
816  role.editor.label-long: |
817    .match {$gender :unknown} {$count :plural}
818    when feminine one {editora}
819roles:
820  editor:
821    long:
822      singular:
823        feminine: editora heredada
824      plural:
825        feminine: editoras heredadas
826"#,
827        )
828        .expect("locale should parse");
829
830        assert_eq!(
831            locale.resolved_role_term(
832                &ContributorRole::Editor,
833                false,
834                &TermForm::Long,
835                Some(GrammaticalGender::Feminine),
836            ),
837            Some("editora heredada".to_string())
838        );
839    }
840}