num2words2_core/lang_vi.rs
1//! Port of `lang_VI.py` (Vietnamese).
2//!
3//! Shape: **self-contained**. `Num2Word_VI` subclasses plain `object` — it has
4//! no `Num2Word_Base` ancestry at all, so there is no inheritance chain to
5//! chase, no `cards`, no `MAXVAL`, no `splitnum`/`clean`/`merge`, and no
6//! `OverflowError` guard. Every one of the four in-scope methods is defined
7//! directly on the class. `cards`/`maxval`/`merge` therefore stay at their
8//! trait defaults and are never reached.
9//!
10//! Verified: `__init__.py` CONVERTER_CLASSES maps `"vi"` → `lang_VI.Num2Word_VI`.
11//!
12//! Call graph (all four in-scope entry points):
13//! * `to_cardinal(n)` → `number_to_text(n)`
14//! * `to_ordinal(n)` → `to_cardinal(n)` — Vietnamese ordinals are just
15//! cardinals here; there is no ordinal morphology whatsoever.
16//! * `to_ordinal_num(n)` → `"thứ " + str(n)` — pure string concat, no words.
17//! * `to_year(n)` → `"năm " + to_cardinal(|n|)` (+ `" trước Công nguyên"`
18//! when `n < 0`).
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Everything below is wrong-looking but is
23//! exactly what CPython emits, verified against the interpreter.
24//!
25//! 1. **The float64 round-trip (the big one).** `number_to_text` does
26//! `number = "%.2f" % number` on an *integer*. Python's `%f` coerces the int
27//! to a C `double` first, so every input above 2^53 is silently rounded to
28//! the nearest representable double before a single word is generated:
29//! * `to_cardinal(10**23)` describes **99999999999999991611392**, not 10^23.
30//! * `to_cardinal(2**53 + 1) == to_cardinal(2**53)` — the +1 vanishes.
31//! * `to_cardinal(10**19 + 1)` == "mười Quintillion" (i.e. exactly 10^19).
32//! Modelled by [`py_float_int`], which reproduces int→double rounding
33//! (round-half-to-even) exactly, including the `OverflowError` CPython
34//! raises once the rounded value reaches 2^1024. This is *not* an academic
35//! edge: it changes the words for ordinary-looking inputs like 10^23.
36//! `%.2f` of an integral double always yields exact digits + `".00"`, so the
37//! `int(the_list[1]) > 0` branch (the " phẩy " decimal tail) is dead for
38//! integer input, and `the_list` always has exactly 2 elements.
39//!
40//! 2. **`denom[5]` is "trăm nghìn tỷ" at 10^15.** That literally reads "hundred
41//! thousand billion" = 10^17, and it sits where "Quadrillion"/"triệu tỷ"
42//! belongs. Hence the corpus row `10**15` → "một trăm nghìn tỷ". Kept verbatim.
43//!
44//! 3. **`denom` skips "Quindecillion".** The table runs
45//! …Quattuordecillion (idx 15), **Sexdecillion** (idx 16), Septendecillion…
46//! so every scale from 10^48 up is labelled one rank too high. Kept verbatim.
47//!
48//! 4. **`denom[20]` ("Vigintillion") is unreachable.** `vietnam_number` scans
49//! `for v in range(len(denom))` → `v` tops out at 20, giving `didx = v-1 = 19`.
50//! Reaching `didx == 20` would need `v == 21`. Dead entry, kept for indexing.
51//!
52//! 5. **`vietnam_number` falls off its loop and returns `None`** once the
53//! (float-rounded!) value is >= 1000^20 == 10^60. Note the guard is applied
54//! to the *rounded* value, which is why `to_cardinal(10**60)` still succeeds
55//! — `float(10**60)` is 999999999999999949387135297074018866963645011013410073083904,
56//! which is *below* 10^60 — while `to_cardinal(10**61)` returns `None`.
57//! See [`LangVi::vietnam_number`] and the `concerns` note in the report.
58//!
59//! 6. **Dead store in `_convert_nn`.** `a = "lăm"` is assigned then immediately
60//! overwritten by the if/else below it (which is exhaustive). The net rule is
61//! digit 1 → "mốt", digit 5 → "lăm", else `to_19[digit]`. Collapsed here
62//! because it is provably unobservable, not because it looked untidy.
63//!
64//! 7. **`_convert_nnn` computes `_convert_nn(lval)` twice** in the
65//! `99 >= r > 0` branch. Pure function, no observable difference.
66//!
67//! # Error variants
68//!
69//! * `OverflowError` (from the `"%.2f"` int→float coercion) → [`N2WError::Overflow`].
70//! * `TypeError` (`"âm " + None` / `"năm " + None`) → [`N2WError::Type`].
71//! See the `concerns` in the report for the one case Rust's `Result<String>`
72//! genuinely cannot express: a bare `None` return.
73//!
74//! # The currency surface
75//!
76//! `Num2Word_VI` still subclasses `object`, so there is **no currency
77//! machinery to inherit**: no `CURRENCY_FORMS`, no `CURRENCY_PRECISION`, no
78//! `pluralize`, no `_money_verbose`/`_cents_verbose`/`_cents_terse`, and no
79//! `to_cheque`. Verified against the live class — every one of those
80//! attributes is absent. It defines exactly one currency method:
81//!
82//! ```python
83//! def to_currency(self, val, currency="VND", cents=True,
84//! separator=",", adjective=False):
85//! from decimal import Decimal
86//! decimal_val = Decimal(str(val))
87//! has_fractional_cents = (decimal_val * 100) % 1 != 0
88//! if isinstance(val, int):
89//! return self.to_cardinal(val) + " đồng"
90//! if has_fractional_cents:
91//! return self.to_cardinal_float(val) + " đồng"
92//! return self.to_cardinal(val) + " đồng"
93//! ```
94//!
95//! Consequences, all reproduced here and all corroborated by the corpus:
96//!
97//! 8. **`currency` is read and thrown away.** Every branch appends the literal
98//! `" đồng"`. The corpus proves it: the same 12 values under EUR, USD, GBP,
99//! JPY, KWD, BHD, INR, CNY and CHF give nine byte-identical blocks of
100//! output. No code can raise `NotImplementedError`, so `currency_forms`,
101//! `currency_precision`, `pluralize` and `lang_name` stay at their trait
102//! defaults — nothing reaches them.
103//!
104//! 9. **`cents`, `separator` and `adjective` are dead parameters.** Never read.
105//! The generated `default_separator()` / `default_currency()` above are
106//! therefore inert for VI; they are kept because the trait resolves them
107//! before this body runs.
108//!
109//! 10. **`to_cardinal_float` does not exist** — the `has_fractional_cents`
110//! branch is a straight `AttributeError`. So *any* value with more than two
111//! decimal places (`12.345`, `1.011`, `2.675`, `0.001`) raises instead of
112//! converting. See [`LangVi::to_currency`].
113//!
114//! 11. **`to_cheque` does not exist**, so the dispatcher's
115//! `getattr(converter, "to_cheque")` raises `AttributeError` before any
116//! conversion runs. All nine `cheque:*` corpus rows record exactly that.
117//!
118//! 12. **`(decimal_val * 100) % 1` runs *before* the `isinstance(val, int)`
119//! test, and `Decimal.__mod__` is bounded by the arithmetic context.**
120//! `decimal`'s default context is `prec=28`, and `%` raises
121//! `InvalidOperation[DivisionImpossible]` once the quotient `int(m)` needs
122//! more than 28 digits. With `m = val * 100` that is `|val| >= 10**26` —
123//! for **ints as well as floats**, because the guard precedes the branch.
124//! Bisected against the live interpreter: `10**26 - 1` converts,
125//! `10**26` raises; likewise `1e25` vs `1e26`. So `to_currency` can never
126//! reach the `2**1024` OverflowError in [`py_float_int`] nor the `10**60`
127//! `None` fall-off in [`LangVi::vietnam_number`] — `10**26` is far below
128//! both, and this guard fires first. See [`div_impossible`].
129//!
130//! 13. **The float branch re-enters `number_to_text` with a `float`, not an
131//! `int`,** so `"%.2f" % number` formats the *double itself* rather than
132//! `repr()`'s shortest round-trip. Above 2^53 the two diverge:
133//! `str(1e23)` is `"1e+23"` but `"%.2f" % 1e23` is
134//! `"99999999999999991611392.00"`, and Vietnamese words follow the latter.
135//! Reproduced in [`LangVi::number_to_text_decimal`] by round-tripping the
136//! decimal string back through `f64` — `str(float)` is shortest-round-trip
137//! by construction, so the parse recovers the original double bit for bit.
138//!
139//! 14. **The `" phẩy "` tail counts *hundredths*, not a fraction.** `12.5`
140//! prints "mười hai phẩy năm mươi" — "twelve point fifty" — because
141//! `"%.2f"` pads to `"12.50"` and the tail is `vietnam_number(50)`.
142//! Likewise `0.01` → "không phẩy một" ("zero point one"). Corpus rows
143//! `0.5` and `0.01` pin both.
144//!
145//! 15. **`str_to_number` does not exist** — same `object` ancestry, same
146//! plain attribute-lookup failure as bugs #10/#11. The dispatcher calls
147//! `converter.str_to_number(number)` for every string input, so *every*
148//! string raises AttributeError before any parsing happens. The
149//! dispatcher's `except (decimal.InvalidOperation, ValueError)` around
150//! that call does not catch AttributeError, so there is no
151//! digits-present sentence fallback either: "room 5" and "abc" die the
152//! same way "5" does. All 115 string corpus rows record it.
153//!
154//! 16. **`to_fraction` does not exist.** The dispatcher's "n/d"
155//! string route (`converter.to_fraction(num_int, den_int)`) and the
156//! `to="fraction"` mode both fail on the attribute lookup — *before*
157//! the values are examined, so "1/0" is an AttributeError, never a
158//! ZeroDivisionError, and every `fraction2` corpus row is
159//! AttributeError regardless of the operands.
160
161use crate::base::{Lang, N2WError, Result};
162use crate::currency::CurrencyValue;
163use crate::floatpath::FloatValue;
164use crate::strnum::ParsedNumber;
165use bigdecimal::BigDecimal;
166use num_bigint::BigInt;
167use num_integer::Integer;
168use num_traits::{One, Signed, Zero};
169use std::str::FromStr;
170
171/// `to_19`; indexed 0..=19 directly by value.
172const TO_19: [&str; 20] = [
173 "không",
174 "một",
175 "hai",
176 "ba",
177 "bốn",
178 "năm",
179 "sáu",
180 "bảy",
181 "tám",
182 "chín",
183 "mười",
184 "mười một",
185 "mười hai",
186 "mười ba",
187 "mười bốn",
188 "mười lăm",
189 "mười sáu",
190 "mười bảy",
191 "mười tám",
192 "mười chín",
193];
194
195/// `tens`; index `v` corresponds to the value `20 + 10*v`.
196const TENS: [&str; 8] = [
197 "hai mươi",
198 "ba mươi",
199 "bốn mươi",
200 "năm mươi",
201 "sáu mươi",
202 "bảy mươi",
203 "tám mươi",
204 "chín mươi",
205];
206
207/// `denom`; index `d` labels the scale `1000^d`.
208///
209/// Preserved verbatim including the three defects documented in the module
210/// header: `[5]` is "trăm nghìn tỷ" at 10^15, "Quindecillion" is missing
211/// between `[15]` and `[16]`, and `[20]` is unreachable. `[0]` is "" and is
212/// never used (the caller only reaches this table for values >= 1000, which
213/// forces `didx >= 1`).
214const DENOM: [&str; 21] = [
215 "",
216 "nghìn",
217 "triệu",
218 "tỷ",
219 "nghìn tỷ",
220 "trăm nghìn tỷ",
221 "Quintillion",
222 "Sextillion",
223 "Septillion",
224 "Octillion",
225 "Nonillion",
226 "Decillion",
227 "Undecillion",
228 "Duodecillion",
229 "Tredecillion",
230 "Quattuordecillion",
231 "Sexdecillion",
232 "Septendecillion",
233 "Octodecillion",
234 "Novemdecillion",
235 "Vigintillion",
236];
237
238fn type_error(msg: impl Into<String>) -> N2WError {
239 N2WError::Type(msg.into())
240}
241
242/// `AttributeError: 'Num2Word_VI' object has no attribute '<name>'`.
243///
244/// `Num2Word_VI` inherits from `object`, so a missing method is a plain
245/// attribute-lookup failure — not a deliberate `NotImplementedError`. Four
246/// call sites reach it: `to_cardinal_float` (bug #10), `to_cheque`
247/// (bug #11), `str_to_number` (bug #15) and `to_fraction` (bug #16).
248fn missing_attr(name: &str) -> N2WError {
249 N2WError::Attribute(format!(
250 "'Num2Word_VI' object has no attribute '{}'",
251 name
252 ))
253}
254
255/// `decimal.InvalidOperation([DivisionImpossible])` — bug #12.
256///
257/// `decimal` is a stdlib module, not a language module, but it is still a
258/// non-builtin exception class, which is exactly what `N2WError::Custom` is
259/// for: the binding imports `decimal`, looks up `InvalidOperation` and raises
260/// the real class, so `except decimal.InvalidOperation` keeps working.
261///
262/// The message is `str(e)` of what CPython raises. Python's `args` is
263/// `([DivisionImpossible],)` — a list holding the condition *class* — whereas
264/// `Custom` can only carry a string, so `args` is the rendered form. The
265/// exception type and `str(e)` match; `e.args[0]` is a `str` rather than a
266/// `list`. Flagged in the report.
267fn div_impossible() -> N2WError {
268 N2WError::Custom {
269 module: "decimal",
270 class: "InvalidOperation",
271 msg: "[<class 'decimal.DivisionImpossible'>]".into(),
272 }
273}
274
275/// Reproduces CPython's `int` → `float` coercion, returning the *exact integer
276/// value* of the resulting double.
277///
278/// `number_to_text` does `"%.2f" % number`, and `%f` coerces its operand to a C
279/// `double`. `"%.2f"` of an integral double prints that double's exact digits
280/// (CPython uses correctly-rounded David Gay formatting — it does **not** clip
281/// to 17 significant digits), so `int(the_list[0])` recovers precisely the
282/// integer this function returns. Any int with a magnitude above 2^53 is
283/// therefore mangled before conversion, which is Python bug #1 above.
284///
285/// Rounding is IEEE-754 round-half-to-even on the 53-bit significand, matching
286/// CPython's `PyLong_AsDouble`. Returns `OverflowError` exactly when CPython
287/// does: when the rounded result reaches 2^1024 (i.e. would be `inf`).
288///
289/// `n` must be non-negative (`number_to_text` applies `abs()` first).
290/// Validated against CPython's `float()` over 30k random widths in 54..=1030
291/// bits plus the overflow-boundary values `2^1024 - 2^970{,-1}`: zero mismatches.
292fn py_float_int(n: &BigInt) -> Result<BigInt> {
293 if n.is_zero() {
294 return Ok(BigInt::zero());
295 }
296 // `bits()` is the magnitude's bit length; n >= 0 here.
297 let bits = n.bits();
298 if bits <= 53 {
299 // Exactly representable — the coercion is lossless.
300 return Ok(n.clone());
301 }
302 let shift = bits - 53;
303 let mut hi = n >> shift; // 53 significant bits, in [2^52, 2^53)
304 let rem = n - (&hi << shift); // the bits being discarded
305 let half = BigInt::one() << (shift - 1);
306
307 // Round half to even: round up on a strict majority, or on an exact tie
308 // when the retained significand is odd.
309 if rem > half || (rem == half && hi.is_odd()) {
310 hi += 1u32;
311 }
312 let res = hi << shift; // carry out of the significand is fine: gives 2^bits
313
314 if res >= (BigInt::one() << 1024u32) {
315 // CPython: OverflowError("int too large to convert to float")
316 return Err(N2WError::Overflow(
317 "int too large to convert to float".into(),
318 ));
319 }
320 Ok(res)
321}
322
323pub struct LangVi;
324
325impl Default for LangVi {
326 fn default() -> Self {
327 Self::new()
328 }
329}
330
331impl LangVi {
332 pub fn new() -> Self {
333 LangVi
334 }
335
336 /// Port of `_convert_nn`. Callers only ever pass `0 <= val < 100`.
337 ///
338 /// Python returns `None` for `val >= 100` (the `dval + 10 > val` scan finds
339 /// no match), but that is unreachable: `vietnam_number` gates on `val < 100`
340 /// and `_convert_nnn` only ever passes `val % 100`.
341 fn convert_nn(&self, val: u32) -> String {
342 debug_assert!(val < 100, "_convert_nn is only ever called with val < 100");
343 if val < 20 {
344 return TO_19[val as usize].to_string();
345 }
346 // Python scans `((k, 20 + 10*v) for (v, k) in enumerate(tens))` for the
347 // first `dval + 10 > val`; that is just the tens digit.
348 let dcap = TENS[(val / 10 - 2) as usize];
349 let unit = val % 10;
350 if unit != 0 {
351 // Bug #6: the `a = "lăm"` dead store is elided; the exhaustive
352 // if/else plus the trailing "năm" override reduce to exactly this.
353 let a = match unit {
354 1 => "mốt",
355 5 => "lăm",
356 d => TO_19[d as usize],
357 };
358 return format!("{} {}", dcap, a);
359 }
360 dcap.to_string()
361 }
362
363 /// Port of `_convert_nnn`. Callers only ever pass `0 <= val < 1000`, so
364 /// `to_19[rem]` (rem = val // 100 <= 9) is always in range.
365 ///
366 /// Note `_convert_nnn(0)` == "" — the empty string, not "không".
367 fn convert_nnn(&self, val: u32) -> String {
368 debug_assert!(val < 1000, "_convert_nnn is only ever called with val < 1000");
369 let mut word = String::new();
370 let (mod_, rem) = (val % 100, val / 100);
371 if rem > 0 {
372 word = format!("{} trăm", TO_19[rem as usize]);
373 if mod_ > 0 {
374 word.push(' ');
375 }
376 }
377 if mod_ > 0 && mod_ < 10 {
378 // Python's `word != "" and A or B` idiom. Both A branches are
379 // non-empty strings (truthy), so this is a plain conditional.
380 if mod_ == 5 {
381 // Note the asymmetry with the else-branch: no space after "lẻ"
382 // because "lẻ năm" is spelled out as one literal in Python.
383 if !word.is_empty() {
384 word.push_str("lẻ năm");
385 } else {
386 word.push_str("năm");
387 }
388 } else if !word.is_empty() {
389 word.push_str("lẻ ");
390 word.push_str(&self.convert_nn(mod_));
391 } else {
392 word.push_str(&self.convert_nn(mod_));
393 }
394 }
395 if mod_ >= 10 {
396 word.push_str(&self.convert_nn(mod_));
397 }
398 word
399 }
400
401 /// Port of `vietnam_number`.
402 ///
403 /// Returns `Ok(None)` where Python falls off the `for` loop and implicitly
404 /// returns `None` — i.e. `val >= 1000^20 == 10^60` (bug #5). `val` is
405 /// already float-rounded and non-negative by the time it gets here.
406 fn vietnam_number(&self, val: &BigInt) -> Option<String> {
407 let hundred = BigInt::from(100);
408 let thousand = BigInt::from(1000);
409 if *val < hundred {
410 // val < 100 fits a u32 trivially.
411 return Some(self.convert_nn(to_u32(val)));
412 }
413 if *val < thousand {
414 return Some(self.convert_nnn(to_u32(val)));
415 }
416 // Python: `for didx, dval in ((v - 1, 1000**v) for v in range(len(denom)))`.
417 // v == 0 (dval == 1) and v == 1 (dval == 1000) can never fire here
418 // because val >= 1000, so didx >= 1 and denom[0] == "" is never read.
419 for v in 0..DENOM.len() as u32 {
420 let dval = thousand.pow(v);
421 if dval > *val {
422 let didx = (v - 1) as usize;
423 let modulus = thousand.pow(v - 1);
424 let lval = val / &modulus;
425 let r = val - (&lval * &modulus);
426
427 // lval < 1000 always (else the previous v would have matched).
428 let head = self.convert_nnn(to_u32(&lval));
429 let mut ret = format!("{} {}", head, DENOM[didx]);
430 if r <= BigInt::from(99) && r > BigInt::zero() {
431 // Python rebuilds the string from scratch here rather than
432 // appending; same result (bug #7).
433 ret = format!("{} {} lẻ", head, DENOM[didx]);
434 }
435 if r > BigInt::zero() {
436 ret.push(' ');
437 // Recursion depth is bounded by the 21-entry denom table.
438 ret.push_str(&self.vietnam_number(&r)?);
439 }
440 return Some(ret);
441 }
442 }
443 // val >= 10^60: Python returns None.
444 None
445 }
446
447 /// Port of `number_to_text`.
448 ///
449 /// The `" phẩy "` decimal branch is unreachable for integer input: the
450 /// `"%.2f"` of an integral double always ends in `".00"`, so
451 /// `int(the_list[1]) > 0` is always false. Omitted deliberately.
452 fn number_to_text(&self, number: &BigInt) -> Result<String> {
453 let is_negative = number.is_negative();
454 let number = number.abs();
455
456 // `number = "%.2f" % number` — the lossy int→float coercion (bug #1).
457 // This is also the only site that can raise, and it raises *before*
458 // the sign is re-applied, so negatives overflow identically.
459 let rounded = py_float_int(&number)?;
460
461 match self.vietnam_number(&rounded) {
462 Some(start_word) => {
463 if is_negative {
464 Ok(format!("âm {}", start_word))
465 } else {
466 Ok(start_word)
467 }
468 }
469 None => {
470 // Python: `final_result = None` (the function falls off the
471 // end past 10^75 rather than raising).
472 // * negative → `"âm " + None` → TypeError.
473 // * positive → `to_cardinal` returns the bare object `None`.
474 if is_negative {
475 Err(type_error(
476 "can only concatenate str (not \"NoneType\") to str",
477 ))
478 } else {
479 // Success returning None — the binding maps this sentinel
480 // to Python `None`. See N2WError::ReturnsNone.
481 Err(N2WError::ReturnsNone)
482 }
483 }
484 }
485 }
486
487 /// `(Decimal(str(val)) * 100) % 1 != 0`, evaluated under `decimal`'s
488 /// default context (bug #12).
489 ///
490 /// Two context effects are load-bearing and both are reproduced:
491 ///
492 /// * The multiply is rounded to `prec = 28` significant digits
493 /// (ROUND_HALF_EVEN). `Inexact`/`Rounded` are not trapped, so this is
494 /// silent — a 31-digit `Decimal` can lose its fractional cents here.
495 /// `BigDecimal::with_prec` is half-even too, matching.
496 /// * `%` then raises `DivisionImpossible` when the quotient `int(m)` would
497 /// exceed those 28 digits, i.e. `|m| >= 10**28`. `_pydecimal._divide`
498 /// reaches that either through `expdiff > prec` or through the
499 /// `q < 10**prec` test; both collapse to the same bound.
500 ///
501 /// `Decimal`'s `%` truncates toward zero (the remainder takes the
502 /// dividend's sign), unlike `int`'s floor `%` — but only `!= 0` is asked,
503 /// so the sign never escapes.
504 fn has_fractional_cents(value: &BigDecimal) -> Result<bool> {
505 let m = (value * BigDecimal::from(100)).with_prec(28);
506 if m.abs() >= BigDecimal::from(BigInt::from(10).pow(28)) {
507 return Err(div_impossible());
508 }
509 // with_scale(0) truncates toward zero — Python's int(m).
510 Ok(&m - m.with_scale(0) != BigDecimal::zero())
511 }
512
513 /// `number_to_text` re-entered with a **float**, which is what the two
514 /// non-int `to_currency` branches do (bug #13).
515 ///
516 /// Distinct from [`LangVi::number_to_text`] on purpose: that one takes an
517 /// `int` and models `%f`'s int→double coercion via [`py_float_int`], and
518 /// its `" phẩy "` tail is dead because `"%.2f"` of an integral double
519 /// always ends `".00"`. Here the tail is live, and the coercion is a
520 /// no-op because the value already *is* a double.
521 ///
522 /// `"%.2f" % number` prints the double's exact value rounded to two
523 /// places, ties to even (CPython routes `%f` through David Gay's
524 /// correctly-rounded `dtoa`). Rust's `{:.2}` is exact and ties-to-even
525 /// as well, so the two agree; the double itself is recovered by parsing
526 /// the decimal string, which round-trips because Python produced it with
527 /// `repr`. Cross-checked against CPython over 20k randomized values.
528 fn number_to_text_decimal(&self, number: &BigDecimal) -> Result<String> {
529 // `number < 0`: BigDecimal has no signed zero, and neither does the
530 // comparison — Python agrees, `-0.0 < 0` is False.
531 let is_negative = number.is_negative();
532 let number = number.abs();
533
534 // `number = "%.2f" % number`. Guarded above to |value| < 10**26, so
535 // the f64 parse cannot reach infinity and the string stays short.
536 let as_f64: f64 = number
537 .to_string()
538 .parse()
539 .map_err(|_| type_error("float() argument must be a number"))?;
540 let formatted = format!("{:.2}", as_f64);
541
542 // `the_list = str(number).split(".")`. Split rather than assert: for a
543 // non-finite double `"%.2f"` yields "inf"/"nan" with no point, and then
544 // Python's `int(the_list[0])` raises ValueError — so the shape below is
545 // the port, and no branch can panic. (`panic = "abort"` in the release
546 // profile would take the host interpreter down with it.) Unreachable in
547 // practice: bug #12's guard caps |value| below 10**26.
548 let (int_part, frac_part) = match formatted.split_once('.') {
549 Some((i, f)) => (i, Some(f)),
550 None => (formatted.as_str(), None),
551 };
552 let int_val = BigInt::from_str(int_part).map_err(|_| {
553 N2WError::Value(format!(
554 "invalid literal for int() with base 10: '{}'",
555 int_part
556 ))
557 })?;
558
559 // Unreachable for the same reason: 10**26 is far below
560 // vietnam_number's 10**60 fall-off. Mapped rather than panicked so the
561 // exception type survives if that guard ever moves — every caller
562 // concatenates onto the result, so None becomes TypeError.
563 let start_word = self.vietnam_number(&int_val).ok_or_else(|| {
564 type_error("unsupported operand type(s) for +: 'NoneType' and 'str'")
565 })?;
566
567 // `if len(the_list) > 1 and int(the_list[1]) > 0`. frac_part is the two
568 // hundredths digits, so this is vietnam_number(0..=99) — bug #14.
569 let mut final_result = start_word;
570 if let Some(frac_part) = frac_part {
571 let frac_val = u32::from_str(frac_part).map_err(|_| {
572 N2WError::Value(format!(
573 "invalid literal for int() with base 10: '{}'",
574 frac_part
575 ))
576 })?;
577 if frac_val > 0 {
578 final_result = format!("{} phẩy {}", final_result, self.convert_nn(frac_val));
579 }
580 }
581 if is_negative {
582 final_result = format!("âm {}", final_result);
583 }
584 Ok(final_result)
585 }
586
587 /// Port of `number_to_text` for the **cardinal** float/Decimal path — i.e.
588 /// `to_cardinal(x)` reached with a non-integer `x`. `Num2Word_VI` has no
589 /// `to_cardinal_float`, so `num2words(<non-int>, lang="vi")` lands straight
590 /// in `to_cardinal(number)` → `number_to_text(number)`, whose first act is
591 /// `number = "%.2f" % number`.
592 ///
593 /// `%f` coerces its operand to a C `double`, so a `Decimal` is coerced too:
594 /// `"%.2f" % Decimal("2.675")` is `"2.67"` — the *float*-rounded value, not
595 /// the exact `"2.68"`. Callers therefore pass `magnitude` already coerced
596 /// to `f64`, and `is_negative` taken from the sign of the *original* value
597 /// (Python evaluates `number < 0` before `abs()`), mirroring exactly:
598 ///
599 /// ```python
600 /// is_negative = number < 0
601 /// number = "%.2f" % abs(number)
602 /// ```
603 ///
604 /// Distinct from [`LangVi::number_to_text_decimal`] (the *currency*
605 /// re-entry) in one load-bearing way: there the `vietnam_number` `None`
606 /// fall-off is capped unreachable by bug #12's 10**26 guard, so that method
607 /// can flatten `None` to a `TypeError`. Here there is no such cap —
608 /// `to_cardinal(1e61)` really does hand `vietnam_number` a value >= 10^60 —
609 /// so the `None` semantics are those of the *integer* `number_to_text`:
610 /// * positive → `to_cardinal` returns a bare `None` (`ReturnsNone`);
611 /// * negative → `"âm " + None` → `TypeError`.
612 /// The `" phẩy "` tail can never fire in that fall-off: any double >= 10^60
613 /// is an exact integer, so `"%.2f"` ends in `".00"` and the fraction is 0.
614 ///
615 /// `precision` / the `precision=` kwarg are irrelevant: `"%.2f"` is always
616 /// two places, and the dispatcher pops `precision=` before `to_cardinal`
617 /// (VI defines no `precision` attribute), so it never reaches this method.
618 fn number_to_text_float(&self, is_negative: bool, magnitude: f64) -> Result<String> {
619 // `number = "%.2f" % magnitude`. CPython routes `%f` through David
620 // Gay's correctly-rounded `dtoa` (ties to even); Rust's `{:.2}` is
621 // correctly rounded and ties-to-even as well, so the two agree — the
622 // same equivalence the currency path already relies on.
623 let formatted = format!("{:.2}", magnitude);
624
625 // `the_list = str(number).split(".")`. Finite magnitudes always carry a
626 // point; the None arm mirrors `"%.2f"` of a non-finite double
627 // ("inf"/"nan"), where Python's `int(the_list[0])` then raises
628 // ValueError — reproduced by the `BigInt::from_str` failure below.
629 let (int_part, frac_part) = match formatted.split_once('.') {
630 Some((i, f)) => (i, Some(f)),
631 None => (formatted.as_str(), None),
632 };
633 let int_val = BigInt::from_str(int_part).map_err(|_| {
634 N2WError::Value(format!(
635 "invalid literal for int() with base 10: '{}'",
636 int_part
637 ))
638 })?;
639
640 match self.vietnam_number(&int_val) {
641 Some(start_word) => {
642 let mut final_result = start_word;
643 // `if len(the_list) > 1 and int(the_list[1]) > 0` — the two
644 // hundredths digits, so `vietnam_number(0..=99)` == convert_nn
645 // (bug #14: the tail counts hundredths, e.g. 12.5 → "... năm
646 // mươi").
647 if let Some(frac_part) = frac_part {
648 let frac_val = u32::from_str(frac_part).map_err(|_| {
649 N2WError::Value(format!(
650 "invalid literal for int() with base 10: '{}'",
651 frac_part
652 ))
653 })?;
654 if frac_val > 0 {
655 final_result =
656 format!("{} phẩy {}", final_result, self.convert_nn(frac_val));
657 }
658 }
659 if is_negative {
660 final_result = format!("âm {}", final_result);
661 }
662 Ok(final_result)
663 }
664 None => {
665 // vietnam_number fell off (int part >= 10^60). Any such double
666 // ends `".00"`, so the phẩy tail never ran and `final_result`
667 // is exactly `start_word == None`.
668 if is_negative {
669 // Python: `final_result = "âm " + None` → TypeError.
670 Err(type_error(
671 "can only concatenate str (not \"NoneType\") to str",
672 ))
673 } else {
674 // Python: `to_cardinal` returns the bare object `None`.
675 Err(N2WError::ReturnsNone)
676 }
677 }
678 }
679 }
680
681 /// `self.to_cardinal(val) + " đồng"`.
682 ///
683 /// Python concatenates onto `to_cardinal`'s result, so the `None` that
684 /// [`LangVi::number_to_text`] can return past 10**60 becomes a TypeError
685 /// here rather than propagating — `to_currency` never yields `None`.
686 /// Unreachable under bug #12's 10**26 cap, but the sentinel must not leak
687 /// to the binding, which would turn it into a bare Python `None`.
688 fn dong(&self, cardinal: Result<String>) -> Result<String> {
689 match cardinal {
690 Ok(s) => Ok(format!("{} đồng", s)),
691 Err(N2WError::ReturnsNone) => Err(type_error(
692 "unsupported operand type(s) for +: 'NoneType' and 'str'",
693 )),
694 Err(e) => Err(e),
695 }
696 }
697}
698
699/// `val` is proven < 1000 at every call site; the fallback is unreachable.
700fn to_u32(val: &BigInt) -> u32 {
701 use num_traits::ToPrimitive;
702 val.to_u32().expect("value is bounded below 1000 by construction")
703}
704
705impl Lang for LangVi {
706 /// This language's own `to_currency(currency=...)` default,
707 /// read from the live Python signature. Only 44 of 156 use EUR.
708 fn default_currency(&self) -> &str {
709 "VND"
710 }
711
712 /// This language's own `to_currency(separator=...)` default,
713 /// read from the live Python signature. Base's is ",", but only
714 /// 36 of 149 languages actually use it — most default to " " or a
715 /// conjunction, so inheriting Base's comma silently corrupts them.
716 fn default_separator(&self) -> &str {
717 ","
718 }
719
720 // cards / maxval / merge: Num2Word_VI subclasses `object` and has no engine
721 // at all — the trait defaults are never reached.
722
723 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
724 self.number_to_text(value)
725 }
726
727 /// `to_ordinal` is `return self.to_cardinal(number)` — identical output.
728 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
729 self.to_cardinal(value)
730 }
731
732 /// `to_ordinal_num` is `"thứ " + str(number)` — the raw digits, never words.
733 /// It never touches `number_to_text`, so it neither float-rounds nor
734 /// overflows: `to_ordinal_num(10**400)` happily prints all 401 digits, and
735 /// negatives keep their minus sign ("thứ -1").
736 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
737 Ok(format!("thứ {}", value))
738 }
739
740 /// `to_year(val, longval=True)` — `longval` is accepted and ignored.
741 fn to_year(&self, value: &BigInt) -> Result<String> {
742 // Python concatenates: `"năm " + self.to_cardinal(val)`. Where
743 // to_cardinal falls off the end and returns None, that concatenation
744 // raises TypeError — so unlike to_cardinal itself, to_year never
745 // yields None. Convert the sentinel rather than propagating it.
746 let concat = |r: Result<String>| -> Result<String> {
747 match r {
748 Err(N2WError::ReturnsNone) => Err(type_error(
749 "can only concatenate str (not \"NoneType\") to str",
750 )),
751 other => other,
752 }
753 };
754 if value.is_negative() {
755 // Python passes `-val` (a positive int), so to_cardinal never sees
756 // the sign and never prefixes "âm".
757 Ok(format!(
758 "năm {} trước Công nguyên",
759 concat(self.to_cardinal(&(-value)))?
760 ))
761 } else {
762 Ok(format!("năm {}", concat(self.to_cardinal(value))?))
763 }
764 }
765
766 /// `to_ordinal_num(float/Decimal)`: still `"thứ " + str(number)` — the
767 /// raw Python string form, so "thứ 5.00", "thứ 1e+16" and "thứ -0.0"
768 /// all succeed. `repr_str` is Python's `str(value)`, computed
769 /// binding-side; nothing here touches `number_to_text`, so this
770 /// neither float-rounds nor overflows.
771 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
772 Ok(format!("thứ {}", repr_str))
773 }
774
775 /// `to_year(float/Decimal)` — the same two-branch body as the integer
776 /// path:
777 ///
778 /// ```python
779 /// if val < 0:
780 /// return "năm " + self.to_cardinal(-val) + " trước Công nguyên"
781 /// return "năm " + self.to_cardinal(val)
782 /// ```
783 ///
784 /// The `val < 0` test is **numeric**, unlike the textual sign tests
785 /// elsewhere in this file: `-0.0 < 0` is False in Python, so a
786 /// negative-zero float is a plain "năm không" — no era suffix, no "âm".
787 /// Either branch hands `to_cardinal` a *non-negative* value, so "âm"
788 /// can never appear; `to_cardinal(float)` is `number_to_text` with its
789 /// `"%.2f"` coercion (a Decimal is coerced to a double the same way, via
790 /// the same `str -> f64` round the cardinal path uses). A bare-None
791 /// fall-off (>= 10^60) dies in the `"năm " + None` concatenation as
792 /// TypeError, exactly like the integer path.
793 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
794 let concat = |r: Result<String>| -> Result<String> {
795 match r {
796 Err(N2WError::ReturnsNone) => Err(type_error(
797 "can only concatenate str (not \"NoneType\") to str",
798 )),
799 other => other,
800 }
801 };
802 let (lt_zero, magnitude) = match value {
803 // `< 0.0`, NOT the sign bit: -0.0 takes the positive branch.
804 FloatValue::Float { value, .. } => (*value < 0.0, value.abs()),
805 FloatValue::Decimal { value, .. } => {
806 let m: f64 = value.abs().to_string().parse().map_err(|_| {
807 // Unreachable: a finite BigDecimal always renders to an
808 // f64-parseable string. Mapped, not panicked, so the host
809 // interpreter is never taken down by a release-mode abort.
810 N2WError::Value("could not convert Decimal to float".into())
811 })?;
812 (value.is_negative(), m)
813 }
814 };
815 let cardinal = concat(self.number_to_text_float(false, magnitude))?;
816 if lt_zero {
817 Ok(format!("năm {} trước Công nguyên", cardinal))
818 } else {
819 Ok(format!("năm {}", cardinal))
820 }
821 }
822
823 /// Float/Decimal cardinal path. `Num2Word_VI` has **no** `to_cardinal_float`
824 /// — its `to_cardinal(number)` handles non-integers inline via
825 /// `number_to_text`, whose `"%.2f" % number` coerces float *and* Decimal
826 /// alike to a C double. So this override reproduces `number_to_text` on a
827 /// non-integral operand rather than the base `float2tuple`/pointword path
828 /// (VI has no `pointword` — the separator is the literal "phẩy").
829 ///
830 /// `precision_override` (the `precision=` kwarg) is discarded: the
831 /// dispatcher pops it before `to_cardinal`, and VI defines no `precision`
832 /// attribute, so `precision=` is a proven no-op (verified: `precision=4`
833 /// still yields two-place `"%.2f"` output). The `precision` on `FloatValue`
834 /// is unused for the same reason — `"%.2f"` is always two places.
835 fn to_cardinal_float(
836 &self,
837 value: &FloatValue,
838 _precision_override: Option<u32>,
839 ) -> Result<String> {
840 match value {
841 // A Python `float`: the raw f64 crosses intact (both sides are
842 // IEEE-754 doubles), so its `"%.2f"` artefacts survive. Sign is
843 // taken before `abs`, exactly as `number < 0` runs before `abs`.
844 FloatValue::Float { value, .. } => {
845 self.number_to_text_float(*value < 0.0, value.abs())
846 }
847 // A Python `Decimal`: `"%.2f"` coerces it to a double. Sign and
848 // `abs` are taken on the *exact* Decimal first (so a value that
849 // underflows to -0.0 on coercion still counts as negative), then
850 // coerced via the same `str -> f64` round the currency path uses —
851 // `float(abs(Decimal(...)))`. Issue #603: `98746251323029.99`
852 // coerces to a double whose `"%.2f"` is `...29.98`, not `...29.99`.
853 FloatValue::Decimal { value, .. } => {
854 let magnitude: f64 = value.abs().to_string().parse().map_err(|_| {
855 // Unreachable: a finite BigDecimal always renders to an
856 // f64-parseable string. Mapped, not panicked, so the host
857 // interpreter is never taken down by a release-mode abort.
858 N2WError::Value("could not convert Decimal to float".into())
859 })?;
860 self.number_to_text_float(value.is_negative(), magnitude)
861 }
862 }
863 }
864
865 /// `Num2Word_VI` has no `str_to_number` at all (bug #15).
866 ///
867 /// The dispatcher's `converter.str_to_number(number)` raises on the
868 /// attribute lookup, before the string is even glanced at — so every
869 /// string input is an AttributeError: digits, no digits, whitespace,
870 /// scientific notation, all alike. AttributeError is not in the
871 /// dispatcher's `except (InvalidOperation, ValueError)`, so it
872 /// propagates instead of triggering the sentence fallback.
873 fn str_to_number(&self, _s: &str) -> Result<ParsedNumber> {
874 Err(missing_attr("str_to_number"))
875 }
876
877 /// `Num2Word_VI` has no `to_fraction` either (bug #16).
878 ///
879 /// Both the "n/d" fraction-string route and `to="fraction"` fail on
880 /// `converter.to_fraction` before the operands are looked at — hence
881 /// `_numerator`/`_denominator` untouched, and "1/0" is an
882 /// AttributeError, never a ZeroDivisionError.
883 fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
884 Err(missing_attr("to_fraction"))
885 }
886
887 // ---- currency -------------------------------------------------------
888 //
889 // `Num2Word_VI` defines `to_currency` and nothing else. It reads no
890 // CURRENCY_FORMS, calls no pluralize/_money_verbose/_cents_*, and has no
891 // to_cheque — so `currency_forms`, `currency_adjective`,
892 // `currency_precision`, `pluralize`, `money_verbose`, `cents_verbose`,
893 // `cents_terse` and `lang_name` are all left at their trait defaults and
894 // are all unreachable. Overriding them would invent behaviour the class
895 // does not have.
896 //
897 // `cardinal_from_decimal` likewise stays at its default: VI's fractional
898 // path is an AttributeError, not a float conversion, so there is no
899 // fractional-cents rendering to port.
900
901 /// Port of `Num2Word_VI.to_currency`.
902 ///
903 /// `currency`, `cents`, `separator` and `adjective` are accepted and
904 /// discarded — bugs #8 and #9. Every branch ends in the literal `" đồng"`,
905 /// which is why all nine currency codes in the corpus produce identical
906 /// output and why no code path can raise NotImplementedError.
907 ///
908 /// Statement order matters and is preserved: the `Decimal` guard runs
909 /// *before* the int/float branch, so `to_currency(10**26)` raises
910 /// InvalidOperation even though the int branch would never have looked at
911 /// `has_fractional_cents` (bug #12).
912 fn to_currency(
913 &self,
914 val: &CurrencyValue,
915 _currency: &str,
916 _cents: bool,
917 _separator: Option<&str>,
918 _adjective: bool,
919 ) -> Result<String> {
920 // decimal_val = Decimal(str(val))
921 let decimal_val = match val {
922 CurrencyValue::Int(v) => BigDecimal::from(v.clone()),
923 CurrencyValue::Decimal { value, .. } => value.clone(),
924 };
925 // Evaluated for its exception as much as its value: it can raise for
926 // either branch, and the int branch then ignores the result.
927 let has_fractional_cents = Self::has_fractional_cents(&decimal_val)?;
928
929 // `isinstance(val, int)` — a true int, never a float or a Decimal.
930 // Note `has_decimal` is not consulted anywhere: VI does not go through
931 // base.to_currency, so Decimal("5") and Decimal("5.00") both take the
932 // float branch and both print "năm đồng".
933 if let CurrencyValue::Int(v) = val {
934 // to_cardinal(int): keeps the "%.2f" int→double mangling, so
935 // to_currency(10**23) describes 99999999999999991611392 đồng.
936 return self.dong(self.to_cardinal(v));
937 }
938
939 if has_fractional_cents {
940 // `self.to_cardinal_float(val)` — the method does not exist, so
941 // the attribute lookup raises before " đồng" is ever appended
942 // (bug #10).
943 return Err(missing_attr("to_cardinal_float"));
944 }
945
946 let value = match val {
947 CurrencyValue::Decimal { value, .. } => value,
948 CurrencyValue::Int(_) => unreachable!("int branch returned above"),
949 };
950 self.dong(self.number_to_text_decimal(value))
951 }
952
953 /// `Num2Word_VI` has no `to_cheque` at all (bug #11).
954 ///
955 /// The dispatcher's `getattr(converter, "to_cheque")` raises on the
956 /// lookup, before the value is even looked at — hence `_val` and
957 /// `_currency` are untouched, and every `cheque:*` corpus row is an
958 /// AttributeError regardless of the code or the amount.
959 fn to_cheque(&self, _val: &BigDecimal, _currency: &str) -> Result<String> {
960 Err(missing_attr("to_cheque"))
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 /// `abs(Decimal(str(value)).as_tuple().exponent)` — what the dispatcher
969 /// computes Python-side before calling `_RUST.to_cardinal_float`. The
970 /// value is irrelevant for VI (bug: `"%.2f"` is always two places), but
971 /// the tests thread the honest number through anyway.
972 fn repr_precision(f: f64) -> u32 {
973 let s = format!("{}", f);
974 match s.split_once('.') {
975 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
976 _ => 0,
977 }
978 }
979
980 fn float_case(v: f64) -> String {
981 LangVi::new()
982 .to_cardinal_float(
983 &FloatValue::Float {
984 value: v,
985 precision: repr_precision(v),
986 },
987 None,
988 )
989 .unwrap()
990 }
991
992 fn dec_case(s: &str) -> String {
993 let value = BigDecimal::from_str(s).unwrap();
994 let precision = s.split_once('.').map_or(0, |(_, f)| f.len() as u32);
995 LangVi::new()
996 .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
997 .unwrap()
998 }
999
1000 /// Every float `cardinal` corpus row with a fractional part (whole floats
1001 /// like 0.0/1.0 are routed to the integer path by the dispatcher and never
1002 /// reach `to_cardinal_float`), plus the live-interpreter sweep.
1003 #[test]
1004 fn float_cardinals_match_python() {
1005 for (v, want) in [
1006 (0.5, "không phẩy năm mươi"),
1007 (1.5, "một phẩy năm mươi"),
1008 (2.25, "hai phẩy hai mươi lăm"),
1009 (3.14, "ba phẩy mười bốn"),
1010 (0.01, "không phẩy một"),
1011 (0.1, "không phẩy mười"),
1012 (0.99, "không phẩy chín mươi chín"),
1013 (1.01, "một phẩy một"),
1014 (12.34, "mười hai phẩy ba mươi bốn"),
1015 (99.99, "chín mươi chín phẩy chín mươi chín"),
1016 (100.5, "một trăm phẩy năm mươi"),
1017 (1234.56, "một nghìn hai trăm ba mươi bốn phẩy năm mươi sáu"),
1018 (-0.5, "âm không phẩy năm mươi"),
1019 (-1.5, "âm một phẩy năm mươi"),
1020 (-12.34, "âm mười hai phẩy ba mươi bốn"),
1021 // The f64-artefact rows: "%.2f" % 1.005 == "1.00" (the double is
1022 // 1.00499999…) and "%.2f" % 2.675 == "2.67" (2.67499999…).
1023 (1.005, "một"),
1024 (2.675, "hai phẩy sáu mươi bảy"),
1025 // Sub-cent values collapse to ".00": no phẩy tail at all, and the
1026 // sign still survives via the pre-abs `number < 0` test.
1027 (0.001, "không"),
1028 (-0.001, "âm không"),
1029 ] {
1030 assert_eq!(float_case(v), want, "float {}", v);
1031 }
1032 }
1033
1034 /// Every `cardinal_dec` corpus row. All go through the same
1035 /// Decimal→double coercion `"%.2f"` performs in Python.
1036 #[test]
1037 fn decimal_cardinals_match_python() {
1038 for (s, want) in [
1039 ("0.01", "không phẩy một"),
1040 ("1.10", "một phẩy mười"),
1041 // float(Decimal("12.345")) is 12.34500000…06 → "%.2f" → "12.35".
1042 ("12.345", "mười hai phẩy ba mươi lăm"),
1043 ("-12.345", "âm mười hai phẩy ba mươi lăm"),
1044 ("0.001", "không"),
1045 // Issue #603's value: the double coercion lands on …29.98.
1046 (
1047 "98746251323029.99",
1048 "chín mươi tám nghìn tỷ bảy trăm bốn mươi sáu tỷ hai trăm \
1049 năm mươi mốt triệu ba trăm hai mươi ba nghìn lẻ hai mươi \
1050 chín phẩy chín mươi tám",
1051 ),
1052 ] {
1053 assert_eq!(dec_case(s), want, "decimal {}", s);
1054 }
1055 }
1056
1057 /// `precision=` is a proven no-op for VI: the dispatcher pops it, the
1058 /// class has no `precision` attribute, and `"%.2f"` is hard-coded.
1059 #[test]
1060 fn precision_override_is_discarded() {
1061 let got = LangVi::new()
1062 .to_cardinal_float(
1063 &FloatValue::Float {
1064 value: 12.345,
1065 precision: 3,
1066 },
1067 Some(1),
1068 )
1069 .unwrap();
1070 assert_eq!(got, "mười hai phẩy ba mươi lăm");
1071 }
1072
1073 /// Past 10^60 `vietnam_number` returns `None`; positive input surfaces
1074 /// the bare-None sentinel, negative concatenates into a TypeError. Only
1075 /// reachable through the Decimal arm (any non-integral double is < 2^53).
1076 #[test]
1077 fn none_falloff_past_1e60() {
1078 let lang = LangVi::new();
1079 let big = "2".to_string() + &"0".repeat(60) + ".5";
1080 let v = FloatValue::Decimal {
1081 value: BigDecimal::from_str(&big).unwrap(),
1082 precision: 1,
1083 };
1084 assert!(matches!(
1085 lang.to_cardinal_float(&v, None),
1086 Err(N2WError::ReturnsNone)
1087 ));
1088 let neg = FloatValue::Decimal {
1089 value: BigDecimal::from_str(&format!("-{}", big)).unwrap(),
1090 precision: 1,
1091 };
1092 assert!(matches!(
1093 lang.to_cardinal_float(&neg, None),
1094 Err(N2WError::Type(_))
1095 ));
1096 }
1097}