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 for a calendar month (`1`-`12`), by its EDTF
492    /// sub-year code. Returns an empty string for an out-of-range month.
493    pub fn month_name(&self, month: u8, short: bool) -> &str {
494        let Some(code) = super::types::SubYearCode::new(month) else {
495            return "";
496        };
497        let table = if short {
498            &self.dates.months.short
499        } else {
500            &self.dates.months.long
501        };
502        table.get(&code).map(String::as_str).unwrap_or("")
503    }
504}
505
506#[cfg(test)]
507#[allow(
508    clippy::unwrap_used,
509    clippy::expect_used,
510    clippy::panic,
511    clippy::indexing_slicing,
512    clippy::todo,
513    clippy::unimplemented,
514    clippy::unreachable,
515    clippy::get_unwrap,
516    reason = "Panicking is acceptable and often desired in tests."
517)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn test_en_us_locale_terms() {
523        let locale = Locale::en_us();
524        assert_eq!(locale.and_term(false), "and");
525        assert_eq!(locale.and_term(true), "&");
526        assert_eq!(locale.et_al(), "et al.");
527    }
528
529    #[test]
530    fn test_month_names() {
531        let locale = Locale::en_us();
532        assert_eq!(locale.month_name(1, false), "January");
533        assert_eq!(locale.month_name(1, true), "Jan.");
534        assert_eq!(locale.month_name(12, false), "December");
535    }
536
537    #[test]
538    fn test_role_terms() {
539        let locale = Locale::en_us();
540
541        assert_eq!(
542            locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
543            Some("ed.")
544        );
545        assert_eq!(
546            locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
547            Some("eds.")
548        );
549        assert_eq!(
550            locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
551            Some("translated by")
552        );
553    }
554
555    #[test]
556    fn test_no_date_term_resolves_long_and_short_forms() {
557        let locale = Locale::en_us();
558
559        assert_eq!(
560            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
561            Some("no date")
562        );
563        assert_eq!(
564            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
565            Some("n.d.")
566        );
567    }
568
569    #[test]
570    fn test_no_date_term_falls_back_to_legacy_short_form() {
571        let mut locale = Locale::default();
572        locale.terms.no_date = Some("n.d.".to_string());
573
574        assert_eq!(
575            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
576            Some("n.d.")
577        );
578        assert_eq!(
579            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
580            Some("n.d.")
581        );
582    }
583
584    #[test]
585    fn test_yaml_no_date_term_preserves_long_and_short_forms() {
586        let yaml = r#"
587locale: en-US
588dates:
589  months:
590    long: [January, February, March, April, May, June, July, August, September, October, November, December]
591    short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
592  seasons: [Spring, Summer, Autumn, Winter]
593roles: {}
594terms:
595  no date:
596    long: no date
597    short: n.d.
598"#;
599
600        let locale = Locale::from_yaml_str(yaml).unwrap();
601        assert_eq!(
602            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
603            Some("no date")
604        );
605        assert_eq!(
606            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
607            Some("n.d.")
608        );
609        assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
610    }
611
612    #[test]
613    fn test_resolved_locator_term_evaluates_plural_message() {
614        let locale = Locale::en_us();
615
616        assert_eq!(
617            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
618            Some("p.".to_string())
619        );
620        assert_eq!(
621            locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
622            Some("pp.".to_string())
623        );
624    }
625
626    #[test]
627    fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
628        let locale = Locale::from_yaml_str(
629            r#"
630locale: en-US
631locators:
632  reel:
633    long:
634      singular: "reel"
635      plural: "reels"
636"#,
637        )
638        .expect("custom locale should parse");
639
640        assert_eq!(
641            locale.resolved_locator_term(
642                &LocatorType::Custom("reel".to_string()),
643                false,
644                &TermForm::Short,
645                None,
646            ),
647            Some("reel".to_string())
648        );
649        assert_eq!(
650            locale.resolved_locator_term(
651                &LocatorType::Custom("movement".to_string()),
652                false,
653                &TermForm::Short,
654                None,
655            ),
656            Some("movement".to_string())
657        );
658    }
659
660    #[test]
661    fn test_legacy_locator_terms_under_terms_still_populate_locators() {
662        let locale = Locale::from_yaml_str(
663            r#"
664locale: en-US
665terms:
666  page:
667    short:
668      singular: "pg."
669      plural: "pgs."
670"#,
671        )
672        .expect("legacy locator terms should parse");
673
674        assert_eq!(
675            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
676            Some("pg.".to_string())
677        );
678    }
679
680    #[test]
681    fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
682        let locale = Locale::from_yaml_str(
683            r#"
684locale: en-US
685terms:
686  page:
687    short:
688      singular: "pg."
689      plural: "pgs."
690locators:
691  page:
692    short:
693      singular: "p."
694      plural: "pp."
695"#,
696        )
697        .expect("mixed locator forms should parse");
698
699        assert_eq!(
700            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
701            Some("p.".to_string())
702        );
703    }
704
705    #[test]
706    fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
707        let locale = Locale::from_yaml_str(
708            r#"
709locale: en-US
710terms:
711  and:
712    long: "und"
713"#,
714        )
715        .expect("general terms should parse");
716
717        assert_eq!(locale.terms.and.as_deref(), Some("und"));
718        assert!(
719            !locale
720                .locators
721                .contains_key(&LocatorType::Custom("and".to_string()))
722        );
723    }
724
725    #[test]
726    fn test_resolved_role_term_evaluates_plural_message() {
727        let locale = Locale::en_us();
728
729        assert_eq!(
730            locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
731            Some("editor".to_string())
732        );
733        assert_eq!(
734            locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
735            Some("editors".to_string())
736        );
737    }
738
739    #[test]
740    fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
741        let locale = Locale::from_yaml_str(
742            r#"
743locale: es-ES
744roles:
745  editor:
746    long:
747      singular:
748        masculine: editor
749        feminine: editora
750        common: persona editora
751      plural:
752        masculine: editores
753        feminine: editoras
754        common: equipo editorial
755    short:
756      singular: ed.
757      plural: eds.
758    verb: editado por
759"#,
760        )
761        .expect("gendered locale should parse");
762
763        assert_eq!(
764            locale.role_term(
765                &ContributorRole::Editor,
766                false,
767                &TermForm::Long,
768                Some(GrammaticalGender::Feminine),
769            ),
770            Some("editora")
771        );
772        assert_eq!(
773            locale.role_term(
774                &ContributorRole::Editor,
775                true,
776                &TermForm::Long,
777                Some(GrammaticalGender::Common),
778            ),
779            Some("equipo editorial")
780        );
781    }
782
783    #[test]
784    fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
785        let locale = Locale::from_yaml_str(
786            r#"
787locale: es-ES
788terms:
789  no date:
790    long:
791      masculine: sin fecha
792  no_date: s. f.
793"#,
794        )
795        .expect("locale should parse");
796
797        assert_eq!(
798            locale.general_term(
799                &GeneralTerm::NoDate,
800                &TermForm::Long,
801                Some(GrammaticalGender::Common),
802            ),
803            Some("s. f.")
804        );
805    }
806
807    #[test]
808    fn test_es_es_locale_is_embedded() {
809        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
810        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
811        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
812
813        assert_eq!(locale.locale, "es-ES");
814        assert_eq!(
815            locale.resolved_role_term(
816                &ContributorRole::Editor,
817                false,
818                &TermForm::Long,
819                Some(GrammaticalGender::Feminine),
820            ),
821            Some("editora".to_string())
822        );
823    }
824
825    #[test]
826    fn test_es_es_role_term_resolves_gendered_mf2_message() {
827        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
828        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
829        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");
830
831        assert_eq!(
832            locale.resolved_role_term(
833                &ContributorRole::Editor,
834                true,
835                &TermForm::Long,
836                Some(GrammaticalGender::Masculine),
837            ),
838            Some("editores".to_string())
839        );
840        assert_eq!(
841            locale.resolved_role_term(
842                &ContributorRole::Translator,
843                true,
844                &TermForm::Long,
845                Some(GrammaticalGender::Feminine),
846            ),
847            Some("traductoras".to_string())
848        );
849        assert_eq!(
850            locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
851            Some("equipo editorial".to_string())
852        );
853    }
854
855    #[test]
856    fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
857        let locale = Locale::from_yaml_str(
858            r#"
859locale: es-ES
860evaluation:
861  message-syntax: mf2
862messages:
863  role.editor.label-long: |
864    .match {$gender :unknown} {$count :plural}
865    when feminine one {editora}
866roles:
867  editor:
868    long:
869      singular:
870        feminine: editora heredada
871      plural:
872        feminine: editoras heredadas
873"#,
874        )
875        .expect("locale should parse");
876
877        assert_eq!(
878            locale.resolved_role_term(
879                &ContributorRole::Editor,
880                false,
881                &TermForm::Long,
882                Some(GrammaticalGender::Feminine),
883            ),
884            Some("editora heredada".to_string())
885        );
886    }
887}