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}