Skip to main content

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}