Skip to main content

prosaic_grammar_de/
lib.rs

1//! German grammar layer for the Prosaic NLG engine.
2//!
3//! Covers: gender-aware articles (all four cases × three genders × singular/plural),
4//! regular and common-irregular noun pluralization, regular weak verb conjugation
5//! (present/preterite/future) plus ~10 strong irregulars, past and present
6//! participles, German cardinal number spelling (0–999,999), and gendered
7//! nominative pronouns/demonstratives.
8//!
9//! Deliberately out of scope: attributive adjective declension, Konjunktiv,
10//! Perfekt compound tenses, full strong-verb tables. See the plan doc for rationale.
11
12pub(crate) mod articles;
13pub(crate) mod conjugate;
14pub mod gender;
15pub(crate) mod numbers;
16pub(crate) mod pluralize;
17
18use prosaic_core::{
19    AgreementFeatures, Conjunction, Gender, GrammaticalNumber, Language, Person, PluralCategory,
20    ReferenceForm, RstRelation, Tense,
21};
22
23pub use articles::indefinite_article;
24use articles::{article_with_features, basic_article};
25use conjugate::{conjugate_de, past_participle_de, present_participle_de};
26use numbers::number_to_words_de;
27use pluralize::{pluralize_de, singularize_de};
28
29/// German language grammar implementation.
30///
31/// Implements `Language` for German with case-aware article selection,
32/// pluralization, and conjugation. See the crate-level documentation for
33/// scope notes.
34#[derive(Debug, Clone, Default)]
35pub struct German;
36
37impl German {
38    pub fn new() -> Self {
39        Self
40    }
41}
42
43impl Language for German {
44    fn pluralize(&self, word: &str, count: usize) -> String {
45        if count == 1 {
46            word.to_string()
47        } else {
48            pluralize_de(word)
49        }
50    }
51
52    fn singularize(&self, word: &str) -> String {
53        singularize_de(word)
54    }
55
56    /// Return the nominative definite article inferred from the noun's ending.
57    fn article(&self, word: &str) -> &str {
58        basic_article(word)
59    }
60
61    fn conjugate(&self, verb: &str, tense: Tense, person: Person) -> String {
62        conjugate_de(verb, tense, person)
63    }
64
65    fn past_participle(&self, verb: &str) -> String {
66        past_participle_de(verb)
67    }
68
69    fn present_participle(&self, verb: &str) -> String {
70        present_participle_de(verb)
71    }
72
73    fn join_list(&self, items: &[&str], conjunction: Conjunction) -> String {
74        let conj = match conjunction {
75            Conjunction::And => "und",
76            Conjunction::Or => "oder",
77        };
78        join_list_de(items, conj)
79    }
80
81    fn ordinal(&self, n: usize) -> String {
82        match n {
83            1 => "erste".into(),
84            2 => "zweite".into(),
85            3 => "dritte".into(),
86            4 => "vierte".into(),
87            5 => "fünfte".into(),
88            6 => "sechste".into(),
89            7 => "siebte".into(),
90            8 => "achte".into(),
91            9 => "neunte".into(),
92            10 => "zehnte".into(),
93            _ => format!("{n}."),
94        }
95    }
96
97    fn number_to_words(&self, n: usize) -> String {
98        number_to_words_de(n)
99    }
100
101    /// German plural: `n == 1` → One, else Other (CLDR `de`).
102    fn plural_category(&self, n: i64) -> PluralCategory {
103        match n {
104            1 => PluralCategory::One,
105            _ => PluralCategory::Other,
106        }
107    }
108
109    fn realize_reference(
110        &self,
111        form: ReferenceForm,
112        features: &AgreementFeatures,
113    ) -> Option<String> {
114        match form {
115            ReferenceForm::Pronoun => Some(german_pronoun(features)),
116            ReferenceForm::Possessive => Some(german_possessive(features)),
117            ReferenceForm::Demonstrative => Some(german_demonstrative(features)),
118            ReferenceForm::Zero => None,
119            ReferenceForm::Full | ReferenceForm::ShortName => None,
120        }
121    }
122
123    fn plural_description(
124        &self,
125        entity_type: &str,
126        count: usize,
127        features: &AgreementFeatures,
128    ) -> String {
129        match count {
130            0 => String::new(),
131            1 => format!("{} {}", article_with_features(features), entity_type),
132            _ => {
133                let plural_features = AgreementFeatures::default()
134                    .with_gender(features.gender)
135                    .with_case(features.case)
136                    .with_number(GrammaticalNumber::Plural);
137                format!(
138                    "{} {count} {}",
139                    article_with_features(&plural_features),
140                    self.pluralize(entity_type, count)
141                )
142            }
143        }
144    }
145
146    fn proportion_phrase(
147        &self,
148        matching: i64,
149        total: i64,
150        noun_singular: Option<&str>,
151        features: &AgreementFeatures,
152    ) -> String {
153        german_proportion(matching, total, noun_singular, features)
154    }
155
156    fn discourse_marker(&self, relation: RstRelation) -> Option<&'static str> {
157        use RstRelation::*;
158        Some(match relation {
159            Elaboration => "Außerdem ",
160            Contrast => "Allerdings ",
161            Cause => "Deshalb ",
162            Result => "Folglich ",
163            Concession => "Dennoch ",
164            Sequence => "Dann ",
165            Condition => "Wenn dies geschieht, ",
166            Background => "Inzwischen ",
167            Summary => "Zusammenfassend ",
168        })
169    }
170
171    fn is_connective_opener(&self, text: &str) -> bool {
172        const GERMAN_OPENERS: &[&str] = &[
173            "Außerdem",
174            "Darüber hinaus",
175            "Zudem",
176            "Ebenso",
177            "Allerdings",
178            "Andererseits",
179            "Inzwischen",
180            "Deshalb",
181            "Folglich",
182            "Dennoch",
183            "Dann",
184            "Wenn dies geschieht,",
185            "Zusammenfassend",
186        ];
187        GERMAN_OPENERS.iter().any(|opener| text.starts_with(opener))
188    }
189
190    #[cfg(feature = "time")]
191    fn since_last_marker(&self, diff_secs: i64) -> String {
192        const MINUTE: i64 = 60;
193        const HOUR: i64 = 60 * MINUTE;
194        const DAY: i64 = 24 * HOUR;
195        const WEEK: i64 = 7 * DAY;
196        const MONTH: i64 = 30 * DAY;
197        const YEAR: i64 = 365 * DAY;
198
199        if diff_secs <= 0 {
200            return "zur gleichen Zeit".to_string();
201        }
202        if diff_secs < 60 {
203            return "einen Augenblick später".to_string();
204        }
205        if diff_secs < HOUR {
206            let n = ((diff_secs + MINUTE / 2) / MINUTE).max(1);
207            return match n {
208                1 => "eine Minute später".to_string(),
209                _ => format!("{n} Minuten später"),
210            };
211        }
212        if diff_secs < DAY {
213            let n = ((diff_secs + HOUR / 2) / HOUR).max(1);
214            if n < 6 {
215                return match n {
216                    1 => "eine Stunde später".to_string(),
217                    _ => format!("{n} Stunden später"),
218                };
219            }
220            return "später am Tag".to_string();
221        }
222        if diff_secs < 2 * DAY {
223            return "am nächsten Tag".to_string();
224        }
225        if diff_secs < WEEK {
226            let n = diff_secs / DAY;
227            return format!("{n} Tage später");
228        }
229        if diff_secs < 2 * WEEK {
230            return "in der folgenden Woche".to_string();
231        }
232        if diff_secs < MONTH {
233            let n = diff_secs / WEEK;
234            return format!("{n} Wochen später");
235        }
236        if diff_secs < 2 * MONTH {
237            return "im folgenden Monat".to_string();
238        }
239        if diff_secs < YEAR {
240            let n = diff_secs / MONTH;
241            return format!("{n} Monate später");
242        }
243        if diff_secs < 2 * YEAR {
244            return "im folgenden Jahr".to_string();
245        }
246        let n = diff_secs / YEAR;
247        format!("{n} Jahre später")
248    }
249}
250
251// ── Helpers ───────────────────────────────────────────────────────────────────
252
253fn german_pronoun(features: &AgreementFeatures) -> String {
254    let plural = matches!(
255        features.number,
256        GrammaticalNumber::Plural | GrammaticalNumber::Dual
257    );
258    if plural {
259        return "sie".into();
260    }
261    match features.gender {
262        Gender::Fem => "sie".into(),
263        Gender::Neut => "es".into(),
264        _ => "er".into(),
265    }
266}
267
268fn german_possessive(features: &AgreementFeatures) -> String {
269    let plural = matches!(
270        features.number,
271        GrammaticalNumber::Plural | GrammaticalNumber::Dual
272    );
273    if plural {
274        return "ihre".into();
275    }
276    match features.gender {
277        Gender::Fem => "ihre".into(),
278        _ => "sein".into(),
279    }
280}
281
282fn german_demonstrative(features: &AgreementFeatures) -> String {
283    let plural = matches!(
284        features.number,
285        GrammaticalNumber::Plural | GrammaticalNumber::Dual
286    );
287    if plural {
288        return "diese".into();
289    }
290    match features.gender {
291        Gender::Fem => "diese".into(),
292        Gender::Neut => "dieses".into(),
293        _ => "dieser".into(),
294    }
295}
296
297/// Resolve gender for proportion phrasing: explicit features take precedence,
298/// then suffix inference on the noun (single-word; multi-word phrases use
299/// the first token), then a masculine fallback.
300fn resolve_proportion_gender(noun: Option<&str>, features: &AgreementFeatures) -> Gender {
301    match features.gender {
302        Gender::Unknown => noun
303            .and_then(|n| n.split_whitespace().next())
304            .map(gender::infer_gender)
305            .unwrap_or(Gender::Masc),
306        g => g,
307    }
308}
309
310/// German proportion phrasing.
311///
312/// Limitations: attributive-adjective declension is out of scope for v1
313/// (see crate docs). Pass single-word nouns (`"Datei"`, `"Buch"`,
314/// `"Tisch"`) for correct output. Multi-word noun phrases like
315/// `"geänderte Datei"` will pluralize the head noun but leave preceding
316/// adjectives undeclined — readable but not perfectly correct German.
317fn german_proportion(
318    matching: i64,
319    total: i64,
320    noun_singular: Option<&str>,
321    features: &AgreementFeatures,
322) -> String {
323    let n = matching.max(0);
324    let t = total.max(0);
325    let gender = resolve_proportion_gender(noun_singular, features);
326
327    if t == 0 {
328        return match (noun_singular, n, gender) {
329            (Some(noun), 0, Gender::Fem) => format!("keine {noun}"),
330            (Some(noun), 0, _) => format!("kein {noun}"),
331            (None, 0, Gender::Fem) => "keine".to_string(),
332            (None, 0, Gender::Neut) => "keines".to_string(),
333            (None, 0, _) => "keiner".to_string(),
334            // n > 0, t == 0: self-inconsistent; literal fall-through.
335            (Some(noun), _, _) => format!("{n} von 0 {}", pluralize_de(noun)),
336            (None, _, _) => format!("{n} von 0"),
337        };
338    }
339
340    if n == 0 {
341        let none_word = match gender {
342            Gender::Fem => "keine",
343            Gender::Neut => "keines",
344            _ => "keiner",
345        };
346        // Genitive plural article "der" is gender-neutral in plural.
347        return match noun_singular {
348            Some(noun) => format!("{none_word} der {t} {}", pluralize_de(noun)),
349            None => format!("{none_word} der {t}"),
350        };
351    }
352
353    if n >= t {
354        return match (noun_singular, t, gender) {
355            (Some(noun), 1, Gender::Fem) => format!("die einzige {noun}"),
356            (Some(noun), 1, Gender::Neut) => format!("das einzige {noun}"),
357            (Some(noun), 1, _) => format!("der einzige {noun}"),
358            (None, 1, Gender::Fem) => "die einzige".to_string(),
359            (None, 1, Gender::Neut) => "das einzige".to_string(),
360            (None, 1, _) => "der einzige".to_string(),
361            // "beide" doesn't decline by gender in nominative.
362            (Some(noun), 2, _) => format!("beide {}", pluralize_de(noun)),
363            (None, 2, _) => "beide".to_string(),
364            // "alle N" works across genders in nominative.
365            (Some(noun), _, _) => format!("alle {t} {}", pluralize_de(noun)),
366            (None, _, _) => format!("alle {t}"),
367        };
368    }
369
370    match noun_singular {
371        Some(noun) => format!("{n} von {t} {}", pluralize_de(noun)),
372        None => format!("{n} von {t}"),
373    }
374}
375
376fn join_list_de(items: &[&str], conj: &str) -> String {
377    match items.len() {
378        0 => String::new(),
379        1 => items[0].into(),
380        2 => format!("{} {} {}", items[0], conj, items[1]),
381        _ => {
382            let (last, rest) = items.split_last().unwrap();
383            // German: "X, Y und Z" — no Oxford comma before und/oder
384            format!("{} {} {}", rest.join(", "), conj, last)
385        }
386    }
387}
388
389// ── Unit tests ────────────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use prosaic_core::Case;
395
396    // ── pluralize ─────────────────────────────────────────────────────────────
397
398    #[test]
399    fn pluralize_count_one_returns_unchanged() {
400        let de = German::new();
401        assert_eq!(de.pluralize("Klasse", 1), "Klasse");
402    }
403
404    #[test]
405    fn pluralize_count_zero_pluralizes() {
406        let de = German::new();
407        assert_eq!(de.pluralize("Mann", 0), "Männer");
408    }
409
410    #[test]
411    fn pluralize_count_two_pluralizes() {
412        let de = German::new();
413        assert_eq!(de.pluralize("Kind", 2), "Kinder");
414    }
415
416    // ── singularize ───────────────────────────────────────────────────────────
417
418    #[test]
419    fn singularize_delegates() {
420        let de = German::new();
421        assert_eq!(de.singularize("Männer"), "Mann");
422        assert_eq!(de.singularize("Zeitungen"), "Zeitung");
423    }
424
425    // ── article ───────────────────────────────────────────────────────────────
426
427    #[test]
428    fn article_masc_inferred() {
429        let de = German::new();
430        assert_eq!(de.article("Tisch"), "der");
431    }
432
433    #[test]
434    fn article_fem_inferred() {
435        let de = German::new();
436        assert_eq!(de.article("Freiheit"), "die");
437    }
438
439    #[test]
440    fn article_neut_inferred() {
441        let de = German::new();
442        assert_eq!(de.article("Buch"), "das");
443    }
444
445    // ── conjugate ─────────────────────────────────────────────────────────────
446
447    #[test]
448    fn conjugate_present_first() {
449        let de = German::new();
450        assert_eq!(
451            de.conjugate("machen", Tense::Present, Person::First),
452            "mache"
453        );
454    }
455
456    #[test]
457    fn conjugate_present_third() {
458        let de = German::new();
459        assert_eq!(
460            de.conjugate("machen", Tense::Present, Person::Third),
461            "macht"
462        );
463    }
464
465    #[test]
466    fn conjugate_past_first() {
467        let de = German::new();
468        assert_eq!(de.conjugate("machen", Tense::Past, Person::First), "machte");
469    }
470
471    #[test]
472    fn conjugate_past_third() {
473        let de = German::new();
474        assert_eq!(de.conjugate("machen", Tense::Past, Person::Third), "machte");
475    }
476
477    // ── participles ───────────────────────────────────────────────────────────
478
479    #[test]
480    fn past_participle_machen() {
481        let de = German::new();
482        assert_eq!(de.past_participle("machen"), "gemacht");
483    }
484
485    #[test]
486    fn present_participle_machen() {
487        let de = German::new();
488        assert_eq!(de.present_participle("machen"), "machend");
489    }
490
491    // ── join_list ─────────────────────────────────────────────────────────────
492
493    // ── proportion_phrase ─────────────────────────────────────────────────────
494
495    fn no_features() -> AgreementFeatures {
496        AgreementFeatures::default()
497    }
498
499    #[test]
500    fn proportion_two_of_two_fem_noun_reads_beide_plural() {
501        let de = German::new();
502        assert_eq!(
503            de.proportion_phrase(2, 2, Some("Datei"), &no_features()),
504            "beide Dateien"
505        );
506    }
507
508    #[test]
509    fn proportion_all_n_with_noun_reads_alle_n_plural() {
510        let de = German::new();
511        assert_eq!(
512            de.proportion_phrase(13, 13, Some("Datei"), &no_features()),
513            "alle 13 Dateien"
514        );
515    }
516
517    #[test]
518    fn proportion_one_of_one_masc_reads_der_einzige() {
519        let de = German::new();
520        assert_eq!(
521            de.proportion_phrase(1, 1, Some("Tisch"), &no_features()),
522            "der einzige Tisch"
523        );
524    }
525
526    #[test]
527    fn proportion_one_of_one_fem_reads_die_einzige() {
528        let de = German::new();
529        assert_eq!(
530            de.proportion_phrase(1, 1, Some("Datei"), &no_features()),
531            "die einzige Datei"
532        );
533    }
534
535    #[test]
536    fn proportion_one_of_one_neut_reads_das_einzige() {
537        let de = German::new();
538        assert_eq!(
539            de.proportion_phrase(1, 1, Some("Buch"), &no_features()),
540            "das einzige Buch"
541        );
542    }
543
544    #[test]
545    fn proportion_zero_of_n_masc_reads_keiner() {
546        let de = German::new();
547        assert_eq!(
548            de.proportion_phrase(0, 5, Some("Tisch"), &no_features()),
549            "keiner der 5 Tische"
550        );
551    }
552
553    #[test]
554    fn proportion_zero_of_n_fem_reads_keine() {
555        let de = German::new();
556        assert_eq!(
557            de.proportion_phrase(0, 5, Some("Datei"), &no_features()),
558            "keine der 5 Dateien"
559        );
560    }
561
562    #[test]
563    fn proportion_zero_of_n_neut_reads_keines() {
564        let de = German::new();
565        assert_eq!(
566            de.proportion_phrase(0, 5, Some("Buch"), &no_features()),
567            "keines der 5 Bücher"
568        );
569    }
570
571    #[test]
572    fn proportion_zero_zero_masc_reads_kein() {
573        let de = German::new();
574        assert_eq!(
575            de.proportion_phrase(0, 0, Some("Tisch"), &no_features()),
576            "kein Tisch"
577        );
578    }
579
580    #[test]
581    fn proportion_zero_zero_fem_reads_keine() {
582        let de = German::new();
583        assert_eq!(
584            de.proportion_phrase(0, 0, Some("Datei"), &no_features()),
585            "keine Datei"
586        );
587    }
588
589    #[test]
590    fn proportion_zero_zero_neut_reads_kein() {
591        let de = German::new();
592        assert_eq!(
593            de.proportion_phrase(0, 0, Some("Buch"), &no_features()),
594            "kein Buch"
595        );
596    }
597
598    #[test]
599    fn proportion_partial_with_noun() {
600        let de = German::new();
601        assert_eq!(
602            de.proportion_phrase(3, 13, Some("Datei"), &no_features()),
603            "3 von 13 Dateien"
604        );
605    }
606
607    #[test]
608    fn proportion_no_noun_two_two_default_masc() {
609        let de = German::new();
610        assert_eq!(de.proportion_phrase(2, 2, None, &no_features()), "beide");
611    }
612
613    #[test]
614    fn proportion_no_noun_all_n() {
615        let de = German::new();
616        assert_eq!(de.proportion_phrase(7, 7, None, &no_features()), "alle 7");
617    }
618
619    #[test]
620    fn proportion_no_noun_partial() {
621        let de = German::new();
622        assert_eq!(de.proportion_phrase(3, 7, None, &no_features()), "3 von 7");
623    }
624
625    #[test]
626    fn proportion_no_noun_one_of_one_default_masc() {
627        let de = German::new();
628        assert_eq!(
629            de.proportion_phrase(1, 1, None, &no_features()),
630            "der einzige"
631        );
632    }
633
634    #[test]
635    fn proportion_no_noun_one_of_one_fem_explicit() {
636        let de = German::new();
637        let f = AgreementFeatures::default().with_gender(Gender::Fem);
638        assert_eq!(de.proportion_phrase(1, 1, None, &f), "die einzige");
639    }
640
641    #[test]
642    fn proportion_no_noun_one_of_one_neut_explicit() {
643        let de = German::new();
644        let f = AgreementFeatures::default().with_gender(Gender::Neut);
645        assert_eq!(de.proportion_phrase(1, 1, None, &f), "das einzige");
646    }
647
648    #[test]
649    fn proportion_no_noun_zero_of_n_masc() {
650        let de = German::new();
651        assert_eq!(
652            de.proportion_phrase(0, 5, None, &no_features()),
653            "keiner der 5"
654        );
655    }
656
657    #[test]
658    fn proportion_no_noun_zero_of_n_fem() {
659        let de = German::new();
660        let f = AgreementFeatures::default().with_gender(Gender::Fem);
661        assert_eq!(de.proportion_phrase(0, 5, None, &f), "keine der 5");
662    }
663
664    #[test]
665    fn proportion_no_noun_zero_zero_default_masc() {
666        let de = German::new();
667        assert_eq!(de.proportion_phrase(0, 0, None, &no_features()), "keiner");
668    }
669
670    #[test]
671    fn proportion_no_noun_zero_zero_neut() {
672        let de = German::new();
673        let f = AgreementFeatures::default().with_gender(Gender::Neut);
674        assert_eq!(de.proportion_phrase(0, 0, None, &f), "keines");
675    }
676
677    #[test]
678    fn proportion_features_gender_overrides_inference() {
679        let de = German::new();
680        // Override fem despite "Tisch" inferring masc.
681        let f = AgreementFeatures::default().with_gender(Gender::Fem);
682        assert_eq!(de.proportion_phrase(0, 0, Some("Tisch"), &f), "keine Tisch");
683    }
684
685    #[test]
686    fn join_list_empty() {
687        let de = German::new();
688        assert_eq!(de.join_list(&[], Conjunction::And), "");
689    }
690
691    #[test]
692    fn join_list_single() {
693        let de = German::new();
694        assert_eq!(de.join_list(&["eins"], Conjunction::And), "eins");
695    }
696
697    #[test]
698    fn join_list_two_and() {
699        let de = German::new();
700        assert_eq!(
701            de.join_list(&["eins", "zwei"], Conjunction::And),
702            "eins und zwei"
703        );
704    }
705
706    #[test]
707    fn join_list_three_and_no_oxford_comma() {
708        let de = German::new();
709        assert_eq!(
710            de.join_list(&["eins", "zwei", "drei"], Conjunction::And),
711            "eins, zwei und drei"
712        );
713    }
714
715    #[test]
716    fn join_list_two_or() {
717        let de = German::new();
718        assert_eq!(
719            de.join_list(&["ja", "nein"], Conjunction::Or),
720            "ja oder nein"
721        );
722    }
723
724    // ── ordinal ───────────────────────────────────────────────────────────────
725
726    #[test]
727    fn ordinal_first_ten() {
728        let de = German::new();
729        assert_eq!(de.ordinal(1), "erste");
730        assert_eq!(de.ordinal(3), "dritte");
731        assert_eq!(de.ordinal(7), "siebte");
732        assert_eq!(de.ordinal(10), "zehnte");
733    }
734
735    #[test]
736    fn ordinal_beyond_ten_numeric() {
737        let de = German::new();
738        assert_eq!(de.ordinal(11), "11.");
739        assert_eq!(de.ordinal(100), "100.");
740    }
741
742    // ── number_to_words ───────────────────────────────────────────────────────
743
744    #[test]
745    fn number_to_words_spot_checks() {
746        let de = German::new();
747        assert_eq!(de.number_to_words(3), "drei");
748        assert_eq!(de.number_to_words(21), "einundzwanzig");
749    }
750
751    // ── plural_category ───────────────────────────────────────────────────────
752
753    #[test]
754    fn plural_category_one_is_one() {
755        let de = German::new();
756        assert_eq!(de.plural_category(1), PluralCategory::One);
757    }
758
759    #[test]
760    fn plural_category_zero_is_other() {
761        let de = German::new();
762        assert_eq!(de.plural_category(0), PluralCategory::Other);
763    }
764
765    #[test]
766    fn plural_category_many_is_other() {
767        let de = German::new();
768        assert_eq!(de.plural_category(5), PluralCategory::Other);
769    }
770
771    // ── realize_reference ─────────────────────────────────────────────────────
772
773    #[test]
774    fn pronoun_masc_singular() {
775        let de = German::new();
776        let f = AgreementFeatures::default().with_gender(Gender::Masc);
777        assert_eq!(
778            de.realize_reference(ReferenceForm::Pronoun, &f),
779            Some("er".to_string())
780        );
781    }
782
783    #[test]
784    fn pronoun_fem_singular() {
785        let de = German::new();
786        let f = AgreementFeatures::default().with_gender(Gender::Fem);
787        assert_eq!(
788            de.realize_reference(ReferenceForm::Pronoun, &f),
789            Some("sie".to_string())
790        );
791    }
792
793    #[test]
794    fn pronoun_neut_singular() {
795        let de = German::new();
796        let f = AgreementFeatures::default().with_gender(Gender::Neut);
797        assert_eq!(
798            de.realize_reference(ReferenceForm::Pronoun, &f),
799            Some("es".to_string())
800        );
801    }
802
803    #[test]
804    fn pronoun_plural() {
805        let de = German::new();
806        let f = AgreementFeatures::default().with_number(GrammaticalNumber::Plural);
807        assert_eq!(
808            de.realize_reference(ReferenceForm::Pronoun, &f),
809            Some("sie".to_string())
810        );
811    }
812
813    #[test]
814    fn possessive_tracks_owner_gender_and_plural() {
815        let de = German::new();
816        let masc = AgreementFeatures::default().with_gender(Gender::Masc);
817        assert_eq!(
818            de.realize_reference(ReferenceForm::Possessive, &masc),
819            Some("sein".to_string())
820        );
821        let fem = AgreementFeatures::default().with_gender(Gender::Fem);
822        assert_eq!(
823            de.realize_reference(ReferenceForm::Possessive, &fem),
824            Some("ihre".to_string())
825        );
826        let plural = AgreementFeatures::default().with_number(GrammaticalNumber::Plural);
827        assert_eq!(
828            de.realize_reference(ReferenceForm::Possessive, &plural),
829            Some("ihre".to_string())
830        );
831    }
832
833    #[test]
834    fn demonstrative_masc_singular() {
835        let de = German::new();
836        let f = AgreementFeatures::default().with_gender(Gender::Masc);
837        assert_eq!(
838            de.realize_reference(ReferenceForm::Demonstrative, &f),
839            Some("dieser".to_string())
840        );
841    }
842
843    #[test]
844    fn demonstrative_fem_singular() {
845        let de = German::new();
846        let f = AgreementFeatures::default().with_gender(Gender::Fem);
847        assert_eq!(
848            de.realize_reference(ReferenceForm::Demonstrative, &f),
849            Some("diese".to_string())
850        );
851    }
852
853    #[test]
854    fn demonstrative_neut_singular() {
855        let de = German::new();
856        let f = AgreementFeatures::default().with_gender(Gender::Neut);
857        assert_eq!(
858            de.realize_reference(ReferenceForm::Demonstrative, &f),
859            Some("dieses".to_string())
860        );
861    }
862
863    #[test]
864    fn demonstrative_plural() {
865        let de = German::new();
866        let f = AgreementFeatures::default().with_number(GrammaticalNumber::Plural);
867        assert_eq!(
868            de.realize_reference(ReferenceForm::Demonstrative, &f),
869            Some("diese".to_string())
870        );
871    }
872
873    #[test]
874    fn realize_zero_is_none() {
875        let de = German::new();
876        assert_eq!(
877            de.realize_reference(ReferenceForm::Zero, &AgreementFeatures::default()),
878            None
879        );
880    }
881
882    #[test]
883    fn realize_full_is_none() {
884        let de = German::new();
885        assert_eq!(
886            de.realize_reference(ReferenceForm::Full, &AgreementFeatures::default()),
887            None
888        );
889    }
890
891    // ── plural_description ────────────────────────────────────────────────────
892
893    #[test]
894    fn plural_description_zero_is_empty() {
895        let de = German::new();
896        assert_eq!(
897            de.plural_description("Klasse", 0, &AgreementFeatures::default()),
898            ""
899        );
900    }
901
902    #[test]
903    fn plural_description_one_masc() {
904        let de = German::new();
905        let f = AgreementFeatures::default().with_gender(Gender::Masc);
906        assert_eq!(de.plural_description("Tisch", 1, &f), "der Tisch");
907    }
908
909    #[test]
910    fn plural_description_one_fem() {
911        let de = German::new();
912        let f = AgreementFeatures::default().with_gender(Gender::Fem);
913        assert_eq!(de.plural_description("Klasse", 1, &f), "die Klasse");
914    }
915
916    #[test]
917    fn plural_description_one_neut() {
918        let de = German::new();
919        let f = AgreementFeatures::default().with_gender(Gender::Neut);
920        assert_eq!(de.plural_description("Haus", 1, &f), "das Haus");
921    }
922
923    #[test]
924    fn plural_description_many_masc() {
925        let de = German::new();
926        let f = AgreementFeatures::default().with_gender(Gender::Masc);
927        assert_eq!(de.plural_description("Mann", 3, &f), "die 3 Männer");
928    }
929
930    #[test]
931    fn plural_description_many_fem() {
932        let de = German::new();
933        let f = AgreementFeatures::default().with_gender(Gender::Fem);
934        assert_eq!(de.plural_description("Klasse", 3, &f), "die 3 Klassen");
935    }
936
937    #[test]
938    fn plural_description_many_neut() {
939        let de = German::new();
940        let f = AgreementFeatures::default().with_gender(Gender::Neut);
941        assert_eq!(de.plural_description("Haus", 3, &f), "die 3 Häuser");
942    }
943
944    // ── Case-aware plural_description ─────────────────────────────────────────
945
946    #[test]
947    fn plural_description_dative_plural_uses_den() {
948        let de = German::new();
949        let f = AgreementFeatures::default()
950            .with_gender(Gender::Masc)
951            .with_case(Case::Dative);
952        // plural dative = "den"
953        assert_eq!(de.plural_description("Mann", 3, &f), "den 3 Männer");
954    }
955
956    #[test]
957    fn plural_description_dative_singular_masc_uses_dem() {
958        let de = German::new();
959        let f = AgreementFeatures::default()
960            .with_gender(Gender::Masc)
961            .with_case(Case::Dative);
962        assert_eq!(de.plural_description("Tisch", 1, &f), "dem Tisch");
963    }
964
965    // ── discourse_marker ──────────────────────────────────────────────────────
966
967    #[test]
968    fn discourse_marker_german() {
969        let de = German::new();
970        assert_eq!(
971            de.discourse_marker(RstRelation::Elaboration),
972            Some("Außerdem ")
973        );
974        assert_eq!(
975            de.discourse_marker(RstRelation::Contrast),
976            Some("Allerdings ")
977        );
978        assert_eq!(de.discourse_marker(RstRelation::Result), Some("Folglich "));
979    }
980
981    // ── Send + Sync ───────────────────────────────────────────────────────────
982
983    #[test]
984    fn german_is_send_sync() {
985        fn assert_send_sync<T: Send + Sync>() {}
986        assert_send_sync::<German>();
987    }
988
989    // ── since_last_marker ─────────────────────────────────────────────────────
990
991    #[cfg(feature = "time")]
992    #[test]
993    fn since_last_marker_at_same_time() {
994        let de = German::new();
995        assert_eq!(de.since_last_marker(0), "zur gleichen Zeit");
996        assert_eq!(de.since_last_marker(-5), "zur gleichen Zeit");
997    }
998
999    #[cfg(feature = "time")]
1000    #[test]
1001    fn since_last_marker_augenblick_spaeter() {
1002        let de = German::new();
1003        assert_eq!(de.since_last_marker(30), "einen Augenblick später");
1004    }
1005
1006    #[cfg(feature = "time")]
1007    #[test]
1008    fn since_last_marker_am_naechsten_tag() {
1009        let de = German::new();
1010        assert_eq!(de.since_last_marker(86_400 + 1), "am nächsten Tag");
1011    }
1012
1013    #[cfg(feature = "time")]
1014    #[test]
1015    fn since_last_marker_folgende_woche() {
1016        let de = German::new();
1017        assert_eq!(
1018            de.since_last_marker(7 * 86_400 + 1),
1019            "in der folgenden Woche"
1020        );
1021    }
1022
1023    #[cfg(feature = "time")]
1024    #[test]
1025    fn since_last_marker_monate_spaeter() {
1026        let de = German::new();
1027        assert_eq!(de.since_last_marker(3 * 30 * 86_400), "3 Monate später");
1028    }
1029}