Skip to main content

num2words2_core/
lang_lg.rs

1//! Port of `lang_LG.py` (Luganda).
2//!
3//! Shape: **self-contained**. `Num2Word_LG` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords` and never
5//! calls `set_high_numwords`, so Python builds neither `self.cards` nor
6//! `MAXVAL`. `to_cardinal` is overridden outright and delegates to a hand-rolled
7//! `_int_to_word` ladder. Consequently `cards`/`maxval`/`merge` stay at their
8//! trait defaults here, and **no code path can ever raise** — see "Error
9//! variants" below.
10//!
11//! `Num2Word_Base.__init__` sets `self.is_title = False` and LG's `setup()`
12//! never touches it, so `title()` is a no-op; in any case LG's `to_cardinal`
13//! bypasses the base entirely and never calls `title()`. The
14//! `exclude_title` list LG populates (`["ne", "akatonnyeze", "wansi", "wa"]`)
15//! is therefore dead weight in Python and is not modelled here.
16//!
17//! All four in-scope modes are overridden by LG itself; nothing is inherited:
18//!   * `to_cardinal`    — the string-driven algorithm below
19//!   * `to_ordinal`     — `"ow' " + to_cardinal(n)`
20//!   * `to_ordinal_num` — `"ow' " + str(n)`
21//!   * `to_year(val, longval=True)` — plain `to_cardinal(val)`; the `longval`
22//!     flag is accepted and ignored, so years get no special treatment
23//!     (`to_year(1492)` == `to_cardinal(1492)` == "lukumi mu nnya kikumi mu
24//!     kyenda mu bbiri", not a "fourteen ninety-two" style split).
25//!
26//! # Faithfully reproduced Python quirks
27//!
28//! This is a port, not a rewrite. Both of the following look wrong and are
29//! exactly what Python emits, verified against the frozen corpus:
30//!
31//! 1. **`_int_to_word` gives up at 10^9 and returns the bare digits.** The
32//!    ladder stops after the `number < 1000000` (thousand) and
33//!    `number < 1000000000` (million) branches; the final statement is a plain
34//!    `return str(number)`. So `to_cardinal(10**9)` == "1000000000" — an
35//!    unconverted numeral, not words, and *not* an `OverflowError`. Corpus rows
36//!    confirm this up to 10^21. By extension `to_ordinal(10**9)` ==
37//!    "ow' 1000000000". Modelled in [`int_to_word`]. Note this is a silent
38//!    wrong-looking answer rather than a raise, so callers get no signal.
39//! 2. **The multiplier "emu" (one) is suppressed on every scale word**, because
40//!    each branch guards the multiplier with `if h > 1` / `if t > 1` /
41//!    `if m > 1` rather than `!= 0`. Hence 100 → "kikumi" (not "emu kikumi"),
42//!    1000 → "lukumi", 10^6 → "kakadde". That is idiomatic for Luganda and
43//!    almost certainly intentional, but it is worth stating explicitly since
44//!    the same guard would be a bug in most European languages.
45//!
46//! # Error variants
47//!
48//! None. Unlike most modules, LG has no reachable crash site in the four
49//! in-scope modes: there is no table lookup that can miss, no `int()` of a
50//! non-numeric token, and no overflow check (the 10^9 ceiling degrades to
51//! digits per quirk 1 instead of raising). Every in-scope call returns `Ok`.
52//!
53//! # Unreachable Python bug, deliberately not modelled
54//!
55//! `_int_to_word(-1)` would fall through `number == 0` into the `number < 10`
56//! branch and evaluate `self.ones[-1]`, which Python resolves as a *negative
57//! index* — yielding "mwenda" (nine) for minus one. It is unreachable from all
58//! four in-scope modes because `to_cardinal` strips the sign at the **string**
59//! level (`n.startswith("-")` → recurse on `n[1:]`) before any `int()` is
60//! taken, so `_int_to_word` only ever sees non-negative values. [`int_to_word`]
61//! documents and relies on that invariant rather than reproducing the quirk.
62//!
63//! # Currency
64//!
65//! LG owns `to_currency` and `pluralize`; everything else on the currency
66//! surface (`to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`)
67//! is `Num2Word_Base`'s, which the trait defaults already mirror. Both
68//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are `{}` — never populated,
69//! never rebound — so `currency_adjective()` stays `None` and
70//! `currency_precision()` stays 100 for *every* code. LG therefore has no
71//! 3-decimal (KWD/BHD) or 0-decimal (JPY) currency: those codes are simply
72//! absent from the table and take the fallback described below. Corpus rows for
73//! `currency:KWD` / `currency:JPY` confirm no divisor ever varies.
74//!
75//! The two halves diverge sharply on an unknown code, and that is not a typo:
76//!
77//! * `to_currency` does `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`
78//!   — a `dict.get` **with a default**, so an unknown code silently renders as
79//!   the first entry (UGX, "shilingi") instead of raising. Hence
80//!   `to_currency(1, "GBP")` == "emu shilingi", and every unimplemented code
81//!   quietly reports Ugandan shillings.
82//! * `to_cheque` is Base's, which does `CURRENCY_FORMS[currency]` and converts
83//!   the `KeyError` into `NotImplementedError`. So `cheque:GBP` *does* raise.
84//!
85//! Same missing code, two different answers. [`LangLg::currency_forms`] is the
86//! strict lookup (feeding `to_cheque`); the fallback lives inside
87//! [`LangLg::to_currency`] where Python puts it.
88//!
89//! ## `str(val)` is the spec, and it leaks `repr(float)`
90//!
91//! `to_currency` never touches `parse_currency_parts`. It splits the *text* of
92//! the value:
93//!
94//! ```python
95//! parts = str(val).split(".")
96//! left  = int(parts[0]) if parts[0] else 0
97//! right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
98//! ```
99//!
100//! Arithmetic (truncate, then scale the remainder by 100) agrees with that on
101//! every value `repr` spells in fixed notation — but not otherwise, and the
102//! disagreement is spectacular. Once `repr` switches to exponential the slicing
103//! keeps going and reads the *exponent's* digits as cents:
104//!
105//! | value | `str(val)` | Python's answer |
106//! |---|---|---|
107//! | `1e16` | `"1e+16"` | ValueError — `int("1e+16")` |
108//! | `1e21` | `"1e+21"` | ValueError |
109//! | `1.5e20` | `"1.5e+20"` | ValueError — `int("5e")` |
110//! | `1.25e20` | `"1.25e+20"` | **"emu yuro abiri mu ttaano senti"** |
111//!
112//! That last row is not a rounding artefact: 1.25e20 euros comes back as *one
113//! euro twenty-five cents*, because `parts[1][:2]` of `"25e+20"` is `"25"` and
114//! `int("25")` happens to succeed. All four verified against the live
115//! interpreter. Reproducing them requires rebuilding the string Python saw, so
116//! [`py_str`] does exactly that and the rest is a literal transcription of the
117//! slicing. See [`py_str`] for what is and is not recoverable.
118
119use crate::base::{Lang, N2WError, Result};
120use crate::currency::{CurrencyForms, CurrencyValue};
121use crate::floatpath::FloatValue;
122use bigdecimal::BigDecimal;
123use num_bigint::BigInt;
124use num_traits::{One, Signed, ToPrimitive, Zero};
125use std::collections::HashMap;
126use std::str::FromStr;
127
128/// `self.ones`. Index 0 is the empty string; `_int_to_word` never reads it
129/// (zero is caught by the `number == 0` branch first). Index 0 *is* read on the
130/// float/Decimal fraction path via [`ones_or_nuli`], where Python's
131/// `self.ones[int(digit)] or "nuli"` turns that empty string into "nuli".
132const ONES: [&str; 10] = [
133    "", "emu", "bbiri", "ssatu", "nnya", "ttaano", "mukaaga", "musanvu", "munaana", "mwenda",
134];
135
136/// `self.tens`. Index 1 ("kkumi") is the standalone word for ten.
137const TENS: [&str; 10] = [
138    "", "kkumi", "abiri", "asatu", "ana", "ataano", "nkaaga", "nsanvu", "kinaana", "kyenda",
139];
140
141const ZERO_WORD: &str = "nuli";
142const HUNDRED: &str = "kikumi";
143const THOUSAND: &str = "lukumi";
144const MILLION: &str = "kakadde";
145
146/// `self.pointword`. Mirrors [`LangLg::pointword`] (same string); duplicated as
147/// a `const` only so the free-function float path can name it without a `self`.
148/// LG's `to_cardinal` splices it verbatim (no `title()`), so the raw form is
149/// what appears in output.
150const POINTWORD: &str = "akatonnyeze";
151
152/// `self.negword`. Keeps its trailing space: Python concatenates it directly
153/// (`self.negword + self.to_cardinal(...)`) and only strips the *outer* ends.
154const NEGWORD: &str = "wansi wa ";
155
156/// The joiner Python splices between every scale and its remainder.
157const JOIN: &str = " mu ";
158
159/// Python's `_int_to_word`, entry point.
160///
161/// # Invariant
162///
163/// `number` must be non-negative. `to_cardinal` guarantees this by stripping
164/// the minus sign from the *string* form before calling `int()`, so the
165/// negative-index quirk described in the module docs is structurally
166/// unreachable. Nothing else in this module calls into here.
167fn int_to_word(number: &BigInt) -> String {
168    // Python checks `number == 0` first; zero is below every bound that
169    // follows, so hoisting the check above the 10^9 test is equivalent.
170    if number.is_zero() {
171        return ZERO_WORD.to_string();
172    }
173
174    // Python's ladder ends with a bare `return str(number)` for anything the
175    // million branch does not cover. Reproduced verbatim: digits, not words,
176    // and not an OverflowError. See module docs, quirk 1.
177    let billion = BigInt::from(1_000_000_000u32);
178    if number >= &billion {
179        return number.to_string();
180    }
181
182    // Proven bounded: 0 < number < 10^9, so u64 is safe here. The BigInt input
183    // is only load-bearing above, where oversized values short-circuit to
184    // their decimal form.
185    let n = number
186        .to_u64()
187        .expect("0 < number < 10^9 was just established");
188    int_to_word_small(n)
189}
190
191/// The word-producing half of `_int_to_word`, for `0 <= n < 10^9`.
192///
193/// Split out from [`int_to_word`] so the recursive calls (which Python makes
194/// back into the full `_int_to_word`) stay on plain integers. Each recursion
195/// site is guarded by `if r` / `if t > 1` in Python, so `n == 0` never actually
196/// recurses — but the zero branch is kept here anyway to mirror the Python
197/// function's own shape exactly.
198fn int_to_word_small(n: u64) -> String {
199    if n == 0 {
200        return ZERO_WORD.to_string();
201    }
202    if n < 10 {
203        return ONES[n as usize].to_string();
204    }
205    if n < 100 {
206        // Python: t, o = divmod(number, 10)
207        //         return self.tens[t] + (" mu " + self.ones[o] if o else "")
208        let (t, o) = (n / 10, n % 10);
209        let mut s = TENS[t as usize].to_string();
210        if o != 0 {
211            s.push_str(JOIN);
212            s.push_str(ONES[o as usize]);
213        }
214        return s;
215    }
216    if n < 1000 {
217        // Python: base = (self.ones[h] + " " if h > 1 else "") + self.hundred
218        let (h, r) = (n / 100, n % 100);
219        let mut s = String::new();
220        if h > 1 {
221            s.push_str(ONES[h as usize]);
222            s.push(' ');
223        }
224        s.push_str(HUNDRED);
225        if r != 0 {
226            s.push_str(JOIN);
227            s.push_str(&int_to_word_small(r));
228        }
229        return s;
230    }
231    if n < 1_000_000 {
232        // Python: base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand
233        let (t, r) = (n / 1000, n % 1000);
234        let mut s = String::new();
235        if t > 1 {
236            s.push_str(&int_to_word_small(t));
237            s.push(' ');
238        }
239        s.push_str(THOUSAND);
240        if r != 0 {
241            s.push_str(JOIN);
242            s.push_str(&int_to_word_small(r));
243        }
244        return s;
245    }
246    // n < 10^9, guaranteed by the caller.
247    // Python: base = (self._int_to_word(m) + " " if m > 1 else "") + self.million
248    let (m, r) = (n / 1_000_000, n % 1_000_000);
249    let mut s = String::new();
250    if m > 1 {
251        s.push_str(&int_to_word_small(m));
252        s.push(' ');
253    }
254    s.push_str(MILLION);
255    if r != 0 {
256        s.push_str(JOIN);
257        s.push_str(&int_to_word_small(r));
258    }
259    s
260}
261
262/// Rebuild Python's `str(v)` for the **absolute** value `v`, from the
263/// `(digits, scale)` that `bigdecimal` parsed out of it.
264///
265/// This is not a re-implementation of float formatting. The shim already sent
266/// `str(value)` across, and `bigdecimal`'s parser stores that text's digits
267/// verbatim as `(int_val, scale)` without normalising — `as_bigint_and_exponent`
268/// hands back exactly the digits `repr` chose. The only thing lost in the round
269/// trip is the **layout**: where the point went, and whether `repr` picked
270/// exponential form. That is a range check on one integer, not an algorithm.
271///
272/// `decpt` is the point's position relative to the digit string
273/// (`value == 0.<digits> * 10^decpt`). CPython emits fixed notation for
274/// `-4 < decpt <= 16` and exponential outside it — which is why `str(1e15)` is
275/// `"1000000000000000.0"` but `str(1e16)` is `"1e+16"`, and `str(1e-4)` is
276/// `"0.0001"` but `str(1e-5)` is `"1e-05"`. Both edges verified against the
277/// interpreter.
278///
279/// # What this cannot recover
280///
281/// The layout rule reproduced here is `repr(float)`'s. `decimal.Decimal`
282/// formats itself by a *different* rule, and by the time a value reaches here
283/// the two are indistinguishable — `1e-05` and `Decimal("0.00001")` both parse
284/// to `(1, scale=5)`, yet Python spells the first `"1e-05"` (→ ValueError) and
285/// the second `"0.00001"` (→ "nuli yuro"). A `Decimal` outside the fixed range
286/// therefore takes the float spelling. Flagged in the report; no corpus row
287/// reaches it, and the shim's own callers pass floats.
288///
289/// Integral `Decimal`s are a benign version of the same gap: `str(Decimal("5"))`
290/// is `"5"` (one part, `right = 0`) while this returns `"5.0"` (two parts,
291/// `right = int("00") = 0`). Different text, same `left`/`right`, same output.
292fn py_str(digits: &BigInt, scale: i64) -> String {
293    debug_assert!(!digits.is_negative(), "py_str expects abs(value)'s digits");
294    let ds = digits.to_string();
295    // `ds` is BigInt::to_string of a non-negative: ASCII digits only, so every
296    // byte slice below lands on a char boundary by construction.
297    let decpt = ds.len() as i64 - scale;
298
299    if decpt > 16 || decpt < -3 {
300        // Exponential: one digit, the rest, then a >=2-digit signed exponent.
301        // `repr` never pads the mantissa with trailing zeros, and `ds` is from
302        // `repr`, so no trimming is needed here.
303        let mantissa = if ds.len() > 1 {
304            format!("{}.{}", &ds[..1], &ds[1..])
305        } else {
306            ds
307        };
308        let exp = decpt - 1;
309        let sign = if exp < 0 { '-' } else { '+' };
310        return format!("{}e{}{:02}", mantissa, sign, exp.abs());
311    }
312
313    if decpt <= 0 {
314        // 0.5 -> decpt 0; 0.01 -> decpt -1.
315        format!("0.{}{}", "0".repeat((-decpt) as usize), ds)
316    } else if decpt as usize >= ds.len() {
317        // Integral: repr appends ".0" (Py_DTSF_ADD_DOT_0). 100.0, 1e15, ...
318        format!("{}{}.0", ds, "0".repeat(decpt as usize - ds.len()))
319    } else {
320        format!("{}.{}", &ds[..decpt as usize], &ds[decpt as usize..])
321    }
322}
323
324/// Python's `int(s)` on a slice of `str(val)`, keeping the ValueError.
325///
326/// `int()` also accepts surrounding whitespace and `_` separators; neither can
327/// occur in a `repr` fragment, so plain `BigInt::from_str` is exact here. The
328/// message is CPython's verbatim — `1e+16` and `5e` both reach a caller that
329/// may be matching on it.
330fn py_int(s: &str) -> Result<BigInt> {
331    BigInt::from_str(s).map_err(|_| {
332        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
333    })
334}
335
336/// Python's `Num2Word_LG.to_cardinal` operating on the already-computed
337/// `n = str(number).strip()`.
338///
339/// LG **overrides `to_cardinal`** and handles non-integers there, working purely
340/// on the *text* of `str(number)` — it never calls `float2tuple`, never rounds,
341/// and takes no `precision`. Floats and Decimals reach it via `str(number)`, so
342/// this is the entire float/Decimal path:
343///
344/// ```python
345/// n = str(number).strip()
346/// if n.startswith("-"):
347///     return (self.negword + self.to_cardinal(n[1:])).strip()
348/// if "." in n:
349///     left, right = n.split(".", 1)
350///     ret = self._int_to_word(int(left)) + " " + self.pointword
351///     for digit in right:
352///         ret += " " + (self.ones[int(digit)] or "nuli")
353///     return ret.strip()
354/// return self._int_to_word(int(n))
355/// ```
356///
357/// Two Python quirks reproduced verbatim:
358/// * `int(left)` / `int(digit)` raise `ValueError` on any non-numeric
359///   character. Once `repr` goes exponential (`str(1e16)` == "1e+16",
360///   `str(1.5e20)` == "1.5e+20") the slices feed `int("1e+16")` / `int("e")` and
361///   Python raises — so those inputs raise here too, via [`py_int`].
362/// * a `'0'` fractional digit renders as "nuli", because `self.ones[0]` is the
363///   empty string and `"" or "nuli"` is `"nuli"`.
364fn cardinal_from_pystr(n: &str) -> Result<String> {
365    let n = n.trim();
366
367    // Python: `if n.startswith("-"): return (negword + to_cardinal(n[1:])).strip()`.
368    // The sign is detached at the *string* level and the rest re-enters, which is
369    // why negatives never reach `int()` with a leading minus.
370    if let Some(rest) = n.strip_prefix('-') {
371        let inner = cardinal_from_pystr(rest)?;
372        return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
373    }
374
375    match n.split_once('.') {
376        Some((left, right)) => {
377            // ret = _int_to_word(int(left)) + " " + pointword
378            let left_int = py_int(left)?;
379            let mut ret = format!("{} {}", int_to_word(&left_int), POINTWORD);
380            // for digit in right: ret += " " + (ones[int(digit)] or "nuli")
381            for ch in right.chars() {
382                ret.push(' ');
383                ret.push_str(ones_or_nuli(ch)?);
384            }
385            Ok(ret.trim().to_string())
386        }
387        // No ".": `return self._int_to_word(int(n))`. Reachable on the float
388        // path only when `repr` produced exponential form (no dot), where
389        // `int(n)` raises — reproduced by `py_int`.
390        None => Ok(int_to_word(&py_int(n)?)),
391    }
392}
393
394/// Python's `self.ones[int(digit)] or "nuli"` for a single fractional character.
395///
396/// `int(digit)` is `ValueError` on a non-digit (e.g. the `'e'` of an exponential
397/// `repr`), matching Python exactly. A digit char is always `0..=9`, so the
398/// `self.ones[...]` index can never be out of range; index 0 holds the empty
399/// string, which `or "nuli"` turns into "nuli".
400fn ones_or_nuli(ch: char) -> Result<&'static str> {
401    let d = py_int(&ch.to_string())?;
402    let idx = d
403        .to_usize()
404        .expect("a single decimal digit is 0..=9, always a valid usize");
405    Ok(if ONES[idx].is_empty() {
406        ZERO_WORD
407    } else {
408        ONES[idx]
409    })
410}
411
412/// Reconstruct Python's `str(f)` (== `repr(f)`) for an f64.
413///
414/// Rust's `{:e}` yields the shortest round-trip scientific form — the same
415/// significant digits `repr` chooses — and [`py_str`] re-lays it out under
416/// CPython's float rule (fixed for `-4 < decpt <= 16`, exponential otherwise,
417/// always a trailing ".0" on integral values). Together they reproduce `repr`
418/// at every magnitude, including the `"1e+16"` / `"1e-05"` edges that then make
419/// the string algorithm raise `ValueError`.
420///
421/// LG's algorithm never looks at `precision`; it only splits this string. So the
422/// f64-artefact trap that bites `base.float2tuple` (2.675 → 674.9999…) does not
423/// arise: `{:e}` of the f64 `2.675` is `"2.675e0"`, exactly as `repr` spells it.
424fn py_float_str(f: f64) -> String {
425    // Python spells -0.0 as "-0.0" (a leading minus that `to_cardinal` strips
426    // and recurses on), so the sign is read from the bit, not from `< 0`.
427    let neg = f.is_sign_negative();
428    let sci = format!("{:e}", f.abs()); // "2.675e0", "1e-2", "0e0", "1e16"
429    let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
430    let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
431    let (int_part, frac_part) = mant.split_once('.').unwrap_or((mant, ""));
432    let digits = BigInt::from_str(&format!("{}{}", int_part, frac_part))
433        .expect("mantissa digits are ASCII 0-9");
434    let scale = frac_part.len() as i64 - exp;
435    let body = py_str(&digits, scale);
436    if neg {
437        format!("-{}", body)
438    } else {
439        body
440    }
441}
442
443/// Reconstruct Python's `str(Decimal)` for `value`.
444///
445/// This is deliberately **not** [`py_str`]'s float rule: `Decimal.__str__` uses
446/// fixed-point notation whenever `exp <= 0 and (exp + len(digits)) > -6`, and
447/// scientific (uppercase `E`, signed exponent) otherwise — a different boundary
448/// from `repr(float)`. Every corpus `cardinal_dec` row is fixed-point, but
449/// `Decimal("1E-7")` really does stringify to "1E-7" and then hits
450/// `int("1E-7")` → `ValueError`; only the correct rule reproduces that. Ported
451/// from CPython's `decimal.Decimal.__str__` (non-engineering branch).
452fn py_decimal_str(value: &BigDecimal) -> String {
453    let neg = value.is_negative();
454    // `bigdecimal` does not normalise, so this hands back exactly the
455    // coefficient and exponent of the Decimal the shim parsed (trailing zeros
456    // of "1.10" preserved as (110, scale=2)); see the `bigdecimal_does_not_normalise`
457    // test guarding this premise.
458    let (coeff, scale) = value.abs().as_bigint_and_exponent();
459    let int_str = coeff.to_string(); // coefficient digits, non-negative ASCII
460    let ndigits = int_str.len() as i64;
461    let exp = -scale; // Python Decimal's `_exp`
462    let leftdigits = exp + ndigits; // adjusted point position
463
464    let dotplace = if exp <= 0 && leftdigits > -6 {
465        leftdigits
466    } else {
467        1 // non-engineering scientific notation
468    };
469
470    let (intpart, fracpart) = if dotplace <= 0 {
471        (
472            "0".to_string(),
473            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
474        )
475    } else if dotplace >= ndigits {
476        (
477            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
478            String::new(),
479        )
480    } else {
481        // ASCII digit string, so byte slicing lands on char boundaries.
482        let dp = dotplace as usize;
483        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
484    };
485
486    let exp_part = if leftdigits == dotplace {
487        String::new()
488    } else {
489        // Python: `['e','E'][capitals] + "%+d" % (leftdigits - dotplace)`;
490        // the default Decimal context has `capitals == 1`, i.e. uppercase 'E'.
491        format!("E{:+}", leftdigits - dotplace)
492    };
493
494    let body = format!("{}{}{}", intpart, fracpart, exp_part);
495    if neg {
496        format!("-{}", body)
497    } else {
498        body
499    }
500}
501
502/// Python's `cr[1] if n != 1 else cr[0]`, as `to_currency` spells it inline.
503///
504/// Deliberately *not* routed through [`LangLg::pluralize`]: that method picks
505/// `forms[-1]`, this picks `forms[1]`. The two coincide for every two-form
506/// entry LG has, but they are separate code in Python and the arities are not
507/// guaranteed to stay at two. A one-form entry would raise IndexError here, so
508/// the miss is typed rather than panicked — unreachable today, since all three
509/// entries and the fallback carry exactly two forms.
510fn pick_form(forms: &[String], n: &BigInt) -> Result<String> {
511    let idx = if n.is_one() { 0 } else { 1 };
512    forms
513        .get(idx)
514        .cloned()
515        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
516}
517
518/// `CURRENCY_FORMS`. Only three codes; no other class touches this dict.
519///
520/// `Num2Word_LG` subclasses `Num2Word_Base` directly, **not** `Num2Word_EUR`,
521/// and declares its own `CURRENCY_FORMS` in the class body. So the shared-dict
522/// mutation `Num2Word_EN.__init__` performs (which rewrites EUR/GBP and adds
523/// ~24 codes onto `Num2Word_EUR`'s table at import time) cannot reach here:
524/// LG's EUR really is `("yuro", "yuro")`, and GBP really is absent. Confirmed
525/// against the live interpreter, and by the corpus — `currency:GBP` renders
526/// "shilingi" via the fallback rather than "pound".
527fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
528    const SENTI: [&str; 2] = ["senti", "senti"];
529    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
530    m.insert("UGX", CurrencyForms::new(&["shilingi", "shilingi"], &SENTI));
531    m.insert("USD", CurrencyForms::new(&["doola", "doola"], &SENTI));
532    m.insert("EUR", CurrencyForms::new(&["yuro", "yuro"], &SENTI));
533    m
534}
535
536pub struct LangLg {
537    currency_forms: HashMap<&'static str, CurrencyForms>,
538    /// `list(self.CURRENCY_FORMS.values())[0]` — `to_currency`'s fallback for
539    /// an unknown code.
540    ///
541    /// Python dicts iterate in insertion order, and LG's class body opens with
542    /// `"UGX"`, so the first value is UGX's forms. Pinned as a field rather
543    /// than recomputed: a `HashMap` has no first entry, and re-deriving "first"
544    /// from an unordered map would be a coin flip. Verified against the live
545    /// interpreter (`list(c.CURRENCY_FORMS.values())[0]` -> shilingi/senti).
546    fallback_forms: CurrencyForms,
547}
548
549impl Default for LangLg {
550    fn default() -> Self {
551        Self::new()
552    }
553}
554
555impl LangLg {
556    pub fn new() -> Self {
557        // `setup()` only assigns constant tables, which live as `const`s above.
558        // There is no per-instance state and no cross-call mutable flag.
559        //
560        // The currency tables are built once, here: `to_currency` only ever
561        // reads them, and rebuilding them per call is what made an earlier
562        // revision of this port slower than the Python it replaces.
563        LangLg {
564            currency_forms: build_currency_forms(),
565            fallback_forms: CurrencyForms::new(
566                &["shilingi", "shilingi"],
567                &["senti", "senti"],
568            ),
569        }
570    }
571}
572
573impl Lang for LangLg {
574
575    fn cardinal_float_entry(
576        &self,
577        value: &crate::floatpath::FloatValue,
578        precision_override: Option<u32>,
579    ) -> crate::base::Result<String> {
580        // Python's to_cardinal routes every float/Decimal through this
581        // language's own decimal grammar — 5.0 keeps its ".0" tail
582        // ("comma nulla"), unlike Base's whole-value integer route.
583        self.to_cardinal_float(value, precision_override)
584    }
585
586    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
587    /// `"ow' " + to_cardinal(number)` for *any* input (no
588    /// `verify_ordinal`), so the float path is the float cardinal put through
589    /// the same literal transformation: `5.0` -> "ow' ttaano akatonnyeze nuli".
590    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
591    /// before the transformation, exactly as in Python.
592    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
593        let cardinal = self.cardinal_float_entry(value, None)?;
594        Ok(format!("ow' {}", cardinal))
595    }
596
597    /// `to_ordinal_num(float/Decimal)`: `"ow' " + str(number)`. `repr_str` is the
598    /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
599    /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
600    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
601        Ok(format!("ow' {}", repr_str))
602    }
603
604    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
605    /// Infinity sentinel becomes the ValueError this language's own
606    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
607    /// fails); the shared dispatcher would otherwise report Base's
608    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
609    /// ValueError for it already matches `int("NaN")`.
610    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
611        match crate::strnum::python_decimal_parse(s)? {
612            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
613                "invalid literal for int() with base 10: 'Infinity'".into(),
614            )),
615            p => Ok(p),
616        }
617    }
618
619    /// This language's own `to_currency(currency=...)` default,
620    /// read from the live Python signature. Only 44 of 156 use EUR.
621    fn default_currency(&self) -> &str {
622        "UGX"
623    }
624
625    /// This language's own `to_currency(separator=...)` default,
626    /// read from the live Python signature. Base's is ",", but only
627    /// 36 of 149 languages actually use it — most default to " " or a
628    /// conjunction, so inheriting Base's comma silently corrupts them.
629    fn default_separator(&self) -> &str {
630        " "
631    }
632
633    // cards() / maxval() / merge() stay at their trait defaults: LG never
634    // builds a card table and the default to_cardinal is fully overridden, so
635    // splitnum/clean/merge are unreachable.
636
637    fn negword(&self) -> &str {
638        NEGWORD
639    }
640
641    fn pointword(&self) -> &str {
642        "akatonnyeze"
643    }
644
645    /// Python:
646    /// ```python
647    /// n = str(number).strip()
648    /// if n.startswith("-"):
649    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
650    /// ...
651    /// return self._int_to_word(int(n))
652    /// ```
653    /// Integer input never contains ".", so the fraction branch is skipped.
654    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
655        if value.is_negative() {
656            // The recursion in Python re-enters to_cardinal with the digits
657            // only, which lands straight on `_int_to_word(int(n))`.
658            let inner = int_to_word(&value.abs());
659            // Python applies .strip() to the whole concatenation. NEGWORD's
660            // trailing space is interior by then, so only the (already absent)
661            // outer whitespace is affected — applied anyway for fidelity.
662            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
663        }
664        // Note: the non-negative branch has NO .strip() in Python. It makes no
665        // difference (int_to_word never pads), but the asymmetry is real.
666        Ok(int_to_word(value))
667    }
668
669    /// Python: `return "ow' " + self.to_cardinal(number)`.
670    ///
671    /// Negatives are accepted rather than rejected — LG defines no
672    /// `errmsg_negord` guard — so `to_ordinal(-1)` == "ow' wansi wa emu".
673    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
674        Ok(format!("ow' {}", self.to_cardinal(value)?))
675    }
676
677    /// Python: `return "ow' " + str(number)`. The number is never converted to
678    /// words, and the sign survives: `to_ordinal_num(-1)` == "ow' -1".
679    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
680        Ok(format!("ow' {}", value))
681    }
682
683    /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
684    /// `longval` is ignored; years are plain cardinals.
685    fn to_year(&self, value: &BigInt) -> Result<String> {
686        self.to_cardinal(value)
687    }
688
689    /// The float / Decimal cardinal path.
690    ///
691    /// LG **overrides `to_cardinal`**, not `to_cardinal_float`: in Python a float
692    /// or Decimal reaches the converter as `to_cardinal(number)`, which renders
693    /// `str(number)` directly with no `float2tuple`, no rounding, and no
694    /// `precision`. This override reconstructs `str(number)` and runs that exact
695    /// string algorithm (see [`cardinal_from_pystr`]).
696    ///
697    /// Consequences worth stating:
698    /// * Neither the banker's-rounding trap nor the f64-artefact trap applies —
699    ///   LG reads the `repr` digits, not `abs(value - pre) * 10**precision`.
700    /// * The `precision=` kwarg is irrelevant and `precision_override` is
701    ///   ignored: Python's `to_cardinal(self, number)` takes no such argument, so
702    ///   passing `precision=` raises `TypeError` upstream of this call rather
703    ///   than altering the output here. The corpus never exercises it.
704    /// * The Float / Decimal split is load-bearing: `str(1.1)` is "1.1" (one
705    ///   fractional digit) but `str(Decimal("1.10"))` is "1.10" (trailing zero
706    ///   kept) — different words. `precision` is likewise unused for the Decimal
707    ///   arm; the coefficient/exponent carried by the `BigDecimal` is the spec.
708    fn to_cardinal_float(
709        &self,
710        value: &FloatValue,
711        _precision_override: Option<u32>,
712    ) -> Result<String> {
713        let n = match value {
714            FloatValue::Float { value, .. } => py_float_str(*value),
715            FloatValue::Decimal { value, .. } => py_decimal_str(value),
716        };
717        cardinal_from_pystr(&n)
718    }
719
720    // ---- currency -------------------------------------------------------
721    //
722    // LG overrides `to_currency` and `pluralize`, nothing else. `to_cheque`,
723    // `_money_verbose`, `_cents_verbose` and `_cents_terse` are Base's, which
724    // the trait defaults already mirror exactly — including `to_cheque`'s
725    // `unit = cr1[-1]` plural rule and its KeyError -> NotImplementedError
726    // conversion. `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both `{}`,
727    // so `currency_adjective` (None) and `currency_precision` (100) are correct
728    // at their defaults too.
729    //
730    // `cardinal_from_decimal` stays at its default: LG's `to_currency` truncates
731    // to two fractional digits with `parts[1][:2]` and has no fractional-cents
732    // concept at all, so nothing can reach it.
733
734    fn lang_name(&self) -> &str {
735        "Num2Word_LG"
736    }
737
738    /// The **strict** lookup — `CURRENCY_FORMS[code]`, the one `to_cheque`
739    /// performs. `None` here is what turns `cheque:GBP` into
740    /// `NotImplementedError`, matching the corpus.
741    ///
742    /// `to_currency` must *not* come through here: it applies a fallback and
743    /// never raises. See [`LangLg::to_currency`].
744    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
745        self.currency_forms.get(code)
746    }
747
748    /// Python:
749    /// ```python
750    /// def pluralize(self, n, forms):
751    ///     if not forms:
752    ///         return ""
753    ///     return forms[0] if n == 1 else forms[-1]
754    /// ```
755    ///
756    /// Note `forms[-1]`, the *last* form, not `forms[1]`; and the empty-tuple
757    /// guard, which means this can never raise. Dead code for LG as it stands —
758    /// `to_currency` is overridden and inlines its own `cr[1]`/`cr[0]` choice,
759    /// and Base's `to_cheque` does not consult `pluralize` — but ported because
760    /// the class defines it.
761    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
762        if forms.is_empty() {
763            return Ok(String::new());
764        }
765        Ok(if n.is_one() {
766            forms[0].clone()
767        } else {
768            forms[forms.len() - 1].clone()
769        })
770    }
771
772    /// Python:
773    /// ```python
774    /// def to_currency(self, val, currency="UGX", cents=True,
775    ///                 separator=" ", adjective=False):
776    ///     is_negative = val < 0
777    ///     val = abs(val)
778    ///     parts = str(val).split(".")
779    ///     left = int(parts[0]) if parts[0] else 0
780    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
781    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
782    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
783    ///     if cents and right:
784    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
785    ///     if is_negative:
786    ///         result = self.negword + result
787    ///     return result.strip()
788    /// ```
789    ///
790    /// `adjective` is in the signature and never read — `CURRENCY_ADJECTIVES`
791    /// is empty anyway — so it is accepted and dropped, as in Python.
792    ///
793    /// The `currency="UGX"` default is not modelled: the trait takes `currency`
794    /// unconditionally and the shim always supplies it, so the default has no
795    /// reachable call site.
796    ///
797    /// Note what is *absent*: no `parse_currency_parts`, no divisor, no
798    /// `has_decimal` guard, no rounding. `right` is two truncated digits of
799    /// text, and `if cents and right` keys off `right == 0` — which subsumes
800    /// `has_decimal`, since `1.0` and `Decimal("1.00")` both slice to `"00"`
801    /// and drop the cents segment exactly as a true `int` does. That is why
802    /// this override ignores `has_decimal` entirely rather than threading it
803    /// through.
804    fn to_currency(
805        &self,
806        val: &CurrencyValue,
807        currency: &str,
808        cents: bool,
809        separator: Option<&str>,
810        _adjective: bool,
811    ) -> Result<String> {
812        // Already resolved through `default_separator()` (" ") when the caller
813        // omitted the kwarg.
814        let separator = separator.unwrap_or(self.default_separator());
815
816        // Python: is_negative = val < 0  (then `val = abs(val)`, folded below).
817        // `-0.0 < 0` is False and bigdecimal has no signed zero, so both sides
818        // agree that -0.0 is non-negative.
819        let is_negative = val.is_negative();
820
821        let (left, right) = match val {
822            // `str(abs(int))` is bare digits: one part, so `right` stays 0 and
823            // `int(parts[0])` round-trips to the value. Reproducing the text
824            // would be a no-op, so the shortcut is exact rather than an
825            // approximation. This is also the only branch that can carry a
826            // value past 10^9, where `_int_to_word` degrades to digits.
827            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
828            // Python: str(abs(val)).split("."), on the text repr produced.
829            CurrencyValue::Decimal { value, .. } => {
830                let (digits, scale) = value.abs().as_bigint_and_exponent();
831                let s = py_str(&digits, scale);
832                let mut parts = s.splitn(2, '.');
833                let p0 = parts.next().unwrap_or("");
834                let p1 = parts.next();
835
836                // left = int(parts[0]) if parts[0] else 0
837                let left = if p0.is_empty() {
838                    BigInt::zero()
839                } else {
840                    py_int(p0)?
841                };
842                // right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
843                let right = match p1 {
844                    Some(f) if !f.is_empty() => {
845                        // `[:2]` slices *characters*; `ljust(2, "0")` pads right
846                        // to exactly 2 since the slice is at most 2 long.
847                        let head: String = f.chars().take(2).collect();
848                        py_int(&format!("{:0<2}", head))?
849                    }
850                    _ => BigInt::zero(),
851                };
852                (left, right)
853            }
854        };
855
856        // Python: CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])
857        // — `.get` with a default, so an unknown code silently becomes UGX
858        // rather than raising. `currency_forms()` above is the strict lookup
859        // and is deliberately bypassed here.
860        let forms = self
861            .currency_forms
862            .get(currency)
863            .unwrap_or(&self.fallback_forms);
864
865        let mut result = format!(
866            "{} {}",
867            int_to_word(&left),
868            pick_form(&forms.unit, &left)?
869        );
870
871        // Python: `if cents and right:` — a zero `right` is falsy, so ".00"
872        // never prints a cents segment.
873        if cents && !right.is_zero() {
874            result.push_str(separator);
875            result.push_str(&int_to_word(&right));
876            result.push(' ');
877            result.push_str(&pick_form(&forms.subunit, &right)?);
878        }
879
880        if is_negative {
881            // negword keeps its trailing space; it is interior after this.
882            result = format!("{}{}", NEGWORD, result);
883        }
884
885        // Python's .strip(). Nothing here can pad the ends — every form and
886        // every `_int_to_word` result is non-empty — but applied for fidelity.
887        Ok(result.trim().to_string())
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use bigdecimal::BigDecimal;
895
896    /// `py_str`'s load-bearing premise, and the one thing here that a
897    /// dependency bump could silently break: `bigdecimal` parses `str(value)`
898    /// **without normalising**, so `as_bigint_and_exponent` hands back exactly
899    /// the digits `repr` chose. If a future version ever normalised (stripping
900    /// `1.0` to `(1, 0)`, or canonicalising `1e+16` to scale 0), `py_str` would
901    /// start reconstructing a string Python never produced.
902    #[test]
903    fn bigdecimal_does_not_normalise() {
904        for (s, want_digits, want_scale) in [
905            ("1.0", "10", 1i64),
906            ("0.5", "5", 1),
907            ("0.01", "1", 2),
908            ("12.34", "1234", 2),
909            ("1234.56", "123456", 2),
910            ("1.50", "150", 2),
911            ("1e+16", "1", -16),
912            ("1e+21", "1", -21),
913            ("1.25e+20", "125", -18),
914            ("1e-05", "1", 5),
915            ("1000000000000000.0", "10000000000000000", 1),
916        ] {
917            let (d, sc) = BigDecimal::from_str(s).unwrap().as_bigint_and_exponent();
918            assert_eq!((d.to_string().as_str(), sc), (want_digits, want_scale), "{s}");
919        }
920    }
921
922    /// `py_str(parse(repr(x))) == repr(x)` across CPython's layout boundary.
923    /// Every string here is a literal `repr()` output taken from the
924    /// interpreter, including both edges (1e15 fixed / 1e16 exponential, and
925    /// 0.0001 fixed / 1e-05 exponential).
926    #[test]
927    fn py_str_round_trips_repr() {
928        for s in [
929            "0.5", "0.01", "1.0", "12.34", "99.99", "1234.56", "100.0", "0.0001",
930            "1000000000000000.0", "1e+16", "1.5e+16", "1e+21", "1.25e+20",
931            "1.5e+20", "1e-05", "1.5e-05", "1e+100", "0.0",
932        ] {
933            let (d, sc) = BigDecimal::from_str(s).unwrap().as_bigint_and_exponent();
934            assert_eq!(py_str(&d, sc), s);
935        }
936    }
937
938    fn currency(v: &str, is_int: bool, code: &str) -> Result<String> {
939        let val = CurrencyValue::parse(v, is_int, !is_int, !is_int).unwrap();
940        LangLg::new().to_currency(&val, code, true, None, false)
941    }
942
943    /// Corpus rows, byte for byte.
944    #[test]
945    fn to_currency_matches_corpus() {
946        for (arg, is_int, code, want) in [
947            ("0", true, "EUR", "nuli yuro"),
948            ("1", true, "EUR", "emu yuro"),
949            ("100", true, "EUR", "kikumi yuro"),
950            ("1000000", true, "EUR", "kakadde yuro"),
951            ("12.34", false, "EUR", "kkumi mu bbiri yuro asatu mu nnya senti"),
952            ("0.01", false, "EUR", "nuli yuro emu senti"),
953            // A float that is whole still takes the int-shaped output, because
954            // ".0" slices to right == 0 rather than because of `has_decimal`.
955            ("1.0", false, "EUR", "emu yuro"),
956            ("0.5", false, "EUR", "nuli yuro ataano senti"),
957            ("-12.34", false, "EUR", "wansi wa kkumi mu bbiri yuro asatu mu nnya senti"),
958            (
959                "1234.56",
960                false,
961                "USD",
962                "lukumi mu bbiri kikumi mu asatu mu nnya doola ataano mu mukaaga senti",
963            ),
964            // Unknown codes fall back to UGX rather than raising — the
965            // `dict.get(code, first_value)` quirk.
966            ("1", true, "GBP", "emu shilingi"),
967            ("12.34", false, "JPY", "kkumi mu bbiri shilingi asatu mu nnya senti"),
968            // No CURRENCY_PRECISION entry exists, so KWD is *not* 3-decimal.
969            ("0.5", false, "KWD", "nuli shilingi ataano senti"),
970            ("1", true, "ZZZ", "emu shilingi"),
971        ] {
972            assert_eq!(currency(arg, is_int, code).unwrap(), want, "{arg} {code}");
973        }
974    }
975
976    /// `str(val)`-slicing artefacts once `repr` goes exponential. Verified
977    /// against the live interpreter; see the module docs.
978    #[test]
979    fn to_currency_reproduces_repr_slicing_bugs() {
980        // int("1e+16") / int("5e") -> ValueError.
981        for arg in ["1e+16", "1e+21", "1.5e+20", "1e-05", "1.5e-05"] {
982            match currency(arg, false, "EUR") {
983                Err(N2WError::Value(_)) => {}
984                other => panic!("{arg}: expected ValueError, got {other:?}"),
985            }
986        }
987        // ...but int("25") succeeds, so 1.25e20 euros is one euro 25 cents.
988        assert_eq!(
989            currency("1.25e+20", false, "EUR").unwrap(),
990            "emu yuro abiri mu ttaano senti"
991        );
992        // A true int never gets stringified, so it is immune: no ValueError,
993        // just _int_to_word's bare-digits fallback past 10^9.
994        assert_eq!(
995            currency("10000000000000000", true, "EUR").unwrap(),
996            "10000000000000000 yuro"
997        );
998    }
999
1000    fn card_f(f: f64) -> Result<String> {
1001        LangLg::new().to_cardinal_float(&FloatValue::Float { value: f, precision: 0 }, None)
1002    }
1003
1004    fn card_d(s: &str) -> Result<String> {
1005        let v = FloatValue::Decimal {
1006            value: BigDecimal::from_str(s).unwrap(),
1007            precision: 0,
1008        };
1009        LangLg::new().to_cardinal_float(&v, None)
1010    }
1011
1012    /// Float `cardinal` rows (arg with a dot), byte for byte from the corpus.
1013    /// LG reads `str(number)` == `repr`, so 1.005 / 2.675 need no f64-artefact
1014    /// rescue: `{:e}` of the f64 already spells the shortest `repr` digits.
1015    #[test]
1016    fn to_cardinal_float_matches_corpus() {
1017        for (f, want) in [
1018            (0.0, "nuli akatonnyeze nuli"),
1019            (0.5, "nuli akatonnyeze ttaano"),
1020            (1.0, "emu akatonnyeze nuli"),
1021            (1.5, "emu akatonnyeze ttaano"),
1022            (2.25, "bbiri akatonnyeze bbiri ttaano"),
1023            (3.14, "ssatu akatonnyeze emu nnya"),
1024            (0.01, "nuli akatonnyeze nuli emu"),
1025            (0.1, "nuli akatonnyeze emu"),
1026            (0.99, "nuli akatonnyeze mwenda mwenda"),
1027            (1.01, "emu akatonnyeze nuli emu"),
1028            (12.34, "kkumi mu bbiri akatonnyeze ssatu nnya"),
1029            (99.99, "kyenda mu mwenda akatonnyeze mwenda mwenda"),
1030            (100.5, "kikumi akatonnyeze ttaano"),
1031            (
1032                1234.56,
1033                "lukumi mu bbiri kikumi mu asatu mu nnya akatonnyeze ttaano mukaaga",
1034            ),
1035            (-0.5, "wansi wa nuli akatonnyeze ttaano"),
1036            (-1.5, "wansi wa emu akatonnyeze ttaano"),
1037            (-12.34, "wansi wa kkumi mu bbiri akatonnyeze ssatu nnya"),
1038            // The two f64-artefact traps: str(number) sidesteps them entirely.
1039            (1.005, "emu akatonnyeze nuli nuli ttaano"),
1040            (2.675, "bbiri akatonnyeze mukaaga musanvu ttaano"),
1041        ] {
1042            assert_eq!(card_f(f).unwrap(), want, "{f}");
1043        }
1044    }
1045
1046    /// `-0.0` keeps its minus in `repr` ("-0.0"), so LG prepends negword —
1047    /// verified against the live interpreter.
1048    #[test]
1049    fn to_cardinal_float_negative_zero() {
1050        assert_eq!(card_f(-0.0).unwrap(), "wansi wa nuli akatonnyeze nuli");
1051    }
1052
1053    /// Once `repr` goes exponential the string algorithm feeds `int()` a
1054    /// non-numeric slice and Python raises `ValueError`; reproduced here.
1055    #[test]
1056    fn to_cardinal_float_exponential_raises() {
1057        // str==="1e+16"/"1e+21"/"1e-05": no dot, int("1e+16") -> ValueError.
1058        // str==="1.5e+20": dot, but int("e") in the fraction loop -> ValueError.
1059        for f in [1e16, 1e21, 1e-5, 1.5e20, 1.25e20] {
1060            match card_f(f) {
1061                Err(N2WError::Value(_)) => {}
1062                other => panic!("{f}: expected ValueError, got {other:?}"),
1063            }
1064        }
1065    }
1066
1067    /// Decimal `cardinal_dec` rows, byte for byte. The Decimal arm is exact:
1068    /// "1.10" keeps its trailing zero (unlike float 1.1), and issue-#603's
1069    /// trillion-scale value survives without a float() cast.
1070    #[test]
1071    fn to_cardinal_decimal_matches_corpus() {
1072        for (s, want) in [
1073            ("0.01", "nuli akatonnyeze nuli emu"),
1074            ("1.10", "emu akatonnyeze emu nuli"),
1075            ("12.345", "kkumi mu bbiri akatonnyeze ssatu nnya ttaano"),
1076            ("98746251323029.99", "98746251323029 akatonnyeze mwenda mwenda"),
1077            ("0.001", "nuli akatonnyeze nuli nuli emu"),
1078            // Integral Decimal: str is "5" (no dot) -> plain _int_to_word.
1079            ("5", "ttaano"),
1080            ("0.0", "nuli akatonnyeze nuli"),
1081        ] {
1082            assert_eq!(card_d(s).unwrap(), want, "{s}");
1083        }
1084    }
1085
1086    /// `py_decimal_str` follows Decimal's own `__str__`, not `py_str`'s float
1087    /// rule: uppercase 'E', and fixed-point down to exponent -6.
1088    #[test]
1089    fn py_decimal_str_follows_decimal_rule() {
1090        for (s, want) in [
1091            ("0.01", "0.01"),
1092            ("1.10", "1.10"),
1093            ("98746251323029.99", "98746251323029.99"),
1094            ("5", "5"),
1095            ("0.0", "0.0"),
1096            // Fixed-point holds to exp -6 (float repr would go exponential at -5).
1097            ("0.00001", "0.00001"),
1098            // Beyond that, and for positive exponents, scientific with 'E'.
1099            ("1E-7", "1E-7"),
1100            ("1E+2", "1E+2"),
1101        ] {
1102            let d = BigDecimal::from_str(s).unwrap();
1103            assert_eq!(py_decimal_str(&d), want, "{s}");
1104        }
1105    }
1106
1107    /// to_cheque is Base's: it raises where to_currency silently falls back.
1108    #[test]
1109    fn to_cheque_matches_corpus() {
1110        let lg = LangLg::new();
1111        let v = BigDecimal::from_str("1234.56").unwrap();
1112        assert_eq!(
1113            lg.to_cheque(&v, "EUR").unwrap(),
1114            "LUKUMI MU BBIRI KIKUMI MU ASATU MU NNYA AND 56/100 YURO"
1115        );
1116        assert_eq!(
1117            lg.to_cheque(&v, "USD").unwrap(),
1118            "LUKUMI MU BBIRI KIKUMI MU ASATU MU NNYA AND 56/100 DOOLA"
1119        );
1120        // Same code that to_currency renders as "shilingi" raises here.
1121        match lg.to_cheque(&v, "GBP") {
1122            Err(N2WError::NotImplemented(m)) => {
1123                assert_eq!(m, "Currency code \"GBP\" not implemented for \"Num2Word_LG\"")
1124            }
1125            other => panic!("expected NotImplementedError, got {other:?}"),
1126        }
1127        assert_eq!(currency("1", true, "GBP").unwrap(), "emu shilingi");
1128    }
1129}