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}