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    /// Resolve an authored term for an ordered combination of contributor roles.
195    #[must_use]
196    pub fn resolved_role_combination_term(
197        &self,
198        roles: &[ContributorRole],
199        plural: bool,
200        form: &TermForm,
201        requested_gender: Option<GrammaticalGender>,
202    ) -> Option<String> {
203        if roles.len() < 2 {
204            return None;
205        }
206        let key = if roles.len() == 2
207            && roles.contains(&ContributorRole::Editor)
208            && roles.contains(&ContributorRole::Translator)
209        {
210            "editor-translator".to_string()
211        } else {
212            roles
213                .iter()
214                .map(ContributorRole::as_str)
215                .collect::<Vec<_>>()
216                .join("-")
217        };
218        let term = self.role_combinations.get(&key)?;
219        let simple = if plural { &term.plural } else { &term.singular };
220        let value = match *form {
221            TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
222            TermForm::Short => {
223                Self::resolve_gendered_value(&simple.short, requested_gender.clone())
224                    .filter(|value| !value.is_empty())
225                    .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
226            }
227            TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, requested_gender),
228            TermForm::VerbShort => {
229                Self::resolve_gendered_value(&term.verb.short, requested_gender.clone())
230                    .filter(|value| !value.is_empty())
231                    .or_else(|| Self::resolve_gendered_value(&term.verb.long, requested_gender))
232            }
233            _ => Self::resolve_gendered_value(&simple.long, requested_gender),
234        };
235        value
236            .filter(|value| !value.is_empty())
237            .map(ToOwned::to_owned)
238    }
239
240    /// Return the locale connector used to compose a missing combined-role term.
241    #[must_use]
242    pub fn role_conjunction(&self) -> &str {
243        self.general_term(&GeneralTerm::RoleConjunction, &TermForm::Long, None)
244            .unwrap_or(" & ")
245    }
246
247    /// Get a locator term.
248    pub fn locator_term(
249        &self,
250        locator: &LocatorType,
251        plural: bool,
252        form: &TermForm,
253        requested_gender: Option<GrammaticalGender>,
254    ) -> Option<&str> {
255        let term = self.locators.get(locator)?;
256        let form_term = match *form {
257            TermForm::Long => &term.long,
258            TermForm::Short => &term.short,
259            TermForm::Symbol => &term.symbol,
260            _ => &term.short, // Fallback
261        };
262
263        if let Some(ft) = form_term {
264            let value = if plural { &ft.plural } else { &ft.singular };
265            Self::resolve_gendered_value(value, requested_gender)
266        } else {
267            None
268        }
269    }
270
271    /// Resolve a locator term, evaluating MF2 messages when configured.
272    pub fn resolved_locator_term(
273        &self,
274        locator: &LocatorType,
275        plural: bool,
276        form: &TermForm,
277        requested_gender: Option<GrammaticalGender>,
278    ) -> Option<String> {
279        if let Some(message_id) = Self::locator_message_id(locator, form)
280            && let Some(resolved) = self.resolve_message_text(
281                message_id,
282                Some(u64::from(plural) + 1),
283                requested_gender.clone(),
284            )
285        {
286            return Some(resolved);
287        }
288
289        self.locator_term(locator, plural, form, requested_gender.clone())
290            .map(ToOwned::to_owned)
291            .or_else(|| {
292                if let LocatorType::Custom(key) = locator {
293                    self.locator_term_any_form(locator, plural, requested_gender)
294                        .map(ToOwned::to_owned)
295                        .or_else(|| Some(key.clone()))
296                } else {
297                    None
298                }
299            })
300    }
301
302    fn locator_term_any_form(
303        &self,
304        locator: &LocatorType,
305        plural: bool,
306        requested_gender: Option<GrammaticalGender>,
307    ) -> Option<&str> {
308        let term = self.locators.get(locator)?;
309        [&term.long, &term.short, &term.symbol]
310            .into_iter()
311            .flatten()
312            .next()
313            .map(|forms| {
314                if plural {
315                    Self::resolve_gendered_value(&forms.plural, requested_gender).unwrap_or("")
316                } else {
317                    Self::resolve_gendered_value(&forms.singular, requested_gender).unwrap_or("")
318                }
319            })
320            .filter(|value| !value.is_empty())
321    }
322
323    /// Resolve a general term to a borrowed string.
324    pub fn general_term(
325        &self,
326        term: &GeneralTerm,
327        form: &TermForm,
328        requested_gender: Option<GrammaticalGender>,
329    ) -> Option<&str> {
330        // Legacy borrowed lookup path: prefer plain v2 messages first, then
331        // alias-backed messages, and finally the v1 term tables. The
332        // candidate id must be form-aware (`general_message_id`) rather than
333        // form-blind, otherwise Long/Short forms collapse onto whichever
334        // message happens to exist for the form-blind id (e.g. `no date`
335        // always resolving to the short "n.d." even when Long was
336        // requested).
337        if let Some(candidate_id) = Self::general_message_id(term, form)
338            && let Some(msg) = self.messages.get(candidate_id)
339        {
340            // Only use plain messages here (no ICU variable syntax)
341            if !msg.contains('{') {
342                return Some(msg.as_str());
343            }
344        }
345        // Check legacy_term_aliases
346        let legacy_key = Self::general_term_to_legacy_key(term);
347        if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
348            && let Some(msg) = self.messages.get(msg_id)
349            && !msg.contains('{')
350        {
351            return Some(msg.as_str());
352        }
353
354        // First try the flattened map
355        if *term != GeneralTerm::NoDate
356            && let Some(simple) = self.terms.general.get(term)
357        {
358            return match *form {
359                TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
360                TermForm::Short => {
361                    Self::resolve_gendered_value(&simple.short, requested_gender.clone())
362                        .filter(|value| !value.is_empty())
363                        .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
364                }
365
366                _ => Self::resolve_gendered_value(&simple.long, requested_gender),
367            };
368        }
369
370        // Fallback to specific fields for common terms
371        match term {
372            GeneralTerm::And => self.terms.and.as_deref(),
373            GeneralTerm::EtAl => self.terms.et_al.as_deref(),
374            GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
375            GeneralTerm::Accessed => self.terms.accessed.as_deref(),
376            GeneralTerm::Ibid => self.terms.ibid.as_deref(),
377            GeneralTerm::In => self.terms.in_.as_deref(),
378            GeneralTerm::NoDate => self
379                .terms
380                .general
381                .get(term)
382                .and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
383                .or(self.terms.no_date.as_deref()),
384            GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
385            GeneralTerm::At => self.terms.at.as_deref(),
386            GeneralTerm::By => self.terms.by.as_deref(),
387            GeneralTerm::From => self.terms.from.as_deref(),
388            GeneralTerm::Of => self
389                .terms
390                .general
391                .get(term)
392                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
393            GeneralTerm::To => self
394                .terms
395                .general
396                .get(term)
397                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
398            GeneralTerm::Anonymous => {
399                Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
400            }
401            GeneralTerm::Circa => {
402                Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
403            }
404            // Fallback to locators for shared terms
405            GeneralTerm::Volume => {
406                self.locator_term(&LocatorType::Volume, false, form, requested_gender)
407            }
408            GeneralTerm::Issue => {
409                self.locator_term(&LocatorType::Issue, false, form, requested_gender)
410            }
411            GeneralTerm::Page => {
412                self.locator_term(&LocatorType::Page, false, form, requested_gender)
413            }
414            GeneralTerm::Chapter => {
415                self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
416            }
417            GeneralTerm::Section => {
418                self.locator_term(&LocatorType::Section, false, form, requested_gender)
419            }
420            GeneralTerm::Here => self
421                .terms
422                .general
423                .get(term)
424                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
425            GeneralTerm::Deposited => self
426                .terms
427                .general
428                .get(term)
429                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
430            _ => None,
431        }
432    }
433
434    /// Resolve a general term, evaluating MF2 messages when configured.
435    pub fn resolved_general_term(
436        &self,
437        term: &GeneralTerm,
438        form: &TermForm,
439        requested_gender: Option<GrammaticalGender>,
440    ) -> Option<String> {
441        if let Some(message_id) = Self::general_message_id(term, form)
442            && let Some(resolved) =
443                self.resolve_message_text(message_id, None, requested_gender.clone())
444        {
445            return Some(resolved);
446        }
447
448        self.general_term(term, form, requested_gender)
449            .map(ToOwned::to_owned)
450    }
451
452    /// Resolve a reference-type description term (e.g. `"dataset"`,
453    /// `"article-journal"`) for the `type-label` template component.
454    ///
455    /// Looks up `ref_type` in [`Locale::type_terms`] directly — unlike
456    /// [`Self::resolved_general_term`], there is no MF2 message path for
457    /// these terms yet, since they are dynamically keyed by an open
458    /// `ref_type` string rather than the closed [`GeneralTerm`] enum.
459    #[must_use]
460    pub fn resolved_type_term(&self, ref_type: &str, form: &TermForm) -> Option<String> {
461        let simple = self.type_terms.get(ref_type)?;
462        match form {
463            TermForm::Short => Self::resolve_gendered_value(&simple.short, None)
464                .filter(|value| !value.is_empty())
465                .or_else(|| Self::resolve_gendered_value(&simple.long, None)),
466            _ => Self::resolve_gendered_value(&simple.long, None),
467        }
468        .map(ToOwned::to_owned)
469    }
470
471    /// Resolve an archive hierarchy label, using MF2 messages.
472    /// Returns singular form (count=1) by default.
473    pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
474        self.resolve_message_text(field.message_id(), Some(1), None)
475    }
476
477    /// Get the "and" term based on style preference.
478    pub fn and_term(&self, use_symbol: bool) -> &str {
479        if use_symbol {
480            self.terms.and_symbol.as_deref().unwrap_or("&")
481        } else {
482            self.terms.and.as_deref().unwrap_or("and")
483        }
484    }
485
486    /// Get the "et al." term.
487    pub fn et_al(&self) -> &str {
488        self.terms.et_al.as_deref().unwrap_or("et al.")
489    }
490
491    /// Get a month name.
492    pub fn month_name(&self, month: u8, short: bool) -> &str {
493        let idx = (month.saturating_sub(1)) as usize;
494        if short {
495            self.dates
496                .months
497                .short
498                .get(idx)
499                .map(|s| s.as_str())
500                .unwrap_or("")
501        } else {
502            self.dates
503                .months
504                .long
505                .get(idx)
506                .map(|s| s.as_str())
507                .unwrap_or("")
508        }
509    }
510}
511
512#[cfg(test)]
513#[allow(
514    clippy::unwrap_used,
515    clippy::expect_used,
516    clippy::panic,
517    clippy::indexing_slicing,
518    clippy::todo,
519    clippy::unimplemented,
520    clippy::unreachable,
521    clippy::get_unwrap,
522    reason = "Panicking is acceptable and often desired in tests."
523)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_en_us_locale_terms() {
529        let locale = Locale::en_us();
530        assert_eq!(locale.and_term(false), "and");
531        assert_eq!(locale.and_term(true), "&");
532        assert_eq!(locale.et_al(), "et al.");
533    }
534
535    #[test]
536    fn test_month_names() {
537        let locale = Locale::en_us();
538        assert_eq!(locale.month_name(1, false), "January");
539        assert_eq!(locale.month_name(1, true), "Jan.");
540        assert_eq!(locale.month_name(12, false), "December");
541    }
542
543    #[test]
544    fn test_role_terms() {
545        let locale = Locale::en_us();
546
547        assert_eq!(
548            locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
549            Some("ed.")
550        );
551        assert_eq!(
552            locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
553            Some("eds.")
554        );
555        assert_eq!(
556            locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
557            Some("translated by")
558        );
559    }
560
561    #[test]
562    fn test_no_date_term_resolves_long_and_short_forms() {
563        let locale = Locale::en_us();
564
565        assert_eq!(
566            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
567            Some("no date")
568        );
569        assert_eq!(
570            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
571            Some("n.d.")
572        );
573    }
574
575    #[test]
576    fn test_no_date_term_falls_back_to_legacy_short_form() {
577        let mut locale = Locale::default();
578        locale.terms.no_date = Some("n.d.".to_string());
579
580        assert_eq!(
581            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
582            Some("n.d.")
583        );
584        assert_eq!(
585            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
586            Some("n.d.")
587        );
588    }
589
590    #[test]
591    fn test_yaml_no_date_term_preserves_long_and_short_forms() {
592        let yaml = r#"
593locale: en-US
594dates:
595  months:
596    long: [January, February, March, April, May, June, July, August, September, October, November, December]
597    short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
598  seasons: [Spring, Summer, Autumn, Winter]
599roles: {}
600terms:
601  no date:
602    long: no date
603    short: n.d.
604"#;
605
606        let locale = Locale::from_yaml_str(yaml).unwrap();
607        assert_eq!(
608            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
609            Some("no date")
610        );
611        assert_eq!(
612            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
613            Some("n.d.")
614        );
615        assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
616    }
617
618    #[test]
619    fn test_resolved_locator_term_evaluates_plural_message() {
620        let locale = Locale::en_us();
621
622        assert_eq!(
623            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
624            Some("p.".to_string())
625        );
626        assert_eq!(
627            locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
628            Some("pp.".to_string())
629        );
630    }
631
632    #[test]
633    fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
634        let locale = Locale::from_yaml_str(
635            r#"
636locale: en-US
637locators:
638  reel:
639    long:
640      singular: "reel"
641      plural: "reels"
642"#,
643        )
644        .expect("custom locale should parse");
645
646        assert_eq!(
647            locale.resolved_locator_term(
648                &LocatorType::Custom("reel".to_string()),
649                false,
650                &TermForm::Short,
651                None,
652            ),
653            Some("reel".to_string())
654        );
655        assert_eq!(
656            locale.resolved_locator_term(
657                &LocatorType::Custom("movement".to_string()),
658                false,
659                &TermForm::Short,
660                None,
661            ),
662            Some("movement".to_string())
663        );
664    }
665
666    #[test]
667    fn test_legacy_locator_terms_under_terms_still_populate_locators() {
668        let locale = Locale::from_yaml_str(
669            r#"
670locale: en-US
671terms:
672  page:
673    short:
674      singular: "pg."
675      plural: "pgs."
676"#,
677        )
678        .expect("legacy locator terms should parse");
679
680        assert_eq!(
681            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
682            Some("pg.".to_string())
683        );
684    }
685
686    #[test]
687    fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
688        let locale = Locale::from_yaml_str(
689            r#"
690locale: en-US
691terms:
692  page:
693    short:
694      singular: "pg."
695      plural: "pgs."
696locators:
697  page:
698    short:
699      singular: "p."
700      plural: "pp."
701"#,
702        )
703        .expect("mixed locator forms should parse");
704
705        assert_eq!(
706            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
707            Some("p.".to_string())
708        );
709    }
710
711    #[test]
712    fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
713        let locale = Locale::from_yaml_str(
714            r#"
715locale: en-US
716terms:
717  and:
718    long: "und"
719"#,
720        )
721        .expect("general terms should parse");
722
723        assert_eq!(locale.terms.and.as_deref(), Some("und"));
724        assert!(
725            !locale
726                .locators
727                .contains_key(&LocatorType::Custom("and".to_string()))
728        );
729    }
730
731    #[test]
732    fn test_resolved_role_term_evaluates_plural_message() {
733        let locale = Locale::en_us();
734
735        assert_eq!(
736            locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
737            Some("editor".to_string())
738        );
739        assert_eq!(
740            locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
741            Some("editors".to_string())
742        );
743    }
744
745    #[test]
746    fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
747        let locale = Locale::from_yaml_str(
748            r#"
749locale: es-ES
750roles:
751  editor:
752    long:
753      singular:
754        masculine: editor
755        feminine: editora
756        common: persona editora
757      plural:
758        masculine: editores
759        feminine: editoras
760        common: equipo editorial
761    short:
762      singular: ed.
763      plural: eds.
764    verb: editado por
765"#,
766        )
767        .expect("gendered locale should parse");
768
769        assert_eq!(
770            locale.role_term(
771                &ContributorRole::Editor,
772                false,
773                &TermForm::Long,
774                Some(GrammaticalGender::Feminine),
775            ),
776            Some("editora")
777        );
778        assert_eq!(
779            locale.role_term(
780                &ContributorRole::Editor,
781                true,
782                &TermForm::Long,
783                Some(GrammaticalGender::Common),
784            ),
785            Some("equipo editorial")
786        );
787    }
788
789    #[test]
790    fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
791        let locale = Locale::from_yaml_str(
792            r#"
793locale: es-ES
794terms:
795  no date:
796    long:
797      masculine: sin fecha
798  no_date: s. f.
799"#,
800        )
801        .expect("locale should parse");
802
803        assert_eq!(
804            locale.general_term(
805                &GeneralTerm::NoDate,
806                &TermForm::Long,
807                Some(GrammaticalGender::Common),
808            ),
809            Some("s. f.")
810        );
811    }
812
813    #[test]
814    fn test_es_es_locale_is_embedded() {
815        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
816        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
817        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
818
819        assert_eq!(locale.locale, "es-ES");
820        assert_eq!(
821            locale.resolved_role_term(
822                &ContributorRole::Editor,
823                false,
824                &TermForm::Long,
825                Some(GrammaticalGender::Feminine),
826            ),
827            Some("editora".to_string())
828        );
829    }
830
831    #[test]
832    fn test_es_es_role_term_resolves_gendered_mf2_message() {
833        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
834        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
835        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
836
837        assert_eq!(
838            locale.resolved_role_term(
839                &ContributorRole::Editor,
840                true,
841                &TermForm::Long,
842                Some(GrammaticalGender::Masculine),
843            ),
844            Some("editores".to_string())
845        );
846        assert_eq!(
847            locale.resolved_role_term(
848                &ContributorRole::Translator,
849                true,
850                &TermForm::Long,
851                Some(GrammaticalGender::Feminine),
852            ),
853            Some("traductoras".to_string())
854        );
855        assert_eq!(
856            locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
857            Some("equipo editorial".to_string())
858        );
859    }
860
861    #[test]
862    fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
863        let locale = Locale::from_yaml_str(
864            r#"
865locale: es-ES
866evaluation:
867  message-syntax: mf2
868messages:
869  role.editor.label-long: |
870    .match {$gender :unknown} {$count :plural}
871    when feminine one {editora}
872roles:
873  editor:
874    long:
875      singular:
876        feminine: editora heredada
877      plural:
878        feminine: editoras heredadas
879"#,
880        )
881        .expect("locale should parse");
882
883        assert_eq!(
884            locale.resolved_role_term(
885                &ContributorRole::Editor,
886                false,
887                &TermForm::Long,
888                Some(GrammaticalGender::Feminine),
889            ),
890            Some("editora heredada".to_string())
891        );
892    }
893}