Skip to main content

num2words2_core/
lang_lo.rs

1//! Port of `lang_LO.py` (Lao).
2//!
3//! Shape: **self-contained**. `Num2Word_LO` subclasses `Num2Word_Base`, but its
4//! `setup()` defines only `negword`, `pointword` and an `ones` list — no
5//! `high_numwords`/`mid_numwords`/`low_numwords`. The `any(hasattr(...))` guard
6//! in `Num2Word_Base.__init__` therefore never fires, so Python never builds
7//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
8//! outright and drives `_int_to_word`, a plain recursive descent over the Lao
9//! myriad-style scale (สิบ/ຊາວ/ຮ້ອຍ/ພັນ/ໝື່ນ/ແສນ/ລ້ານ).
10//!
11//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here, and
12//! there is **no overflow check at all** — see bug 1 below for what happens
13//! instead.
14//!
15//! Inherited from `Num2Word_Base` and *not* overridden by LO:
16//!   * `to_cheque` — the only inherited method that runs. It reads
17//!     `CURRENCY_FORMS[currency]` *strictly* (`KeyError` -> `NotImplementedError`)
18//!     and `_money_verbose` -> `to_cardinal`, both of which the trait defaults
19//!     already provide. See bug 5 for the contradiction this creates with LO's
20//!     own `to_currency`.
21//!   * `verify_ordinal` is inherited but **never called**, which is why
22//!     negatives sail through `to_ordinal` (bug 2).
23//!   * `pluralize`, `_cents_verbose` and `_cents_terse` are all unreachable —
24//!     LO's `to_currency` never calls them and `to_cheque` does not either — so
25//!     their trait defaults (`pluralize` raises) are deliberately left alone.
26//!
27//! # Currency
28//!
29//! `CURRENCY_FORMS` is defined on `Num2Word_LO` itself (LAK/USD/EUR), so none of
30//! the `lang_EUR`/`Num2Word_EN` shared-dict mutation described in
31//! `PORTING_CURRENCY.md` applies here — the live interpreter confirms exactly
32//! those three codes. `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both
33//! empty (`{}`), so every code is divisor 100 and no adjective exists; the trait
34//! defaults cover both and neither hook is overridden.
35//!
36//! `to_currency` is overridden outright and shares no code with
37//! `Num2Word_Base.to_currency`: no `parse_currency_parts`, no divisor, no
38//! `pluralize`. It re-splits `str(val)` by hand.
39//!
40//! # Faithfully reproduced Python bugs
41//!
42//! This is a port, not a rewrite. Each of the following is wrong-looking but is
43//! exactly what Python emits, and each is pinned by the frozen corpus:
44//!
45//! 1. **`_int_to_word` gives up at 10^9 and returns the decimal digits.** The
46//!    final `else` arm is `return str(number)`, so `to_cardinal(10**9)` is the
47//!    ASCII string `"1000000000"`, not Lao words. Every value >= 10^9 is echoed
48//!    back as digits, forever — there is no `OverflowError` and no upper bound.
49//!    Corpus confirms this out to 10^21. See [`LangLo::int_to_word`].
50//! 2. **`to_ordinal` never calls `verify_ordinal`**, so negatives are accepted
51//!    and produce `"ທີ່" + to_cardinal(n)` — e.g. `to_ordinal(-1)` ==
52//!    `"ທີ່ລົບ ໜຶ່ງ"` ("th-minus one"). Most languages raise `TypeError` here.
53//! 3. **`to_ordinal`/`to_ordinal_num` are pure prefixing** — the "ordinal" is
54//!    just `"ທີ່"` glued onto the cardinal (or onto `str(number)`), with no
55//!    separator and no morphology. So `to_ordinal(10**9)` == `"ທີ່1000000000"`,
56//!    inheriting bug 1.
57//! 4. **`negword` carries a trailing space** (`"ລົບ "`, not `"ລົບ"`), unlike the
58//!    base class's convention of stripping it at the call site. `to_cardinal`
59//!    concatenates it raw, which happens to give the right single space —
60//!    but it means [`Lang::negword`] here returns a *space-suffixed* word, so a
61//!    caller applying the base's usual `"%s " % negword.strip()` idiom would
62//!    get the same result only by coincidence. `to_currency` concatenates it
63//!    raw too.
64//! 5. **`to_currency` and `to_cheque` disagree about unknown currency codes.**
65//!    LO's `to_currency` looks the code up *leniently* —
66//!    `CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["LAK"])` — so an
67//!    unimplemented code silently renders in **Lao kip**, while the inherited
68//!    `to_cheque` does a strict `CURRENCY_FORMS[currency]` and raises
69//!    `NotImplementedError` for the very same code. The corpus pins both halves:
70//!    `currency:GBP` -> `"ສິບສອງ ກີບ ສາມສິບສີ່ ອັດ"` (kip/at, not pounds/pence)
71//!    while `cheque:GBP` -> `NotImplementedError`. Same for JPY, KWD, BHD, INR,
72//!    CNY and CHF. See [`LangLo::to_currency`].
73//! 6. **`to_currency` ignores `CURRENCY_PRECISION` entirely.** It hardcodes a
74//!    two-digit subunit via `parts[1][:2]`, so the 3-decimal (KWD/BHD) and
75//!    0-decimal (JPY) currencies get plain hundredths like everything else —
76//!    and since those codes are not in `CURRENCY_FORMS` anyway they land on the
77//!    LAK fallback first. `currency:JPY` of `0.5` is `"ສູນ ກີບ ຫ້າສິບ ອັດ"`, not
78//!    the whole-unit rounding `Num2Word_Base.to_currency` would apply. LO's
79//!    `CURRENCY_PRECISION` is `{}` regardless, so `to_cheque` uses 100.
80//! 7. **A float with zero cents drops the cents segment.** The guard is
81//!    `if cents and right`, and `right` is an int, so `1.0` (which yields
82//!    `right = 0`) renders `"ໜຶ່ງ ເອີໂຣ"` — identical to the int `1`.
83//!    `Num2Word_Base.to_currency` would emit a "zero cents" tail here. The
84//!    int/float distinction is still threaded through faithfully (see
85//!    [`split_currency`]) because it only *coincidentally* collapses in LO's
86//!    output.
87//! 8. **`cents=False` omits the cents rather than making them terse**, because
88//!    the same `cents and right` guard suppresses the whole segment instead of
89//!    switching to `_cents_terse` the way the base class does.
90//! 9. **`adjective` is accepted and completely ignored** — declared in the
91//!    signature, never read, and `CURRENCY_ADJECTIVES` is empty anyway.
92//! 10. **Subunits are truncated, not rounded.** `parts[1][:2]` slices the
93//!    decimal string, so `12.349` gives 34 cents (not 35) and `0.001` gives 0.
94//!
95//! # Non-bugs that look like bugs
96//!
97//! * The trailing `.strip()` in `to_cardinal` is a no-op for integer input:
98//!   every `_int_to_word` arm returns a non-empty, non-padded string and the
99//!   only prefix is `""` or `"ລົບ "`. Reproduced anyway ([`str::trim`]) so the
100//!   port matches the source line for line.
101//! * The `if number < 0` arm of `_int_to_word` is **dead code** on the four
102//!   in-scope entry points: `to_cardinal` strips the sign before calling, and
103//!   every recursive call passes a non-negative quotient or remainder. It is
104//!   reachable only from `to_currency` (out of scope). Ported regardless.
105//! * `to_cardinal`'s `"." in n` branch (the `pointword` per-digit path) is
106//!   unreachable from `BigInt` input — `str(int)` never contains a dot. The
107//!   `BigInt` `to_cardinal` above therefore only runs the integer `else` branch.
108//!   The dotted branch *is* ported, in [`LangLo::to_cardinal_float`]: because
109//!   `Num2Word_LO` overrides `to_cardinal` and handles non-integers inline via
110//!   `str(number)` string-splitting — nothing like `Num2Word_Base`'s
111//!   `float2tuple` — the trait's default `to_cardinal_float` is wrong for LO and
112//!   is overridden. See that method for the full port and its consequences.
113//!
114//! # Float routing: every float/Decimal goes through the string surgery
115//!
116//! `to_cardinal` branches on `"." in str(number)`, never on
117//! `int(value) == value`, so a *whole* float still reads its trailing zero:
118//! `to_cardinal(5.0)` == `"ຫ້າ ຈຸດ ສູນ"` and `Decimal("5.00")` reads two zero
119//! digits, while a point-free `Decimal("5")` takes the integer reading. The
120//! base trait's `cardinal_float_entry` (whole -> int path) is therefore
121//! overridden to send **all** float/Decimal input through the cascade;
122//! `to_ordinal`/`to_year` float entries prefix that same output
123//! (`"ທີ່"`/`"ປີ "`), and `to_ordinal_num`'s float entry prefixes the
124//! binding-provided `str(value)`.
125//!
126//! `str(number)` is rebuilt exactly: `str(Decimal)` via
127//! [`crate::strnum::python_decimal_str`] (trailing zeros and exponent forms
128//! kept — `"5.00"`, `"1E+2"`), and `str(float)` via [`py_float_str`], which
129//! mirrors CPython's shortest-repr formatting *including* the switch to
130//! exponent form at `|v| >= 1e16` or `0 < |v| < 1e-4`. Exponential text then
131//! fails in `int()` exactly as Python's does: `int("1e+16")` / `int("1E+2")`
132//! raise ValueError (no dot in the repr), and a mantissa-dotted `1.23e+16`
133//! dies on `int("e")` in the digit walk. `Decimal("-0.0")` arrives as
134//! `FloatValue::Float { -0.0 }` (the binding converts it — `BigDecimal` has
135//! no signed zero) and the sign bit keeps the negword:
136//! `"ລົບ ສູນ ຈຸດ ສູນ"`, as pinned.
137//!
138//! # String input: Infinity/NaN raise ValueError, not OverflowError
139//!
140//! `num2words("Infinity", lang="lo")` parses to `Decimal("Infinity")` and
141//! lands in this `to_cardinal`: `str()` gives `"Infinity"` — no `"."` — and
142//! `int("Infinity")` raises **ValueError**. The binding would otherwise map
143//! `ParsedNumber::Inf` to the base path's OverflowError before any LO code
144//! runs, so [`Lang::str_to_number`] intercepts Inf (and NaN, whose binding
145//! message differs from `int("NaN")`'s). Known gap (unpinned): Python's
146//! `to_ordinal_num(Decimal("Infinity"))` echoes `"ທີ່Infinity"`, which the
147//! entry-level interception turns into the cardinal path's ValueError; the
148//! strings corpus pins Infinity/NaN under `to=cardinal` only.
149//!
150//! # Error variants
151//!
152//! **The four integer modes raise nothing.** No `MAXVAL` check, no dict lookups,
153//! no list indexing that can run out of range (every `ones[...]` index is pinned
154//! to 0..=9 by the branch guard that precedes it), and no `int()` of a
155//! non-numeric token. Every *integer* corpus row for `lo` in those modes is
156//! `"ok": true`. The float/Decimal side of cardinal/ordinal/year raises
157//! `Value` — Python's ValueError from `int()` — exactly when `str(number)`
158//! is exponential (see the float-routing section above).
159//!
160//! The currency surface has exactly two:
161//!
162//! * `NotImplemented` — from the inherited `to_cheque` only, on a code outside
163//!   {LAK, USD, EUR}. Message: `Currency code "X" not implemented for
164//!   "Num2Word_LO"`. **`to_currency` cannot raise it** (bug 5).
165//! * `Value` — from `int()` on an exponent-notation token inside
166//!   [`split_currency`]; see that function for the exact conditions and the one
167//!   known gap.
168
169use crate::base::{Lang, N2WError, Result};
170use crate::currency::{CurrencyForms, CurrencyValue};
171use crate::floatpath::FloatValue;
172use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
173use bigdecimal::BigDecimal;
174use num_bigint::BigInt;
175use num_traits::{Signed, ToPrimitive, Zero};
176use std::collections::HashMap;
177use std::str::FromStr;
178
179/// `self.ones`, indices 0..=9. Every read is guarded by a range check that
180/// pins the index into this table, so there is no `IndexError` path.
181const ONES: [&str; 10] = [
182    "ສູນ",   // 0
183    "ໜຶ່ງ",  // 1
184    "ສອງ",   // 2
185    "ສາມ",   // 3
186    "ສີ່",    // 4
187    "ຫ້າ",   // 5
188    "ຫົກ",   // 6
189    "ເຈັດ",  // 7
190    "ແປດ",   // 8
191    "ເກົ້າ",  // 9
192];
193
194/// `self.negword`. Note the trailing space — see bug 4 in the module docs.
195const NEGWORD: &str = "ລົບ ";
196
197/// `self.pointword`. Reachable only via the float/Decimal path — `str(int)`
198/// never contains the dot that gates it.
199const POINTWORD: &str = "ຈຸດ";
200
201/// 10 — a standalone word, not `ones[1] + ສິບ`.
202const TEN: &str = "ສິບ";
203/// 20 — likewise standalone, and the reason 20..=29 gets its own arm.
204const TWENTY: &str = "ຊາວ";
205/// Scale suffixes: 10^2, 10^3, 10^4, 10^5, 10^6.
206const HUNDRED: &str = "ຮ້ອຍ";
207const THOUSAND: &str = "ພັນ";
208const TEN_THOUSAND: &str = "ໝື່ນ";
209const HUNDRED_THOUSAND: &str = "ແສນ";
210/// 10^6. Unlike the smaller scales this one is joined with a **leading space**
211/// (`" ລ້ານ"`), which is why `to_cardinal(10**6)` == `"ໜຶ່ງ ລ້ານ"` while
212/// `to_cardinal(1000)` == `"ໜຶ່ງພັນ"` (no space).
213const MILLION: &str = " ລ້ານ";
214
215/// The ordinal prefix, glued on with no separator.
216const ORDINAL_PREFIX: &str = "ທີ່";
217/// The year prefix, which *does* carry a trailing space.
218const YEAR_PREFIX: &str = "ປີ ";
219
220/// `Num2Word_LO.to_currency`'s own default `separator=" "`.
221///
222/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
223const SEPARATOR_DEFAULT: &str = " ";
224
225/// The separator the pyo3 binding passes when the Python caller omitted one.
226///
227/// `Num2Word_LO.to_currency` declares `separator=" "`, but the `Lang` trait has
228/// no per-language defaults: `__init__.py`'s currency fast path (and
229/// `bench/diff_test.py`) both substitute `kwargs.get("separator", ",")` —
230/// **`Num2Word_Base`'s** default — before the value ever reaches Rust. By then
231/// "caller omitted separator" and "caller explicitly passed a comma" are the
232/// same string, and the information needed to tell them apart no longer exists
233/// on this side of the boundary.
234///
235/// So `,` is read back as the unset sentinel and LO's own default restored.
236/// This is the only reading that matches the oracle: every float row of the `lo`
237/// currency corpus was generated by `num2words(v, lang="lo", to="currency",
238/// currency=c)` with no `separator=`, and every one of them expects `" "` —
239/// taking the comma at face value would render `"ສິບສອງ ເອີໂຣ,ສາມສິບສີ່ ເຊັນ"`
240/// and fail all 63 of them. It is also what the *shipped* shim needs: a plain
241/// `num2words(12.34, lang="lo", to="currency", currency="EUR")` must not start
242/// emitting commas just because the fast path took over.
243///
244/// The cost is narrow and known: a caller who *explicitly* passes
245/// `separator=","` gets `" "` here where Python would give `","`. Fixing that
246/// properly needs `Option<&str>` in the trait signature, which lives in
247/// `base.rs` — outside this port's remit. Flagged in the port report. This
248/// mirrors the convention `lang_ca.rs` / `lang_bo.rs` / `lang_as.rs` established
249/// for the same problem.
250const SEPARATOR_UNSET: &str = ",";
251
252/// `to_currency`'s fallback code: `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["LAK"])`.
253///
254/// Note Python evaluates `self.CURRENCY_FORMS["LAK"]` *eagerly* as the `.get`
255/// default, so a class that dropped the LAK entry would raise `KeyError` on
256/// every call. LO always has it, so the lookup below is infallible.
257const FALLBACK_CURRENCY: &str = "LAK";
258
259fn bi(n: u32) -> BigInt {
260    BigInt::from(n)
261}
262
263/// `self.ones[i]` where the caller has already proven `0 <= i <= 9`.
264///
265/// The conversion cannot fail: each call site is dominated by a comparison
266/// bounding the value to a single decimal digit.
267fn ones_at(n: &BigInt) -> &'static str {
268    let i = n
269        .to_usize()
270        .expect("ones index is bounded to 0..=9 by the caller's range check");
271    ONES[i]
272}
273
274/// The `(left, right)` split inlined at the top of `Num2Word_LO.to_currency`.
275///
276/// ```text
277/// is_negative = False
278/// if val < 0:
279///     is_negative = True
280///     val = abs(val)
281///
282/// parts = str(val).split(".")
283/// left = int(parts[0]) if parts[0] else 0
284/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
285/// ```
286///
287/// (Byte for byte the body of `Num2Word_BO._split_currency`, which `lang_bo.rs`
288/// ports the same way. The sign is handled by the caller; this takes `abs`.)
289///
290/// # Why this is arithmetic and not string surgery
291///
292/// The Python operates on `str(val)`, but the string is gone by the time the
293/// value reaches Rust — `currency.rs` deliberately keeps `repr(float)` on the
294/// Python side and hands over a parsed `BigDecimal`. The arithmetic below is
295/// nonetheless *exactly* equivalent, because `BigDecimal` retains the scale of
296/// the literal it was parsed from:
297///
298/// * `int(parts[0])` is the truncated integer part, and `with_scale(0)` divides
299///   by a power of ten with `BigInt` division, i.e. truncates toward zero. `val`
300///   is already non-negative here, so truncation is the floor.
301/// * `int(parts[1][:2].ljust(2, "0"))` is `floor(frac * 100)`. The `[:2]`
302///   truncation and the `ljust` zero-padding are both subsumed: the scale
303///   carries `"1.0"`'s trailing zero, and truncating at two places is what
304///   discarding the third digit does.
305///
306/// | `str(val)` | `parts[1][:2].ljust(2,"0")` | Python | `floor(frac*100)` |
307/// |---|---|---|---|
308/// | `"1.0"`   | `"00"` | 0  | 0  |
309/// | `"0.5"`   | `"50"` | 50 | 50 |
310/// | `"0.01"`  | `"01"` | 1  | 1  |
311/// | `"12.34"` | `"34"` | 34 | 34 |
312/// | `"99.99"` | `"99"` | 99 | 99 |
313/// | `"0.001"` | `"00"` | 0  | 0  |
314/// | `"0.567"` | `"56"` | 56 | 56 |
315///
316/// `parts[0]` is never `""` and `parts[1]` is never `""` for any string Python's
317/// `str()` produces, so the two `else 0` fallbacks are dead; they coincide with
318/// the arithmetic anyway.
319///
320/// # The exponent-notation `ValueError`
321///
322/// `str()` switches to exponent notation for a float outside roughly
323/// `1e-4 <= |v| < 1e16`, and `int()` chokes on the resulting token:
324/// `str(1e16)` is `"1e+16"`, `parts` is `["1e+16"]`, and `int("1e+16")` raises
325/// **`ValueError`** — not `NotImplementedError`, and not anything LO catches. So
326/// `to_currency` raises for large floats rather than returning a string.
327///
328/// The check below keys off the *representation*, which is what Python's
329/// `str().split(".")` does too, so it stays faithful for the `Decimal` inputs the
330/// shim also accepts: `Decimal("1E+3")` stringifies with an exponent and raises,
331/// while `Decimal(1000)` stringifies as plain digits and succeeds — and the two
332/// parse to different scales (-3 vs 0), so both land correctly.
333///
334/// A **positive** exponent is therefore recoverable: plain decimal notation can
335/// never yield a negative `BigDecimal` scale, so `scale < 0` proves `str()`
336/// emitted an exponent. No false positives are possible.
337///
338/// Two exponent cases are **not** recoverable. Both are flagged in the port
339/// report rather than papered over, and no corpus row exercises either.
340///
341/// 1. *Negative exponent.* `str(1e-05)` is `"1e-05"` (CPython goes scientific
342///    below `1e-4`) and raises `ValueError`, but it parses to the very same
343///    `(1, scale=5)` as `Decimal("0.00001")`, which stringifies as `"0.00001"`
344///    and does *not* raise — Python returns `"ສູນ ເອີໂຣ"` for it. The two are
345///    indistinguishable once across the boundary, so a float `< 1e-4` returns
346///    `(0, 0)` here (`"ສູນ ເອີໂຣ"`) where Python raises. Discriminating on the
347///    value instead (`|v| < 1e-4`) would fix the float and break the `Decimal`,
348///    so the representation-faithful rule is kept.
349///
350/// 2. *Exponent absorbed by the mantissa's own digits.* When a float's repr
351///    carries at least as many fractional digits as its exponent the scale
352///    comes out `>= 0` and no error fires — but Python does not error either.
353///    It splits the **exponent string** on the dot and parses the pieces:
354///    `str(1.2345678901234568e+16).split(".")` is
355///    `["1", "2345678901234568e+16"]`, so `left = int("1") = 1` and
356///    `right = int("23") = 23`, giving `"ໜຶ່ງ ເອີໂຣ ຊາວສາມ ເຊັນ"` — one euro
357///    and twenty-three cents for a value of ~1.2e16. This port instead reads
358///    the number it was handed and returns `"12345678901234568 ເອີໂຣ"` (bug 1's
359///    digit echo). Reproducing Python's answer would mean re-deriving
360///    `repr(float)` in Rust, which `currency.rs` deliberately keeps on the
361///    Python side; the divergence only affects floats `>= 1e16`, where Python's
362///    own output is nonsense.
363fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
364    match val {
365        // str(int) never contains "." — parts has length 1, so `right` is 0 and
366        // the cents segment is unreachable for a true int.
367        CurrencyValue::Int(v) => Ok((v.abs(), BigInt::zero())),
368        CurrencyValue::Decimal { value: d, .. } => {
369            let d = d.abs();
370            if d.as_bigint_and_exponent().1 < 0 {
371                // str(n) used exponent notation; int() on that token raises.
372                return Err(N2WError::Value(format!(
373                    "invalid literal for int() with base 10: '{}'",
374                    d
375                )));
376            }
377            let left = d.with_scale(0);
378            let frac = &d - &left;
379            let right = (frac * BigDecimal::from(100)).with_scale(0);
380            Ok((
381                left.as_bigint_and_exponent().0,
382                right.as_bigint_and_exponent().0,
383            ))
384        }
385    }
386}
387
388/// Python's `str(float)` for a finite-or-not f64, sign included.
389///
390/// CPython's `float.__str__` is the shortest round-trip repr, printed in
391/// fixed notation while the decimal point sits in `-3 <= decpt <= 16` and in
392/// exponent notation outside it — i.e. e-form iff `|v| >= 1e16` or
393/// `0 < |v| < 1e-4` (`str(1e16)` == `"1e+16"`, `str(1e15)` ==
394/// `"1000000000000000.0"`, `str(0.0001)` == `"0.0001"`, `str(1e-05)` ==
395/// `"1e-05"`).
396///
397/// * **Fixed form** — `format!("{:.*}", precision, mag)`: `precision` is the
398///   repr-derived fractional digit count the binding carried over
399///   (`abs(Decimal(repr(v)).as_tuple().exponent)`), and correctly rounding
400///   the *exact* f64 to that many places reproduces the shortest-repr digits
401///   byte for byte (both sides are IEEE-754 doubles).
402/// * **Exponent form** — Rust's `{:e}` is shortest-round-trip too (`"1e16"`,
403///   `"9.99e-5"`); reformatted to Python's spelling: explicit `+`, exponent
404///   zero-padded to two digits (`1e+16`, `1e-05`, `1e+100`).
405/// * **inf/nan** — `str(float("inf"))` == `"inf"`, `str(float("nan"))` ==
406///   `"nan"` (CPython prints NaN unsigned regardless of the sign bit).
407///
408/// The sign comes from the *bit*, not a `< 0` compare: `str(-0.0)` is
409/// `"-0.0"`, which is why LO renders the negword for negative zero.
410fn py_float_str(value: f64, precision: u32) -> String {
411    if value.is_nan() {
412        return "nan".to_string();
413    }
414    let neg = value.is_sign_negative();
415    let mag = value.abs();
416    let body = if mag.is_infinite() {
417        "inf".to_string()
418    } else if mag != 0.0 && !(1e-4..1e16).contains(&mag) {
419        // repr picks exponent form. {:e} gives e.g. "1e16" / "9.99e-5".
420        let s = format!("{:e}", mag);
421        let (mant, exp) = s.split_once('e').expect("{:e} always contains 'e'");
422        let e: i32 = exp.parse().expect("{:e} exponent is a valid i32");
423        format!("{}e{}{:02}", mant, if e < 0 { '-' } else { '+' }, e.abs())
424    } else {
425        format!("{:.*}", precision as usize, mag)
426    };
427    if neg {
428        format!("-{}", body)
429    } else {
430        body
431    }
432}
433
434/// `str(number)` for the value the dispatcher handed over: `repr(float)` via
435/// [`py_float_str`], `str(Decimal)` via the spec port in `strnum` — which
436/// keeps trailing zeros (`"1.10"`, leading zeros of `"0.001"` included) and
437/// exponent forms (`"1E+2"`), byte for byte.
438fn py_str(value: &FloatValue) -> String {
439    match value {
440        FloatValue::Float { value, precision } => py_float_str(*value, *precision),
441        FloatValue::Decimal { value, .. } => python_decimal_str(value),
442    }
443}
444
445/// `int(token)` on a run of decimal digits. Python only ever hands a non-digit
446/// token here through exponent notation (which no corpus row hits and whose LO
447/// output is a crash anyway), so a parse failure reproduces the `ValueError`
448/// `int()` raises, as [`N2WError::Value`] — never a foreign exception name.
449fn py_int(s: &str) -> Result<BigInt> {
450    BigInt::from_str(s).map_err(|_| {
451        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
452    })
453}
454
455/// `int(digit)` on a single fractional character, matching Python's per-digit
456/// `int(digit)` in the `for digit in right` loop.
457fn py_digit(ch: char) -> Result<BigInt> {
458    ch.to_digit(10).map(BigInt::from).ok_or_else(|| {
459        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
460    })
461}
462
463pub struct LangLo {
464    /// `Num2Word_LO.CURRENCY_FORMS`, built once in [`LangLo::new`].
465    ///
466    /// The binding holds each `LangLo` in a `OnceLock`
467    /// (`LO.get_or_init(LangLo::new)`), so this table is constructed exactly
468    /// once per process rather than per call.
469    currency_forms: HashMap<&'static str, CurrencyForms>,
470}
471
472impl Default for LangLo {
473    fn default() -> Self {
474        Self::new()
475    }
476}
477
478impl LangLo {
479    pub fn new() -> Self {
480        // Num2Word_LO.CURRENCY_FORMS — three codes, each with two identical
481        // forms per side. Lao does not inflect for number, so the singular and
482        // plural slots carry the same word; the arity is kept at Python's 2
483        // because the inherited `to_cheque` reads `cr1[-1]`.
484        //
485        // This is the class's own dict, not the `lang_EUR`/`Num2Word_EN` shared
486        // one — `Num2Word_LO` subclasses `Num2Word_Base` directly and rebinds
487        // `CURRENCY_FORMS`, so EN's import-time mutation cannot reach it. The
488        // live interpreter confirms exactly these three codes; note EUR's forms
489        // are LO's own and are *not* the mutated ("euro", "euros") pair.
490        let mut currency_forms = HashMap::new();
491        currency_forms.insert("LAK", CurrencyForms::new(&["ກີບ", "ກີບ"], &["ອັດ", "ອັດ"]));
492        currency_forms.insert(
493            "USD",
494            CurrencyForms::new(&["ໂດລາ", "ໂດລາ"], &["ເຊັນ", "ເຊັນ"]),
495        );
496        currency_forms.insert(
497            "EUR",
498            CurrencyForms::new(&["ເອີໂຣ", "ເອີໂຣ"], &["ເຊັນ", "ເຊັນ"]),
499        );
500        LangLo { currency_forms }
501    }
502
503    /// Port of `Num2Word_LO._int_to_word`.
504    ///
505    /// A straight recursive descent, ordered exactly as the Python `if/elif`
506    /// chain — the order is load-bearing (`number == 0` is tested *before*
507    /// `number < 0`, and the standalone `== 10` / `== 20` arms precede the
508    /// general tens arm).
509    ///
510    /// Beyond 10^9 the Python falls off the end of the chain into
511    /// `return str(number)`, echoing the decimal digits. That is bug 1 in the
512    /// module docs and is why this returns `String` rather than words for large
513    /// input — and why it cannot overflow or raise.
514    fn int_to_word(&self, number: &BigInt) -> String {
515        if number.is_zero() {
516            return ONES[0].to_string();
517        }
518
519        if number.is_negative() {
520            // Dead on every in-scope path (see module docs); ported for fidelity.
521            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
522        } else if number < &bi(10) {
523            return ones_at(number).to_string();
524        } else if number == &bi(10) {
525            return TEN.to_string();
526        } else if number < &bi(20) {
527            return format!("{}{}", TEN, ones_at(&(number - 10u32)));
528        } else if number == &bi(20) {
529            return TWENTY.to_string();
530        } else if number < &bi(30) {
531            return format!("{}{}", TWENTY, ones_at(&(number - 20u32)));
532        } else if number < &bi(100) {
533            // 30..=99. Python: tens_word = ones[n // 10] + "ສິບ"; append the
534            // units word only when non-zero.
535            let tens_val = number / 10u32;
536            let ones_val = number % 10u32;
537            let tens_word = format!("{}{}", ones_at(&tens_val), TEN);
538            if ones_val.is_zero() {
539                return tens_word;
540            } else {
541                return format!("{}{}", tens_word, ones_at(&ones_val));
542            }
543        }
544
545        // The four "ones[scale_digit] + suffix [+ ' ' + recurse(remainder)]"
546        // arms. Each divisor's quotient is 1..=9, so `ones_at` is always in
547        // range. Note the separator is a space here but the suffix itself is
548        // glued directly onto the digit word.
549        for (limit, divisor, suffix) in [
550            (1_000u32, 100u32, HUNDRED),
551            (10_000, 1_000, THOUSAND),
552            (100_000, 10_000, TEN_THOUSAND),
553            (1_000_000, 100_000, HUNDRED_THOUSAND),
554        ] {
555            if number < &bi(limit) {
556                let scale_val = number / divisor;
557                let remainder = number % divisor;
558                let mut result = format!("{}{}", ones_at(&scale_val), suffix);
559                if !remainder.is_zero() {
560                    result.push(' ');
561                    result.push_str(&self.int_to_word(&remainder));
562                }
563                return result;
564            }
565        }
566
567        if number < &bi(1_000_000_000) {
568            // 10^6..10^9-1. Unlike the arms above, the multiplier here is
569            // *recursed* (it can be 1..=999) and ລ້ານ carries a leading space.
570            let millions_val = number / 1_000_000u32;
571            let remainder = number % 1_000_000u32;
572            let mut result = format!("{}{}", self.int_to_word(&millions_val), MILLION);
573            if !remainder.is_zero() {
574                result.push(' ');
575                result.push_str(&self.int_to_word(&remainder));
576            }
577            return result;
578        }
579
580        // Bug 1: `return str(number)` — the digits, verbatim, with no ceiling.
581        number.to_string()
582    }
583}
584
585impl Lang for LangLo {
586    /// This language's own `to_currency(currency=...)` default,
587    /// read from the live Python signature. Only 44 of 156 use EUR.
588    fn default_currency(&self) -> &str {
589        "LAK"
590    }
591
592    /// This language's own `to_currency(separator=...)` default,
593    /// read from the live Python signature. Base's is ",", but only
594    /// 36 of 149 languages actually use it — most default to " " or a
595    /// conjunction, so inheriting Base's comma silently corrupts them.
596    fn default_separator(&self) -> &str {
597        " "
598    }
599
600    /// `self.negword` — space-suffixed, exactly as Python sets it. See bug 4.
601    fn negword(&self) -> &str {
602        NEGWORD
603    }
604
605    fn pointword(&self) -> &str {
606        "ຈຸດ"
607    }
608
609    /// Port of `Num2Word_LO.to_cardinal`, integer path only.
610    ///
611    /// Python does `n = str(number).strip()`, peels a leading `"-"` into
612    /// `ret = self.negword`, then (no `"."` in an int's repr) returns
613    /// `(ret + self._int_to_word(int(n))).strip()`.
614    ///
615    /// The negword is concatenated **raw**, keeping its own trailing space; the
616    /// final `.strip()` is a no-op for integers but is reproduced anyway.
617    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
618        let ret = if value.is_negative() { NEGWORD } else { "" };
619        // int(n) after stripping "-" is exactly abs(value).
620        let words = self.int_to_word(&value.abs());
621        Ok(format!("{}{}", ret, words).trim().to_string())
622    }
623
624    /// Port of `Num2Word_LO.to_ordinal`: `"ທີ່" + self.to_cardinal(number)`.
625    ///
626    /// No `verify_ordinal`, so negatives pass straight through (bug 2), and no
627    /// separator, so large values give `"ທີ່1000000000"` (bugs 1 + 3).
628    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
629        let cardinal = self.to_cardinal(value)?;
630        Ok(format!("{}{}", ORDINAL_PREFIX, cardinal))
631    }
632
633    /// Port of `Num2Word_LO.to_ordinal_num`: `"ທີ່" + str(number)`.
634    ///
635    /// The sign survives verbatim: `to_ordinal_num(-1)` == `"ທີ່-1"`.
636    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
637        Ok(format!("{}{}", ORDINAL_PREFIX, value))
638    }
639
640    /// Port of `Num2Word_LO.to_year`: `"ປີ " + self.to_cardinal(val)`.
641    ///
642    /// Python's signature is `to_year(self, val, longval=True)`, but `longval`
643    /// is accepted and then completely ignored — there is no era handling and
644    /// no two-part ("nineteen eighty-four") form. Negatives just inherit
645    /// `to_cardinal`'s negword: `to_year(-500)` == `"ປີ ລົບ ຫ້າຮ້ອຍ"`.
646    fn to_year(&self, value: &BigInt) -> Result<String> {
647        let cardinal = self.to_cardinal(value)?;
648        Ok(format!("{}{}", YEAR_PREFIX, cardinal))
649    }
650
651    /// Port of `Num2Word_LO.to_cardinal`'s float/Decimal branch — the dotted
652    /// path the `BigInt` `to_cardinal` above can never reach (`str(int)` has no
653    /// dot).
654    ///
655    /// `Num2Word_LO` overrides `to_cardinal` (not `to_cardinal_float`) and
656    /// handles non-integers *inline* via `str(number)` string-splitting, which
657    /// is nothing like `Num2Word_Base.to_cardinal_float`/`float2tuple`. So the
658    /// trait's default (`default_to_cardinal_float`) is wrong for LO and this
659    /// overrides it.
660    ///
661    /// ```text
662    /// n = str(number).strip()
663    /// if n.startswith("-"):
664    ///     n = n[1:]; ret = self.negword
665    /// else:
666    ///     ret = ""
667    /// if "." in n:
668    ///     left, right = n.split(".", 1)
669    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
670    ///     for digit in right:
671    ///         ret += self._int_to_word(int(digit)) + " "
672    ///     return ret.strip()
673    /// else:
674    ///     return (ret + self._int_to_word(int(n))).strip()
675    /// ```
676    ///
677    /// # Consequences of splitting `str()` rather than `float2tuple`
678    ///
679    /// * **No f64 artefact — trap #2 does not apply.** `str(2.675)` is
680    ///   `"2.675"`, so the fractional digits are `ຫົກ ເຈັດ ຫ້າ` (6 7 5) taken
681    ///   straight from the repr — *not* the `674.999…` that `float2tuple`'s
682    ///   `< 0.01` heuristic would rescue back to `675` (JA/TH take that path; LO
683    ///   does not). They agree here, but LO computes it from `str()`.
684    /// * **Trailing zeros survive.** `str(Decimal("1.10"))` is `"1.10"`, so the
685    ///   fraction `"10"` renders `ໜຶ່ງ ສູນ`, digit for digit.
686    /// * **`precision=` is ignored.** LO never reads `self.precision`; it splits
687    ///   the repr. Verified live: `precision=1` and `precision=5` both leave
688    ///   `2.675` -> `ສອງ ຈຸດ ຫົກ ເຈັດ ຫ້າ` unchanged. So `precision_override` is
689    ///   dropped here.
690    /// * **`_int_to_word`, not `to_cardinal`, converts each part** — but they
691    ///   agree: `left`/each digit are non-negative, so the negword branch and
692    ///   trailing `.strip()` of `to_cardinal` are no-ops. `int_to_word` is called
693    ///   directly to mirror the Python.
694    ///
695    /// Rebuilding `str(number)`: [`py_str`] — `repr(float)` (exponent form
696    /// included, so `str(1e16)` == `"1e+16"` crashes in `int()` exactly as
697    /// Python's does) and the spec port of `str(Decimal)`
698    /// ([`crate::strnum::python_decimal_str`], so `Decimal("1E+2")` yields
699    /// `"1E+2"` and crashes the same way, while `"1.10"`/`"0.001"` keep their
700    /// zeros byte for byte).
701    fn to_cardinal_float(
702        &self,
703        value: &FloatValue,
704        _precision_override: Option<u32>, // ignored: LO splits str(number), not precision
705    ) -> Result<String> {
706        // n = str(number)
707        let s = py_str(value);
708
709        // if n.startswith("-"): n = n[1:]; ret = self.negword  else: ret = ""
710        let (neg, n) = match s.strip_prefix('-') {
711            Some(rest) => (true, rest),
712            None => (false, s.as_str()),
713        };
714        let mut ret = String::new();
715        if neg {
716            // "ລົບ " — trailing space, concatenated raw, exactly as Python (bug 4).
717            ret.push_str(NEGWORD);
718        }
719
720        if let Some((left, right)) = n.split_once('.') {
721            // ret += _int_to_word(int(left)) + " " + pointword + " "
722            ret.push_str(&self.int_to_word(&py_int(left)?));
723            ret.push(' ');
724            // Python uses `self.pointword` raw here (not title()); is_title is
725            // false anyway, so this matches either way.
726            ret.push_str(POINTWORD);
727            ret.push(' ');
728            // for digit in right: ret += _int_to_word(int(digit)) + " "
729            for ch in right.chars() {
730                ret.push_str(&self.int_to_word(&py_digit(ch)?));
731                ret.push(' ');
732            }
733            Ok(ret.trim().to_string())
734        } else {
735            // else: (ret + _int_to_word(int(n))).strip() — reached when str()
736            // has no dot: a point-free Decimal ("5" -> integer reading) or an
737            // exponent form ("1e+16"/"1E+2", where int() raises ValueError).
738            ret.push_str(&self.int_to_word(&py_int(n)?));
739            Ok(ret.trim().to_string())
740        }
741    }
742
743    /// `to_cardinal(float/Decimal)`, whole values included. LO routes on
744    /// `"." in str(number)`, never on `int(value) == value`, so a whole float
745    /// still reads its point digits (`5.0` -> "ຫ້າ ຈຸດ ສູນ",
746    /// `Decimal("5.00")` -> two zero digits) while a point-free `Decimal("5")`
747    /// takes the integer reading — both fall out of the same string surgery,
748    /// so *everything* is sent there, overriding the base whole -> int route.
749    fn cardinal_float_entry(
750        &self,
751        value: &FloatValue,
752        precision_override: Option<u32>,
753    ) -> Result<String> {
754        self.to_cardinal_float(value, precision_override)
755    }
756
757    /// `to_ordinal(float/Decimal)`: `"ທີ່" + self.to_cardinal(number)` — the
758    /// prefix lands in front of whatever the cardinal produced, negword and
759    /// point digits included ("ທີ່ລົບ ສູນ ຈຸດ ສູນ" for -0.0), and the
760    /// cardinal's ValueError on exponential reprs propagates unchanged.
761    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
762        Ok(format!(
763            "{}{}",
764            ORDINAL_PREFIX,
765            self.cardinal_float_entry(value, None)?
766        ))
767    }
768
769    /// `to_ordinal_num(float/Decimal)`: `"ທີ່" + str(number)`. `repr_str` is
770    /// the binding's Python `str(value)`, so `"ທີ່1e+16"` / `"ທີ່5.00"` echo
771    /// exactly — no conversion, no ValueError.
772    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
773        Ok(format!("{}{}", ORDINAL_PREFIX, repr_str))
774    }
775
776    /// `to_year(float/Decimal)`: `"ປີ " + self.to_cardinal(val)`, same
777    /// full-cardinal routing (and the same ValueError pass-through).
778    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
779        Ok(format!(
780            "{}{}",
781            YEAR_PREFIX,
782            self.cardinal_float_entry(value, None)?
783        ))
784    }
785
786    /// `converter.str_to_number` — Base's `Decimal(value)`, which LO does not
787    /// override. The Inf/NaN interception reproduces what happens *next* on
788    /// the pinned path: `to_cardinal(Decimal("Infinity"))` reads
789    /// `str(number)` == "Infinity" (canonical capitalization, minus sign
790    /// already peeled by the `startswith("-")` branch), finds no ".", and
791    /// dies in `int("Infinity")` with ValueError; NaN dies in `int("NaN")`.
792    /// The binding otherwise maps `ParsedNumber::Inf` to the base integer
793    /// path's OverflowError (and NaN to a differently-worded ValueError)
794    /// before any LO code runs, so the exact errors must be raised here.
795    ///
796    /// Known gap (unpinned): Python's `to_ordinal_num(Decimal("Infinity"))`
797    /// echoes `"ທີ່Infinity"`, which this entry-level interception turns into
798    /// the cardinal path's ValueError. The strings corpus pins Infinity/NaN
799    /// under `to=cardinal` only.
800    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
801        match python_decimal_parse(s)? {
802            ParsedNumber::Inf { .. } => Err(N2WError::Value(
803                "invalid literal for int() with base 10: 'Infinity'".into(),
804            )),
805            ParsedNumber::NaN => Err(N2WError::Value(
806                "invalid literal for int() with base 10: 'NaN'".into(),
807            )),
808            other => Ok(other),
809        }
810    }
811
812    // ---- currency ----------------------------------------------------
813
814    /// For the inherited `to_cheque`'s
815    /// `Currency code "X" not implemented for "Num2Word_LO"` message.
816    fn lang_name(&self) -> &str {
817        "Num2Word_LO"
818    }
819
820    /// `Num2Word_LO.CURRENCY_FORMS[code]`, with `None` for a missing code.
821    ///
822    /// Only the inherited `to_cheque` reads currency forms through this hook,
823    /// and it wants the strict `CURRENCY_FORMS[currency]` lookup that turns a
824    /// `KeyError` into `NotImplementedError`. LO's own `to_currency` uses the
825    /// *lenient* `.get(currency, CURRENCY_FORMS["LAK"])` instead and so reads
826    /// the field directly rather than going through here — see bug 5 and
827    /// [`LangLo::to_currency`].
828    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
829        self.currency_forms.get(code)
830    }
831
832    /// Port of `Num2Word_LO.to_currency`.
833    ///
834    /// ```text
835    /// def to_currency(self, val, currency="LAK", cents=True, separator=" ",
836    ///                 adjective=False):
837    ///     is_negative = False
838    ///     if val < 0:
839    ///         is_negative = True
840    ///         val = abs(val)
841    ///
842    ///     parts = str(val).split(".")
843    ///     left = int(parts[0]) if parts[0] else 0
844    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
845    ///
846    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["LAK"])
847    ///
848    ///     left_str = self._int_to_word(left)
849    ///     result = left_str + " " + cr1[0]
850    ///
851    ///     if cents and right:
852    ///         cents_str = self._int_to_word(right)
853    ///         result += separator + cents_str + " " + cr2[0]
854    ///
855    ///     return (self.negword if is_negative else "") + result
856    /// ```
857    ///
858    /// A complete override — it shares nothing with
859    /// `Num2Word_Base.to_currency`. No `parse_currency_parts`, no
860    /// `CURRENCY_PRECISION`, no `pluralize`, no `_cents_verbose`/`_cents_terse`,
861    /// and **no `NotImplementedError`**: bugs 5-10 in the module docs all live
862    /// here, and every one is corpus-confirmed.
863    ///
864    /// Note `_int_to_word` is called directly rather than `to_cardinal`. Both
865    /// agree because `left`/`right` are non-negative by construction, so
866    /// `to_cardinal`'s negword branch and trailing `.strip()` are both no-ops.
867    fn to_currency(
868        &self,
869        val: &CurrencyValue,
870        currency: &str,
871        cents: bool,
872        separator: Option<&str>,
873        _adjective: bool, // bug 9: declared, never read
874    ) -> Result<String> {
875        // Trait now hands us None when the caller omitted separator=;
876        // resolve it to this language's own default before the ported body.
877        let separator = separator.unwrap_or(self.default_separator());
878        // Restore LO's own `separator=" "` default; see SEPARATOR_UNSET.
879        let separator = if separator == SEPARATOR_UNSET {
880            SEPARATOR_DEFAULT
881        } else {
882            separator
883        };
884
885        // `if val < 0: is_negative = True; val = abs(val)` — split_currency
886        // takes the absolute value itself.
887        let is_negative = val.is_negative();
888        let (left, right) = split_currency(val)?;
889
890        // CURRENCY_FORMS.get(currency, CURRENCY_FORMS["LAK"]) — lenient, unlike
891        // the strict lookup the inherited `to_cheque` performs through
892        // `currency_forms()`. This is why `currency:GBP` renders in kip while
893        // `cheque:GBP` raises (bug 5).
894        let forms = self
895            .currency_forms
896            .get(currency)
897            .or_else(|| self.currency_forms.get(FALLBACK_CURRENCY))
898            .expect("CURRENCY_FORMS always carries the LAK fallback entry");
899
900        // cr1[0] / cr2[0]: the *first* form. LO never reaches for the plural
901        // slot (only `to_cheque`'s `cr1[-1]` does), and both slots are equal.
902        let mut result = format!("{} {}", self.int_to_word(&left), forms.unit[0]);
903
904        // `cents and right` — `right` is an int, so 0 is falsy and the whole
905        // segment vanishes. Bugs 7 and 8.
906        if cents && !right.is_zero() {
907            result.push_str(separator);
908            result.push_str(&self.int_to_word(&right));
909            result.push(' ');
910            result.push_str(&forms.subunit[0]);
911        }
912
913        // `self.negword` raw, not `"%s " % negword.strip()` — bug 4. Same
914        // result either way: the literal already ends in exactly one space.
915        Ok(if is_negative {
916            format!("{}{}", NEGWORD, result)
917        } else {
918            result
919        })
920    }
921}
922
923#[cfg(test)]
924mod float_tests {
925    use super::*;
926
927    fn f(value: f64, precision: u32) -> String {
928        LangLo::new()
929            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
930            .unwrap()
931    }
932
933    fn d(s: &str) -> String {
934        let bd = BigDecimal::from_str(s).unwrap();
935        let precision = bd.as_bigint_and_exponent().1.max(0) as u32;
936        LangLo::new()
937            .to_cardinal_float(&FloatValue::Decimal { value: bd, precision }, None)
938            .unwrap()
939    }
940
941    #[test]
942    fn corpus_float_rows() {
943        assert_eq!(f(0.0, 1), "ສູນ ຈຸດ ສູນ");
944        assert_eq!(f(0.5, 1), "ສູນ ຈຸດ ຫ້າ");
945        assert_eq!(f(1.0, 1), "ໜຶ່ງ ຈຸດ ສູນ");
946        assert_eq!(f(1.5, 1), "ໜຶ່ງ ຈຸດ ຫ້າ");
947        assert_eq!(f(2.25, 2), "ສອງ ຈຸດ ສອງ ຫ້າ");
948        assert_eq!(f(3.14, 2), "ສາມ ຈຸດ ໜຶ່ງ ສີ່");
949        assert_eq!(f(0.01, 2), "ສູນ ຈຸດ ສູນ ໜຶ່ງ");
950        assert_eq!(f(0.1, 1), "ສູນ ຈຸດ ໜຶ່ງ");
951        assert_eq!(f(0.99, 2), "ສູນ ຈຸດ ເກົ້າ ເກົ້າ");
952        assert_eq!(f(1.01, 2), "ໜຶ່ງ ຈຸດ ສູນ ໜຶ່ງ");
953        assert_eq!(f(12.34, 2), "ສິບສອງ ຈຸດ ສາມ ສີ່");
954        assert_eq!(f(99.99, 2), "ເກົ້າສິບເກົ້າ ຈຸດ ເກົ້າ ເກົ້າ");
955        assert_eq!(f(100.5, 1), "ໜຶ່ງຮ້ອຍ ຈຸດ ຫ້າ");
956        assert_eq!(f(1234.56, 2), "ໜຶ່ງພັນ ສອງຮ້ອຍ ສາມສິບສີ່ ຈຸດ ຫ້າ ຫົກ");
957        assert_eq!(f(-0.5, 1), "ລົບ ສູນ ຈຸດ ຫ້າ");
958        assert_eq!(f(-1.5, 1), "ລົບ ໜຶ່ງ ຈຸດ ຫ້າ");
959        assert_eq!(f(-12.34, 2), "ລົບ ສິບສອງ ຈຸດ ສາມ ສີ່");
960        // f64-artefact cases: str() gives the exact repr digits, no float2tuple.
961        assert_eq!(f(1.005, 3), "ໜຶ່ງ ຈຸດ ສູນ ສູນ ຫ້າ");
962        assert_eq!(f(2.675, 3), "ສອງ ຈຸດ ຫົກ ເຈັດ ຫ້າ");
963    }
964
965    #[test]
966    fn corpus_decimal_rows() {
967        assert_eq!(d("0.01"), "ສູນ ຈຸດ ສູນ ໜຶ່ງ");
968        assert_eq!(d("1.10"), "ໜຶ່ງ ຈຸດ ໜຶ່ງ ສູນ");
969        assert_eq!(d("12.345"), "ສິບສອງ ຈຸດ ສາມ ສີ່ ຫ້າ");
970        assert_eq!(d("98746251323029.99"), "98746251323029 ຈຸດ ເກົ້າ ເກົ້າ");
971        assert_eq!(d("0.001"), "ສູນ ຈຸດ ສູນ ສູນ ໜຶ່ງ");
972    }
973
974    #[test]
975    fn precision_override_ignored() {
976        // LO splits str(number); it never reads self.precision.
977        assert_eq!(
978            LangLo::new()
979                .to_cardinal_float(&FloatValue::Float { value: 2.675, precision: 3 }, Some(1))
980                .unwrap(),
981            "ສອງ ຈຸດ ຫົກ ເຈັດ ຫ້າ"
982        );
983    }
984
985    #[test]
986    fn whole_floats_keep_their_point_digits() {
987        let lo = LangLo::new();
988        // "." in str(5.0) -> the float grammar, never the int path.
989        assert_eq!(
990            lo.cardinal_float_entry(&FloatValue::Float { value: 5.0, precision: 1 }, None)
991                .unwrap(),
992            "ຫ້າ ຈຸດ ສູນ"
993        );
994        // str(-0.0) begins "-": the sign bit keeps the negword.
995        assert_eq!(
996            lo.cardinal_float_entry(&FloatValue::Float { value: -0.0, precision: 1 }, None)
997                .unwrap(),
998            "ລົບ ສູນ ຈຸດ ສູນ"
999        );
1000        // Decimal("5.00") reads both trailing zeros; Decimal("5") does not.
1001        let d500 = FloatValue::Decimal {
1002            value: BigDecimal::from_str("5.00").unwrap(),
1003            precision: 2,
1004        };
1005        assert_eq!(lo.cardinal_float_entry(&d500, None).unwrap(), "ຫ້າ ຈຸດ ສູນ ສູນ");
1006        let d5 = FloatValue::Decimal {
1007            value: BigDecimal::from_str("5").unwrap(),
1008            precision: 0,
1009        };
1010        assert_eq!(lo.cardinal_float_entry(&d5, None).unwrap(), "ຫ້າ");
1011    }
1012
1013    #[test]
1014    fn float_entries_prefix_like_python() {
1015        let lo = LangLo::new();
1016        let v = FloatValue::Float { value: 5.0, precision: 1 };
1017        assert_eq!(lo.ordinal_float_entry(&v).unwrap(), "ທີ່ຫ້າ ຈຸດ ສູນ");
1018        assert_eq!(lo.year_float_entry(&v).unwrap(), "ປີ ຫ້າ ຈຸດ ສູນ");
1019        assert_eq!(lo.ordinal_num_float_entry(&v, "5.0").unwrap(), "ທີ່5.0");
1020    }
1021
1022    #[test]
1023    fn exponential_reprs_raise_value_error() {
1024        let lo = LangLo::new();
1025        // str(1e16) == "1e+16": no ".", int("1e+16") -> ValueError.
1026        let e16 = FloatValue::Float { value: 1e16, precision: 16 };
1027        assert!(matches!(
1028            lo.cardinal_float_entry(&e16, None),
1029            Err(N2WError::Value(_))
1030        ));
1031        // str(Decimal("1E+2")) == "1E+2": int("1E+2") -> ValueError.
1032        let d1e2 = FloatValue::Decimal {
1033            value: BigDecimal::from_str("1E+2").unwrap(),
1034            precision: 2,
1035        };
1036        assert!(matches!(
1037            lo.ordinal_float_entry(&d1e2),
1038            Err(N2WError::Value(_))
1039        ));
1040        // ordinal_num echoes the repr instead of converting.
1041        assert_eq!(lo.ordinal_num_float_entry(&e16, "1e+16").unwrap(), "ທີ່1e+16");
1042    }
1043
1044    #[test]
1045    fn infinity_string_raises_value_error() {
1046        let lo = LangLo::new();
1047        assert!(matches!(lo.str_to_number("Infinity"), Err(N2WError::Value(_))));
1048        assert!(matches!(lo.str_to_number("-Infinity"), Err(N2WError::Value(_))));
1049        assert!(matches!(lo.str_to_number("NaN"), Err(N2WError::Value(_))));
1050        // Plain numbers still parse through the base Decimal grammar.
1051        assert!(matches!(lo.str_to_number("1.5"), Ok(ParsedNumber::Dec(_))));
1052    }
1053}