Skip to main content

num2words2_core/
lang_es_ni.rs

1//! Port of `lang_ES_NI.py` (Spanish — Nicaragua), the class the `"es_NI"`
2//! registry key resolves to (verified in `__init__.py`: `"es_NI":
3//! lang_ES_NI.Num2Word_ES_NI()`).
4//!
5//! `Num2Word_ES_NI` subclasses `Num2Word_ES` and overrides **only**
6//! `CURRENCY_FORMS` and `to_currency`, so for the four cardinal/ordinal/year
7//! modes this is `Num2Word_ES` verbatim. Chain:
8//! `Num2Word_ES_NI` → `Num2Word_ES` → `Num2Word_EUR` → `Num2Word_Base`.
9//!
10//! # Currency: `super(Num2Word_ES, self)` skips its own parent
11//!
12//! The single load-bearing subtlety of this module. `to_currency` calls
13//!
14//! ```python
15//! result = super(Num2Word_ES, self).to_currency(...)
16//! return result.replace("uno", "un")
17//! ```
18//!
19//! `super(Num2Word_ES, self)` starts the MRO lookup *after* `Num2Word_ES`, not
20//! after `Num2Word_ES_NI`. The next class is `Num2Word_EUR`, which defines no
21//! `to_currency`, so the call lands on `Num2Word_Base.to_currency` and
22//! `Num2Word_ES.to_currency` is **bypassed entirely**. Two consequences, both
23//! observable:
24//!
25//! * ES's integer fast path (which special-cases `abs(val) == 1` to emit "un"
26//!   and picks the singular/plural form by hand) never runs. Base's integer
27//!   branch runs instead, and it pluralizes via `pluralize`, which agrees only
28//!   with `n == 1` — hence `to_currency(1000000)` == "un millón euros", with a
29//!   plural noun after a singular "millón". Reproduced.
30//! * ES's *targeted* fixups (`"veintiuno euro"` → `"veintiún euro"`, `"uno
31//!   céntimo"` → `"un céntimo"`, ...) never run. ES_NI substitutes one blanket
32//!   `replace("uno", "un")` over the whole rendered string — see below.
33//!
34//! # The blanket `replace("uno", "un")`
35//!
36//! It is a plain substring replace, not a word-boundary one, so it also fires
37//! inside "veintiuno" and yields the **unaccented** "veintiun" — where ES's
38//! targeted replace would have produced the correctly accented "veintiún".
39//! Verified against the interpreter and preserved verbatim:
40//!
41//! | input | output |
42//! |---|---|
43//! | `21` NIO | `veintiun córdobas` (not "veintiún") |
44//! | `101.0` NIO | `ciento un córdobas con cero centavos` |
45//! | `21.21` NIO | `veintiun córdobas con veintiun centavos` |
46//! | `1` NIO | `un córdoba` |
47//!
48//! Applying it to the finished string (rather than to the money/cents pieces)
49//! is what lets it reach both segments at once, so the port delegates first and
50//! replaces last, exactly as Python does.
51//!
52//! `to_cheque` is **not** overridden and therefore does *not* get the replace:
53//! `to_cheque(1.0, "NIO")` == "UNO AND 00/100 CÓRDOBAS", with "UNO" intact.
54//!
55//! Shape: **engine**. ES defines `mid_numwords`/`low_numwords` and inherits
56//! `set_high_numwords` from `Num2Word_EUR`, letting `Num2Word_Base.to_cardinal`
57//! drive `splitnum`/`clean`/`merge`. So `cards` + `merge` are supplied here and
58//! the `base.rs` engine does the rest.
59//!
60//! # Number scale
61//!
62//! ES sets `GIGA_SUFFIX = None` and `MEGA_SUFFIX = "illón"`, so EUR's
63//! `set_high_numwords` emits *only* the `10^(n-3)` (mega) cards and skips the
64//! `10^n` (giga) ones. That yields the Spanish long scale — 10^6 "millón",
65//! 10^12 "billón", 10^18 "trillón" — with no card for 10^9, which is why
66//! 10^9 comes out compositionally as "mil millones". `MAXVAL` is 10^57.
67//!
68//! ES also calls `gen_high_numwords([], [], lows)` with **empty** units/tens.
69//! The comprehension `[u + t for t in tens for u in units]` over two empty
70//! lists is empty, so the function degenerates to `[] + lows == lows` and the
71//! Latin-prefix elision machinery never runs. The high list is therefore just
72//! `lows`, hardcoded below — no `gen_high_numwords` port is needed.
73//!
74//! # Faithfully reproduced Python oddities
75//!
76//! Verified against the interpreter; all are preserved verbatim.
77//!
78//! 1. **Unaccented "vigesimo"**. `to_ordinal` for 11..=29 does
79//!    `self.ords[dec].replace("é", "e")`, so 20 → "vigesimo" and 120 →
80//!    "centésimo vigesimo" — accented elsewhere ("trigésimo"), bare here.
81//! 2. **`ords` typos**: 400 is `"cuadrigentésim"` (standard Spanish is
82//!    *cuadringentésimo*) and 700 is `"septigentésim"` (standard:
83//!    *septingentésimo*). Corpus rows 123456 and 700 confirm both.
84//! 3. **Ordinal/cardinal scale mismatch**: `ords[1e9] = "billonésim"` while
85//!    `cards[1e12] = "billón"`. The ordinals follow the *short* scale, the
86//!    cardinals the *long* scale. Hence `to_cardinal(10^9)` == "mil millones"
87//!    but `to_ordinal(10^9)` == "billonésimo", and `to_ordinal(10^12)` ==
88//!    "trillonésimo" while `to_cardinal(10^12)` == "un billón".
89//! 4. **Missing separator in the >1000 ordinal branch**: the high-part cardinal
90//!    is concatenated straight onto the scale word, so `to_ordinal(99999)` ==
91//!    "noventa y nuevemilésimo noningentésimo nonagésimo noveno" (note
92//!    "nuevemilésimo"). Corpus-confirmed.
93//! 5. **`replace("oo", "o")`** at every recursion level: "decimooctavo" →
94//!    "decimoctavo" (documented in the Python source as deliberate).
95//! 6. **`to_ordinal(0)` == `""`** — the empty string, not a word.
96//! 7. Two hard crashes near 10^18, see [`log1000_floor`] and the notes below.
97//!
98//! # Crashes reproduced (see `concerns` in the port report)
99//!
100//! `to_ordinal` computes `dec = 1000 ** int(math.log(int(value), 1000))` using
101//! **binary floating point**, which rounds *up* to an exact power just below
102//! the true boundary. Two distinct failures follow, both confirmed against
103//! CPython:
104//!
105//! * `999999999999996..=999999999999999` → float log yields exactly `5.0`, so
106//!   `dec = 10^15 > value`, `divmod` gives `high_part = 0`, `low_part = value`,
107//!   and `to_ordinal(low_part)` re-enters with the *same* value forever →
108//!   Python `RecursionError`. Guarded here by the `high_part.is_zero()` check;
109//!   see [`RECURSION_MSG`] for why the error variant is approximate.
110//! * `999999999999995072..=999999999999999999` → float log yields exactly
111//!   `6.0`, so `dec = 10^18`, which is **absent** from `ords` (it stops at
112//!   1e15) → Python `KeyError: 1000000000000000000`. The `KeyError` fires
113//!   *before* the recursion because Python evaluates the `%` format tuple left
114//!   to right (`self.ords[dec]` precedes the recursive call), so the ordering
115//!   of the two checks below is load-bearing.
116//!
117//! Both windows are outside the frozen corpus but are real reachable inputs.
118
119use crate::base::{
120    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, Lang, N2WError,
121    Result,
122};
123use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
124use crate::floatpath::FloatValue;
125use num_bigint::BigInt;
126use num_integer::Integer;
127use num_traits::{FromPrimitive, One, Signed, Zero};
128use std::collections::HashMap;
129
130/// `Num2Word_ES_NI.CURRENCY_FORMS`.
131///
132/// ES_NI declares this in its own class body, which **shadows** the ~180-entry
133/// `Num2Word_ES.CURRENCY_FORMS` rather than extending it — so exactly these
134/// three codes exist and everything else (GBP, JPY, KWD, BHD, INR, CNY, CHF,
135/// ...) raises NotImplementedError. The corpus asserts that for all of them.
136///
137/// Note also that the `lang_EUR` shared-dict trap does **not** apply here:
138/// `Num2Word_EN.__init__` mutates `Num2Word_EUR.CURRENCY_FORMS` in place, but
139/// ES_NI never reads that dict — `CURRENCY_FORMS in Num2Word_ES_NI.__dict__`
140/// is True and `instance.CURRENCY_FORMS is Num2Word_ES_NI.__dict__[...]` was
141/// verified against the live interpreter after a full package import.
142///
143/// There is no `CURRENCY_PRECISION` anywhere in the chain: it resolves to
144/// `Num2Word_Base.CURRENCY_PRECISION == {}` (EN *rebinds* its own as an
145/// instance attribute, so nothing leaks), leaving `.get(code, 100)` at 100 for
146/// every code. The trait's default `currency_precision` already returns 100,
147/// so it is deliberately not overridden — ES_NI has no 3-decimal or 0-decimal
148/// currency and `default_to_currency`'s `divisor == 1` branch is unreachable.
149fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
150    const CENTAVOS: [&str; 2] = ["centavo", "centavos"];
151
152    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
153    m.insert("NIO", CurrencyForms::new(&["córdoba", "córdobas"], &CENTAVOS));
154    m.insert(
155        "EUR",
156        CurrencyForms::new(&["euro", "euros"], &["céntimo", "céntimos"]),
157    );
158    m.insert("USD", CurrencyForms::new(&["dólar", "dólares"], &CENTAVOS));
159    m
160}
161
162/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited untouched — neither ES nor
163/// ES_NI defines one, and (unlike `CURRENCY_FORMS`) no class mutates it.
164///
165/// Nearly dead weight, since only USD appears in both this table and ES_NI's
166/// three-code `CURRENCY_FORMS`. But it is reachable: `to_currency(1, "USD",
167/// adjective=True)` == "un US dólar", so the table is ported rather than
168/// dropped.
169fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
170    [
171        ("AUD", "Australian"),
172        ("BYN", "Belarusian"),
173        ("CAD", "Canadian"),
174        ("EEK", "Estonian"),
175        ("USD", "US"),
176        ("RUB", "Russian"),
177        ("NOK", "Norwegian"),
178        ("MXN", "Mexican"),
179        ("RON", "Romanian"),
180        ("INR", "Indian"),
181        ("HUF", "Hungarian"),
182        ("ISK", "íslenskar"),
183        ("UZS", "Uzbekistan"),
184        ("SAR", "Saudi"),
185        ("JPY", "Japanese"),
186        ("KRW", "Korean"),
187    ]
188    .into_iter()
189    .collect()
190}
191
192/// `self.gender_stem`, set to "o" in `Num2Word_ES.setup` and never reassigned.
193///
194/// `to_ordinal`/`to_ordinal_num` shadow it with a *local* `gender_stem`, so the
195/// instance attribute that `merge` reads stays "o" regardless of any ordinal
196/// gender argument. Cardinals are therefore never gender-affected.
197const GENDER_STEM: &str = "o";
198
199/// Stands in for Python's `RecursionError`, which `N2WError` has no variant
200/// for. Rust would abort the process on real infinite recursion, so the
201/// condition is detected up front and reported as an error instead.
202const RECURSION_MSG: &str = "maximum recursion depth exceeded \
203    (Python raises RecursionError here: dec > value makes high_part 0, so \
204     to_ordinal(low_part) re-enters with the same value)";
205
206/// Python's `s[:-n]` — drop the last `n` **characters**.
207///
208/// Must be character-based, not byte-based: the strings this runs on contain
209/// "ó" (2 bytes in UTF-8), e.g. `"millón"[:-3] == "mil"`. Slicing by byte would
210/// split the accented codepoint and panic. Python returns `""` when the string
211/// is shorter than `n`; so does this.
212fn drop_last_chars(s: &str, n: usize) -> String {
213    let count = s.chars().count();
214    if count <= n {
215        return String::new();
216    }
217    s.chars().take(count - n).collect()
218}
219
220/// Exact integer reimplementation of `int(math.log(int(value), 1000))`.
221///
222/// CPython evaluates `math.log(x, base)` as `log(float(x)) / log(1000.0)` (the
223/// big-int `frexp` path only engages when the value overflows a double, which
224/// cannot happen below 10^18). Recomputing that in Rust would make the result
225/// depend on `f64::ln` agreeing with the platform libm bit for bit — a 1-ulp
226/// difference at a boundary flips `dec` by a factor of 1000 and silently
227/// changes the output or the exception.
228///
229/// So the float is eliminated entirely. The computed quotient is monotonic
230/// non-decreasing in `value`, so each `k` has a single exact threshold; these
231/// were recovered by binary-searching CPython and re-verified against
232/// `int(math.log(v, 1000))` on 365,931 values (every boundary ±6000 plus
233/// 300k random draws below 10^18) with zero mismatches.
234///
235/// Note the last two thresholds sit *below* their exact powers — 10^15 - 4 and
236/// 10^18 - 4928 — which is precisely the float rounding that causes the
237/// `RecursionError` and `KeyError` windows documented at module level.
238///
239/// Only called with `value > 1000`, so the result is always >= 1.
240fn log1000_floor(value: &BigInt) -> u32 {
241    // (threshold, k), ascending; the last threshold <= value wins.
242    let thresholds: [(&str, u32); 6] = [
243        ("1000", 1),
244        ("1000000", 2),
245        ("1000000000", 3),
246        ("1000000000000", 4),
247        ("999999999999996", 5),        // 10^15 - 4
248        ("999999999999995072", 6),     // 10^18 - 4928
249    ];
250    let mut k = 0;
251    for (t, kk) in thresholds.iter() {
252        let t: BigInt = t.parse().expect("static threshold literal");
253        if value >= &t {
254            k = *kk;
255        } else {
256            break;
257        }
258    }
259    k
260}
261
262
263/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
264/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
265/// OverflowError before any TypeError; a finite fractional value fails the
266/// comparison and raises TypeError with ES's `errmsg_floatord`.
267fn float_ordinal_reject(value: &FloatValue) -> N2WError {
268    if let FloatValue::Float { value: f, .. } = value {
269        if f.is_nan() {
270            return N2WError::Value("cannot convert float NaN to integer".into());
271        }
272        if f.is_infinite() {
273            return N2WError::Overflow("cannot convert float infinity to integer".into());
274        }
275    }
276    let shown = match value {
277        FloatValue::Float { value: f, .. } => format!("{}", f),
278        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
279    };
280    N2WError::Type(format!(
281        "El float {} no puede ser tratado como un ordinal.",
282        shown
283    ))
284}
285
286/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
287/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
288fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
289    match value {
290        FloatValue::Float { value: f, .. } => {
291            if f.is_nan() {
292                return Err(N2WError::Value(
293                    "cannot convert float NaN to integer".into(),
294                ));
295            }
296            if f.is_infinite() {
297                return Err(N2WError::Overflow(
298                    "cannot convert float infinity to integer".into(),
299                ));
300            }
301            // Exact: every whole f64 is exactly representable as an integer.
302            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
303        }
304        FloatValue::Decimal { value: d, .. } => {
305            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
306            // which truncates toward zero — exactly Python's int().
307            Ok(d.with_scale(0).as_bigint_and_exponent().0)
308        }
309    }
310}
311
312pub struct LangEsNi {
313    cards: Cards,
314    maxval: BigInt,
315    /// `Num2Word_ES.ords`. Python mixes int keys (1..900) with float keys
316    /// (1e3..1e15); since `hash(1000) == hash(1000.0)` and they compare equal,
317    /// `ords[1000]` finds the `1e3` entry. Collapsing them to `BigInt` here is
318    /// therefore faithful — and note 1e18 is deliberately absent (see module
319    /// docs: that absence is what raises `KeyError`).
320    ords: HashMap<BigInt, &'static str>,
321    exclude_title: Vec<String>,
322    currency_forms: HashMap<&'static str, CurrencyForms>,
323    currency_adjectives: HashMap<&'static str, &'static str>,
324}
325
326impl Default for LangEsNi {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332impl LangEsNi {
333    pub fn new() -> Self {
334        let mut cards = Cards::new();
335
336        // Num2Word_ES.setup: gen_high_numwords([], [], lows) degenerates to
337        // `lows` (empty units/tens => empty comprehension). See module docs.
338        let high = ["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"];
339
340        // Num2Word_EUR.set_high_numwords, with GIGA_SUFFIX = None and
341        // MEGA_SUFFIX = "illón":
342        //   cap = 3 + 6 * len(high) = 57
343        //   for word, n in zip(high, range(cap, 3, -6)):
344        //       cards[10 ** (n - 3)] = word + "illón"
345        // range(57, 3, -6) has exactly 9 entries, matching len(high), so zip
346        // consumes both fully: 10^54 nonillón .. 10^6 millón.
347        let cap = 3 + 6 * high.len() as i64;
348        let mut n = cap;
349        for word in high.iter() {
350            if n <= 3 {
351                break;
352            }
353            cards.insert(
354                BigInt::from(10u8).pow((n - 3) as u32),
355                format!("{}illón", word),
356            );
357            n -= 6;
358        }
359
360        set_mid_numwords(
361            &mut cards,
362            &[
363                (1000, "mil"),
364                (100, "cien"),
365                (90, "noventa"),
366                (80, "ochenta"),
367                (70, "setenta"),
368                (60, "sesenta"),
369                (50, "cincuenta"),
370                (40, "cuarenta"),
371                (30, "treinta"),
372            ],
373        );
374        set_low_numwords(
375            &mut cards,
376            &[
377                "veintinueve",
378                "veintiocho",
379                "veintisiete",
380                "veintiséis",
381                "veinticinco",
382                "veinticuatro",
383                "veintitrés",
384                "veintidós",
385                "veintiuno",
386                "veinte",
387                "diecinueve",
388                "dieciocho",
389                "diecisiete",
390                "dieciséis",
391                "quince",
392                "catorce",
393                "trece",
394                "doce",
395                "once",
396                "diez",
397                "nueve",
398                "ocho",
399                "siete",
400                "seis",
401                "cinco",
402                "cuatro",
403                "tres",
404                "dos",
405                "uno",
406                "cero",
407            ],
408        );
409
410        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10^54 = 10^57.
411        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
412
413        // Typos "cuadrigentésim" (400) and "septigentésim" (700) are verbatim
414        // from Python — see module docs.
415        let ords: HashMap<BigInt, &'static str> = [
416            (BigInt::from(1), "primer"),
417            (BigInt::from(2), "segund"),
418            (BigInt::from(3), "tercer"),
419            (BigInt::from(4), "cuart"),
420            (BigInt::from(5), "quint"),
421            (BigInt::from(6), "sext"),
422            (BigInt::from(7), "séptim"),
423            (BigInt::from(8), "octav"),
424            (BigInt::from(9), "noven"),
425            (BigInt::from(10), "décim"),
426            (BigInt::from(20), "vigésim"),
427            (BigInt::from(30), "trigésim"),
428            (BigInt::from(40), "cuadragésim"),
429            (BigInt::from(50), "quincuagésim"),
430            (BigInt::from(60), "sexagésim"),
431            (BigInt::from(70), "septuagésim"),
432            (BigInt::from(80), "octogésim"),
433            (BigInt::from(90), "nonagésim"),
434            (BigInt::from(100), "centésim"),
435            (BigInt::from(200), "ducentésim"),
436            (BigInt::from(300), "tricentésim"),
437            (BigInt::from(400), "cuadrigentésim"),
438            (BigInt::from(500), "quingentésim"),
439            (BigInt::from(600), "sexcentésim"),
440            (BigInt::from(700), "septigentésim"),
441            (BigInt::from(800), "octigentésim"),
442            (BigInt::from(900), "noningentésim"),
443            (BigInt::from(1_000i64), "milésim"),
444            (BigInt::from(1_000_000i64), "millonésim"),
445            (BigInt::from(1_000_000_000i64), "billonésim"),
446            (BigInt::from(1_000_000_000_000i64), "trillonésim"),
447            (BigInt::from(1_000_000_000_000_000i64), "cuadrillonésim"),
448        ]
449        .into_iter()
450        .collect();
451
452        LangEsNi {
453            cards,
454            maxval,
455            ords,
456            // Num2Word_ES.setup: exclude_title = ["y", "menos", "punto"]
457            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
458            // Built once here, never per call. `to_currency` only reads these,
459            // and rebuilding them on every call is what made an earlier
460            // revision of this port slower than the Python it replaces.
461            currency_forms: build_currency_forms(),
462            currency_adjectives: build_currency_adjectives(),
463        }
464    }
465
466    /// `self.ords[key]`, raising Python's `KeyError` when absent.
467    fn ords_get(&self, key: &BigInt) -> Result<&'static str> {
468        self.ords
469            .get(key)
470            .copied()
471            .ok_or_else(|| N2WError::Key(format!("{}", key)))
472    }
473
474    /// `Num2Word_Base.verify_ordinal`.
475    ///
476    /// The first check (`value == int(value)`) cannot fail for integer input,
477    /// so only the negative check is reachable. Python raises **TypeError**
478    /// here, not ValueError — corpus rows for -1/-7/-21/... confirm it.
479    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
480        if value.is_negative() {
481            // errmsg_negord, with the implicit string concatenation resolved.
482            return Err(N2WError::Type(format!(
483                "El número negativo {} no puede ser tratado como un ordinal.",
484                value
485            )));
486        }
487        Ok(())
488    }
489
490    /// `Num2Word_ES.to_ordinal(value, gender="m")`.
491    ///
492    /// `gender` is threaded through recursion exactly as Python does: the
493    /// 11..=29 branch overrides the *local* `gender_stem` to "o" but still
494    /// forwards the original `gender` downward. Only "m" is reachable from the
495    /// `Lang` trait, but the parameter is kept so the port stays 1:1.
496    fn to_ordinal_gendered(&self, value: &BigInt, gender: &str) -> Result<String> {
497        let mut gender_stem = if gender == "f" { "a" } else { "o" };
498
499        self.verify_ordinal(value)?;
500
501        let ten = BigInt::from(10);
502        let hundred = BigInt::from(100);
503        let thousand = BigInt::from(1000);
504        let e18 = BigInt::from(10u8).pow(18);
505
506        let text: String = if value.is_zero() {
507            String::new()
508        } else if value <= &ten {
509            format!("{}{}", self.ords_get(value)?, gender_stem)
510        } else if value <= &BigInt::from(29) {
511            // RAE: simple forms preferred up to 30; "sobreesdrújula" spelling
512            // drops the accent via replace("é", "e") -> "decimo"/"vigesimo".
513            gender_stem = "o";
514            let dec = (value / &ten) * &ten;
515            format!(
516                "{}{}{}",
517                self.ords_get(&dec)?.replace('é', "e"),
518                gender_stem,
519                self.to_ordinal_gendered(&(value % &ten), gender)?
520            )
521        } else if value <= &hundred {
522            let dec = (value / &ten) * &ten;
523            format!(
524                "{}{} {}",
525                self.ords_get(&dec)?,
526                gender_stem,
527                self.to_ordinal_gendered(&(value - &dec), gender)?
528            )
529        } else if value <= &thousand {
530            let cen = (value / &hundred) * &hundred;
531            format!(
532                "{}{} {}",
533                self.ords_get(&cen)?,
534                gender_stem,
535                self.to_ordinal_gendered(&(value - &cen), gender)?
536            )
537        } else if value < &e18 {
538            // Round down to the nearest 1e(3n). See log1000_floor for why this
539            // is integer thresholds rather than a float log.
540            let dec = thousand.pow(log1000_floor(value));
541            let (high_part, low_part) = value.div_mod_floor(&dec);
542
543            let cardinal = if !high_part.is_one() {
544                self.to_cardinal(&high_part)?
545            } else {
546                String::new()
547            };
548
549            // Order matters. Python builds "%s%s%s %s" % (cardinal,
550            // self.ords[dec], gender_stem, self.to_ordinal(low_part)) and
551            // evaluates the tuple left to right, so a missing `ords[dec]`
552            // (dec == 10^18) raises KeyError *before* the recursive call can
553            // blow the stack. Keep the lookup ahead of the recursion guard.
554            let ordword = self.ords_get(&dec)?;
555
556            // high_part == 0 <=> dec > value <=> low_part == value, i.e. the
557            // recursive call re-enters unchanged. Python spins until
558            // RecursionError; detect it instead of overflowing the Rust stack.
559            if high_part.is_zero() {
560                return Err(N2WError::Value(format!("{}: {}", RECURSION_MSG, value)));
561            }
562
563            format!(
564                "{}{}{} {}",
565                cardinal,
566                ordword,
567                gender_stem,
568                self.to_ordinal_gendered(&low_part, gender)?
569            )
570        } else {
571            self.to_cardinal(value)?
572        };
573
574        // Handle exception: it's not "decimooctavo" but "decimoctavo".
575        // strip() first, then replace() — applied at *every* recursion level.
576        Ok(text.trim().replace("oo", "o"))
577    }
578}
579
580impl Lang for LangEsNi {
581
582    fn es_currency_ordinal_fires(&self) -> bool {
583        true
584    }
585
586    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
587        crate::lang_es::es_str_to_number(s)
588    }
589    /// This language's own `to_currency(currency=...)` default,
590    /// read from the live Python signature. Only 44 of 156 use EUR.
591    fn default_currency(&self) -> &str {
592        "NIO"
593    }
594
595    /// This language's own `to_currency(separator=...)` default,
596    /// read from the live Python signature. Base's is ",", but only
597    /// 36 of 149 languages actually use it — most default to " " or a
598    /// conjunction, so inheriting Base's comma silently corrupts them.
599    fn default_separator(&self) -> &str {
600        " con"
601    }
602
603    fn cards(&self) -> &Cards {
604        &self.cards
605    }
606    fn maxval(&self) -> &BigInt {
607        &self.maxval
608    }
609    fn negword(&self) -> &str {
610        "menos "
611    }
612    fn pointword(&self) -> &str {
613        "punto"
614    }
615    fn exclude_title(&self) -> &[String] {
616        &self.exclude_title
617    }
618
619    /// `Num2Word_ES.merge`.
620    ///
621    /// Python names the params `curr`/`next` and unpacks `curr + next` into
622    /// `ctext, cnum, ntext, nnum`.
623    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
624        let (ctext_in, cnum) = l;
625        let (ntext_in, nnum) = r;
626        let mut ctext = ctext_in.to_string();
627        let mut ntext = ntext_in.to_string();
628
629        let one = BigInt::one();
630        let hundred = BigInt::from(100);
631        let thousand = BigInt::from(1000);
632        let million = BigInt::from(1_000_000);
633
634        if cnum.is_one() {
635            if nnum < &million {
636                // Python `return next` — drops "uno" entirely ("cien", not
637                // "uno cien"); only >= 1e6 keeps a spoken unit ("un millón").
638                return (ntext, nnum.clone());
639            }
640            ctext = "un".to_string();
641        } else if cnum == &hundred && !(nnum % &thousand).is_zero() {
642            // `not nnum % 1000 == 0` parses as `not ((nnum % 1000) == 0)`:
643            // "cien" -> "ciento" only when the next chunk is not a round
644            // thousand, which is what keeps 100000 as "cien mil".
645            ctext.push('t');
646            ctext.push_str(GENDER_STEM);
647        }
648
649        if nnum < cnum {
650            if cnum < &hundred {
651                return (format!("{} y {}", ctext, ntext), cnum + nnum);
652            }
653            return (format!("{} {}", ctext, ntext), cnum + nnum);
654        } else if (nnum % &million).is_zero() && cnum > &one {
655            // Pluralize the mega word: "millón" -> "millones", "billón" ->
656            // "billones". Character-based slice: "ó" is two bytes.
657            ntext = format!("{}lones", drop_last_chars(&ntext, 3));
658        }
659
660        if nnum == &hundred {
661            if cnum == &BigInt::from(5) {
662                ctext = "quinien".to_string();
663                ntext = String::new();
664            } else if cnum == &BigInt::from(7) {
665                ctext = "sete".to_string();
666            } else if cnum == &BigInt::from(9) {
667                ctext = "nove".to_string();
668            }
669            // Note ntext is only cleared for 5, so 7/9 build on "cien":
670            // "sete" + "cien" + "tos" = "setecientos".
671            ntext.push('t');
672            ntext.push_str(GENDER_STEM);
673            ntext.push('s');
674        } else {
675            // Spanish drops the final 'o' of 'uno' (and adds an accent in
676            // 'veintiuno') before any noun like 'mil' or 'millones':
677            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
678            if nnum >= &thousand {
679                if ctext.ends_with("veintiuno") {
680                    ctext = format!("{}ún", drop_last_chars(&ctext, 3));
681                } else if ctext.ends_with("uno") {
682                    ctext = drop_last_chars(&ctext, 1);
683                }
684            }
685            ntext = format!(" {}", ntext);
686        }
687
688        (format!("{}{}", ctext, ntext), cnum * nnum)
689    }
690
691    /// `Num2Word_ES.to_cardinal` → `Num2Word_Base.to_cardinal` for integers.
692    ///
693    /// The overflow check is duplicated from the engine purely to emit ES's
694    /// own `errmsg_toobig`, "abs(%s) deber ser inferior a %s." — note the
695    /// Python typo "deber" (should be "debe"), kept verbatim. Otherwise the
696    /// base engine drives splitnum/clean/merge unchanged.
697    ///
698    /// The `_pending_ordinal` handshake in Python's `Num2Word_ES.to_cardinal`
699    /// is intentionally NOT modelled; it is unreachable for integer input.
700    /// See the port report's `concerns`.
701    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
702        let v = value.abs();
703        if &v >= self.maxval() {
704            return Err(N2WError::Overflow(format!(
705                "abs({}) deber ser inferior a {}.",
706                v,
707                self.maxval()
708            )));
709        }
710        default_to_cardinal(self, value)
711    }
712
713    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
714        self.to_ordinal_gendered(value, "m")
715    }
716
717    /// `Num2Word_ES.to_ordinal_num(value, gender="m")` — digits plus the
718    /// masculine ordinal indicator; no words involved.
719    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
720        self.verify_ordinal(value)?;
721        Ok(format!("{}º", value))
722    }
723
724    /// `Num2Word_ES.to_year` is `return self.to_cardinal(int(val))` — no
725    /// century splitting, no BC/AD suffix. The `int()` cast is a no-op for
726    /// integer input, and negatives simply pick up "menos" from to_cardinal
727    /// (corpus: -500 -> "menos quinientos").
728    fn to_year(&self, value: &BigInt) -> Result<String> {
729        self.to_cardinal(value)
730    }
731
732    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
733    //
734    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
735    // so float/Decimal input is accepted only when whole and non-negative:
736    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
737    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
738    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
739    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
740
741    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
742        match value.as_whole_int() {
743            // A whole value renders exactly like the int: Python's dict
744            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
745            // re-runs the (still reachable) negative check.
746            Some(i) => self.to_ordinal(&i),
747            None => Err(float_ordinal_reject(value)),
748        }
749    }
750
751    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
752        match value.as_whole_int() {
753            // `"%s%s" % (value, "º")` — the Python str of the value, so
754            // "5.00º", "1e+16º", "-0.0º".
755            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
756            Some(_) => Err(N2WError::Type(format!(
757                "El número negativo {} no puede ser tratado como un ordinal.",
758                repr_str
759            ))),
760            None => Err(float_ordinal_reject(value)),
761        }
762    }
763
764    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
765        // `return self.to_cardinal(int(val))` — truncation toward zero, so
766        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
767        self.to_cardinal(&float_trunc_int(value)?)
768    }
769
770    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
771
772    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
773    /// in the Python signature -> NotImplemented, so the dispatcher falls back
774    /// to Python, which raises the original TypeError. Any gender value other
775    /// than "f" (an explicit None, "x", an int, ...) is masculine:
776    /// `gender_stem = "a" if gender == "f" else "o"`.
777    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
778        if !kw.only(&["gender"]) {
779            return Err(N2WError::Fallback("kwargs".into()));
780        }
781        let gender = if kw.str("gender") == Some("f") { "f" } else { "m" };
782        self.to_ordinal_gendered(value, gender)
783    }
784
785    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
786    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
787        if !kw.only(&["gender"]) {
788            return Err(N2WError::Fallback("kwargs".into()));
789        }
790        self.verify_ordinal(value)?;
791        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
792        Ok(format!("{}{}", value, marker))
793    }
794
795    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
796    /// and ignored — the body is `to_cardinal(int(val))` regardless.
797    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
798        if !kw.only(&["suffix", "longval"]) {
799            return Err(N2WError::Fallback("kwargs".into()));
800        }
801        self.to_year(value)
802    }
803
804    /// `Num2Word_ES.to_fraction` (issue #584), inherited unchanged by
805    /// `Num2Word_ES_NI`: idiomatic medio/tercio/cuarto for denominators
806    /// 2/3/4, ordinal-as-noun otherwise (a bare "s" plural appended only when
807    /// the ordinal isn't s-final already), and the apocopated "un" for
808    /// numerator 1. `denominator == 1` (not -1!) or `numerator == 0`
809    /// short-circuits to the plain cardinal.
810    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
811        if denominator.is_zero() {
812            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
813        }
814        if denominator.is_one() || numerator.is_zero() {
815            return self.to_cardinal(numerator);
816        }
817        let is_negative = numerator.is_negative() ^ denominator.is_negative();
818        let abs_n = numerator.abs();
819        let abs_d = denominator.abs();
820
821        let den_word = if abs_d == BigInt::from(2) {
822            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
823        } else if abs_d == BigInt::from(3) {
824            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
825        } else if abs_d == BigInt::from(4) {
826            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
827        } else {
828            let mut w = self.to_ordinal(&abs_d)?;
829            if !abs_n.is_one() && !w.ends_with('s') {
830                w.push('s');
831            }
832            w
833        };
834
835        // "un" (never "uno") before the denominator noun; the sign is
836        // `"%s " % self.negword.strip()`.
837        let num_word = if abs_n.is_one() {
838            "un".to_string()
839        } else {
840            self.to_cardinal(&abs_n)?
841        };
842        let sign = if is_negative { "menos " } else { "" };
843        Ok(format!("{}{} {}", sign, num_word, den_word))
844    }
845
846    // ---- currency -------------------------------------------------------
847    //
848    // ES_NI inherits `to_cheque`, `_money_verbose`, `_cents_verbose` and
849    // `_cents_terse` unchanged from `Num2Word_Base`, and `pluralize` from
850    // `Num2Word_EUR`. `CURRENCY_PRECISION` is Base's empty dict, so the
851    // trait's default `currency_precision` (100) is already right. Only the
852    // data tables, the class name, that one plural rule and `to_currency`
853    // itself are language-specific.
854
855    /// `self.__class__.__name__`, as `Num2Word_Base` interpolates it into
856    /// `'Currency code "%s" not implemented for "%s"'`.
857    fn lang_name(&self) -> &str {
858        "Num2Word_ES_NI"
859    }
860
861    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
862        self.currency_forms.get(code)
863    }
864
865    fn currency_adjective(&self, code: &str) -> Option<&str> {
866        self.currency_adjectives.get(code).copied()
867    }
868
869    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
870    ///
871    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
872    /// would raise IndexError. All three of ES_NI's entries carry two forms, so
873    /// that is unreachable — but it is mapped to `Index` rather than panicking
874    /// so the exception type survives if the table ever changes.
875    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
876        let form = if n.is_one() { 0 } else { 1 };
877        forms
878            .get(form)
879            .cloned()
880            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
881    }
882
883    /// `Num2Word_ES_NI.to_currency`.
884    ///
885    /// ```python
886    /// result = super(Num2Word_ES, self).to_currency(
887    ///     val, currency=currency, cents=cents,
888    ///     separator=separator, adjective=adjective)
889    /// return result.replace("uno", "un")
890    /// ```
891    ///
892    /// The explicit `super(Num2Word_ES, self)` resolves past `Num2Word_ES` to
893    /// `Num2Word_Base.to_currency` (`Num2Word_EUR` defines none), so this
894    /// delegates to `default_to_currency` — *not* to any ES-specific body. See
895    /// the module docs for why that distinction changes the output.
896    ///
897    /// `replace` runs on the finished string, after the cents segment has been
898    /// appended, so it rewrites both halves in one pass ("veintiuno córdobas
899    /// con veintiuno centavos" → "veintiun córdobas con veintiun centavos").
900    /// Rust's `str::replace` and Python's `str.replace` agree: every
901    /// non-overlapping occurrence, scanned left to right.
902    fn to_currency(
903        &self,
904        val: &CurrencyValue,
905        currency: &str,
906        cents: bool,
907        separator: Option<&str>,
908        adjective: bool,
909    ) -> Result<String> {
910        // Trait hands us None when the caller omitted `separator=`; resolve it
911        // to this language's own default (" con") before the ported body.
912        let separator = separator.unwrap_or(self.default_separator());
913        let result = default_to_currency(self, val, currency, cents, separator, adjective)?;
914        Ok(result.replace("uno", "un"))
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use bigdecimal::BigDecimal;
922    use std::str::FromStr;
923
924    /// A Python `int` arg (a corpus `arg` with no dot and no exponent).
925    fn int(s: &str) -> CurrencyValue {
926        CurrencyValue::parse(s, true, false, false).unwrap()
927    }
928
929    /// A Python `float` arg. `has_decimal` is always true for a float repr.
930    fn float(s: &str) -> CurrencyValue {
931        CurrencyValue::parse(s, false, true, true).unwrap()
932    }
933
934    fn cur(lang: &LangEsNi, code: &str, v: CurrencyValue) -> Result<String> {
935        lang.to_currency(&v, code, true, None, false)
936    }
937
938    /// Every succeeding `"lang": "es_NI", "to": "currency:*"` row of the frozen
939    /// corpus, verbatim. The corpus carries 24 such rows (EUR and USD only).
940    #[test]
941    fn currency_corpus_rows() {
942        let l = LangEsNi::new();
943        let rows: Vec<(&str, CurrencyValue, &str)> = vec![
944            ("EUR", int("0"), "cero euros"),
945            ("EUR", int("1"), "un euro"),
946            ("EUR", int("2"), "dos euros"),
947            ("EUR", int("100"), "cien euros"),
948            ("EUR", float("12.34"), "doce euros con treinta y cuatro céntimos"),
949            ("EUR", float("0.01"), "cero euros con un céntimo"),
950            ("EUR", float("1.0"), "un euro con cero céntimos"),
951            (
952                "EUR",
953                float("99.99"),
954                "noventa y nueve euros con noventa y nueve céntimos",
955            ),
956            (
957                "EUR",
958                float("1234.56"),
959                "mil doscientos treinta y cuatro euros con cincuenta y seis céntimos",
960            ),
961            (
962                "EUR",
963                float("-12.34"),
964                "menos doce euros con treinta y cuatro céntimos",
965            ),
966            ("EUR", int("1000000"), "un millón euros"),
967            ("EUR", float("0.5"), "cero euros con cincuenta céntimos"),
968            ("USD", int("0"), "cero dólares"),
969            ("USD", int("1"), "un dólar"),
970            ("USD", int("2"), "dos dólares"),
971            ("USD", int("100"), "cien dólares"),
972            ("USD", float("12.34"), "doce dólares con treinta y cuatro centavos"),
973            ("USD", float("0.01"), "cero dólares con un centavo"),
974            ("USD", float("1.0"), "un dólar con cero centavos"),
975            (
976                "USD",
977                float("99.99"),
978                "noventa y nueve dólares con noventa y nueve centavos",
979            ),
980            (
981                "USD",
982                float("1234.56"),
983                "mil doscientos treinta y cuatro dólares con cincuenta y seis centavos",
984            ),
985            (
986                "USD",
987                float("-12.34"),
988                "menos doce dólares con treinta y cuatro centavos",
989            ),
990            ("USD", int("1000000"), "un millón dólares"),
991            ("USD", float("0.5"), "cero dólares con cincuenta centavos"),
992        ];
993        for (code, val, want) in rows {
994            assert_eq!(cur(&l, code, val.clone()).unwrap(), want, "{code} {val:?}");
995        }
996    }
997
998    /// The 84 NotImplementedError currency rows: seven codes x twelve args.
999    /// ES_NI's own three-code `CURRENCY_FORMS` shadows `Num2Word_ES`'s ~180
1000    /// entries, so even INR/CHF/CNY — which `Num2Word_ES` defines — are absent.
1001    #[test]
1002    fn currency_missing_codes_raise_not_implemented() {
1003        let l = LangEsNi::new();
1004        let args = [
1005            int("0"),
1006            int("1"),
1007            int("2"),
1008            int("100"),
1009            float("12.34"),
1010            float("0.01"),
1011            float("1.0"),
1012            float("99.99"),
1013            float("1234.56"),
1014            float("-12.34"),
1015            int("1000000"),
1016            float("0.5"),
1017        ];
1018        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1019            for a in args.iter() {
1020                match cur(&l, code, a.clone()) {
1021                    Err(N2WError::NotImplemented(msg)) => assert_eq!(
1022                        msg,
1023                        format!("Currency code \"{code}\" not implemented for \"Num2Word_ES_NI\"")
1024                    ),
1025                    other => panic!("{code} {a:?}: expected NotImplemented, got {other:?}"),
1026                }
1027            }
1028        }
1029    }
1030
1031    /// Both succeeding `to": "cheque:*"` corpus rows, plus the seven that
1032    /// raise. `to_cheque` is `Num2Word_Base`'s, so it is *not* subject to
1033    /// `to_currency`'s "uno" -> "un" replace — `1.0` keeps "UNO".
1034    #[test]
1035    fn cheque_corpus_rows() {
1036        let l = LangEsNi::new();
1037        let v = BigDecimal::from_str("1234.56").unwrap();
1038        assert_eq!(
1039            l.to_cheque(&v, "EUR").unwrap(),
1040            "MIL DOSCIENTOS TREINTA Y CUATRO AND 56/100 EUROS"
1041        );
1042        assert_eq!(
1043            l.to_cheque(&v, "USD").unwrap(),
1044            "MIL DOSCIENTOS TREINTA Y CUATRO AND 56/100 DÓLARES"
1045        );
1046        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1047            match l.to_cheque(&v, code) {
1048                Err(N2WError::NotImplemented(msg)) => assert_eq!(
1049                    msg,
1050                    format!("Currency code \"{code}\" not implemented for \"Num2Word_ES_NI\"")
1051                ),
1052                other => panic!("cheque {code}: expected NotImplemented, got {other:?}"),
1053            }
1054        }
1055        // No "uno" -> "un" on the cheque path, and MINUS for negatives.
1056        assert_eq!(
1057            l.to_cheque(&BigDecimal::from_str("1.0").unwrap(), "NIO").unwrap(),
1058            "UNO AND 00/100 CÓRDOBAS"
1059        );
1060        assert_eq!(
1061            l.to_cheque(&BigDecimal::from_str("21.0").unwrap(), "NIO").unwrap(),
1062            "VEINTIUNO AND 00/100 CÓRDOBAS"
1063        );
1064        assert_eq!(
1065            l.to_cheque(&BigDecimal::from_str("-1234.56").unwrap(), "NIO").unwrap(),
1066            "MINUS MIL DOSCIENTOS TREINTA Y CUATRO AND 56/100 CÓRDOBAS"
1067        );
1068    }
1069
1070    /// NIO — the `to_currency(currency="NIO")` default and ES_NI's raison
1071    /// d'être — has **no corpus rows at all**. Pinned here against the live
1072    /// interpreter instead.
1073    #[test]
1074    fn currency_nio_default() {
1075        let l = LangEsNi::new();
1076        assert_eq!(l.default_currency(), "NIO");
1077        assert_eq!(l.default_separator(), " con");
1078        let rows: Vec<(CurrencyValue, &str)> = vec![
1079            (int("0"), "cero córdobas"),
1080            (int("1"), "un córdoba"),
1081            (int("2"), "dos córdobas"),
1082            (int("100"), "cien córdobas"),
1083            (int("-1"), "menos un córdoba"),
1084            (int("1000000"), "un millón córdobas"),
1085            (float("1.0"), "un córdoba con cero centavos"),
1086            (float("0.01"), "cero córdobas con un centavo"),
1087            (float("1.01"), "un córdoba con un centavo"),
1088            (float("0.5"), "cero córdobas con cincuenta centavos"),
1089            (float("12.34"), "doce córdobas con treinta y cuatro centavos"),
1090            (
1091                float("-12.34"),
1092                "menos doce córdobas con treinta y cuatro centavos",
1093            ),
1094            (
1095                float("1234.56"),
1096                "mil doscientos treinta y cuatro córdobas con cincuenta y seis centavos",
1097            ),
1098        ];
1099        for (val, want) in rows {
1100            assert_eq!(cur(&l, "NIO", val.clone()).unwrap(), want, "{val:?}");
1101        }
1102    }
1103
1104    /// The blanket `replace("uno", "un")` fires inside "veintiuno" and "ciento
1105    /// uno" too, yielding the **unaccented** "veintiun" where `Num2Word_ES`'s
1106    /// targeted replace would have written "veintiún". Python bug, preserved.
1107    /// All values confirmed against the live interpreter.
1108    #[test]
1109    fn blanket_uno_replace_quirks() {
1110        let l = LangEsNi::new();
1111        // "veintiuno" -> "veintiun", not "veintiún".
1112        assert_eq!(cur(&l, "NIO", int("21")).unwrap(), "veintiun córdobas");
1113        assert_eq!(cur(&l, "NIO", int("-21")).unwrap(), "menos veintiun córdobas");
1114        // Both segments rewritten in one pass over the finished string.
1115        assert_eq!(
1116            cur(&l, "NIO", float("21.21")).unwrap(),
1117            "veintiun córdobas con veintiun centavos"
1118        );
1119        assert_eq!(
1120            cur(&l, "NIO", float("101.0")).unwrap(),
1121            "ciento un córdobas con cero centavos"
1122        );
1123        assert_eq!(
1124            cur(&l, "NIO", float("31.0")).unwrap(),
1125            "treinta y un córdobas con cero centavos"
1126        );
1127        // Base's int branch pluralizes on n == 1 only, so a singular "millón"
1128        // takes a plural noun. ES's own int fast path is bypassed by
1129        // `super(Num2Word_ES, self)`; this is what that costs.
1130        assert_eq!(
1131            cur(&l, "EUR", float("2000000.21")).unwrap(),
1132            "dos millones euros con veintiun céntimos"
1133        );
1134    }
1135
1136    /// `adjective=True` reaches EUR's inherited `CURRENCY_ADJECTIVES`. USD is
1137    /// the only code present in both that table and ES_NI's `CURRENCY_FORMS`.
1138    #[test]
1139    fn currency_adjective_only_bites_on_usd() {
1140        let l = LangEsNi::new();
1141        assert_eq!(
1142            l.to_currency(&int("1"), "USD", true, None, true).unwrap(),
1143            "un US dólar"
1144        );
1145        assert_eq!(
1146            l.to_currency(&int("2"), "USD", true, None, true).unwrap(),
1147            "dos US dólares"
1148        );
1149        assert_eq!(
1150            l.to_currency(&float("12.34"), "USD", true, None, true).unwrap(),
1151            "doce US dólares con treinta y cuatro centavos"
1152        );
1153        // NIO/EUR have no adjective entry, so the flag is inert.
1154        assert_eq!(
1155            l.to_currency(&int("1"), "NIO", true, None, true).unwrap(),
1156            "un córdoba"
1157        );
1158        assert_eq!(
1159            l.to_currency(&int("1"), "EUR", true, None, true).unwrap(),
1160            "un euro"
1161        );
1162    }
1163
1164    /// `cents=False` routes to Base's `_cents_terse`; precision is 100 for
1165    /// every code (Base's `CURRENCY_PRECISION` is empty), so the width is 2.
1166    /// An explicit `separator=` must also override the " con" default.
1167    #[test]
1168    fn cents_terse_and_explicit_separator() {
1169        let l = LangEsNi::new();
1170        assert_eq!(
1171            l.to_currency(&float("12.34"), "NIO", false, None, false).unwrap(),
1172            "doce córdobas con 34 centavos"
1173        );
1174        assert_eq!(
1175            l.to_currency(&float("1.0"), "NIO", false, None, false).unwrap(),
1176            "un córdoba con 00 centavos"
1177        );
1178        assert_eq!(
1179            l.to_currency(&float("0.01"), "NIO", false, None, false).unwrap(),
1180            "cero córdobas con 01 centavo"
1181        );
1182        assert_eq!(
1183            l.to_currency(&float("12.34"), "NIO", true, Some(" y"), false).unwrap(),
1184            "doce córdobas y treinta y cuatro centavos"
1185        );
1186        assert_eq!(l.currency_precision("NIO"), 100);
1187        assert_eq!(l.currency_precision("KWD"), 100);
1188    }
1189
1190    use crate::floatpath::FloatValue;
1191
1192    /// Every non-integer `"lang": "es_NI", "to": "cardinal"` float row of the
1193    /// frozen corpus (whole-number floats like `1.0`/`0.0` take the integer
1194    /// path via the shim and never reach here). `Num2Word_ES` overrides
1195    /// `to_cardinal` but delegates float input to
1196    /// `super().to_cardinal` -> `Num2Word_Base.to_cardinal_float`, so the
1197    /// inherited default float path is exact — this pins that.
1198    #[test]
1199    fn cardinal_float_corpus_rows() {
1200        let l = LangEsNi::new();
1201        // (value, precision = abs(Decimal(str(value)).as_tuple().exponent), out)
1202        let rows: Vec<(f64, u32, &str)> = vec![
1203            (0.5, 1, "cero punto cinco"),
1204            (1.5, 1, "uno punto cinco"),
1205            (2.25, 2, "dos punto dos cinco"),
1206            (3.14, 2, "tres punto uno cuatro"),
1207            (0.01, 2, "cero punto cero uno"),
1208            (0.1, 1, "cero punto uno"),
1209            (0.99, 2, "cero punto nueve nueve"),
1210            (1.01, 2, "uno punto cero uno"),
1211            (12.34, 2, "doce punto tres cuatro"),
1212            (99.99, 2, "noventa y nueve punto nueve nueve"),
1213            (100.5, 1, "cien punto cinco"),
1214            (1234.56, 2, "mil doscientos treinta y cuatro punto cinco seis"),
1215            (-0.5, 1, "menos cero punto cinco"),
1216            (-1.5, 1, "menos uno punto cinco"),
1217            (-12.34, 2, "menos doce punto tres cuatro"),
1218            // f64-artefact traps: 0.005*1000 == 4.999...893, 0.675*1000 ==
1219            // 674.999...998; the `< 0.01` heuristic rescues both to 5 / 675.
1220            (1.005, 3, "uno punto cero cero cinco"),
1221            (2.675, 3, "dos punto seis siete cinco"),
1222        ];
1223        for (value, precision, want) in rows {
1224            let v = FloatValue::Float { value, precision };
1225            assert_eq!(
1226                l.to_cardinal_float(&v, None).unwrap(),
1227                want,
1228                "{value}"
1229            );
1230        }
1231    }
1232
1233    /// Every `"lang": "es_NI", "to": "cardinal_dec"` Decimal row, verbatim.
1234    /// Decimal input takes float2tuple's exact arbitrary-precision arm (issue
1235    /// #603): `98746251323029.99` must keep `.99`, not round at trillion scale.
1236    #[test]
1237    fn cardinal_decimal_corpus_rows() {
1238        let l = LangEsNi::new();
1239        // (decimal literal, out); precision = the literal's scale.
1240        let rows: Vec<(&str, &str)> = vec![
1241            ("0.01", "cero punto cero uno"),
1242            ("1.10", "uno punto uno cero"),
1243            ("12.345", "doce punto tres cuatro cinco"),
1244            (
1245                "98746251323029.99",
1246                "noventa y ocho billones setecientos cuarenta y seis mil \
1247                 doscientos cincuenta y un millones trescientos veintitrés mil \
1248                 veintinueve punto nueve nueve",
1249            ),
1250            ("0.001", "cero punto cero cero uno"),
1251        ];
1252        for (lit, want) in rows {
1253            let d = BigDecimal::from_str(lit).unwrap();
1254            // precision == abs(exponent) == the BigDecimal's scale.
1255            let precision = d.as_bigint_and_exponent().1 as u32;
1256            let v = FloatValue::Decimal { value: d, precision };
1257            assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want, "{lit}");
1258        }
1259    }
1260}