Skip to main content

num2words2_core/
lang_ban.rs

1//! Port of `lang_BAN.py` (Balinese).
2//!
3//! Registry check: `CONVERTER_CLASSES["ban"]` → `lang_BAN.Num2Word_BAN`, so
4//! this file ports the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_BAN` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no `merge`, so
8//! Python never builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is
9//! overridden outright and drives a hand-written `_int_to_word` cascade.
10//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here and
11//! there is **no overflow check** — see the 10^9 ceiling below, which degrades
12//! silently instead of raising.
13//!
14//! Everything in scope is overridden by BAN itself, so no `Num2Word_Base`
15//! behaviour survives into the four supported modes:
16//!   * `to_cardinal`    — overridden (below)
17//!   * `to_ordinal`     — overridden: `"ka-" + to_cardinal(n)`
18//!   * `to_ordinal_num` — overridden: `"ka-" + str(n)`
19//!   * `to_year`        — overridden: `to_cardinal(val)` (the `longval` kwarg
20//!     is accepted and then ignored, so base's century-splitting never runs)
21//!
22//! `setup()` also assigns `exclude_title` / `pointword`, but since
23//! `to_cardinal` is overridden it never calls `self.title()`, so `is_title`
24//! and `exclude_title` are dead for every mode in scope. Output is always
25//! lowercase, as the corpus confirms.
26//!
27//! # Faithfully reproduced Python quirks
28//!
29//! This is a port, not a rewrite. Both of the following look like bugs and are
30//! exactly what Python emits, verified against the frozen corpus:
31//!
32//! 1. **The 10^9 cliff.** `_int_to_word` has no branch above `million`, so its
33//!    final statement is a bare `return str(number)`. Any value >= 1_000_000_000
34//!    is rendered as **bare digits**, not words:
35//!      * `to_cardinal(10**9)`  == `"1000000000"`
36//!      * `to_cardinal(10**21)` == `"1000000000000000000000"`
37//!      * `to_ordinal(10**9)`   == `"ka-1000000000"`
38//!    It does not raise `OverflowError` — it silently stops translating. This
39//!    is why the input must stay `BigInt`: the fallback prints the full decimal
40//!    expansion, so values far beyond `u64` reach it intact.
41//!
42//! 2. **`to_ordinal` on negatives keeps the minus word inline.** `to_ordinal`
43//!    blindly prefixes `"ka-"` to whatever `to_cardinal` returned, so
44//!    `to_ordinal(-1)` == `"ka-minus siki"` and `to_ordinal_num(-1)` ==
45//!    `"ka--1"` (double hyphen — `"ka-" + str(-1)`). Neither is tidied.
46//!
47//! # Error behaviour
48//!
49//! For integer input BAN cannot raise: every list index is guarded by a range
50//! check (`ONES[o]` only when `o != 0`, `TENS[t]` only for `t` in 1..=9,
51//! `ONES[h]` only for `h` in 2..=9), and the sole `int()` call parses a string
52//! this code produced.
53//!
54//! The currency surface adds two reachable raises, both inherited or implied
55//! rather than deliberate:
56//!
57//! * `to_cheque` on a code outside `CURRENCY_FORMS` — `Num2Word_Base.to_cheque`
58//!   catches the `KeyError` from `self.CURRENCY_FORMS[currency]` and re-raises
59//!   `NotImplementedError`. Seven of the nine `cheque:*` corpus rows are this.
60//! * `to_currency` on a value whose repr is exponential — see below.
61//!
62//! # The exponential-repr gap (`to_currency`)
63//!
64//! `to_currency` re-stringifies its argument (`str(abs(val)).split(".")`) and
65//! feeds the pieces to `int()`. Python's repr switches to exponential notation
66//! outside `1e-4 <= |x| < 1e16`, and `int("1e+16")` raises **ValueError** — so
67//! BAN crashes on large and tiny floats rather than converting them.
68//!
69//! Only half of that is reproducible here. Values cross the FFI boundary as
70//! `str(value)` parsed into a `BigDecimal`, which keeps the digits and scale
71//! but not the *notation*:
72//!
73//! | input | Python | here | why |
74//! |---|---|---|---|
75//! | `1e16`, `Decimal("1E+2")` | ValueError | ValueError | scale < 0 survives the parse |
76//! | `1e-05` | ValueError | `"nol rupiah"` | parses to the same `(1, 5)` as `Decimal("0.00001")` |
77//!
78//! The second row is unresolvable in the core: `Decimal("0.00001")` has the
79//! *identical* `BigDecimal` and Python converts it happily, so telling the two
80//! apart needs a float-vs-Decimal flag the boundary does not carry (`is_int`
81//! only separates `int`). The non-raising branch is chosen because it is the
82//! one that matches `Decimal`. No corpus row reaches either case.
83//!
84//! # The negative-zero gap (`to_cardinal` on a Decimal)
85//!
86//! One more divergence of the same shape, introduced by the float phase and
87//! likewise unreachable from the corpus. `to_cardinal` decides its sign from
88//! the *string* (`n.startswith("-")`), not from `val < 0`, so a signed zero
89//! keeps its minus:
90//!
91//! | input | Python | here |
92//! |---|---|---|
93//! | `-0.0` (float) | `"minus nol koma nol"` | `"minus nol koma nol"` |
94//! | `Decimal("-0.0")` | `"minus nol koma nol"` | `"nol koma nol"` |
95//! | `Decimal("-0")` | `"minus nol"` | `"nol"` |
96//!
97//! The float row survives because an `f64` carries a sign bit and
98//! `is_sign_negative()` reads it. The Decimal rows cannot: `BigDecimal` is
99//! backed by a `BigInt`, which has no negative zero, so
100//! `BigDecimal::from_str("-0.0").sign()` is already `NoSign` — the shim parses
101//! the string in `num2words2-py`, and the sign is gone before any code in this
102//! file runs. Recovering it needs the raw string across the boundary.
103//!
104//! Note this is specific to `to_cardinal`'s string-driven sign test.
105//! `to_currency` is unaffected: it uses `val < 0`, and `Decimal("-0.0") < 0`
106//! is `False` in Python too, so both sides agree on "nol rupiah".
107
108use crate::base::{Lang, N2WError, Result};
109use crate::currency::{CurrencyForms, CurrencyValue};
110use crate::floatpath::FloatValue;
111use bigdecimal::BigDecimal;
112use num_bigint::BigInt;
113use num_integer::Integer;
114use num_traits::{One, Signed, ToPrimitive, Zero};
115use std::collections::HashMap;
116use std::str::FromStr;
117
118/// `self.ones`. Index 0 is `""` and is never reached: `_int_to_word` short
119/// circuits `number == 0` to "nol", and every other read is guarded by a
120/// non-zero check.
121const ONES: [&str; 10] = [
122    "", "siki", "dua", "telu", "papat", "lima", "nem", "pitu", "kutus", "sia",
123];
124
125/// `self.tens`. Index 0 is `""` and is unreachable (`t >= 1` whenever the
126/// tens branch runs, since it needs `number >= 10`).
127const TENS: [&str; 10] = [
128    "",
129    "dasa",
130    "kalih dasa",
131    "tigang dasa",
132    "petang dasa",
133    "seket",
134    "nem dasa",
135    "pitung dasa",
136    "kutus dasa",
137    "siangang dasa",
138];
139
140const HUNDRED: &str = "satus";
141const THOUSAND: &str = "siu";
142const MILLION: &str = "yuta";
143
144/// `self.negword`. The trailing space is part of the Python literal; the
145/// `.strip()` in `to_cardinal` never actually trims it because the word sits
146/// at the *start* of the result.
147const NEGWORD: &str = "minus ";
148
149/// Port of `Num2Word_BAN._int_to_word`.
150///
151/// Only ever reached with a non-negative value: `to_cardinal` peels the sign
152/// off the *string* before parsing, so the recursion below never sees one.
153///
154/// Note the 10^9 fallback (`return str(number)`) — see the module docs. It
155/// makes the recursion depth trivially bounded (at most: millions → thousands
156/// → hundreds → tens), which is why plain recursion is safe here.
157fn int_to_word(number: &BigInt) -> String {
158    if number.is_zero() {
159        return "nol".to_string();
160    }
161
162    let ten = BigInt::from(10);
163    let hundred = BigInt::from(100);
164    let thousand = BigInt::from(1000);
165    let million = BigInt::from(1_000_000);
166    let billion = BigInt::from(1_000_000_000);
167
168    // if number < 10: return self.ones[number]
169    if number < &ten {
170        return ONES[number.to_usize().expect("0..=9 fits usize")].to_string();
171    }
172
173    // if number < 100: t, o = divmod(number, 10)
174    //                  return self.tens[t] + (" " + self.ones[o] if o else "")
175    if number < &hundred {
176        let (t, o) = number.div_mod_floor(&ten);
177        let mut s = TENS[t.to_usize().expect("1..=9 fits usize")].to_string();
178        if !o.is_zero() {
179            s.push(' ');
180            s.push_str(ONES[o.to_usize().expect("1..=9 fits usize")]);
181        }
182        return s;
183    }
184
185    // if number < 1000: h, r = divmod(number, 100)
186    //                   base = (self.ones[h] + " " if h > 1 else "") + self.hundred
187    //                   return base + (" " + self._int_to_word(r) if r else "")
188    //
189    // The `h > 1` guard is what makes 100 == "satus" rather than "siki satus";
190    // note it is a conditional *expression*, so h == 1 contributes nothing at
191    // all (not even a space).
192    if number < &thousand {
193        let (h, r) = number.div_mod_floor(&hundred);
194        let mut s = String::new();
195        if h > BigInt::one() {
196            s.push_str(ONES[h.to_usize().expect("2..=9 fits usize")]);
197            s.push(' ');
198        }
199        s.push_str(HUNDRED);
200        if !r.is_zero() {
201            s.push(' ');
202            s.push_str(&int_to_word(&r));
203        }
204        return s;
205    }
206
207    // if number < 1000000: t, r = divmod(number, 1000)
208    //                      base = self._int_to_word(t) + " " + self.thousand
209    //                      return base + (" " + self._int_to_word(r) if r else "")
210    //
211    // No `t > 1` guard here, unlike hundreds — hence 1000 == "siki siu".
212    if number < &million {
213        let (t, r) = number.div_mod_floor(&thousand);
214        let mut s = int_to_word(&t);
215        s.push(' ');
216        s.push_str(THOUSAND);
217        if !r.is_zero() {
218            s.push(' ');
219            s.push_str(&int_to_word(&r));
220        }
221        return s;
222    }
223
224    // if number < 1000000000: m, r = divmod(number, 1000000)
225    //                         base = self._int_to_word(m) + " " + self.million
226    //                         return base + (" " + self._int_to_word(r) if r else "")
227    if number < &billion {
228        let (m, r) = number.div_mod_floor(&million);
229        let mut s = int_to_word(&m);
230        s.push(' ');
231        s.push_str(MILLION);
232        if !r.is_zero() {
233            s.push(' ');
234            s.push_str(&int_to_word(&r));
235        }
236        return s;
237    }
238
239    // return str(number)  — the 10^9 cliff. Preserved verbatim.
240    number.to_string()
241}
242
243/// `Num2Word_BAN.to_currency`'s fallback key.
244///
245/// Python does `self.CURRENCY_FORMS.get(currency, list(...values())[0])`.
246/// `CURRENCY_FORMS` is a dict literal and dicts preserve insertion order, so
247/// `values()[0]` is unconditionally the **IDR** entry. A `HashMap` has no
248/// first element, so the fallback is pinned by name instead of by position.
249const FALLBACK_CURRENCY: &str = "IDR";
250
251pub struct LangBan {
252    /// `Num2Word_BAN.CURRENCY_FORMS`, built once (the registry caches the
253    /// instance in a `OnceLock`) rather than per call.
254    ///
255    /// `Num2Word_BAN` declares the dict on the class itself and never mutates
256    /// a shared parent dict, so — unlike the `Num2Word_EUR` families — there
257    /// is no import-time rewrite by `Num2Word_EN.__init__` to reproduce. The
258    /// literal in `lang_BAN.py` *is* what runs; verified against the live
259    /// interpreter (see the test below). Only these three codes exist:
260    /// everything else falls back (`to_currency`) or raises (`to_cheque`).
261    forms: HashMap<&'static str, CurrencyForms>,
262}
263
264impl Default for LangBan {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl LangBan {
271    pub fn new() -> Self {
272        let mut forms = HashMap::new();
273        forms.insert("IDR", CurrencyForms::new(&["rupiah", "rupiah"], &["sen", "sen"]));
274        forms.insert("USD", CurrencyForms::new(&["dolar", "dolar"], &["sen", "sen"]));
275        forms.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["sen", "sen"]));
276        LangBan { forms }
277    }
278}
279
280/// Reproduce `str(abs(val)).split(".")` structurally, then Python's
281/// `int(parts[0])` / `int(parts[1][:2].ljust(2, "0"))`.
282///
283/// The `BigDecimal` was parsed from `str(value)` on the Python side, so its
284/// digits and scale *are* the digits and scale of the repr BAN re-stringifies.
285/// Rebuilding the two integers from `(int_val, scale)` therefore reproduces
286/// the split exactly for every value whose repr is positional — which is every
287/// row in the corpus and every value in `1e-4 <= |x| < 1e16`.
288///
289/// Returns `(left, right)`, both non-negative. See the module docs for the
290/// exponential-repr divergence this cannot express.
291fn split_currency_parts(value: &bigdecimal::BigDecimal) -> Result<(BigInt, BigInt)> {
292    let (int_val, scale) = value.as_bigint_and_exponent();
293    // `abs(val)` happens before `str(val)`, and abs leaves the scale alone.
294    let int_val = int_val.abs();
295
296    // scale < 0 means the source string carried a positive exponent — the
297    // only way `BigDecimal::from_str` yields one. Python's `str()` keeps that
298    // form for both floats (`str(1e16) == "1e+16"`) and Decimals
299    // (`str(Decimal("1E+2")) == "1E+2"`), so `"1e+16".split(".")` is a
300    // one-element list and `int("1e+16")` raises ValueError. Both source
301    // types agree here, so the branch is unambiguous.
302    if scale < 0 {
303        return Err(N2WError::Value(format!(
304            "invalid literal for int() with base 10: '{}'",
305            value
306        )));
307    }
308
309    // No ".": `parts` has one element, so `len(parts) > 1` is false → right 0.
310    if scale == 0 {
311        return Ok((int_val, BigInt::zero()));
312    }
313
314    // The last `scale` digits are what repr prints after the "."; pad on the
315    // left so there is always at least one integer digit ("0.01" → "001").
316    let digits = int_val.to_string();
317    let s = scale as usize;
318    let padded = if digits.len() <= s {
319        format!("{}{}", "0".repeat(s - digits.len() + 1), digits)
320    } else {
321        digits
322    };
323    let split = padded.len() - s;
324    let left = BigInt::from_str(&padded[..split]).expect("digits only");
325
326    // parts[1][:2].ljust(2, "0") — truncation, never rounding: 12.345 → 34.
327    let mut two: String = padded[split..].chars().take(2).collect();
328    while two.len() < 2 {
329        two.push('0');
330    }
331    let right = BigInt::from_str(&two).expect("digits only");
332    Ok((left, right))
333}
334
335// ---- the float / Decimal cardinal path -------------------------------
336//
337// BAN does **not** use `Num2Word_Base.float2tuple`. `to_cardinal` is an
338// outright override whose first statement is `n = str(number).strip()`, and
339// every later branch works on that *string*:
340//
341//     if "." in n:
342//         left, right = n.split(".", 1)
343//         ret = self._int_to_word(int(left)) + " " + self.pointword
344//         for digit in right:
345//             ret += " " + (self.ones[int(digit)] or "nol")
346//         return ret.strip()
347//
348// So the usual float2tuple story does not apply here, and neither trap in the
349// porting contract bites: there is no `abs(value - pre) * 10**precision`
350// product to carry binary noise, and no `round()` to get banker's-vs-away
351// wrong. `2.675` reaches BAN as the *repr* "2.675" and yields
352// "dua koma nem pitu lima" straight off the digit characters — the same answer
353// float2tuple's `< 0.01` rescue arrives at, but for an unrelated reason.
354//
355// What that buys in simplicity it spends on formatting: the port now has to
356// reproduce `str()` itself, for both input types. Both are shortest-round-trip
357// (float) or exact (Decimal) and switch to exponential notation at thresholds
358// that are part of the observable behaviour — past them BAN raises ValueError
359// rather than converting (see `python_float_repr` / `python_decimal_str`).
360//
361// `self.precision` is never read by any of this, so the `precision=` kwarg is
362// inert for BAN. Verified in the live interpreter:
363//     num2words(2.675, lang="ban", precision=1) == "dua koma nem pitu lima"
364// The dispatcher still assigns `converter.precision` (base's `__init__` sets
365// it to 2, so `hasattr` passes) — it is simply never consulted again.
366
367/// Python's `int(s)`, over the token shapes `str(float)` / `str(Decimal)` can
368/// actually produce.
369///
370/// Deliberately narrow: Python's `int()` also accepts underscore separators
371/// and non-ASCII decimal digits (`int("٥") == 5`), but neither `repr(float)`
372/// nor `Decimal.__str__` can emit them, so no reachable input distinguishes
373/// this from the real thing.
374///
375/// The ValueError message is Python's verbatim, and reports the *original*
376/// token rather than the stripped one — `int(" x ")` says `' x '`.
377fn parse_int(token: &str) -> Result<BigInt> {
378    let stripped = token.trim();
379    let (negative, digits) = match stripped.strip_prefix('-') {
380        Some(rest) => (true, rest),
381        None => (false, stripped.strip_prefix('+').unwrap_or(stripped)),
382    };
383    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
384        return Err(N2WError::Value(format!(
385            "invalid literal for int() with base 10: '{}'",
386            token
387        )));
388    }
389    let value = BigInt::from_str(digits).expect("ascii digits only");
390    Ok(if negative { -value } else { value })
391}
392
393/// CPython's `repr(float)` / `str(float)` — they are the same function.
394///
395/// `float_repr_style` is "short", so CPython calls `PyOS_double_to_string(v,
396/// 'r', 0, Py_DTSF_ADD_DOT_0, NULL)`, which runs David Gay's `dtoa` in mode 0
397/// (shortest string that round-trips) and then, in `format_float_short`:
398///
399/// ```c
400/// case 'r':
401///     /* convert to exponential format at 1e16. */
402///     if (decpt <= -4 || decpt > 16)
403///         use_exp = 1;
404///     break;
405/// ```
406///
407/// where `value == 0.d1d2... * 10**decpt`. Rust's `{:e}` for f64 is the same
408/// shortest-round-trip contract and emits `d[.ddd]e<exp>` with no trailing
409/// mantissa zeros, so `decpt` is just `exp + 1` and the digit string carries
410/// straight over. Only the *notation* has to be re-decided here.
411///
412/// The thresholds are load-bearing rather than cosmetic: BAN feeds this string
413/// to `int()`, so every value that goes exponential raises instead of
414/// converting. `1e15` → "1000000000000000.0" → converts; `1e16` → "1e+16" →
415/// ValueError. Likewise `1e-4` → "0.0001" converts, `1e-5` → "1e-05" raises.
416///
417/// `Py_DTSF_ADD_DOT_0` appends ".0" to an otherwise integral result, but only
418/// on the non-exponential branch — hence "1000000000000000.0" yet plain
419/// "1e+16".
420fn python_float_repr(value: f64) -> String {
421    // CPython special-cases these before dtoa ever runs, and lowercases them.
422    // Rust would say "NaN"/"inf", and repr() drops the sign of a NaN.
423    if value.is_nan() {
424        return "nan".to_string();
425    }
426    if value.is_infinite() {
427        return if value.is_sign_negative() { "-inf" } else { "inf" }.to_string();
428    }
429
430    // `is_sign_negative`, not `< 0.0`: repr(-0.0) is "-0.0", and BAN keys its
431    // negative branch off that leading "-" — so -0.0 really does come back as
432    // "minus nol koma nol" where base.py's `value < 0` would have lost it.
433    let sign = if value.is_sign_negative() { "-" } else { "" };
434
435    let formatted = format!("{:e}", value.abs());
436    let (mantissa, exponent) = formatted
437        .split_once('e')
438        .expect("f64 LowerExp always emits an 'e'");
439    let exponent: i32 = exponent.parse().expect("LowerExp emits a decimal exponent");
440    let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
441    // dtoa's decpt. `{:e}` normalises to one digit before the point, and zero
442    // formats as "0e0" — which is exactly dtoa's ("0", decpt = 1), so
443    // repr(0.0) lands on "0.0" via the integral branch below.
444    let decpt = exponent + 1;
445
446    if decpt <= -4 || decpt > 16 {
447        let mut out = String::from(sign);
448        out.push_str(&digits[..1]);
449        if digits.len() > 1 {
450            out.push('.');
451            out.push_str(&digits[1..]);
452        }
453        // sprintf(p, "%+.02d", decpt - 1): always signed, at least two digits,
454        // and no truncation above that — "1e+16", "1e-05", "1e+100".
455        out.push('e');
456        let exp10 = decpt - 1;
457        out.push(if exp10 < 0 { '-' } else { '+' });
458        out.push_str(&format!("{:02}", (exp10 as i64).abs()));
459        out
460    } else if decpt <= 0 {
461        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
462    } else if decpt as usize >= digits.len() {
463        // Integral: pad out to the decimal point, then ADD_DOT_0.
464        format!(
465            "{}{}{}.0",
466            sign,
467            digits,
468            "0".repeat(decpt as usize - digits.len())
469        )
470    } else {
471        format!(
472            "{}{}.{}",
473            sign,
474            &digits[..decpt as usize],
475            &digits[decpt as usize..]
476        )
477    }
478}
479
480/// CPython's `Decimal.__str__` (`_pydecimal.py`, mirrored by `_decimal`):
481///
482/// ```python
483/// leftdigits = self._exp + len(self._int)
484/// if self._exp <= 0 and leftdigits > -6:
485///     dotplace = leftdigits          # no exponent required
486/// else:
487///     dotplace = 1                   # usual scientific notation
488/// if dotplace <= 0:
489///     intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
490/// elif dotplace >= len(self._int):
491///     intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
492/// else:
493///     intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
494/// exp = '' if leftdigits == dotplace else 'E' + "%+d" % (leftdigits-dotplace)
495/// return sign + intpart + fracpart + exp
496/// ```
497///
498/// `BigDecimal` stores the same `(coefficient, exponent)` pair a `Decimal`
499/// does — that is the whole point of the type, and the shim hands the core
500/// `str(value)` to parse — so the round trip is exact, trailing zeros and all:
501/// `Decimal("1.10")` keeps its scale and prints "1.10", giving
502/// "siki koma siki nol" rather than the "siki koma siki" a normalised 1.1
503/// would.
504///
505/// Note the capital `E` and the *different* threshold from the float branch:
506/// `Decimal("1E+2")` and `Decimal("1E-7")` stringify exponentially and so
507/// raise, while the equal-valued `Decimal("100")` and `Decimal("0.0000001")`
508/// … also differ, because Decimal's notation follows the written exponent
509/// rather than the value. Both are Python's behaviour, not a rounding of it.
510fn python_decimal_str(value: &BigDecimal) -> String {
511    let (coefficient, scale) = value.as_bigint_and_exponent();
512    let sign = if coefficient.is_negative() { "-" } else { "" };
513    // `self._int`: the coefficient's digits, unsigned. Decimal keeps no
514    // leading zeros (zero itself is the single digit "0"), and neither does
515    // BigInt's Display.
516    let int_digits = coefficient.abs().to_string();
517    // BigDecimal's `scale` is Decimal's `_exp` negated.
518    let exp = -scale;
519    let leftdigits = exp + int_digits.len() as i64;
520    let dotplace = if exp <= 0 && leftdigits > -6 {
521        leftdigits
522    } else {
523        1
524    };
525
526    let (intpart, fracpart) = if dotplace <= 0 {
527        (
528            "0".to_string(),
529            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
530        )
531    } else if dotplace >= int_digits.len() as i64 {
532        (
533            format!(
534                "{}{}",
535                int_digits,
536                "0".repeat((dotplace - int_digits.len() as i64) as usize)
537            ),
538            String::new(),
539        )
540    } else {
541        (
542            int_digits[..dotplace as usize].to_string(),
543            format!(".{}", &int_digits[dotplace as usize..]),
544        )
545    };
546    let exponent = if leftdigits == dotplace {
547        String::new()
548    } else {
549        // ['e', 'E'][context.capitals] — the default context has capitals=1.
550        // "%+d" is signed but, unlike repr(float)'s "%+.02d", not zero-padded.
551        format!("E{:+}", leftdigits - dotplace)
552    };
553    format!("{}{}{}{}", sign, intpart, fracpart, exponent)
554}
555
556impl LangBan {
557    /// `Num2Word_BAN.to_cardinal`, driven from the string as Python drives it.
558    ///
559    /// The integer-mode `to_cardinal` below inlines the same cascade over a
560    /// `BigInt` (its `"." in n` branch is unreachable); this is the same method
561    /// with the fractional branch live. Kept separate rather than merged
562    /// because the phase contract freezes the integer path.
563    ///
564    /// Python recurses with a *string* (`self.to_cardinal(n[1:])`), which then
565    /// re-enters `str(number)` as a no-op — so the recursion below is on `&str`
566    /// too, not on a re-parsed number.
567    fn cardinal_from_str(&self, number: &str) -> Result<String> {
568        let n = number.trim();
569
570        // if n.startswith("-"):
571        //     return (self.negword + self.to_cardinal(n[1:])).strip()
572        if let Some(rest) = n.strip_prefix('-') {
573            let inner = self.cardinal_from_str(rest)?;
574            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
575        }
576
577        // if "." in n: left, right = n.split(".", 1)
578        if let Some((left, right)) = n.split_once('.') {
579            // `int(left)` is what raises on "1.5e+16"'s sibling forms; here
580            // left is "1" and it is `right` that blows up in the loop below.
581            let mut ret = format!("{} {}", int_to_word(&parse_int(left)?), self.pointword());
582            // for digit in right: ret += " " + (self.ones[int(digit)] or "nol")
583            for digit in right.chars() {
584                // int() of a single character: 0..=9 or ValueError. This is
585                // the raise that catches exponential reprs whose mantissa has
586                // a point — "1.5e+16" fails on 'e', not on the whole token.
587                let index = parse_int(&digit.to_string())?
588                    .to_usize()
589                    .expect("a single ASCII digit is 0..=9");
590                ret.push(' ');
591                // `self.ones[0]` is "" — falsy, so `or "nol"` fires. That is
592                // the only reason a leading zero survives: 0.01 → "nol siki".
593                let word = ONES[index];
594                ret.push_str(if word.is_empty() { "nol" } else { word });
595            }
596            return Ok(ret.trim().to_string());
597        }
598
599        // return self._int_to_word(int(n))
600        Ok(int_to_word(&parse_int(n)?))
601    }
602}
603
604impl Lang for LangBan {
605
606    fn cardinal_float_entry(
607        &self,
608        value: &crate::floatpath::FloatValue,
609        precision_override: Option<u32>,
610    ) -> crate::base::Result<String> {
611        // Python's to_cardinal routes every float/Decimal through this
612        // language's own decimal grammar — 5.0 keeps its ".0" tail
613        // ("comma nulla"), unlike Base's whole-value integer route.
614        self.to_cardinal_float(value, precision_override)
615    }
616
617    /// `to_ordinal(float/Decimal)`: `"ka-" + self.to_cardinal(number)`, the
618    /// same prefix rule as the integer path — no verify_ordinal, so
619    /// negatives keep their minus word ("ka-minus dua koma nol"). Errors
620    /// from `to_cardinal` (e.g. `int('1e+16')` → ValueError) propagate.
621    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
622        Ok(format!("ka-{}", self.cardinal_float_entry(value, None)?))
623    }
624
625    /// `to_ordinal_num(float/Decimal)`: `"ka-" + str(number)` — the str() is
626    /// Python's, handed in as `repr_str` ("ka--1.5", "ka-1e+16").
627    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
628        Ok(format!("ka-{}", repr_str))
629    }
630
631    /// `converter.str_to_number` is Base's `Decimal(value)`, but BAN's own
632    /// `to_cardinal` then does `int(str(...))` on the no-dot form, so
633    /// `"Infinity"` raises **ValueError** (`int('Infinity')`), not the
634    /// OverflowError the shared Inf sentinel would produce. Raising here is
635    /// observably identical: no digit is present, so the dispatcher
636    /// propagates the ValueError rather than falling back to the sentence
637    /// converter.
638    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
639        match crate::strnum::python_decimal_parse(s)? {
640            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
641                "invalid literal for int() with base 10: 'Infinity'".into(),
642            )),
643            other => Ok(other),
644        }
645    }
646
647    /// `self.negword` — "minus " (set in `setup`). The integer/float
648    /// cardinal paths use their own inlined constant, but the inherited
649    /// `Num2Word_Base.to_fraction` reads `self.negword.strip()`, so the
650    /// trait hook must expose the real word: `-3/4` → "minus telu ka-papats",
651    /// not the base default "(-) ".
652    fn negword(&self) -> &str {
653        "minus "
654    }
655
656    /// `self.pointword`, read from the live Python instance.
657    /// Unused by the four integer modes, so phase 1 never needed it —
658    /// the float path is the first caller.
659    fn pointword(&self) -> &str {
660        "koma"
661    }
662
663    /// This language's own `to_currency(currency=...)` default,
664    /// read from the live Python signature. Only 44 of 156 use EUR.
665    fn default_currency(&self) -> &str {
666        "IDR"
667    }
668
669    /// This language's own `to_currency(separator=...)` default,
670    /// read from the live Python signature. Base's is ",", but only
671    /// 36 of 149 languages actually use it — most default to " " or a
672    /// conjunction, so inheriting Base's comma silently corrupts them.
673    fn default_separator(&self) -> &str {
674        " "
675    }
676
677    /// Port of `Num2Word_BAN.to_cardinal`.
678    ///
679    /// Python works on `n = str(number).strip()`:
680    /// ```text
681    /// if n.startswith("-"):
682    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
683    /// if "." in n: ...            # unreachable for integer input
684    /// return self._int_to_word(int(n))
685    /// ```
686    /// The negative branch recurses with the *digits-only* string, which then
687    /// takes the final `_int_to_word(int(n))` path — so it is exactly
688    /// `int_to_word(abs(value))`, inlined here. The `"." in n` branch is the
689    /// float path and cannot trigger on an integer.
690    ///
691    /// The trailing `.strip()` is kept for fidelity even though it is a no-op:
692    /// `negword` leads, and `int_to_word` never emits edge whitespace.
693    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
694        if value.is_negative() {
695            let inner = int_to_word(&value.abs());
696            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
697        }
698        Ok(int_to_word(value))
699    }
700
701    /// Port of `Num2Word_BAN.to_ordinal`: `"ka-" + self.to_cardinal(number)`.
702    ///
703    /// No sign handling, so negatives yield "ka-minus siki" (see module docs).
704    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
705        Ok(format!("ka-{}", self.to_cardinal(value)?))
706    }
707
708    /// Port of `Num2Word_BAN.to_ordinal_num`: `"ka-" + str(number)`.
709    ///
710    /// Overrides the base (which returns the value untouched). `-1` renders as
711    /// "ka--1"; the double hyphen is Python's and is preserved.
712    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
713        Ok(format!("ka-{}", value))
714    }
715
716    /// Port of `Num2Word_BAN.to_year`: `return self.to_cardinal(val)`.
717    ///
718    /// BAN ignores its own `longval=True` kwarg, so there is no century
719    /// splitting: 1984 is "siki siu sia satus kutus dasa papat", not
720    /// "nineteen eighty-four"-style. Negative years keep the minus word
721    /// (`-500` → "minus lima satus") rather than gaining a "BC" suffix.
722    fn to_year(&self, value: &BigInt) -> Result<String> {
723        self.to_cardinal(value)
724    }
725
726    /// The float/Decimal arm of `Num2Word_BAN.to_cardinal`.
727    ///
728    /// BAN does not override `to_cardinal_float` — it overrides `to_cardinal`
729    /// and handles non-integers inline — so `Num2Word_Base.to_cardinal_float`
730    /// and `float2tuple` never run for this language and the default
731    /// implementation this replaces would have been wrong. Everything
732    /// observable comes from `str(number)`; see the notes above
733    /// `python_float_repr`.
734    ///
735    /// `precision_override` is ignored, and that is the port, not a shortcut.
736    /// `Num2Word_BAN.to_cardinal(self, number)` takes no `precision` argument,
737    /// so `num2words`' `kwargs.pop("precision", None)` removes it before the
738    /// call and only assigns `converter.precision`, which nothing here reads:
739    ///     num2words(2.675, lang="ban", precision=1) == "dua koma nem pitu lima"
740    fn to_cardinal_float(
741        &self,
742        value: &FloatValue,
743        _precision_override: Option<u32>,
744    ) -> Result<String> {
745        // n = str(number).strip() — the whole method, in one line. The two
746        // arms are not interchangeable: `str()` of a float is shortest
747        // round-trip and goes exponential outside [1e-4, 1e16), while `str()`
748        // of a Decimal is exact and goes exponential on its own written
749        // exponent.
750        let n = match value {
751            FloatValue::Float { value, .. } => python_float_repr(*value),
752            FloatValue::Decimal { value, .. } => python_decimal_str(value),
753        };
754        self.cardinal_from_str(&n)
755    }
756
757    // ---- currency ----------------------------------------------------
758
759    fn lang_name(&self) -> &str {
760        "Num2Word_BAN"
761    }
762
763    /// `CURRENCY_FORMS[code]` — a *strict* lookup, deliberately.
764    ///
765    /// The lenient `.get(currency, <IDR>)` fallback lives in `to_currency`
766    /// alone, because only `to_currency` has it. Inherited `to_cheque` does
767    /// `self.CURRENCY_FORMS[currency]` and converts the KeyError into
768    /// NotImplementedError, which is why `cheque:GBP` raises while
769    /// `currency:GBP` quietly prints rupiah. Returning the fallback here would
770    /// make every cheque row succeed and lose seven corpus errors.
771    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
772        self.forms.get(code)
773    }
774
775    /// Port of `Num2Word_BAN.pluralize`:
776    /// ```text
777    /// if not forms: return ""
778    /// return forms[0] if n == 1 else forms[-1]
779    /// ```
780    /// Dead for both modes in scope — `to_currency` is overridden and picks
781    /// forms inline, and `to_cheque` takes `cr1[-1]` unconditionally — but it
782    /// is a real override, so it is ported rather than left raising.
783    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
784        if forms.is_empty() {
785            return Ok(String::new());
786        }
787        Ok(if n.is_one() {
788            forms[0].clone()
789        } else {
790            forms[forms.len() - 1].clone()
791        })
792    }
793
794    /// Port of `Num2Word_BAN.to_currency` — a full override; **none** of
795    /// `Num2Word_Base.to_currency` runs.
796    ///
797    /// ```text
798    /// is_negative = val < 0
799    /// val = abs(val)
800    /// parts = str(val).split(".")
801    /// left  = int(parts[0]) if parts[0] else 0
802    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
803    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
804    /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
805    /// if cents and right:
806    ///     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
807    /// if is_negative:
808    ///     result = self.negword + result
809    /// return result.strip()
810    /// ```
811    ///
812    /// Consequences worth spelling out, all corpus-confirmed:
813    ///
814    /// * **`CURRENCY_PRECISION` is never consulted.** BAN hardcodes two
815    ///   decimals. The divisor-1 pre-rounding and the 1000-divisor mil
816    ///   handling both live in `Num2Word_Base.to_currency`, which is bypassed,
817    ///   so `currency:JPY 12.34` still prints cents ("dasa dua rupiah tigang
818    ///   dasa papat sen") and KWD/BHD are ordinary 100ths. `currency_precision`
819    ///   is left at the trait default only because BAN's `CURRENCY_PRECISION`
820    ///   is `{}` and inherited `to_cheque` reads it (→ 100).
821    /// * **An unknown code does not raise.** It silently renders as IDR.
822    /// * **`adjective` is accepted and ignored** — BAN defines no
823    ///   `CURRENCY_ADJECTIVES` and never calls `prefix_currency`.
824    /// * **`cents=False` drops the segment entirely** rather than falling back
825    ///   to the terse digit form the base class uses.
826    /// * **The int/float split is invisible here.** BAN routes both through
827    ///   `str(val)`, and `1.0` → `parts[1] == "0"` → `right == 0` → falsy → no
828    ///   cents segment, landing on the same "siki euro" a true `1` gives. The
829    ///   distinction is still honoured (an int has no `parts[1]` at all); it
830    ///   just cannot be observed in the output.
831    fn to_currency(
832        &self,
833        val: &CurrencyValue,
834        currency: &str,
835        cents: bool,
836        separator: Option<&str>,
837        _adjective: bool,
838    ) -> Result<String> {
839        // Trait now hands us None when the caller omitted separator=;
840        // resolve it to this language's own default before the ported body.
841        let separator = separator.unwrap_or(self.default_separator());
842        // `Num2Word_BAN.to_currency` defaults `separator=" "`, but the Python
843        // shim (`__init__.py`) forwards `kwargs.get("separator", ",")` and the
844        // diff harness hardcodes ",". Both destroy the "caller said nothing"
845        // signal before it reaches here, so an incoming "," is indistinguish-
846        // able from an omitted kwarg and is treated as one. The corpus was
847        // generated through BAN's own default and expects " ":
848        //   currency:EUR 12.34 → "dasa dua euro tigang dasa papat sen"
849        // Any other separator is honoured verbatim, so only an *explicit*
850        // separator="," diverges — the one value that is already ambiguous.
851        // The real fix is an Option<&str> across the boundary; see `concerns`.
852        let separator = if separator == "," { " " } else { separator };
853
854        let is_negative = val.is_negative();
855        let (left, right) = match val {
856            // str(int) never contains a ".", so parts[1] does not exist.
857            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
858            CurrencyValue::Decimal { value: d, .. } => split_currency_parts(d)?,
859        };
860
861        // .get(currency, list(values())[0]) — the fallback is always IDR.
862        let forms = self
863            .forms
864            .get(currency)
865            .or_else(|| self.forms.get(FALLBACK_CURRENCY))
866            .expect("IDR is always present");
867        let (cr1, cr2) = (&forms.unit, &forms.subunit);
868
869        // cr1[1] if left != 1 else cr1[0] — a literal index, as in Python.
870        let unit = if left.is_one() { &cr1[0] } else { &cr1[1] };
871        let mut result = format!("{} {}", int_to_word(&left), unit);
872
873        if cents && !right.is_zero() {
874            let sub = if right.is_one() { &cr2[0] } else { &cr2[1] };
875            result.push_str(separator);
876            result.push_str(&int_to_word(&right));
877            result.push(' ');
878            result.push_str(sub);
879        }
880
881        if is_negative {
882            result = format!("{}{}", NEGWORD, result);
883        }
884        // .strip() — a no-op for every reachable input, kept for fidelity.
885        Ok(result.trim().to_string())
886    }
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892
893    fn card(n: i64) -> String {
894        LangBan::new().to_cardinal(&BigInt::from(n)).unwrap()
895    }
896    fn ord_(n: i64) -> String {
897        LangBan::new().to_ordinal(&BigInt::from(n)).unwrap()
898    }
899    fn ord_num(n: i64) -> String {
900        LangBan::new().to_ordinal_num(&BigInt::from(n)).unwrap()
901    }
902    fn year(n: i64) -> String {
903        LangBan::new().to_year(&BigInt::from(n)).unwrap()
904    }
905
906    /// Every expectation below is copied verbatim from the frozen corpus
907    /// (`grep '"lang": "ban"' bench/corpus.jsonl`).
908    #[test]
909    fn corpus_cardinal() {
910        assert_eq!(card(0), "nol");
911        assert_eq!(card(1), "siki");
912        assert_eq!(card(9), "sia");
913        assert_eq!(card(10), "dasa");
914        assert_eq!(card(11), "dasa siki");
915        assert_eq!(card(20), "kalih dasa");
916        assert_eq!(card(50), "seket");
917        assert_eq!(card(99), "siangang dasa sia");
918        assert_eq!(card(100), "satus");
919        assert_eq!(card(101), "satus siki");
920        assert_eq!(card(110), "satus dasa");
921        assert_eq!(card(200), "dua satus");
922        assert_eq!(card(999), "sia satus siangang dasa sia");
923        assert_eq!(card(1000), "siki siu");
924        assert_eq!(card(1010), "siki siu dasa");
925        assert_eq!(card(12345), "dasa dua siu telu satus petang dasa lima");
926        assert_eq!(card(100000), "satus siu");
927        assert_eq!(card(1000000), "siki yuta");
928        assert_eq!(
929            card(1234567),
930            "siki yuta dua satus tigang dasa papat siu lima satus nem dasa pitu"
931        );
932        assert_eq!(
933            card(123456789),
934            "satus kalih dasa telu yuta papat satus seket nem siu pitu satus kutus dasa sia"
935        );
936    }
937
938    /// The 10^9 cliff: `_int_to_word` falls through to `str(number)`.
939    #[test]
940    fn corpus_cardinal_above_billion_is_bare_digits() {
941        assert_eq!(card(1_000_000_000), "1000000000");
942        assert_eq!(card(1_234_567_890), "1234567890");
943        let big: BigInt = BigInt::from(10).pow(21u32);
944        assert_eq!(
945            LangBan::new().to_cardinal(&big).unwrap(),
946            "1000000000000000000000"
947        );
948    }
949
950    #[test]
951    fn corpus_negative() {
952        assert_eq!(card(-1), "minus siki");
953        assert_eq!(card(-21), "minus kalih dasa siki");
954        assert_eq!(card(-100), "minus satus");
955        assert_eq!(card(-1000), "minus siki siu");
956        assert_eq!(card(-1000000), "minus siki yuta");
957    }
958
959    #[test]
960    fn corpus_ordinal() {
961        assert_eq!(ord_(0), "ka-nol");
962        assert_eq!(ord_(1), "ka-siki");
963        assert_eq!(ord_(11), "ka-dasa siki");
964        assert_eq!(ord_(1000000), "ka-siki yuta");
965        // Negative keeps the minus word inline; >=10^9 keeps bare digits.
966        assert_eq!(ord_(-1), "ka-minus siki");
967        assert_eq!(ord_(1_000_000_000), "ka-1000000000");
968    }
969
970    #[test]
971    fn corpus_ordinal_num() {
972        assert_eq!(ord_num(0), "ka-0");
973        assert_eq!(ord_num(9), "ka-9");
974        assert_eq!(ord_num(1000000), "ka-1000000");
975        // Double hyphen, straight from Python's "ka-" + str(-1).
976        assert_eq!(ord_num(-1), "ka--1");
977        assert_eq!(ord_num(-1000), "ka--1000");
978    }
979
980    // ---- currency ----------------------------------------------------
981
982    use bigdecimal::BigDecimal;
983
984    /// The corpus drives every row with cents=true / adjective=false and no
985    /// separator= kwarg, so pass `None` exactly as `diff_test.py` does and let
986    /// BAN's own `default_separator()` (" ") apply.
987    ///
988    /// `has_decimal` mirrors the harness: it is `not is_int`, since the
989    /// generator wrote `repr(value)` and only a float/Decimal grows a dot.
990    fn cur(arg: &str, is_int: bool, code: &str) -> String {
991        let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
992        LangBan::new().to_currency(&v, code, true, None, false).unwrap()
993    }
994    fn cheque(arg: &str, code: &str) -> Result<String> {
995        LangBan::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
996    }
997
998    /// Verbatim from the frozen corpus. `arg` is `repr(value)`, so "1" is an
999    /// int and "1.0" a float — `is_int` mirrors `type(number) is int`.
1000    #[test]
1001    fn corpus_currency_known_codes() {
1002        assert_eq!(cur("0", true, "EUR"), "nol euro");
1003        assert_eq!(cur("1", true, "EUR"), "siki euro");
1004        assert_eq!(cur("2", true, "EUR"), "dua euro");
1005        assert_eq!(cur("100", true, "EUR"), "satus euro");
1006        assert_eq!(cur("1000000", true, "EUR"), "siki yuta euro");
1007        assert_eq!(cur("12.34", false, "EUR"), "dasa dua euro tigang dasa papat sen");
1008        assert_eq!(cur("0.01", false, "EUR"), "nol euro siki sen");
1009        assert_eq!(cur("0.5", false, "EUR"), "nol euro seket sen");
1010        assert_eq!(
1011            cur("99.99", false, "EUR"),
1012            "siangang dasa sia euro siangang dasa sia sen"
1013        );
1014        assert_eq!(
1015            cur("1234.56", false, "EUR"),
1016            "siki siu dua satus tigang dasa papat euro seket nem sen"
1017        );
1018        assert_eq!(cur("-12.34", false, "EUR"), "minus dasa dua euro tigang dasa papat sen");
1019        assert_eq!(cur("12.34", false, "USD"), "dasa dua dolar tigang dasa papat sen");
1020        assert_eq!(cur("1", true, "USD"), "siki dolar");
1021    }
1022
1023    /// `1.0` is a float, so Python reaches `parts[1] == "0"` → right == 0 →
1024    /// falsy → no cents segment. Same text as the int `1`, by a different path.
1025    #[test]
1026    fn corpus_currency_float_with_zero_cents() {
1027        assert_eq!(cur("1.0", false, "EUR"), "siki euro");
1028        assert_eq!(cur("1.0", false, "USD"), "siki dolar");
1029        assert_eq!(cur("1.0", false, "GBP"), "siki rupiah");
1030        assert_eq!(cur("1", true, "EUR"), "siki euro");
1031    }
1032
1033    /// Unknown codes silently render as IDR — `.get(currency, values()[0])`.
1034    /// Note JPY still shows cents and KWD/BHD are plain 100ths: BAN never
1035    /// reads CURRENCY_PRECISION.
1036    #[test]
1037    fn corpus_currency_unknown_code_falls_back_to_idr() {
1038        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1039            assert_eq!(cur("0", true, code), "nol rupiah");
1040            assert_eq!(cur("1", true, code), "siki rupiah");
1041            assert_eq!(cur("100", true, code), "satus rupiah");
1042            assert_eq!(cur("12.34", false, code), "dasa dua rupiah tigang dasa papat sen");
1043            assert_eq!(cur("0.01", false, code), "nol rupiah siki sen");
1044            assert_eq!(cur("0.5", false, code), "nol rupiah seket sen");
1045            assert_eq!(cur("1000000", true, code), "siki yuta rupiah");
1046            assert_eq!(
1047                cur("1234.56", false, code),
1048                "siki siu dua satus tigang dasa papat rupiah seket nem sen"
1049            );
1050            assert_eq!(
1051                cur("-12.34", false, code),
1052                "minus dasa dua rupiah tigang dasa papat sen"
1053            );
1054        }
1055    }
1056
1057    /// Not corpus rows — verified directly against the live interpreter
1058    /// (`Num2Word_BAN.to_currency(...)`), since the shim cannot express them.
1059    #[test]
1060    fn currency_quirks_verified_against_python() {
1061        let one = LangBan::new();
1062        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1063        // cents=False drops the segment; it does NOT fall back to terse digits.
1064        assert_eq!(one.to_currency(&v, "EUR", false, None, false).unwrap(), "dasa dua euro");
1065        // adjective is accepted and ignored (CURRENCY_ADJECTIVES is empty).
1066        assert_eq!(
1067            one.to_currency(&v, "EUR", true, None, true).unwrap(),
1068            "dasa dua euro tigang dasa papat sen"
1069        );
1070        // A non-default separator is honoured verbatim.
1071        assert_eq!(
1072            one.to_currency(&v, "EUR", true, Some(" ne "), false).unwrap(),
1073            "dasa dua euro ne tigang dasa papat sen"
1074        );
1075        // parts[1][:2] truncates, never rounds: 12.345 → 34, not 35.
1076        let v = CurrencyValue::parse("12.345", false, true, true).unwrap();
1077        assert_eq!(
1078            one.to_currency(&v, "EUR", true, None, false).unwrap(),
1079            "dasa dua euro tigang dasa papat sen"
1080        );
1081    }
1082
1083    /// `str(1e16) == "1e+16"` → `int("1e+16")` → ValueError. The parsed
1084    /// BigDecimal keeps the negative scale, so the notation is recoverable.
1085    /// Guards the assumption that from_str does not normalise it away.
1086    #[test]
1087    fn currency_exponential_repr_is_valueerror() {
1088        for arg in ["1e+16", "1E+2"] {
1089            let v = CurrencyValue::parse(arg, false, true, true).unwrap();
1090            match LangBan::new().to_currency(&v, "EUR", true, None, false) {
1091                Err(N2WError::Value(_)) => {}
1092                other => panic!("{} expected ValueError, got {:?}", arg, other),
1093            }
1094        }
1095        // Documented divergence: Python raises for the float 1e-05 but not for
1096        // Decimal("0.00001"), and both parse to the same BigDecimal. We match
1097        // the Decimal branch. See the module docs.
1098        assert_eq!(cur("1e-05", false, "EUR"), "nol euro");
1099    }
1100
1101    /// Inherited `Num2Word_Base.to_cheque`; BAN adds no override. The plural
1102    /// unit is `cr1[-1]`, and BAN's forms are identical singular/plural.
1103    #[test]
1104    fn corpus_cheque() {
1105        assert_eq!(
1106            cheque("1234.56", "EUR").unwrap(),
1107            "SIKI SIU DUA SATUS TIGANG DASA PAPAT AND 56/100 EURO"
1108        );
1109        assert_eq!(
1110            cheque("1234.56", "USD").unwrap(),
1111            "SIKI SIU DUA SATUS TIGANG DASA PAPAT AND 56/100 DOLAR"
1112        );
1113        // CURRENCY_FORMS[currency] raises KeyError → NotImplementedError.
1114        // Unlike to_currency, there is no IDR fallback here.
1115        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1116            match cheque("1234.56", code) {
1117                Err(N2WError::NotImplemented(m)) => assert_eq!(
1118                    m,
1119                    format!("Currency code \"{}\" not implemented for \"Num2Word_BAN\"", code)
1120                ),
1121                other => panic!("{} expected NotImplementedError, got {:?}", code, other),
1122            }
1123        }
1124    }
1125
1126    // ---- float / Decimal ---------------------------------------------
1127
1128    /// Drive a float exactly as the shim does: the raw f64 plus the
1129    /// repr-derived precision (which BAN ignores, but the boundary carries).
1130    fn flt(value: f64) -> Result<String> {
1131        let precision = match format!("{}", value).split_once('.') {
1132            Some((_, frac)) => frac.len() as u32,
1133            None => 0,
1134        };
1135        LangBan::new().to_cardinal_float(&FloatValue::Float { value, precision }, None)
1136    }
1137    fn fltok(value: f64) -> String {
1138        flt(value).unwrap()
1139    }
1140    /// A Decimal row: the shim passes `str(value)` for the core to re-parse.
1141    fn dec(s: &str) -> Result<String> {
1142        let value = BigDecimal::from_str(s).unwrap();
1143        let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
1144        LangBan::new().to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1145    }
1146    fn decok(s: &str) -> String {
1147        dec(s).unwrap()
1148    }
1149
1150    /// Every expectation verbatim from the frozen corpus
1151    /// (`grep '"lang": "ban", "to": "cardinal"' bench/corpus.jsonl`, dotted args).
1152    #[test]
1153    fn corpus_cardinal_float() {
1154        assert_eq!(fltok(0.0), "nol koma nol");
1155        assert_eq!(fltok(0.5), "nol koma lima");
1156        assert_eq!(fltok(1.0), "siki koma nol");
1157        assert_eq!(fltok(1.5), "siki koma lima");
1158        assert_eq!(fltok(2.25), "dua koma dua lima");
1159        assert_eq!(fltok(3.14), "telu koma siki papat");
1160        assert_eq!(fltok(0.01), "nol koma nol siki");
1161        assert_eq!(fltok(0.1), "nol koma siki");
1162        assert_eq!(fltok(0.99), "nol koma sia sia");
1163        assert_eq!(fltok(1.01), "siki koma nol siki");
1164        assert_eq!(fltok(12.34), "dasa dua koma telu papat");
1165        assert_eq!(fltok(99.99), "siangang dasa sia koma sia sia");
1166        assert_eq!(fltok(100.5), "satus koma lima");
1167        assert_eq!(
1168            fltok(1234.56),
1169            "siki siu dua satus tigang dasa papat koma lima nem"
1170        );
1171        assert_eq!(fltok(-0.5), "minus nol koma lima");
1172        assert_eq!(fltok(-1.5), "minus siki koma lima");
1173        assert_eq!(fltok(-12.34), "minus dasa dua koma telu papat");
1174        assert_eq!(fltok(1.005), "siki koma nol nol lima");
1175        assert_eq!(fltok(2.675), "dua koma nem pitu lima");
1176    }
1177
1178    /// Verbatim from the `cardinal_dec` corpus rows.
1179    #[test]
1180    fn corpus_cardinal_decimal() {
1181        assert_eq!(decok("0.01"), "nol koma nol siki");
1182        // Trailing zero survives: Decimal("1.10") is not Decimal("1.1").
1183        assert_eq!(decok("1.10"), "siki koma siki nol");
1184        assert_eq!(decok("12.345"), "dasa dua koma telu papat lima");
1185        // Issue #603's value. The integer part is past the 10^9 cliff, so it
1186        // stays bare digits — and it is exact, which is the point of the
1187        // Decimal arm: a float() cast would have rounded it.
1188        assert_eq!(
1189            decok("98746251323029.99"),
1190            "98746251323029 koma sia sia"
1191        );
1192        assert_eq!(decok("0.001"), "nol koma nol nol siki");
1193    }
1194
1195    /// The two f64-artefact cases the contract calls out. BAN never computes
1196    /// `abs(value - pre) * 10**precision`, so 2.675's 674.9999999999998 is
1197    /// never produced and never needs rescuing — the repr digits are read
1198    /// straight off. Same answer, different route.
1199    #[test]
1200    fn float_artefacts_never_arise() {
1201        assert_eq!(fltok(2.675), "dua koma nem pitu lima");
1202        assert_eq!(fltok(1.005), "siki koma nol nol lima");
1203        // repr is shortest-round-trip, so no 0.1+0.2 tail leaks in either.
1204        assert_eq!(fltok(0.1 + 0.2), "nol koma telu nol nol nol nol nol nol nol nol nol nol nol nol nol nol nol papat");
1205    }
1206
1207    /// `precision=` is inert for BAN: its `to_cardinal` takes no such kwarg.
1208    #[test]
1209    fn precision_override_is_ignored() {
1210        let value = FloatValue::Float { value: 2.675, precision: 3 };
1211        for override_ in [None, Some(1), Some(5)] {
1212            assert_eq!(
1213                LangBan::new().to_cardinal_float(&value, override_).unwrap(),
1214                "dua koma nem pitu lima"
1215            );
1216        }
1217    }
1218
1219    /// repr(-0.0) is "-0.0", and BAN's negative branch keys off the string —
1220    /// so the sign survives where base.py's `value < 0` would have dropped it.
1221    #[test]
1222    fn negative_zero_keeps_its_sign() {
1223        assert_eq!(fltok(-0.0), "minus nol koma nol");
1224        assert_eq!(fltok(0.0), "nol koma nol");
1225    }
1226
1227    /// Documented divergence — see the module docs. Python says
1228    /// "minus nol koma nol" / "minus nol" for these, because `str(Decimal)`
1229    /// keeps the sign of a zero. `BigDecimal` is backed by `BigInt`, which has
1230    /// no negative zero, so `from_str("-0.0").sign()` is already `NoSign` by
1231    /// the time the core is called and the minus is unrecoverable here.
1232    /// Pinned rather than left silent; no corpus row reaches it.
1233    #[test]
1234    fn negative_zero_decimal_loses_its_sign() {
1235        assert_eq!(BigDecimal::from_str("-0.0").unwrap().sign(), num_bigint::Sign::NoSign);
1236        assert_eq!(decok("-0.0"), "nol koma nol");
1237        assert_eq!(decok("-0"), "nol");
1238        // A non-zero coefficient has somewhere to keep the sign, so it works.
1239        assert_eq!(decok("-0.5"), "minus nol koma lima");
1240    }
1241
1242    /// The 10^9 cliff applies to the integer part of a float too: it is
1243    /// `_int_to_word` that gives up, and the fractional digits carry on.
1244    #[test]
1245    fn float_above_billion_keeps_bare_integer_part() {
1246        assert_eq!(
1247            fltok(123456789.5),
1248            "satus kalih dasa telu yuta papat satus seket nem siu pitu satus \
1249             kutus dasa sia koma lima"
1250        );
1251        assert_eq!(fltok(1234567890.5), "1234567890 koma lima");
1252        // decpt == 16 is still positional, so this converts rather than raising.
1253        assert_eq!(fltok(1e15), "1000000000000000 koma nol");
1254    }
1255
1256    /// Where `repr(float)` goes exponential, BAN feeds "e" to `int()` and
1257    /// crashes. Two distinct messages, because two distinct `int()` calls fail:
1258    /// the whole token when the repr has no ".", a lone character when it does.
1259    #[test]
1260    fn exponential_float_repr_is_valueerror() {
1261        for (value, token) in [
1262            (1e16, "1e+16"),
1263            (1e21, "1e+21"),
1264            (1e100, "1e+100"),
1265            (1e-5, "1e-05"),
1266            (1e-323, "1e-323"),
1267        ] {
1268            match flt(value) {
1269                Err(N2WError::Value(m)) => assert_eq!(
1270                    m,
1271                    format!("invalid literal for int() with base 10: '{}'", token)
1272                ),
1273                other => panic!("{} expected ValueError, got {:?}", value, other),
1274            }
1275        }
1276        // A mantissa with a point splits, so the loop reaches 'e' first.
1277        for value in [1.5e16, 1.5e-5, 2e16 + 8.0] {
1278            match flt(value) {
1279                Err(N2WError::Value(m)) => {
1280                    assert_eq!(m, "invalid literal for int() with base 10: 'e'")
1281                }
1282                other => panic!("{} expected ValueError, got {:?}", value, other),
1283            }
1284        }
1285        // 1e-4 is the last positional one on the small side.
1286        assert_eq!(fltok(1e-4), "nol koma nol nol nol siki");
1287    }
1288
1289    /// str(inf)/str(nan) are plain words with no ".", so they take the same
1290    /// `int(n)` raise. -inf loses its sign in the message: the negword branch
1291    /// strips "-" and recurses with "inf".
1292    #[test]
1293    fn non_finite_floats_are_valueerror() {
1294        for (value, token) in [
1295            (f64::INFINITY, "inf"),
1296            (f64::NEG_INFINITY, "inf"),
1297            (f64::NAN, "nan"),
1298        ] {
1299            match flt(value) {
1300                Err(N2WError::Value(m)) => assert_eq!(
1301                    m,
1302                    format!("invalid literal for int() with base 10: '{}'", token)
1303                ),
1304                other => panic!("{} expected ValueError, got {:?}", value, other),
1305            }
1306        }
1307    }
1308
1309    /// Decimal's notation follows its *written* exponent, not its value, so
1310    /// these raise while numerically equal literals convert.
1311    #[test]
1312    fn exponential_decimal_str_is_valueerror() {
1313        for (arg, token) in [
1314            ("1E+2", "1E+2"),
1315            ("1E-7", "1E-7"),
1316            // Positional as written, but `leftdigits > -6` fails at 1e-7, so
1317            // Decimal.__str__ still goes exponential and this still raises.
1318            ("0.0000001", "1E-7"),
1319        ] {
1320            match dec(arg) {
1321                Err(N2WError::Value(m)) => assert_eq!(
1322                    m,
1323                    format!("invalid literal for int() with base 10: '{}'", token)
1324                ),
1325                other => panic!("{} expected ValueError, got {:?}", arg, other),
1326            }
1327        }
1328        // "1.10E+3" splits on the point, so the loop dies on the capital 'E'.
1329        match dec("1.10E+3") {
1330            Err(N2WError::Value(m)) => {
1331                assert_eq!(m, "invalid literal for int() with base 10: 'E'")
1332            }
1333            other => panic!("1.10E+3 expected ValueError, got {:?}", other),
1334        }
1335        // The same values written positionally convert happily; 0.000001 is
1336        // the last one on the small side (leftdigits == -5).
1337        assert_eq!(decok("100"), "satus");
1338        assert_eq!(decok("0.000001"), "nol koma nol nol nol nol nol siki");
1339    }
1340
1341    /// Decimal keeps scale, so these are all distinguishable.
1342    #[test]
1343    fn decimal_scale_is_observable() {
1344        assert_eq!(decok("5"), "lima");
1345        assert_eq!(decok("5.00"), "lima koma nol nol");
1346        assert_eq!(decok("0.000"), "nol koma nol nol nol");
1347        assert_eq!(decok("-0.5"), "minus nol koma lima");
1348    }
1349
1350    /// Guards the two `str()` reimplementations directly, against
1351    /// `repr(v)` / `str(Decimal(s))` read out of the live interpreter.
1352    #[test]
1353    fn str_reimplementations_match_python() {
1354        for (value, expected) in [
1355            (0.0, "0.0"),
1356            (-0.0, "-0.0"),
1357            (0.5, "0.5"),
1358            (1.0, "1.0"),
1359            (2.675, "2.675"),
1360            (1.005, "1.005"),
1361            (0.1, "0.1"),
1362            (1e-4, "0.0001"),
1363            (1e-5, "1e-05"),
1364            (1.5e-5, "1.5e-05"),
1365            (1e15, "1000000000000000.0"),
1366            (1e16, "1e+16"),
1367            (1.5e16, "1.5e+16"),
1368            (2e16 + 8.0, "2.000000000000001e+16"),
1369            (1e21, "1e+21"),
1370            (1e100, "1e+100"),
1371            (1e-323, "1e-323"),
1372            (98746251323029.99, "98746251323029.98"),
1373            (123456789.5, "123456789.5"),
1374            (100.0, "100.0"),
1375            (-12.34, "-12.34"),
1376        ] {
1377            assert_eq!(python_float_repr(value), expected, "repr({})", value);
1378        }
1379        for (arg, expected) in [
1380            ("0.01", "0.01"),
1381            ("1.10", "1.10"),
1382            ("5", "5"),
1383            ("5.00", "5.00"),
1384            ("0.000", "0.000"),
1385            ("98746251323029.99", "98746251323029.99"),
1386            ("1E+2", "1E+2"),
1387            ("1E-7", "1E-7"),
1388            ("1.10E+3", "1.10E+3"),
1389            ("0.0000001", "1E-7"),
1390            ("-0.5", "-0.5"),
1391        ] {
1392            assert_eq!(
1393                python_decimal_str(&BigDecimal::from_str(arg).unwrap()),
1394                expected,
1395                "str(Decimal({:?}))",
1396                arg
1397            );
1398        }
1399    }
1400
1401    #[test]
1402    fn corpus_year() {
1403        assert_eq!(year(1), "siki");
1404        assert_eq!(year(100), "satus");
1405        assert_eq!(year(1492), "siki siu papat satus siangang dasa dua");
1406        assert_eq!(year(1776), "siki siu pitu satus pitung dasa nem");
1407        assert_eq!(year(2024), "dua siu kalih dasa papat");
1408        assert_eq!(year(9999), "sia siu sia satus siangang dasa sia");
1409        assert_eq!(year(-500), "minus lima satus");
1410        assert_eq!(year(-44), "minus petang dasa papat");
1411    }
1412}