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}