Skip to main content

num2words2_core/
lang_ca.rs

1//! Port of `lang_CA.py` (Catalan), registered by `CONVERTER_CLASSES["ca"]`
2//! as `Num2Word_CA`.
3//!
4//! Shape: **engine**. `Num2Word_CA` subclasses `Num2Word_EUR` →
5//! `Num2Word_Base`, defines `high_numwords`/`mid_numwords`/`low_numwords` plus
6//! `merge`, and lets the base `to_cardinal` drive `splitnum`/`clean`. It then
7//! overrides `to_ordinal` / `to_ordinal_num` with hand-rolled algorithms.
8//!
9//! Inheritance chain actually walked:
10//!   * `Num2Word_EUR.set_high_numwords` — pairs each high word with
11//!     `range(3 + 6*len(high), 3, -6)`. CA sets `GIGA_SUFFIX = None` and
12//!     `MEGA_SUFFIX = "ilió"`, so **only** the MEGA half fires and the cards
13//!     land at 10^24/10^18/10^12/10^6. There is deliberately **no 10^9 card**
14//!     — a milliard is spelled compositionally as "mil milions".
15//!   * `Num2Word_EUR.gen_high_numwords([], [], lows)` — with empty units and
16//!     tens the comprehension yields `[]`, so the Latin elision table is dead
17//!     code here and the result is just `lows == ["quadr","tr","b","m"]`.
18//!   * `Num2Word_Base.to_year(value)` → `self.to_cardinal(value)`; CA does not
19//!     override it, so years are plain cardinals ("menys cinc-cents" for -500).
20//!   * `Num2Word_Base.verify_ordinal` — negatives raise `TypeError`.
21//!
22//! `MAXVAL = 1000 * 10^24 = 10^27`; `to_cardinal(10^27)` raises OverflowError.
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! Verified against the interpreter; all of these are wrong-looking but real.
27//!
28//! 1. **`ords_3` has no key 0**, so every exact multiple of ten from 30 to 90
29//!    raises `KeyError`: `to_ordinal(30)`, `(40)`, … `(90)` all blow up, while
30//!    `to_ordinal(31)` == "trenta-unè" works. 30 dies in the `value <= 30` arm
31//!    (`frac = 30 % 10 == 0`), 40–90 in the `value < 100` arm
32//!    (`value - dec == 0`). See [`LangCa::ords_3`].
33//! 2. **Missing space in the `< 1e18` arm.** The format is
34//!    `"%s%s%s %s" % (cardinal, ords[dec], gender_stem, ...)` with no
35//!    separator between `cardinal` and `ords[dec]`, so
36//!    `to_ordinal(9999999)` == "nou**milionè** nou-cents…" and
37//!    `to_ordinal(123456789)` == "cent vint-i-tres**milionè** quatre-cents…".
38//!    The sibling `< 1e6` arm *does* include the space ("dotze mil …").
39//! 3. **`value >= 1e18` chops a character and glues on "onè".**
40//!    `part1[:-1] + "onè"` turns "un trilió" into "un trilionè" (fine) but
41//!    "mil trilions" into "mil trilion**onè**" and
42//!    `to_cardinal(10^27 - 1)` into a string ending "…noranta-no**onè**".
43//! 4. **`int(math.log(v, 1000))` overshoots on two float-rounding windows.**
44//!    `ln` is computed in `f64`, and for v just below 10^15 or 10^18 the
45//!    result rounds up to exactly `5.0` / `6.0`:
46//!    * v ∈ [999999999999996, 999999999999999] → `dec = 10^15 > v`, so
47//!      `divmod` gives `high_part = 0`, `low_part = v`, and
48//!      `to_ordinal(low_part)` recurses on the *same value* forever →
49//!      Python raises **RecursionError**. See [`RECURSION_LIMIT`].
50//!    * v ∈ [999999999999995072, 10^18) → `dec = 10^18`, which is absent from
51//!      `ords` → **KeyError**. This is why `to_ordinal(10^18 - 1)` raises.
52//!    [`log1000_trunc`] reproduces the float computation rather than taking an
53//!    exact integer logarithm, precisely so these windows survive.
54//! 5. **`to_ordinal(0)` returns the empty string** (not "zero"-ish), because
55//!    the first arm sets `text = ""` and the function ends with `.strip()`.
56//!    `to_ordinal(200)` == "dos-cents" (no ordinal suffix at all) for the same
57//!    reason: the trailing `to_ordinal(0)` contributes nothing and `.strip()`
58//!    eats the space.
59//! 6. `to_ordinal(1234567890)` raises `KeyError` — it reaches
60//!    `to_ordinal(90)`, which is bug 1.
61//! 7. **Negative integer currency amounts get a double space**, and
62//!    `adjective=True` is ignored for ints but honoured for floats — CA's
63//!    `to_currency` handles ints itself and delegates floats to the base
64//!    class, and the two arms disagree. See [`LangCa::to_currency`].
65//! 8. **`CURRENCY_FORMS` typos are load-bearing output**: `SLL` is
66//!    `("leonE", "leones")` and `STD` is `("dobra", "dobrAs")` — mid-word
67//!    capitals in the *source dict*, so `to_currency(1, "SLL")` really is
68//!    "un leonE". `ALL`'s singular is misspelt `"qqindarka"`. Copied verbatim.
69//! 9. **`GBP`/`INR`/… take masculine "un" against a feminine noun**
70//!    ("un lliura", "un rupia"): the int arm renders the number with plain
71//!    `to_cardinal`, which has no gender agreement. In the corpus as-is.
72//! 10. **CA's `to_cardinal_float` un-fixes issue #603.** It calls
73//!    `self.float2tuple(float(value))`, float-casting Decimal input that
74//!    base.py deliberately keeps exact, so `Decimal("98746251323029.99")`
75//!    renders "…coma noranta-vuit" (98) rather than 99, and
76//!    `Decimal("1.10")` gives "un coma un" rather than "un coma un zero".
77//!    Both are frozen in the corpus. See [`LangCa::to_cardinal_float`].
78//! 11. **A fraction of exactly zero can leave a dangling "coma".** The
79//!    pointword is appended unconditionally (base guards it with
80//!    `if self.precision:`), while the fraction's own word is guarded by
81//!    `int(post_str) > 0 or leading_zeros == 0`. At precision 0 — reachable
82//!    for 17-significant-digit doubles like `1.2345678901234568e16` — both
83//!    combine into "…cinc-cents seixanta-vuit coma" with nothing after it.
84//!
85//! # Error variants
86//!
87//! `KeyError` → [`N2WError::Key`], `TypeError` → [`N2WError::Type`],
88//! `OverflowError` → [`N2WError::Overflow`].
89//!
90//! **RecursionError has no matching `N2WError` variant.** The four values in
91//! bug 4's first window are mapped to [`N2WError::Value`] by the depth guard in
92//! [`LangCa::to_ordinal_inner`]. That is knowingly the wrong exception *type*;
93//! it is chosen because the faithful alternative — unbounded recursion — would
94//! abort the process on stack overflow rather than return. Flagged in the port
95//! report.
96
97use crate::base::{clean, splitnum, Cards, Lang, N2WError, Node, Result};
98use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
99use crate::floatpath::{float2tuple, FloatValue};
100use bigdecimal::BigDecimal;
101use num_bigint::BigInt;
102use num_integer::Integer;
103use num_traits::{One, Signed, ToPrimitive, Zero};
104use std::collections::HashMap;
105use std::str::FromStr;
106
107/// `self.gender_stem`. CA hardcodes "è"; `to_ordinal_num`'s
108/// `"è" if self.gender_stem == "è" else "a"` therefore always picks "è".
109const GENDER_STEM: &str = "è";
110
111/// `self.negword`, kept with its trailing space exactly as Python sets it.
112/// `to_cardinal` uses `negword.strip()` + `" "`, so the space is cosmetic.
113const NEGWORD: &str = "menys ";
114
115/// `Num2Word_CA.to_currency`'s own default `separator=" amb"`.
116///
117/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
118const SEPARATOR_DEFAULT: &str = " amb";
119
120/// The separator the pyo3 binding passes when the Python caller omitted one.
121///
122/// `Num2Word_CA.to_currency` declares `separator=" amb"`, but the `Lang` trait
123/// has no per-language defaults: `__init__.py`'s fast path (and `diff_test.py`)
124/// substitute `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default —
125/// before the value ever reaches Rust. By then "caller omitted separator" and
126/// "caller explicitly passed a comma" are the same string, and the information
127/// needed to tell them apart no longer exists on this side of the boundary.
128///
129/// So `,` is read back as the unset sentinel and CA's own default restored.
130/// This is the only reading that matches the oracle: all 63 float rows of the
131/// `ca` currency corpus were generated by `num2words(v, lang="ca",
132/// to="currency", currency=c)` with no `separator=`, and every one of them
133/// expects " amb".
134///
135/// The cost is narrow and known: a caller who *explicitly* passes
136/// `separator=","` gets " amb" here where Python would give ",". Fixing that
137/// properly needs `Option<&str>` in the trait signature, which lives in
138/// `base.rs` — outside this port's remit. Flagged in the port report.
139const SEPARATOR_UNSET: &str = ",";
140
141/// Depth at which we stand in for CPython's RecursionError.
142///
143/// Python's default limit is 1000 frames and the ordinal recursion is only
144/// ever unbounded for the four values in module-doc bug 4. The exact frame
145/// count is not observable through the corpus (only the exception type is), so
146/// any limit comfortably above the deepest legitimate nesting (~4) works.
147const RECURSION_LIMIT: u32 = 900;
148
149/// Python's `int(math.log(int(value), 1000))`.
150///
151/// CPython's `loghelper` converts the int with `PyLong_AsDouble` (round to
152/// nearest) and calls C `log`; the two-argument form then divides by
153/// `log(1000.0)`. Both call sites are guarded by `value < 1e18`, so `to_u64`
154/// is lossless and `as f64` performs exactly `PyLong_AsDouble`'s rounding.
155///
156/// This deliberately does **not** compute an exact integer logarithm: the f64
157/// round-off is load-bearing (module-doc bug 4).
158fn log1000_trunc(value: &BigInt) -> u32 {
159    // math.log(1000) == 0x1.ba18a998fffa0p+2; this literal round-trips to the
160    // same bits under Rust's correctly-rounded float parsing.
161    const LN_1000: f64 = 6.907755278982137_f64;
162    let x = value
163        .to_u64()
164        .expect("log1000_trunc call sites are guarded by value < 1e18")
165        as f64;
166    // Python's int() truncates toward zero; the ratio is >= 1 here, so
167    // truncation and floor agree.
168    (x.ln() / LN_1000).trunc() as u32
169}
170
171fn pow10(e: u32) -> BigInt {
172    BigInt::from(10u8).pow(e)
173}
174
175/// Python's `abs(Decimal(str(value)).as_tuple().exponent)` for an `f64` — the
176/// precision `Num2Word_Base.float2tuple` derives from `repr` and assigns to
177/// `self.precision` on every call.
178///
179/// CA needs its own copy because it float-casts **Decimal** input too (see
180/// [`LangCa::to_cardinal_float`]), so the precision carried on
181/// `FloatValue::Decimal` — `abs(exponent)` of the *exact* decimal — is not the
182/// one Python ends up reading. `floatpath` has an equivalent helper but it is
183/// private, and it counts the digits of `format!("{}", f)`, which disagrees
184/// with `repr` at both ends of the range:
185///
186/// * `repr(5.0)` is `"5.0"` → precision **1**, but `format!("{}", 5.0)` is
187///   `"5"` → 0. Not academic: `to_cardinal_float(5.0)` is "cinc coma zero",
188///   and at precision 0 this port would emit a dangling "cinc coma".
189/// * `repr(1e16)` is `"1e+16"` → `Decimal("1e+16").as_tuple().exponent == 16`
190///   → precision **16**, but `format!("{}", 1e16)` is plain digits → 0. Python
191///   really does say "deu mil bilions coma" followed by fifteen "zero".
192///
193/// CPython's `float_repr_style='short'` formats with David Gay's shortest
194/// `dtoa` (a digit string plus `decpt`, the decimal point's position), then
195/// picks fixed notation iff `-4 < decpt <= 16`, and in fixed notation appends
196/// ".0" when no fractional digits were left. Rust's `{:e}` is the same
197/// shortest-round-trip digit string, so `decpt == exp + 1` recovers Gay's.
198/// `Decimal(repr(f)).as_tuple().exponent` is then `-1` for that ".0" case and
199/// `decpt - ndigits` in every other, scientific notation included.
200///
201/// Note precision 0 *is* reachable, via the scientific arm: `1.2345678901234568e16`
202/// has 17 digits at `decpt == 17`. Python then prints a bare trailing "coma";
203/// see [`LangCa::to_cardinal_float`].
204///
205/// Verified against the interpreter over 4035 values — every case in this
206/// module's tests plus 4000 random ones spanning 1e-30..1e30, both signs — with
207/// no mismatch.
208fn float_repr_precision(f: f64) -> u32 {
209    // `{:e}` always emits the exponent, except for inf/nan; callers guard those
210    // (Python raises out of `int(value)` first), so the fallback is unreachable.
211    let s = format!("{:e}", f);
212    let (mantissa, exp) = match s.split_once('e') {
213        Some(parts) => parts,
214        None => return 0,
215    };
216    let exp: i32 = match exp.parse() {
217        Ok(e) => e,
218        Err(_) => return 0,
219    };
220    // The sign and the point are formatting; only the digits count.
221    let ndigits = mantissa.chars().filter(|c| c.is_ascii_digit()).count() as i32;
222    let decpt = exp + 1;
223    if decpt > -4 && decpt <= 16 && decpt >= ndigits {
224        // Fixed notation with nothing after the point: repr appends ".0", so
225        // the Decimal exponent is -1 rather than 0.
226        1
227    } else {
228        (decpt - ndigits).unsigned_abs()
229    }
230}
231
232/// Python's `float(value)` on a `Decimal`.
233///
234/// Goes through the decimal string rather than `BigDecimal::to_f64`: that
235/// method trims the significand to ~25 digits before dividing, so it is only
236/// approximately rounded, whereas `Decimal.__float__` (libmpdec) and Rust's
237/// `f64::from_str` are both *correctly* rounded to the nearest double. They
238/// agree on every corpus value either way, but only the string route is
239/// guaranteed to.
240fn decimal_to_f64(value: &BigDecimal) -> f64 {
241    // BigDecimal's Display emits plain or `E`-notation decimal, both of which
242    // Rust's float parser accepts; a value too large for f64 parses to
243    // infinity, exactly as Python's float(Decimal) overflows to inf.
244    f64::from_str(&value.to_string()).unwrap_or(f64::NAN)
245}
246
247pub struct LangCa {
248    cards: Cards,
249    maxval: BigInt,
250    /// `self.ords`. Python mixes int keys (1..20, 30..90, 100..900) with float
251    /// keys (1e3, 1e6, 1e9, 1e12, 1e15); dict lookup matches an int `dec`
252    /// against a float key by numeric equality, and every such key is exactly
253    /// representable, so a single integer-keyed map is equivalent.
254    ords_tbl: HashMap<u128, &'static str>,
255    exclude_title: Vec<String>,
256    /// `Num2Word_CA.CURRENCY_FORMS`. CA *replaces* `Num2Word_EUR`'s dict
257    /// wholesale — plain class-attribute shadowing, no merge anywhere in the
258    /// chain — so EUR's entries are entirely absent. That is observable:
259    /// EUR spells `PLN` with three forms `("zloty","zlotys","zlotu")` and
260    /// `EUR` as `("euro","euro")`, whereas CA has two-form `("zloty","zlotys")`
261    /// and `("euro","euros")` — hence "zero euros", not "zero euro".
262    /// All 166 CA entries are 2+2, so `pluralize` never indexes past [1].
263    ///
264    /// Built once here rather than per call, as the porting contract requires.
265    currency_forms: HashMap<&'static str, CurrencyForms>,
266    /// `Num2Word_CA.CURRENCY_ADJECTIVES` — *not* defined by CA, so this is
267    /// `Num2Word_EUR`'s dict inherited verbatim (English adjectives on a
268    /// Catalan converter: `to_currency(100.0, "USD", adjective=True)` gives
269    /// "cent US dòlars amb zero centaus"). Reproduced, not corrected.
270    currency_adjectives: HashMap<&'static str, &'static str>,
271}
272
273impl Default for LangCa {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl LangCa {
280    pub fn new() -> Self {
281        let mut cards = Cards::new();
282
283        // Num2Word_EUR.set_high_numwords(["quadr", "tr", "b", "m"]).
284        // cap = 3 + 6*4 = 27; zip(high, range(27, 3, -6)) -> 27, 21, 15, 9.
285        // GIGA_SUFFIX is None so the 10**n insert is skipped entirely;
286        // MEGA_SUFFIX = "ilió" gives cards[10**(n-3)].
287        let high = ["quadr", "tr", "b", "m"];
288        let cap: i64 = 3 + 6 * high.len() as i64;
289        let mut n = cap;
290        for word in high.iter() {
291            if n <= 3 {
292                break;
293            }
294            cards.insert(pow10((n - 3) as u32), format!("{}ilió", word));
295            n -= 6;
296        }
297
298        for (k, v) in [
299            (1000i64, "mil"),
300            (100, "cent"),
301            (90, "noranta"),
302            (80, "vuitanta"),
303            (70, "setanta"),
304            (60, "seixanta"),
305            (50, "cinquanta"),
306            (40, "quaranta"),
307            (30, "trenta"),
308        ] {
309            cards.insert(BigInt::from(k), v);
310        }
311
312        // set_low_numwords: words map to descending values ending at 0.
313        let low = [
314            "vint-i-nou",
315            "vint-i-vuit",
316            "vint-i-set",
317            "vint-i-sis",
318            "vint-i-cinc",
319            "vint-i-quatre",
320            "vint-i-tres",
321            "vint-i-dos",
322            "vint-i-un",
323            "vint",
324            "dinou",
325            "divuit",
326            "disset",
327            "setze",
328            "quinze",
329            "catorze",
330            "tretze",
331            "dotze",
332            "onze",
333            "deu",
334            "nou",
335            "vuit",
336            "set",
337            "sis",
338            "cinc",
339            "quatre",
340            "tres",
341            "dos",
342            "un",
343            "zero",
344        ];
345        let len = low.len();
346        for (i, word) in low.iter().enumerate() {
347            cards.insert(BigInt::from(len - 1 - i), *word);
348        }
349
350        // MAXVAL = 1000 * list(cards)[0]; insertion order puts 10**24 first.
351        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
352
353        let ords_tbl: HashMap<u128, &'static str> = [
354            (1u128, "primer"),
355            (2, "segon"),
356            (3, "tercer"),
357            (4, "quart"),
358            (5, "cinqu"),
359            (6, "sis"),
360            (7, "set"),
361            (8, "vuit"),
362            (9, "nov"),
363            (10, "des"),
364            (11, "onz"),
365            (12, "dotz"),
366            (13, "tretz"),
367            (14, "catorz"),
368            (15, "quinz"),
369            (16, "setz"),
370            (17, "disset"),
371            (18, "divuit"),
372            (19, "dinov"),
373            (20, "vint"),
374            (30, "trent"),
375            (40, "quarant"),
376            (50, "cinquant"),
377            (60, "seixant"),
378            (70, "setant"),
379            (80, "vuitant"),
380            (90, "norant"),
381            (100, "cent"),
382            (200, "dos-cent"),
383            (300, "tres-cent"),
384            (400, "quatre-cent"),
385            (500, "cinc-cent"),
386            (600, "sis-cent"),
387            (700, "set-cent"),
388            (800, "vuit-cent"),
389            (900, "nou-cent"),
390            (1_000, "mil"),
391            (1_000_000, "milion"),
392            (1_000_000_000, "mil milion"),
393            (1_000_000_000_000, "bilion"),
394            (1_000_000_000_000_000, "mil bilion"),
395        ]
396        .into_iter()
397        .collect();
398
399        // CURRENCY_FORMS / CURRENCY_ADJECTIVES are built once, here, and
400        // stored on the struct: rebuilding a 166-entry table on every
401        // to_currency call is what made an earlier revision of this port an
402        // order of magnitude slower than the Python it replaces.
403        let currency_forms: HashMap<&'static str, CurrencyForms> = [
404            ("EUR", &["euro", "euros"][..], &["cèntim", "cèntims"][..]),
405            ("ESP", &["pesseta", "pessetes"][..], &["cèntim", "cèntims"][..]),
406            ("USD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
407            ("PEN", &["sol", "sols"][..], &["cèntim", "cèntims"][..]),
408            ("CRC", &["colón", "colons"][..], &["centau", "centaus"][..]),
409            ("AUD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
410            ("CAD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
411            ("GBP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
412            ("RUB", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
413            ("SEK", &["corona", "corones"][..], &["öre", "öre"][..]),
414            ("NOK", &["corona", "corones"][..], &["øre", "øre"][..]),
415            ("PLN", &["zloty", "zlotys"][..], &["grosz", "groszy"][..]),
416            ("MXN", &["peso", "pesos"][..], &["centau", "centaus"][..]),
417            ("RON", &["leu", "lei"][..], &["ban", "bani"][..]),
418            ("INR", &["rupia", "rupies"][..], &["paisa", "paise"][..]),
419            ("HUF", &["fòrint", "fòrints"][..], &["fillér", "fillérs"][..]),
420            ("FRF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
421            ("CNY", &["iuan", "iuans"][..], &["fen", "jiao"][..]),
422            ("CZK", &["corona", "corones"][..], &["haléř", "haléřů"][..]),
423            ("NIO", &["córdoba", "córdobas"][..], &["centau", "centaus"][..]),
424            ("VES", &["bolívar", "bolívars"][..], &["cèntim", "cèntims"][..]),
425            ("BRL", &["real", "reals"][..], &["centau", "centaus"][..]),
426            ("CHF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
427            ("JPY", &["ien", "iens"][..], &["sen", "sen"][..]),
428            ("KRW", &["won", "wons"][..], &["jeon", "jeon"][..]),
429            ("KPW", &["won", "wons"][..], &["chŏn", "chŏn"][..]),
430            ("TRY", &["lira", "lires"][..], &["kuruş", "kuruş"][..]),
431            ("ZAR", &["rand", "rands"][..], &["cèntim", "cèntims"][..]),
432            ("KZT", &["tenge", "tenge"][..], &["tin", "tin"][..]),
433            ("UAH", &["hrívnia", "hrívnies"][..], &["kopiika", "kopíok"][..]),
434            ("THB", &["baht", "bahts"][..], &["satang", "satang"][..]),
435            ("AED", &["dirham", "dirhams"][..], &["fils", "fulūs"][..]),
436            ("AFN", &["afgani", "afganis"][..], &["puli", "puls"][..]),
437            ("ALL", &["lek", "lekë"][..], &["qqindarka", "qindarkë"][..]),
438            ("AMD", &["dram", "drams"][..], &["luma", "lumas"][..]),
439            ("ANG", &["florí", "florins"][..], &["cèntim", "cèntims"][..]),
440            ("AOA", &["kwanza", "kwanzes"][..], &["cèntim", "cèntims"][..]),
441            ("ARS", &["peso", "pesos"][..], &["centau", "centaus"][..]),
442            ("AWG", &["florí", "florins"][..], &["centau", "centaus"][..]),
443            ("AZN", &["manat", "manats"][..], &["qəpik", "qəpik"][..]),
444            ("BBD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
445            ("BDT", &["taka", "taka"][..], &["poisha", "poisha"][..]),
446            ("BGN", &["lev", "leva"][..], &["stotinka", "stotinki"][..]),
447            ("BHD", &["dinar", "dinars"][..], &["fils", "fulūs"][..]),
448            ("BIF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
449            ("BMD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
450            ("BND", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
451            ("BOB", &["boliviano", "bolivianos"][..], &["centau", "centaus"][..]),
452            ("BSD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
453            ("BTN", &["ngultrum", "ngultrums"][..], &["chetrum", "chetrums"][..]),
454            ("BWP", &["pula", "pula"][..], &["thebe", "thebe"][..]),
455            ("BYN", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
456            ("BYR", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
457            ("BZD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
458            ("CDF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
459            ("CLP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
460            ("COP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
461            ("CUP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
462            ("CVE", &["escut", "escuts"][..], &["centau", "centaus"][..]),
463            ("CYP", &["lliura", "lliures"][..], &["cèntim", "cèntims"][..]),
464            ("DJF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
465            ("DKK", &["corona", "corones"][..], &["øre", "øre"][..]),
466            ("DOP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
467            ("DZD", &["dinar", "dinars"][..], &["cèntim", "cèntims"][..]),
468            ("ECS", &["sucre", "sucres"][..], &["centau", "centaus"][..]),
469            ("EGP", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
470            ("ERN", &["nakfa", "nakfes"][..], &["cèntim", "cèntims"][..]),
471            ("ETB", &["birr", "birr"][..], &["cèntim", "cèntims"][..]),
472            ("FJD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
473            ("FKP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
474            ("GEL", &["lari", "laris"][..], &["tetri", "tetri"][..]),
475            ("GHS", &["cedi", "cedis"][..], &["pesewa", "pesewas"][..]),
476            ("GIP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
477            ("GMD", &["dalasi", "dalasis"][..], &["butut", "bututs"][..]),
478            ("GNF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
479            ("GTQ", &["quetzal", "quetzals"][..], &["centau", "centaus"][..]),
480            ("GYD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
481            ("HKD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
482            ("HNL", &["lempira", "lempires"][..], &["centau", "centaus"][..]),
483            ("HRK", &["kuna", "kuna"][..], &["lipa", "lipa"][..]),
484            ("HTG", &["gourde", "gourdes"][..], &["cèntim", "cèntims"][..]),
485            ("IDR", &["rúpia", "rúpies"][..], &["cèntim", "cèntims"][..]),
486            ("ILS", &["xéquel", "xéquels"][..], &["agorà", "agorot"][..]),
487            ("IQD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
488            ("IRR", &["rial", "rials"][..], &["dinar", "dinars"][..]),
489            ("ISK", &["corona", "corones"][..], &["eyrir", "aurar"][..]),
490            ("ITL", &["lira", "lires"][..], &["cèntim", "cèntims"][..]),
491            ("JMD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
492            ("JOD", &["dinar", "dinars"][..], &["piastra", "piastres"][..]),
493            ("KES", &["xiling", "xílings"][..], &["cèntim", "cèntims"][..]),
494            ("KGS", &["som", "som"][..], &["tyiyn", "tyiyn"][..]),
495            ("KHR", &["riel", "riels"][..], &["cèntim", "cèntims"][..]),
496            ("KMF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
497            ("KWD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
498            ("KYD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
499            ("LAK", &["kip", "kips"][..], &["at", "at"][..]),
500            ("LBP", &["lliura", "lliures"][..], &["piastra", "piastres"][..]),
501            ("LKR", &["rúpia", "rúpies"][..], &["cèntim", "cèntims"][..]),
502            ("LRD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
503            ("LSL", &["loti", "maloti"][..], &["sente", "lisente"][..]),
504            ("LTL", &["lita", "litai"][..], &["cèntim", "cèntims"][..]),
505            ("LYD", &["dinar", "dinars"][..], &["dírham", "dírhams"][..]),
506            ("MAD", &["dírham", "dirhams"][..], &["cèntim", "cèntims"][..]),
507            ("MDL", &["leu", "lei"][..], &["ban", "bani"][..]),
508            ("MGA", &["ariary", "ariary"][..], &["iraimbilanja", "iraimbilanja"][..]),
509            ("MKD", &["denar", "denari"][..], &["deni", "deni"][..]),
510            ("MMK", &["kyat", "kyats"][..], &["pya", "pyas"][..]),
511            ("MNT", &["tögrög", "tögrög"][..], &["möngö", "möngö"][..]),
512            ("MOP", &["pataca", "pataques"][..], &["avo", "avos"][..]),
513            ("MRO", &["ouguiya", "ouguiya"][..], &["khoums", "khoums"][..]),
514            ("MRU", &["ouguiya", "ouguiya"][..], &["khoums", "khoums"][..]),
515            ("MUR", &["rupia", "rúpies"][..], &["cèntim", "cèntims"][..]),
516            ("MVR", &["rufiyaa", "rufiyaa"][..], &["laari", "laari"][..]),
517            ("MWK", &["kwacha", "kwacha"][..], &["tambala", "tambala"][..]),
518            ("MYR", &["ringgit", "ringgits"][..], &["sen", "sens"][..]),
519            ("MZN", &["metical", "meticals"][..], &["centau", "centaus"][..]),
520            ("NAD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
521            ("NGN", &["naira", "naires"][..], &["kobo", "kobos"][..]),
522            ("NPR", &["rupia", "rupies"][..], &["paisa", "paises"][..]),
523            ("NZD", &["dòlar", "dòlars"][..], &["centau", "centaus"][..]),
524            ("OMR", &["rial", "rials"][..], &["baisa", "baisa"][..]),
525            ("PAB", &["dòlar", "dòlars"][..], &["centésimo", "centésimos"][..]),
526            ("PGK", &["kina", "kina"][..], &["toea", "toea"][..]),
527            ("PHP", &["peso", "pesos"][..], &["centau", "centaus"][..]),
528            ("PKR", &["rupia", "rupies"][..], &["paisa", "paise"][..]),
529            ("PLZ", &["zloty", "zlotys"][..], &["grosz", "groszy"][..]),
530            ("PYG", &["guaraní", "guaranís"][..], &["cèntim", "cèntims"][..]),
531            ("QAR", &["rial", "rials"][..], &["dírham", "dírhams"][..]),
532            ("QTQ", &["quetzal", "quetzals"][..], &["centau", "centaus"][..]),
533            ("RSD", &["dinar", "dinars"][..], &["para", "para"][..]),
534            ("RUR", &["ruble", "rubles"][..], &["copec", "copecs"][..]),
535            ("RWF", &["franc", "francs"][..], &["cèntim", "cèntims"][..]),
536            ("SAR", &["riyal", "riyals"][..], &["hàl·lala", "hàl·lalat"][..]),
537            ("SBD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
538            ("SCR", &["rupia", "rupies"][..], &["cèntim", "cèntims"][..]),
539            ("SDG", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
540            ("SGD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
541            ("SHP", &["lliura", "lliures"][..], &["penic", "penics"][..]),
542            ("SLL", &["leonE", "leones"][..], &["cèntim", "cèntims"][..]),
543            ("SRD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
544            ("SSP", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
545            ("STD", &["dobra", "dobrAs"][..], &["cèntim", "cèntims"][..]),
546            ("SVC", &["colón", "colons"][..], &["centau", "centaus"][..]),
547            ("SYP", &["lliura", "lliures"][..], &["piastre", "piastres"][..]),
548            ("SZL", &["lilangeni", "emalangeni"][..], &["cèntim", "cèntims"][..]),
549            ("TJS", &["somoni", "somoni"][..], &["diram", "diram"][..]),
550            ("TMT", &["manat", "manats"][..], &["teňňesi", "teňňesi"][..]),
551            ("TND", &["dinar", "dinars"][..], &["mil·lim", "mil·limat"][..]),
552            ("TOP", &["paanga", "paangas"][..], &["seniti", "seniti"][..]),
553            ("TTD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
554            ("TWD", &["nou dòlar", "nous dòlars"][..], &["fen", "fen"][..]),
555            ("TZS", &["xíling", "xílings"][..], &["cèntim", "cèntims"][..]),
556            ("UGX", &["xíling", "xílings"][..], &["cèntim", "cèntims"][..]),
557            ("UYU", &["peso", "pesos"][..], &["centèsim", "centèsims"][..]),
558            ("UZS", &["som", "som"][..], &["tiyin", "tiyin"][..]),
559            ("VND", &["dong", "dongs"][..], &["xu", "xu"][..]),
560            ("VUV", &["vatu", "vatus"][..], &["cèntim", "cèntims"][..]),
561            ("WST", &["tala", "tala"][..], &["sene", "sene"][..]),
562            ("XAF", &["franc CFA", "francs CFA"][..], &["cèntim", "cèntims"][..]),
563            ("XCD", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
564            ("XOF", &["franc CFA", "francs CFA"][..], &["cèntim", "cèntims"][..]),
565            ("XPF", &["franc CFP", "francs CFP"][..], &["cèntim", "cèntims"][..]),
566            ("YER", &["rial", "rials"][..], &["fils", "fils"][..]),
567            ("YUM", &["dinar", "dinars"][..], &["para", "para"][..]),
568            ("ZMW", &["kwacha", "kwacha"][..], &["ngwee", "ngwee"][..]),
569            ("ZWL", &["dòlar", "dòlars"][..], &["cèntim", "cèntims"][..]),
570        ]
571        .into_iter()
572        .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
573        .collect();
574
575        let currency_adjectives: HashMap<&'static str, &'static str> = [
576            ("AUD", "Australian"),
577            ("BYN", "Belarusian"),
578            ("CAD", "Canadian"),
579            ("EEK", "Estonian"),
580            ("USD", "US"),
581            ("RUB", "Russian"),
582            ("NOK", "Norwegian"),
583            ("MXN", "Mexican"),
584            ("RON", "Romanian"),
585            ("INR", "Indian"),
586            ("HUF", "Hungarian"),
587            ("ISK", "íslenskar"),
588            ("UZS", "Uzbekistan"),
589            ("SAR", "Saudi"),
590            ("JPY", "Japanese"),
591            ("KRW", "Korean"),
592        ]
593        .into_iter()
594        .collect();
595
596        LangCa {
597            cards,
598            maxval,
599            ords_tbl,
600            // self.exclude_title = ["i", "menys", "coma"]
601            exclude_title: vec!["i".into(), "menys".into(), "coma".into()],
602            currency_forms,
603            currency_adjectives,
604        }
605    }
606
607    /// `self.ords[key]` — missing keys raise `KeyError`, which several inputs
608    /// genuinely hit (`dec == 10**18`; see module-doc bugs 4 and 6).
609    fn ords(&self, key: &BigInt) -> Result<&'static str> {
610        key.to_u128()
611            .and_then(|k| self.ords_tbl.get(&k).copied())
612            .ok_or_else(|| N2WError::Key(format!("{}", key)))
613    }
614
615    /// `self.ords_3[key]` — keys are 1..=9 only. **There is no key 0**, so
616    /// every round ten 30..90 raises `KeyError` (module-doc bug 1).
617    fn ords_3(&self, key: &BigInt) -> Result<&'static str> {
618        let word = match key.to_u32() {
619            Some(1) => "unè",
620            Some(2) => "dosè",
621            Some(3) => "tresè",
622            Some(4) => "quatrè",
623            Some(5) => "cinquè",
624            Some(6) => "sisè",
625            Some(7) => "setè",
626            Some(8) => "vuitè",
627            Some(9) => "novè",
628            _ => return Err(N2WError::Key(format!("{}", key))),
629        };
630        Ok(word)
631    }
632
633    /// `Num2Word_Base.verify_ordinal`. The float check (`value == int(value)`)
634    /// is vacuous for integer input; only the negative check can fire.
635    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
636        if value.is_negative() {
637            return Err(N2WError::Type(format!(
638                "El número negatiu {} no pot ser tractat com un ordinal.",
639                value
640            )));
641        }
642        Ok(())
643    }
644
645    /// `Num2Word_Base.verify_ordinal` over the float/Decimal entry, plus the
646    /// truncating `int(value)` the two checks perform first: NaN raises
647    /// ValueError and ±inf OverflowError inside the first comparison;
648    /// fractional values get CA's own `errmsg_floatord`, negative whole ones
649    /// its `errmsg_negord` (both TypeError). `-0.0` passes both checks and
650    /// ordinalises as zero → "" (bug 5). Returns the whole value as the
651    /// integer the ordinal path then renders.
652    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
653        if let FloatValue::Float { value: f, .. } = value {
654            if f.is_nan() {
655                return Err(N2WError::Value(
656                    "cannot convert float NaN to integer".into(),
657                ));
658            }
659            if f.is_infinite() {
660                return Err(N2WError::Overflow(
661                    "cannot convert float infinity to integer".into(),
662                ));
663            }
664        }
665        match value.as_whole_int() {
666            None => Err(N2WError::Type(
667                "El float no pot ser tractat com un ordinal.".into(),
668            )),
669            Some(i) if i.is_negative() => Err(N2WError::Type(format!(
670                "El número negatiu {} no pot ser tractat com un ordinal.",
671                i
672            ))),
673            Some(i) => Ok(i),
674        }
675    }
676
677    fn to_ordinal_inner(&self, value: &BigInt, depth: u32) -> Result<String> {
678        self.verify_ordinal(value)?;
679        if depth > RECURSION_LIMIT {
680            // Stands in for CPython's RecursionError; see module docs.
681            return Err(N2WError::Value(format!(
682                "RecursionError: maximum recursion depth exceeded in to_ordinal({})",
683                value
684            )));
685        }
686
687        let five = BigInt::from(5);
688        let ten = BigInt::from(10);
689        let twenty = BigInt::from(20);
690        let thirty = BigInt::from(30);
691        let hundred = BigInt::from(100);
692        let two_hundred = BigInt::from(200);
693        let thousand = pow10(3);
694
695        let text: String = if value.is_zero() {
696            // to_ordinal(0) == "" (module-doc bug 5).
697            String::new()
698        } else if value < &five {
699            self.ords(value)?.to_string()
700        } else if value <= &twenty {
701            format!("{}{}", self.ords(value)?, GENDER_STEM)
702        } else if value <= &thirty {
703            let frac = value % &ten;
704            // PR savoirfairelinux/num2words#670: an exact ten ("trentè") has
705            // no units part; the old `ords_3[0]` lookup raised KeyError.
706            if frac.is_zero() {
707                format!("{}{}", self.ords(value)?, GENDER_STEM)
708            } else {
709                format!(
710                    "{}{}{}",
711                    self.ords(&twenty)?,
712                    "-i-",
713                    self.ords_3(&frac)?
714                )
715            }
716        } else if value < &hundred {
717            let dec = (value / &ten) * &ten;
718            let frac = value - &dec;
719            // PR savoirfairelinux/num2words#670: an exact ten ("quarantè")
720            // has no units part; the old `ords_3[0]` lookup raised KeyError.
721            if frac.is_zero() {
722                format!("{}{}", self.ords(&dec)?, GENDER_STEM)
723            } else {
724                format!(
725                    "{}{}{}{}",
726                    self.ords(&dec)?,
727                    "a",
728                    "-",
729                    self.ords_3(&frac)?
730                )
731            }
732        } else if value == &hundred {
733            format!("{}{}", self.ords(value)?, GENDER_STEM)
734        } else if value < &two_hundred {
735            let cen = (value / &hundred) * &hundred;
736            format!(
737                "{} {}",
738                self.ords(&cen)?,
739                self.to_ordinal_inner(&(value - &cen), depth + 1)?
740            )
741        } else if value < &thousand {
742            let cen = (value / &hundred) * &hundred;
743            format!(
744                "{}{} {}",
745                self.ords(&cen)?,
746                "s",
747                self.to_ordinal_inner(&(value - &cen), depth + 1)?
748            )
749        } else if value == &thousand {
750            format!("{}{}", self.ords(value)?, GENDER_STEM)
751        } else if value < &pow10(6) {
752            // dec is always 1000 here: the ratio lies in (1, 2) with no float
753            // window wide enough to reach 2.0.
754            let dec = BigInt::from(1000u16).pow(log1000_trunc(value));
755            let (high_part, low_part) = value.div_mod_floor(&dec);
756            let cardinal = if high_part.is_one() {
757                String::new()
758            } else {
759                self.to_cardinal(&high_part)?
760            };
761            // Note the spaces — unlike the < 1e18 arm below.
762            format!(
763                "{} {} {}",
764                cardinal,
765                self.ords(&dec)?,
766                self.to_ordinal_inner(&low_part, depth + 1)?
767            )
768        } else if value < &pow10(18) {
769            let dec = BigInt::from(1000u16).pow(log1000_trunc(value));
770            let (high_part, low_part) = value.div_mod_floor(&dec);
771            let cardinal = if high_part.is_one() {
772                String::new()
773            } else {
774                self.to_cardinal(&high_part)?
775            };
776            // Python builds the %-tuple left to right, so `self.ords[dec]`
777            // raises KeyError *before* the recursive `to_ordinal(low_part)`
778            // is evaluated. Binding it first preserves that ordering — it is
779            // what makes to_ordinal(10**18 - 1) a KeyError rather than a
780            // RecursionError (module-doc bug 4).
781            let o = self.ords(&dec)?;
782            // No separator between `cardinal` and `o` (module-doc bug 2).
783            format!(
784                "{}{}{} {}",
785                cardinal,
786                o,
787                GENDER_STEM,
788                self.to_ordinal_inner(&low_part, depth + 1)?
789            )
790        } else {
791            let part1 = self.to_cardinal(value)?;
792            // part1[:-1] is CHARACTER slicing: "un trilió" -> "un trili"
793            // ("ó" is two bytes, so a byte slice would split it).
794            let chars: Vec<char> = part1.chars().collect();
795            let keep = chars.len().saturating_sub(1);
796            format!(
797                "{}{}",
798                chars[..keep].iter().collect::<String>(),
799                "onè"
800            )
801        };
802
803        Ok(text.trim().to_string())
804    }
805}
806
807impl Lang for LangCa {
808    /// This language's own `to_currency(currency=...)` default,
809    /// read from the live Python signature. Only 44 of 156 use EUR.
810    fn default_currency(&self) -> &str {
811        "EUR"
812    }
813
814    /// This language's own `to_currency(separator=...)` default,
815    /// read from the live Python signature. Base's is ",", but only
816    /// 36 of 149 languages actually use it — most default to " " or a
817    /// conjunction, so inheriting Base's comma silently corrupts them.
818    fn default_separator(&self) -> &str {
819        " amb"
820    }
821
822    fn cards(&self) -> &Cards {
823        &self.cards
824    }
825    fn maxval(&self) -> &BigInt {
826        &self.maxval
827    }
828    fn negword(&self) -> &str {
829        NEGWORD
830    }
831    fn pointword(&self) -> &str {
832        "coma"
833    }
834    fn exclude_title(&self) -> &[String] {
835        &self.exclude_title
836    }
837
838    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
839        let (ltext, cnum) = l;
840        let (rtext, nnum) = r;
841        let mut ctext = ltext.to_string();
842        let mut ntext = rtext.to_string();
843
844        let hundred = BigInt::from(100);
845        let million = BigInt::from(1_000_000);
846
847        if cnum.is_one() {
848            if nnum < &million {
849                // Python `return next` — the unmodified right tuple.
850                return (ntext, nnum.clone());
851            }
852            ctext = "un".to_string();
853        }
854
855        if nnum < cnum {
856            // Python spells out four arms here (cnum < 100 / nnum == 1 /
857            // cnum == 100 / else) but only the first behaves differently:
858            // it joins with "-", the other three are identical " " joins.
859            if cnum < &hundred {
860                return (format!("{}-{}", ctext, ntext), cnum + nnum);
861            }
862            return (format!("{} {}", ctext, ntext), cnum + nnum);
863        } else if (nnum % &million).is_zero() && cnum > &BigInt::one() {
864            // ntext[:-3] + "lions" — CHARACTER slicing. "milió" is 5 chars but
865            // 6 bytes, so a byte slice would yield "mil" and produce
866            // "millions". Correct result: "mi" + "lions" == "milions".
867            let chars: Vec<char> = ntext.chars().collect();
868            let keep = chars.len().saturating_sub(3);
869            ntext = chars[..keep].iter().collect::<String>() + "lions";
870        }
871
872        if nnum == &hundred {
873            ntext.push('s');
874            ctext.push('-');
875        } else {
876            ntext = format!(" {}", ntext);
877        }
878        (format!("{}{}", ctext, ntext), cnum * nnum)
879    }
880
881    /// `Num2Word_Base.to_cardinal` for integral input.
882    ///
883    /// Reimplemented rather than delegating to `default_to_cardinal` purely so
884    /// the OverflowError carries CA's `errmsg_toobig`
885    /// ("abs(%s) ha de ser inferior a %s.") instead of the base English text.
886    /// The algorithm is identical.
887    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
888        let mut out = String::new();
889        let mut v = value.clone();
890        if v.is_negative() {
891            v = v.abs();
892            out = format!("{} ", self.negword().trim());
893        }
894
895        if &v >= self.maxval() {
896            return Err(N2WError::Overflow(format!(
897                "abs({}) ha de ser inferior a {}.",
898                v,
899                self.maxval()
900            )));
901        }
902
903        let tree = splitnum(self, &v).ok_or_else(|| {
904            N2WError::Overflow(format!(
905                "abs({}) ha de ser inferior a {}.",
906                v,
907                self.maxval()
908            ))
909        })?;
910        let words = match clean(self, tree) {
911            Node::Leaf(t, _) => t,
912            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
913        };
914        Ok(self.title(&format!("{}{}", out, words)))
915    }
916
917    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
918        self.to_ordinal_inner(value, 0)
919    }
920
921    /// `to_ordinal(float/Decimal)`: CA's `to_ordinal` opens with
922    /// `verify_ordinal(value)`, so a fractional or negative value raises
923    /// TypeError; a whole non-negative one then runs the same arithmetic the
924    /// integer path does (dict lookups hash `5.0` like `5`, `//`/`%` agree on
925    /// whole floats), so the entry delegates to the integer ordinal —
926    /// `5.0` → "cinquè", `0.0` → "" (bug 5), `1e20` → "cent triliononè".
927    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
928        let i = self.verify_ordinal_float(value)?;
929        self.to_ordinal(&i)
930    }
931
932    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)`, then the
933    /// `ords_2` membership test — numeric, so `1.0`..`4.0` hit "1r".."4t" —
934    /// and otherwise `"%s%s" % (value, "è")` with the repr verbatim:
935    /// `5.0` → "5.0è", `Decimal("5.00")` → "5.00è", `-0.0` → "-0.0è";
936    /// `0.5`/`-1.0` raise TypeError from the verify.
937    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
938        let i = self.verify_ordinal_float(value)?;
939        match i.to_u32() {
940            Some(1) => Ok("1r".to_string()),
941            Some(2) => Ok("2n".to_string()),
942            Some(3) => Ok("3r".to_string()),
943            Some(4) => Ok("4t".to_string()),
944            _ => Ok(format!("{}{}", repr_str, GENDER_STEM)),
945        }
946    }
947
948    /// `Num2Word_CA.to_cardinal_float` — **not** `Num2Word_Base`'s.
949    ///
950    /// ```python
951    /// def to_cardinal_float(self, value):
952    ///     try:
953    ///         float(value) == value
954    ///     except (ValueError, TypeError):
955    ///         raise TypeError(self.errmsg_nonnum % value)
956    ///     pre, post = self.float2tuple(float(value))
957    ///     post_str = str(post)
958    ///     leading_zeros = self.precision - len(post_str)
959    ///     post_str = "0" * leading_zeros + post_str
960    ///     out = [self.to_cardinal(pre)]
961    ///     if value < 0 and pre == 0:
962    ///         out = [self.negword.strip()] + out
963    ///     out.append(self.title(self.pointword))
964    ///     for _ in range(leading_zeros):
965    ///         out.append(self.to_cardinal(0))
966    ///     if int(post_str) > 0 or leading_zeros == 0:
967    ///         out.append(self.to_cardinal(int(post_str)))
968    ///     return " ".join(out)
969    /// ```
970    ///
971    /// Catalan reads the fraction as **one integer**, not digit by digit:
972    /// `2.675` is "dos coma sis-cents setanta-cinc" where the shared base path
973    /// would say "dos coma sis set cinc". Leading zeros are spelled out one by
974    /// one *first*, so `0.01` is "zero coma zero un" — one "zero" from the
975    /// padding loop, then `int("01") == 1` as "un".
976    ///
977    /// Four deliberate departures from `Num2Word_Base.to_cardinal_float`, all
978    /// observable:
979    ///
980    /// 1. **`float2tuple(float(value))` — the cast is unconditional**, so
981    ///    Decimal input never reaches float2tuple's exact arm and issue #603's
982    ///    fix is silently undone. `Decimal("98746251323029.99")` collapses to
983    ///    the double `98746251323029.98`, and the corpus row really does end
984    ///    "coma noranta-vuit" rather than "…noranta-nou". `Decimal("1.10")`
985    ///    likewise loses its trailing zero to `repr(1.1)` and yields "un coma
986    ///    un", where base-path languages say "een komma een nul". This is why
987    ///    the `FloatValue::Decimal` arm below re-derives precision from the
988    ///    f64 instead of trusting the carried `abs(exponent)`.
989    /// 2. **`pointword` is appended unconditionally**, where base guards it
990    ///    with `if self.precision:`. Reachable: at precision 0 — e.g.
991    ///    `1.2345678901234568e16`, 17 significant digits with `decpt == 17` —
992    ///    `leading_zeros` goes to -1 and the final guard is False too, so
993    ///    Python emits the integer, then a bare trailing "coma", and nothing
994    ///    else. `en` prints no "point" at all for the same input.
995    /// 3. **`precision=` is not a parameter here**, so `precision_override` is
996    ///    ignored. That is not this port cutting a corner: `num2words`
997    ///    implements the kwarg by assigning `converter.precision`, and
998    ///    `float2tuple` — which runs first — unconditionally overwrites that
999    ///    attribute with the repr-derived value before `leading_zeros` reads
1000    ///    it. `num2words(2.675, lang="ca", precision=1)` is byte-identical to
1001    ///    the plain call; so is the `en` equivalent. Verified in the
1002    ///    interpreter for overrides 1, 3 and 5.
1003    /// 4. **The final `to_cardinal` is skipped when the fraction is all
1004    ///    zeros** (`int(post_str) > 0 or leading_zeros == 0`), which is what
1005    ///    makes `1e16` "deu mil bilions coma" + fifteen "zero" with no
1006    ///    sixteenth word. When `leading_zeros == 0` the guard passes anyway, so
1007    ///    a lone zero still prints: `to_cardinal_float(5.0)` is "cinc coma
1008    ///    zero".
1009    ///
1010    /// Python's `except (ValueError, TypeError)` guard around `float(value)`
1011    /// has no analogue — `FloatValue` cannot hold a non-numeric — so
1012    /// `errmsg_nonnum` is unreachable from Rust.
1013    fn to_cardinal_float(
1014        &self,
1015        value: &FloatValue,
1016        _precision_override: Option<u32>,
1017    ) -> Result<String> {
1018        // `float(value)` — for BOTH arms; see departure 1 above.
1019        let f = match value {
1020            FloatValue::Float { value, .. } => *value,
1021            FloatValue::Decimal { value, .. } => decimal_to_f64(value),
1022        };
1023
1024        // float2tuple opens with `pre = int(value)`, so a non-finite double
1025        // raises there, before the precision is ever computed.
1026        if f.is_nan() {
1027            return Err(N2WError::Value("cannot convert float NaN to integer".into()));
1028        }
1029        if f.is_infinite() {
1030            return Err(N2WError::Overflow(
1031                "cannot convert float infinity to integer".into(),
1032            ));
1033        }
1034
1035        let precision = match value {
1036            // Already `abs(Decimal(str(f)).as_tuple().exponent)` — the Python
1037            // shim derived it from repr of this very double, which is exactly
1038            // what float2tuple would recompute.
1039            FloatValue::Float { precision, .. } => *precision,
1040            // Carried precision is the *Decimal's* scale; the float cast has
1041            // its own repr and hence its own precision (2 for the .99 row
1042            // above, 1 for Decimal("1.10")).
1043            FloatValue::Decimal { .. } => float_repr_precision(f),
1044        };
1045
1046        // The f64 artefacts are load-bearing, so this reuses floatpath's
1047        // float2tuple rather than re-deriving from the decimal string: 2.675
1048        // really does give 674.9999999999998, rescued to 675 by the `< 0.01`
1049        // heuristic, and 1.005 gives 4.999999999999893, rescued to 5. Both
1050        // would come out one lower from an exact recomputation.
1051        //
1052        // (float2tuple rounds with `round_ties_even`, matching Python. Worth
1053        // noting the choice is provably inert *here*: the mode can only differ
1054        // on an exact .5 tie, and a tie is 0.5 away from its integer, so the
1055        // `< 0.01` guard rejects it and both modes fall through to the same
1056        // `floor`. The corpus cannot tell them apart either.)
1057        let (pre, post) = float2tuple(&FloatValue::Float { value: f, precision });
1058
1059        let post_str = post.to_string();
1060        // Python lets this go negative; "0" * -1 == "" and range(-1) is empty,
1061        // so a negative count is a no-op rather than an error. It then feeds
1062        // the `leading_zeros == 0` guard below, where the sign matters.
1063        let leading_zeros = precision as i64 - post_str.chars().count() as i64;
1064        let post_str = if leading_zeros > 0 {
1065            format!("{}{}", "0".repeat(leading_zeros as usize), post_str)
1066        } else {
1067            post_str
1068        };
1069
1070        let mut out = vec![self.to_cardinal(&pre)?];
1071        // `value < 0` tests the ORIGINAL, not the cast — but int(-0.5) == 0
1072        // carries no minus either way, so the sign has to be re-attached here.
1073        if value.is_negative() && pre.is_zero() {
1074            out.insert(0, self.negword().trim().to_string());
1075        }
1076
1077        // Unconditional — departure 2.
1078        out.push(self.title(self.pointword()));
1079
1080        for _ in 0..leading_zeros.max(0) {
1081            out.push(self.to_cardinal(&BigInt::zero())?);
1082        }
1083
1084        // `int(post_str)` drops the padding again: "01" -> 1 -> "un".
1085        let post_val = BigInt::from_str(&post_str)
1086            .map_err(|e| N2WError::Value(e.to_string()))?;
1087        if post_val.is_positive() || leading_zeros == 0 {
1088            out.push(self.to_cardinal(&post_val)?);
1089        }
1090
1091        Ok(out.join(" "))
1092    }
1093
1094    /// The fractional-cents entry point: Python's
1095    /// `cents_str = self.to_cardinal(float(right))` inside
1096    /// `Num2Word_Base.to_currency`.
1097    ///
1098    /// Overridden because `base.rs`'s default routes to
1099    /// `floatpath::cardinal_from_bigdecimal`, which calls
1100    /// `default_to_cardinal_float` directly and so would bypass CA's override.
1101    /// Python goes through `self.to_cardinal`, which dispatches back to
1102    /// `Num2Word_CA.to_cardinal_float` for a non-integral float — and the two
1103    /// spell the fraction differently: `to_currency(1.0125, "USD")` is "un
1104    /// dòlar amb un coma **vint-i-cinc** centaus", not the base path's "un coma
1105    /// dos cinc". Verified against the interpreter.
1106    ///
1107    /// The `Float` variant is built explicitly to mirror Python casting with
1108    /// `float(right)` *before* `to_cardinal` sees the value.
1109    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1110        let f = decimal_to_f64(value);
1111        self.to_cardinal_float(
1112            &FloatValue::Float {
1113                value: f,
1114                precision: float_repr_precision(f),
1115            },
1116            None,
1117        )
1118    }
1119
1120    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1121        self.verify_ordinal(value)?;
1122        // ords_2 = {1: "1r", 2: "2n", 3: "3r", 4: "4t"}; everything else gets
1123        // the digits plus "è" (gender_stem is always "è", so the conditional
1124        // `"è" if self.gender_stem == "è" else "a"` never picks "a").
1125        match value.to_u32() {
1126            Some(1) => Ok("1r".to_string()),
1127            Some(2) => Ok("2n".to_string()),
1128            Some(3) => Ok("3r".to_string()),
1129            Some(4) => Ok("4t".to_string()),
1130            _ => Ok(format!("{}{}", value, GENDER_STEM)),
1131        }
1132    }
1133
1134    /// `Num2Word_Base.to_year` — CA does not override it, so a year is just a
1135    /// cardinal (`to_year(-500)` == "menys cinc-cents").
1136    fn to_year(&self, value: &BigInt) -> Result<String> {
1137        self.to_cardinal(value)
1138    }
1139
1140    // ---- currency -----------------------------------------------------
1141    //
1142    // Not overridden, because CA inherits them unchanged and the base.rs
1143    // defaults already reproduce `Num2Word_Base`:
1144    //
1145    //   * `currency_precision` — `CURRENCY_PRECISION` is `{}` all the way up
1146    //     the chain (CA and EUR never define it), so
1147    //     `CURRENCY_PRECISION.get(code, 100)` is **always 100**. That is not a
1148    //     typo: it makes JPY and KWD ordinary 2-decimal currencies here, so
1149    //     `to_currency(12.34, "JPY")` == "dotze iens amb trenta-quatre sen"
1150    //     and `to_cheque(1234.56, "KWD")` ends "56/100 DINARS", not "560/1000".
1151    //     The divisor-1 shortcut in `default_to_currency` is therefore dead
1152    //     code for CA, and 0-decimal JPY never happens.
1153    //   * `money_verbose` / `cents_verbose` — both are `to_cardinal`.
1154    //   * `cents_terse` — `"%0*d" % (2, n)`; `default_cents_terse` at
1155    //     divisor 100 gives the same ("dotze euros amb 34 cèntims").
1156    //   * `to_cheque` — CA does not override `Num2Word_Base.to_cheque`, and
1157    //     `default_to_cheque` is a faithful port of it.
1158
1159    fn lang_name(&self) -> &str {
1160        "Num2Word_CA"
1161    }
1162
1163    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1164        self.currency_forms.get(code)
1165    }
1166
1167    fn currency_adjective(&self, code: &str) -> Option<&str> {
1168        self.currency_adjectives.get(code).copied()
1169    }
1170
1171    /// `Num2Word_EUR.pluralize` — inherited; CA does not define its own.
1172    ///
1173    /// ```python
1174    /// def pluralize(self, n, forms):
1175    ///     form = 0 if n == 1 else 1
1176    ///     return forms[form]
1177    /// ```
1178    ///
1179    /// Note `n == 1`, not `abs(n) == 1`: the sign is not folded away. Every CA
1180    /// entry has two forms, so `forms[1]` is always in range and the IndexError
1181    /// arm below is unreachable in practice — it exists so that a one-form
1182    /// entry would crash the way Python's list indexing does rather than
1183    /// silently falling back to the singular.
1184    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1185        let form = if n.is_one() { 0 } else { 1 };
1186        forms
1187            .get(form)
1188            .cloned()
1189            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1190    }
1191
1192    /// `Num2Word_CA.to_currency`.
1193    ///
1194    /// CA intercepts **integers only** and hands every float straight back to
1195    /// `Num2Word_Base.to_currency` via `super()`. The two paths are not
1196    /// consistent with each other, and the differences are observable:
1197    ///
1198    /// 1. **Negative ints render a double space.** The int arm builds
1199    ///    `("%s %s %s" % (minus_str, money_str, currency_str)).strip()` with
1200    ///    `minus_str = self.negword` — and `negword` is `"menys "`, *already*
1201    ///    carrying a trailing space, which the format string's own space then
1202    ///    doubles. `.strip()` only trims the ends, so the pair survives in the
1203    ///    middle: `to_currency(-5, "USD")` == "menys␣␣cinc dòlars". The float
1204    ///    arm uses base's `"%s " % self.negword.strip()` and gets it right:
1205    ///    `to_currency(-12.34, "EUR")` == "menys␣dotze euros amb …".
1206    ///    Both verified against the interpreter.
1207    /// 2. **`adjective=True` is silently ignored for ints.** The int arm never
1208    ///    consults `CURRENCY_ADJECTIVES`, so `to_currency(100, "USD",
1209    ///    adjective=True)` == "cent dòlars" while the float `100.0` gives
1210    ///    "cent US dòlars amb zero centaus".
1211    /// 3. The int arm reimplements `pluralize`'s rule inline
1212    ///    (`cr1[0] if abs_val == 1 else cr1[1]`) but against `abs_val`, where
1213    ///    base passes `abs(val_int)` to `pluralize` — same answer either way.
1214    ///
1215    /// The `except (KeyError, AttributeError)` fallback re-enters base for an
1216    /// unknown code, which then raises NotImplementedError with CA's class
1217    /// name. `AttributeError` is unreachable (`CURRENCY_FORMS` always exists),
1218    /// so only the missing-key path is modelled.
1219    fn to_currency(
1220        &self,
1221        val: &CurrencyValue,
1222        currency: &str,
1223        cents: bool,
1224        separator: Option<&str>,
1225        adjective: bool,
1226    ) -> Result<String> {
1227        // Trait now hands us None when the caller omitted separator=;
1228        // resolve it to this language's own default before the ported body.
1229        let separator = separator.unwrap_or(self.default_separator());
1230        // Restore CA's own `separator=" amb"` default; see SEPARATOR_UNSET.
1231        let separator = if separator == SEPARATOR_UNSET {
1232            SEPARATOR_DEFAULT
1233        } else {
1234            separator
1235        };
1236
1237        if let CurrencyValue::Int(v) = val {
1238            let forms = match self.currency_forms.get(currency) {
1239                Some(f) => f,
1240                // KeyError -> super().to_currency(...), which raises
1241                // NotImplementedError from its own CURRENCY_FORMS lookup.
1242                None => {
1243                    return default_to_currency(self, val, currency, cents, separator, adjective)
1244                }
1245            };
1246            let cr1 = &forms.unit;
1247
1248            let minus_str = if v.is_negative() { NEGWORD } else { "" };
1249            let abs_val = v.abs();
1250            let money_str = self.to_cardinal(&abs_val)?;
1251
1252            let currency_str = if abs_val.is_one() {
1253                &cr1[0]
1254            } else if cr1.len() > 1 {
1255                &cr1[1]
1256            } else {
1257                &cr1[0]
1258            };
1259
1260            // Python: ("%s %s %s" % (...)).strip(). The interior double space
1261            // on negatives is bug 1 above — trim() must not collapse it.
1262            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1263                .trim()
1264                .to_string());
1265        }
1266
1267        // Floats/Decimals: `super(Num2Word_CA, self).to_currency(...)`.
1268        default_to_currency(self, val, currency, cents, separator, adjective)
1269    }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    /// `precision` is what the Python shim derives from `repr(value)`.
1277    fn f(lang: &LangCa, value: f64, precision: u32) -> String {
1278        lang.to_cardinal_float(&FloatValue::Float { value, precision }, None)
1279            .unwrap()
1280    }
1281
1282    fn d(lang: &LangCa, lit: &str, precision: u32) -> String {
1283        lang.to_cardinal_float(
1284            &FloatValue::Decimal {
1285                value: BigDecimal::from_str(lit).unwrap(),
1286                precision,
1287            },
1288            None,
1289        )
1290        .unwrap()
1291    }
1292
1293    /// The float `to: "cardinal"` rows of the frozen `ca` corpus.
1294    #[test]
1295    fn corpus_float_rows() {
1296        let l = LangCa::new();
1297        assert_eq!(f(&l, 0.5, 1), "zero coma cinc");
1298        assert_eq!(f(&l, 1.5, 1), "un coma cinc");
1299        assert_eq!(f(&l, 2.25, 2), "dos coma vint-i-cinc");
1300        assert_eq!(f(&l, 3.14, 2), "tres coma catorze");
1301        assert_eq!(f(&l, 0.01, 2), "zero coma zero un");
1302        assert_eq!(f(&l, 0.1, 1), "zero coma un");
1303        assert_eq!(f(&l, 0.99, 2), "zero coma noranta-nou");
1304        assert_eq!(f(&l, 1.01, 2), "un coma zero un");
1305        assert_eq!(f(&l, 12.34, 2), "dotze coma trenta-quatre");
1306        assert_eq!(f(&l, 99.99, 2), "noranta-nou coma noranta-nou");
1307        assert_eq!(f(&l, 100.5, 1), "cent coma cinc");
1308        assert_eq!(
1309            f(&l, 1234.56, 2),
1310            "mil dos-cents trenta-quatre coma cinquanta-sis"
1311        );
1312        // int(-0.5) == 0 carries no minus, so negword is re-attached.
1313        assert_eq!(f(&l, -0.5, 1), "menys zero coma cinc");
1314        assert_eq!(f(&l, -1.5, 1), "menys un coma cinc");
1315        assert_eq!(f(&l, -12.34, 2), "menys dotze coma trenta-quatre");
1316        // The f64 artefacts: 1.005 floors, 2.675 gives 674.9999999999998 and
1317        // is rescued to 675 by float2tuple's `< 0.01` heuristic.
1318        assert_eq!(f(&l, 1.005, 3), "un coma zero zero cinc");
1319        assert_eq!(f(&l, 2.675, 3), "dos coma sis-cents setanta-cinc");
1320    }
1321
1322    /// The `to: "cardinal_dec"` rows — Decimal input, which CA float-casts.
1323    #[test]
1324    fn corpus_decimal_rows() {
1325        let l = LangCa::new();
1326        assert_eq!(d(&l, "0.01", 2), "zero coma zero un");
1327        // precision 1, not the Decimal's own 2: repr(float(Decimal("1.10")))
1328        // is "1.1". A base-path language says "un coma un zero".
1329        assert_eq!(d(&l, "1.10", 2), "un coma un");
1330        assert_eq!(d(&l, "12.345", 3), "dotze coma tres-cents quaranta-cinc");
1331        assert_eq!(d(&l, "0.001", 3), "zero coma zero zero un");
1332        // Issue #603 undone by CA's unconditional float cast: the exact
1333        // Decimal ends .99, but the double it collapses to ends .98.
1334        assert_eq!(
1335            d(&l, "98746251323029.99", 2),
1336            "noranta-vuit bilions set-cents quaranta-sis mil dos-cents \
1337             cinquanta-un milions tres-cents vint-i-tres mil vint-i-nou \
1338             coma noranta-vuit"
1339        );
1340    }
1341
1342    /// The quirks that separate CA's override from `Num2Word_Base`'s.
1343    #[test]
1344    fn ca_specific_quirks() {
1345        let l = LangCa::new();
1346        // An integral float reaches here only on a direct call; precision is 1
1347        // because repr(5.0) == "5.0", and `leading_zeros == 0` lets the lone
1348        // zero through.
1349        assert_eq!(f(&l, 5.0, 1), "cinc coma zero");
1350        assert_eq!(f(&l, 0.0, 1), "zero coma zero");
1351        assert_eq!(f(&l, 100.0, 1), "cent coma zero");
1352        // repr(1e16) == "1e+16" -> precision 16; the fraction is all zeros so
1353        // the final to_cardinal is skipped: fifteen "zero" and no sixteenth.
1354        assert_eq!(
1355            f(&l, 1e16, 16),
1356            "deu mil bilions coma zero zero zero zero zero zero zero zero \
1357             zero zero zero zero zero zero zero"
1358        );
1359        // precision 0 (17 significant digits, decpt 17): a bare trailing
1360        // "coma" with nothing after it. `en` prints no "point" at all.
1361        assert_eq!(
1362            f(&l, 1.2345678901234568e16, 0),
1363            "dotze mil tres-cents quaranta-cinc bilions sis-cents setanta-vuit \
1364             mil nou-cents un milions dos-cents trenta-quatre mil cinc-cents \
1365             seixanta-vuit coma"
1366        );
1367        // precision= is inert: float2tuple overwrites self.precision first.
1368        let v = FloatValue::Float { value: 2.675, precision: 3 };
1369        for p in [None, Some(1), Some(3), Some(5)] {
1370            assert_eq!(
1371                l.to_cardinal_float(&v, p).unwrap(),
1372                "dos coma sis-cents setanta-cinc"
1373            );
1374        }
1375    }
1376
1377    /// `float_repr_precision` reproduces `abs(Decimal(repr(f)).as_tuple()
1378    /// .exponent)`, including the ".0" and scientific-notation arms.
1379    #[test]
1380    fn repr_precision_matches_python() {
1381        for (v, want) in [
1382            (0.5, 1u32),
1383            (2.675, 3),
1384            (12.34, 2),
1385            (0.0, 1),
1386            (1.0, 1),
1387            (5.0, 1),
1388            (100.0, 1),
1389            (-1.5, 1),
1390            (1e15, 1),
1391            (1e16, 16),
1392            (1e17, 17),
1393            (1e21, 21),
1394            (1e-4, 4),
1395            (1e-5, 5),
1396            (1e-20, 20),
1397            (1.5e-25, 26),
1398            (1.2345e-7, 11),
1399            (98746251323029.98, 2),
1400            (1.2345678901234568e16, 0),
1401            (9.876543210987654e16, 1),
1402        ] {
1403            assert_eq!(float_repr_precision(v), want, "precision of {:e}", v);
1404        }
1405    }
1406
1407    /// The fractional-cents hook must reach CA's override, not base's.
1408    /// `to_currency(1.0125, "USD")` is "…un coma vint-i-cinc centaus"; routing
1409    /// through `floatpath` instead would say "…un coma dos cinc centaus".
1410    #[test]
1411    fn fractional_cents_uses_ca_float_path() {
1412        let l = LangCa::new();
1413        assert_eq!(
1414            l.cardinal_from_decimal(&BigDecimal::from_str("1.2500").unwrap())
1415                .unwrap(),
1416            "un coma vint-i-cinc"
1417        );
1418        let v = CurrencyValue::parse("1.0125", false, true, true).unwrap();
1419        assert_eq!(
1420            l.to_currency(&v, "USD", true, None, false).unwrap(),
1421            "un dòlar amb un coma vint-i-cinc centaus"
1422        );
1423    }
1424
1425    /// `int(value)` raises before anything else for a non-finite double.
1426    #[test]
1427    fn non_finite_raises_like_python() {
1428        let l = LangCa::new();
1429        assert!(matches!(
1430            l.to_cardinal_float(&FloatValue::Float { value: f64::NAN, precision: 1 }, None),
1431            Err(N2WError::Value(_))
1432        ));
1433        assert!(matches!(
1434            l.to_cardinal_float(
1435                &FloatValue::Float { value: f64::INFINITY, precision: 1 },
1436                None
1437            ),
1438            Err(N2WError::Overflow(_))
1439        ));
1440    }
1441}