Skip to main content

num2words2_core/
lang_ku.rs

1//! Port of `lang_KU.py` (Kurdish / Kurmanji).
2//!
3//! Shape: **self-contained**. `Num2Word_KU` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` guard in `Num2Word_Base.__init__` never fires: Python never builds
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives a hand-written `_int_to_word` recursion. Consequently
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
9//! **no overflow check at all** — see bug 1 below for what happens instead.
10//!
11//! Inherited from `Num2Word_Base` but overridden by KU (so the trait defaults
12//! are *not* used):
13//!   * `to_ordinal`     — KU's own; notably does **not** call `verify_ordinal`,
14//!     which is why negative ordinals succeed instead of raising (bug 4).
15//!   * `to_ordinal_num` — KU's own: `str(number) + "em"`.
16//!   * `to_year`        — KU's own: ignores `longval` and the base class's
17//!     BC/AD machinery, delegating straight to `to_cardinal`.
18//!
19//! `setup()` also assigns `self.exclude_title = ["û", "xal", "negatîv"]`, but
20//! `Num2Word_Base.__init__` leaves `is_title = False` and KU's `to_cardinal`
21//! never calls `self.title()`. The list is therefore dead — `is_title()` and
22//! `exclude_title()` are left at their trait defaults deliberately.
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! This is a port, not a rewrite. Every item below looks wrong and is exactly
27//! what Python emits; each is pinned by a row in the frozen corpus.
28//!
29//! 1. **`_int_to_word` gives up at 10^9 and returns bare digits.** The final
30//!    `return str(number)` has no word forms behind it, so `to_cardinal(10**9)`
31//!    == `"1000000000"` — the *numeral*, not words. There is no "milyar"
32//!    branch. This is the de facto ceiling, and it degrades silently rather
33//!    than raising `OverflowError`. Hence `to_ordinal(10**9)` ==
34//!    `"1000000000em"`, identical to `to_ordinal_num(10**9)`.
35//! 2. **Asymmetric "yek" suppression.** The hundreds branch suppresses the
36//!    multiplier for exactly one hundred (`if h > 1`), so 100 == `"sed"`, but
37//!    the thousand and million branches recurse unconditionally, so 1000 ==
38//!    `"yek hezar"` and 10^6 == `"yek milyon"`. The suppression then leaks
39//!    through the recursion: 100000 == `"sed hezar"` (not "yek sed hezar") and
40//!    10^8 == `"sed milyon"`.
41//! 3. **Ordinals concatenate "em" with no separator or elision**, producing a
42//!    doubled vowel on words already ending in "e"/"ê": `to_ordinal(3)` ==
43//!    `"sêem"`, `to_ordinal(10)` == `"dehem"`. Preserved verbatim.
44//! 4. **Negative ordinals do not raise.** KU's `to_ordinal` skips
45//!    `verify_ordinal`, so `to_ordinal(-1)` == `"negatîv yekem"` — the "em"
46//!    lands on the end of a negated cardinal. `to_ordinal_num(-1)` == `"-1em"`.
47//! 5. **`tens[1]` ("deh") is unreachable.** The `< 100` branch only runs for
48//!    `number >= 20` (10..19 is consumed by `teens`), so the tens quotient is
49//!    never 1. Kept in the table for positional fidelity.
50//! 6. **`to_ordinal(1)`'s special case is a no-op.** `if number == 1: return
51//!    "yekem"` produces exactly what the general path would (`to_cardinal(1)`
52//!    + `"em"` == `"yek"` + `"em"`). Harmless, but reproduced as written.
53//!
54//! # Currency
55//!
56//! `Num2Word_KU` declares its **own** `CURRENCY_FORMS` class attribute, so it is
57//! untouched by the `lang_EUR` / `Num2Word_EN.__init__` mutation trap described
58//! in PORTING_CURRENCY.md — EN mutates `Num2Word_EUR`'s dict, and KU does not
59//! share it. The live table is exactly the source literal, four codes wide.
60//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}`, so
61//! `currency_precision` (always 100) and `currency_adjective` (always `None`)
62//! stay at their trait defaults.
63//!
64//! KU overrides `to_currency` outright and defines `pluralize`. Everything else
65//! on the currency path — `to_cheque`, `_money_verbose`, `_cents_verbose`,
66//! `_cents_terse` — is inherited from `Num2Word_Base` unchanged, and the trait
67//! defaults already mirror those.
68//!
69//! ## More faithfully reproduced Python bugs
70//!
71//! 7. **An unknown currency code does not raise; it silently becomes TRY.**
72//!    `to_currency` looks the code up with
73//!    `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`, and that
74//!    default is the *first inserted* entry — TRY. So `to_currency(0, "JPY")` is
75//!    `"sifir lîre"`, quietly denominated in Turkish lira. Only `to_cheque`,
76//!    which is Base's and subscripts the dict, raises NotImplementedError. The
77//!    corpus pins both halves: every one of the nine codes it exercises returns
78//!    a value through `to_currency`, while GBP/JPY/KWD/BHD/INR/CNY/CHF raise
79//!    through `to_cheque`.
80//! 8. **`cents=False` drops the subunit segment instead of writing it tersely.**
81//!    Base would emit `"... 56"` via `_cents_terse`; KU's `if cents and right:`
82//!    just omits it, so `to_currency(12.34, "USD", cents=False)` is
83//!    `"donzdeh dolar"`.
84//! 9. **`adjective=` is accepted and then completely ignored.** KU never touches
85//!    `CURRENCY_ADJECTIVES` (which is empty anyway), so `adjective=True` changes
86//!    nothing.
87//! 10. **Cents are truncated to two digits, not rounded, and short fractions are
88//!     right-padded.** `int(parts[1][:2].ljust(2, "0"))` means `12.345` -> 34
89//!     (not 35), `12.005` -> `int("00")` -> 0 -> *no cents at all*, `0.099` -> 9,
90//!     and `1.5` -> 50. A 3-decimal currency gets no special treatment: KU has no
91//!     `CURRENCY_PRECISION`, so KWD is truncated at 2 digits like everything else
92//!     (and falls back to TRY's forms per bug 7 regardless).
93//! 11. **Bug 1's digit fallback leaks into money.** `to_currency` calls
94//!     `_int_to_word` directly, so `to_currency(10**9, "USD")` is
95//!     `"1000000000 dolar"` and `to_currency(1234567890.12, "USD")` is
96//!     `"1234567890 dolar donzdeh sent"`.
97//! 12. **`pluralize` is dead code.** KU defines it, but `to_currency` inlines its
98//!     own `cr1[1] if left != 1 else cr1[0]` and Base's `to_cheque` takes `cr1[-1]`
99//!     unconditionally, so nothing in the library reaches it. It is ported anyway
100//!     (the trait default *raises*, which would be wrong for KU) and differs from
101//!     `Num2Word_EUR.pluralize`: it returns `forms[-1]`, not `forms[1]`, and
102//!     answers `""` for an empty tuple rather than raising IndexError.
103//! 13. **The separator is concatenated with no following space.** Base formats
104//!     `"%s%s %s %s"`, putting a space *after* the separator; KU writes
105//!     `result += separator + self._int_to_word(right) + " " + ...`. KU's own
106//!     default (`" "`) hides this, but an explicit `separator=","` yields
107//!     `"donzdeh dolar,sî û çar sent"` where Base's languages give `", sî..."`.
108//!
109//! Note that all four of KU's entries — and TRY's fallback — carry *identical*
110//! singular and plural forms (`("lîre", "lîre")`), so the `left != 1` branch is
111//! unobservable through the shipped table. It is ported literally regardless.
112//!
113//! # Notes on the port
114//!
115//! Python's `to_cardinal` is string-driven: it does `n = str(number).strip()`,
116//! tests `n.startswith("-")`, and recurses on the substring `n[1:]`. For
117//! integral input that is exactly "negate and recurse on the absolute value",
118//! which is what [`LangKu::cardinal`] does. The `"." in n` branch (float input)
119//! is out of scope per PORTING.md and omitted.
120//!
121//! `_int_to_word` is never reached with a negative: `to_cardinal` strips the
122//! sign before calling it. (Were it, Python's negative list indexing would make
123//! `self.ones[-1]` == "neh" — a latent trap this port cannot hit, since the
124//! only in-scope caller guarantees a non-negative argument.)
125
126use crate::base::{Lang, N2WError, Result};
127use crate::currency::{CurrencyForms, CurrencyValue};
128use crate::floatpath::FloatValue;
129use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
130use num_bigint::BigInt;
131use num_traits::{One, Signed, ToPrimitive};
132use std::collections::HashMap;
133use std::str::FromStr;
134
135/// `self.negword`. The trailing space is load-bearing: Python builds
136/// `negword + cardinal` and only then `.strip()`s the result.
137const NEGWORD: &str = "negatîv ";
138
139/// `self.ones`. Index 0 is `""` — unreachable from `_int_to_word` (the
140/// `number == 0` guard fires first), but used by the float path Python keeps.
141const ONES: [&str; 10] = [
142    "", "yek", "du", "sê", "çar", "pênc", "şeş", "heft", "heşt", "neh",
143];
144
145/// `self.teens`, indexed by `number - 10` for 10..=19.
146const TEENS: [&str; 10] = [
147    "deh", "yanzdeh", "donzdeh", "sêzdeh", "çardeh", "panzdeh", "şanzdeh", "hivdeh", "hijdeh",
148    "nozdeh",
149];
150
151/// `self.tens`. Index 0 is `""` and index 1 ("deh") is dead code — see bug 5.
152const TENS: [&str; 10] = [
153    "", "deh", "bîst", "sî", "çil", "pêncî", "şêst", "heftê", "heştê", "not",
154];
155
156const HUNDRED: &str = "sed";
157const THOUSAND: &str = "hezar";
158const MILLION: &str = "milyon";
159
160/// The word for zero, emitted by `_int_to_word(0)`.
161const ZERO: &str = "sifir";
162
163/// The joiner between a magnitude and its remainder: Python's `" û "`.
164const AND: &str = " û ";
165
166/// The code `to_currency`'s unknown-code fallback resolves to (bug 7).
167///
168/// Python writes `list(self.CURRENCY_FORMS.values())[0]`, i.e. the first entry
169/// in the class dict's *insertion* order. `CURRENCY_FORMS` is a dict literal
170/// whose first key is "TRY", and dicts have preserved insertion order since
171/// CPython 3.7 — so that expression is exactly TRY's forms. Naming the code
172/// pins the semantics to the literal rather than to a `HashMap`, which has no
173/// order to borrow.
174const FALLBACK_CURRENCY: &str = "TRY";
175
176/// `Num2Word_KU.CURRENCY_FORMS`, in the source dict's insertion order.
177///
178/// KU declares this on its own class, so — unlike the 16 classes that read
179/// `Num2Word_EUR`'s shared dict — nothing mutates it at import time. What is
180/// written here is what runs; verified against the live interpreter.
181fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
182    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
183    // Insertion order matters only for FALLBACK_CURRENCY above; the map itself
184    // is unordered, so the first key is called out by name rather than implied.
185    m.insert("TRY", CurrencyForms::new(&["lîre", "lîre"], &["qurûş", "qurûş"]));
186    m.insert("IQD", CurrencyForms::new(&["dînar", "dînar"], &["fils", "fils"]));
187    m.insert("USD", CurrencyForms::new(&["dolar", "dolar"], &["sent", "sent"]));
188    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["sent", "sent"]));
189    m
190}
191
192/// Kurdish (Kurmanji).
193///
194/// Stateless apart from the currency tables: `setup()` only assigns constant
195/// tables, and no method stashes state for another to consume.
196pub struct LangKu {
197    /// Built once here rather than per call — `to_currency` only ever reads it.
198    currency_forms: HashMap<&'static str, CurrencyForms>,
199    /// TRY's entry, pre-resolved so the unknown-code fallback (bug 7) is a
200    /// plain borrow instead of a lookup that could itself miss.
201    currency_fallback: CurrencyForms,
202}
203
204impl Default for LangKu {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl LangKu {
211    pub fn new() -> Self {
212        let currency_forms = build_currency_forms();
213        // Indexing is deliberate: FALLBACK_CURRENCY must name a key that
214        // `build_currency_forms` actually inserts, and this runs once at
215        // construction, so a bad edit to either fails loudly and immediately
216        // rather than silently changing what unknown codes render as.
217        let currency_fallback = currency_forms[FALLBACK_CURRENCY].clone();
218        LangKu {
219            currency_forms,
220            currency_fallback,
221        }
222    }
223
224    /// Python's `parts = str(abs(val)).split(".")` plus the two `int()` calls
225    /// that consume it, returning `(left, right)`.
226    ///
227    /// KU's `to_currency` is *string*-driven: it stringifies the value and
228    /// slices the text. The Rust boundary only carries the parsed number, so
229    /// this reconstructs the digits `str()` would have printed from the
230    /// `BigDecimal`'s `(digits, scale)` pair — which is exactly what a canonical
231    /// decimal repr encodes: `str(0.099)` == "0.099" <-> digits=99, scale=3.
232    ///
233    /// `right` is Python's `int(parts[1][:2].ljust(2, "0"))`: the first two
234    /// fractional digits, **truncated** (never rounded) and right-padded, hence
235    /// bounded to 0..=99 (bug 10).
236    fn currency_parts(&self, val: &CurrencyValue) -> Result<(BigInt, u64)> {
237        let value = match val {
238            // A true `int`: `str()` has no ".", so `parts` has length 1 and the
239            // `len(parts) > 1` guard leaves `right` at 0. Cents never appear.
240            CurrencyValue::Int(v) => return Ok((v.abs(), 0)),
241            CurrencyValue::Decimal { value, .. } => value,
242        };
243
244        // Python abs()es before str()ing; for a decimal repr the sign is just a
245        // prefix, so abs()ing the parsed value is the same reconstruction.
246        let (digits, scale) = value.abs().as_bigint_and_exponent();
247
248        if scale < 0 {
249            // A negative scale can only come from a string with a positive net
250            // exponent (bigdecimal computes scale = digits_after_point - exp and
251            // never normalises trailing zeros away, so "100" is scale 0, not
252            // -2). Python's str() of such a value keeps the exponent, and every
253            // shape of that then feeds an "e" to int(): "1e+21" -> parts
254            // ["1e+21"] -> int("1e+21"), "1.5E+3" -> parts ["1", "5E+3"] ->
255            // int("5E"). Both raise ValueError, so this is the exception Python
256            // raises, not an approximation of it. See `concerns` for the
257            // small-magnitude ("1e-05") half, which is *not* recoverable here.
258            return Err(N2WError::Value(format!(
259                "invalid literal for int() with base 10: {} carries an exponent \
260                 in Python's str() form",
261                value
262            )));
263        }
264        if scale == 0 {
265            // No "." in str(): parts has length 1, so right stays 0.
266            return Ok((digits, 0));
267        }
268
269        let scale = scale as usize;
270        let s = digits.to_string();
271        // str() always prints at least one integer digit ("0.5", never ".5"),
272        // so pad the digit string out to scale+1 before splitting.
273        let padded = if s.len() <= scale {
274            format!("{}{}", "0".repeat(scale + 1 - s.len()), s)
275        } else {
276            s
277        };
278        let (part0, part1) = padded.split_at(padded.len() - scale);
279
280        // `int(parts[0]) if parts[0] else 0` — part0 is never empty here (the
281        // padding above guarantees a digit), so the falsy branch is dead.
282        let left = BigInt::from_str(part0)
283            .map_err(|e| N2WError::Value(e.to_string()))?;
284
285        // `parts[1][:2].ljust(2, "0")`. Sliced by chars per PORTING.md, though
286        // these are all ASCII digits.
287        let mut frac: String = part1.chars().take(2).collect();
288        while frac.len() < 2 {
289            frac.push('0');
290        }
291        let right = frac
292            .parse::<u64>()
293            .map_err(|e| N2WError::Value(e.to_string()))?;
294        Ok((left, right))
295    }
296
297    /// Python's `_int_to_word`. `number` must be non-negative (guaranteed by
298    /// [`LangKu::cardinal`], the only in-scope caller).
299    fn int_to_word(&self, number: &BigInt) -> String {
300        // Bug 1: the Python chain of `if number < ...` branches ends in a bare
301        // `return str(number)`. Anything at or above 10^9 renders as digits.
302        // Checking the ceiling first also proves the remaining value fits a
303        // u64, so the cast below is sound rather than hopeful.
304        let billion = BigInt::from(1_000_000_000u64);
305        if *number >= billion {
306            return number.to_string();
307        }
308
309        // Proven bounded: 0 <= number < 10^9.
310        let n = number
311            .to_u64()
312            .expect("0 <= number < 10^9 is representable as u64");
313        self.int_to_word_small(n)
314    }
315
316    /// The `< 10^9` half of `_int_to_word`, on a value proven to fit a u64.
317    fn int_to_word_small(&self, number: u64) -> String {
318        if number == 0 {
319            return ZERO.to_string();
320        }
321        if number < 10 {
322            return ONES[number as usize].to_string();
323        }
324        if number < 20 {
325            return TEENS[(number - 10) as usize].to_string();
326        }
327        if number < 100 {
328            let (t, o) = (number / 10, number % 10);
329            let mut s = TENS[t as usize].to_string();
330            if o != 0 {
331                s.push_str(AND);
332                s.push_str(ONES[o as usize]);
333            }
334            return s;
335        }
336        if number < 1_000 {
337            let (h, r) = (number / 100, number % 100);
338            // Bug 2: the multiplier is suppressed for h == 1, so 100 == "sed".
339            let mut s = String::new();
340            if h > 1 {
341                s.push_str(ONES[h as usize]);
342                s.push(' ');
343            }
344            s.push_str(HUNDRED);
345            if r != 0 {
346                s.push_str(AND);
347                s.push_str(&self.int_to_word_small(r));
348            }
349            return s;
350        }
351        if number < 1_000_000 {
352            let (t, r) = (number / 1_000, number % 1_000);
353            // No `if t > 1` guard here — hence "yek hezar" for 1000 (bug 2).
354            let mut s = self.int_to_word_small(t);
355            s.push(' ');
356            s.push_str(THOUSAND);
357            if r != 0 {
358                s.push_str(AND);
359                s.push_str(&self.int_to_word_small(r));
360            }
361            return s;
362        }
363        // number < 10^9, enforced by int_to_word before the cast.
364        let (m, r) = (number / 1_000_000, number % 1_000_000);
365        let mut s = self.int_to_word_small(m);
366        s.push(' ');
367        s.push_str(MILLION);
368        if r != 0 {
369            s.push_str(AND);
370            s.push_str(&self.int_to_word_small(r));
371        }
372        s
373    }
374
375    /// Python's `to_cardinal` for integral input.
376    ///
377    /// Python inspects `str(number)` and recurses on the `n[1:]` substring when
378    /// it sees a leading "-"; for integers that is exactly negate-and-recurse.
379    /// The final `.strip()` is reproduced by `trim()` — it is a no-op for every
380    /// integral input (the inner result never has surrounding whitespace), but
381    /// it is what keeps `NEGWORD`'s trailing space from being observable.
382    fn cardinal(&self, value: &BigInt) -> String {
383        if value.is_negative() {
384            let inner = self.cardinal(&value.abs());
385            return format!("{}{}", NEGWORD, inner).trim().to_string();
386        }
387        self.int_to_word(value)
388    }
389
390    /// Python's `to_cardinal` for **float** input: the `"." in n` branch of
391    /// `Num2Word_KU.to_cardinal`, which is *string*-driven and never touches
392    /// `base.float2tuple`. It stringifies the float (`n = str(number)`), splits
393    /// on the first ".", renders the integer part with `_int_to_word`, then
394    /// appends the pointword and each **raw** fractional digit as
395    /// `self.ones[int(digit)] or "sifir"`.
396    ///
397    /// Reading `str(number)`'s digits verbatim is exactly why KU cannot inherit
398    /// base.py's float path. `base.float2tuple` recomputes the fraction as
399    /// `abs(value - pre) * 10**precision` in f64 and loses the final digit for
400    /// long reprs — `str(5.525882872479642)` keeps `...642`, but the f64 product
401    /// rounds it to `...641`. KU sidesteps that entirely by never doing the
402    /// arithmetic. Consequently `precision` is *unused for the digit values*
403    /// (KU's `to_cardinal` takes no `precision=` kwarg); it is threaded in only
404    /// to restore the ".0" of an integer-valued float, whose Rust `Display`
405    /// (unlike Python's `str`) drops the trailing fractional part.
406    fn cardinal_float(&self, value: f64, precision: u32) -> Result<String> {
407        // Python tests `str(number).strip().startswith("-")` — the sign *bit*,
408        // not `value < 0`. `str(-0.0) == "-0.0"` starts with "-", so KU prepends
409        // negword even for negative zero; `is_sign_negative` reproduces that.
410        let neg = value.is_sign_negative();
411        let a = value.abs();
412
413        // Python's `str(float)` switches to exponential form for a nonzero
414        // magnitude below 1e-4 or at/above 1e16 (and prints "inf"/"nan" for a
415        // non-finite value). KU then feeds that text to `int()`, which raises
416        // ValueError: "1e+16" has no "." so `int("1e+16")` fails, and "1.5e+16"
417        // splits a "5e+16" run whose `int("e")` fails. Either path is a
418        // ValueError; reproduce the *variant* (the diff harness compares the
419        // exception type, not its message).
420        if !a.is_finite() || (a != 0.0 && (a < 1e-4 || a >= 1e16)) {
421            return Err(N2WError::Value(format!(
422                "invalid literal for int() with base 10: str({}) is not a plain decimal",
423                value
424            )));
425        }
426
427        // Rust's f64 `Display` is shortest-round-trip like Python's `repr`, so
428        // the digits match across this range — save for a few genuine dtoa
429        // *ties* above ~1e13 where "N.d2" and "N.d3" both round-trip to the same
430        // double and the two libraries break the tie oppositely (see concerns).
431        // Display never uses exponential form, so within [1e-4, 1e16) `s` is a
432        // plain decimal string.
433        let s = format!("{}", a);
434        let (int_str, frac_str): (String, String) = match s.split_once('.') {
435            Some((i, f)) => (i.to_string(), f.to_string()),
436            // No ".": an integer-valued float, whose Python `str` is "N.0".
437            // `precision` is 1 for such a value, recovering the single "0".
438            None => (s, "0".repeat(precision as usize)),
439        };
440
441        // `_int_to_word(int(left))` — `left` is the non-negative integer part.
442        let left = BigInt::from_str(&int_str).map_err(|e| N2WError::Value(e.to_string()))?;
443        let mut ret = self.int_to_word(&left);
444
445        if !frac_str.is_empty() {
446            // `ret += " " + self.pointword`
447            ret.push(' ');
448            ret.push_str(self.pointword());
449            // `for digit in right: ret += " " + (self.ones[int(digit)] or "sifir")`
450            for ch in frac_str.chars() {
451                let d = ch.to_digit(10).ok_or_else(|| {
452                    N2WError::Value(format!("non-digit {:?} in fractional part", ch))
453                })?;
454                ret.push(' ');
455                // `ones[0]` is "" (falsy), so a 0 digit becomes "sifir".
456                ret.push_str(if d == 0 { ZERO } else { ONES[d as usize] });
457            }
458        }
459
460        // Python: negative -> `(negword + <inner>).strip()`, positive ->
461        // `<inner>.strip()`. Both strips are no-ops beyond negword's own space.
462        Ok(if neg {
463            format!("{}{}", NEGWORD, ret).trim().to_string()
464        } else {
465            ret.trim().to_string()
466        })
467    }
468}
469
470/// The `ValueError` for a `Decimal` whose `str()` is scientific notation
471/// (`"1E+2"`, `"1E-7"`, `"1.5E+3"`). KU feeds `str(number)` to `int()`: with
472/// no "." the whole (sign-stripped) literal is rejected; with a "." the
473/// per-character digit loop dies on `int("E")`.
474fn decimal_sci_value_error(s: &str) -> N2WError {
475    let n = s.strip_prefix('-').unwrap_or(s);
476    let lit = if n.contains('.') { "E" } else { n };
477    N2WError::Value(format!(
478        "invalid literal for int() with base 10: '{}'",
479        lit
480    ))
481}
482
483impl Lang for LangKu {
484    /// This language's own `to_currency(currency=...)` default,
485    /// read from the live Python signature. Only 44 of 156 use EUR.
486    fn default_currency(&self) -> &str {
487        "TRY"
488    }
489
490    /// This language's own `to_currency(separator=...)` default,
491    /// read from the live Python signature. Base's is ",", but only
492    /// 36 of 149 languages actually use it — most default to " " or a
493    /// conjunction, so inheriting Base's comma silently corrupts them.
494    fn default_separator(&self) -> &str {
495        " "
496    }
497
498    fn negword(&self) -> &str {
499        NEGWORD
500    }
501
502    fn pointword(&self) -> &str {
503        "xal"
504    }
505
506    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
507        Ok(self.cardinal(value))
508    }
509
510    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
511        // Bug 6: this special case is indistinguishable from the general path.
512        // Bug 4: no verify_ordinal call, so negatives fall through happily.
513        if value == &BigInt::from(1u32) {
514            return Ok("yekem".to_string());
515        }
516        Ok(format!("{}em", self.cardinal(value)))
517    }
518
519    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
520        // str(number) + "em" — the sign survives: -1 -> "-1em".
521        Ok(format!("{}em", value))
522    }
523
524    fn to_year(&self, value: &BigInt) -> Result<String> {
525        // KU ignores `longval` and the base class's BC/AD handling entirely.
526        Ok(self.cardinal(value))
527    }
528
529    /// `Num2Word_KU.to_cardinal` handles non-integers inline via `str(number)`,
530    /// so KU does **not** inherit `Num2Word_Base.to_cardinal_float`. Float input
531    /// takes KU's own string algorithm ([`LangKu::cardinal_float`]).
532    ///
533    /// The Decimal arm, by contrast, *is* reproduced bit-for-bit by the base
534    /// path: `str(Decimal)`'s fractional digits equal
535    /// `int(abs(value - int(value)) * 10**precision)` computed in exact
536    /// arbitrary precision, which is what `default_to_cardinal_float`'s Decimal
537    /// branch does — and its per-digit / pointword / sign rendering routes back
538    /// through KU's own `to_cardinal`, `pointword` and `negword`. So delegate
539    /// it rather than re-deriving the string. (The lone gap is a *negative-zero*
540    /// Decimal: KU's `str(Decimal("-0.0"))` starts with "-" and prepends
541    /// negword, but `num_bigint` has no negative zero, so the sign is already
542    /// gone by the time a `BigDecimal` reaches here — unrecoverable at this
543    /// layer, and absent from the corpus. See concerns.)
544    ///
545    /// `precision_override` (the `precision=` kwarg) is dropped for both arms:
546    /// KU's `to_cardinal(number)` takes no such parameter and reads `str(number)`
547    /// regardless, so an override never changes its output.
548    fn to_cardinal_float(
549        &self,
550        value: &FloatValue,
551        _precision_override: Option<u32>,
552    ) -> Result<String> {
553        match value {
554            FloatValue::Float { value, precision } => self.cardinal_float(*value, *precision),
555            FloatValue::Decimal { .. } => {
556                crate::floatpath::default_to_cardinal_float(self, value, None)
557            }
558        }
559    }
560
561    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
562    ///
563    /// KU routes on the string, not the value: `"." in str(number)` decides
564    /// between the pointword grammar and `int(n)`. So the base default's
565    /// whole→int shortcut is wrong here — `str(5.0)` is `"5.0"` and must read
566    /// `"pênc xal sifir"`, while the point-free `Decimal("5")` stays `"pênc"`,
567    /// and an exponent-form repr (`"1e+16"`, `"1E+2"`) raises the `int()`
568    /// ValueError. The Float arm delegates wholesale to [`LangKu::cardinal_float`],
569    /// which already reproduces the scientific-notation raise, the sign-bit
570    /// negword of `-0.0`, and the restored ".0" of a whole float.
571    fn cardinal_float_entry(
572        &self,
573        value: &FloatValue,
574        precision_override: Option<u32>,
575    ) -> Result<String> {
576        match value {
577            FloatValue::Float {
578                value: f,
579                precision,
580            } => self.cardinal_float(*f, *precision),
581            FloatValue::Decimal { value: d, .. } => {
582                let s = python_decimal_str(d);
583                if s.contains('E') {
584                    return Err(decimal_sci_value_error(&s));
585                }
586                if s.contains('.') {
587                    // `"." in n`: the pointword grammar, whole values included
588                    // (`Decimal("5.00")` -> "pênc xal sifir sifir").
589                    return self.to_cardinal_float(value, precision_override);
590                }
591                // Point-free, non-scientific: an integer-valued Decimal —
592                // Python's `return self._int_to_word(int(n))` arm, with the
593                // negword recursion for a leading "-".
594                let i = value
595                    .as_whole_int()
596                    .expect("a point-free, non-scientific Decimal is whole");
597                Ok(self.cardinal(&i))
598            }
599        }
600    }
601
602    /// `to_ordinal(float/Decimal)`. Python's `number == 1` is a *numeric*
603    /// comparison, so `1.0` and `Decimal("1.00")` hit "yekem"; everything
604    /// else — negative zero included — is the string-routed cardinal plus
605    /// "em": `to_ordinal(5.0)` == `"pênc xal sifirem"`, `to_ordinal(-0.0)` ==
606    /// `"negatîv sifir xal sifirem"`. An exponent-form repr propagates the
607    /// cardinal's ValueError.
608    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
609        if value.as_whole_int() == Some(BigInt::from(1u32)) {
610            return Ok("yekem".to_string());
611        }
612        Ok(format!("{}em", self.cardinal_float_entry(value, None)?))
613    }
614
615    /// `to_ordinal_num(float/Decimal)`: `str(number) + "em"` — the raw repr,
616    /// never an error: `to_ordinal_num(1e16)` == `"1e+16em"` and
617    /// `Decimal("5.00")` == `"5.00em"`.
618    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
619        Ok(format!("{}em", repr_str))
620    }
621
622    /// `to_year(float/Decimal)`: bare `self.to_cardinal(val)`, string routing
623    /// included — `to_year(5.0)` == `"pênc xal sifir"`, `to_year(1e16)`
624    /// raises ValueError.
625    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
626        self.cardinal_float_entry(value, None)
627    }
628
629    /// `str_to_number` is inherited from the base (`Decimal(value)`) and this
630    /// override does not change what parses — `python_decimal_parse` still
631    /// decides. It exists because `Decimal("Infinity")`/`Decimal("NaN")` *do*
632    /// parse in Python, and KU's `to_cardinal` then dies at `int("Infinity")`
633    /// with ValueError — where the bridge hard-wires the *base* integer-path
634    /// errors (OverflowError / "cannot convert NaN"), which KU never
635    /// produces. The one input this would misserve is
636    /// `to_ordinal_num("Infinity")` (Python: `"Infinityem"`), which no corpus
637    /// exercises.
638    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
639        match python_decimal_parse(s)? {
640            ParsedNumber::Inf { .. } => Err(N2WError::Value(
641                "invalid literal for int() with base 10: 'Infinity'".into(),
642            )),
643            ParsedNumber::NaN => Err(N2WError::Value(
644                "invalid literal for int() with base 10: 'NaN'".into(),
645            )),
646            other => Ok(other),
647        }
648    }
649
650    // ---- currency -------------------------------------------------------
651    //
652    // KU overrides `to_currency` and `pluralize`; `to_cheque`, `_money_verbose`,
653    // `_cents_verbose` and `_cents_terse` are Base's, and the trait defaults
654    // already mirror them. `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are
655    // empty on the live class, so `currency_precision` (100) and
656    // `currency_adjective` (None) are left at their defaults too.
657
658    fn lang_name(&self) -> &str {
659        "Num2Word_KU"
660    }
661
662    /// `CURRENCY_FORMS[code]`, subscripted — this is the *strict* lookup that
663    /// Base's `to_cheque` uses, so a miss here is the NotImplementedError the
664    /// corpus expects for GBP/JPY/KWD/BHD/INR/CNY/CHF.
665    ///
666    /// Deliberately **not** the fallback-to-TRY lookup: bug 7 lives inside
667    /// `to_currency`'s own `.get(..., default)` call, and leaking it into this
668    /// hook would silently make every cheque succeed in lira.
669    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
670        self.currency_forms.get(code)
671    }
672
673    /// `Num2Word_KU.pluralize`: `"" if not forms else forms[0] if n == 1 else
674    /// forms[-1]`.
675    ///
676    /// Unreachable in practice (bug 12), but the trait default raises
677    /// NotImplementedError and KU's method plainly does not, so it is ported.
678    /// Note `forms[-1]`, not `Num2Word_EUR`'s `forms[1]`.
679    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
680        if forms.is_empty() {
681            return Ok(String::new());
682        }
683        Ok(if n.is_one() {
684            forms[0].clone()
685        } else {
686            forms[forms.len() - 1].clone()
687        })
688    }
689
690    /// `Num2Word_KU.to_currency`. Replaces Base's wholesale: no
691    /// `parse_currency_parts`, no `CURRENCY_PRECISION`, no `pluralize`, no
692    /// adjective handling, and no NotImplementedError.
693    fn to_currency(
694        &self,
695        val: &CurrencyValue,
696        currency: &str,
697        cents: bool,
698        separator: Option<&str>,
699        // `adjective` is in KU's signature and is never read (bug 9).
700        _adjective: bool,
701    ) -> Result<String> {
702        // None means the caller omitted the kwarg, so KU's own default applies.
703        let separator = separator.unwrap_or(self.default_separator());
704
705        // `is_negative = val < 0` is read from the *original* value, before
706        // abs(). `-0.0 < 0` is False in Python, and BigInt/BigDecimal have no
707        // negative zero, so both sides agree that -0.0 is not negative.
708        let is_negative = val.is_negative();
709        let (left, right) = self.currency_parts(val)?;
710
711        // Bug 7: `.get(currency, list(CURRENCY_FORMS.values())[0])`.
712        let forms = self
713            .currency_forms
714            .get(currency)
715            .unwrap_or(&self.currency_fallback);
716
717        // `cr1[1] if left != 1 else cr1[0]` — Python subscripts the tuple
718        // directly rather than calling pluralize(), so a form short of two
719        // entries would raise IndexError. Every KU entry has two, making this
720        // unreachable; mapped rather than panicked so the exception type would
721        // survive a table change (as in lang_en.rs).
722        let unit = if left.is_one() {
723            forms.unit.first()
724        } else {
725            forms.unit.get(1)
726        }
727        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
728
729        let mut result = format!("{} {}", self.int_to_word(&left), unit);
730
731        // `if cents and right:` — `right` falsy (0) skips the segment, which is
732        // why 1.0 and 12.005 render bare. cents=False drops it outright rather
733        // than falling back to _cents_terse (bug 8).
734        if cents && right != 0 {
735            let subunit = if right == 1 {
736                forms.subunit.first()
737            } else {
738                forms.subunit.get(1)
739            }
740            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
741            // Python calls _int_to_word(right); right is bounded to 0..=99 by
742            // the two-digit truncation, so the small path is the same function.
743            result.push_str(separator);
744            result.push_str(&format!("{} {}", self.int_to_word_small(right), subunit));
745        }
746
747        if is_negative {
748            result = format!("{}{}", NEGWORD, result);
749        }
750        // Python's trailing `.strip()`.
751        Ok(result.trim().to_string())
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use bigdecimal::BigDecimal;
759
760    fn ku() -> LangKu {
761        LangKu::new()
762    }
763
764    fn int(s: &str) -> CurrencyValue {
765        CurrencyValue::parse(s, true, false, false).unwrap()
766    }
767
768    /// `has_decimal` is true for every float repr, matching the shim's
769    /// `is_int = "." not in arg and "e" not in arg.lower()`.
770    fn flt(s: &str) -> CurrencyValue {
771        CurrencyValue::parse(s, false, true, true).unwrap()
772    }
773
774    fn cur(l: &LangKu, v: &CurrencyValue, code: &str) -> String {
775        l.to_currency(v, code, true, None, false).unwrap()
776    }
777
778    /// The frozen corpus rows for `to": "currency:*`, verbatim.
779    #[test]
780    fn corpus_currency_rows() {
781        let l = ku();
782        // EUR — its own entry.
783        assert_eq!(cur(&l, &int("0"), "EUR"), "sifir euro");
784        assert_eq!(cur(&l, &int("1"), "EUR"), "yek euro");
785        assert_eq!(cur(&l, &int("2"), "EUR"), "du euro");
786        assert_eq!(cur(&l, &int("100"), "EUR"), "sed euro");
787        assert_eq!(cur(&l, &flt("12.34"), "EUR"), "donzdeh euro sî û çar sent");
788        assert_eq!(cur(&l, &flt("0.01"), "EUR"), "sifir euro yek sent");
789        assert_eq!(cur(&l, &flt("1.0"), "EUR"), "yek euro");
790        assert_eq!(cur(&l, &flt("99.99"), "EUR"), "not û neh euro not û neh sent");
791        assert_eq!(
792            cur(&l, &flt("1234.56"), "EUR"),
793            "yek hezar û du sed û sî û çar euro pêncî û şeş sent"
794        );
795        assert_eq!(cur(&l, &flt("-12.34"), "EUR"), "negatîv donzdeh euro sî û çar sent");
796        assert_eq!(cur(&l, &int("1000000"), "EUR"), "yek milyon euro");
797        assert_eq!(cur(&l, &flt("0.5"), "EUR"), "sifir euro pêncî sent");
798
799        // USD — its own entry.
800        assert_eq!(cur(&l, &int("0"), "USD"), "sifir dolar");
801        assert_eq!(cur(&l, &flt("12.34"), "USD"), "donzdeh dolar sî û çar sent");
802        assert_eq!(cur(&l, &flt("-12.34"), "USD"), "negatîv donzdeh dolar sî û çar sent");
803        assert_eq!(cur(&l, &int("1000000"), "USD"), "yek milyon dolar");
804
805        // Bug 7: every code KU does not declare silently renders as TRY.
806        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
807            assert_eq!(cur(&l, &int("0"), code), "sifir lîre", "{code}");
808            assert_eq!(cur(&l, &int("1"), code), "yek lîre", "{code}");
809            assert_eq!(cur(&l, &int("2"), code), "du lîre", "{code}");
810            assert_eq!(cur(&l, &int("100"), code), "sed lîre", "{code}");
811            assert_eq!(cur(&l, &flt("12.34"), code), "donzdeh lîre sî û çar qurûş", "{code}");
812            assert_eq!(cur(&l, &flt("0.01"), code), "sifir lîre yek qurûş", "{code}");
813            assert_eq!(cur(&l, &flt("1.0"), code), "yek lîre", "{code}");
814            assert_eq!(
815                cur(&l, &flt("99.99"), code),
816                "not û neh lîre not û neh qurûş",
817                "{code}"
818            );
819            assert_eq!(
820                cur(&l, &flt("1234.56"), code),
821                "yek hezar û du sed û sî û çar lîre pêncî û şeş qurûş",
822                "{code}"
823            );
824            assert_eq!(
825                cur(&l, &flt("-12.34"), code),
826                "negatîv donzdeh lîre sî û çar qurûş",
827                "{code}"
828            );
829            assert_eq!(cur(&l, &int("1000000"), code), "yek milyon lîre", "{code}");
830            assert_eq!(cur(&l, &flt("0.5"), code), "sifir lîre pêncî qurûş", "{code}");
831        }
832        // KWD/BHD get no 3-decimal treatment: CURRENCY_PRECISION is empty.
833        assert_eq!(cur(&l, &flt("1.234"), "KWD"), "yek lîre bîst û sê qurûş");
834    }
835
836    /// The frozen corpus rows for `to": "cheque:*`, verbatim.
837    #[test]
838    fn corpus_cheque_rows() {
839        let l = ku();
840        let v = BigDecimal::from_str("1234.56").unwrap();
841        assert_eq!(
842            l.to_cheque(&v, "EUR").unwrap(),
843            "YEK HEZAR Û DU SED Û SÎ Û ÇAR AND 56/100 EURO"
844        );
845        assert_eq!(
846            l.to_cheque(&v, "USD").unwrap(),
847            "YEK HEZAR Û DU SED Û SÎ Û ÇAR AND 56/100 DOLAR"
848        );
849        // Base's to_cheque subscripts the dict, so unknown codes raise here
850        // even though to_currency quietly falls back to TRY (bug 7).
851        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
852            match l.to_cheque(&v, code) {
853                Err(N2WError::NotImplemented(m)) => assert_eq!(
854                    m,
855                    format!("Currency code \"{code}\" not implemented for \"Num2Word_KU\"")
856                ),
857                other => panic!("{code}: expected NotImplemented, got {other:?}"),
858            }
859        }
860    }
861
862    /// Bug 10: two-digit truncation with ljust padding, never rounding.
863    #[test]
864    fn cents_truncate_and_pad() {
865        let l = ku();
866        assert_eq!(cur(&l, &flt("12.345"), "USD"), "donzdeh dolar sî û çar sent");
867        assert_eq!(cur(&l, &flt("12.305"), "USD"), "donzdeh dolar sî sent");
868        // "005"[:2] -> "00" -> 0 -> falsy -> the whole segment vanishes.
869        assert_eq!(cur(&l, &flt("12.005"), "USD"), "donzdeh dolar");
870        assert_eq!(cur(&l, &flt("1.5"), "USD"), "yek dolar pêncî sent");
871        assert_eq!(cur(&l, &flt("0.099"), "USD"), "sifir dolar neh sent");
872        assert_eq!(cur(&l, &flt("2.999"), "USD"), "du dolar not û neh sent");
873    }
874
875    /// Bugs 8, 9 and 11, plus the -0.0 sign edge.
876    #[test]
877    fn currency_quirks() {
878        let l = ku();
879        // Bug 8: cents=False omits rather than terse-renders.
880        assert_eq!(
881            l.to_currency(&flt("12.34"), "USD", false, None, false).unwrap(),
882            "donzdeh dolar"
883        );
884        // Bug 9: adjective is ignored.
885        assert_eq!(
886            l.to_currency(&flt("12.34"), "USD", true, None, true).unwrap(),
887            "donzdeh dolar sî û çar sent"
888        );
889        // Bug 11: _int_to_word's digit fallback reaches money.
890        assert_eq!(cur(&l, &int("1000000000"), "USD"), "1000000000 dolar");
891        assert_eq!(
892            cur(&l, &flt("1234567890.12"), "USD"),
893            "1234567890 dolar donzdeh sent"
894        );
895        // -0.0 is not < 0 in Python; no negword.
896        assert_eq!(cur(&l, &flt("-0.0"), "USD"), "sifir dolar");
897        assert_eq!(cur(&l, &flt("-0.5"), "USD"), "negatîv sifir dolar pêncî sent");
898        assert_eq!(cur(&l, &int("-1"), "USD"), "negatîv yek dolar");
899        // Bug 13: the separator is concatenated raw, with no space after it.
900        // Base writes "%s%s %s %s" (space *after* the separator); KU writes
901        // `result += separator + ...`. The default " " hides the difference;
902        // any other separator exposes it.
903        assert_eq!(
904            l.to_currency(&flt("12.34"), "USD", true, Some(","), false).unwrap(),
905            "donzdeh dolar,sî û çar sent"
906        );
907    }
908
909    /// `precision` as the harness computes it:
910    /// `abs(Decimal(str(v)).as_tuple().exponent)`. For a value Rust's `Display`
911    /// prints with no ".", Python's `str` still writes ".0", so precision is 1.
912    fn prec(v: f64) -> u32 {
913        match format!("{}", v.abs()).split_once('.') {
914            Some((_, f)) => f.len() as u32,
915            None => 1,
916        }
917    }
918
919    fn cf(l: &LangKu, v: f64) -> String {
920        l.to_cardinal_float(&FloatValue::Float { value: v, precision: prec(v) }, None)
921            .unwrap()
922    }
923
924    /// The frozen corpus rows for `to": "cardinal` with a float `arg`, verbatim.
925    #[test]
926    fn corpus_cardinal_float_rows() {
927        let l = ku();
928        assert_eq!(cf(&l, 0.0), "sifir xal sifir");
929        assert_eq!(cf(&l, 0.5), "sifir xal pênc");
930        assert_eq!(cf(&l, 1.0), "yek xal sifir");
931        assert_eq!(cf(&l, 1.5), "yek xal pênc");
932        assert_eq!(cf(&l, 2.25), "du xal du pênc");
933        assert_eq!(cf(&l, 3.14), "sê xal yek çar");
934        assert_eq!(cf(&l, 0.01), "sifir xal sifir yek");
935        assert_eq!(cf(&l, 0.1), "sifir xal yek");
936        assert_eq!(cf(&l, 0.99), "sifir xal neh neh");
937        assert_eq!(cf(&l, 1.01), "yek xal sifir yek");
938        assert_eq!(cf(&l, 12.34), "donzdeh xal sê çar");
939        assert_eq!(cf(&l, 99.99), "not û neh xal neh neh");
940        assert_eq!(cf(&l, 100.5), "sed xal pênc");
941        assert_eq!(cf(&l, 1234.56), "yek hezar û du sed û sî û çar xal pênc şeş");
942        assert_eq!(cf(&l, -0.5), "negatîv sifir xal pênc");
943        assert_eq!(cf(&l, -1.5), "negatîv yek xal pênc");
944        assert_eq!(cf(&l, -12.34), "negatîv donzdeh xal sê çar");
945        // The two f64-artefact cases: KU reads str() digits, so no rounding.
946        assert_eq!(cf(&l, 1.005), "yek xal sifir sifir pênc");
947        assert_eq!(cf(&l, 2.675), "du xal şeş heft pênc");
948    }
949
950    /// Live-interpreter edges not in the frozen corpus: -0.0 (sign bit),
951    /// bug 1's digit fallback leaking into the integer part, and sci-notation
952    /// floats that make `int()` raise ValueError.
953    #[test]
954    fn cardinal_float_edges() {
955        let l = ku();
956        // str(-0.0) == "-0.0" starts with "-": negword is prepended.
957        assert_eq!(cf(&l, -0.0), "negatîv sifir xal sifir");
958        // Bug 1: the >=1e9 integer part renders as bare digits.
959        assert_eq!(cf(&l, 1234567890.12), "1234567890 xal yek du");
960        // Python str(1e16)=="1e+16" -> int() ValueError; likewise 1e-5, inf/nan.
961        for v in [1e16, 1.5e16, 1e-5, f64::INFINITY, f64::NAN] {
962            assert!(
963                matches!(
964                    l.to_cardinal_float(&FloatValue::Float { value: v, precision: 1 }, None),
965                    Err(N2WError::Value(_))
966                ),
967                "{v} should raise ValueError"
968            );
969        }
970        // 1e-4 is the boundary: Python keeps it non-exponential ("0.0001"),
971        // so all four fractional digits (0,0,0,1) render.
972        assert_eq!(cf(&l, 1e-4), "sifir xal sifir sifir sifir yek");
973    }
974
975    /// The frozen corpus rows for `to": "cardinal_dec` (Decimal input), verbatim.
976    /// These route through the base path, which is exact for Decimals.
977    #[test]
978    fn corpus_cardinal_dec_rows() {
979        let l = ku();
980        let dec = |s: &str| {
981            let precision = s.split_once('.').map(|(_, f)| f.len() as u32).unwrap_or(0);
982            FloatValue::Decimal {
983                value: BigDecimal::from_str(s).unwrap(),
984                precision,
985            }
986        };
987        let d = |s: &str| l.to_cardinal_float(&dec(s), None).unwrap();
988        assert_eq!(d("0.01"), "sifir xal sifir yek");
989        assert_eq!(d("1.10"), "yek xal yek sifir");
990        assert_eq!(d("12.345"), "donzdeh xal sê çar pênc");
991        // Issue #603: exact at trillion scale, no float() round.
992        assert_eq!(d("98746251323029.99"), "98746251323029 xal neh neh");
993        assert_eq!(d("0.001"), "sifir xal sifir sifir yek");
994    }
995
996    /// Bug 12: ported despite being unreachable. Note forms[-1], not forms[1].
997    #[test]
998    fn pluralize_takes_last_form() {
999        let l = ku();
1000        let three: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1001        assert_eq!(l.pluralize(&BigInt::from(1), &three).unwrap(), "a");
1002        assert_eq!(l.pluralize(&BigInt::from(2), &three).unwrap(), "c");
1003        assert_eq!(l.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1004    }
1005
1006    /// str() of a value with a positive net exponent feeds an "e" to int().
1007    #[test]
1008    fn exponent_form_raises_value_error() {
1009        let l = ku();
1010        // "1e+21" parses to scale -21; Python: int("1e+21") -> ValueError.
1011        assert!(matches!(
1012            l.to_currency(&flt("1e+21"), "USD", true, None, false),
1013            Err(N2WError::Value(_))
1014        ));
1015        // A plain integral Decimal ("5", scale 0) must NOT trip that branch.
1016        assert_eq!(cur(&l, &flt("5"), "USD"), "pênc dolar");
1017    }
1018}