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.
279        let candidate_id = format!("term.{}", Self::general_term_to_message_id(term));
280        if let Some(msg) = self.messages.get(&candidate_id) {
281            // Only use plain messages here (no ICU variable syntax)
282            if !msg.contains('{') {
283                return Some(msg.as_str());
284            }
285        }
286        // Check legacy_term_aliases
287        let legacy_key = Self::general_term_to_legacy_key(term);
288        if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
289            && let Some(msg) = self.messages.get(msg_id)
290            && !msg.contains('{')
291        {
292            return Some(msg.as_str());
293        }
294
295        // First try the flattened map
296        if *term != GeneralTerm::NoDate
297            && let Some(simple) = self.terms.general.get(term)
298        {
299            return match *form {
300                TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
301                TermForm::Short => {
302                    Self::resolve_gendered_value(&simple.short, requested_gender.clone())
303                        .filter(|value| !value.is_empty())
304                        .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
305                }
306
307                _ => Self::resolve_gendered_value(&simple.long, requested_gender),
308            };
309        }
310
311        // Fallback to specific fields for common terms
312        match term {
313            GeneralTerm::And => self.terms.and.as_deref(),
314            GeneralTerm::EtAl => self.terms.et_al.as_deref(),
315            GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
316            GeneralTerm::Accessed => self.terms.accessed.as_deref(),
317            GeneralTerm::Ibid => self.terms.ibid.as_deref(),
318            GeneralTerm::In => self.terms.in_.as_deref(),
319            GeneralTerm::NoDate => self
320                .terms
321                .general
322                .get(term)
323                .and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
324                .or(self.terms.no_date.as_deref()),
325            GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
326            GeneralTerm::At => self.terms.at.as_deref(),
327            GeneralTerm::By => self.terms.by.as_deref(),
328            GeneralTerm::From => self.terms.from.as_deref(),
329            GeneralTerm::Of => self
330                .terms
331                .general
332                .get(term)
333                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
334            GeneralTerm::To => self
335                .terms
336                .general
337                .get(term)
338                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
339            GeneralTerm::Anonymous => {
340                Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
341            }
342            GeneralTerm::Circa => {
343                Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
344            }
345            // Fallback to locators for shared terms
346            GeneralTerm::Volume => {
347                self.locator_term(&LocatorType::Volume, false, form, requested_gender)
348            }
349            GeneralTerm::Issue => {
350                self.locator_term(&LocatorType::Issue, false, form, requested_gender)
351            }
352            GeneralTerm::Page => {
353                self.locator_term(&LocatorType::Page, false, form, requested_gender)
354            }
355            GeneralTerm::Chapter => {
356                self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
357            }
358            GeneralTerm::Section => {
359                self.locator_term(&LocatorType::Section, false, form, requested_gender)
360            }
361            GeneralTerm::Here => self
362                .terms
363                .general
364                .get(term)
365                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
366            GeneralTerm::Deposited => self
367                .terms
368                .general
369                .get(term)
370                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
371            _ => None,
372        }
373    }
374
375    /// Resolve a general term, evaluating MF2 messages when configured.
376    pub fn resolved_general_term(
377        &self,
378        term: &GeneralTerm,
379        form: &TermForm,
380        requested_gender: Option<GrammaticalGender>,
381    ) -> Option<String> {
382        if let Some(message_id) = Self::general_message_id(term, form)
383            && let Some(resolved) =
384                self.resolve_message_text(message_id, None, requested_gender.clone())
385        {
386            return Some(resolved);
387        }
388
389        self.general_term(term, form, requested_gender)
390            .map(ToOwned::to_owned)
391    }
392
393    /// Resolve an archive hierarchy label, using MF2 messages.
394    /// Returns singular form (count=1) by default.
395    pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
396        self.resolve_message_text(field.message_id(), Some(1), None)
397    }
398
399    /// Get the "and" term based on style preference.
400    pub fn and_term(&self, use_symbol: bool) -> &str {
401        if use_symbol {
402            self.terms.and_symbol.as_deref().unwrap_or("&")
403        } else {
404            self.terms.and.as_deref().unwrap_or("and")
405        }
406    }
407
408    /// Get the "et al." term.
409    pub fn et_al(&self) -> &str {
410        self.terms.et_al.as_deref().unwrap_or("et al.")
411    }
412
413    /// Get a month name.
414    pub fn month_name(&self, month: u8, short: bool) -> &str {
415        let idx = (month.saturating_sub(1)) as usize;
416        if short {
417            self.dates
418                .months
419                .short
420                .get(idx)
421                .map(|s| s.as_str())
422                .unwrap_or("")
423        } else {
424            self.dates
425                .months
426                .long
427                .get(idx)
428                .map(|s| s.as_str())
429                .unwrap_or("")
430        }
431    }
432}
433
434#[cfg(test)]
435#[allow(
436    clippy::unwrap_used,
437    clippy::expect_used,
438    clippy::panic,
439    clippy::indexing_slicing,
440    clippy::todo,
441    clippy::unimplemented,
442    clippy::unreachable,
443    clippy::get_unwrap,
444    reason = "Panicking is acceptable and often desired in tests."
445)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_en_us_locale_terms() {
451        let locale = Locale::en_us();
452        assert_eq!(locale.and_term(false), "and");
453        assert_eq!(locale.and_term(true), "&");
454        assert_eq!(locale.et_al(), "et al.");
455    }
456
457    #[test]
458    fn test_month_names() {
459        let locale = Locale::en_us();
460        assert_eq!(locale.month_name(1, false), "January");
461        assert_eq!(locale.month_name(1, true), "Jan.");
462        assert_eq!(locale.month_name(12, false), "December");
463    }
464
465    #[test]
466    fn test_role_terms() {
467        let locale = Locale::en_us();
468
469        assert_eq!(
470            locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
471            Some("ed.")
472        );
473        assert_eq!(
474            locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
475            Some("eds.")
476        );
477        assert_eq!(
478            locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
479            Some("translated by")
480        );
481    }
482
483    #[test]
484    fn test_no_date_term_resolves_long_and_short_forms() {
485        let locale = Locale::en_us();
486
487        assert_eq!(
488            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
489            Some("no date")
490        );
491        assert_eq!(
492            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
493            Some("n.d.")
494        );
495    }
496
497    #[test]
498    fn test_no_date_term_falls_back_to_legacy_short_form() {
499        let mut locale = Locale::default();
500        locale.terms.no_date = Some("n.d.".to_string());
501
502        assert_eq!(
503            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
504            Some("n.d.")
505        );
506        assert_eq!(
507            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
508            Some("n.d.")
509        );
510    }
511
512    #[test]
513    fn test_yaml_no_date_term_preserves_long_and_short_forms() {
514        let yaml = r#"
515locale: en-US
516dates:
517  months:
518    long: [January, February, March, April, May, June, July, August, September, October, November, December]
519    short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
520  seasons: [Spring, Summer, Autumn, Winter]
521roles: {}
522terms:
523  no date:
524    long: no date
525    short: n.d.
526"#;
527
528        let locale = Locale::from_yaml_str(yaml).unwrap();
529        assert_eq!(
530            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
531            Some("no date")
532        );
533        assert_eq!(
534            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
535            Some("n.d.")
536        );
537        assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
538    }
539
540    #[test]
541    fn test_resolved_locator_term_evaluates_plural_message() {
542        let locale = Locale::en_us();
543
544        assert_eq!(
545            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
546            Some("p.".to_string())
547        );
548        assert_eq!(
549            locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
550            Some("pp.".to_string())
551        );
552    }
553
554    #[test]
555    fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
556        let locale = Locale::from_yaml_str(
557            r#"
558locale: en-US
559locators:
560  reel:
561    long:
562      singular: "reel"
563      plural: "reels"
564"#,
565        )
566        .expect("custom locale should parse");
567
568        assert_eq!(
569            locale.resolved_locator_term(
570                &LocatorType::Custom("reel".to_string()),
571                false,
572                &TermForm::Short,
573                None,
574            ),
575            Some("reel".to_string())
576        );
577        assert_eq!(
578            locale.resolved_locator_term(
579                &LocatorType::Custom("movement".to_string()),
580                false,
581                &TermForm::Short,
582                None,
583            ),
584            Some("movement".to_string())
585        );
586    }
587
588    #[test]
589    fn test_legacy_locator_terms_under_terms_still_populate_locators() {
590        let locale = Locale::from_yaml_str(
591            r#"
592locale: en-US
593terms:
594  page:
595    short:
596      singular: "pg."
597      plural: "pgs."
598"#,
599        )
600        .expect("legacy locator terms should parse");
601
602        assert_eq!(
603            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
604            Some("pg.".to_string())
605        );
606    }
607
608    #[test]
609    fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
610        let locale = Locale::from_yaml_str(
611            r#"
612locale: en-US
613terms:
614  page:
615    short:
616      singular: "pg."
617      plural: "pgs."
618locators:
619  page:
620    short:
621      singular: "p."
622      plural: "pp."
623"#,
624        )
625        .expect("mixed locator forms should parse");
626
627        assert_eq!(
628            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
629            Some("p.".to_string())
630        );
631    }
632
633    #[test]
634    fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
635        let locale = Locale::from_yaml_str(
636            r#"
637locale: en-US
638terms:
639  and:
640    long: "und"
641"#,
642        )
643        .expect("general terms should parse");
644
645        assert_eq!(locale.terms.and.as_deref(), Some("und"));
646        assert!(
647            !locale
648                .locators
649                .contains_key(&LocatorType::Custom("and".to_string()))
650        );
651    }
652
653    #[test]
654    fn test_resolved_role_term_evaluates_plural_message() {
655        let locale = Locale::en_us();
656
657        assert_eq!(
658            locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
659            Some("editor".to_string())
660        );
661        assert_eq!(
662            locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
663            Some("editors".to_string())
664        );
665    }
666
667    #[test]
668    fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
669        let locale = Locale::from_yaml_str(
670            r#"
671locale: es-ES
672roles:
673  editor:
674    long:
675      singular:
676        masculine: editor
677        feminine: editora
678        common: persona editora
679      plural:
680        masculine: editores
681        feminine: editoras
682        common: equipo editorial
683    short:
684      singular: ed.
685      plural: eds.
686    verb: editado por
687"#,
688        )
689        .expect("gendered locale should parse");
690
691        assert_eq!(
692            locale.role_term(
693                &ContributorRole::Editor,
694                false,
695                &TermForm::Long,
696                Some(GrammaticalGender::Feminine),
697            ),
698            Some("editora")
699        );
700        assert_eq!(
701            locale.role_term(
702                &ContributorRole::Editor,
703                true,
704                &TermForm::Long,
705                Some(GrammaticalGender::Common),
706            ),
707            Some("equipo editorial")
708        );
709    }
710
711    #[test]
712    fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
713        let locale = Locale::from_yaml_str(
714            r#"
715locale: es-ES
716terms:
717  no date:
718    long:
719      masculine: sin fecha
720  no_date: s. f.
721"#,
722        )
723        .expect("locale should parse");
724
725        assert_eq!(
726            locale.general_term(
727                &GeneralTerm::NoDate,
728                &TermForm::Long,
729                Some(GrammaticalGender::Common),
730            ),
731            Some("s. f.")
732        );
733    }
734
735    #[test]
736    fn test_es_es_locale_is_embedded() {
737        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
738        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
739        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
740
741        assert_eq!(locale.locale, "es-ES");
742        assert_eq!(
743            locale.resolved_role_term(
744                &ContributorRole::Editor,
745                false,
746                &TermForm::Long,
747                Some(GrammaticalGender::Feminine),
748            ),
749            Some("editora".to_string())
750        );
751    }
752
753    #[test]
754    fn test_es_es_role_term_resolves_gendered_mf2_message() {
755        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
756        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
757        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
758
759        assert_eq!(
760            locale.resolved_role_term(
761                &ContributorRole::Editor,
762                true,
763                &TermForm::Long,
764                Some(GrammaticalGender::Masculine),
765            ),
766            Some("editores".to_string())
767        );
768        assert_eq!(
769            locale.resolved_role_term(
770                &ContributorRole::Translator,
771                true,
772                &TermForm::Long,
773                Some(GrammaticalGender::Feminine),
774            ),
775            Some("traductoras".to_string())
776        );
777        assert_eq!(
778            locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
779            Some("equipo editorial".to_string())
780        );
781    }
782
783    #[test]
784    fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
785        let locale = Locale::from_yaml_str(
786            r#"
787locale: es-ES
788evaluation:
789  message-syntax: mf2
790messages:
791  role.editor.label-long: |
792    .match {$gender :unknown} {$count :plural}
793    when feminine one {editora}
794roles:
795  editor:
796    long:
797      singular:
798        feminine: editora heredada
799      plural:
800        feminine: editoras heredadas
801"#,
802        )
803        .expect("locale should parse");
804
805        assert_eq!(
806            locale.resolved_role_term(
807                &ContributorRole::Editor,
808                false,
809                &TermForm::Long,
810                Some(GrammaticalGender::Feminine),
811            ),
812            Some("editora heredada".to_string())
813        );
814    }
815}