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