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}