Skip to main content

num2words2_core/
lang_es_cr.rs

1//! Port of `lang_ES_CR.py` (Spanish — Costa Rica).
2//!
3//! Registry check: `__init__.py` maps `"es_CR" -> lang_ES_CR.Num2Word_ES_CR()`,
4//! so this file ports `Num2Word_ES_CR` — the class the key actually resolves to.
5//!
6//! **`Num2Word_ES_CR` overrides only `CURRENCY_FORMS` and `to_currency`.** For
7//! `to_cardinal` / `to_ordinal` / `to_ordinal_num` / `to_year` this language is
8//! byte-for-byte `Num2Word_ES`. Everything below is therefore a port of
9//! `lang_ES.py` via its `lang_EUR` → `Num2Word_Base` ancestry. (`lang_EU.py` is
10//! Basque and is *not* in this chain despite the similar name.)
11//!
12//! Shape: **engine**. `Num2Word_ES.setup` defines `high_numwords` /
13//! `mid_numwords` / `low_numwords` and `merge`, and lets
14//! `Num2Word_Base.to_cardinal` drive `splitnum`/`clean`. `to_ordinal`,
15//! `to_ordinal_num` and `to_year` are overridden outright.
16//!
17//! # Card table
18//!
19//! `setup` calls `gen_high_numwords([], [], lows)`. With `units == tens == []`
20//! the comprehension `[u + t for t in tens for u in units]` is empty, so the
21//! Latin-prefix elision machinery is a no-op and the function just returns
22//! `lows`. Those nine stems then go through `Num2Word_EUR.set_high_numwords`
23//! with `GIGA_SUFFIX = None` (long-scale "illiard" rungs suppressed) and
24//! `MEGA_SUFFIX = "illón"`, yielding one card every 10^6 from 10^6 to 10^54.
25//!
26//! `MAXVAL = 1000 * list(self.cards.keys())[0]`. Python indexes the *first
27//! inserted* key of an `OrderedDict`, which here is also the largest (10^54),
28//! so `Cards::highest()` reproduces it exactly: MAXVAL == 10^57.
29//!
30//! # Faithfully reproduced Python quirks
31//!
32//! This is a port, not a rewrite. All of the following are exactly what Python
33//! emits, verified against the interpreter:
34//!
35//! 1. **`to_ordinal(20)` == "vigesimo", not "vigésimo".** The `value <= 29`
36//!    branch does `self.ords[dec].replace("é", "e")` to handle Spanish
37//!    *sobreesdrújula* orthography, and 20 lands in that branch, so the accent
38//!    is stripped even though nothing is suffixed. 30..=100 keep the accent
39//!    ("trigésimo"). Likewise `to_ordinal(120)` == "centésimo vigesimo".
40//! 2. **Missing space before "milésimo"/"millonésimo"/…** The `value < 1e18`
41//!    branch formats `"%s%s%s %s" % (cardinal, ords[dec], gender_stem, ...)`
42//!    with no separator after `cardinal`, so `to_ordinal(2000)` ==
43//!    "dosmilésimo" and `to_ordinal(99999)` ==
44//!    "noventa y nuevemilésimo noningentésimo nonagésimo noveno".
45//! 3. **`.replace("oo", "o")`** is applied at *every* recursion level, not just
46//!    the top. It exists for "decimooctavo" -> "decimoctavo" (and
47//!    "vigesimooctavo" -> "vigesimoctavo"), but it is an unconditional global
48//!    replace, not a targeted fix.
49//! 4. **`to_ordinal(0)` == ""** (empty string), while `to_ordinal_num(0)` == "0º".
50//! 5. **`errmsg_toobig` reads "deber ser inferior"**, not "debe ser inferior" —
51//!    a typo in the Python source, preserved verbatim in [`LangEsCr::to_cardinal`].
52//! 6. **`ords` keys 1e3..1e15 are Python floats** (`1e3`, `1e6`, …) yet are
53//!    looked up with `int` values. Python dicts hash `1000 == 1000.0` to the
54//!    same slot, so the lookups succeed; the `BigInt` keys here are the
55//!    equivalent.
56//!
57//! # `int(math.log(value, 1000))` — float error is load-bearing
58//!
59//! The `value < 1e18` branch picks its scale with
60//! `dec = 1000 ** int(math.log(int(value), 1000))`. That is a *float* log, and
61//! it disagrees with the exact `floor(log1000(v))` on two contiguous ranges,
62//! both of which are observable. Verified exhaustively at the thresholds and
63//! against 535k sampled values (see [`py_log1000`]):
64//!
65//! * `v` in `[999999999999996, 10^15)` -> returns 5 (exact: 4), so
66//!   `dec = 10^15 > v`, giving `high_part == 0` and `low_part == v`. The
67//!   recursive `to_ordinal(low_part)` is then called with the *same* value:
68//!   Python raises **`RecursionError`**.
69//! * `v` in `[999999999999995072, 10^18)` -> returns 6 (exact: 5), so
70//!   `dec = 10^18`, which is absent from `ords`: Python raises
71//!   **`KeyError: 1000000000000000000`**. This fires *before* the recursion
72//!   because the `%`-format tuple is evaluated left to right and `ords[dec]`
73//!   precedes `to_ordinal(low_part)`.
74//!
75//! `RecursionError` has no `N2WError` variant (PORTING.md maps neither it nor
76//! its `RuntimeError` base). Letting it recur for real would overflow the Rust
77//! stack and abort the process, which is strictly worse than any error, so
78//! [`LangEsCr::to_ordinal_gender`] guards on the exact trigger condition
79//! (`high_part == 0`, checked *after* the `ords` lookup so the `KeyError` range
80//! still reports `Key`) and returns `N2WError::Value` carrying the Python
81//! exception name. **This is the one place where the error *type* is knowingly
82//! not reproducible** — flagged in the report.
83//!
84//! # Currency
85//!
86//! `to_currency` is a three-layer sandwich, and every layer is observable:
87//!
88//! ```text
89//! Num2Word_ES_CR.to_currency   separator=" y", currency="CRC"
90//!   -> Num2Word_ES.to_currency     int arm hand-rolled; float arm -> super()
91//!        -> Num2Word_Base.to_currency
92//!        <- six literal .replace() fixups          (ES)
93//!   <- one blanket .replace("uno", "un")           (ES_CR)
94//! ```
95//!
96//! `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
97//! inherited from `Num2Word_Base` untouched, and `pluralize` from
98//! `Num2Word_EUR`; the trait defaults already mirror those, so they are not
99//! re-implemented here. Note that `to_cheque` reaches `to_cardinal` through
100//! `_money_verbose` and therefore gets **none** of the un/uno fixups:
101//! `to_cheque(1.0, "EUR")` is `"UNO AND 00/100 EUROS"`, not `"UN ..."`.
102//!
103//! ## `CURRENCY_FORMS` shadows, it does not extend
104//!
105//! `Num2Word_ES_CR.CURRENCY_FORMS` is a class attribute **of its own**, so
106//! attribute lookup stops at `Num2Word_ES_CR`: neither `Num2Word_ES`'s
107//! ~150-entry table nor the `lang_EUR` dict that `Num2Word_EN.__init__` mutates
108//! in place is ever consulted. Only CRC/EUR/USD resolve — GBP, JPY, KWD, BHD,
109//! INR, CNY and CHF all raise NotImplementedError here even though `lang_ES.py`
110//! defines every one of them. The corpus records exactly that.
111//!
112//! `CURRENCY_PRECISION` is likewise never defined down this chain, so it is
113//! `Num2Word_Base`'s empty dict and every code has divisor 100. (EN *rebinds*
114//! rather than mutates it, so EN's mils table does not leak.) The 3-decimal and
115//! 0-decimal branches are therefore unreachable for this language — doubly so,
116//! since KWD/BHD/JPY are not in the forms table to begin with.
117//!
118//! `CURRENCY_ADJECTIVES` *is* inherited — from `Num2Word_EUR`, unmutated, since
119//! neither ES nor ES_CR defines one. Of the three codes that resolve, only USD
120//! carries an adjective ("US"), and it is reachable only on the float arm.
121//!
122//! ## Faithfully reproduced Python quirks (currency)
123//!
124//! All verified against the interpreter:
125//!
126//! 7. **Double space after "menos" on negative integers.** `Num2Word_ES`'s
127//!    integer arm sets `minus_str = self.negword` — the *raw* value, trailing
128//!    space and all — then formats `"%s %s %s"` and calls `.strip()`, which only
129//!    trims the ends. `to_currency(-1, "EUR")` == `"menos  un euro"`. The float
130//!    arm goes through `Num2Word_Base`, which uses `"%s " % self.negword.strip()`
131//!    and yields a single space: `to_currency(-1.0, "EUR")` ==
132//!    `"menos un euro y cero céntimos"`. Same converter, two spacings.
133//! 8. **`adjective=` is ignored on the integer arm.** `Num2Word_ES` never reads
134//!    `CURRENCY_ADJECTIVES` there, so `to_currency(2, "USD", adjective=True)` is
135//!    `"dos dólares"`, while the float `to_currency(12.34, "USD",
136//!    adjective=True)` *is* prefixed: `"doce US dólares y ..."`.
137//! 9. **"colónes" is misspelled.** RAE has "colones", and `lang_ES.py` spells
138//!    the same entry correctly; `lang_ES_CR.py` does not. It is what the class
139//!    emits, so it is what this port emits.
140//! 10. **The un/uno fixups are blind substring replaces**, and the two layers
141//!     disagree with each other. See [`es_uno_fixups`] and
142//!     [`Lang::to_currency`].
143//!
144//! ## Out of scope: fractional cents
145//!
146//! `to_currency(1.011, "EUR")` is reachable and Python answers
147//! `"un euro y un punto un céntimos"` — `_money_verbose` is fine but the cents
148//! word comes from `self.to_cardinal(float(right))`, i.e. Base's float path.
149//! `cardinal_from_decimal` is left at its trait default (which routes to
150//! `floatpath`) per the porting contract; the fixup layers above still apply on
151//! top of whatever it returns. No corpus row exercises this. Flagged in the
152//! report.
153
154use crate::base::{
155    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, Lang, N2WError,
156    Result,
157};
158use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
159use crate::floatpath::FloatValue;
160use num_bigint::BigInt;
161use num_traits::{FromPrimitive, One, Signed, Zero};
162use std::collections::HashMap;
163
164/// `self.gender_stem`, set once in `setup` and never mutated.
165///
166/// `to_ordinal`/`to_ordinal_num` shadow it with a *local* `gender_stem`, so the
167/// instance attribute stays "o" for the whole lifetime of the converter and
168/// `merge` can treat it as a constant.
169const GENDER_STEM: &str = "o";
170
171fn pow10(n: u32) -> BigInt {
172    BigInt::from(10u8).pow(n)
173}
174
175/// Python's `s[:-n]` — slices by **character**, not byte.
176///
177/// Load-bearing: `merge` does `ntext[:-3] + "lones"` on words like "millón"
178/// (6 chars / 7 bytes, because "ó" is two bytes). Byte slicing would yield
179/// "mill" + "lones" and could split the "ó" mid-codepoint.
180fn drop_last_chars(s: &str, n: usize) -> String {
181    let count = s.chars().count();
182    s.chars().take(count.saturating_sub(n)).collect()
183}
184
185/// Reproduces `int(math.log(int(v), 1000))` for `1000 < v < 10^18`.
186///
187/// Exact everywhere except the two ranges documented in the module header,
188/// where CPython's float log overshoots the true floor. Modelling those
189/// explicitly is cheaper and more predictable than trying to re-derive f64
190/// rounding, and it was checked exhaustively at both thresholds.
191fn py_log1000(v: &BigInt) -> u32 {
192    // Ordered high-to-low; the two ranges do not overlap.
193    if *v >= BigInt::from(999_999_999_999_995_072u64) && *v < pow10(18) {
194        return 6;
195    }
196    if *v >= BigInt::from(999_999_999_999_996u64) && *v < pow10(15) {
197        return 5;
198    }
199    let thousand = BigInt::from(1000);
200    let mut k = 0u32;
201    let mut p = BigInt::one();
202    loop {
203        let next = &p * &thousand;
204        if next <= *v {
205            p = next;
206            k += 1;
207        } else {
208            return k;
209        }
210    }
211}
212
213/// `Num2Word_ES_CR.CURRENCY_FORMS`, verbatim — all three entries of it.
214///
215/// This is the whole table, not a delta: the class attribute shadows
216/// `Num2Word_ES`'s (see module header), which is why so many corpus rows are
217/// NotImplementedError.
218fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
219    const CENTIMOS: [&str; 2] = ["céntimo", "céntimos"];
220
221    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
222    // "colónes" is a typo for "colones" (quirk 9) — preserved.
223    m.insert("CRC", CurrencyForms::new(&["colón", "colónes"], &CENTIMOS));
224    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTIMOS));
225    m.insert(
226        "USD",
227        CurrencyForms::new(&["dólar", "dólares"], &["centavo", "centavos"]),
228    );
229    m
230}
231
232/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged.
233///
234/// Neither `Num2Word_ES` nor `Num2Word_ES_CR` defines or mutates it, and
235/// `Num2Word_EN.__init__` only ever writes to `CURRENCY_FORMS`, so this dict is
236/// exactly EUR's class body. Fifteen of the sixteen codes are unreachable here
237/// (they are not in `CURRENCY_FORMS`); only USD can ever match. It is ported in
238/// full anyway, because the lookup Python performs is against this table and
239/// trimming it would encode an inference rather than the data.
240fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
241    [
242        ("AUD", "Australian"),
243        ("BYN", "Belarusian"),
244        ("CAD", "Canadian"),
245        ("EEK", "Estonian"),
246        ("USD", "US"),
247        ("RUB", "Russian"),
248        ("NOK", "Norwegian"),
249        ("MXN", "Mexican"),
250        ("RON", "Romanian"),
251        ("INR", "Indian"),
252        ("HUF", "Hungarian"),
253        ("ISK", "íslenskar"),
254        ("UZS", "Uzbekistan"),
255        ("SAR", "Saudi"),
256        ("JPY", "Japanese"),
257        ("KRW", "Korean"),
258    ]
259    .into_iter()
260    .collect()
261}
262
263/// The six `str.replace` calls closing `Num2Word_ES.to_currency`'s float arm,
264/// in source order.
265///
266/// Both the order and the blind-substring semantics are load-bearing; these
267/// read like word-aware apocope rules but are not, and the difference shows up
268/// in real corpus-adjacent values:
269///
270/// * `"veintiuno dólares y uno centavo"` (21.01 USD): the `"uno centavo"` rule
271///   fires first, then `"uno dólar"` matches **inside** `"veintiuno dólares"`
272///   (`veinti|uno dólar|es`), giving `"veintiun dólares y un centavo"`.
273/// * The `"veintiuno euro"`/`"veintiuno céntimo"` rules exist only for EUR and
274///   céntimos, so 21 cents renders "veintiún" against EUR but "veintiun"
275///   against USD: `to_currency(21.21, "EUR")` == `"veintiún euros y veintiún
276///   céntimos"` while `to_currency(21.21, "USD")` == `"veintiun dólares y
277///   veintiun centavos"` — accented in one, unaccented in the other, because
278///   the accented rewrites simply never mention "centavo" or "dólar".
279///
280/// Rewriting this as a rule would change output. It is a transcription.
281fn es_uno_fixups(s: &str) -> String {
282    s.replace("veintiuno euro", "veintiún euro")
283        .replace("veintiuno céntimo", "veintiún céntimo")
284        .replace("uno euro", "un euro")
285        .replace("uno céntimo", "un céntimo")
286        .replace("uno centavo", "un centavo")
287        .replace("uno dólar", "un dólar")
288}
289
290
291/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
292/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
293/// OverflowError before any TypeError; a finite fractional value fails the
294/// comparison and raises TypeError with ES's `errmsg_floatord`.
295fn float_ordinal_reject(value: &FloatValue) -> N2WError {
296    if let FloatValue::Float { value: f, .. } = value {
297        if f.is_nan() {
298            return N2WError::Value("cannot convert float NaN to integer".into());
299        }
300        if f.is_infinite() {
301            return N2WError::Overflow("cannot convert float infinity to integer".into());
302        }
303    }
304    let shown = match value {
305        FloatValue::Float { value: f, .. } => format!("{}", f),
306        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
307    };
308    N2WError::Type(format!(
309        "El float {} no puede ser tratado como un ordinal.",
310        shown
311    ))
312}
313
314/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
315/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
316fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
317    match value {
318        FloatValue::Float { value: f, .. } => {
319            if f.is_nan() {
320                return Err(N2WError::Value(
321                    "cannot convert float NaN to integer".into(),
322                ));
323            }
324            if f.is_infinite() {
325                return Err(N2WError::Overflow(
326                    "cannot convert float infinity to integer".into(),
327                ));
328            }
329            // Exact: every whole f64 is exactly representable as an integer.
330            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
331        }
332        FloatValue::Decimal { value: d, .. } => {
333            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
334            // which truncates toward zero — exactly Python's int().
335            Ok(d.with_scale(0).as_bigint_and_exponent().0)
336        }
337    }
338}
339
340pub struct LangEsCr {
341    cards: Cards,
342    maxval: BigInt,
343    ords: HashMap<BigInt, &'static str>,
344    exclude_title: Vec<String>,
345    currency_forms: HashMap<&'static str, CurrencyForms>,
346    currency_adjectives: HashMap<&'static str, &'static str>,
347}
348
349impl Default for LangEsCr {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355impl LangEsCr {
356    pub fn new() -> Self {
357        // setup(): gen_high_numwords([], [], lows) == lows, since both the
358        // units and tens lists are empty (see module header).
359        let lows = [
360            "non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m",
361        ];
362
363        let mut cards = Cards::new();
364
365        // Num2Word_EUR.set_high_numwords, with GIGA_SUFFIX=None / MEGA_SUFFIX="illón":
366        //   cap = 3 + 6*len(high)                       -> 57
367        //   for word, n in zip(high, range(cap, 3, -6)) -> n = 57, 51, ..., 9
368        //       cards[10**(n-3)] = word + "illón"       -> 10^54 ... 10^6
369        // Both sides of the zip have 9 elements, so neither truncates.
370        let cap: u32 = 3 + 6 * lows.len() as u32;
371        let mut n = cap;
372        for word in lows.iter() {
373            if n <= 3 {
374                break;
375            }
376            cards.insert(pow10(n - 3), format!("{}illón", word));
377            n -= 6;
378        }
379
380        set_mid_numwords(
381            &mut cards,
382            &[
383                (1000, "mil"),
384                (100, "cien"),
385                (90, "noventa"),
386                (80, "ochenta"),
387                (70, "setenta"),
388                (60, "sesenta"),
389                (50, "cincuenta"),
390                (40, "cuarenta"),
391                (30, "treinta"),
392            ],
393        );
394        set_low_numwords(
395            &mut cards,
396            &[
397                "veintinueve",
398                "veintiocho",
399                "veintisiete",
400                "veintiséis",
401                "veinticinco",
402                "veinticuatro",
403                "veintitrés",
404                "veintidós",
405                "veintiuno",
406                "veinte",
407                "diecinueve",
408                "dieciocho",
409                "diecisiete",
410                "dieciséis",
411                "quince",
412                "catorce",
413                "trece",
414                "doce",
415                "once",
416                "diez",
417                "nueve",
418                "ocho",
419                "siete",
420                "seis",
421                "cinco",
422                "cuatro",
423                "tres",
424                "dos",
425                "uno",
426                "cero",
427            ],
428        );
429
430        // MAXVAL = 1000 * list(self.cards.keys())[0]; the first-inserted key is
431        // also the largest (10^54), so this is 10^57.
432        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
433
434        let mut ords: HashMap<BigInt, &'static str> = HashMap::new();
435        for (k, v) in [
436            (1i64, "primer"),
437            (2, "segund"),
438            (3, "tercer"),
439            (4, "cuart"),
440            (5, "quint"),
441            (6, "sext"),
442            (7, "séptim"),
443            (8, "octav"),
444            (9, "noven"),
445            (10, "décim"),
446            (20, "vigésim"),
447            (30, "trigésim"),
448            (40, "cuadragésim"),
449            (50, "quincuagésim"),
450            (60, "sexagésim"),
451            (70, "septuagésim"),
452            (80, "octogésim"),
453            (90, "nonagésim"),
454            (100, "centésim"),
455            (200, "ducentésim"),
456            (300, "tricentésim"),
457            (400, "cuadrigentésim"),
458            (500, "quingentésim"),
459            (600, "sexcentésim"),
460            (700, "septigentésim"),
461            (800, "octigentésim"),
462            (900, "noningentésim"),
463        ] {
464            ords.insert(BigInt::from(k), v);
465        }
466        // Python spells these as floats (1e3, 1e6, ...) but looks them up with
467        // ints; dict hashing makes the two equivalent.
468        ords.insert(pow10(3), "milésim");
469        ords.insert(pow10(6), "millonésim");
470        ords.insert(pow10(9), "billonésim");
471        ords.insert(pow10(12), "trillonésim");
472        ords.insert(pow10(15), "cuadrillonésim");
473
474        LangEsCr {
475            cards,
476            maxval,
477            ords,
478            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
479            // Built once here, never per call. `to_currency` only reads these.
480            currency_forms: build_currency_forms(),
481            currency_adjectives: build_currency_adjectives(),
482        }
483    }
484
485    /// `Num2Word_Base.verify_ordinal`.
486    ///
487    /// The float check (`errmsg_floatord`) is unreachable for `BigInt` input;
488    /// only the negative check can fire, and it raises `TypeError` — not
489    /// `ValueError` — which is what the corpus records for every negative
490    /// `ordinal` / `ordinal_num` row.
491    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
492        if value.is_negative() {
493            return Err(N2WError::Type(format!(
494                "El número negativo {} no puede ser tratado como un ordinal.",
495                value
496            )));
497        }
498        Ok(())
499    }
500
501    /// `self.ords[key]`, raising `KeyError` when absent (as Python does for
502    /// `dec == 10^18`).
503    fn ord_word(&self, key: &BigInt) -> Result<&'static str> {
504        self.ords
505            .get(key)
506            .copied()
507            .ok_or_else(|| N2WError::Key(format!("{}", key)))
508    }
509
510    /// `Num2Word_ES.to_ordinal(value, gender="m")`.
511    ///
512    /// The dispatcher only ever calls with the default gender, but the method
513    /// recurses passing `gender` through, so the parameter is kept internally.
514    fn to_ordinal_gender(&self, value: &BigInt, gender: &str) -> Result<String> {
515        let mut gender_stem = if gender == "f" { "a" } else { "o" };
516        self.verify_ordinal(value)?;
517
518        let ten = BigInt::from(10);
519        let hundred = BigInt::from(100);
520        let thousand = BigInt::from(1000);
521
522        let text: String = if value.is_zero() {
523            String::new()
524        } else if *value <= ten {
525            format!("{}{}", self.ord_word(value)?, gender_stem)
526        } else if *value <= BigInt::from(29) {
527            // RAE: simple forms preferred up to 30. The unconditional
528            // `.replace("é", "e")` is what makes 20 render as "vigesimo".
529            gender_stem = "o";
530            let dec = (value / &ten) * &ten;
531            format!(
532                "{}{}{}",
533                self.ord_word(&dec)?.replace('é', "e"),
534                gender_stem,
535                self.to_ordinal_gender(&(value % &ten), gender)?
536            )
537        } else if *value <= hundred {
538            let dec = (value / &ten) * &ten;
539            format!(
540                "{}{} {}",
541                self.ord_word(&dec)?,
542                gender_stem,
543                self.to_ordinal_gender(&(value - &dec), gender)?
544            )
545        } else if *value <= thousand {
546            let cen = (value / &hundred) * &hundred;
547            format!(
548                "{}{} {}",
549                self.ord_word(&cen)?,
550                gender_stem,
551                self.to_ordinal_gender(&(value - &cen), gender)?
552            )
553        } else if *value < pow10(18) {
554            // Round down to the nearest 1e(3n). See py_log1000 for why this is
555            // not simply floor(log1000(value)).
556            let dec = pow10(3 * py_log1000(value));
557            let high_part = value / &dec;
558            let low_part = value % &dec;
559
560            // Python evaluates the %-format operands left to right:
561            //   cardinal -> ords[dec] -> gender_stem -> to_ordinal(low_part)
562            // so `cardinal` is computed first and `ords[dec]` can raise before
563            // the recursion is ever entered. Order preserved here.
564            let cardinal = if !high_part.is_one() {
565                self.to_cardinal(&high_part)?
566            } else {
567                String::new()
568            };
569            let ordw = self.ord_word(&dec)?;
570            if high_part.is_zero() {
571                // dec > value: Python calls to_ordinal(low_part) with
572                // low_part == value and recurses until RecursionError.
573                return Err(N2WError::Value(format!(
574                    "RecursionError: maximum recursion depth exceeded (to_ordinal({}))",
575                    value
576                )));
577            }
578            format!(
579                "{}{}{} {}",
580                cardinal,
581                ordw,
582                gender_stem,
583                self.to_ordinal_gender(&low_part, gender)?
584            )
585        } else {
586            self.to_cardinal(value)?
587        };
588
589        // Applied at every level, not just the top: "decimooctavo" ->
590        // "decimoctavo".
591        Ok(text.trim().replace("oo", "o"))
592    }
593
594    /// `Num2Word_ES.to_currency` — i.e. the `super()` call that
595    /// `Num2Word_ES_CR.to_currency` makes. Kept as its own method so the two
596    /// classes' fixup layers stay visibly distinct; they do *not* compose into
597    /// one rule (see [`Lang::to_currency`]).
598    ///
599    /// `separator` is already resolved to a concrete `&str` by the caller.
600    fn es_to_currency(
601        &self,
602        val: &CurrencyValue,
603        currency: &str,
604        cents: bool,
605        separator: &str,
606        adjective: bool,
607    ) -> Result<String> {
608        // `if isinstance(val, int)` — pure ints only. A float that happens to
609        // be whole (1.0) takes the arm below and still prints cents.
610        if let CurrencyValue::Int(v) = val {
611            // Python: `except (KeyError, AttributeError): return
612            // super().to_currency(...)`. Base repeats the lookup and converts
613            // the KeyError into NotImplementedError, so delegating keeps that
614            // message coming from one place instead of duplicating it. (The
615            // AttributeError half is dead code: `CURRENCY_FORMS` always
616            // resolves through the MRO.)
617            let forms = match self.currency_forms.get(currency) {
618                Some(f) => f,
619                None => {
620                    return default_to_currency(self, val, currency, cents, separator, adjective)
621                }
622            };
623            let cr1 = &forms.unit;
624
625            // `minus_str = self.negword if val < 0 else ""` — the raw negword,
626            // trailing space included. Quirk 7.
627            let minus_str = if v.is_negative() { self.negword() } else { "" };
628            let abs_val = v.abs();
629            // Python computes this unconditionally and then throws it away when
630            // abs_val == 1. It can raise OverflowError first, and the lookup
631            // above can raise NotImplementedError before *it*, so the order of
632            // all three is observable: to_currency(10**60, "GBP") is
633            // NotImplementedError, to_currency(10**60, "EUR") is OverflowError.
634            let cardinal = self.to_cardinal(&abs_val)?;
635
636            let (money_str, currency_str) = if abs_val.is_one() {
637                // `money_str = "un"`: a hand-written apocope that discards
638                // to_cardinal(1) == "uno".
639                ("un".to_string(), cr1.first())
640            } else {
641                // `cr1[1] if len(cr1) > 1 else cr1[0]`.
642                (cardinal, cr1.get(1).or_else(|| cr1.first()))
643            };
644            let currency_str =
645                currency_str.ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
646
647            // `adjective` is deliberately not consulted here — Python's integer
648            // arm never reads CURRENCY_ADJECTIVES (quirk 8). It *is* honoured on
649            // the float arm below.
650            //
651            // `("%s %s %s" % (...)).strip()`: trim() matches strip() at the ends
652            // only, and must NOT collapse the interior double space that a
653            // negative minus_str introduces (quirk 7).
654            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
655                .trim()
656                .to_string());
657        }
658
659        // Floats/Decimals: `super(Num2Word_ES, self).to_currency(...)`, which is
660        // `Num2Word_Base.to_currency` (EUR defines none). This is the arm that
661        // honours `adjective=`, renders the cents segment, and uses the *trimmed*
662        // negword.
663        let result = default_to_currency(self, val, currency, cents, separator, adjective)?;
664        Ok(es_uno_fixups(&result))
665    }
666}
667
668impl Lang for LangEsCr {
669
670    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
671        crate::lang_es::es_str_to_number(s)
672    }
673    /// This language's own `to_currency(currency=...)` default,
674    /// read from the live Python signature. Only 44 of 156 use EUR.
675    fn default_currency(&self) -> &str {
676        "CRC"
677    }
678
679    /// This language's own `to_currency(separator=...)` default,
680    /// read from the live Python signature. Base's is ",", but only
681    /// 36 of 149 languages actually use it — most default to " " or a
682    /// conjunction, so inheriting Base's comma silently corrupts them.
683    fn default_separator(&self) -> &str {
684        " y"
685    }
686
687    fn cards(&self) -> &Cards {
688        &self.cards
689    }
690    fn maxval(&self) -> &BigInt {
691        &self.maxval
692    }
693    fn negword(&self) -> &str {
694        "menos "
695    }
696    fn pointword(&self) -> &str {
697        "punto"
698    }
699    fn exclude_title(&self) -> &[String] {
700        &self.exclude_title
701    }
702
703    /// `Num2Word_ES.merge`.
704    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
705        let (ctext0, cnum) = l;
706        let (ntext0, nnum) = r;
707        let mut ctext = ctext0.to_string();
708        let mut ntext = ntext0.to_string();
709
710        let hundred = BigInt::from(100);
711        let thousand = BigInt::from(1000);
712        let million = BigInt::from(1_000_000);
713
714        if cnum.is_one() {
715            if *nnum < million {
716                return (ntext, nnum.clone());
717            }
718            ctext = "un".to_string();
719        } else if *cnum == hundred && !(nnum % &thousand).is_zero() {
720            // "cien" -> "ciento", but only when the tail is not a round
721            // thousand: 100_000 stays "cien mil".
722            ctext.push('t');
723            ctext.push_str(GENDER_STEM);
724        }
725
726        if nnum < cnum {
727            if *cnum < hundred {
728                return (format!("{} y {}", ctext, ntext), cnum + nnum);
729            }
730            return (format!("{} {}", ctext, ntext), cnum + nnum);
731        } else if (nnum % &million).is_zero() && *cnum > BigInt::one() {
732            // "millón" -> "millones", "billón" -> "billones", ... Character
733            // slicing is required here ("ó" is two bytes).
734            ntext = format!("{}lones", drop_last_chars(&ntext, 3));
735        }
736
737        if *nnum == hundred {
738            if *cnum == BigInt::from(5) {
739                ctext = "quinien".to_string();
740                ntext = String::new();
741            } else if *cnum == BigInt::from(7) {
742                ctext = "sete".to_string();
743            } else if *cnum == BigInt::from(9) {
744                ctext = "nove".to_string();
745            }
746            ntext.push('t');
747            ntext.push_str(GENDER_STEM);
748            ntext.push('s');
749        } else {
750            // Spanish drops the final 'o' of 'uno' (and adds an accent in
751            // 'veintiuno') before a noun like 'mil' or 'millones':
752            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
753            if *nnum >= thousand {
754                if ctext.ends_with("veintiuno") {
755                    ctext = format!("{}ún", drop_last_chars(&ctext, 3));
756                } else if ctext.ends_with("uno") {
757                    ctext = drop_last_chars(&ctext, 1);
758                }
759            }
760            ntext = format!(" {}", ntext);
761        }
762
763        (format!("{}{}", ctext, ntext), cnum * nnum)
764    }
765
766    /// `Num2Word_ES.to_cardinal` -> `Num2Word_Base.to_cardinal`.
767    ///
768    /// The Python override exists only to service the `_pending_ordinal`
769    /// handshake set by `str_to_number` (see the report): with no string
770    /// parsing in this path, `_pending_ordinal` is always `None` and the
771    /// override degenerates to `super().to_cardinal(value)`.
772    ///
773    /// The overflow test is duplicated ahead of `default_to_cardinal` purely to
774    /// carry Spanish `errmsg_toobig` — including its "deber ser" typo. The
775    /// condition is identical to the base engine's, so the inner check can
776    /// never fire.
777    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
778        let v = value.abs();
779        if v >= self.maxval {
780            return Err(N2WError::Overflow(format!(
781                "abs({}) deber ser inferior a {}.",
782                v, self.maxval
783            )));
784        }
785        default_to_cardinal(self, value)
786    }
787
788    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
789        self.to_ordinal_gender(value, "m")
790    }
791
792    /// `Num2Word_ES.to_ordinal_num`: the masculine indicator "º" (U+00BA
793    /// MASCULINE ORDINAL INDICATOR), never the digit-zero lookalike.
794    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
795        self.verify_ordinal(value)?;
796        Ok(format!("{}º", value))
797    }
798
799    /// `Num2Word_ES.to_year(val, suffix=None, longval=True)` ignores both
800    /// keyword arguments and is a plain cardinal — no BC/AD suffix, no
801    /// "nineteen-ninety-nine" pairing. Negative years therefore render with
802    /// `negword`: `to_year(-500)` == "menos quinientos".
803    fn to_year(&self, value: &BigInt) -> Result<String> {
804        self.to_cardinal(value)
805    }
806
807    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
808    //
809    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
810    // so float/Decimal input is accepted only when whole and non-negative:
811    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
812    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
813    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
814    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
815
816    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
817        match value.as_whole_int() {
818            // A whole value renders exactly like the int: Python's dict
819            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
820            // re-runs the (still reachable) negative check.
821            Some(i) => self.to_ordinal(&i),
822            None => Err(float_ordinal_reject(value)),
823        }
824    }
825
826    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
827        match value.as_whole_int() {
828            // `"%s%s" % (value, "º")` — the Python str of the value, so
829            // "5.00º", "1e+16º", "-0.0º".
830            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
831            Some(_) => Err(N2WError::Type(format!(
832                "El número negativo {} no puede ser tratado como un ordinal.",
833                repr_str
834            ))),
835            None => Err(float_ordinal_reject(value)),
836        }
837    }
838
839    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
840        // `return self.to_cardinal(int(val))` — truncation toward zero, so
841        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
842        self.to_cardinal(&float_trunc_int(value)?)
843    }
844
845    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
846
847    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
848    /// in the Python signature -> NotImplemented, so the dispatcher falls back
849    /// to Python, which raises the original TypeError. Any gender value other
850    /// than "f" (an explicit None, "x", an int, ...) is masculine:
851    /// `gender_stem = "a" if gender == "f" else "o"`.
852    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
853        if !kw.only(&["gender"]) {
854            return Err(N2WError::Fallback("kwargs".into()));
855        }
856        let gender = if kw.str("gender") == Some("f") { "f" } else { "m" };
857        self.to_ordinal_gender(value, gender)
858    }
859
860    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
861    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
862        if !kw.only(&["gender"]) {
863            return Err(N2WError::Fallback("kwargs".into()));
864        }
865        self.verify_ordinal(value)?;
866        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
867        Ok(format!("{}{}", value, marker))
868    }
869
870    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
871    /// and ignored — the body is `to_cardinal(int(val))` regardless.
872    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
873        if !kw.only(&["suffix", "longval"]) {
874            return Err(N2WError::Fallback("kwargs".into()));
875        }
876        self.to_year(value)
877    }
878
879    /// `Num2Word_ES.to_fraction` (issue #584), inherited unchanged by
880    /// `Num2Word_ES_CR`: idiomatic medio/tercio/cuarto for denominators
881    /// 2/3/4, ordinal-as-noun otherwise (a bare "s" plural appended only when
882    /// the ordinal isn't s-final already), and the apocopated "un" for
883    /// numerator 1. `denominator == 1` (not -1!) or `numerator == 0`
884    /// short-circuits to the plain cardinal.
885    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
886        if denominator.is_zero() {
887            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
888        }
889        if denominator.is_one() || numerator.is_zero() {
890            return self.to_cardinal(numerator);
891        }
892        let is_negative = numerator.is_negative() ^ denominator.is_negative();
893        let abs_n = numerator.abs();
894        let abs_d = denominator.abs();
895
896        let den_word = if abs_d == BigInt::from(2) {
897            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
898        } else if abs_d == BigInt::from(3) {
899            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
900        } else if abs_d == BigInt::from(4) {
901            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
902        } else {
903            let mut w = self.to_ordinal(&abs_d)?;
904            if !abs_n.is_one() && !w.ends_with('s') {
905                w.push('s');
906            }
907            w
908        };
909
910        // "un" (never "uno") before the denominator noun; the sign is
911        // `"%s " % self.negword.strip()`.
912        let num_word = if abs_n.is_one() {
913            "un".to_string()
914        } else {
915            self.to_cardinal(&abs_n)?
916        };
917        let sign = if is_negative { "menos " } else { "" };
918        Ok(format!("{}{} {}", sign, num_word, den_word))
919    }
920
921    // ---- currency -------------------------------------------------------
922    //
923    // `to_cheque`, `money_verbose`, `cents_verbose` and `cents_terse` are
924    // inherited from `Num2Word_Base` and `currency_precision` is Base's empty
925    // dict, so the trait defaults already match — only the tables, the class
926    // name, EUR's plural rule and the two-layer `to_currency` are overridden.
927
928    /// `self.__class__.__name__`, for the NotImplementedError message.
929    fn lang_name(&self) -> &str {
930        "Num2Word_ES_CR"
931    }
932
933    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
934        self.currency_forms.get(code)
935    }
936
937    fn currency_adjective(&self, code: &str) -> Option<&str> {
938        self.currency_adjectives.get(code).copied()
939    }
940
941    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
942    ///
943    /// Python indexes the tuple directly, so a single-form entry with `n != 1`
944    /// would raise IndexError. All three of this language's entries carry two
945    /// forms, so that is unreachable — mapped to `Index` rather than panicking
946    /// in case the table ever changes.
947    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
948        let form = if n.is_one() { 0 } else { 1 };
949        forms
950            .get(form)
951            .cloned()
952            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
953    }
954
955    /// `Num2Word_ES_CR.to_currency`: delegate to `Num2Word_ES`, then
956    /// `result.replace("uno", "un")`.
957    ///
958    /// That final replace is unconditional and untargeted — it rewrites every
959    /// "uno" anywhere in the string, including inside "veintiuno" and in the
960    /// middle of a compound. It is *also* applied on top of ES's own six
961    /// fixups, and the two layers do not agree:
962    ///
963    /// * Integer arm — ES does nothing, so this rule alone acts:
964    ///   `to_currency(21, "EUR")` == `"veintiun euros"` (no accent).
965    /// * Float arm — ES's `"veintiuno euro"` -> `"veintiún euro"` rewrite has
966    ///   already consumed the "uno", so this rule finds nothing:
967    ///   `to_currency(21.21, "EUR")` == `"veintiún euros y veintiún céntimos"`
968    ///   (accented). Same number, same currency, different accent by arm.
969    /// * It fires on ordinary compounds too: `to_currency(1000001, "EUR")` ==
970    ///   `"un millón un euros"`, from "un millón uno".
971    ///
972    /// No currency word in this language's table contains "uno", so the unit and
973    /// subunit names survive the replace intact — but that is luck, not design.
974    fn to_currency(
975        &self,
976        val: &CurrencyValue,
977        currency: &str,
978        cents: bool,
979        separator: Option<&str>,
980        adjective: bool,
981    ) -> Result<String> {
982        // `None` == the caller omitted `separator=`, so this language's own
983        // default (" y") applies. The trait resolves that for the *default*
984        // body; an override has to do it itself.
985        let separator = separator.unwrap_or(self.default_separator());
986        let result = self.es_to_currency(val, currency, cents, separator, adjective)?;
987        Ok(result.replace("uno", "un"))
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use crate::floatpath::FloatValue;
995    use bigdecimal::BigDecimal;
996    use std::str::FromStr;
997
998    /// A Python `int` arg (a corpus `arg` that `repr`s without a dot).
999    fn int(s: &str) -> CurrencyValue {
1000        CurrencyValue::parse(s, true, false, false).unwrap()
1001    }
1002
1003    /// Build a `FloatValue::Float` the way the binding does: the raw f64 plus
1004    /// the repr-derived precision (`abs(Decimal(str(v)).as_tuple().exponent)`).
1005    fn flt(value: f64, precision: u32) -> FloatValue {
1006        FloatValue::Float { value, precision }
1007    }
1008
1009    /// Build a `FloatValue::Decimal` from the literal decimal string; precision
1010    /// is `abs(exponent)` of that literal.
1011    fn dec(s: &str, precision: u32) -> FloatValue {
1012        FloatValue::Decimal {
1013            value: BigDecimal::from_str(s).unwrap(),
1014            precision,
1015        }
1016    }
1017
1018    /// Every `"lang": "es_CR", "to": "cardinal"` corpus row whose `arg` is a
1019    /// non-integral float — i.e. the ones that actually reach the float path
1020    /// (whole floats like `1.0`/`0.0` are routed to the integer `to_cardinal`
1021    /// by Python's `assert int(value) == value`, so they are not here).
1022    ///
1023    /// `Num2Word_ES` overrides neither `to_cardinal_float` nor `float2tuple`,
1024    /// and its `to_cardinal` override degenerates to `super().to_cardinal` for
1025    /// non-string input, so es_CR's float path *is* `Num2Word_Base`'s. This
1026    /// locks that the inherited trait default reproduces it byte for byte,
1027    /// including the f64-artefact rescues for 1.005 / 2.675.
1028    #[test]
1029    fn cardinal_float_corpus_rows() {
1030        let l = LangEsCr::new();
1031        let rows: Vec<(FloatValue, &str)> = vec![
1032            (flt(0.5, 1), "cero punto cinco"),
1033            (flt(1.5, 1), "uno punto cinco"),
1034            (flt(2.25, 2), "dos punto dos cinco"),
1035            (flt(3.14, 2), "tres punto uno cuatro"),
1036            (flt(0.01, 2), "cero punto cero uno"),
1037            (flt(0.1, 1), "cero punto uno"),
1038            (flt(0.99, 2), "cero punto nueve nueve"),
1039            (flt(1.01, 2), "uno punto cero uno"),
1040            (flt(12.34, 2), "doce punto tres cuatro"),
1041            (flt(99.99, 2), "noventa y nueve punto nueve nueve"),
1042            (flt(100.5, 1), "cien punto cinco"),
1043            (
1044                flt(1234.56, 2),
1045                "mil doscientos treinta y cuatro punto cinco seis",
1046            ),
1047            (flt(-0.5, 1), "menos cero punto cinco"),
1048            (flt(-1.5, 1), "menos uno punto cinco"),
1049            (flt(-12.34, 2), "menos doce punto tres cuatro"),
1050            // f64 artefacts: 1.005 -> 4.999.../1000 rescued to 005, 2.675 ->
1051            // 674.9999999999998 rescued to 675 by the `< 0.01` heuristic.
1052            (flt(1.005, 3), "uno punto cero cero cinco"),
1053            (flt(2.675, 3), "dos punto seis siete cinco"),
1054        ];
1055        for (v, want) in rows {
1056            let got = l.to_cardinal_float(&v, None).unwrap();
1057            assert_eq!(got, want, "to_cardinal_float({:?})", v);
1058        }
1059    }
1060
1061    /// Every `"lang": "es_CR", "to": "cardinal_dec"` corpus row — Decimal input,
1062    /// the exact arbitrary-precision arm (never a float cast). Includes the
1063    /// issue #603 trillion-scale value that a float cast would corrupt.
1064    #[test]
1065    fn cardinal_dec_corpus_rows() {
1066        let l = LangEsCr::new();
1067        let rows: Vec<(FloatValue, &str)> = vec![
1068            (dec("0.01", 2), "cero punto cero uno"),
1069            (dec("1.10", 2), "uno punto uno cero"),
1070            (dec("12.345", 3), "doce punto tres cuatro cinco"),
1071            (
1072                dec("98746251323029.99", 2),
1073                "noventa y ocho billones setecientos cuarenta y seis mil \
1074                 doscientos cincuenta y un millones trescientos veintitrés mil \
1075                 veintinueve punto nueve nueve",
1076            ),
1077            (dec("0.001", 3), "cero punto cero cero uno"),
1078        ];
1079        for (v, want) in rows {
1080            let got = l.to_cardinal_float(&v, None).unwrap();
1081            assert_eq!(got, want, "to_cardinal_float({:?})", v);
1082        }
1083    }
1084
1085    /// A Python `float` arg. `has_decimal` is `isinstance(val, float) or "." in
1086    /// str(val)`, so it is unconditionally true for a float — including `1e21`,
1087    /// whose repr carries no dot at all.
1088    fn float(s: &str) -> CurrencyValue {
1089        CurrencyValue::parse(s, false, true, true).unwrap()
1090    }
1091
1092    /// Every succeeding `"lang": "es_CR", "to": "currency:*"` row of the frozen
1093    /// corpus, verbatim.
1094    #[test]
1095    fn currency_corpus_rows() {
1096        let l = LangEsCr::new();
1097        let rows: Vec<(&str, CurrencyValue, &str)> = vec![
1098            ("EUR", int("0"), "cero euros"),
1099            ("EUR", int("1"), "un euro"),
1100            ("EUR", int("2"), "dos euros"),
1101            ("EUR", int("100"), "cien euros"),
1102            ("EUR", float("12.34"), "doce euros y treinta y cuatro céntimos"),
1103            ("EUR", float("0.01"), "cero euros y un céntimo"),
1104            ("EUR", float("1.0"), "un euro y cero céntimos"),
1105            (
1106                "EUR",
1107                float("99.99"),
1108                "noventa y nueve euros y noventa y nueve céntimos",
1109            ),
1110            (
1111                "EUR",
1112                float("1234.56"),
1113                "mil doscientos treinta y cuatro euros y cincuenta y seis céntimos",
1114            ),
1115            (
1116                "EUR",
1117                float("-12.34"),
1118                "menos doce euros y treinta y cuatro céntimos",
1119            ),
1120            ("EUR", int("1000000"), "un millón euros"),
1121            ("EUR", float("0.5"), "cero euros y cincuenta céntimos"),
1122            ("USD", int("0"), "cero dólares"),
1123            ("USD", int("1"), "un dólar"),
1124            ("USD", int("2"), "dos dólares"),
1125            ("USD", int("100"), "cien dólares"),
1126            (
1127                "USD",
1128                float("12.34"),
1129                "doce dólares y treinta y cuatro centavos",
1130            ),
1131            ("USD", float("0.01"), "cero dólares y un centavo"),
1132            ("USD", float("1.0"), "un dólar y cero centavos"),
1133            (
1134                "USD",
1135                float("99.99"),
1136                "noventa y nueve dólares y noventa y nueve centavos",
1137            ),
1138            (
1139                "USD",
1140                float("1234.56"),
1141                "mil doscientos treinta y cuatro dólares y cincuenta y seis centavos",
1142            ),
1143            (
1144                "USD",
1145                float("-12.34"),
1146                "menos doce dólares y treinta y cuatro centavos",
1147            ),
1148            ("USD", int("1000000"), "un millón dólares"),
1149            ("USD", float("0.5"), "cero dólares y cincuenta centavos"),
1150        ];
1151        for (cur, val, want) in rows {
1152            let got = l.to_currency(&val, cur, true, None, false).unwrap();
1153            assert_eq!(got, want, "to_currency({:?}, {})", val, cur);
1154        }
1155    }
1156
1157    /// The seven codes the corpus records as NotImplementedError, across all
1158    /// twelve `arg` values it pairs them with — 84 rows.
1159    ///
1160    /// Every one of these codes *is* defined in `lang_ES.py`;
1161    /// `Num2Word_ES_CR.CURRENCY_FORMS` shadows that table rather than extending
1162    /// it, so they are unreachable here. KWD/BHD (divisor 1000 elsewhere) and
1163    /// JPY (divisor 1) are among them, which is why this language never
1164    /// exercises the 3-decimal or 0-decimal branches.
1165    #[test]
1166    fn currency_codes_shadowed_away() {
1167        let l = LangEsCr::new();
1168        let args = || {
1169            vec![
1170                int("0"),
1171                int("1"),
1172                int("2"),
1173                int("100"),
1174                float("12.34"),
1175                float("0.01"),
1176                float("1.0"),
1177                float("99.99"),
1178                float("1234.56"),
1179                float("-12.34"),
1180                int("1000000"),
1181                float("0.5"),
1182            ]
1183        };
1184        let mut n = 0;
1185        for cur in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1186            for val in args() {
1187                match l.to_currency(&val, cur, true, None, false) {
1188                    Err(N2WError::NotImplemented(m)) => assert_eq!(
1189                        m,
1190                        format!(
1191                            "Currency code \"{}\" not implemented for \"Num2Word_ES_CR\"",
1192                            cur
1193                        )
1194                    ),
1195                    other => panic!("{} {:?}: want NotImplemented, got {:?}", cur, val, other),
1196                }
1197                n += 1;
1198            }
1199        }
1200        assert_eq!(n, 84, "corpus has 84 NotImplementedError currency rows");
1201    }
1202
1203    /// Both `"lang": "es_CR", "to": "cheque:*"` corpus rows, plus the codes it
1204    /// records as NotImplementedError.
1205    #[test]
1206    fn cheque_corpus_rows() {
1207        let l = LangEsCr::new();
1208        let v = BigDecimal::from_str("1234.56").unwrap();
1209        assert_eq!(
1210            l.to_cheque(&v, "EUR").unwrap(),
1211            "MIL DOSCIENTOS TREINTA Y CUATRO AND 56/100 EUROS"
1212        );
1213        assert_eq!(
1214            l.to_cheque(&v, "USD").unwrap(),
1215            "MIL DOSCIENTOS TREINTA Y CUATRO AND 56/100 DÓLARES"
1216        );
1217        for cur in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1218            assert!(matches!(
1219                l.to_cheque(&v, cur),
1220                Err(N2WError::NotImplemented(_))
1221            ));
1222        }
1223    }
1224
1225    /// `to_cheque` reaches `to_cardinal` via `_money_verbose` and so bypasses
1226    /// every un/uno fixup, and it always takes the plural unit.
1227    #[test]
1228    fn cheque_keeps_uno_and_pluralizes() {
1229        let l = LangEsCr::new();
1230        let cases = [
1231            ("1.0", "EUR", "UNO AND 00/100 EUROS"),
1232            ("21.21", "EUR", "VEINTIUNO AND 21/100 EUROS"),
1233            ("2.0", "CRC", "DOS AND 00/100 COLÓNES"),
1234            ("0.01", "CRC", "CERO AND 01/100 COLÓNES"),
1235            ("-1234.56", "EUR", "MINUS MIL DOSCIENTOS TREINTA Y CUATRO AND 56/100 EUROS"),
1236            // int() truncates toward zero; no half-even bump to CATORCE.
1237            ("13.5", "EUR", "TRECE AND 50/100 EUROS"),
1238        ];
1239        for (v, cur, want) in cases {
1240            let d = BigDecimal::from_str(v).unwrap();
1241            assert_eq!(l.to_cheque(&d, cur).unwrap(), want, "to_cheque({}, {})", v, cur);
1242        }
1243    }
1244
1245    /// Quirk 7: the integer arm's raw `negword` leaves a double space that
1246    /// `.strip()` cannot reach, while the float arm's trimmed one does not.
1247    #[test]
1248    fn negative_int_double_space_but_float_single() {
1249        let l = LangEsCr::new();
1250        for (val, want) in [
1251            (int("-1"), "menos  un euro"),
1252            (int("-2"), "menos  dos euros"),
1253            (int("-21"), "menos  veintiun euros"),
1254        ] {
1255            assert_eq!(l.to_currency(&val, "EUR", true, None, false).unwrap(), want);
1256        }
1257        for (val, want) in [
1258            (float("-1.0"), "menos un euro y cero céntimos"),
1259            (float("-0.01"), "menos cero euros y un céntimo"),
1260        ] {
1261            assert_eq!(l.to_currency(&val, "EUR", true, None, false).unwrap(), want);
1262        }
1263    }
1264
1265    /// Quirk 10: the blanket `replace("uno", "un")` and ES's targeted rewrites
1266    /// disagree, so the same 21 is accented on one arm and bare on the other.
1267    #[test]
1268    fn uno_fixups_are_substring_replaces() {
1269        let l = LangEsCr::new();
1270        let cases: Vec<(&str, CurrencyValue, &str)> = vec![
1271            // Integer arm: only ES_CR's blanket rule runs -> no accent.
1272            ("EUR", int("21"), "veintiun euros"),
1273            ("EUR", int("31"), "treinta y un euros"),
1274            ("EUR", int("101"), "ciento un euros"),
1275            // ...and it happily rewrites mid-compound.
1276            ("EUR", int("1000001"), "un millón un euros"),
1277            ("EUR", int("21000"), "veintiún mil euros"),
1278            ("EUR", int("100000000021"), "cien mil millones veintiun euros"),
1279            // Float arm, EUR: ES's accented rules consume the "uno" first.
1280            ("EUR", float("21.21"), "veintiún euros y veintiún céntimos"),
1281            ("EUR", float("21.0"), "veintiún euros y cero céntimos"),
1282            ("EUR", float("21.01"), "veintiún euros y un céntimo"),
1283            ("EUR", float("1.21"), "un euro y veintiún céntimos"),
1284            // Float arm, USD: no accented rule mentions dólar/centavo, so the
1285            // blanket rule lands instead -> unaccented. Note "uno dólar" also
1286            // matches *inside* "veintiuno dólares".
1287            ("USD", float("21.21"), "veintiun dólares y veintiun centavos"),
1288            ("USD", float("21.01"), "veintiun dólares y un centavo"),
1289            ("USD", float("1.21"), "un dólar y veintiun centavos"),
1290            // CRC mixes the two: céntimos accented, colónes not.
1291            ("CRC", float("21.21"), "veintiun colónes y veintiún céntimos"),
1292            ("CRC", float("21.0"), "veintiun colónes y cero céntimos"),
1293            ("CRC", float("1.21"), "un colón y veintiún céntimos"),
1294        ];
1295        for (cur, val, want) in cases {
1296            let got = l.to_currency(&val, cur, true, None, false).unwrap();
1297            assert_eq!(got, want, "to_currency({:?}, {})", val, cur);
1298        }
1299    }
1300
1301    /// `currency="CRC"` / `separator=" y"` are this class's own signature
1302    /// defaults, and "colónes" is spelled as `lang_ES_CR.py` spells it.
1303    #[test]
1304    fn defaults_and_the_colones_typo() {
1305        let l = LangEsCr::new();
1306        assert_eq!(l.default_currency(), "CRC");
1307        assert_eq!(l.default_separator(), " y");
1308        let cur = l.default_currency();
1309        for (val, want) in [
1310            (int("1"), "un colón"),
1311            (int("2"), "dos colónes"),
1312            (int("0"), "cero colónes"),
1313            (int("-3"), "menos  tres colónes"),
1314        ] {
1315            assert_eq!(l.to_currency(&val, cur, true, None, false).unwrap(), want);
1316        }
1317        assert_eq!(
1318            l.to_currency(&float("1.0"), cur, true, None, false).unwrap(),
1319            "un colón y cero céntimos"
1320        );
1321    }
1322
1323    /// Quirk 8: `adjective=` is honoured on the float arm and silently dropped
1324    /// on the integer arm.
1325    #[test]
1326    fn adjective_only_reaches_the_float_arm() {
1327        let l = LangEsCr::new();
1328        assert_eq!(
1329            l.to_currency(&float("12.34"), "USD", true, None, true).unwrap(),
1330            "doce US dólares y treinta y cuatro centavos"
1331        );
1332        for (val, want) in [(int("2"), "dos dólares"), (int("1"), "un dólar")] {
1333            assert_eq!(l.to_currency(&val, "USD", true, None, true).unwrap(), want);
1334        }
1335        // EUR has no adjective, so the flag is inert there.
1336        assert_eq!(
1337            l.to_currency(&float("1.0"), "EUR", true, None, true).unwrap(),
1338            "un euro y cero céntimos"
1339        );
1340    }
1341
1342    /// `cents=false` routes through `_cents_terse`, which zero-pads to
1343    /// `len(str(divisor)) - 1` == 2 digits.
1344    #[test]
1345    fn cents_terse() {
1346        let l = LangEsCr::new();
1347        assert_eq!(
1348            l.to_currency(&float("1.0"), "EUR", false, None, false).unwrap(),
1349            "un euro y 00 céntimos"
1350        );
1351        assert_eq!(
1352            l.to_currency(&float("21.21"), "EUR", false, None, false).unwrap(),
1353            "veintiún euros y 21 céntimos"
1354        );
1355        assert_eq!(
1356            l.to_currency(&float("12.34"), "USD", false, None, false).unwrap(),
1357            "doce dólares y 34 centavos"
1358        );
1359    }
1360
1361    /// `1e21` is a float whose repr has no dot, yet `has_decimal` is still true
1362    /// (`isinstance(val, float)` short-circuits), so the cents segment shows.
1363    #[test]
1364    fn float_without_a_dot_in_its_repr_still_prints_cents() {
1365        let l = LangEsCr::new();
1366        assert_eq!(
1367            l.to_currency(&float("1e+21"), "EUR", true, None, false).unwrap(),
1368            "mil trillones euros y cero céntimos"
1369        );
1370        assert_eq!(
1371            l.to_currency(&float("1000000.5"), "EUR", true, None, false).unwrap(),
1372            "un millón euros y cincuenta céntimos"
1373        );
1374    }
1375
1376    /// The forms lookup precedes `to_cardinal`, so an unknown code out-ranks an
1377    /// out-of-range value even though Python evaluates both.
1378    #[test]
1379    fn notimplemented_outranks_overflow() {
1380        let l = LangEsCr::new();
1381        let huge = int(&format!("1{}", "0".repeat(60)));
1382        assert!(matches!(
1383            l.to_currency(&huge, "GBP", true, None, false),
1384            Err(N2WError::NotImplemented(_))
1385        ));
1386        assert!(matches!(
1387            l.to_currency(&huge, "EUR", true, None, false),
1388            Err(N2WError::Overflow(_))
1389        ));
1390    }
1391}