num2words2_core/lang_uz.rs
1//! Port of `lang_UZ.py` (Uzbek).
2//!
3//! Registry check: `__init__.py` maps `"uz"` to `lang_UZ.Num2Word_UZ()`, which
4//! is the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_UZ` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. `Num2Word_Base.
8//! __init__` guards its card-table construction behind
9//! `if any(hasattr(self, field) for field in [...])`, so for UZ `self.cards`
10//! is **never built** and `self.MAXVAL` is **never set**. `to_cardinal` is
11//! overridden outright and drives a recursive `_int_to_word`. Consequently
12//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
13//! **no overflow check** and no `splitnum`/`clean`/`merge` involvement at all.
14//!
15//! `setup()` only assigns `negword`/`pointword`/`ones`/`tens`/`hundred`/
16//! `thousand`/`million`. Nothing else from the base is reconfigured, so
17//! `is_title` stays `False` and `title()` is a no-op — note that the
18//! overridden `to_cardinal` does not call `self.title()` anyway (the base's
19//! does), so titling could never apply here even if it were enabled.
20//!
21//! Inherited from `Num2Word_Base` and left alone by UZ:
22//! * nothing relevant — UZ overrides `to_cardinal`, `to_ordinal`,
23//! `to_ordinal_num` and `to_year` itself.
24//!
25//! `to_year(self, val, longval=True)` ignores `longval` entirely and just
26//! returns `self.to_cardinal(val)`, which is exactly the trait's default
27//! `to_year`, so the integer hook is not re-implemented below. (The *float*
28//! hook is: [`Lang::year_float_entry`]'s default would route whole floats to
29//! the integer path, but UZ's `to_cardinal` keeps them in the `pointword`
30//! branch — see the float section below.)
31//!
32//! # Float / Decimal cardinal path
33//!
34//! `Num2Word_UZ.to_cardinal` starts from `n = str(number).strip()` and branches
35//! on `"." in n`. For integer input `str()` never yields a ".", so `to_cardinal`
36//! below handles only the integer branch; the float/`pointword` branch is ported
37//! into the [`LangUz::to_cardinal_float`] override, because the Rust core routes
38//! integer cardinal (`&BigInt`) and float cardinal (`&FloatValue`) through
39//! separate trait hooks. See that method for the sign, integer-part and
40//! digit-by-digit fraction details.
41//!
42//! The `"." in str(number)` routing itself lives in the
43//! [`Lang::cardinal_float_entry`] override:
44//!
45//! * a repr **with** a dot (every finite float with `|v| == 0` or
46//! `1e-4 <= |v| < 1e16`; any Decimal with a positive scale) takes the
47//! `pointword` branch — so whole values keep their tail: `5.0` ->
48//! "besh point zero", `Decimal("5.00")` -> "besh point zero zero";
49//! * a repr **without** a dot and all digits (`Decimal("5")`, `Decimal("100")`)
50//! is `int(n)` -> the integer path;
51//! * a repr without a dot that `int()` cannot parse — scientific notation
52//! (`str(1e16)` == "1e+16", `str(Decimal("1E+2"))` == "1E+2") or a
53//! non-finite value ("inf"/"nan") — raises **ValueError**, exactly like
54//! Python's `int("1e+16")`. Corpus-pinned for 1e+16, 1e+20, `Decimal("1E+2")`
55//! and `Decimal("1E+20")` across cardinal/ordinal/year.
56//!
57//! `to_ordinal` (cardinal + "-chi", no verify_ordinal) and `to_year`
58//! (`to_cardinal`, `longval` ignored) inherit all of the above through their
59//! own float entries; `to_ordinal_num` is `str(number) + "."` with no checks,
60//! so floats keep their repr ("5.0.", "-0.0.", "1e+16.").
61//!
62//! Strings: `str_to_number` is Base's `Decimal(value)`, but a parse that
63//! yields an *infinity* is intercepted and turned into the `ValueError` that
64//! Python's `int("Infinity")` raises inside `to_cardinal` — the binding would
65//! otherwise raise Base's OverflowError (`int(Decimal('Infinity'))`), which is
66//! wrong for this self-contained class. See [`Lang::str_to_number`] below for
67//! the off-corpus caveat (`to_ordinal_num("Infinity")`).
68//!
69//! # Currency
70//!
71//! `Num2Word_UZ` defines its own `CURRENCY_FORMS` (three codes: UZS, USD, EUR)
72//! as a **class attribute on itself**, so it does not share — and is not
73//! polluted by — the `Num2Word_EUR` dict that `Num2Word_EN.__init__` mutates in
74//! place. Verified against the live interpreter: `CONVERTER_CLASSES["uz"].
75//! CURRENCY_FORMS` has exactly those three keys, and `CURRENCY_ADJECTIVES` /
76//! `CURRENCY_PRECISION` are both `{}` (inherited from `Num2Word_Base`, never
77//! written). So `currency_adjective` stays `None` and `currency_precision`
78//! stays 100 for *every* code — including JPY and KWD, which UZ has no entry
79//! for and no precision override for. Both trait defaults already do that, so
80//! neither is overridden below.
81//!
82//! `to_currency` is overridden outright by UZ and shares nothing with
83//! `Num2Word_Base.to_currency`: no `parse_currency_parts`, no `divisor`, no
84//! `pluralize`, no `CURRENCY_ADJECTIVES`. `to_cheque` is **not** overridden, so
85//! it runs `Num2Word_Base.to_cheque` — which is what makes the two paths
86//! disagree about unknown codes (quirks 7 and 8 below).
87//!
88//! `pluralize` is never reached: UZ's `to_currency` picks its form by hand
89//! (`cr1[1] if left != 1 else cr1[0]`) and `Num2Word_Base.to_cheque` takes
90//! `cr1[-1]` unconditionally. It therefore stays at the trait default, which
91//! raises — exactly as `Num2Word_Base.pluralize` does.
92//!
93//! # Faithfully reproduced Python quirks
94//!
95//! This is a port, not a rewrite. All of the following look wrong but are
96//! exactly what Python emits:
97//!
98//! 1. **`_int_to_word` gives up at 10^9 and returns the bare digits.** The
99//! final `else` is `return str(number) # Fallback for very large numbers`.
100//! So `to_cardinal(10**9)` == "1000000000" (a numeral, not words) and
101//! `to_ordinal(10**9)` == "1000000000-chi". Verified against corpus rows
102//! for 10^9, 1234567890, 10^10, 10^12, 10^15, 10^18 and 10^21 — the value
103//! is unbounded, hence `BigInt` and `to_string()` rather than any cast.
104//! Negative inputs below -10^9 compose as "minus 1000000000": `to_cardinal`
105//! strips the sign, `_int_to_word` stringifies the magnitude, and the
106//! `negword` prefix is re-attached. (No corpus row covers that; see the
107//! report's `concerns`.)
108//! 2. **Zero is the English "zero", not an Uzbek word.** `_int_to_word` does
109//! `return self.ones[0] if self.ones[0] else "zero"`, and `ones[0]` is the
110//! empty string (falsy), so the fallback always wins: `to_cardinal(0)` ==
111//! "zero" and `to_ordinal(0)` == "zero-chi". The `self.ones[0]` arm is
112//! dead code. Uzbek for zero is "nol".
113//! 3. **The hundreds digit is always spelled out**, so 100 == "bir yuz"
114//! rather than a bare "yuz" ("hundreds_val" is 1..=9 and never suppressed).
115//! 4. **`pointword` is the untranslated English "point"**, not an Uzbek word,
116//! and is used raw (UZ never titles). Reached only by the float path
117//! ([`LangUz::to_cardinal_float`]).
118//! 5. **The ordinal suffix is applied to the whole cardinal with a hyphen and
119//! no agreement logic**: `to_ordinal(n) == to_cardinal(n) + "-chi"`. That
120//! means the sign leaks in too — `to_ordinal(-1)` == "minus bir-chi" — and
121//! the suffix lands on the digit fallback for large values (quirk 1).
122//! 6. **`to_ordinal_num` is `str(number) + "."`**, a period rather than the
123//! "-chi"/"-inchi" abbreviation an Uzbek reader would expect, and it does
124//! not reject negatives: `to_ordinal_num(-1)` == "-1.". Note `Num2Word_Base.
125//! verify_ordinal` is never called by UZ, so no negative/float guard runs on
126//! any of the ordinal paths.
127//! 7. **`to_currency` never raises for an unknown code.** It does
128//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
129//! — a `.get` with a *default*, not a subscript — so GBP, JPY, KWD, BHD,
130//! INR, CNY and CHF all silently render as Uzbek som. The corpus pins this:
131//! `currency:JPY 12.34` is "o'n ikki so'm o'ttiz to'rt tiyin", not a
132//! `NotImplementedError` and not yen.
133//! 8. **`to_cheque` *does* raise for those same codes**, because UZ does not
134//! override it and `Num2Word_Base.to_cheque` subscripts
135//! `self.CURRENCY_FORMS[currency]` inside a `try/except KeyError`. So
136//! `cheque:GBP` raises `NotImplementedError` while `currency:GBP` quietly
137//! prints som — the two entry points disagree about the same code. Both are
138//! in the corpus; both are reproduced.
139//! 9. **`CURRENCY_PRECISION` is ignored by `to_currency` entirely.** UZ slices
140//! two decimal digits out of the string no matter the currency, so the
141//! 3-decimal (KWD/BHD) and 0-decimal (JPY) conventions never apply:
142//! `currency:KWD 12.34` is 34 tiyin (not 340 fils) and `currency:JPY 12.34`
143//! shows a subunit at all (base would have rounded to a whole yen). The
144//! inherited `to_cheque` *does* read `CURRENCY_PRECISION`, but UZ's is `{}`,
145//! so it resolves to 100 for every code it accepts.
146//! 10. **A float with zero cents drops the cents segment**, because `right` is
147//! an `int` and `if cents and right` makes 0 falsy. So `1.0` renders "bir
148//! euro" — identical to the int `1` — even though `base.to_currency` goes to
149//! great lengths to keep floats showing "... 00 cents". UZ's string-slicing
150//! rewrite loses the int/float distinction that `isinstance(val, int)`
151//! exists to preserve.
152//! 11. **`cents=False` drops the segment outright** rather than falling back to
153//! the terse `"%02d"` digits: `_cents_terse` is never called, so
154//! `to_currency(12.34, cents=False)` is "o'n ikki euros", losing the .34.
155//! 12. **A float extreme enough for `repr` to go scientific raises
156//! `ValueError`.** `str(1e16)` is "1e+16", which has no "." to split on, so
157//! `int(parts[0])` gets fed "1e+16" and dies. Verified: `to_currency(1e16)`
158//! raises `ValueError: invalid literal for int() with base 10: '1e+16'`.
159//! Reproduced here because `BigDecimal`'s `Display` happens to keep the
160//! scientific form for large magnitudes, so `BigInt::from_str` fails the
161//! same way. **Known gap at the other end of the range** — see the
162//! `str(val)` note below.
163//! 13. **`adjective=True` is accepted and ignored** — UZ has no
164//! `CURRENCY_ADJECTIVES` and never calls `prefix_currency`.
165//!
166//! # Errors
167//!
168//! None of the four integer modes can raise for integer input: there is no
169//! `MAXVAL` (so no `OverflowError`), no dict lookup (so no `KeyError`), and
170//! every list index is bounded by the branch that guards it (so no
171//! `IndexError`). `Result` is returned only to satisfy the trait.
172//!
173//! The currency surface has exactly two failure modes: `NotImplementedError`
174//! from the inherited `to_cheque` on an unknown code (quirk 8), and
175//! `ValueError` from `int()` on a scientific-notation float (quirk 12).
176//! `to_currency` itself cannot raise `NotImplementedError` at all (quirk 7).
177//!
178//! # Known gap: `to_currency` depends on `str(val)`, which the boundary drops
179//!
180//! UZ is the awkward case for the `CurrencyValue` contract. `base.to_currency`
181//! only ever does *arithmetic* on its Decimal, so passing `str(value)` across
182//! the boundary and re-parsing it into a `BigDecimal` is lossless for it. UZ
183//! instead re-stringifies the value (`parts = str(val).split(".")`), which
184//! makes its output depend on the value's exact **textual** form — and
185//! `BigDecimal::to_string()` is not Python's `str()`.
186//!
187//! They agree on every corpus row and on every ordinary magnitude, and they
188//! coincidentally agree on large floats (both fail, quirk 12). They disagree
189//! for a float whose `repr` goes scientific *below* 1e-4:
190//!
191//! | input | Python | here |
192//! |---|---|---|
193//! | `1e-05` (float) | `ValueError` — `str` is "1e-05" | "zero euros" — Display is "0.00001" |
194//! | `Decimal("0.00001")` | "zero euros" — `str` is "0.00001" | "zero euros" |
195//!
196//! Note the two Python rows are the *same number* and differ only in type, so
197//! this is not something the language file can repair: `CurrencyValue::Decimal`
198//! carries neither the original string nor a float-vs-`Decimal` tag, and
199//! reconstructing `repr(float)` here would be both the second
200//! shortest-round-trip implementation `currency.rs` exists to avoid and wrong
201//! for the `Decimal` row. No corpus row reaches it (the smallest float is
202//! 0.01). Flagged rather than hacked around.
203
204use crate::base::{Lang, N2WError, Result};
205use crate::currency::{CurrencyForms, CurrencyValue};
206use crate::floatpath::{float2tuple, FloatValue};
207use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
208use num_bigint::BigInt;
209use num_integer::Integer;
210use num_traits::{One, Signed, ToPrimitive, Zero};
211use std::collections::HashMap;
212use std::str::FromStr;
213
214/// `self.negword`. The trailing space is load-bearing: `to_cardinal` does
215/// `ret = self.negword` and then `ret + self._int_to_word(...)` with no
216/// separator of its own, so the space here is what separates "minus" from the
217/// number. (The base's `to_cardinal` would `.strip()` it and re-add a space;
218/// UZ's override does not, and the final `.strip()` only trims the ends.)
219const NEGWORD: &str = "minus ";
220
221/// `self.pointword`. Float path only — unreachable for integer input.
222const POINTWORD: &str = "point";
223
224/// `self.ones`. Index 0 is `""`; see quirk 2 — it is only ever read by the
225/// dead `if self.ones[0]` test, never as a word.
226const ONES: [&str; 10] = [
227 "", "bir", "ikki", "uch", "to'rt", "besh", "olti", "yetti", "sakkiz", "to'qqiz",
228];
229
230/// `self.tens`. Index 0 is `""` and is unreachable: the tens branch is guarded
231/// by `10 <= number < 100`, so `number // 10` is 1..=9.
232const TENS: [&str; 10] = [
233 "", "o'n", "yigirma", "o'ttiz", "qirq", "ellik", "oltmish", "yetmish", "sakson", "to'qson",
234];
235
236const HUNDRED: &str = "yuz";
237const THOUSAND: &str = "ming";
238const MILLION: &str = "million";
239
240/// The "zero" literal from `_int_to_word`'s falsy-`ones[0]` fallback.
241const ZERO_WORD: &str = "zero";
242
243/// The `-chi` suffix `to_ordinal` glues onto the cardinal.
244const ORDINAL_SUFFIX: &str = "-chi";
245
246/// `Num2Word_UZ.to_currency`'s own default `separator=" "`. Confirmed against
247/// the interpreter: `Num2Word_UZ.to_currency.__defaults__` is
248/// `("UZS", True, " ", False)`.
249const DEFAULT_SEPARATOR: &str = " ";
250
251/// `Num2Word_Base.to_currency`'s default `separator=","`, which the pyo3
252/// binding substitutes when the Python caller omits the kwarg.
253///
254/// `__init__.py`'s Rust fast path sends `kwargs.get("separator", ",")` — the
255/// **base** default, not UZ's — so by the time the trait sees a separator, a
256/// caller who said nothing is indistinguishable from one who explicitly asked
257/// for `","`. [`LangUz::to_currency`] reads this value back as the "unset"
258/// sentinel it is; see the note there.
259const BASE_DEFAULT_SEPARATOR: &str = ",";
260
261/// `self.__class__.__name__`, for the `to_cheque` NotImplementedError message.
262const LANG_NAME: &str = "Num2Word_UZ";
263
264/// Narrow a `BigInt` that the caller has already bounded to 0..=9 into a
265/// list index.
266///
267/// Every call site is inside a branch that has proven `number < 10`, or takes
268/// `number / 10` (resp. `/ 100`) under `number < 100` (resp. `< 1000`), so the
269/// value always fits. The `expect` documents that invariant rather than
270/// guarding a reachable path.
271fn digit(n: &BigInt) -> usize {
272 n.to_usize()
273 .expect("callers bound this to 0..=9 before indexing ONES/TENS")
274}
275
276/// `Num2Word_UZ._int_to_word`.
277///
278/// Recursion is shallow and bounded: the thousands branch recurses on
279/// `number // 1000 < 1000` and the millions branch on `number // 10**6 < 1000`,
280/// so neither can re-enter a branch at or above its own magnitude, and the
281/// 10^9 fallback is only ever reached at the top level.
282fn int_to_word(number: &BigInt) -> String {
283 if number.is_zero() {
284 // Python: `self.ones[0] if self.ones[0] else "zero"`. ones[0] == ""
285 // is falsy, so this is unconditionally "zero" (quirk 2).
286 return ZERO_WORD.to_string();
287 }
288
289 if number.is_negative() {
290 // Faithful, but dead from `to_cardinal`, which strips the sign before
291 // calling in. Reachable in Python only via a direct `_int_to_word`
292 // call (or `to_currency`, out of scope).
293 return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
294 }
295
296 let ten = BigInt::from(10u32);
297 let hundred = BigInt::from(100u32);
298 let thousand = BigInt::from(1_000u32);
299 let million = BigInt::from(1_000_000u32);
300 let billion = BigInt::from(1_000_000_000u32);
301
302 if number < &ten {
303 return ONES[digit(number)].to_string();
304 }
305
306 if number < &hundred {
307 // Python `//` and `%`; operands are non-negative here, so floor and
308 // truncating division agree. div_mod_floor matches Python regardless.
309 let (tens_val, ones_val) = number.div_mod_floor(&ten);
310 if ones_val.is_zero() {
311 return TENS[digit(&tens_val)].to_string();
312 }
313 return format!("{} {}", TENS[digit(&tens_val)], ONES[digit(&ones_val)]);
314 }
315
316 if number < &thousand {
317 let (hundreds_val, remainder) = number.div_mod_floor(&hundred);
318 // hundreds_val is 1..=9, so ONES[..] is never the empty string:
319 // 100 -> "bir yuz", never a bare "yuz" (quirk 3).
320 let mut result = format!("{} {}", ONES[digit(&hundreds_val)], HUNDRED);
321 if !remainder.is_zero() {
322 result.push(' ');
323 result.push_str(&int_to_word(&remainder));
324 }
325 return result;
326 }
327
328 if number < &million {
329 let (thousands_val, remainder) = number.div_mod_floor(&thousand);
330 let mut result = format!("{} {}", int_to_word(&thousands_val), THOUSAND);
331 if !remainder.is_zero() {
332 result.push(' ');
333 result.push_str(&int_to_word(&remainder));
334 }
335 return result;
336 }
337
338 if number < &billion {
339 let (millions_val, remainder) = number.div_mod_floor(&million);
340 let mut result = format!("{} {}", int_to_word(&millions_val), MILLION);
341 if !remainder.is_zero() {
342 result.push(' ');
343 result.push_str(&int_to_word(&remainder));
344 }
345 return result;
346 }
347
348 // `return str(number) # Fallback for very large numbers` (quirk 1).
349 number.to_string()
350}
351
352/// Python `str(float)`/`repr(float)` — needed only for the reprs that carry
353/// **no** decimal point (scientific notation and non-finite values), whose
354/// text lands verbatim in the `int()` ValueError message
355/// (`invalid literal for int() with base 10: '1e+16'`). Dotted fixed-form
356/// reprs never come through here: [`Lang::cardinal_float_entry`] routes them
357/// into the float grammar without stringifying.
358fn py_float_str(f: f64) -> String {
359 if f.is_nan() {
360 return "nan".to_string();
361 }
362 if f.is_infinite() {
363 return if f.is_sign_positive() {
364 "inf".to_string()
365 } else {
366 "-inf".to_string()
367 };
368 }
369 let a = f.abs();
370 if a != 0.0 && (a >= 1e16 || a < 1e-4) {
371 // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
372 let s = format!("{:e}", f);
373 if let Some((m, e)) = s.split_once('e') {
374 let (sign, digits) = match e.strip_prefix('-') {
375 Some(d) => ("-", d.to_string()),
376 None => ("+", e.to_string()),
377 };
378 let digits = if digits.len() < 2 {
379 format!("0{}", digits)
380 } else {
381 digits
382 };
383 return format!("{}e{}{}", m, sign, digits);
384 }
385 s
386 } else if f.fract() == 0.0 {
387 // repr keeps the trailing ".0" that Rust's `{}` would drop.
388 format!("{:.1}", f)
389 } else {
390 format!("{}", f)
391 }
392}
393
394/// The ValueError Python's `int(n)` raises on a non-numeric string — the
395/// failure mode of `to_cardinal`'s dot-less branch for scientific reprs.
396/// Python strips a leading "-" from `n` before calling `int()`, so the
397/// quoted literal never carries the sign.
398fn int_value_error(repr_no_dot: &str) -> N2WError {
399 let unsigned = repr_no_dot.strip_prefix('-').unwrap_or(repr_no_dot);
400 N2WError::Value(format!(
401 "invalid literal for int() with base 10: '{}'",
402 unsigned
403 ))
404}
405
406pub struct LangUz {
407 /// `Num2Word_UZ.CURRENCY_FORMS`. Built once in [`LangUz::new`] and only
408 /// read afterwards: the generated registry parks each language in a
409 /// `OnceLock` and calls `new` through `get_or_init`, so this is
410 /// constructed once per process, not once per conversion.
411 currency_forms: HashMap<&'static str, CurrencyForms>,
412 /// `list(self.CURRENCY_FORMS.values())[0]` — the default `to_currency`'s
413 /// `.get` falls back to for an unknown code (quirk 7).
414 ///
415 /// Python re-evaluates that expression on every call, and under CPython's
416 /// insertion-ordered dicts it resolves to the **first key in the class
417 /// body**, which for UZ is `UZS`. A `HashMap` has no first element, so the
418 /// choice is pinned here rather than left to iteration order. Confirmed
419 /// against the interpreter — `list(CURRENCY_FORMS.values())[0]` is
420 /// `(("so'm", "so'm"), ("tiyin", "tiyin"))` — and against the corpus, where
421 /// `currency:GBP 1` is "bir so'm". (Note `pprint` sorts dict keys and
422 /// displays EUR first; that is a display artefact, not the real order.)
423 fallback_forms: CurrencyForms,
424}
425
426impl LangUz {
427 pub fn new() -> Self {
428 let mut currency_forms = HashMap::new();
429 // Insertion order is irrelevant to a HashMap but the *first* Python key
430 // is not — see `fallback_forms`. Listed in class-body order so the two
431 // can be compared by eye. Arity is load-bearing: `to_currency` indexes
432 // `cr1[0]`/`cr1[1]` and `cr2[0]`/`cr2[1]`, and the inherited
433 // `to_cheque` takes `cr1[-1]`, so both forms of both tuples must
434 // survive verbatim — including UZS's, where the two are identical
435 // ("so'm" has no distinct plural).
436 currency_forms.insert(
437 "UZS",
438 CurrencyForms::new(&["so'm", "so'm"], &["tiyin", "tiyin"]),
439 );
440 currency_forms.insert(
441 "USD",
442 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
443 );
444 currency_forms.insert(
445 "EUR",
446 CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
447 );
448 let fallback_forms = currency_forms
449 .get("UZS")
450 .expect("CURRENCY_FORMS[\"UZS\"] is inserted directly above")
451 .clone();
452 LangUz {
453 currency_forms,
454 fallback_forms,
455 }
456 }
457}
458
459impl Default for LangUz {
460 fn default() -> Self {
461 LangUz::new()
462 }
463}
464
465impl Lang for LangUz {
466 /// This language's own `to_currency(currency=...)` default,
467 /// read from the live Python signature. Only 44 of 156 use EUR.
468 fn default_currency(&self) -> &str {
469 "UZS"
470 }
471
472 /// This language's own `to_currency(separator=...)` default,
473 /// read from the live Python signature. Base's is ",", but only
474 /// 36 of 149 languages actually use it — most default to " " or a
475 /// conjunction, so inheriting Base's comma silently corrupts them.
476 fn default_separator(&self) -> &str {
477 " "
478 }
479
480 fn negword(&self) -> &str {
481 NEGWORD
482 }
483
484 fn pointword(&self) -> &str {
485 "point"
486 }
487
488 /// `Num2Word_UZ.to_cardinal`.
489 ///
490 /// Python works on `str(number).strip()`, peels a leading "-" into `ret`,
491 /// and — for integer input, where `"." in n` is always false — returns
492 /// `(ret + self._int_to_word(int(n))).strip()`. `int(n)` is therefore the
493 /// magnitude, and `_int_to_word`'s own negative branch is never taken from
494 /// here.
495 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
496 let (ret, magnitude) = if value.is_negative() {
497 (NEGWORD, value.abs())
498 } else {
499 ("", value.clone())
500 };
501
502 // The trailing `.strip()`. `int_to_word` never returns an empty string
503 // (0 yields "zero"), so this only ever no-ops, but it is what Python
504 // does.
505 Ok(format!("{}{}", ret, int_to_word(&magnitude))
506 .trim()
507 .to_string())
508 }
509
510 /// `Num2Word_UZ.to_ordinal`: the cardinal with "-chi" glued on (quirk 5).
511 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
512 let cardinal = self.to_cardinal(value)?;
513 Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
514 }
515
516 /// `Num2Word_UZ.to_ordinal_num`: `str(number) + "."` (quirk 6). No
517 /// `verify_ordinal` call, so negatives pass straight through.
518 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
519 Ok(format!("{}.", value))
520 }
521
522 // to_year is `self.to_cardinal(val)` (longval ignored) == the trait
523 // default, which dispatches through `&self` and picks up the override
524 // above. Not re-implemented.
525
526 /// `Num2Word_UZ.to_cardinal`'s float/`pointword` branch, split out of the
527 /// integer `to_cardinal` because the Rust core routes `&BigInt` and
528 /// `&FloatValue` through separate trait hooks.
529 ///
530 /// Python:
531 ///
532 /// ```python
533 /// n = str(number).strip()
534 /// if n.startswith("-"):
535 /// n = n[1:]; ret = self.negword # "minus "
536 /// else:
537 /// ret = ""
538 /// if "." in n: # this branch
539 /// left, right = n.split(".", 1)
540 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
541 /// for digit in right:
542 /// ret += self._int_to_word(int(digit)) + " "
543 /// return ret.strip()
544 /// ```
545 ///
546 /// Three faithful choices, all confirmed against the corpus and the live
547 /// interpreter:
548 ///
549 /// * **The fraction is spelled digit by digit** (`for digit in right`), each
550 /// through `_int_to_word` — so `.34` is "uch to'rt" (three four), never
551 /// "o'ttiz to'rt" (thirty-four). This happens to coincide with
552 /// `Num2Word_Base.to_cardinal_float`'s per-digit spelling, but the sign
553 /// and integer-part handling below do not, so the default is still wrong.
554 /// * **The integer part is `_int_to_word(int(left))`**, i.e. the *absolute*
555 /// value's digits — Python strips the "-" before splitting — so this uses
556 /// `pre.abs()`, not the signed `pre` that `Num2Word_Base` feeds to
557 /// `to_cardinal`. `_int_to_word` (not `to_cardinal`) also means the digit
558 /// fallback (quirk 1) applies: `str(98746251323029.99)` → integer part
559 /// "98746251323029".
560 /// * **The sign follows `str(number).startswith("-")`**, i.e. the IEEE sign
561 /// bit, so `-0.0` (str "-0.0") is negative and renders "minus zero point
562 /// zero" even though `-0.0 < 0.0` is false. That is `is_sign_negative()`
563 /// on the raw f64 — and it is the one negword divergence from
564 /// `Num2Word_Base`, which keys on `value < 0` and so drops the sign for
565 /// `-0.0`. The negword is always prepended for a negative here (Python
566 /// sets `ret = self.negword` up front), not only when the integer part is
567 /// zero.
568 ///
569 /// `right` (Python's fractional digit string) is reconstructed from
570 /// `float2tuple`'s `post`, zero-padded to `precision`. `precision` is by
571 /// construction `len(right)` — `abs(Decimal(str(f)).as_tuple().exponent)`
572 /// for floats, the Decimal scale otherwise — so the padded string equals
573 /// `right`. Keeping `float2tuple`'s f64 arithmetic (rather than re-deriving
574 /// from a decimal string) preserves the load-bearing `< 0.01` rescue
575 /// heuristic: `2.675` → `674.999…` → `675` → "olti yetti besh", exactly as
576 /// the corpus pins it.
577 ///
578 /// `precision=` (issue #580 → `precision_override`) is **inert** for UZ:
579 /// `Num2Word_UZ.to_cardinal(self, number)` takes no `precision` argument and
580 /// never reads `self.precision`. Confirmed live — `num2words(2.675, 'uz',
581 /// precision=2)` and `precision=0` both give "ikki point olti yetti besh".
582 /// The argument is accepted and ignored.
583 ///
584 /// # Scientific-notation reprs never reach this method
585 ///
586 /// `str(1e16)` is "1e+16" (no "." → `int("1e+16")` → ValueError) and
587 /// `str(1e-05)` is "1e-05"; Python's `int()` dies either way. That
588 /// routing now lives in [`Lang::cardinal_float_entry`], which reproduces
589 /// Python's repr boundary (`abs(value) >= 1e16` or `0 < abs(value) <
590 /// 1e-4` goes scientific) and raises the ValueError *before* this float
591 /// grammar runs — so every value that arrives here has a dotted repr.
592 ///
593 /// # Known off-corpus gap (flagged, not repaired)
594 ///
595 /// * **Large-magnitude fractional digits.** For a float big enough that
596 /// `(value - trunc(value)) * 10**precision` loses precision (e.g.
597 /// `959926189324.9778`), `str()`'s shortest-round-trip digits ("…9778")
598 /// and `float2tuple`'s arithmetic ("…9777") differ in the last digit.
599 /// `float2tuple` matches `Num2Word_Base` here, not `str()`; no corpus
600 /// float exceeds 1234.56, so it is not exercised.
601 fn to_cardinal_float(
602 &self,
603 value: &FloatValue,
604 _precision_override: Option<u32>,
605 ) -> Result<String> {
606 // precision is `len(right)` by construction; UZ ignores precision=.
607 let precision = value.precision() as usize;
608
609 // (pre, post): pre = int(number) truncated toward zero (signed);
610 // post = the fractional digits as a non-negative integer.
611 let (pre, post) = float2tuple(value);
612
613 // `str(number).startswith("-")` == the IEEE sign bit, so -0.0 is
614 // negative. The Decimal arm keeps BigDecimal's own sign.
615 let is_negative = match value {
616 FloatValue::Float { value, .. } => value.is_sign_negative(),
617 FloatValue::Decimal { value, .. } => value.is_negative(),
618 };
619
620 // Build the words and join with single spaces — Python concatenates
621 // "minus " / word / " point " / "<digit> " fragments and `.strip()`s
622 // the trailing space, which is exactly a single-space join.
623 let mut words: Vec<String> = Vec::new();
624 if is_negative {
625 // `ret = self.negword` ("minus "); after the join it is just "minus".
626 words.push(NEGWORD.trim().to_string());
627 }
628 // `_int_to_word(int(left))` on the absolute integer part.
629 words.push(int_to_word(&pre.abs()));
630
631 if precision > 0 {
632 // `self.pointword`, used raw (UZ never titles; is_title is false).
633 // POINTWORD == self.pointword() ("point") for UZ.
634 words.push(POINTWORD.to_string());
635
636 // right = post, zero-padded on the left to `precision` digits. post
637 // < 10**precision, so this is exactly `precision` ASCII digits.
638 let post_digits = post.to_string();
639 let right = format!(
640 "{}{}",
641 "0".repeat(precision.saturating_sub(post_digits.len())),
642 post_digits
643 );
644 // `for digit in right: _int_to_word(int(digit))`.
645 for ch in right.chars() {
646 let d = ch.to_digit(10).ok_or_else(|| {
647 N2WError::Value(format!("non-digit {:?} in fractional part", ch))
648 })?;
649 words.push(int_to_word(&BigInt::from(d)));
650 }
651 }
652
653 Ok(words.join(" "))
654 }
655
656 // ---- float/Decimal entries -------------------------------------------
657 //
658 // Python's dispatcher hands a float/Decimal straight to the converter
659 // method, and UZ's own `to_cardinal` decides where it lands by looking at
660 // `str(number)` — NOT by base's `int(value) == value` assert. The trait
661 // default (whole -> int path) is therefore wrong for every whole float:
662 // `to_cardinal(5.0)` is "besh point zero", never "besh".
663
664 /// `Num2Word_UZ.to_cardinal`'s `"." in str(number)` routing, whole values
665 /// included:
666 ///
667 /// * dotted repr -> the `pointword` branch ([`Self::to_cardinal_float`]),
668 /// so 5.0 -> "besh point zero" and -0.0 -> "minus zero point zero";
669 /// * dot-less digits (`Decimal("5")`, `Decimal("-3")`) -> `int(n)` ->
670 /// the integer path, identical to [`Self::to_cardinal`];
671 /// * dot-less non-digits — scientific reprs ("1e+16", "1E+2") and
672 /// non-finite values ("inf", "nan") — -> `int(n)` raises **ValueError**.
673 ///
674 /// The float repr boundary is CPython's: fixed form iff the value is zero
675 /// or `1e-4 <= |v| < 1e16` (both literals exact in f64; `1e-4` parses to
676 /// the nearest double *above* decimal 1e-4, matching repr's decision bit
677 /// for bit). Every finite fixed-form float shows a ".", so the boundary
678 /// test doubles as the `"." in n` test without building the repr.
679 fn cardinal_float_entry(
680 &self,
681 value: &FloatValue,
682 precision_override: Option<u32>,
683 ) -> Result<String> {
684 match value {
685 FloatValue::Float { value: f, .. } => {
686 let a = f.abs();
687 if f.is_finite() && (*f == 0.0 || (a >= 1e-4 && a < 1e16)) {
688 self.to_cardinal_float(value, precision_override)
689 } else {
690 Err(int_value_error(&py_float_str(*f)))
691 }
692 }
693 FloatValue::Decimal { value: d, .. } => {
694 // `str(Decimal)` — python_decimal_str is byte-exact, including
695 // the plain/E±n selection that decides this branch.
696 let s = python_decimal_str(d);
697 if s.contains('.') {
698 self.to_cardinal_float(value, precision_override)
699 } else if s.contains('E') {
700 // "1E+2", "1E+20", "1E-7": no dot, int() dies.
701 Err(int_value_error(&s))
702 } else {
703 // Plain integer string -> scale 0 -> exact whole value.
704 let i = value
705 .as_whole_int()
706 .expect("dot-less, E-less Decimal repr is integral");
707 self.to_cardinal(&i)
708 }
709 }
710 }
711 }
712
713 /// `Num2Word_UZ.to_ordinal(float/Decimal)`: the cardinal with "-chi"
714 /// glued on (quirk 5) — **no** `verify_ordinal`, so negatives and
715 /// fractions pass straight through ("minus bir point besh-chi") and the
716 /// scientific-repr ValueError propagates before the suffix is reached.
717 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
718 let cardinal = self.cardinal_float_entry(value, None)?;
719 Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
720 }
721
722 /// `Num2Word_UZ.to_ordinal_num(float/Decimal)`: `str(number) + "."` with
723 /// no checks (quirk 6), so the repr survives verbatim — "5.0.", "-0.0.",
724 /// "1e+16.", "5.00.".
725 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
726 Ok(format!("{}.", repr_str))
727 }
728
729 /// `Num2Word_UZ.to_year(float/Decimal)`: `self.to_cardinal(val)` with
730 /// `longval` ignored — i.e. exactly the cardinal entry above.
731 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
732 self.cardinal_float_entry(value, None)
733 }
734
735 // ---- string inputs ---------------------------------------------------
736
737 /// `converter.str_to_number` is Base's `Decimal(value)` — UZ does not
738 /// override it — but an *infinity* parse must not surface as Base's
739 /// OverflowError (`int(Decimal('Infinity'))`): UZ's `to_cardinal` never
740 /// calls `int()` on the Decimal, it calls `int(str(number))`, and
741 /// `int("Infinity")` raises **ValueError**. Corpus-pinned:
742 /// `num2words("Infinity", lang="uz")` / `"-Infinity"` are ValueError.
743 /// The message quotes the sign-stripped literal, as `to_cardinal` peels
744 /// a leading "-" before `int()`.
745 ///
746 /// Off-corpus caveat, flagged not repaired: Python's
747 /// `to_ordinal_num(Decimal('Infinity'))` would return "Infinity." (no
748 /// int() involved), which this early raise cannot reproduce — the parse
749 /// result routes through one shared entry. No corpus row reaches it
750 /// (only `to="cardinal"` Infinity rows exist).
751 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
752 match python_decimal_parse(s)? {
753 ParsedNumber::Inf { .. } => Err(N2WError::Value(
754 "invalid literal for int() with base 10: 'Infinity'".into(),
755 )),
756 other => Ok(other),
757 }
758 }
759
760 // ---- currency ------------------------------------------------------
761
762 fn lang_name(&self) -> &str {
763 LANG_NAME
764 }
765
766 /// `Num2Word_UZ.CURRENCY_FORMS[code]`, as a *subscript* — a miss is `None`.
767 ///
768 /// This is only reached from the inherited `to_cheque`, which is what makes
769 /// `cheque:GBP` raise `NotImplementedError` (quirk 8). `to_currency` below
770 /// deliberately does **not** route through here: it uses `.get` with a
771 /// fallback and so must still see a miss as som, not as an error (quirk 7).
772 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
773 self.currency_forms.get(code)
774 }
775
776 // currency_adjective: CURRENCY_ADJECTIVES is {} -> always None == default.
777 // currency_precision: CURRENCY_PRECISION is {} -> .get(code, 100) is always
778 // 100 == default. Not overridden; see quirk 9.
779 // pluralize: unreachable from either currency path, so the raising default
780 // stands, matching Num2Word_Base.pluralize.
781 // money_verbose / cents_verbose / cents_terse: UZ inherits all three from
782 // Num2Word_Base. Only _money_verbose is ever called (by to_cheque), and
783 // the trait default already forwards to self.to_cardinal, which resolves
784 // to UZ's override above.
785 // to_cheque: not overridden by UZ, so the trait default -- a port of
786 // Num2Word_Base.to_cheque -- is exactly right. It reads currency_forms
787 // (subscript semantics, above), currency_precision (100) and
788 // money_verbose, all of which now resolve correctly for UZ.
789
790 /// Port of `Num2Word_UZ.to_currency`.
791 ///
792 /// UZ ignores `base.to_currency` wholesale and slices the decimal *string*:
793 ///
794 /// ```python
795 /// parts = str(val).split(".")
796 /// left = int(parts[0]) if parts[0] else 0
797 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
798 /// ```
799 ///
800 /// Because the branch is on the *text* rather than on `isinstance(val,
801 /// int)`, the int/float distinction collapses on its own: `1` and `1.0`
802 /// both reach `right == 0` and both print "bir euro" (quirk 10). The
803 /// `CurrencyValue` split is still honoured — it decides whether `str(val)`
804 /// has a "." at all — it simply stops mattering downstream.
805 ///
806 /// # The `separator` argument is not what Python's default would be
807 ///
808 /// UZ's own signature defaults to `separator=" "`, but the trait cannot
809 /// carry a per-language default argument: `__init__.py` resolves it before
810 /// the call and substitutes `Num2Word_Base`'s `","`. The frozen corpus was
811 /// generated through Python with no `separator=` kwarg — so UZ's `" "`
812 /// applied, and `currency:EUR 12.34` is "o'n ikki euros o'ttiz to'rt cents"
813 /// with a space. `","` is therefore read back as the "unset" sentinel it
814 /// is. Exact for a caller who omits `separator` and for one who passes
815 /// anything other than `","`; wrong only for an explicit `separator=","`,
816 /// which Python renders as "o'n ikki euros,o'ttiz to'rt cents" (no space —
817 /// the separator replaces it) and this renders with a space. `lang_br.rs`
818 /// and `lang_bs.rs` take the same approach for the same reason. See
819 /// `concerns`.
820 fn to_currency(
821 &self,
822 val: &CurrencyValue,
823 currency: &str,
824 cents: bool,
825 separator: Option<&str>,
826 adjective: bool,
827 ) -> Result<String> {
828 // Trait now hands us None when the caller omitted separator=;
829 // resolve it to this language's own default before the ported body.
830 let separator = separator.unwrap_or(self.default_separator());
831 // Python accepts `adjective` and never reads it (quirk 13).
832 let _ = adjective;
833
834 let separator = if separator == BASE_DEFAULT_SEPARATOR {
835 DEFAULT_SEPARATOR
836 } else {
837 separator
838 };
839
840 // `if val < 0: is_negative = True; val = abs(val)` runs *before*
841 // `str(val)`, so the string never carries a sign. The abs() is
842 // conditional in Python, so it stays conditional here.
843 let is_negative = val.is_negative();
844 let s = match val {
845 CurrencyValue::Int(v) => {
846 if is_negative {
847 v.abs().to_string()
848 } else {
849 v.to_string()
850 }
851 }
852 CurrencyValue::Decimal { value: d, .. } => {
853 if is_negative {
854 d.abs().to_string()
855 } else {
856 d.to_string()
857 }
858 }
859 };
860
861 // `str(val).split(".")`. Python splits on every dot and then reads only
862 // parts[0] and parts[1], so any trailing fragment is ignored; taking
863 // the first two off the iterator is the same thing.
864 let mut parts = s.split('.');
865 let part0 = parts.next().unwrap_or("");
866 let part1 = parts.next();
867
868 // `int(parts[0]) if parts[0] else 0`. Evaluated before `right`, so a
869 // scientific-notation string raises here first (quirk 12). ValueError
870 // is what Python's int() raises, hence N2WError::Value.
871 let left = if part0.is_empty() {
872 BigInt::zero()
873 } else {
874 BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
875 };
876
877 // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
878 //
879 // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50 cents,
880 // not 5) and "01" -> "01" (0.01 is 1). Sliced by chars, not bytes.
881 // A whole float gives "0" -> "00" -> 0, which is falsy (quirk 10).
882 let right = match part1 {
883 Some(f) if !f.is_empty() => {
884 let mut two: String = f.chars().take(2).collect();
885 while two.chars().count() < 2 {
886 two.push('0');
887 }
888 BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
889 }
890 _ => BigInt::zero(),
891 };
892
893 // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — quirk 7.
894 // Deliberately not `self.currency_forms(currency)`: that hook has
895 // subscript semantics for to_cheque's sake.
896 let forms = self
897 .currency_forms
898 .get(currency)
899 .unwrap_or(&self.fallback_forms);
900 let cr1 = &forms.unit;
901 let cr2 = &forms.subunit;
902
903 let one = BigInt::one();
904
905 // `left_str = self._int_to_word(left)` — `_int_to_word`, not
906 // `to_cardinal`, so none of to_cardinal's str()/strip() wrapper runs.
907 // `left` is already non-negative, so int_to_word's negative branch is
908 // unreachable from here.
909 //
910 // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`. Note
911 // 0 takes the *plural*: "zero euros".
912 let mut result = format!(
913 "{} {}",
914 int_to_word(&left),
915 if left != one { &cr1[1] } else { &cr1[0] }
916 );
917
918 // `if cents and right:` — `right` is an int, so 0 is falsy and a float
919 // with zero cents drops the whole segment (quirk 10). `cents=False`
920 // drops it too, with no terse fallback (quirk 11).
921 if cents && !right.is_zero() {
922 result.push_str(separator);
923 result.push_str(&int_to_word(&right));
924 result.push(' ');
925 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
926 }
927
928 // `result = self.negword + result` — raw, so NEGWORD's trailing space
929 // is what separates it from the number.
930 if is_negative {
931 result = format!("{}{}", NEGWORD, result);
932 }
933
934 // `result.strip()`. A no-op for every reachable input, but it is what
935 // Python writes.
936 Ok(result.trim().to_string())
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use bigdecimal::BigDecimal;
944
945 fn f(value: f64, precision: u32) -> FloatValue {
946 FloatValue::Float { value, precision }
947 }
948
949 fn d(s: &str) -> FloatValue {
950 // `precision` mirrors what the shim computes
951 // (`abs(Decimal(str(value)).as_tuple().exponent)`); by construction it
952 // is the digit count after the dot.
953 let value = BigDecimal::from_str(s).unwrap();
954 let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
955 FloatValue::Decimal { value, precision }
956 }
957
958 fn go(v: &FloatValue) -> String {
959 LangUz::new().to_cardinal_float(v, None).unwrap()
960 }
961
962 /// Every `"to": "cardinal"` row with a dot in `arg` that the frozen corpus
963 /// records for uz, verbatim. `precision` is the fractional digit count of
964 /// the arg text, which for these shortest-repr floats equals
965 /// `abs(Decimal(str(f)).as_tuple().exponent)`.
966 #[test]
967 fn corpus_cardinal_float_rows() {
968 for (value, precision, out) in [
969 (0.0, 1, "zero point zero"),
970 (0.5, 1, "zero point besh"),
971 (1.0, 1, "bir point zero"),
972 (1.5, 1, "bir point besh"),
973 (2.25, 2, "ikki point ikki besh"),
974 (3.14, 2, "uch point bir to'rt"),
975 (0.01, 2, "zero point zero bir"),
976 (0.1, 1, "zero point bir"),
977 (0.99, 2, "zero point to'qqiz to'qqiz"),
978 (1.01, 2, "bir point zero bir"),
979 (12.34, 2, "o'n ikki point uch to'rt"),
980 (99.99, 2, "to'qson to'qqiz point to'qqiz to'qqiz"),
981 (100.5, 1, "bir yuz point besh"),
982 (1234.56, 2, "bir ming ikki yuz o'ttiz to'rt point besh olti"),
983 (-0.5, 1, "minus zero point besh"),
984 (-1.5, 1, "minus bir point besh"),
985 (-12.34, 2, "minus o'n ikki point uch to'rt"),
986 // The f64-artefact rows: 1.005 -> 4.99999999999989 and
987 // 2.675 -> 674.9999999999998, both rescued by float2tuple's
988 // `< 0.01` heuristic, exactly as in Python.
989 (1.005, 3, "bir point zero zero besh"),
990 (2.675, 3, "ikki point olti yetti besh"),
991 ] {
992 assert_eq!(go(&f(value, precision)), out, "float {}", value);
993 }
994 }
995
996 /// Every `"to": "cardinal_dec"` row the frozen corpus records for uz,
997 /// verbatim — including the trillion-scale one that pins both the exact
998 /// Decimal arm (no f64 rounding of .99) and `_int_to_word`'s bare-digit
999 /// fallback at >= 10^9 (quirk 1).
1000 #[test]
1001 fn corpus_cardinal_dec_rows() {
1002 for (arg, out) in [
1003 ("0.01", "zero point zero bir"),
1004 ("1.10", "bir point bir zero"),
1005 ("12.345", "o'n ikki point uch to'rt besh"),
1006 ("98746251323029.99", "98746251323029 point to'qqiz to'qqiz"),
1007 ("0.001", "zero point zero zero bir"),
1008 ] {
1009 assert_eq!(go(&d(arg)), out, "decimal {}", arg);
1010 }
1011 }
1012
1013 /// Off-corpus cases traced against the pure-Python converter
1014 /// (`CONVERTER_CLASSES['uz'].to_cardinal(...)`, bypassing the dispatcher's
1015 /// Rust fast path).
1016 #[test]
1017 fn traced_against_pure_python() {
1018 // str(-0.0) is "-0.0", so the sign survives: the IEEE sign bit, not
1019 // `< 0` (which -0.0 fails), decides the negword.
1020 assert_eq!(go(&f(-0.0, 1)), "minus zero point zero");
1021 // Integer part past 10^9: _int_to_word's digit fallback (quirk 1)
1022 // applies to the float path's integer part too.
1023 assert_eq!(go(&f(1234567890.5, 1)), "1234567890 point besh");
1024 // Large magnitude where float2tuple takes the floor branch
1025 // (67.1875 is not within 0.01 of an integer) and still agrees with
1026 // Python's str()-derived digits.
1027 assert_eq!(
1028 go(&f(123456789012345.67, 2)),
1029 "123456789012345 point olti yetti"
1030 );
1031 }
1032
1033 /// `precision=` is inert for UZ: `Num2Word_UZ.to_cardinal` takes no such
1034 /// argument and never reads `self.precision`. Confirmed live —
1035 /// `num2words(2.675, 'uz', precision=0)` == `precision=2` == no kwarg.
1036 #[test]
1037 fn precision_override_is_ignored() {
1038 let uz = LangUz::new();
1039 let v = f(2.675, 3);
1040 let expect = "ikki point olti yetti besh";
1041 assert_eq!(uz.to_cardinal_float(&v, None).unwrap(), expect);
1042 assert_eq!(uz.to_cardinal_float(&v, Some(2)).unwrap(), expect);
1043 assert_eq!(uz.to_cardinal_float(&v, Some(0)).unwrap(), expect);
1044 }
1045}