num2words2_core/lang_xh.rs
1//! Port of `lang_XH.py` (Xhosa).
2//!
3//! Shape: **self-contained**. `Num2Word_XH` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` guard in `Num2Word_Base.__init__` never fires: Python builds
6//! neither `self.cards` nor `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives a hand-written `_int_to_word` recursion.
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here,
9//! and there is **no overflow check** — see bug 1 below for what happens
10//! instead of an `OverflowError`.
11//!
12//! Inherited from `Num2Word_Base` and left alone by XH:
13//! * `is_title` stays `False` (set in `__init__`, never overridden), so
14//! `title()` is a no-op and the `exclude_title` list XH populates in
15//! `setup()` is dead weight. The trait default `is_title() -> false`
16//! reproduces this, so `exclude_title` is not modelled.
17//!
18//! XH overrides all four in-scope entry points (`to_cardinal`, `to_ordinal`,
19//! `to_ordinal_num`, `to_year`), so no base-class conversion logic is reached.
20//!
21//! # Faithfully reproduced Python bugs
22//!
23//! This is a port, not a rewrite. All of the following are wrong-looking but
24//! are exactly what Python emits, verified against the frozen corpus:
25//!
26//! 1. **`_int_to_word` gives up at 10^9 and returns the bare digits.** The
27//! final `return str(number)` means `to_cardinal(10**9) == "1000000000"` —
28//! a decimal numeral, not words. No `OverflowError` is raised, because XH
29//! never sets `MAXVAL`. Corpus confirms this all the way to 10^21, so the
30//! fallback must stay a `BigInt::to_string()` and must never be capped.
31//! 2. **`to_ordinal` blindly prefixes "we"** to the cardinal for every value
32//! except exactly 1. This yields `to_ordinal(0) == "weiqanda"` and, for
33//! negatives, `to_ordinal(-1) == "wengaphantsi kwe nye"` — "we" glued onto
34//! the negative word "ngaphantsi" ("below"). Preserved verbatim.
35//! 3. **`to_ordinal_num` does no formatting at all**: `"we" + str(number)`,
36//! so `to_ordinal_num(-1) == "we-1"` (sign kept, glued on).
37//! 4. `self.ones[0]` is `""`. It is unreachable on the integer path
38//! (`_int_to_word` returns "iqanda" for 0 before the `< 10` branch), and is
39//! reachable only via the float path's `self.ones[int(digit)] or "qanda"`,
40//! where the empty string is falsy and the fraction digit 0 therefore
41//! renders as the filler "qanda" (a *different* word from the integer
42//! zero "iqanda"). See [`LangXh::to_cardinal_float`]. Kept in [`ONES`] to
43//! preserve the indexing.
44//!
45//! # Float / Decimal cardinal path
46//!
47//! XH does **not** use `Num2Word_Base.to_cardinal_float`/`float2tuple`. Its
48//! overridden `to_cardinal` handles non-integers inline off `str(number)`:
49//! split on the first ".", speak the integer part through `_int_to_word`, emit
50//! the pointword "ichaphaza", then read each fraction *digit* left-to-right.
51//! Because the digits come straight from `str(number)` there is no
52//! banker's-rounding or f64-artefact rescue: `2.675` speaks its literal repr
53//! digits 6,7,5, and `1.005` speaks 0,0,5. See [`LangXh::to_cardinal_float`].
54//!
55//! And because the route is the *string* form, it applies to every
56//! float/Decimal, whole values included: `to_cardinal(5.0)` is
57//! "ntlanu ichaphaza qanda", never "ntlanu" — Base's `int(value) == value`
58//! whole-value shortcut does not exist here. [`LangXh::cardinal_float_entry`]
59//! pins that full routing (`year_float_entry` inherits it, matching `to_year`
60//! == `to_cardinal`); [`LangXh::ordinal_float_entry`] reproduces `to_ordinal`'s
61//! `number == 1` test and "we" prefix on the same path (`to_ordinal(1.0)` ==
62//! "okokuqala" because `1.0 == 1`, `to_ordinal(5.0)` == "wentlanu ichaphaza
63//! qanda"); [`LangXh::ordinal_num_float_entry`] is `"we" + str(number)`, so
64//! "we5.0" and even "we1e+16" are real outputs.
65//!
66//! # String inputs: Infinity/NaN punt to Python
67//!
68//! XH inherits Base's `str_to_number` (`Decimal(value)`), which parses
69//! `"Infinity"`/`"NaN"` *successfully* — the failure only happens later,
70//! inside XH's `to_cardinal` (`int("Infinity")` → ValueError; "-Infinity"
71//! recurses through the negword branch first, so the message always quotes
72//! 'Infinity') — while `to_ordinal_num("Infinity")` is *answered*, with
73//! "weInfinity". The Rust dispatcher hard-codes Base semantics for non-finite
74//! parses (OverflowError), which XH never executes, so
75//! [`LangXh::str_to_number`] bounces them back to the pure-Python original
76//! via NotImplemented; the fallback then raises (or returns) the byte-exact
77//! original per mode.
78//!
79//! Because everything keys on `str(number)`, *scientific-notation* strings
80//! crash the digit loop — a Python bug reproduced here (bug 10 below):
81//!
82//! 10. **Values whose `str()` uses exponent notation raise ValueError.**
83//! A float below 10^-4 reprs as `'1e-05'`/`'1.5e-05'`; a Decimal whose
84//! adjusted exponent is `< -6` prints as `'1E-7'`/`'1.5E-7'` (note
85//! `str(Decimal("0.0000001")) == "1E-7"` — Python *normalises* it into
86//! exponent form). With no "." the whole token hits `int()`
87//! (`ValueError: invalid literal for int() with base 10: '1e-05'`); with
88//! a "." the digit loop reaches the exponent marker and dies on
89//! `int('e')` (lowercase for floats, uppercase `int('E')` for Decimals).
90//! The reconstruction below therefore mirrors *both* of CPython's
91//! switchover rules so the same inputs fail with the same messages.
92//!
93//! # Currency
94//!
95//! XH overrides `to_currency` **wholesale** and shares almost nothing with
96//! `Num2Word_Base`'s version — see [`LangXh::to_currency`]. It inherits
97//! `to_cheque` unchanged, so the two disagree with each other about what an
98//! unknown currency code means (bug 5 below).
99//!
100//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both left at
101//! `Num2Word_Base`'s empty dicts, so `CURRENCY_PRECISION.get(code, 100)` is
102//! *always* 100 and `adjective and currency in self.CURRENCY_ADJECTIVES` is
103//! always false. The trait defaults already say exactly that, so neither hook
104//! is overridden here. The practical effect: JPY is not treated as a 0-decimal
105//! currency and KWD/BHD are not treated as 3-decimal ones — everything gets
106//! two decimal places. The corpus pins this.
107//!
108//! # Faithfully reproduced Python bugs (currency)
109//!
110//! 5. **An unknown currency code silently becomes ZAR in `to_currency`, but
111//! raises in `to_cheque`.** XH's `to_currency` looks the code up with
112//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
113//! — a `.get` with a *default*, not a `[]` — so `to_currency(1, "JPY")` is
114//! "nye randi", quietly denominated in rand. Inherited `to_cheque` still
115//! does `self.CURRENCY_FORMS[currency]` and raises NotImplementedError for
116//! the same code. Both halves are pinned by the corpus.
117//! 6. **The plural form is chosen by direct tuple index, not `pluralize`.**
118//! `cr1[1] if left != 1 else cr1[0]` bypasses the `pluralize` XH defines,
119//! leaving that method dead code on every path (ported anyway, see
120//! [`LangXh::pluralize`]).
121//! 7. **`adjective` is accepted and ignored.** It is not even read, so
122//! `adjective=True` changes nothing regardless of `CURRENCY_ADJECTIVES`.
123//! 8. **Cents come from string slicing, so they truncate rather than round**:
124//! `int(parts[1][:2].ljust(2, "0"))`. `1.005` -> `"00"` -> 0 cents ->
125//! "nye randi" with no cents segment at all; `1.999` -> `"99"` -> 99.
126//! 9. **The separator is glued straight onto the next word.** Python does
127//! `result += separator + self._int_to_word(right)`, with no space between
128//! them, so `separator=" na"` yields "...iyuro naamashumi amathathu...".
129//! Only the default `separator=" "` looks right.
130//!
131//! # Error variants
132//!
133//! * The four integer modes cannot raise: no `MAXVAL` check, no dict lookup,
134//! and every list index is bounded by an enclosing magnitude test. The
135//! unreachable `expect`s in [`digit`] document that reasoning.
136//! * `to_currency` cannot raise either — the `.get` default removes the only
137//! dict lookup (see bug 5), and the plural index is provably in range for
138//! every entry in the table.
139//! * `to_cheque` raises `NotImplemented` for a code outside the table, via the
140//! inherited `Num2Word_Base.to_cheque`. That is the only reachable error on
141//! the currency surface.
142//!
143//! # Cross-call mutable state
144//!
145//! None. `setup()` only assigns constant tables; no method sets a flag that
146//! another consumes.
147
148use crate::base::{Lang, N2WError, Result};
149use crate::currency::{CurrencyForms, CurrencyValue};
150use crate::floatpath::FloatValue;
151use crate::strnum::python_decimal_str;
152use bigdecimal::BigDecimal;
153use num_bigint::BigInt;
154use num_integer::Integer;
155use num_traits::{One, Signed, ToPrimitive, Zero};
156use std::collections::HashMap;
157
158/// `self.negword`. The trailing space is load-bearing — Python concatenates
159/// it directly (`self.negword + self.to_cardinal(...)`) rather than joining.
160const NEGWORD: &str = "ngaphantsi kwe ";
161
162/// `self.pointword`. Only reachable on the out-of-scope float path; kept so
163/// the trait's `pointword()` reports what `setup()` actually installs.
164const POINTWORD: &str = "ichaphaza";
165
166/// `_int_to_word(0)`. Note this is a *different* word from the float path's
167/// zero-digit filler "qanda".
168const ZERO_WORD: &str = "iqanda";
169
170/// `self.ones`. Index 0 is `""` in Python — see bug 4.
171const ONES: [&str; 10] = [
172 "", "nye", "mbini", "ntathu", "ne", "ntlanu", "ntandathu", "sixhenxe", "sibhozo", "lithoba",
173];
174
175/// `self.tens`. Index 0 is `""` and is unreachable (`number < 100` implies
176/// `t >= 1` once `number >= 10`).
177const TENS: [&str; 10] = [
178 "",
179 "lishumi",
180 "amashumi amabini",
181 "amashumi amathathu",
182 "amashumi amane",
183 "amashumi amahlanu",
184 "amashumi amathandathu",
185 "amashumi asixhenxe",
186 "amashumi asibhozo",
187 "amashumi alithoba",
188];
189
190const HUNDRED: &str = "ikhulu";
191const THOUSAND: &str = "iwaka";
192const MILLION: &str = "isigidi";
193
194/// Narrow a provably-small `BigInt` to a table index.
195///
196/// Every call site is guarded by an enclosing magnitude test that bounds the
197/// value to 0..=9, so the `expect` is unreachable. It is an `expect` rather
198/// than an `unwrap_or(0)` because a silent 0 would emit `""` and corrupt the
199/// output instead of failing loudly.
200fn digit(n: &BigInt) -> usize {
201 n.to_usize()
202 .expect("XH: table index provably in 0..=9 by the enclosing bound")
203}
204
205/// Python's `Num2Word_XH._int_to_word`.
206///
207/// Faithful to the original's cascade of magnitude tests. The input is always
208/// non-negative: `to_cardinal` strips the sign before recursing, and every
209/// internal recursion passes a quotient or remainder of a non-negative value.
210fn int_to_word(number: &BigInt) -> String {
211 if number.is_zero() {
212 return ZERO_WORD.to_string();
213 }
214
215 let ten = BigInt::from(10);
216 let hundred = BigInt::from(100);
217 let thousand = BigInt::from(1000);
218 let million = BigInt::from(1_000_000);
219 let billion = BigInt::from(1_000_000_000);
220 let one = BigInt::one();
221
222 // if number < 10: return self.ones[number]
223 if number < &ten {
224 return ONES[digit(number)].to_string();
225 }
226
227 // if number < 100:
228 // t, o = divmod(number, 10)
229 // return self.tens[t] + (" ana " + self.ones[o] if o else "")
230 if number < &hundred {
231 let (t, o) = number.div_mod_floor(&ten);
232 let mut out = TENS[digit(&t)].to_string();
233 if !o.is_zero() {
234 out.push_str(" ana ");
235 out.push_str(ONES[digit(&o)]);
236 }
237 return out;
238 }
239
240 // if number < 1000:
241 // h, r = divmod(number, 100)
242 // base = (self.ones[h] + " " if h > 1 else "") + self.hundred
243 // return base + (" na " + self._int_to_word(r) if r else "")
244 //
245 // Note the asymmetry with the thousand/million branches below: the
246 // hundreds branch indexes `self.ones[h]` directly instead of recursing,
247 // which is safe only because `h` is bounded to 1..=9 here.
248 if number < &thousand {
249 let (h, r) = number.div_mod_floor(&hundred);
250 let mut out = String::new();
251 if h > one {
252 out.push_str(ONES[digit(&h)]);
253 out.push(' ');
254 }
255 out.push_str(HUNDRED);
256 if !r.is_zero() {
257 out.push_str(" na ");
258 out.push_str(&int_to_word(&r));
259 }
260 return out;
261 }
262
263 // if number < 1000000:
264 // t, r = divmod(number, 1000)
265 // base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand
266 // return base + (" na " + self._int_to_word(r) if r else "")
267 if number < &million {
268 let (t, r) = number.div_mod_floor(&thousand);
269 let mut out = String::new();
270 if t > one {
271 out.push_str(&int_to_word(&t));
272 out.push(' ');
273 }
274 out.push_str(THOUSAND);
275 if !r.is_zero() {
276 out.push_str(" na ");
277 out.push_str(&int_to_word(&r));
278 }
279 return out;
280 }
281
282 // if number < 1000000000:
283 // m, r = divmod(number, 1000000)
284 // base = (self._int_to_word(m) + " " if m > 1 else "") + self.million
285 // return base + (" na " + self._int_to_word(r) if r else "")
286 if number < &billion {
287 let (m, r) = number.div_mod_floor(&million);
288 let mut out = String::new();
289 if m > one {
290 out.push_str(&int_to_word(&m));
291 out.push(' ');
292 }
293 out.push_str(MILLION);
294 if !r.is_zero() {
295 out.push_str(" na ");
296 out.push_str(&int_to_word(&r));
297 }
298 return out;
299 }
300
301 // return str(number) -- bug 1: words run out at 10^9.
302 number.to_string()
303}
304
305// Reconstructing `str(number)` for a `FloatValue::Decimal` is
306// [`python_decimal_str`] — CPython's `Decimal.__str__` (the spec's
307// *to-scientific-string*), shared crate-wide. It preserves the written scale
308// (`Decimal("1.10")` → `"1.10"`, `Decimal("1.5000")` → `"1.5000"`), flips to
309// `'1E-7'` form once the adjusted exponent drops below -6 — including
310// `Decimal("0.0000001")`, which Python *normalises* to `"1E-7"` (and XH then
311// crashes on, bug 10) — and prints positive-exponent values the way Python
312// does (`Decimal("1E+2")` → `"1E+2"`, again a bug-10 crash, corpus-pinned).
313// The sign is stripped by the caller before the digit walk.
314
315/// Reconstruct `str(abs(number))` for a `FloatValue::Float`.
316///
317/// CPython reprs a float in fixed notation only while its decimal exponent is
318/// in `[-4, 16)`; outside that it switches to exponent form (`repr(1e-05) ==
319/// '1e-05'`, `repr(1e16) == '1e+16'`), which XH's digit loop then chokes on
320/// (bug 10). Inside the fixed window, `format!("{:.p}")` with the repr-derived
321/// `precision` reproduces `str()` byte for byte — correctly rounding the
322/// binary value to the repr's own digit count re-derives exactly those digits
323/// (shortest-round-trip is unique at its own length), and it restores the
324/// trailing ".0" that Rust's plain `{}` drops from whole floats.
325///
326/// In the exponent window the shortest digits are recovered from Rust's `{}`
327/// (also shortest-round-trip, hence the same digits as Python's repr) and
328/// re-assembled Python-style: one leading digit, "." only if more digits
329/// follow, and a signed, two-digit-minimum exponent (`1.5e-05`, `5e-324`).
330/// `precision` is irrelevant there — nothing survives to count digits with.
331///
332/// The `>= 1e16` arm is live: every f64 at that magnitude is whole, and since
333/// [`LangXh::cardinal_float_entry`] routes whole floats through the string
334/// path too, `1e16`/`1e20` arrive here and must come out as `"1e+16"`/
335/// `"1e+20"` — no dot, so `int()` raises exactly Python's ValueError
336/// (corpus-pinned across cardinal/ordinal/year). Non-finite values print as
337/// Python does ('inf'/'nan' — Rust would say "NaN"), and then fail `int()`
338/// with that token, as in Python; those are binding-unreachable.
339fn float_unsigned_repr(abs: f64, precision: u32) -> String {
340 if abs.is_nan() {
341 return "nan".to_string();
342 }
343 if abs.is_infinite() {
344 return "inf".to_string();
345 }
346 if abs != 0.0 && abs < 1e-4 {
347 // '1e-05' side. Rust `{}` is always positional: "0." + zeros + digits.
348 let s = format!("{}", abs);
349 let frac = &s[2..];
350 let zeros = frac.bytes().take_while(|&b| b == b'0').count();
351 let sig = &frac[zeros..];
352 let coeff = if sig.len() == 1 {
353 sig.to_string()
354 } else {
355 format!("{}.{}", &sig[..1], &sig[1..])
356 };
357 return format!("{}e-{:02}", coeff, zeros + 1);
358 }
359 if abs >= 1e16 {
360 // '1e+16' side: at this magnitude `{}` is all integer digits.
361 let s = format!("{}", abs);
362 let sig = s.trim_end_matches('0');
363 let coeff = if sig.len() == 1 {
364 sig.to_string()
365 } else {
366 format!("{}.{}", &sig[..1], &sig[1..])
367 };
368 return format!("{}e+{:02}", coeff, s.len() - 1);
369 }
370 format!("{:.*}", precision as usize, abs)
371}
372
373/// Python's `Num2Word_XH.to_cardinal` applied to an *unsigned* `str(number)`
374/// (the sign is stripped and re-applied by the caller, mirroring the
375/// `n.startswith("-")` recursion):
376///
377/// ```text
378/// if "." in n:
379/// left, right = n.split(".", 1)
380/// ret = self._int_to_word(int(left)) + " " + self.pointword
381/// for digit in right:
382/// ret += " " + (self.ones[int(digit)] or "qanda")
383/// return ret.strip()
384/// return self._int_to_word(int(n))
385/// ```
386///
387/// The trailing `.strip()` is a no-op here (`ret` neither starts nor ends with
388/// whitespace), so it is dropped. `int(...)` on a non-numeric token raises
389/// `ValueError` in Python, and scientific-notation `str()`s genuinely reach it
390/// (bug 10): `'1e-05'` has no "." so the whole token hits the integer parse,
391/// while `'1.5e-05'` splits and then dies in the digit loop on the exponent
392/// marker — `int('e')` (or `int('E')` for a Decimal). Both error messages
393/// mirror CPython's byte for byte, including which token they quote.
394fn xh_string_to_words(n: &str) -> Result<String> {
395 if let Some((left, right)) = n.split_once('.') {
396 let left_int = left.parse::<BigInt>().map_err(|_| {
397 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", left))
398 })?;
399 let mut ret = int_to_word(&left_int);
400 ret.push(' ');
401 ret.push_str(POINTWORD);
402 for ch in right.chars() {
403 let d = ch.to_digit(10).ok_or_else(|| {
404 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
405 })? as usize;
406 ret.push(' ');
407 // `self.ones[int(digit)] or "qanda"`: ONES[0] is "" (falsy) -> filler.
408 if ONES[d].is_empty() {
409 ret.push_str("qanda");
410 } else {
411 ret.push_str(ONES[d]);
412 }
413 }
414 Ok(ret)
415 } else {
416 let v = n.parse::<BigInt>().map_err(|_| {
417 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", n))
418 })?;
419 Ok(int_to_word(&v))
420 }
421}
422
423/// Python's `parts = str(abs(val)).split(".")` reduced to `(is_negative, left,
424/// right)`, without reconstructing the string.
425///
426/// Python slices the *decimal representation*: `left = int(parts[0])` and
427/// `right = int(parts[1][:2].ljust(2, "0"))`. For any value whose `str()` is
428/// plain (non-exponential) decimal that is exactly:
429///
430/// * `left == trunc(abs)` — `parts[0]` is the integer-part digits, and `abs`
431/// is non-negative so truncation is floor;
432/// * `right == trunc(frac(abs) * 100)` — taking the first two fraction digits
433/// and right-padding with "0" to width two *is* a truncating scale-by-100.
434/// `"5"` -> `"50"` -> 50 == trunc(0.5*100); `"004"` -> `"00"` -> 0 ==
435/// trunc(0.004*100). Note this truncates where the rest of the library
436/// rounds — bug 8.
437///
438/// `has_decimal` is exactly "`str(val)` contains a dot" for plain-form values,
439/// which is the condition guarding `parts[1]`, so it selects the `right`
440/// branch. A true `int` never has one, hence no cents for ints — matching
441/// `Num2Word_Base`'s `isinstance(val, int)` split even though XH arrives there
442/// by a different route.
443///
444/// The abs-before-str order matters and is preserved: Python computes
445/// `val = abs(val)` *first*, so no "-" ever reaches the split.
446fn split_value(val: &CurrencyValue) -> (bool, BigInt, BigInt) {
447 match val {
448 // str(int) has no ".", so parts has length 1 and `right` stays 0.
449 CurrencyValue::Int(v) => (v.is_negative(), v.abs(), BigInt::zero()),
450 CurrencyValue::Decimal { value, has_decimal, .. } => {
451 let is_negative = value.is_negative();
452 let abs = value.abs();
453 // with_scale(0) truncates toward zero, i.e. Python's int().
454 let left_dec = abs.with_scale(0);
455 let left = left_dec.as_bigint_and_exponent().0;
456 let right = if *has_decimal {
457 let frac = &abs - &left_dec;
458 (frac * BigDecimal::from(100))
459 .with_scale(0)
460 .as_bigint_and_exponent()
461 .0
462 } else {
463 BigInt::zero()
464 };
465 (is_negative, left, right)
466 }
467 }
468}
469
470/// Python's `forms[1] if n != 1 else forms[0]` — the direct tuple index XH
471/// uses in place of `pluralize` (bug 6).
472///
473/// A one-element entry would raise IndexError for `n != 1`. Every entry in
474/// [`LangXh`]'s table has two forms, so this is unreachable; it is mapped to
475/// `Index` rather than panicking so the exception type survives if the table
476/// ever changes.
477fn indexed_form(forms: &[String], n: &BigInt) -> Result<String> {
478 let i = if n.is_one() { 0 } else { 1 };
479 forms
480 .get(i)
481 .cloned()
482 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
483}
484
485pub struct LangXh {
486 /// `Num2Word_XH.CURRENCY_FORMS`, verbatim. Built once in [`LangXh::new`]
487 /// and only read afterwards.
488 currency_forms: HashMap<&'static str, CurrencyForms>,
489 /// `list(self.CURRENCY_FORMS.values())[0]` — the default `to_currency`
490 /// falls back to for an unknown code (bug 5).
491 ///
492 /// This depends on dict *iteration order*, which since Python 3.7 is
493 /// insertion order, and ZAR is written first in the class body — confirmed
494 /// against the live interpreter and pinned by the corpus's GBP/JPY/KWD
495 /// rows, which all come out in rand. A `HashMap` has no order, so the
496 /// choice is resolved here, once, instead of being left to iteration.
497 fallback_forms: CurrencyForms,
498}
499
500impl Default for LangXh {
501 fn default() -> Self {
502 Self::new()
503 }
504}
505
506impl LangXh {
507 pub fn new() -> Self {
508 // Declared as an ordered array rather than straight into the HashMap
509 // because `fallback_forms` is defined by position, not by key.
510 const ISENTI: [&str; 2] = ["isenti", "isenti"];
511 let entries = [
512 ("ZAR", CurrencyForms::new(&["randi", "randi"], &ISENTI)),
513 ("USD", CurrencyForms::new(&["idola", "idola"], &ISENTI)),
514 ("EUR", CurrencyForms::new(&["iyuro", "iyuro"], &ISENTI)),
515 ];
516 let fallback_forms = entries[0].1.clone();
517 LangXh {
518 currency_forms: entries.into_iter().collect(),
519 fallback_forms,
520 }
521 }
522}
523
524impl Lang for LangXh {
525 /// This language's own `to_currency(currency=...)` default,
526 /// read from the live Python signature. Only 44 of 156 use EUR.
527 fn default_currency(&self) -> &str {
528 "ZAR"
529 }
530
531 /// This language's own `to_currency(separator=...)` default,
532 /// read from the live Python signature. Base's is ",", but only
533 /// 36 of 149 languages actually use it — most default to " " or a
534 /// conjunction, so inheriting Base's comma silently corrupts them.
535 fn default_separator(&self) -> &str {
536 " "
537 }
538
539 fn negword(&self) -> &str {
540 NEGWORD
541 }
542
543 fn pointword(&self) -> &str {
544 "ichaphaza"
545 }
546
547 /// Python:
548 /// ```text
549 /// n = str(number).strip()
550 /// if n.startswith("-"):
551 /// return (self.negword + self.to_cardinal(n[1:])).strip()
552 /// if "." in n: ... # float path, out of scope
553 /// return self._int_to_word(int(n))
554 /// ```
555 ///
556 /// For integer input the string round-trip is inert: `str(BigInt)` never
557 /// contains whitespace or a ".", and the negative branch's
558 /// `to_cardinal(n[1:])` re-enters with the digits of `abs(number)` and
559 /// falls straight through to `_int_to_word`. So the sign test collapses to
560 /// `is_negative()` and the recursion to `int_to_word(abs)`.
561 ///
562 /// The trailing `.strip()` is a no-op here (`_int_to_word` never returns
563 /// padded output and NEGWORD starts with 'n'), but is kept for fidelity.
564 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
565 if value.is_negative() {
566 let words = int_to_word(&value.abs());
567 return Ok(format!("{}{}", NEGWORD, words).trim().to_string());
568 }
569 Ok(int_to_word(value))
570 }
571
572 /// Python:
573 /// ```text
574 /// if number == 1: return "okokuqala"
575 /// return "we" + self.to_cardinal(number)
576 /// ```
577 /// See bug 2 — no guard for zero or negatives.
578 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
579 if value.is_one() {
580 return Ok("okokuqala".to_string());
581 }
582 Ok(format!("we{}", self.to_cardinal(value)?))
583 }
584
585 /// Python: `return "we" + str(number)`. See bug 3.
586 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
587 Ok(format!("we{}", value))
588 }
589
590 /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
591 /// `longval` is accepted and ignored, so years get no era/pair treatment.
592 fn to_year(&self, value: &BigInt) -> Result<String> {
593 self.to_cardinal(value)
594 }
595
596 /// XH's float/Decimal cardinal path. `Num2Word_XH` does **not** override
597 /// `to_cardinal_float`; its overridden `to_cardinal` handles non-integers
598 /// inline off `str(number)`:
599 ///
600 /// ```text
601 /// n = str(number).strip()
602 /// if n.startswith("-"):
603 /// return (self.negword + self.to_cardinal(n[1:])).strip()
604 /// if "." in n:
605 /// left, right = n.split(".", 1)
606 /// ret = self._int_to_word(int(left)) + " " + self.pointword
607 /// for digit in right:
608 /// ret += " " + (self.ones[int(digit)] or "qanda")
609 /// return ret.strip()
610 /// return self._int_to_word(int(n))
611 /// ```
612 ///
613 /// Everything keys on `str(number)`, so — unlike the shared
614 /// `float2tuple` path — there is no `10**precision` binary arithmetic and no
615 /// `< 0.01` rescue: `2.675` speaks its repr digits 6,7,5 and `1.005` speaks
616 /// 0,0,5. The exact `str()` is reconstructed by [`float_unsigned_repr`]
617 /// (fixed `{:.p}` inside repr's fixed window, Python-style `1e+16`/`1.5e-05`
618 /// form outside it) and [`python_decimal_str`] (written scale preserved, or
619 /// `1E+2`/`1.5E-7` exponent form exactly where CPython flips to it). The
620 /// exponent forms contain no speakable digits — they exist so the same
621 /// inputs raise the same `ValueError`s Python does (bug 10).
622 ///
623 /// The sign test is `str(number).startswith("-")`, so it keys on the sign
624 /// *bit*, not `< 0`: `str(-0.0) == "-0.0"` earns the negword, matching the
625 /// live interpreter's `num2words(-0.0, lang='xh')`. `-0.0` reaches XH's
626 /// integer overload as an int and never comes here, so this only affects
627 /// genuine `-0.0` floats.
628 ///
629 /// `precision_override` (the `precision=` kwarg) is ignored: XH's
630 /// `to_cardinal` never reads `self.precision`, so the override — which the
631 /// dispatcher only writes onto that attribute — cannot change its output.
632 fn to_cardinal_float(
633 &self,
634 value: &FloatValue,
635 _precision_override: Option<u32>,
636 ) -> Result<String> {
637 let (is_negative, unsigned) = match value {
638 FloatValue::Float { value, precision } => (
639 value.is_sign_negative(),
640 float_unsigned_repr(value.abs(), *precision),
641 ),
642 // `python_decimal_str` of the abs() — the sign is re-applied
643 // below, mirroring Python's `n.startswith("-")` recursion.
644 // (BigDecimal cannot carry Decimal("-0.0")'s sign anyway; the
645 // binding reroutes that one value through the Float arm.)
646 FloatValue::Decimal { value, .. } => (
647 value.is_negative(),
648 python_decimal_str(&value.abs()),
649 ),
650 };
651
652 let body = xh_string_to_words(&unsigned)?;
653 if is_negative {
654 // Python: `(self.negword + <stripped body>).strip()`. NEGWORD keeps
655 // its trailing space; the outer strip trims nothing here (body has
656 // no edge whitespace), but is kept for fidelity.
657 Ok(format!("{}{}", NEGWORD, body).trim().to_string())
658 } else {
659 Ok(body)
660 }
661 }
662
663 // ---- float/Decimal routing --------------------------------------------
664
665 /// `to_cardinal(float/Decimal)` full routing. Python's `to_cardinal` keys
666 /// on `str(number)`, so *every* float/Decimal takes the string route —
667 /// whole values included: 5.0 keeps its ".0" tail ("ntlanu ichaphaza
668 /// qanda"), `Decimal("5.00")` speaks both zeros ("ntlanu ichaphaza qanda
669 /// qanda"), and only a point-less string (`Decimal("5")` -> "5") lands on
670 /// the integer branch, via the same `int(n)` call. Base's whole-value
671 /// shortcut never runs.
672 fn cardinal_float_entry(
673 &self,
674 value: &FloatValue,
675 precision_override: Option<u32>,
676 ) -> Result<String> {
677 self.to_cardinal_float(value, precision_override)
678 }
679
680 /// `to_ordinal(float/Decimal)`. Python:
681 /// ```text
682 /// if number == 1: return "okokuqala"
683 /// return "we" + self.to_cardinal(number)
684 /// ```
685 /// The `== 1` test is *numeric*, so `1.0 == 1` and `Decimal("1.00") == 1`
686 /// are both True and short-circuit to "okokuqala" before any string work
687 /// (corpus: `to_ordinal(1.0)` == "okokuqala"). Everything else rides the
688 /// full string route and takes the blind "we" prefix (bug 2), negatives
689 /// included: `to_ordinal(-0.0)` == "wengaphantsi kwe iqanda ichaphaza
690 /// qanda". An exponent-notation repr (`str(1e16)` == "1e+16",
691 /// `str(Decimal("1E+2"))` == "1E+2") makes the inner `int()` raise its
692 /// ValueError *before* Python's `"we" +` runs — the `?` reproduces that
693 /// ordering. `as_whole_int` is exactly Python's numeric equality here:
694 /// it yields the value iff the value is whole, sign included.
695 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
696 if value.as_whole_int().is_some_and(|i| i.is_one()) {
697 return Ok("okokuqala".to_string());
698 }
699 Ok(format!("we{}", self.cardinal_float_entry(value, None)?))
700 }
701
702 /// `to_ordinal_num(float/Decimal)`: `"we" + str(number)` never casts to
703 /// int, so it *succeeds* on every float and Decimal — "we5.0", "we-0.0",
704 /// "we1e+16" and "we1E+2" are all real Python outputs (bug 3's
705 /// sign-and-repr passthrough extends to floats). `repr_str` is the
706 /// binding's Python `str(value)`, exactly the string Python concatenates.
707 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
708 Ok(format!("we{}", repr_str))
709 }
710
711 // `year_float_entry` is deliberately NOT overridden: the trait default
712 // routes to `cardinal_float_entry`, and Python's `to_year` is a plain
713 // `return self.to_cardinal(val)` — identical by construction.
714
715 /// `Decimal('Infinity')` / `-Infinity` from a string arg. XH inherits
716 /// `Num2Word_Base`'s `str_to_number` (`Decimal(value)`), so the token parses
717 /// *successfully* and only blows up later, inside each mode:
718 ///
719 /// * `to_cardinal` / `to_year` reach `int("Infinity")` → `ValueError`.
720 /// `-Infinity` recurses through the negword branch first, so the message
721 /// always quotes `'Infinity'`.
722 /// * `to_ordinal` is `"okokuqala"` only for `number == 1` (false here), then
723 /// `"we" + self.to_cardinal(number)` → the `int()` raises → `ValueError`.
724 /// * `to_ordinal_num` == `"we" + str(number)` never casts, so it *answers*:
725 /// `"weInfinity"` / `"we-Infinity"`.
726 ///
727 /// Byte-exact against the pure-Python oracle (verified live).
728 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
729 match to {
730 "ordinal_num" => Ok(format!(
731 "we{}Infinity",
732 if negative { "-" } else { "" }
733 )),
734 _ => Err(N2WError::Value(
735 "invalid literal for int() with base 10: 'Infinity'".into(),
736 )),
737 }
738 }
739
740 /// `Decimal('NaN')` from a string arg. Same shape as [`LangXh::inf_result`]:
741 /// `int("NaN")` → `ValueError` on the cardinal/ordinal/year paths, while
742 /// `to_ordinal_num` answers `"weNaN"`.
743 fn nan_result(&self, to: &str) -> Result<String> {
744 match to {
745 "ordinal_num" => Ok("weNaN".into()),
746 _ => Err(N2WError::Value(
747 "invalid literal for int() with base 10: 'NaN'".into(),
748 )),
749 }
750 }
751
752 // ---- currency -------------------------------------------------------
753 //
754 // XH overrides only `to_currency` and `pluralize`. `to_cheque`,
755 // `_money_verbose`, `_cents_verbose` and `_cents_terse` are inherited from
756 // `Num2Word_Base` untouched, and the trait defaults already mirror them —
757 // in particular the default `money_verbose` calls `self.to_cardinal`,
758 // which resolves to XH's override, exactly as Python's does.
759 //
760 // `currency_precision` and `currency_adjective` are deliberately NOT
761 // overridden: both dicts are empty on XH, so the trait defaults (100, and
762 // None) are already what Python computes. See the module header.
763 //
764 // `cardinal_from_decimal` is left at its default. It is reachable only
765 // from `default_to_currency`'s fractional-cents branch, and XH replaces
766 // `to_currency` outright, so nothing on this language can reach it.
767
768 fn lang_name(&self) -> &str {
769 "Num2Word_XH"
770 }
771
772 /// The raw `CURRENCY_FORMS[code]` lookup, i.e. the *strict* one.
773 ///
774 /// This backs the inherited `to_cheque`, which subscripts the dict and
775 /// raises NotImplementedError on KeyError. `to_currency` does **not** go
776 /// through here — it applies a `.get(code, <first value>)` fallback of its
777 /// own (bug 5), so the two halves genuinely disagree.
778 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
779 self.currency_forms.get(code)
780 }
781
782 /// Python:
783 /// ```text
784 /// if not forms:
785 /// return ""
786 /// return forms[0] if n == 1 else forms[-1]
787 /// ```
788 ///
789 /// Dead code in practice: the only caller in `Num2Word_Base` is
790 /// `to_currency`, which XH replaces with a version that indexes the tuple
791 /// directly (bug 6), and `to_cheque` takes `cr1[-1]` unconditionally. It is
792 /// ported because Python defines it — the hook exists, so it should answer
793 /// the way Python would.
794 ///
795 /// Note the empty-forms guard returns `""` rather than raising, so unlike
796 /// most languages this override cannot produce an IndexError.
797 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
798 if forms.is_empty() {
799 return Ok(String::new());
800 }
801 Ok(if n.is_one() {
802 forms[0].clone()
803 } else {
804 forms[forms.len() - 1].clone()
805 })
806 }
807
808 /// Python:
809 /// ```text
810 /// def to_currency(self, val, currency="ZAR", cents=True,
811 /// separator=" ", adjective=False):
812 /// is_negative = val < 0
813 /// val = abs(val)
814 /// parts = str(val).split(".")
815 /// left = int(parts[0]) if parts[0] else 0
816 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
817 /// cr1, cr2 = self.CURRENCY_FORMS.get(
818 /// currency, list(self.CURRENCY_FORMS.values())[0])
819 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
820 /// if cents and right:
821 /// result += separator + self._int_to_word(right) + " " + (
822 /// cr2[1] if right != 1 else cr2[0])
823 /// if is_negative:
824 /// result = self.negword + result
825 /// return result.strip()
826 /// ```
827 ///
828 /// This shares nothing with `Num2Word_Base.to_currency` beyond the name:
829 /// no `parse_currency_parts`, no `CURRENCY_PRECISION`, no `pluralize`, no
830 /// `_money_verbose`/`_cents_verbose`, no NotImplementedError. It calls
831 /// `_int_to_word` directly — note *not* `to_cardinal` — which is why a
832 /// negative never double-prefixes the negword.
833 ///
834 /// The cents guard is `if cents and right`, testing `right` for
835 /// *truthiness* only. `Num2Word_Base` instead asks `has_decimal or right >
836 /// 0`, so where Base renders "five dollars, zero cents" for `5.0`, XH
837 /// renders plain "ntlanu randi". `has_decimal` therefore only selects
838 /// whether `right` gets computed at all (see [`split_value`]); it never
839 /// forces a zero-cents segment.
840 ///
841 /// `separator: None` means the caller omitted the kwarg. The trait resolves
842 /// that through `default_separator` (" " for XH) only inside its *default*
843 /// body, which this override replaces — so the `unwrap_or` below is doing
844 /// that job, not duplicating it. `adjective` is ignored, as in Python
845 /// (bug 7).
846 fn to_currency(
847 &self,
848 val: &CurrencyValue,
849 currency: &str,
850 cents: bool,
851 separator: Option<&str>,
852 _adjective: bool,
853 ) -> Result<String> {
854 let separator = separator.unwrap_or(self.default_separator());
855 let (is_negative, left, right) = split_value(val);
856
857 // .get(currency, list(...)[0]) -- a default, not a raise. Bug 5.
858 let forms = self
859 .currency_forms
860 .get(currency)
861 .unwrap_or(&self.fallback_forms);
862
863 let mut result = format!(
864 "{} {}",
865 int_to_word(&left),
866 indexed_form(&forms.unit, &left)?
867 );
868
869 if cents && !right.is_zero() {
870 // Python concatenates `separator + word` with no space between
871 // them -- bug 9. The space before cr2 is a separate literal.
872 result.push_str(separator);
873 result.push_str(&int_to_word(&right));
874 result.push(' ');
875 result.push_str(&indexed_form(&forms.subunit, &right)?);
876 }
877
878 if is_negative {
879 result = format!("{}{}", NEGWORD, result);
880 }
881
882 // `.strip()`: inert here (NEGWORD starts with 'n', and no form is
883 // empty), kept for fidelity.
884 Ok(result.trim().to_string())
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use bigdecimal::BigDecimal;
892 use std::str::FromStr;
893
894 /// Rebuild the `CurrencyValue` the binding would hand us for a corpus
895 /// `arg`, which is `repr(value)`: no dot and no exponent means Python had
896 /// an `int`, and `has_decimal` mirrors
897 /// `isinstance(val, float) or "." in str(val)`.
898 fn cur_sep(arg: &str, code: &str, cents: bool, sep: Option<&str>) -> String {
899 let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
900 let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
901 LangXh::new()
902 .to_currency(&v, code, cents, sep, false)
903 .unwrap()
904 }
905
906 fn cur(arg: &str, code: &str) -> String {
907 cur_sep(arg, code, true, None)
908 }
909
910 fn cheque(arg: &str, code: &str) -> Result<String> {
911 LangXh::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
912 }
913
914 /// The corpus's currency rows for the three codes XH actually defines.
915 #[test]
916 fn corpus_currency_known_codes() {
917 for (code, unit) in [("EUR", "iyuro"), ("USD", "idola")] {
918 assert_eq!(cur("0", code), format!("iqanda {unit}"));
919 assert_eq!(cur("1", code), format!("nye {unit}"));
920 assert_eq!(cur("2", code), format!("mbini {unit}"));
921 assert_eq!(cur("100", code), format!("ikhulu {unit}"));
922 assert_eq!(cur("1000000", code), format!("isigidi {unit}"));
923 // 1.0 is a float, yet right == 0 -> no cents segment at all.
924 assert_eq!(cur("1.0", code), format!("nye {unit}"));
925 assert_eq!(
926 cur("12.34", code),
927 format!("lishumi ana mbini {unit} amashumi amathathu ana ne isenti")
928 );
929 // 0.01: left == 0 -> "iqanda", and right == 1 -> singular isenti.
930 assert_eq!(cur("0.01", code), format!("iqanda {unit} nye isenti"));
931 // 0.5: "5".ljust(2, "0") == "50", not 5.
932 assert_eq!(
933 cur("0.5", code),
934 format!("iqanda {unit} amashumi amahlanu isenti")
935 );
936 assert_eq!(
937 cur("99.99", code),
938 format!(
939 "amashumi alithoba ana lithoba {unit} \
940 amashumi alithoba ana lithoba isenti"
941 )
942 );
943 assert_eq!(
944 cur("1234.56", code),
945 format!(
946 "iwaka na mbini ikhulu na amashumi amathathu ana ne {unit} \
947 amashumi amahlanu ana ntandathu isenti"
948 )
949 );
950 assert_eq!(
951 cur("-12.34", code),
952 format!("ngaphantsi kwe lishumi ana mbini {unit} amashumi amathathu ana ne isenti")
953 );
954 }
955 }
956
957 /// Bug 5: every code outside the table silently renders in rand, including
958 /// the 0-decimal (JPY) and 3-decimal (KWD/BHD) ones — precision is never
959 /// consulted, so 12.34 JPY still shows cents.
960 #[test]
961 fn corpus_currency_unknown_codes_fall_back_to_zar() {
962 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
963 assert_eq!(cur("0", code), "iqanda randi");
964 assert_eq!(cur("1", code), "nye randi");
965 assert_eq!(cur("2", code), "mbini randi");
966 assert_eq!(cur("100", code), "ikhulu randi");
967 assert_eq!(cur("1000000", code), "isigidi randi");
968 assert_eq!(cur("1.0", code), "nye randi");
969 assert_eq!(
970 cur("12.34", code),
971 "lishumi ana mbini randi amashumi amathathu ana ne isenti"
972 );
973 assert_eq!(cur("0.01", code), "iqanda randi nye isenti");
974 assert_eq!(cur("0.5", code), "iqanda randi amashumi amahlanu isenti");
975 assert_eq!(
976 cur("99.99", code),
977 "amashumi alithoba ana lithoba randi amashumi alithoba ana lithoba isenti"
978 );
979 assert_eq!(
980 cur("1234.56", code),
981 "iwaka na mbini ikhulu na amashumi amathathu ana ne randi \
982 amashumi amahlanu ana ntandathu isenti"
983 );
984 assert_eq!(
985 cur("-12.34", code),
986 "ngaphantsi kwe lishumi ana mbini randi amashumi amathathu ana ne isenti"
987 );
988 }
989 // ZAR itself is the fallback, so it must agree with the unknown codes.
990 assert_eq!(cur("12.34", "ZAR"), cur("12.34", "GBP"));
991 }
992
993 /// Behaviour beyond the corpus, checked against the live interpreter.
994 #[test]
995 fn python_parity_edge_cases() {
996 // Bug 8: cents truncate. 1.005 -> "00" -> 0 -> no cents segment.
997 assert_eq!(cur("1.005", "ZAR"), "nye randi");
998 assert_eq!(
999 cur("1.999", "ZAR"),
1000 "nye randi amashumi alithoba ana lithoba isenti"
1001 );
1002 assert_eq!(
1003 cur("12.349", "ZAR"),
1004 "lishumi ana mbini randi amashumi amathathu ana ne isenti"
1005 );
1006 // -0.0 < 0 is False in Python, so no negword.
1007 assert_eq!(cur("-0.0", "ZAR"), "iqanda randi");
1008 assert_eq!(cur("-1", "ZAR"), "ngaphantsi kwe nye randi");
1009 // Bug 1 leaks into currency: _int_to_word gives up at 10^9.
1010 assert_eq!(cur("1000000000", "ZAR"), "1000000000 randi");
1011 // A Decimal with no dot in str() takes the no-cents branch; one with a
1012 // dot but a zero fraction still ends up with right == 0.
1013 let five = CurrencyValue::parse("5", false, false, false).unwrap();
1014 assert_eq!(
1015 LangXh::new().to_currency(&five, "ZAR", true, None, false).unwrap(),
1016 "ntlanu randi"
1017 );
1018 let five_00 = CurrencyValue::parse("5.00", false, true, true).unwrap();
1019 assert_eq!(
1020 LangXh::new().to_currency(&five_00, "ZAR", true, None, false).unwrap(),
1021 "ntlanu randi"
1022 );
1023 // cents=False drops the segment; bug 7: adjective changes nothing.
1024 assert_eq!(cur_sep("12.34", "EUR", false, None), "lishumi ana mbini iyuro");
1025 // Bug 9: separator is glued to the following word.
1026 assert_eq!(
1027 cur_sep("12.34", "EUR", true, Some(" na")),
1028 "lishumi ana mbini iyuro naamashumi amathathu ana ne isenti"
1029 );
1030 }
1031
1032 /// Invoke the float cardinal path with a raw f64 and its repr-derived
1033 /// precision, exactly as the binding builds `FloatValue::Float`.
1034 fn card_float(value: f64, precision: u32) -> String {
1035 LangXh::new()
1036 .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1037 .unwrap()
1038 }
1039
1040 /// Invoke the Decimal cardinal path. `precision` mirrors the binding's
1041 /// `abs(Decimal(str(value)).as_tuple().exponent)` — the fraction-digit count
1042 /// of the plain decimal string.
1043 fn card_dec(s: &str) -> String {
1044 let precision = s
1045 .split_once('.')
1046 .map(|(_, f)| f.len() as u32)
1047 .unwrap_or(0);
1048 let value = BigDecimal::from_str(s).unwrap();
1049 LangXh::new()
1050 .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1051 .unwrap()
1052 }
1053
1054 /// The corpus's `to: "cardinal"` rows whose `arg` is a float (has a dot).
1055 #[test]
1056 fn corpus_cardinal_float() {
1057 assert_eq!(card_float(0.0, 1), "iqanda ichaphaza qanda");
1058 assert_eq!(card_float(0.5, 1), "iqanda ichaphaza ntlanu");
1059 assert_eq!(card_float(1.0, 1), "nye ichaphaza qanda");
1060 assert_eq!(card_float(1.5, 1), "nye ichaphaza ntlanu");
1061 assert_eq!(card_float(2.25, 2), "mbini ichaphaza mbini ntlanu");
1062 assert_eq!(card_float(3.14, 2), "ntathu ichaphaza nye ne");
1063 assert_eq!(card_float(0.01, 2), "iqanda ichaphaza qanda nye");
1064 assert_eq!(card_float(0.1, 1), "iqanda ichaphaza nye");
1065 assert_eq!(card_float(0.99, 2), "iqanda ichaphaza lithoba lithoba");
1066 assert_eq!(card_float(1.01, 2), "nye ichaphaza qanda nye");
1067 assert_eq!(card_float(12.34, 2), "lishumi ana mbini ichaphaza ntathu ne");
1068 assert_eq!(
1069 card_float(99.99, 2),
1070 "amashumi alithoba ana lithoba ichaphaza lithoba lithoba"
1071 );
1072 assert_eq!(card_float(100.5, 1), "ikhulu ichaphaza ntlanu");
1073 assert_eq!(
1074 card_float(1234.56, 2),
1075 "iwaka na mbini ikhulu na amashumi amathathu ana ne ichaphaza ntlanu ntandathu"
1076 );
1077 assert_eq!(card_float(-0.5, 1), "ngaphantsi kwe iqanda ichaphaza ntlanu");
1078 assert_eq!(card_float(-1.5, 1), "ngaphantsi kwe nye ichaphaza ntlanu");
1079 assert_eq!(
1080 card_float(-12.34, 2),
1081 "ngaphantsi kwe lishumi ana mbini ichaphaza ntathu ne"
1082 );
1083 // f64-artefact cases: str()-driven, so NO banker's rounding / rescue.
1084 // 1.005 speaks 0,0,5 and 2.675 speaks 6,7,5 (their literal repr digits).
1085 assert_eq!(card_float(1.005, 3), "nye ichaphaza qanda qanda ntlanu");
1086 assert_eq!(
1087 card_float(2.675, 3),
1088 "mbini ichaphaza ntandathu sixhenxe ntlanu"
1089 );
1090 }
1091
1092 /// The corpus's `to: "cardinal_dec"` (Decimal input) rows, plus the trailing
1093 /// zero preservation the `float()` path would lose (issue #603).
1094 #[test]
1095 fn corpus_cardinal_decimal() {
1096 assert_eq!(card_dec("0.01"), "iqanda ichaphaza qanda nye");
1097 assert_eq!(card_dec("1.10"), "nye ichaphaza nye qanda");
1098 assert_eq!(card_dec("12.345"), "lishumi ana mbini ichaphaza ntathu ne ntlanu");
1099 // Bug 1 leaks in: the 10^13 integer part gives up and prints digits.
1100 assert_eq!(
1101 card_dec("98746251323029.99"),
1102 "98746251323029 ichaphaza lithoba lithoba"
1103 );
1104 assert_eq!(card_dec("0.001"), "iqanda ichaphaza qanda qanda nye");
1105 // Beyond the corpus, checked against the live interpreter.
1106 assert_eq!(card_dec("1.5000"), "nye ichaphaza ntlanu qanda qanda qanda");
1107 assert_eq!(card_dec("-12.34"), "ngaphantsi kwe lishumi ana mbini ichaphaza ntathu ne");
1108 }
1109
1110 /// `str(-0.0) == "-0.0"` starts with "-", so a genuine negative-zero float
1111 /// earns the negword — keyed on the sign bit, not `< 0`. Checked against
1112 /// `num2words(-0.0, lang='xh')`.
1113 #[test]
1114 fn negative_zero_float_gets_negword() {
1115 assert_eq!(card_float(-0.0, 1), "ngaphantsi kwe iqanda ichaphaza qanda");
1116 assert_eq!(card_float(0.0, 1), "iqanda ichaphaza qanda");
1117 }
1118
1119 /// Invoke the float path expecting Python's ValueError.
1120 fn card_float_err(value: f64, precision: u32) -> N2WError {
1121 LangXh::new()
1122 .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1123 .unwrap_err()
1124 }
1125
1126 /// Invoke the Decimal path with an explicit binding-side precision
1127 /// (`abs(Decimal(str(v)).as_tuple().exponent)`), needed where the plain
1128 /// string form has no "." to count from (e.g. "1E-7" → precision 7).
1129 fn card_dec_p(s: &str, precision: u32) -> Result<String> {
1130 LangXh::new().to_cardinal_float(
1131 &FloatValue::Decimal {
1132 value: BigDecimal::from_str(s).unwrap(),
1133 precision,
1134 },
1135 None,
1136 )
1137 }
1138
1139 fn assert_value_err(err: N2WError, token: &str) {
1140 match err {
1141 N2WError::Value(msg) => assert_eq!(
1142 msg,
1143 format!("invalid literal for int() with base 10: '{token}'")
1144 ),
1145 other => panic!("expected ValueError, got {other:?}"),
1146 }
1147 }
1148
1149 /// Bug 10: exponent-notation `str()`s raise ValueError, exactly as pure
1150 /// Python does (verified live with the Rust fast path disabled). The
1151 /// quoted token depends on whether the string has a "." — the whole repr
1152 /// when it does not, the exponent marker 'e'/'E' when it does. Precisions
1153 /// are the binding's `abs(Decimal(str(v)).as_tuple().exponent)` values.
1154 #[test]
1155 fn scientific_notation_str_raises_valueerror() {
1156 // Floats: repr flips to '1e-05' form below 1e-4.
1157 assert_value_err(card_float_err(1e-05, 5), "1e-05");
1158 assert_value_err(card_float_err(1.5e-05, 6), "e");
1159 // The error escapes before the negword is applied, as in Python.
1160 assert_value_err(card_float_err(-1.5e-05, 6), "e");
1161 assert_value_err(card_float_err(9.999999999999999e-05, 20), "e");
1162 // Smallest subnormal: exponent grows past two digits, unpadded.
1163 assert_value_err(card_float_err(5e-324, 324), "5e-324");
1164 // Boundary: 1e-4 itself still reprs fixed and speaks normally.
1165 assert_eq!(card_float(0.0001, 4), "iqanda ichaphaza qanda qanda qanda nye");
1166
1167 // Decimals: str() flips to '1E-7' form once adjusted exponent < -6,
1168 // with an *uppercase* E — and normalises "0.0000001" into it too.
1169 assert_value_err(card_dec_p("1E-7", 7).unwrap_err(), "1E-7");
1170 assert_value_err(card_dec_p("0.0000001", 7).unwrap_err(), "1E-7");
1171 assert_value_err(card_dec_p("1.5E-7", 8).unwrap_err(), "E");
1172 assert_value_err(card_dec_p("-1.5E-7", 8).unwrap_err(), "E");
1173 // Boundaries that stay fixed: adjusted exponent exactly -6 — one
1174 // significant digit at the 10^-6 place, or a two-digit coefficient
1175 // holding a 10^-7-scale value up at -6.
1176 assert_eq!(
1177 card_dec_p("0.000001", 6).unwrap(),
1178 "iqanda ichaphaza qanda qanda qanda qanda qanda nye"
1179 );
1180 assert_eq!(
1181 card_dec_p("0.0000010", 7).unwrap(),
1182 "iqanda ichaphaza qanda qanda qanda qanda qanda nye qanda"
1183 );
1184 }
1185
1186 /// Large values: the integer part exhausts `_int_to_word` at 10^9 (bug 1)
1187 /// but the float stays in repr's fixed window until 10^16, so digits are
1188 /// spoken either side of the pointword. Checked against the live
1189 /// interpreter: 1234567890.5 -> '1234567890 ichaphaza ntlanu'.
1190 #[test]
1191 fn large_values_fixed_window() {
1192 assert_eq!(card_float(1234567890.5, 1), "1234567890 ichaphaza ntlanu");
1193 // A 17-digit Decimal is still fixed notation (adjusted exponent 16
1194 // matters only for floats): '12345678901234567 ichaphaza sibhozo
1195 // lithoba', verified live.
1196 assert_eq!(
1197 card_dec_p("12345678901234567.89", 2).unwrap(),
1198 "12345678901234567 ichaphaza sibhozo lithoba"
1199 );
1200 }
1201
1202 /// The whole-value routing: Python's `to_cardinal` keys on `str(number)`,
1203 /// so 5.0 keeps its ".0" tail and 1e16 dies in `int()`. The ordinal rides
1204 /// the same route behind the numeric `== 1` test, and `to_ordinal_num` is
1205 /// a pure string concat. All corpus-pinned.
1206 #[test]
1207 fn corpus_float_entry_routing() {
1208 let l = LangXh::new();
1209 let f = |v: f64, p: u32| FloatValue::Float { value: v, precision: p };
1210 let d = |s: &str, p: u32| FloatValue::Decimal {
1211 value: BigDecimal::from_str(s).unwrap(),
1212 precision: p,
1213 };
1214
1215 // cardinal: whole floats/Decimals speak their visible decimals...
1216 assert_eq!(
1217 l.cardinal_float_entry(&f(5.0, 1), None).unwrap(),
1218 "ntlanu ichaphaza qanda"
1219 );
1220 assert_eq!(
1221 l.cardinal_float_entry(&f(-0.0, 1), None).unwrap(),
1222 "ngaphantsi kwe iqanda ichaphaza qanda"
1223 );
1224 assert_eq!(
1225 l.cardinal_float_entry(&d("5.00", 2), None).unwrap(),
1226 "ntlanu ichaphaza qanda qanda"
1227 );
1228 // ...a point-less Decimal lands on the integer branch...
1229 assert_eq!(l.cardinal_float_entry(&d("5", 0), None).unwrap(), "ntlanu");
1230 // ...and exponent-form strings raise Python's ValueError (bug 10).
1231 assert_value_err(
1232 l.cardinal_float_entry(&f(1e16, 16), None).unwrap_err(),
1233 "1e+16",
1234 );
1235 assert_value_err(
1236 l.cardinal_float_entry(&f(1e20, 20), None).unwrap_err(),
1237 "1e+20",
1238 );
1239 assert_value_err(
1240 l.cardinal_float_entry(&d("1E+2", 2), None).unwrap_err(),
1241 "1E+2",
1242 );
1243
1244 // ordinal: 1.0 == 1 short-circuits; everything else gets "we".
1245 assert_eq!(l.ordinal_float_entry(&f(1.0, 1)).unwrap(), "okokuqala");
1246 assert_eq!(
1247 l.ordinal_float_entry(&f(5.0, 1)).unwrap(),
1248 "wentlanu ichaphaza qanda"
1249 );
1250 assert_eq!(
1251 l.ordinal_float_entry(&f(-0.0, 1)).unwrap(),
1252 "wengaphantsi kwe iqanda ichaphaza qanda"
1253 );
1254 assert_eq!(l.ordinal_float_entry(&d("5", 0)).unwrap(), "wentlanu");
1255 assert_eq!(l.ordinal_float_entry(&d("0", 0)).unwrap(), "weiqanda");
1256 assert_value_err(l.ordinal_float_entry(&d("1E+20", 20)).unwrap_err(), "1E+20");
1257
1258 // ordinal_num: "we" + the binding's repr, verbatim.
1259 assert_eq!(
1260 l.ordinal_num_float_entry(&f(5.0, 1), "5.0").unwrap(),
1261 "we5.0"
1262 );
1263 assert_eq!(
1264 l.ordinal_num_float_entry(&f(1e16, 16), "1e+16").unwrap(),
1265 "we1e+16"
1266 );
1267
1268 // year == cardinal, via the un-overridden trait default.
1269 assert_eq!(
1270 l.year_float_entry(&f(-3.0, 1)).unwrap(),
1271 "ngaphantsi kwe ntathu ichaphaza qanda"
1272 );
1273 }
1274
1275 /// Inf/NaN string inputs are served natively per mode: `ValueError` on the
1276 /// cardinal/ordinal/year paths, a literal answer on `ordinal_num`.
1277 #[test]
1278 fn inf_nan_results_native() {
1279 let l = LangXh::new();
1280 for to in ["cardinal", "ordinal", "year"] {
1281 assert_value_err(l.inf_result(false, to).unwrap_err(), "Infinity");
1282 assert_value_err(l.inf_result(true, to).unwrap_err(), "Infinity");
1283 assert_value_err(l.nan_result(to).unwrap_err(), "NaN");
1284 }
1285 assert_eq!(l.inf_result(false, "ordinal_num").unwrap(), "weInfinity");
1286 assert_eq!(l.inf_result(true, "ordinal_num").unwrap(), "we-Infinity");
1287 assert_eq!(l.nan_result("ordinal_num").unwrap(), "weNaN");
1288 }
1289
1290 /// `to_cheque` is `Num2Word_Base`'s, so unlike `to_currency` it raises for
1291 /// an unknown code (bug 5) and always uses the plural unit form.
1292 #[test]
1293 fn corpus_cheque() {
1294 assert_eq!(
1295 cheque("1234.56", "EUR").unwrap(),
1296 "IWAKA NA MBINI IKHULU NA AMASHUMI AMATHATHU ANA NE AND 56/100 IYURO"
1297 );
1298 assert_eq!(
1299 cheque("1234.56", "USD").unwrap(),
1300 "IWAKA NA MBINI IKHULU NA AMASHUMI AMATHATHU ANA NE AND 56/100 IDOLA"
1301 );
1302 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1303 match cheque("1234.56", code) {
1304 Err(N2WError::NotImplemented(msg)) => assert_eq!(
1305 msg,
1306 format!("Currency code \"{code}\" not implemented for \"Num2Word_XH\"")
1307 ),
1308 other => panic!("{code}: expected NotImplemented, got {other:?}"),
1309 }
1310 }
1311 // Checked against the live interpreter.
1312 assert_eq!(
1313 cheque("1234.56", "ZAR").unwrap(),
1314 "IWAKA NA MBINI IKHULU NA AMASHUMI AMATHATHU ANA NE AND 56/100 RANDI"
1315 );
1316 assert_eq!(cheque("-5.5", "USD").unwrap(), "MINUS NTLANU AND 50/100 IDOLA");
1317 assert_eq!(cheque("1.0", "USD").unwrap(), "NYE AND 00/100 IDOLA");
1318 assert_eq!(cheque("0.01", "USD").unwrap(), "IQANDA AND 01/100 IDOLA");
1319 }
1320}