Skip to main content

num2words2_core/
lang_jv.rs

1//! Port of `lang_JW.py` (Javanese).
2//!
3//! Registry note: the key `"jv"` resolves to `Num2Word_JW` — `__init__.py` has
4//! both `"jw": lang_JW.Num2Word_JW()` (line 342) and
5//! `"jv": lang_JW.Num2Word_JW()` (line 406, "Alias for Javanese (modern ISO
6//! 639-1)"). Same class, two keys; this file is the `jv` spelling.
7//!
8//! Shape: **self-contained**. `Num2Word_JW` subclasses `Num2Word_Base` but
9//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the `__init__`
10//! guard in `base.py`
11//!
12//! ```text
13//! if any(hasattr(self, field) for field in
14//!        ["high_numwords", "mid_numwords", "low_numwords"]):
15//!     self.cards = OrderedDict(); self.set_numwords()
16//!     self.MAXVAL = 1000 * list(self.cards.keys())[0]
17//! ```
18//!
19//! never fires: `self.cards` and `self.MAXVAL` are never created. `to_cardinal`
20//! is overridden outright and drives `_int_to_word`. Consequently
21//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
22//! **no overflow check at all** — see bug 3 below for what happens instead.
23//!
24//! Overridden by JW (so the trait defaults are *not* used):
25//!   * `to_cardinal`    — own algorithm, no `splitnum`/`clean`
26//!   * `to_ordinal`     — `to_cardinal(n) + "-e"`
27//!   * `to_ordinal_num` — `str(n) + "."`
28//!   * `to_year`        — `to_cardinal(val)`; the `longval` kwarg is accepted
29//!     and then ignored, and there is no BC/AD handling, so
30//!     `to_year(-500)` == `to_cardinal(-500)` == "minus lima atus".
31//!
32//! No cross-call mutable state: JW sets no `_pending_*` flags and does not
33//! override `str_to_number` in Python, so the stateless Rust path is safe to
34//! dispatch to. (The Rust `str_to_number` hook below *is* overridden, but only
35//! to bounce `"Infinity"`/`"NaN"` strings back to the Python original — see
36//! the override site — not to change any parse result.)
37//!
38//! Because `to_ordinal` / `to_ordinal_num` / `to_year` are plain wrappers with
39//! no type guard, they accept floats and Decimals too: `to_ordinal(5.0)` ==
40//! "lima point zero-e" (the float grammar plus the suffix) and
41//! `to_ordinal_num(5.0)` == "5.0." (`str(number)` plus a dot — yes, `"-0.0."`
42//! and `"1e+16."` are real outputs). The `ordinal_float_entry` /
43//! `ordinal_num_float_entry` hooks below reproduce exactly that; `to_year`'s
44//! float routing already falls out of `cardinal_float_entry`.
45//!
46//! # Faithfully reproduced Python bugs
47//!
48//! This is a port, not a rewrite. All of the following look wrong but are
49//! exactly what Python emits, verified against `bench/corpus.jsonl`:
50//!
51//! 1. **Zero is English.** `_int_to_word(0)` is
52//!    `self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is `""`, which is
53//!    falsy, so the fallback always wins and `to_cardinal(0)` == `"zero"` —
54//!    not the Javanese "nol". The `self.ones[0]` arm is unreachable.
55//! 2. **No teens, and inconsistent hundreds.** The tens table is applied
56//!    positionally with no special forms, so 11 is "sepuluh siji" (lit. "ten
57//!    one") rather than the real Javanese "sewelas", and 12 is "sepuluh loro"
58//!    rather than "rolas". Likewise hundreds use the plain `ones` word while
59//!    tens use combining forms from the `tens` table: 800 is "wolu atus" but
60//!    80 is "wolung puluh"; 100 is "siji atus", not "satus". 20 is "rong
61//!    puluh" but 200 is "loro atus". Preserved verbatim.
62//! 3. **Numbers >= 10^9 come back as digits.** `_int_to_word`'s final `else`
63//!    is `return str(number)` — a literal "Fallback for very large numbers".
64//!    So `to_cardinal(10**9)` == `"1000000000"` and
65//!    `to_ordinal(10**9)` == `"1000000000-e"`. No exception is raised, because
66//!    `MAXVAL` was never set (see above). The corpus goes to 10**21, which
67//!    exceeds `u64::MAX` — hence `BigInt` throughout rather than a fixed-width
68//!    cast.
69//! 4. **`_int_to_word`'s negative branch is dead** on every path in scope.
70//!    `to_cardinal` strips the "-" from `str(number)` *before* calling
71//!    `int()`, so `_int_to_word` only ever receives a non-negative value. The
72//!    branch is reproduced below for structural fidelity but is unreachable
73//!    from the four modes we implement.
74//! 5. **`to_ordinal_num` keeps the sign and ignores the fallback**, since it
75//!    never consults `_int_to_word`: `to_ordinal_num(-1)` == `"-1."`.
76//!
77//! # The currency surface
78//!
79//! `Num2Word_JW` overrides `to_currency` **wholesale** and shares nothing with
80//! `Num2Word_Base`'s currency machinery: it never touches
81//! `parse_currency_parts`, `prefix_currency`, `_money_verbose`,
82//! `_cents_verbose`, `_cents_terse` or `pluralize`. It does its own string
83//! surgery on `str(val)` and calls `_int_to_word` directly for both halves. It
84//! also never reads `CURRENCY_PRECISION`, so there is no divisor and no
85//! `ROUND_HALF_UP` quantize — the cent count is always the first two fractional
86//! digits (quirks 7 and 8 below).
87//!
88//! `to_cheque` is the mirror image: JW does **not** define it, so
89//! `Num2Word_Base.to_cheque` runs unchanged and reaches `CURRENCY_FORMS`,
90//! `CURRENCY_PRECISION` and `_money_verbose` through the trait defaults
91//! (`currency_forms`, `100`, and `to_cardinal` respectively). The two entry
92//! points therefore **disagree about an unknown currency code** —
93//! `to_currency` silently falls back to IDR (quirk 9) while `to_cheque` indexes
94//! `CURRENCY_FORMS[currency]` and raises `NotImplementedError`. Both are pinned
95//! by the frozen corpus and both are reproduced here.
96//!
97//! `CURRENCY_FORMS` is a plain class attribute on `Num2Word_JW` with three
98//! entries. Nothing merges into it, and — unlike the `lang_EUR` family
99//! described in `PORTING_CURRENCY.md` — `Num2Word_EN.__init__` cannot reach it:
100//! JW subclasses `Num2Word_Base` directly and *shadows* the base's empty
101//! `CURRENCY_FORMS` with its own dict, so EN's in-place mutation of
102//! `Num2Word_EUR.CURRENCY_FORMS` lands on a different object. Verified against
103//! the live interpreter after import, not read off the source:
104//!
105//! ```text
106//! {'IDR': (('rupiah', 'rupiah'), ('sen', 'sen')),
107//!  'USD': (('dollar', 'dollars'), ('cent', 'cents')),
108//!  'EUR': (('euro', 'euros'), ('cent', 'cents'))}
109//! ```
110//!
111//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both `{}` on the base and
112//! are never overridden, so `currency_adjective` (`None`) and
113//! `currency_precision` (`100`) stay at their trait defaults. `pluralize` is
114//! left at the raising default because `Num2Word_Base.pluralize` is abstract
115//! and JW never defines it — it is unreachable on both currency paths anyway
116//! (`to_currency` selects forms inline, `to_cheque` takes `cr1[-1]`).
117//!
118//! # Faithfully reproduced Python bugs (currency)
119//!
120//! 6. **A float with zero cents drops the cents segment.** The guard is
121//!    `if cents and right:` and `right` is an `int`, so `0` is falsy. This is
122//!    the *opposite* of `Num2Word_Base.to_currency`, which shows `1.0` as
123//!    "one euro, zero cents" because it branches on `isinstance(val, int)`
124//!    rather than on the cent count. JW stringifies first and so cannot tell
125//!    `1` from `1.0` at all: both render `"siji euro"`. Corpus-pinned on both.
126//! 7. **Cents are truncated to two digits, never rounded, and right-padded.**
127//!    `int(parts[1][:2].ljust(2, "0"))` — so `0.5` is **50** cents (pad), and
128//!    `1.239` would be 23 cents (truncate, not 24). The `[:2]` also means a
129//!    3-decimal currency's mils are silently cut to cents.
130//! 8. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** So `JPY`
131//!    still shows a cents segment (divisor 1 is ignored) and `KWD`/`BHD` still
132//!    use two fractional digits rather than three. Corpus-pinned.
133//! 9. **An unknown currency code does not raise — it becomes IDR.**
134//!    `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`. Python
135//!    dicts have preserved insertion order since 3.7 and the literal is written
136//!    IDR, USD, EUR, so `values()[0]` is IDR's. Hence `currency="GBP"`,
137//!    `"JPY"`, `"KWD"`, `"BHD"`, `"INR"`, `"CNY"` and `"CHF"` all render
138//!    "rupiah"/"sen" — all corpus-pinned, 12 rows each.
139//! 10. **Singular vs plural is a no-op for IDR.** Its form pairs are identical
140//!    (`("rupiah", "rupiah")`), so the `left != 1` / `right != 1` selects
141//!    Python performs cannot affect the output for the fallback currency. USD
142//!    and EUR do inflect. Preserved verbatim either way.
143//!
144//! # Errors
145//!
146//! The four integer modes raise nothing. There is no `MAXVAL` overflow check
147//! (bug 3), the table lookups are all provably in range (see [`idx`]), and
148//! negatives are handled rather than rejected — `to_ordinal(-1)` returns
149//! "minus siji-e" rather than the `errmsg_negord` TypeError that
150//! `Num2Word_Base.to_ordinal` would have produced, because JW overrides it. The
151//! corpus confirms: every `cardinal`/`ordinal`/`ordinal_num`/`year` row for
152//! "jv" is `"ok": true`.
153//!
154//! The currency surface adds exactly two reachable raises:
155//!   * `to_cheque` with a code outside {IDR, USD, EUR} → `NotImplemented`,
156//!     message `Currency code "GBP" not implemented for "Num2Word_JW"`. Seven
157//!     corpus rows.
158//!   * `to_currency` → `Value` (Python `ValueError`) when `int()` is handed a
159//!     non-numeric token. Unreachable for any value `str()` renders in plain
160//!     decimal notation; see the exponent-notation note on
161//!     [`LangJv::to_currency`].
162
163use crate::base::{Lang, N2WError, Result};
164use crate::currency::{CurrencyForms, CurrencyValue};
165use crate::floatpath::FloatValue;
166use crate::strnum::{python_decimal_parse, ParsedNumber};
167use bigdecimal::BigDecimal;
168use num_bigint::BigInt;
169use num_integer::Integer;
170use num_traits::{One, Signed, ToPrimitive, Zero};
171use std::collections::HashMap;
172use std::str::FromStr;
173
174/// `self.negword`, set in `setup()`. Note the trailing space: `to_cardinal`
175/// concatenates it directly and relies on `.strip()` only for the ends.
176const NEGWORD: &str = "minus ";
177
178/// The `"zero"` from `_int_to_word`'s falsy-`ones[0]` fallback (bug 1).
179const ZERO_WORD: &str = "zero";
180
181/// `self.ones`. Index 0 is `""` and is never emitted — `_int_to_word` returns
182/// early for 0, and every other lookup uses a nonzero digit.
183const ONES: [&str; 10] = [
184    "", "siji", "loro", "telu", "papat", "lima", "enem", "pitu", "wolu", "sanga",
185];
186
187/// `self.tens`. Index 0 is `""`; unreachable, as `_int_to_word` only consults
188/// this table for `10 <= number < 100`, where `number // 10` is 1..=9.
189///
190/// 5 and 6 are the irregular "seket"/"sewidak" (no " puluh" suffix); the rest
191/// are `<combining form> puluh`.
192const TENS: [&str; 10] = [
193    "",
194    "sepuluh",
195    "rong puluh",
196    "telung puluh",
197    "patang puluh",
198    "seket",
199    "sewidak",
200    "pitung puluh",
201    "wolung puluh",
202    "sanga puluh",
203];
204
205/// `self.hundred` / `self.thousand` / `self.million`.
206const HUNDRED: &str = "atus";
207const THOUSAND: &str = "ewu";
208const MILLION: &str = "yuta";
209
210/// `self.pointword`, set in `setup()`. Used by the float/Decimal branch of
211/// `to_cardinal` (see [`cardinal_from_repr`]); the same value the
212/// [`pointword`](LangJv::pointword) hook returns.
213const POINTWORD: &str = "point";
214
215/// `self.__class__.__name__`. The file is `lang_jv.rs` but the Python class is
216/// `Num2Word_JW` — the `jv`/`jw` keys share one class (see the header). This is
217/// the string `to_cheque` interpolates into its `NotImplementedError`, so the
218/// registry key must **not** be substituted here.
219const LANG_NAME: &str = "Num2Word_JW";
220
221/// `Num2Word_JW.to_currency`'s own `separator=" "` default.
222///
223/// JW narrows `Num2Word_Base`'s `","`, and every currency corpus row is
224/// generated by `num2words(v, lang="jv", to="currency", currency=C)` with
225/// **no** separator kwarg — so `" "` is what each expected string carries
226/// ("sepuluh loro euros telung puluh papat cents", not
227/// "sepuluh loro euros,telung puluh papat cents").
228const DEFAULT_SEPARATOR: &str = " ";
229
230/// `Num2Word_Base.to_currency`'s `separator=","` default, used here as the
231/// "caller did not pass one" sentinel — see [`LangJv::to_currency`].
232const BASE_DEFAULT_SEPARATOR: &str = ",";
233
234/// The key whose value is `list(CURRENCY_FORMS.values())[0]` — the fallback an
235/// unknown code lands on (bug 9). Python's dict literal is written IDR, USD,
236/// EUR, and dicts have preserved insertion order since 3.7, so the first value
237/// is IDR's.
238const FALLBACK_CURRENCY: &str = "IDR";
239
240pub struct LangJv {
241    /// `Num2Word_JW.CURRENCY_FORMS`, built once in [`LangJv::new`] and never
242    /// per call — rebuilding it per call is what made an earlier revision of
243    /// this port 10x slower than the Python it replaces.
244    currency_forms: HashMap<&'static str, CurrencyForms>,
245    /// `list(CURRENCY_FORMS.values())[0]`, resolved once. A `HashMap` is enough
246    /// for lookup but cannot answer "the first value", so the IDR entry is
247    /// cloned out here rather than making the map an ordered one — it is two
248    /// short strings, and holding it by value keeps the struct free of a
249    /// self-referential borrow. See [`FALLBACK_CURRENCY`].
250    fallback_forms: CurrencyForms,
251}
252
253impl LangJv {
254    pub fn new() -> Self {
255        // `Num2Word_JW.CURRENCY_FORMS`, verbatim and in the literal's order.
256        // Built once here, never per call. IDR's pairs are (form, form) — the
257        // singular/plural select is a no-op for it (bug 10) — while USD and EUR
258        // genuinely inflect. The arity is 2 throughout, because `to_currency`
259        // indexes `cr1[1]`/`cr2[1]` directly and `to_cheque` takes `cr1[-1]`.
260        let mut currency_forms: HashMap<&'static str, CurrencyForms> = HashMap::new();
261        currency_forms.insert(
262            "IDR",
263            CurrencyForms::new(&["rupiah", "rupiah"], &["sen", "sen"]),
264        );
265        currency_forms.insert(
266            "USD",
267            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
268        );
269        currency_forms.insert(
270            "EUR",
271            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
272        );
273
274        // `list(CURRENCY_FORMS.values())[0]` resolved once — see bug 9.
275        let fallback_forms = currency_forms
276            .get(FALLBACK_CURRENCY)
277            .expect("FALLBACK_CURRENCY is inserted above")
278            .clone();
279
280        LangJv {
281            currency_forms,
282            fallback_forms,
283        }
284    }
285}
286
287impl Default for LangJv {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293/// Narrow a table index to `usize`.
294///
295/// Every caller has already bounded the value by an enclosing `number < 10`,
296/// `< 100`, `< 1000` comparison, so the digit is 0..=9 and the conversion
297/// cannot fail. Falling back to 0 rather than panicking keeps a hypothetical
298/// future miscount from aborting the process; index 0 is the empty string in
299/// both tables.
300fn idx(n: &BigInt) -> usize {
301    n.to_usize().unwrap_or(0)
302}
303
304/// Python's `_int_to_word`.
305///
306/// Mirrors the original's cascade exactly, including the dead negative branch
307/// (bug 4) and the `str(number)` fallback for >= 10^9 (bug 3).
308fn int_to_word(number: &BigInt) -> String {
309    // `self.ones[0] if self.ones[0] else "zero"` — ones[0] == "" is falsy.
310    if number.is_zero() {
311        return ZERO_WORD.to_string();
312    }
313
314    // Dead on every in-scope path: to_cardinal strips the sign first.
315    if number.is_negative() {
316        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
317    }
318
319    let ten = BigInt::from(10u32);
320    let hundred = BigInt::from(100u32);
321    let thousand = BigInt::from(1_000u32);
322    let million = BigInt::from(1_000_000u32);
323    let billion = BigInt::from(1_000_000_000u32);
324
325    if number < &ten {
326        return ONES[idx(number)].to_string();
327    }
328
329    if number < &hundred {
330        // number > 0 here, so div_rem == floor division; no Python `%` skew.
331        let (tens_val, ones_val) = number.div_rem(&ten);
332        if ones_val.is_zero() {
333            return TENS[idx(&tens_val)].to_string();
334        }
335        return format!("{} {}", TENS[idx(&tens_val)], ONES[idx(&ones_val)]);
336    }
337
338    if number < &thousand {
339        let (hundreds_val, remainder) = number.div_rem(&hundred);
340        // `self.ones[hundreds_val] + " " + self.hundred` — the plain ones word,
341        // not a combining form (bug 2): 800 -> "wolu atus".
342        let mut result = format!("{} {}", ONES[idx(&hundreds_val)], HUNDRED);
343        if !remainder.is_zero() {
344            result.push(' ');
345            result.push_str(&int_to_word(&remainder));
346        }
347        return result;
348    }
349
350    if number < &million {
351        let (thousands_val, remainder) = number.div_rem(&thousand);
352        let mut result = format!("{} {}", int_to_word(&thousands_val), THOUSAND);
353        if !remainder.is_zero() {
354            result.push(' ');
355            result.push_str(&int_to_word(&remainder));
356        }
357        return result;
358    }
359
360    if number < &billion {
361        let (millions_val, remainder) = number.div_rem(&million);
362        let mut result = format!("{} {}", int_to_word(&millions_val), MILLION);
363        if !remainder.is_zero() {
364            result.push(' ');
365            result.push_str(&int_to_word(&remainder));
366        }
367        return result;
368    }
369
370    // `return str(number)  # Fallback for very large numbers` (bug 3).
371    //
372    // Only reachable at top level: the recursive calls above pass a quotient
373    // or remainder that the enclosing bound already forced below 10^9.
374    number.to_string()
375}
376
377// ---- float / Decimal path -----------------------------------------------
378//
379// JW does **not** override `to_cardinal_float`; in Python the float and
380// Decimal cases are handled *inline* by `to_cardinal`, which stringifies the
381// input and walks the fractional characters one at a time:
382//
383// ```python
384// def to_cardinal(self, number):
385//     n = str(number).strip()
386//     if n.startswith("-"):
387//         n = n[1:]
388//         ret = self.negword
389//     else:
390//         ret = ""
391//     if "." in n:
392//         left, right = n.split(".", 1)
393//         ret += self._int_to_word(int(left)) + " " + self.pointword + " "
394//         for digit in right:
395//             ret += self._int_to_word(int(digit)) + " "
396//         return ret.strip()
397//     else:
398//         return (ret + self._int_to_word(int(n))).strip()
399// ```
400//
401// So there is **no** `float2tuple`, no banker's-`round()`, and no `< 0.01`
402// heuristic on this path — the fraction is literally the digits of
403// `str(number)`. The load-bearing artefacts move one level down, into
404// reproducing CPython's `str(float)` / `str(Decimal)` exactly. The Rust
405// dispatcher routes every float/Decimal to `to_cardinal_float`, so JW must
406// override *that* hook to run the same string algorithm (the integer
407// `to_cardinal` above never sees a dot).
408
409/// Python's `int(s)` on a whole token, with CPython's error text.
410///
411/// Reached with whatever `str(number)` / a `split(".", 1)` half produced.
412/// `str(float)`/`str(Decimal)` only ever emit plain digits here, but exponent
413/// forms (`"1E+2"`) would land a non-numeric token and raise `ValueError`,
414/// exactly as Python's `int()` does — same variant, matching message.
415fn py_int(s: &str) -> Result<BigInt> {
416    BigInt::from_str(s).map_err(|_| {
417        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
418    })
419}
420
421/// Python's `int(ch)` for a single fractional character.
422///
423/// `char::to_digit(10)` is ASCII-only; no `str(float)`/`str(Decimal)` can emit
424/// a non-ASCII digit, so it agrees with CPython on every string that reaches
425/// here. A stray `'E'`/`'+'` from a scientific repr raises `ValueError` on the
426/// offending character, quoting it as CPython does.
427fn py_int_digit(ch: char) -> Result<usize> {
428    ch.to_digit(10).map(|d| d as usize).ok_or_else(|| {
429        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
430    })
431}
432
433/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
434/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
435///
436/// Rust's shortest `{:e}` and CPython's `repr` agree on the digit count always
437/// and the digits themselves almost always, parting only on an exact tie:
438/// CPython's `dtoa` rounds the tie to **even**, Rust's shortest `flt2dec`
439/// rounds it **away from zero**. Rust's *fixed-precision* `{:.*e}` is correctly
440/// rounded half-to-even, so re-emitting the same significant-digit count
441/// through it applies CPython's tie rule — kept only if it still round-trips,
442/// so the rare asymmetric interval near a power of two falls back to shortest.
443///
444/// (Copied from the CEB port, which validated this against CPython over
445/// 777,014 doubles with zero mismatches; `{:e}` alone missed 21 of the first
446/// 75,034. The float path here is the same string-of-digits algorithm.)
447fn shortest_repr_digits(a: f64) -> (String, i32) {
448    let split = |s: &str| -> (String, i32) {
449        let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
450        (
451            mant.chars().filter(|c| *c != '.').collect(),
452            exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
453        )
454    };
455
456    let shortest = format!("{:e}", a);
457    let (digits, decpt) = split(&shortest);
458
459    let ties_even = format!("{:.*e}", digits.len() - 1, a);
460    if ties_even.parse::<f64>() == Ok(a) {
461        return split(&ties_even);
462    }
463    (digits, decpt)
464}
465
466/// CPython's `str(float)` / `repr(float)` — `PyOS_double_to_string(v, 'r', 0,
467/// Py_DTSF_ADD_DOT_0)`.
468///
469/// * exponent form when `decpt <= -4 || decpt > 16` (so `str(1e16) == "1e+16"`
470///   but `str(1e15) == "1000000000000000.0"`), exponent `%+.02d`;
471/// * otherwise positional, appending `.0` if nothing follows the point — which
472///   is why `1.0` renders "siji point zero" rather than "siji".
473///
474/// The sign is read from the sign *bit*, so `str(-0.0) == "-0.0"` and JW's
475/// `startswith("-")` fires: `-0.0` renders "minus zero point zero".
476fn py_str_f64(v: f64) -> String {
477    if v.is_nan() {
478        return "nan".to_string();
479    }
480    if v.is_infinite() {
481        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
482    }
483
484    let sign = if v.is_sign_negative() { "-" } else { "" };
485    let (digits, decpt) = shortest_repr_digits(v.abs());
486    let ndig = digits.len() as i32;
487
488    if decpt <= -4 || decpt > 16 {
489        let mantissa = if ndig > 1 {
490            format!("{}.{}", &digits[..1], &digits[1..])
491        } else {
492            digits.clone()
493        };
494        let exp = decpt - 1;
495        let (esign, eabs) = if exp < 0 {
496            ("-", -(exp as i64))
497        } else {
498            ("+", exp as i64)
499        };
500        return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
501    }
502    if decpt <= 0 {
503        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
504    } else if decpt >= ndig {
505        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
506    } else {
507        format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
508    }
509}
510
511/// CPython's `Decimal.__str__` (the spec's `to-scientific-string`), ported from
512/// `_pydecimal.Decimal.__str__`.
513///
514/// `BigDecimal::from_str` keeps the written scale rather than normalising, so
515/// `"1.10"` stays coefficient 110 / scale 2 — which is what makes the trailing
516/// "zero" appear ("siji point siji zero"). `(coefficient, -scale)` is exactly
517/// Python's `(_int, _exp)`. (Copied from the CEB port, validated against
518/// CPython over 40,029 Decimals; the only divergence is negative zero, which
519/// `BigInt` cannot represent — see the port report.)
520fn py_str_decimal(value: &BigDecimal) -> String {
521    let (coefficient, scale) = value.as_bigint_and_exponent();
522    let exp = -scale;
523    let sign = if coefficient.is_negative() { "-" } else { "" };
524    let int_digits = coefficient.abs().to_string();
525    let ndig = int_digits.len() as i64;
526
527    let leftdigits = exp + ndig;
528    let dotplace = if exp <= 0 && leftdigits > -6 {
529        leftdigits
530    } else {
531        1
532    };
533
534    let (intpart, fracpart) = if dotplace <= 0 {
535        (
536            "0".to_string(),
537            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
538        )
539    } else if dotplace >= ndig {
540        (
541            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
542            String::new(),
543        )
544    } else {
545        (
546            int_digits[..dotplace as usize].to_string(),
547            format!(".{}", &int_digits[dotplace as usize..]),
548        )
549    };
550
551    let exponent = if leftdigits == dotplace {
552        String::new()
553    } else {
554        format!("E{:+}", leftdigits - dotplace)
555    };
556
557    format!("{}{}{}{}", sign, intpart, fracpart, exponent)
558}
559
560/// Port of `Num2Word_JW.to_cardinal` operating on the repr — the form Python's
561/// own code walks. See the section comment above for the source.
562///
563/// The negative handling is JW's own (non-recursive): a single leading `"-"` is
564/// stripped and `self.negword` prepended, then the remainder is processed. Each
565/// fractional character goes through the *full* `_int_to_word`, so a `0` digit
566/// becomes "zero" (bug 1) exactly as an integer 0 would. `int(left)` is the
567/// whole integer part, so bug 3 applies there too: at 1e9 and above it comes
568/// back as bare digits ("98746251323029 point sanga sanga").
569fn cardinal_from_repr(n: &str) -> Result<String> {
570    // n = str(number).strip()
571    let n = n.trim();
572
573    // if n.startswith("-"): n = n[1:]; ret = self.negword  else: ret = ""
574    let (ret, n) = match n.strip_prefix('-') {
575        Some(rest) => (NEGWORD.to_string(), rest),
576        None => (String::new(), n),
577    };
578
579    match n.split_once('.') {
580        // if "." in n:
581        Some((left, right)) => {
582            // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
583            let mut ret = ret;
584            ret.push_str(&int_to_word(&py_int(left)?));
585            ret.push(' ');
586            ret.push_str(POINTWORD);
587            ret.push(' ');
588            // for digit in right: ret += self._int_to_word(int(digit)) + " "
589            for digit in right.chars() {
590                ret.push_str(&int_to_word(&BigInt::from(py_int_digit(digit)?)));
591                ret.push(' ');
592            }
593            // return ret.strip()
594            Ok(ret.trim().to_string())
595        }
596        // else: return (ret + self._int_to_word(int(n))).strip()
597        None => Ok(format!("{}{}", ret, int_to_word(&py_int(n)?)).trim().to_string()),
598    }
599}
600
601impl Lang for LangJv {
602
603    fn cardinal_float_entry(
604        &self,
605        value: &crate::floatpath::FloatValue,
606        precision_override: Option<u32>,
607    ) -> crate::base::Result<String> {
608        // Python's to_cardinal routes every float/Decimal through this
609        // language's own decimal grammar — 5.0 keeps its ".0" tail
610        // ("comma nulla"), unlike Base's whole-value integer route.
611        self.to_cardinal_float(value, precision_override)
612    }
613
614    /// `to_ordinal(float/Decimal)`. `Num2Word_JW.to_ordinal` is
615    /// `self.to_cardinal(number) + "-e"` with **no type guard**, so a float or
616    /// Decimal rides the same decimal grammar as `to_cardinal` and then takes
617    /// the suffix: `to_ordinal(5.0)` == "lima point zero-e", `to_ordinal(-0.0)`
618    /// == "minus zero point zero-e", and a whole `Decimal("100")` ==
619    /// "siji atus-e" (the ordinal analogue of keeping the ".0" tail). An
620    /// exponent-notation repr (`str(1e16)` == "1e+16", `str(Decimal("1E+2"))`
621    /// == "1E+2") makes the inner `int()` raise its ValueError *before*
622    /// Python's `+ "-e"` runs — the `?` reproduces that ordering.
623    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
624        Ok(format!("{}-e", self.cardinal_float_entry(value, None)?))
625    }
626
627    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` never casts to
628    /// int, so it *succeeds* on every float and Decimal — "5.0.", "-0.0.",
629    /// "1e+16." and "1E+2." are all real Python outputs (bug 5's
630    /// sign-and-repr passthrough extends to floats). `repr_str` is the
631    /// binding's Python `str(value)`, exactly the string Python concatenates.
632    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
633        Ok(format!("{}.", repr_str))
634    }
635
636    /// `converter.str_to_number` — JW inherits `Num2Word_Base`'s
637    /// `Decimal(value)`, so `"Infinity"`/`"NaN"` strings parse *successfully*
638    /// and only blow up later, inside `to_cardinal`'s `int("Infinity")` /
639    /// `int("NaN")`. The non-finite sentinels pass straight through here and
640    /// the mode-aware [`inf_result`](Self::inf_result) / [`nan_result`](Self::nan_result)
641    /// hooks reproduce the exact per-mode Python behaviour natively — no
642    /// fallback. Finite parses are returned unchanged.
643    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
644        python_decimal_parse(s)
645    }
646
647    /// `Decimal('Infinity')` / `-Infinity`. `to_cardinal` strips a leading
648    /// "-" and then `int("Infinity")` raises `ValueError: invalid literal for
649    /// int() with base 10: 'Infinity'` (the sign is gone, so both signs quote
650    /// 'Infinity'); `to_ordinal` adds "-e" *after* that int(), so it raises the
651    /// same way; `to_year` == `to_cardinal`. `to_ordinal_num` is
652    /// `str(number) + "."` and never calls `int()`, so it succeeds with
653    /// "Infinity." / "-Infinity.".
654    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
655        if to == "ordinal_num" {
656            return Ok(format!("{}Infinity.", if negative { "-" } else { "" }));
657        }
658        Err(N2WError::Value(
659            "invalid literal for int() with base 10: 'Infinity'".into(),
660        ))
661    }
662
663    /// `Decimal('NaN')`. `str(Decimal('NaN'))` is "NaN"; `int("NaN")` raises
664    /// `ValueError: invalid literal for int() with base 10: 'NaN'` on the
665    /// cardinal/ordinal/year paths, while `to_ordinal_num` echoes "NaN.".
666    fn nan_result(&self, to: &str) -> Result<String> {
667        if to == "ordinal_num" {
668            return Ok("NaN.".into());
669        }
670        Err(N2WError::Value(
671            "invalid literal for int() with base 10: 'NaN'".into(),
672        ))
673    }
674
675    /// This language's own `to_currency(currency=...)` default,
676    /// read from the live Python signature. Only 44 of 156 use EUR.
677    fn default_currency(&self) -> &str {
678        "IDR"
679    }
680
681    /// This language's own `to_currency(separator=...)` default,
682    /// read from the live Python signature. Base's is ",", but only
683    /// 36 of 149 languages actually use it — most default to " " or a
684    /// conjunction, so inheriting Base's comma silently corrupts them.
685    fn default_separator(&self) -> &str {
686        " "
687    }
688
689    fn negword(&self) -> &str {
690        NEGWORD
691    }
692
693    fn pointword(&self) -> &str {
694        "point"
695    }
696
697    /// Python's `to_cardinal`, integer path.
698    ///
699    /// The original goes through the *string*: `n = str(number).strip()`, then
700    /// strips a leading `"-"` into `ret`, then `int(n)`s the rest. For integer
701    /// input `"." in n` is never true, so the float branch is unreachable and
702    /// the whole thing reduces to sign-split + `_int_to_word` + `.strip()`.
703    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
704        let (ret, n) = if value.is_negative() {
705            (NEGWORD, value.abs())
706        } else {
707            ("", value.clone())
708        };
709
710        // Python's `.strip()`. Only ever trims NEGWORD's trailing space in the
711        // (impossible) event of an empty word — `_int_to_word` never returns
712        // "" — but reproduced so the shape matches.
713        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
714    }
715
716    /// `return cardinal + "-e"` — one suffix for every number, no agreement,
717    /// no negative rejection. `to_ordinal(-1)` == "minus siji-e".
718    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
719        Ok(format!("{}-e", self.to_cardinal(value)?))
720    }
721
722    /// `return str(number) + "."` — never touches `_int_to_word`, so the sign
723    /// survives (bug 5) and no fallback applies.
724    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
725        Ok(format!("{}.", value))
726    }
727
728    /// `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
729    /// `longval` is ignored; there is no BC/AD suffix.
730    fn to_year(&self, value: &BigInt) -> Result<String> {
731        self.to_cardinal(value)
732    }
733
734    /// Float / Decimal cardinal path.
735    ///
736    /// JW does not override `Num2Word_Base.to_cardinal_float` in Python — the
737    /// non-integer cases live inline in its `to_cardinal`, reached because the
738    /// stringified input contains a `"."`. The Rust dispatcher routes every
739    /// float/Decimal here, so this override reproduces that inline algorithm
740    /// (see [`cardinal_from_repr`] and the section comment above `py_int`).
741    ///
742    /// `precision_override` (the `precision=` kwarg) is accepted and ignored:
743    /// JW's `to_cardinal` never reads `self.precision`, and the fraction is the
744    /// literal digits of `str(number)`. `FloatValue::precision` is likewise
745    /// unused — `str(number)` is recomputed from the raw f64 / BigDecimal, which
746    /// is more faithful than any digit count. Verified live: `precision=1`,
747    /// `2`, `5` all give "loro point enem pitu lima" for 2.675.
748    fn to_cardinal_float(
749        &self,
750        value: &FloatValue,
751        precision_override: Option<u32>,
752    ) -> Result<String> {
753        let _ = precision_override;
754        // n = str(number). The float and Decimal arms are not interchangeable
755        // (issue #603): `str` of a Decimal keeps every written digit, so
756        // Decimal("1.10") ends in "zero" where the float 1.10 could not.
757        let n = match value {
758            FloatValue::Float { value, .. } => py_str_f64(*value),
759            FloatValue::Decimal { value, .. } => py_str_decimal(value),
760        };
761        cardinal_from_repr(&n)
762    }
763
764    // ---- currency ----------------------------------------------------
765    //
766    // Only three hooks are overridden. `currency_adjective` ({} -> None),
767    // `currency_precision` ({}.get(code, 100) -> 100), `pluralize` (abstract,
768    // raises), `money_verbose`/`cents_verbose`/`cents_terse` (-> to_cardinal)
769    // and `to_cheque` (Num2Word_Base's, unmodified) all match the trait
770    // defaults exactly — see the header. Restating them would be noise.
771
772    /// `self.__class__.__name__`, as the inherited `to_cheque` interpolates it
773    /// into its `NotImplementedError`.
774    fn lang_name(&self) -> &str {
775        LANG_NAME
776    }
777
778    /// `Num2Word_JW.CURRENCY_FORMS[code]`.
779    ///
780    /// Deliberately a **strict** lookup that returns `None` for an unknown
781    /// code. This hook feeds the inherited `to_cheque`, which in Python does
782    /// `CURRENCY_FORMS[currency]` inside a `try/except KeyError` and re-raises
783    /// as `NotImplementedError`. `to_currency` must *not* use this hook — it
784    /// does a `.get(..., default)` and falls back to IDR instead (bug 9), so it
785    /// consults `self.currency_forms`/`self.fallback_forms` directly.
786    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
787        self.currency_forms.get(code)
788    }
789
790    /// Port of `Num2Word_JW.to_currency` — a wholesale replacement for
791    /// `Num2Word_Base.to_currency`, sharing none of its machinery.
792    ///
793    /// Python:
794    /// ```text
795    /// is_negative = False
796    /// if val < 0:
797    ///     is_negative = True
798    ///     val = abs(val)
799    /// parts = str(val).split(".")
800    /// left = int(parts[0]) if parts[0] else 0
801    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
802    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency,
803    ///                                    list(self.CURRENCY_FORMS.values())[0])
804    /// left_str = self._int_to_word(left)
805    /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
806    /// if cents and right:
807    ///     cents_str = self._int_to_word(right)
808    ///     result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
809    /// if is_negative:
810    ///     result = self.negword + result
811    /// return result.strip()
812    /// ```
813    ///
814    /// # Why the int/float split collapses here
815    ///
816    /// `base.to_currency` branches on `isinstance(val, int)` to decide whether
817    /// to render cents at all. JW never does: it stringifies first, then tests
818    /// the *cent count* (`if cents and right`). `1` gives `str` `"1"` (no
819    /// `parts[1]`, `right = 0`) and `1.0` gives `str` `"1.0"`
820    /// (`parts[1] == "0"`, so `right = int("00") = 0`) — both falsy, both
821    /// `"siji euro"`. So `CurrencyValue::Int` vs `Decimal` is still honoured
822    /// exactly (it changes the string being split), it simply cannot change the
823    /// output for this language. Corpus-pinned on `1` and `1.0`.
824    ///
825    /// # `str(val)` fidelity
826    ///
827    /// `CurrencyValue::Decimal` carries `str(value)` re-parsed into a
828    /// `BigDecimal`, and this reproduces Python's `str(val)` via `Display`.
829    /// That roundtrip is exact for plain decimal notation — `BigDecimal`
830    /// preserves scale, so `"1.0"`, `"0.5"`, `"0.01"`, `"12.34"`, `"99.99"` and
831    /// `"1234.56"` all render back byte-identically, which is every value the
832    /// corpus exercises.
833    ///
834    /// It is *not* exact for the floats whose `repr` uses exponent notation,
835    /// because the original repr string is not carried across the boundary and
836    /// `BigDecimal`'s `Display` picks its own threshold. Three outcomes, none
837    /// corpus-covered (see `concerns`):
838    ///   * `1e+16`, `1e+21`, `1e-07`, `5e-324` — `Display` keeps the exponent,
839    ///     `BigInt::from_str` rejects the token, and this raises
840    ///     `N2WError::Value`. Python's `int("1e+21")` raises `ValueError`: same
841    ///     variant, different message text.
842    ///   * `1e-05`, `1e-06` — `Display` renders them plain (`"0.00001"`), so
843    ///     this returns "zero rupiah" where Python raises `ValueError`.
844    ///   * `1.2345678901234568e+16` — `Display` renders `"12345678901234568"`,
845    ///     so this returns "12345678901234568 rupiah" (via the `str(number)`
846    ///     fallback, bug 3) where Python splits the repr on its `"."` and
847    ///     returns "siji rupiah rong puluh telu sen".
848    fn to_currency(
849        &self,
850        val: &CurrencyValue,
851        currency: &str,
852        cents: bool,
853        separator: Option<&str>,
854        adjective: bool,
855    ) -> Result<String> {
856        // Trait now hands us None when the caller omitted separator=;
857        // resolve it to this language's own default before the ported body.
858        let separator = separator.unwrap_or(self.default_separator());
859        // Python accepts `adjective` and never reads it: JW has no
860        // CURRENCY_ADJECTIVES and never calls prefix_currency.
861        let _ = adjective;
862
863        // The trait passes whatever the caller gave, and the dispatcher's
864        // "caller said nothing" value is `Num2Word_Base`'s `","` — not JW's own
865        // `" "`. Treat it as the sentinel it is. (The trait has no
866        // per-language default-separator hook; see `concerns`.)
867        let separator = if separator == BASE_DEFAULT_SEPARATOR {
868            DEFAULT_SEPARATOR
869        } else {
870            separator
871        };
872
873        // `if val < 0: is_negative = True; val = abs(val)` — the test precedes
874        // the abs, and the abs runs only on the negative branch. Taking abs
875        // unconditionally here is equivalent: the branch Python skips is
876        // `val >= 0`, whose `str()` carries no sign anyway. The one input where
877        // the two strings differ is `-0.0` (`val < 0` is False, so Python keeps
878        // `"-0.0"` while this yields `"0.0"`), and it converges regardless —
879        // `int("-0") == int("0") == 0`, giving "zero rupiah" on both sides.
880        let is_negative = val.is_negative();
881        let s = match val {
882            CurrencyValue::Int(v) => v.abs().to_string(),
883            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
884        };
885
886        // `str(val).split(".")`: take parts[0] and parts[1]. Splitting on every
887        // dot rather than just the first matters only for input Python could
888        // never produce, but it is what `.split(".")` does.
889        let mut parts = s.split('.');
890        let part0 = parts.next().unwrap_or("");
891        let part1 = parts.next();
892
893        // `int(parts[0]) if parts[0] else 0`. The empty arm is dead — `str()`
894        // of a non-negative int or float never starts with "." — but ported.
895        let left = if part0.is_empty() {
896            BigInt::zero()
897        } else {
898            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
899        };
900
901        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
902        //
903        // `[:2]` truncates and `ljust` pads on the right, so "5" -> "50"
904        // (0.5 is 50 cents) and "01" -> "01" (0.01 is 1 cent). Sliced by
905        // chars, never bytes. `len(parts) > 1` is `part1.is_some()`; the
906        // `and parts[1]` guard is the non-empty test.
907        let right = match part1 {
908            Some(f) if !f.is_empty() => {
909                let mut two: String = f.chars().take(2).collect();
910                while two.chars().count() < 2 {
911                    two.push('0');
912                }
913                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
914            }
915            _ => BigInt::zero(),
916        };
917
918        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
919        // code becomes IDR rather than an error (bug 9). Deliberately not the
920        // `currency_forms()` hook, which is the strict lookup `to_cheque` needs.
921        let forms = self
922            .currency_forms
923            .get(currency)
924            .unwrap_or(&self.fallback_forms);
925        let cr1 = &forms.unit;
926        let cr2 = &forms.subunit;
927
928        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`
929        let mut result = format!(
930            "{} {}",
931            int_to_word(&left),
932            if left.is_one() { &cr1[0] } else { &cr1[1] }
933        );
934
935        // `if cents and right:` — `right` is an int, so 0 is falsy and a float
936        // with zero cents drops the whole segment (bug 6).
937        if cents && !right.is_zero() {
938            result.push_str(separator);
939            result.push_str(&int_to_word(&right));
940            result.push(' ');
941            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
942        }
943
944        // `result = self.negword + result` — raw, keeping the trailing space
945        // baked into "minus ". Base would strip and re-add it; JW does not.
946        if is_negative {
947            result = format!("{}{}", NEGWORD, result);
948        }
949
950        // `result.strip()`. A no-op for every reachable input — `_int_to_word`
951        // never returns padding and no currency form is blank — but it is what
952        // Python writes.
953        Ok(result.trim().to_string())
954    }
955}
956
957#[cfg(test)]
958mod float_tests {
959    use super::*;
960
961    /// Python's `abs(Decimal(str(value)).as_tuple().exponent)` for a plain
962    /// decimal string — count of chars after the dot.
963    fn prec(arg: &str) -> u32 {
964        match arg.split_once('.') {
965            Some((_, frac)) => frac.len() as u32,
966            None => 0,
967        }
968    }
969
970    fn card_float(arg: &str) -> String {
971        let v = FloatValue::Float {
972            value: f64::from_str(arg).unwrap(),
973            precision: prec(arg),
974        };
975        LangJv::new().to_cardinal_float(&v, None).unwrap()
976    }
977
978    fn card_dec(arg: &str) -> String {
979        let v = FloatValue::Decimal {
980            value: BigDecimal::from_str(arg).unwrap(),
981            precision: prec(arg),
982        };
983        LangJv::new().to_cardinal_float(&v, None).unwrap()
984    }
985
986    #[test]
987    fn corpus_cardinal_float() {
988        // Every `"to": "cardinal"` row for jv with a dot in `arg`.
989        let cases = [
990            ("0.0", "zero point zero"),
991            ("0.5", "zero point lima"),
992            ("1.0", "siji point zero"),
993            ("1.5", "siji point lima"),
994            ("2.25", "loro point loro lima"),
995            ("3.14", "telu point siji papat"),
996            ("0.01", "zero point zero siji"),
997            ("0.1", "zero point siji"),
998            ("0.99", "zero point sanga sanga"),
999            ("1.01", "siji point zero siji"),
1000            ("12.34", "sepuluh loro point telu papat"),
1001            ("99.99", "sanga puluh sanga point sanga sanga"),
1002            ("100.5", "siji atus point lima"),
1003            ("1234.56", "siji ewu loro atus telung puluh papat point lima enem"),
1004            ("-0.5", "minus zero point lima"),
1005            ("-1.5", "minus siji point lima"),
1006            ("-12.34", "minus sepuluh loro point telu papat"),
1007            ("1.005", "siji point zero zero lima"),
1008            ("2.675", "loro point enem pitu lima"),
1009        ];
1010        for (arg, want) in cases {
1011            assert_eq!(card_float(arg), want, "float {}", arg);
1012        }
1013    }
1014
1015    #[test]
1016    fn corpus_cardinal_dec() {
1017        // Every `"to": "cardinal_dec"` row for jv.
1018        let cases = [
1019            ("0.01", "zero point zero siji"),
1020            ("1.10", "siji point siji zero"),
1021            ("12.345", "sepuluh loro point telu papat lima"),
1022            ("98746251323029.99", "98746251323029 point sanga sanga"),
1023            ("0.001", "zero point zero zero siji"),
1024        ];
1025        for (arg, want) in cases {
1026            assert_eq!(card_dec(arg), want, "dec {}", arg);
1027        }
1028    }
1029
1030    #[test]
1031    fn negative_zero_float_carries_sign() {
1032        // str(-0.0) == "-0.0" -> the sign bit fires JW's startswith("-").
1033        assert_eq!(card_float("-0.0"), "minus zero point zero");
1034    }
1035
1036    #[test]
1037    fn extra_live_interpreter_rows() {
1038        assert_eq!(card_float("1000000000.5"), "1000000000 point lima");
1039        assert_eq!(
1040            card_float("12345.678"),
1041            "sepuluh loro ewu telu atus patang puluh lima point enem pitu wolu"
1042        );
1043        // Decimal that stringifies without a dot -> integer branch.
1044        assert_eq!(card_dec("100"), "siji atus");
1045    }
1046
1047    #[test]
1048    fn exponent_notation_raises_valueerror_like_python() {
1049        // str(1e16)=="1e+16" etc. have no dot; int("1e+16") is a ValueError with
1050        // the character-exact CPython message. Same for a scientific Decimal.
1051        let l = LangJv::new();
1052        for (f, tok) in [(1e16, "1e+16"), (1e-5, "1e-05"), (1e21, "1e+21")] {
1053            let v = FloatValue::Float { value: f, precision: 0 };
1054            match l.to_cardinal_float(&v, None) {
1055                Err(N2WError::Value(m)) => assert_eq!(
1056                    m,
1057                    format!("invalid literal for int() with base 10: '{}'", tok)
1058                ),
1059                other => panic!("{}: expected ValueError, got {:?}", tok, other.map(|_| ())),
1060            }
1061        }
1062        let v = FloatValue::Decimal {
1063            value: BigDecimal::from_str("1E+2").unwrap(),
1064            precision: 0,
1065        };
1066        match l.to_cardinal_float(&v, None) {
1067            Err(N2WError::Value(m)) => {
1068                assert_eq!(m, "invalid literal for int() with base 10: '1E+2'")
1069            }
1070            other => panic!("expected ValueError, got {:?}", other.map(|_| ())),
1071        }
1072    }
1073
1074    #[test]
1075    fn precision_override_is_inert() {
1076        let v = FloatValue::Float { value: 2.675, precision: 3 };
1077        let base = LangJv::new().to_cardinal_float(&v, None).unwrap();
1078        for p in [0u32, 1, 2, 5, 10] {
1079            assert_eq!(
1080                LangJv::new().to_cardinal_float(&v, Some(p)).unwrap(),
1081                base,
1082                "precision override {}",
1083                p
1084            );
1085        }
1086    }
1087}