num2words2_core/lang_mn.rs
1//! Port of `lang_MN.py` (Mongolian).
2//!
3//! Shape: **self-contained**. `Num2Word_MN` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`, so Python never populates `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives `_int2word` over
7//! 3-digit chunks produced by `utils.splitbyx`. `cards`/`maxval`/`merge`
8//! therefore stay at their trait defaults here, and there is **no overflow
9//! check** — the only ceiling is the `THOUSANDS` table (keys 1..=22), which
10//! raises `KeyError` rather than `OverflowError` for values >= 10^69.
11//!
12//! `setup()` sets `negword = "хасах"`; everything else in the four in-scope
13//! modes is defined locally, so nothing is inherited from `Num2Word_Base`
14//! except the class scaffolding. All four modes (`to_cardinal`, `to_ordinal`,
15//! `to_ordinal_num`, `to_year`) are overridden by MN itself.
16//!
17//! # The `all_suffixed` flag
18//!
19//! Mongolian numerals carry two forms: a *free/final* form ("арав", "хорь",
20//! "зуу", "гурав") and an *attributive/suffixed* form ("арван", "хорин",
21//! "зуун", "гурван"). `_int2word` normally emits the suffixed form everywhere
22//! except on the **last** word of the **final** (i == 0) chunk, which takes the
23//! free form. `all_suffixed=True` — used only by `to_year` and the currency
24//! path — suppresses that exception, so every word keeps its suffixed form.
25//!
26//! `to_cardinal(1234)` == "нэг мянга хоёр зуун гучин дөрөв" (free "дөрөв")
27//! `to_year(1234)` == "нэг мянга хоёр зуун гучин дөрвөн он" (suffixed)
28//!
29//! # Faithfully reproduced Python quirks
30//!
31//! This is a port, not a rewrite. The following look wrong but are exactly
32//! what Python emits:
33//!
34//! 1. **`all_suffixed` is dropped across the negative recursion.**
35//! `_int2word` line 180 reads
36//! `return " ".join([self.negword, self._int2word(abs(n))])` — it does
37//! *not* forward `all_suffixed`, so `to_cardinal(-1234, all_suffixed=True)`
38//! silently produces the *unsuffixed* "хасах нэг мянга хоёр зуун гучин
39//! дөрөв". Reachable through the `all_suffixed` kwarg (the kwargs corpus
40//! pins `to_cardinal(-5, all_suffixed=True)` == "хасах тав", not "таван")
41//! and inherited by the currency path; unreachable from `to_year`, which
42//! takes `abs()` and prefixes "МЭӨ " before calling.
43//! 2. **`to_ordinal(0)` / `to_ordinal_num(0)` raise `IndexError`.**
44//! `_get_ordinal_suffix` tests `number_str[-1] != "0"`; for "0" that is
45//! false, so it falls to the `elif number_str[-2] != "0"` arm and indexes
46//! position -2 of a 1-character string. Zero is the *only* input that can
47//! trigger this: every other value ending in "0" has at least two
48//! characters, and no negative renders as "-0". See [`ordinal_suffix`].
49//! 3. **The ordinal suffix only inspects the last two digits, and only the
50//! tens digit when the units digit is 0.** So 40 → "дөч дүгээр" (tens is
51//! 4) but 100 → "зуу дугаар" (tens is 0, so the `elif` guard fails and the
52//! default "дугаар" survives — the hundreds digit is never consulted).
53//! 4. **The "мянга" → "мянган" fixup is a literal last-word string compare.**
54//! `if all_suffixed and words and words[-1] == "мянга"`. It fires for
55//! `to_year(1000)` → "нэг мянган он" and `to_year(2000)` → "хоёр мянган
56//! он", but not for `to_year(1005)` (last word is "таван"). No other
57//! `THOUSANDS` entry gets an equivalent fixup.
58//! 5. **`ONES[9][0]` is "ес" but `ONES[9][1]` is "есөн"** — an irregular pair
59//! (most others just append -н/-ан). Kept verbatim, as is `TWENTIES[8]`
60//! ("ная"/"наян") and `ONES[4]` ("дөрөв"/"дөрвөн", with the vowel dropping
61//! out of the stem).
62//! 6. **`to_currency` on an `int` ignores the currency entirely.** The method
63//! opens with `if isinstance(val, int): return self.to_cardinal(val)` — no
64//! table lookup, no unit word, no `NotImplementedError`. So
65//! `to_currency(100, "BHD")` is "зуу" (a bare cardinal) even though BHD is
66//! absent from `CURRENCY_FORMS`, while `to_currency(12.34, "BHD")` raises.
67//! The corpus pins both halves of that split. See [`LangMn::to_currency`].
68//! 7. **The 1/100 divisor is hardcoded, `CURRENCY_PRECISION` is never read.**
69//! `to_currency` computes `(Decimal(str(val)) * 100) % 1` and calls
70//! `parse_currency_parts` without a `divisor=`, taking its default of 100.
71//! `Num2Word_MN` leaves `CURRENCY_PRECISION` at Base's empty dict, so the
72//! two agree today at 100 for every code — but the hardcoding is what the
73//! source says, and it is why JPY renders сен at 1/100 here rather than
74//! taking `base.to_currency`'s zero-decimal shortcut. See [`DIVISOR`].
75//! 8. **`cents=False` emits `str(right)`, not `_cents_terse(right)`.** So a
76//! sub-unit under ten prints unpadded — 0.04 gives "4" where Base gives
77//! "04". [`Lang::cents_terse`] is consequently dead code for Mongolian and
78//! is left at its trait default.
79//! 9. **The cents segment is gated on `right > 0` alone.** MN never computes
80//! Base's `has_decimal` guard, so `1.0` renders "нэг евро" with no cents
81//! tail — the same output an `int` 1 would give by quirk 6, but reached by
82//! a different path.
83//! 10. **`CURRENCY_ADJECTIVES["CZK"]` is `"Чехийн "` with a trailing space**,
84//! and `prefix_currency` joins with `"%s %s"` — so CZK renders the double
85//! space "Чехийн крон". Reproduced verbatim; no corpus row covers CZK.
86//! 11. **`CURRENCY_FORMS["KWD"]` is `"динaр"` with a LATIN SMALL LETTER A**
87//! (U+0061) where the Cyrillic а (U+0430) belongs. It survives `.upper()`
88//! as a Latin "A", which is why the cheque corpus expects "ДИНAР". The
89//! tables here were generated from the live interpreter rather than
90//! transcribed, so the mixed script is preserved byte for byte.
91//!
92//! # Error variants
93//!
94//! Mongolian raises two exception types that the four-way mapping in
95//! PORTING.md (Overflow/Type/NotImplemented/ZeroDivision) does not cover:
96//! `IndexError` (quirk 2 above) and `KeyError` (`THOUSANDS[i]` for i >= 23,
97//! i.e. values >= 10^69). Both are Python *crashes* rather than deliberate
98//! raises, but the exception type is observable, so parity means reproducing
99//! it rather than tidying it into a `TypeError`. See [`index_error`] and
100//! [`key_error`].
101//!
102//! # The float/`POINT_WORDS` branch of `to_cardinal`
103//!
104//! `Num2Word_MN` overrides **`to_cardinal`** (not `to_cardinal_float`) and
105//! handles a non-integer inline. The algorithm is *not* Base's `float2tuple`
106//! path: it reads the **string** `n = str(value).replace(",", ".")`, splits on
107//! ".", and renders
108//! `"%s, %s %s" % (_int2word(int(left)), POINT_WORDS[len(right)],
109//! _int2word(int(right)))`, keyed on the *number of decimal places*. So MN
110//! **cannot** inherit either [`Lang::to_cardinal_float`] or
111//! [`Lang::cardinal_from_decimal`] from Base — the default emits "жаран долоо
112//! (.) тав" (Base's untranslated `pointword`) where MN says "жаран долоо,
113//! аравны тав". Both are overridden below, along with the full entry
114//! [`Lang::cardinal_float_entry`], because MN's `to_cardinal` owns the
115//! whole-value routing too (it never runs Base's `int(value) == value` test).
116//!
117//! Because the algorithm keys on `str(value)`, the f64 artefacts that
118//! `floatpath.rs` reproduces do **not** apply: `str(2.675)` is the literal
119//! "2.675", so `right == "675"` with no `674.9999…`/`< 0.01` rescue involved.
120//! [`mn_value_str`] reconstructs Python's `str(value)`: the f64 arm via
121//! [`python_float_str`] (`format!("{:.p$}", f)` — Python already supplied
122//! `precision` as the repr's fractional-digit count — switching to `1e+16`
123//! exponent form at the same thresholds CPython's repr uses), the Decimal arm
124//! exactly via `strnum::python_decimal_str`, so a `Decimal("1.10")` keeps its
125//! trailing zero and 98746251323029.99 keeps every digit (issue #603).
126//! [`mn_to_cardinal_str`] then runs the Python method verbatim on that string.
127//!
128//! Python quirks in this branch, all reproduced:
129//!
130//! * **`int(right) == 0` returns before the `len(right) > 6` raise.** So
131//! `Decimal("1.00000000")` is "нэг" (all-zero fraction, integer early
132//! return) while `Decimal("1.10000000")` raises `NotImplementedError` (eight
133//! non-zero decimal places). The early return is `_int2word(int(float(n)))`,
134//! which re-casts through f64 and so is signed but lossy for huge integers.
135//! * **`str(value)` with no "." hits the integer `else` and its `int(str)`
136//! raises `ValueError`** — `invalid literal for int() with base 10: '…'` —
137//! for every value whose repr is scientific or non-finite: floats >= 1e16
138//! (`str(1e16) == "1e+16"`), Decimals Python renders in E-notation
139//! (`Decimal("1e3")` prints "1E+3", `Decimal("1E-7")` prints "1E-7"), and
140//! `Decimal("Infinity")`/`"NaN"`. The corpus pins 1e+16/1e+20/1E+2/1E+20 and
141//! the "Infinity" strings. So MN's ceiling on the cardinal *float* path is
142//! 1e16, while ints run to the `THOUSANDS` KeyError at 10^69 — and
143//! `to_ordinal(1e+16)` still works, because it truncates the float without
144//! ever stringifying it (see below).
145//!
146//! # Float/Decimal entries for the other three modes
147//!
148//! `to_ordinal`/`to_ordinal_num` open with `number = int(value)` — plain
149//! truncation toward zero, no string inspection — so `to_ordinal(2.5)` is
150//! "хоёр дугаар", `to_ordinal(1e+16)` is "арван тунамал дугаар" (no
151//! ValueError: the float is never stringified), and `to_ordinal(±0.0)`
152//! truncates to 0 and hits the quirk-2 IndexError. [`mn_trunc_int`] ports the
153//! `int()` cast, including `int(inf)` -> OverflowError / `int(nan)` ->
154//! ValueError for completeness (the dispatcher filters non-finite floats
155//! before the core is reached).
156//!
157//! `to_year` tests **`value < 0`** — a numeric comparison, unlike the
158//! cardinal's `n.startswith("-")` — so `to_year(-0.0)` takes **no** "МЭӨ "
159//! prefix; the "-" of "-0.0" then reaches the cardinal, whose `int(right)==0`
160//! early return drops it: "тэг он". A genuinely negative value is `abs()`ed
161//! *before* `to_cardinal(value, all_suffixed=True)`, so "МЭӨ нэг, аравны
162//! таван он" carries no "хасах". And because `to_year` re-enters the
163//! stringifying cardinal, `to_year(1e+16)` **does** raise the ValueError that
164//! `to_ordinal(1e+16)` avoids.
165//!
166//! # String inputs and the `Infinity` interception
167//!
168//! MN does not override `str_to_number`; Base's `Decimal(value)` applies. But
169//! the binding hardwires `ParsedNumber::Inf` to OverflowError — the base-
170//! language outcome of `int(Decimal("Infinity"))` — before any language hook
171//! runs, while MN's `to_cardinal` reaches `int("Infinity")` and raises
172//! **ValueError** instead. The only interception point the core owns is
173//! [`Lang::str_to_number`], so MN's override turns the Inf parse itself into
174//! that ValueError. Known residual: a *non-cardinal* mode fed the string
175//! "Infinity" would raise OverflowError in Python (via `int(Decimal)`), but
176//! gets this ValueError here — no corpus row exercises that combination.
177//!
178//! # Grammatical kwargs
179//!
180//! `to_cardinal(value, all_suffixed=False)` is the only extended signature
181//! (`to_ordinal`/`to_ordinal_num`/`to_year` take no extras; `to_currency`
182//! adds nothing beyond the standard four). `to_cardinal_kw` and
183//! `to_cardinal_float_kw` accept exactly `all_suffixed`, applying Python
184//! truthiness (None/0/""/[] are falsy — the source only ever tests the flag).
185//! `to_cardinal(-5, all_suffixed=True)` is "хасах тав": quirk 1 drops the
186//! flag across the negative recursion.
187
188use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
189use crate::currency::{parse_currency_parts, prefix_currency, CurrencyForms, CurrencyValue};
190use crate::floatpath::FloatValue;
191use crate::strnum::{python_decimal_parse, python_decimal_str, python_int_parse, ParsedNumber};
192use bigdecimal::BigDecimal;
193use num_bigint::BigInt;
194use num_traits::{Signed, ToPrimitive, Zero};
195use std::collections::HashMap;
196
197const ZERO: &str = "тэг";
198
199/// `setup()`: `self.negword = "хасах"`.
200const NEGWORD: &str = "хасах";
201
202/// `to_year`'s BCE prefix. The trailing space is part of the literal.
203const BCE_PREFIX: &str = "МЭӨ ";
204
205/// `to_year`'s trailing unit word.
206const YEAR_WORD: &str = "он";
207
208/// `ONES`: (free form, suffixed form). Index 0 is absent in Python — every
209/// read is guarded by `n1 > 0` or `n3 > 1`, so the placeholder is never used.
210const ONES: [(&str, &str); 10] = [
211 ("", ""),
212 ("нэг", "нэг"),
213 ("хоёр", "хоёр"),
214 ("гурав", "гурван"),
215 ("дөрөв", "дөрвөн"),
216 ("тав", "таван"),
217 ("зургаа", "зургаан"),
218 ("долоо", "долоон"),
219 ("найм", "найман"),
220 ("ес", "есөн"),
221];
222
223/// `TEN`: (free, suffixed). Used when the tens digit is exactly 1.
224const TEN: (&str, &str) = ("арав", "арван");
225
226/// `TWENTIES`: (free, suffixed), keys 2..=9 in Python. Indices 0 and 1 are
227/// absent there; reads are guarded by `n2 > 1`.
228const TWENTIES: [(&str, &str); 10] = [
229 ("", ""),
230 ("", ""),
231 ("хорь", "хорин"),
232 ("гуч", "гучин"),
233 ("дөч", "дөчин"),
234 ("тавь", "тавин"),
235 ("жар", "жаран"),
236 ("дал", "далан"),
237 ("ная", "наян"),
238 ("ер", "ерэн"),
239];
240
241/// `HUNDRED`: (free, suffixed).
242const HUNDRED: (&str, &str) = ("зуу", "зуун");
243
244/// `THOUSANDS`: chunk index → scale word, keys 1..=22 in Python (10^3 through
245/// 10^66). Index 0 is a placeholder — `THOUSANDS[i]` is only read under
246/// `if i > 0`. A chunk index of 23 or more is a `KeyError`, which is
247/// Mongolian's de facto (and rather abrupt) MAXVAL: a value with 70+ decimal
248/// digits, i.e. >= 10^69, splits into 24+ chunks and blows up on the first one.
249const THOUSANDS: [&str; 23] = [
250 "", // unused (i == 0 never reaches the lookup)
251 "мянга", // 10^3
252 "сая", // 10^6
253 "тэрбум", // 10^9
254 "их наяд", // 10^12
255 "тунамал", // 10^15
256 "их ингүүмэл", // 10^18
257 "ялгаруулагч", // 10^21
258 "их өөр дээр", // 10^24
259 "хязгаар үзэгдэл", // 10^27
260 "их шалтгааны зүйл", // 10^30
261 "эрхт", // 10^33
262 "их сайтар хүргэсэн", // 10^36
263 "живэх тоосон билэг", // 10^39
264 "их билэг тэмдэг", // 10^42
265 "тохио мэдэхүй", // 10^45
266 "их тийн болсон", // 10^48
267 "асрахуй", // 10^51
268 "их нигүүлсэнгүй", // 10^54
269 "тоолшгүй", // 10^57
270 "өгүүлшгүй", // 10^60
271 "үлэшгүй", // 10^63
272 "сэтгэшгүй", // 10^66
273];
274
275/// The `THOUSANDS[1]` value, and the `words[-1]` needle for the `all_suffixed`
276/// fixup in `_int2word`.
277const THOUSAND_FREE: &str = "мянга";
278
279/// What `THOUSAND_FREE` becomes when `all_suffixed` is set and it lands last.
280const THOUSAND_SUFFIXED: &str = "мянган";
281
282/// The default ordinal suffix.
283const ORD_DUGAAR: &str = "дугаар";
284
285/// The ordinal suffix for numbers whose significant final digit is 1, 4 or 9
286/// (units digit), or 4 or 9 (tens digit, when the units digit is 0).
287const ORD_DUGEER: &str = "дүгээр";
288
289/// `POINT_WORDS`: the connective between whole and fractional part, keyed on
290/// the **number of decimal places** (`len(right)`), 1..=6 in Python. Index 0 is
291/// a placeholder — the fractional branch is only reached with 1..=6 digits (a
292/// 0-digit fraction hits the integer `else`, and >6 raises). This is what makes
293/// the MN float path irreducible to Base's `pointword`: Base emits one
294/// `pointword` then each digit; MN emits *one* scale word chosen by digit count
295/// and the fraction as a whole number. So 0.5 is "аравны тав" (tenths + five),
296/// not "(.) тав".
297const POINT_WORDS: [&str; 7] = [
298 "", // 0 unused
299 "аравны", // 1 tenths
300 "зууны", // 2 hundredths
301 "мянганы", // 3 thousandths
302 "арван мянганы", // 4 ten-thousandths
303 "зуун мянганы", // 5 hundred-thousandths
304 "саяны", // 6 millionths
305];
306
307// These mirror crashes in lang_MN.py, not deliberate raises: the exception
308// *type* is observable behaviour a caller may catch, so parity requires
309// reproducing it rather than tidying it into a TypeError. The ordinal(0)
310// IndexError is now fixed upstream (PR #661), so this helper is retained for
311// its doc reference and any future faithfully-ported crash.
312#[allow(dead_code)]
313fn index_error(msg: &str) -> N2WError {
314 N2WError::Index(msg.to_string())
315}
316
317fn key_error(key: usize) -> N2WError {
318 N2WError::Key(key.to_string())
319}
320
321/// `THOUSANDS[i]`. Python raises `KeyError: i` for i > 22.
322fn thousands(i: usize) -> Result<&'static str> {
323 THOUSANDS.get(i).copied().ok_or_else(|| key_error(i))
324}
325
326/// Port of `utils.splitbyx(n, 3)` with `format_int=True`, for the
327/// non-negative digit strings `_int2word` feeds it.
328///
329/// `_int2word` strips the sign before calling (`if n < 0` returns early), so
330/// unlike some sibling modules this never sees a "-" and never trips the
331/// `int("-")` ValueError that e.g. lang_PL does. `s` is guaranteed to be pure
332/// ASCII digits from `BigInt::to_string()` on a non-negative value, so byte
333/// indexing is safe here.
334///
335/// Chunks are at most 3 digits, hence always in 0..=999 — `u16` is provably
336/// sufficient and no BigInt survives past this point.
337fn splitbyx(s: &str) -> Vec<u16> {
338 let length = s.len();
339 let mut out: Vec<u16> = Vec::new();
340 if length > 3 {
341 let start = length % 3;
342 if start > 0 {
343 out.push(s[..start].parse().expect("ascii digits"));
344 }
345 let mut i = start;
346 while i < length {
347 out.push(s[i..i + 3].parse().expect("ascii digits"));
348 i += 3;
349 }
350 } else {
351 out.push(s.parse().expect("ascii digits"));
352 }
353 out
354}
355
356/// Port of `utils.get_digits(n)`.
357///
358/// Python builds `("%03d" % n)[-3:]`, reverses it, and unpacks to
359/// `n1, n2, n3` — so n1 is the **units** digit, n2 the tens, n3 the hundreds.
360/// Arithmetic gives the identical result for the 0..=999 chunks produced by
361/// [`splitbyx`].
362fn get_digits(x: u16) -> (u16, u16, u16) {
363 (x % 10, (x / 10) % 10, (x / 100) % 10)
364}
365
366/// Port of `Num2Word_MN._int2word`.
367fn int2word(n: &BigInt, all_suffixed: bool) -> Result<String> {
368 if n.is_negative() {
369 // Python: `" ".join([self.negword, self._int2word(abs(n))])`.
370 // `all_suffixed` is NOT forwarded — see quirk 1 in the module docs.
371 return Ok(format!("{} {}", NEGWORD, int2word(&n.abs(), false)?));
372 }
373
374 if n.is_zero() {
375 return Ok(ZERO.to_string());
376 }
377
378 let mut words: Vec<&'static str> = Vec::new();
379 let chunks = splitbyx(&n.to_string());
380
381 // Python: `i = len(chunks)`, then `i -= 1` at the top of each iteration —
382 // so the first chunk sees i == len-1 and the last sees i == 0.
383 let mut i = chunks.len();
384
385 for x in chunks {
386 i -= 1;
387
388 if x == 0 {
389 // Skips the THOUSANDS lookup too, so an all-zero chunk can never
390 // raise KeyError on its own.
391 continue;
392 }
393
394 let (n1, n2, n3) = get_digits(x);
395
396 let (mut use_suffix1, mut use_suffix2, mut use_suffix3) = (true, true, true);
397 if !all_suffixed && i == 0 {
398 // Only the final chunk's *last* emitted word takes the free form.
399 if n1 == 0 && n2 == 0 {
400 use_suffix3 = false;
401 } else if n1 == 0 {
402 use_suffix2 = false;
403 } else {
404 use_suffix1 = false;
405 }
406 }
407
408 if n3 > 0 {
409 if n3 > 1 {
410 words.push(ONES[n3 as usize].1);
411 }
412 words.push(if use_suffix3 { HUNDRED.1 } else { HUNDRED.0 });
413 }
414
415 if n2 == 1 {
416 words.push(if use_suffix2 { TEN.1 } else { TEN.0 });
417 } else if n2 > 1 {
418 let t = TWENTIES[n2 as usize];
419 words.push(if use_suffix2 { t.1 } else { t.0 });
420 }
421
422 if n1 > 0 {
423 let o = ONES[n1 as usize];
424 words.push(if use_suffix1 { o.1 } else { o.0 });
425 }
426
427 if i > 0 {
428 words.push(thousands(i)?);
429 }
430 }
431
432 // Python: `if all_suffixed and words and words[-1] == "мянга":
433 // words[-1] = "мянган"`.
434 // A literal last-word compare; see quirk 4 in the module docs. The `words`
435 // emptiness guard is dead code in Python (n == 0 returned early and every
436 // other n has at least one non-zero chunk), but is kept for fidelity.
437 if all_suffixed {
438 if let Some(last) = words.last_mut() {
439 if *last == THOUSAND_FREE {
440 *last = THOUSAND_SUFFIXED;
441 }
442 }
443 }
444
445 Ok(words.join(" "))
446}
447
448/// Port of `Num2Word_MN._get_ordinal_suffix`.
449///
450/// Reproduces the `number_str[-2]` IndexError on "0" (quirk 2) and the
451/// two-digit-only inspection window (quirk 3).
452fn ordinal_suffix(number: &BigInt) -> Result<&'static str> {
453 let number_str = number.to_string();
454 // ASCII digits plus a possible leading '-', but index by chars() anyway
455 // per the porting contract — never assume byte offsets line up.
456 let chars: Vec<char> = number_str.chars().collect();
457
458 let mut suffix = ORD_DUGAAR;
459
460 // `number_str` is never empty: BigInt::to_string() always yields at least
461 // one character, so [-1] cannot itself raise.
462 let last = chars[chars.len() - 1];
463
464 if last != '0' {
465 if last == '1' || last == '4' || last == '9' {
466 suffix = ORD_DUGEER;
467 }
468 } else if chars.len() > 1 {
469 // PR savoirfairelinux/num2words#661: Python's `elif number_str[-2]`
470 // indexed out of range on the single-character string "0"; guard the
471 // tens-digit inspection with `len(number_str) > 1` so to_ordinal(0)
472 // falls through to the default "дугаар" suffix instead of crashing.
473 let second_last = chars[chars.len() - 2];
474 if second_last != '0' && (second_last == '4' || second_last == '9') {
475 suffix = ORD_DUGEER;
476 }
477 // Note the nesting: when the tens digit IS '0' the default "дугаар"
478 // survives untouched — the hundreds digit is never consulted, so
479 // to_ordinal(100) == "зуу дугаар" and to_ordinal(1000) ==
480 // "нэг мянга дугаар".
481 }
482
483 Ok(suffix)
484}
485
486/// `int(float(n))` — Python's `int()` truncates toward zero. Reproduced by
487/// truncating the f64 then formatting with 0 fractional digits (`{:.0}` never
488/// uses scientific notation, so this survives large magnitudes). `f.trunc()`
489/// yields an integer-valued double, so the format is exact rather than rounded.
490fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
491 format!("{:.0}", f.trunc())
492 .parse::<BigInt>()
493 .map_err(|e| N2WError::Value(e.to_string()))
494}
495
496/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the count of
497/// fractional digits in Python's shortest round-trip `repr`. Rust's `{}` for
498/// f64 is the same shortest round-trip contract, so counting the digits after
499/// the point matches. Mirrors the private `floatpath::float_repr_precision`,
500/// reimplemented here because that one is not `pub`.
501fn float_repr_precision(f: f64) -> u32 {
502 let s = format!("{}", f);
503 match s.split_once('.') {
504 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
505 _ => 0,
506 }
507}
508
509/// Python's `str(value)` for the f64 arm — `repr(float)`: shortest
510/// round-trip digits, fixed-point with at least one fractional digit,
511/// switching to exponent form at CPython's thresholds (decimal exponent
512/// >= 16 or < -4: `str(1e16)` is "1e+16", `str(0.00001)` is "1e-05", but
513/// `str(0.0001)` is "0.0001").
514fn python_float_str(f: f64, precision: u32) -> String {
515 if f.is_nan() {
516 // str(float("nan")) is "nan" whatever the sign bit says.
517 return "nan".to_string();
518 }
519 if f.is_infinite() {
520 return if f.is_sign_negative() { "-inf" } else { "inf" }.to_string();
521 }
522 if f != 0.0 {
523 // Rust {:e} is shortest-round-trip "d[.ddd]e<exp>", the same digit
524 // string CPython's repr computes. CPython picks exponent form iff
525 // exp >= 16 or exp < -4, then always signs the exponent and pads it
526 // to at least two digits ("1e+16", "1e-05").
527 let sci = format!("{:e}", f);
528 let (mant, exp) = sci.split_once('e').expect("LowerExp always has an e");
529 let exp: i32 = exp.parse().expect("exponent is an integer");
530 if !(-4..16).contains(&exp) {
531 return format!(
532 "{}e{}{:02}",
533 mant,
534 if exp < 0 { "-" } else { "+" },
535 exp.abs()
536 );
537 }
538 }
539 // Fixed-point. `precision` is Python's fractional-digit count of this
540 // very repr (`abs(Decimal(str(number)).as_tuple().exponent)`, computed by
541 // the binding), so formatting to that many places recovers the repr
542 // exactly. repr never prints fewer than one fractional digit ("21.0",
543 // "-0.0"), hence the max(1) backstop.
544 format!("{:.*}", precision.max(1) as usize, f)
545}
546
547/// Python's `n = str(value)` — the string `Num2Word_MN.to_cardinal` routes
548/// on. The Decimal arm is `strnum::python_decimal_str`, byte-exact including
549/// E-notation ("1E+3", "1E-7") and preserved trailing zeros ("5.00").
550fn mn_value_str(v: &FloatValue) -> String {
551 match v {
552 FloatValue::Float { value, precision } => python_float_str(*value, *precision),
553 FloatValue::Decimal { value, .. } => python_decimal_str(value),
554 }
555}
556
557/// Port of `Num2Word_MN.to_cardinal(value, all_suffixed=...)` — the whole
558/// method, both arms, running on the reconstructed `str(value)`.
559fn mn_to_cardinal_str(n: &str, all_suffixed: bool) -> Result<String> {
560 // Python: `n = str(value).replace(",", ".")` — no reconstructed repr ever
561 // contains a comma, so the replace is a no-op here.
562 if n.contains('.') {
563 // is_negative = n.startswith("-"); the sign-bit test upstream matches
564 // this for a signed zero too (str(-0.0) == "-0.0"), though the sign
565 // is then dropped by the int(right)==0 branch, exactly as in Python.
566 let is_negative = n.starts_with('-');
567 let abs_n = if is_negative { &n[1..] } else { n };
568 let (left, right) = abs_n.split_once('.').expect("checked: contains '.'");
569
570 // `if int(right) == 0:` runs BEFORE the `len(right) > 6` raise, so
571 // Decimal("1.00000000") is "нэг" rather than NotImplementedError.
572 // `right` is pure ASCII digits from a repr, so the parse cannot fail.
573 let right_int = right
574 .parse::<BigInt>()
575 .map_err(|e| N2WError::Value(e.to_string()))?;
576 if right_int.is_zero() {
577 // `return self._int2word(int(float(n)), all_suffixed=...)` —
578 // re-cast through f64: signed, truncated toward zero, and lossy
579 // for huge integers, exactly as in Python.
580 let f: f64 = n
581 .parse()
582 .map_err(|e: std::num::ParseFloatError| N2WError::Value(e.to_string()))?;
583 return int2word(&f64_trunc_to_bigint(f)?, all_suffixed);
584 }
585
586 // `fractional_length = len(right); if fractional_length > 6: raise
587 // NotImplementedError()` — empty message, matching the bare raise.
588 if right.len() > 6 {
589 return Err(N2WError::NotImplemented(String::new()));
590 }
591
592 let left_int = left
593 .parse::<BigInt>()
594 .map_err(|e| N2WError::Value(e.to_string()))?;
595 let body = format!(
596 "{}, {} {}",
597 int2word(&left_int, all_suffixed)?,
598 POINT_WORDS[right.len()],
599 int2word(&right_int, all_suffixed)?,
600 );
601 return Ok(if is_negative {
602 // Python: `self.negword + " " + result`.
603 format!("{} {}", NEGWORD, body)
604 } else {
605 body
606 });
607 }
608 // `else: return self._int2word(int(n), ...)`. int(str) accepts an
609 // optional sign plus digits — scientific reprs ("1e+16", "1E+2") and
610 // non-finite ones ("inf", "Infinity", "nan") raise the ValueError the
611 // corpus pins, with Python's exact message.
612 match python_int_parse(n) {
613 Some(i) => int2word(&i, all_suffixed),
614 None => Err(N2WError::Value(format!(
615 "invalid literal for int() with base 10: '{}'",
616 n
617 ))),
618 }
619}
620
621/// `Num2Word_MN.to_cardinal` on a float/Decimal, `all_suffixed` threaded.
622fn mn_to_cardinal(v: &FloatValue, all_suffixed: bool) -> Result<String> {
623 mn_to_cardinal_str(&mn_value_str(v), all_suffixed)
624}
625
626/// Python's `int(value)` on a float/Decimal — the opening line of MN's
627/// `to_ordinal`/`to_ordinal_num`. Truncates toward zero; `int(inf)` raises
628/// OverflowError and `int(nan)` ValueError (unreachable from the dispatcher,
629/// which keeps non-finite floats on the Python side, but ported anyway).
630fn mn_trunc_int(v: &FloatValue) -> Result<BigInt> {
631 match v {
632 FloatValue::Float { value, .. } => {
633 if value.is_nan() {
634 return Err(N2WError::Value(
635 "cannot convert float NaN to integer".into(),
636 ));
637 }
638 if value.is_infinite() {
639 return Err(N2WError::Overflow(
640 "cannot convert float infinity to integer".into(),
641 ));
642 }
643 f64_trunc_to_bigint(*value)
644 }
645 // `with_scale(0)` truncates toward zero, as in floatpath.rs.
646 FloatValue::Decimal { value, .. } => Ok(value.with_scale(0).as_bigint_and_exponent().0),
647 }
648}
649
650/// Python truthiness for the `all_suffixed` kwarg: the source only ever
651/// tests the flag (`if all_suffixed`, `if (not all_suffixed) and i == 0`), so
652/// an explicit None — which Python treats as falsy — behaves like the
653/// default, as do 0/""/[].
654fn kw_truthy(v: Option<&KwVal>) -> bool {
655 match v {
656 None | Some(KwVal::None) => false,
657 Some(KwVal::Bool(b)) => *b,
658 Some(KwVal::Int(i)) => *i != 0,
659 Some(KwVal::Str(s)) => !s.is_empty(),
660 Some(KwVal::List(l)) => !l.is_empty(),
661 }
662}
663
664/// The sub-unit divisor `to_currency` uses for **every** code.
665///
666/// `Num2Word_MN.to_currency` hardcodes it twice — `(decimal_val * 100) % 1`
667/// and the `divisor=100` default it lets `parse_currency_parts` supply — and
668/// never consults `CURRENCY_PRECISION`. See quirk 7 in the module docs.
669const DIVISOR: i64 = 100;
670
671/// `Num2Word_MN.CURRENCY_FORMS`, 36 codes.
672///
673/// MN declares this as its own class attribute, so it is **not** the shared
674/// `Num2Word_EUR` dict that `Num2Word_EN.__init__` mutates in place: the
675/// lang_EUR trap in PORTING_CURRENCY.md does not apply here. Verified against
676/// the live interpreter — MN's EUR is `("евро",)`, not English's
677/// `("euro", "euros")`, and EN's ~24 added codes (BHD, OMR, ...) are absent.
678///
679/// Every entry is a **1-tuple** on both sides. That arity is load-bearing:
680/// `pluralize` returns `form[0]` unconditionally, so a second form would be
681/// dead — but adding one would still change nothing, and dropping the tuple
682/// shape would. Kept at exactly the arity Python has.
683fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
684 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
685 m.insert("AED", CurrencyForms::new(&["дирхам"], &["филс"]));
686 m.insert("AUD", CurrencyForms::new(&["доллар"], &["цент"]));
687 m.insert("BGN", CurrencyForms::new(&["лев"], &["стотинка"]));
688 m.insert("CAD", CurrencyForms::new(&["доллар"], &["цент"]));
689 m.insert("CHF", CurrencyForms::new(&["франк"], &["раппен"]));
690 m.insert("CNY", CurrencyForms::new(&["юань"], &["фэнь"]));
691 m.insert("CZK", CurrencyForms::new(&["крон"], &["галерж"]));
692 m.insert("DKK", CurrencyForms::new(&["крон"], &["өре"]));
693 m.insert("EGP", CurrencyForms::new(&["фунт"], &["пиастр"]));
694 m.insert("EUR", CurrencyForms::new(&["евро"], &["цент"]));
695 m.insert("GBP", CurrencyForms::new(&["фунт стерлинг"], &["пенс"]));
696 m.insert("HKD", CurrencyForms::new(&["доллар"], &["цент"]));
697 m.insert("HUF", CurrencyForms::new(&["форинт"], &["филлер"]));
698 m.insert("IDR", CurrencyForms::new(&["рупи"], &["сен"]));
699 m.insert("INR", CurrencyForms::new(&["рупи"], &["пайса"]));
700 m.insert("JPY", CurrencyForms::new(&["иен"], &["сен"]));
701 m.insert("KPW", CurrencyForms::new(&["вон"], &["чон"]));
702 m.insert("KRW", CurrencyForms::new(&["вон"], &["чон"]));
703 // "динaр" carries a LATIN "a" (U+0061), not Cyrillic "а" — quirk 11.
704 m.insert("KWD", CurrencyForms::new(&["динaр"], &["филс"]));
705 m.insert("KZT", CurrencyForms::new(&["тенге"], &["тийн"]));
706 m.insert("MNT", CurrencyForms::new(&["төгрөг"], &["мөнгө"]));
707 m.insert("MYR", CurrencyForms::new(&["ринггит"], &["сен"]));
708 m.insert("NOK", CurrencyForms::new(&["крон"], &["өре"]));
709 m.insert("NPR", CurrencyForms::new(&["рупи"], &["пайса"]));
710 m.insert("NZD", CurrencyForms::new(&["доллар"], &["цент"]));
711 m.insert("PLN", CurrencyForms::new(&["злот"], &["грош"]));
712 m.insert("RUB", CurrencyForms::new(&["рубль"], &["копейк"]));
713 m.insert("SEK", CurrencyForms::new(&["крон"], &["өре"]));
714 m.insert("SGD", CurrencyForms::new(&["доллар"], &["цент"]));
715 m.insert("THB", CurrencyForms::new(&["бат"], &["сатанг"]));
716 m.insert("TRY", CurrencyForms::new(&["лира"], &["куруш"]));
717 m.insert("TWD", CurrencyForms::new(&["доллар"], &["сентао"]));
718 m.insert("UAH", CurrencyForms::new(&["гривн"], &["копейк"]));
719 m.insert("USD", CurrencyForms::new(&["доллар"], &["цент"]));
720 m.insert("VND", CurrencyForms::new(&["донг"], &["су"]));
721 m.insert("ZAR", CurrencyForms::new(&["ранд"], &["сента"]));
722 m
723}
724
725/// `Num2Word_MN.CURRENCY_ADJECTIVES`, 16 codes.
726///
727/// Note "Чехийн " — the trailing space is in the Python source and produces a
728/// double-spaced "Чехийн крон" once `prefix_currency` joins with `"%s %s"`.
729/// See quirk 10.
730fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
731 [
732 ("AUD", "Австралийн"),
733 ("CAD", "Канадын"),
734 ("CZK", "Чехийн "),
735 ("DKK", "Данийн"),
736 ("HKD", "Хонконг"),
737 ("IDR", "Индонезийн"),
738 ("INR", "Энэтхэгийн"),
739 ("KPW", "БНАСАУ-ын"),
740 ("KRW", "БНСУ-ын"),
741 ("NOK", "Норвегийн"),
742 ("NPR", "Непалын"),
743 ("NZD", "Шинэ Зеландын"),
744 ("SEK", "Шведийн"),
745 ("SGD", "Сингапур"),
746 ("TWD", "Тайванийн"),
747 ("USD", "Америк"),
748 ]
749 .into_iter()
750 .collect()
751}
752
753pub struct LangMn {
754 currency_forms: HashMap<&'static str, CurrencyForms>,
755 currency_adjectives: HashMap<&'static str, &'static str>,
756}
757
758impl Default for LangMn {
759 fn default() -> Self {
760 Self::new()
761 }
762}
763
764impl LangMn {
765 pub fn new() -> Self {
766 LangMn {
767 // Built once here, never per call. `to_currency` and `to_cheque`
768 // only ever read these; rebuilding 36 entries per conversion is
769 // what made an earlier revision of this port slower than the
770 // Python it replaces.
771 currency_forms: build_currency_forms(),
772 currency_adjectives: build_currency_adjectives(),
773 }
774 }
775}
776
777impl Lang for LangMn {
778 /// `self.pointword`, read from the live Python instance.
779 /// Unused by the four integer modes, so phase 1 never needed
780 /// it — the float path is its first caller.
781 fn pointword(&self) -> &str {
782 "(.)"
783 }
784
785 /// This language's own `to_currency(currency=...)` default,
786 /// read from the live Python signature. Only 44 of 156 use EUR.
787 fn default_currency(&self) -> &str {
788 "MNT"
789 }
790
791 /// This language's own `to_currency(separator=...)` default,
792 /// read from the live Python signature. Base's is ",", but only
793 /// 36 of 149 languages actually use it — most default to " " or a
794 /// conjunction, so inheriting Base's comma silently corrupts them.
795 fn default_separator(&self) -> &str {
796 ","
797 }
798
799 /// `setup()`: `self.negword = "хасах"`.
800 ///
801 /// Unused by the code below — `int2word` reaches for [`NEGWORD`] directly,
802 /// mirroring `_int2word`'s `self.negword` — but overridden so the trait
803 /// default "(-) " never leaks out of this language.
804 fn negword(&self) -> &str {
805 NEGWORD
806 }
807
808 /// Port of `to_cardinal(value)` for integral input.
809 ///
810 /// Python does `n = str(value).replace(",", ".")` and branches on whether
811 /// "." is present. An integer never stringifies with a ".", so control
812 /// always lands in the `else` arm: `self._int2word(int(n))`. The
813 /// `POINT_WORDS` fractional branch above it is out of scope.
814 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
815 int2word(value, false)
816 }
817
818 /// Port of `to_ordinal(value)`:
819 /// `"%s %s" % (self.to_cardinal(number), self._get_ordinal_suffix(number))`.
820 ///
821 /// Python evaluates the cardinal first, so when both would raise (they
822 /// never do for the same input) the cardinal's exception wins. Order is
823 /// preserved here regardless.
824 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
825 let cardinal = self.to_cardinal(value)?;
826 let suffix = ordinal_suffix(value)?;
827 Ok(format!("{} {}", cardinal, suffix))
828 }
829
830 /// Port of `to_ordinal_num(value)`:
831 /// `"%s %s" % (number, self._get_ordinal_suffix(number))`.
832 ///
833 /// `number` is `int(value)`, so it renders as plain decimal digits — the
834 /// sign is kept, giving "-1 дүгээр".
835 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
836 let suffix = ordinal_suffix(value)?;
837 Ok(format!("{} {}", value, suffix))
838 }
839
840 /// Port of `to_year(value)`.
841 ///
842 /// Negatives are turned positive and prefixed "МЭӨ " *before* the cardinal
843 /// call, so `_int2word`'s negative branch — and with it the dropped
844 /// `all_suffixed` of quirk 1 — is unreachable from here.
845 fn to_year(&self, value: &BigInt) -> Result<String> {
846 let mut prefix = "";
847 let mut v = value.clone();
848 if v.is_negative() {
849 v = v.abs();
850 prefix = BCE_PREFIX;
851 }
852 // Python: `self.to_cardinal(value, all_suffixed=True)`, which for an
853 // integer reduces to `_int2word(value, all_suffixed=True)`.
854 Ok(format!("{}{} {}", prefix, int2word(&v, true)?, YEAR_WORD))
855 }
856
857 // ---- currency -------------------------------------------------------
858 //
859 // MN overrides `to_currency`, `_money_verbose`, `_cents_verbose` and
860 // `pluralize`, and inherits `to_cheque` and `_cents_terse` from
861 // `Num2Word_Base`. `CURRENCY_PRECISION` is left at Base's empty dict, so
862 // `currency_precision()` stays at its trait default of 100 for every code
863 // — which is also what `default_to_cheque` reads.
864
865 fn lang_name(&self) -> &str {
866 "Num2Word_MN"
867 }
868
869 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
870 self.currency_forms.get(code)
871 }
872
873 fn currency_adjective(&self, code: &str) -> Option<&str> {
874 self.currency_adjectives.get(code).copied()
875 }
876
877 /// MN's own signature default: `to_currency(..., adjective=True)` —
878 /// the only language in the library that defaults it on.
879 fn default_adjective(&self) -> bool {
880 true
881 }
882
883 /// `Num2Word_MN.pluralize`: `return form[0]` — the count is ignored.
884 ///
885 /// Mongolian does not inflect the noun after a numeral, so every
886 /// `CURRENCY_FORMS` entry is a 1-tuple and this always yields the only
887 /// form. Python would raise IndexError on an empty tuple; that is
888 /// unreachable with MN's table but mapped rather than panicked so the
889 /// exception type survives if the table ever changes.
890 fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
891 forms
892 .first()
893 .cloned()
894 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
895 }
896
897 /// `_money_verbose`: `self.to_cardinal(number, all_suffixed=True)`.
898 ///
899 /// `number` is always a non-negative int here (`parse_currency_parts`
900 /// strips the sign, and `to_cheque` passes `int(abs_val)`), so the dropped
901 /// `all_suffixed` of quirk 1 is unreachable through this hook.
902 fn money_verbose(&self, number: &BigInt, _currency: &str) -> Result<String> {
903 int2word(number, true)
904 }
905
906 /// `_cents_verbose`: `self.to_cardinal(number, all_suffixed=True)`.
907 fn cents_verbose(&self, number: &BigInt, _currency: &str) -> Result<String> {
908 int2word(number, true)
909 }
910
911 /// Port of `Num2Word_MN.to_cardinal(float/Decimal)` — the **full** entry.
912 ///
913 /// MN overrides `to_cardinal` outright, so the whole-value routing is its
914 /// own too: Base's `int(value) == value` test never runs. `str(value)`
915 /// decides everything — "5.00" takes the fraction branch and its
916 /// `int(right)==0` early return ("тав"), while "1e+16"/"1E+2"/"Infinity"
917 /// (no ".") fall to `int(str)` and raise ValueError. See the module docs.
918 ///
919 /// `precision_override` (the `precision=` kwarg, issue #580) is ignored,
920 /// as in Python: MN's `to_cardinal` takes no `precision` argument and
921 /// never reads `self.precision`, so the dispatcher's
922 /// `converter.precision = …` mutation is a no-op for it. Verified against
923 /// the live interpreter (`precision=1` and `precision=4` both leave 2.675
924 /// unchanged).
925 fn cardinal_float_entry(
926 &self,
927 value: &FloatValue,
928 _precision_override: Option<u32>,
929 ) -> Result<String> {
930 mn_to_cardinal(value, false)
931 }
932
933 /// MN's raw float grammar is the same routine as the full entry — Python
934 /// has a single `to_cardinal` handling both — kept overridden so nothing
935 /// reaches Base's `float2tuple` dialect ("жаран долоо (.) тав" instead of
936 /// "жаран долоо, аравны тав").
937 fn to_cardinal_float(
938 &self,
939 value: &FloatValue,
940 _precision_override: Option<u32>,
941 ) -> Result<String> {
942 mn_to_cardinal(value, false)
943 }
944
945 /// Port of `to_ordinal(float/Decimal)`: `number = int(value)` — plain
946 /// truncation toward zero, then the integer body. So 2.5 -> "хоёр
947 /// дугаар", 1e+16 -> "арван тунамал дугаар" (the float is never
948 /// stringified, so no ValueError here), and ±0.0 -> 0 -> the quirk-2
949 /// IndexError.
950 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
951 let n = mn_trunc_int(value)?;
952 self.to_ordinal(&n)
953 }
954
955 /// Port of `to_ordinal_num(float/Decimal)`: `number = int(value)`, then
956 /// `"%s %s" % (number, suffix)` — so "-1.5" renders "-1 дүгээр" and
957 /// Decimal("1E+20") renders all 21 digits.
958 fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
959 let n = mn_trunc_int(value)?;
960 self.to_ordinal_num(&n)
961 }
962
963 /// Port of `to_year(float/Decimal)`.
964 ///
965 /// `if value < 0` is a numeric comparison — NOT the cardinal's
966 /// `startswith("-")` — so -0.0 takes no "МЭӨ " prefix and its stray "-"
967 /// is later dropped by the `int(right)==0` early return ("тэг он"). A
968 /// negative value is `abs()`ed *before* `to_cardinal(value,
969 /// all_suffixed=True)`, so the cardinal never sees the sign. Because this
970 /// re-enters the stringifying cardinal, `to_year(1e+16)` raises the
971 /// ValueError that `to_ordinal(1e+16)` avoids.
972 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
973 let lt_zero = match value {
974 FloatValue::Float { value, .. } => *value < 0.0,
975 // BigDecimal has no signed zero, so is_negative() is exactly < 0.
976 FloatValue::Decimal { value, .. } => value.is_negative(),
977 };
978 let (v, prefix) = if lt_zero {
979 let abs = match value {
980 FloatValue::Float { value, precision } => FloatValue::Float {
981 value: value.abs(),
982 precision: *precision,
983 },
984 FloatValue::Decimal { value, precision } => FloatValue::Decimal {
985 value: value.abs(),
986 precision: *precision,
987 },
988 };
989 (abs, BCE_PREFIX)
990 } else {
991 (value.clone(), "")
992 };
993 Ok(format!("{}{} {}", prefix, mn_to_cardinal(&v, true)?, YEAR_WORD))
994 }
995
996 /// Base `str_to_number` (`Decimal(value)`) with the Inf parse turned into
997 /// the ValueError MN's `to_cardinal` would raise on `int("Infinity")`.
998 ///
999 /// The binding hardwires `ParsedNumber::Inf` to OverflowError — correct
1000 /// for base languages, whose `int(Decimal("Infinity"))` raises it — before
1001 /// any other language hook runs, so this parse-time interception is the
1002 /// only way to surface MN's ValueError. Residual divergence: a
1003 /// non-cardinal mode fed "Infinity" would raise OverflowError in Python
1004 /// but ValueError here; no corpus row exercises that combination.
1005 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1006 match python_decimal_parse(s)? {
1007 ParsedNumber::Inf { negative } => Err(N2WError::Value(format!(
1008 "invalid literal for int() with base 10: '{}Infinity'",
1009 if negative { "-" } else { "" },
1010 ))),
1011 other => Ok(other),
1012 }
1013 }
1014
1015 /// `to_cardinal(value, all_suffixed=False)` — the one extended signature
1016 /// in `Num2Word_MN` (ordinal/ordinal_num/year take no extras, and
1017 /// to_currency adds nothing beyond the standard four).
1018 fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1019 if !kw.only(&["all_suffixed"]) {
1020 return Err(N2WError::Fallback("kwargs".into()));
1021 }
1022 // Integer input always lands in `_int2word(int(n), all_suffixed)`.
1023 // -5 with all_suffixed=True is still "хасах тав": quirk 1 drops the
1024 // flag across the negative recursion inside int2word.
1025 int2word(value, kw_truthy(kw.get("all_suffixed")))
1026 }
1027
1028 /// The float/Decimal cardinal with kwargs: same single `all_suffixed`
1029 /// flag, threaded through both branches of the string algorithm exactly
1030 /// as Python's `to_cardinal(value, all_suffixed=...)` does.
1031 fn to_cardinal_float_kw(
1032 &self,
1033 value: &FloatValue,
1034 _precision_override: Option<u32>,
1035 kw: &Kwargs,
1036 ) -> Result<String> {
1037 if !kw.only(&["all_suffixed"]) {
1038 return Err(N2WError::Fallback("kwargs".into()));
1039 }
1040 mn_to_cardinal(value, kw_truthy(kw.get("all_suffixed")))
1041 }
1042
1043 /// Port of the currency path's `self.to_cardinal(float(right))`.
1044 ///
1045 /// The fractional-cents branch of `Num2Word_MN.to_currency` casts the
1046 /// Decimal sub-unit to a **float** first (`float(right)`), then runs the
1047 /// float branch of `to_cardinal`. So this renders through the f64 arm — not
1048 /// the exact Decimal arm — deriving the precision from the resulting float's
1049 /// repr, exactly as `to_cardinal(67.5)` would. Reproduces the correct
1050 /// dialect ("жаран долоо, аравны тав") in-core, so the currency path no
1051 /// longer needs to defer to the Python converter for fractional cents.
1052 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1053 let f = value
1054 .to_f64()
1055 .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
1056 let precision = float_repr_precision(f);
1057 mn_to_cardinal(&FloatValue::Float { value: f, precision }, false)
1058 }
1059
1060 /// Port of `Num2Word_MN.to_currency`.
1061 ///
1062 /// MN replaces `Num2Word_Base.to_currency` wholesale rather than extending
1063 /// it, and the two differ in ways the corpus pins:
1064 ///
1065 /// * an `int` returns a bare cardinal with no unit word and no table
1066 /// lookup (quirk 6) — so an unknown code cannot raise on that path;
1067 /// * the divisor is hardcoded to 100 (quirk 7);
1068 /// * the cents segment is gated on `right > 0` alone, with no `has_decimal`
1069 /// guard (quirk 9);
1070 /// * `cents=False` emits `str(right)`, not `_cents_terse` (quirk 8).
1071 ///
1072 /// # The `adjective` parameter is deliberately ignored
1073 ///
1074 /// `Num2Word_MN.to_currency` is the **only** converter in the library whose
1075 /// `adjective` parameter defaults to `True` (all 148 others default to
1076 /// `False`, matching Base). The shim resolves that default on the Python
1077 /// side with a hardcoded `kwargs.get("adjective", False)` — Base's default,
1078 /// not the language's — so by the time the core is called, "caller omitted
1079 /// `adjective`" (Python: apply it) and "caller passed `adjective=False`"
1080 /// (Python: skip it) both arrive here as `false` and are indistinguishable.
1081 ///
1082 /// `separator` has the same problem and solves it with `Option<&str>` plus
1083 /// [`Lang::default_separator`]; there is no `default_adjective()` hook, and
1084 /// `base.rs` is out of bounds for this change. So this body applies MN's
1085 /// `adjective=True` default unconditionally, which reproduces Python for
1086 /// every call that omits the kwarg — i.e. the corpus, and every route the
1087 /// shim actually takes. `num2words(12.34, lang="mn", to="currency",
1088 /// currency="USD")` is "арван хоёр Америк доллар, гучин дөрвөн цент" in
1089 /// Python and here; honouring the incoming `false` would drop "Америк" and
1090 /// fail 14 corpus rows (the USD and INR floats).
1091 ///
1092 /// The residual divergence is an **explicit** `adjective=False`, which
1093 /// Python honours and this cannot see. Flagged rather than papered over;
1094 /// the fix is a `default_adjective()` hook mirroring `default_separator()`.
1095 fn to_currency(
1096 &self,
1097 val: &CurrencyValue,
1098 currency: &str,
1099 cents: bool,
1100 separator: Option<&str>,
1101 adjective: bool,
1102 ) -> Result<String> {
1103 let d = match val {
1104 // `if isinstance(val, int): return self.to_cardinal(val)` — the
1105 // whole method, for an int. The sign is kept (to_cardinal(-5) is
1106 // "хасах тав"), `all_suffixed` is not passed, and `currency` is
1107 // never looked at. Quirk 6.
1108 CurrencyValue::Int(v) => return int2word(v, false),
1109 CurrencyValue::Decimal { value, .. } => value,
1110 };
1111
1112 // MN's own `separator=","` default, applied when the caller omitted
1113 // the kwarg. Same value as Base's here, but read through the hook
1114 // rather than hardcoded.
1115 let separator = separator.unwrap_or(self.default_separator());
1116
1117 // `has_fractional_cents = (decimal_val * 100) % 1 != 0`. Decimal's `%`
1118 // truncates toward zero, and so does `with_scale(0)`, so the two agree
1119 // on negatives: -12.34 * 100 is -1234.00, remainder -0.00, which is 0.
1120 let scaled = d * BigDecimal::from(DIVISOR);
1121 let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
1122
1123 // `parse_currency_parts(val, is_int_with_cents=False,
1124 // keep_precision=has_fractional_cents)`
1125 // — divisor comes from the callee's default of 100.
1126 let (left, right, is_negative) =
1127 parse_currency_parts(val, false, has_fractional_cents, DIVISOR);
1128
1129 // The lookup happens *after* parsing, exactly as in Python. Only
1130 // reachable for a float, so an unknown code raises here but not on the
1131 // int path above.
1132 let forms = self.currency_forms(currency).ok_or_else(|| {
1133 N2WError::NotImplemented(format!(
1134 "Currency code \"{}\" not implemented for \"{}\"",
1135 currency,
1136 self.lang_name()
1137 ))
1138 })?;
1139
1140 let mut cr1 = forms.unit.clone();
1141 let cr2 = forms.subunit.clone();
1142 // `if adjective and currency in self.CURRENCY_ADJECTIVES`. The param
1143 // arrives already resolved: the binding substitutes MN's own default
1144 // (True, via default_adjective) when the caller omitted the kwarg,
1145 // so an explicit adjective=False is honoured — test_mn passes one.
1146 if adjective {
1147 if let Some(adj) = self.currency_adjective(currency) {
1148 cr1 = prefix_currency(adj, &cr1);
1149 }
1150 }
1151
1152 // `"%s " % self.negword.strip()`. NEGWORD has no padding to strip.
1153 let minus_str = if is_negative {
1154 format!("{} ", NEGWORD)
1155 } else {
1156 String::new()
1157 };
1158 let money_str = self.money_verbose(&left, currency)?;
1159
1160 // `if right > 0:` — `right` is non-negative (parse_currency_parts took
1161 // abs first), so this is purely "are there any sub-units". No
1162 // `has_decimal` guard, hence 1.0 -> "нэг евро". Quirk 9.
1163 if right <= BigDecimal::zero() {
1164 return Ok(format!(
1165 "{}{} {}",
1166 minus_str,
1167 money_str,
1168 self.pluralize(&left, &cr1)?
1169 ));
1170 }
1171
1172 // `isinstance(right, Decimal)` is exactly `keep_precision`: Python's
1173 // parse_currency_parts returns `int(fraction * divisor)` when it is
1174 // false and a raw Decimal when true.
1175 let cents_str = if has_fractional_cents {
1176 // Python: `self.to_cardinal(float(right)) if cents else
1177 // str(float(right))`, i.e. the float/POINT_WORDS branch.
1178 // Out of scope per PORTING_CURRENCY.md. Both arms raise
1179 // NotImplemented, which `num2words()` catches and falls back to the
1180 // Python converter for. NB `cardinal_from_decimal` is overridden
1181 // above precisely so this does NOT reach Base's float default,
1182 // which would emit the wrong dialect instead of deferring.
1183 if cents {
1184 self.cardinal_from_decimal(&right)?
1185 } else {
1186 return Err(N2WError::NotImplemented(format!(
1187 "str(float(...)) repr of fractional sub-units not \
1188 implemented for {} ({})",
1189 self.lang_name(),
1190 right
1191 )));
1192 }
1193 } else {
1194 // `right` went through `.with_scale(0)`, so exponent 0 and the
1195 // mantissa is the value.
1196 let right_int = right.as_bigint_and_exponent().0;
1197 if cents {
1198 self.cents_verbose(&right_int, currency)?
1199 } else {
1200 // `str(right)` on a Python int — NOT `_cents_terse`, so no
1201 // zero padding. Quirk 8.
1202 right_int.to_string()
1203 }
1204 };
1205
1206 // `self.pluralize(right, cr2)` — MN passes `right` itself, which is a
1207 // Decimal on the fractional path. `pluralize` ignores its count
1208 // entirely, so the BigInt/BigDecimal split cannot change the output.
1209 //
1210 // Note for whoever implements `cardinal_from_decimal`: this line is
1211 // currently unreachable on the fractional path (the `?` above bails
1212 // first). Once it is reachable, `as_bigint_and_exponent().0` yields the
1213 // *mantissa*, not the value — Decimal("1.100") gives 1100. Harmless
1214 // while `pluralize` discards the count, but it is a trap if that ever
1215 // changes.
1216 let right_int = right.as_bigint_and_exponent().0;
1217 Ok(format!(
1218 "{}{} {}{} {} {}",
1219 minus_str,
1220 money_str,
1221 self.pluralize(&left, &cr1)?,
1222 separator,
1223 cents_str,
1224 self.pluralize(&right_int, &cr2)?
1225 ))
1226 }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232 use std::str::FromStr;
1233
1234 fn f(v: f64, p: u32) -> Result<String> {
1235 LangMn::new().to_cardinal_float(&FloatValue::Float { value: v, precision: p }, None)
1236 }
1237
1238 fn dec(s: &str, p: u32) -> Result<String> {
1239 LangMn::new().to_cardinal_float(
1240 &FloatValue::Decimal { value: BigDecimal::from_str(s).unwrap(), precision: p },
1241 None,
1242 )
1243 }
1244
1245 /// Every `to: "cardinal"` corpus row whose `arg` has a dot (float input).
1246 /// `precision` is Python's `abs(Decimal(str(value)).as_tuple().exponent)`.
1247 #[test]
1248 fn float_cardinal_corpus() {
1249 assert_eq!(f(0.0, 1).unwrap(), "тэг");
1250 assert_eq!(f(0.5, 1).unwrap(), "тэг, аравны тав");
1251 assert_eq!(f(1.0, 1).unwrap(), "нэг");
1252 assert_eq!(f(1.5, 1).unwrap(), "нэг, аравны тав");
1253 assert_eq!(f(2.25, 2).unwrap(), "хоёр, зууны хорин тав");
1254 assert_eq!(f(3.14, 2).unwrap(), "гурав, зууны арван дөрөв");
1255 assert_eq!(f(0.01, 2).unwrap(), "тэг, зууны нэг");
1256 assert_eq!(f(0.1, 1).unwrap(), "тэг, аравны нэг");
1257 assert_eq!(f(0.99, 2).unwrap(), "тэг, зууны ерэн ес");
1258 assert_eq!(f(1.01, 2).unwrap(), "нэг, зууны нэг");
1259 assert_eq!(f(12.34, 2).unwrap(), "арван хоёр, зууны гучин дөрөв");
1260 assert_eq!(f(99.99, 2).unwrap(), "ерэн ес, зууны ерэн ес");
1261 assert_eq!(f(100.5, 1).unwrap(), "зуу, аравны тав");
1262 assert_eq!(
1263 f(1234.56, 2).unwrap(),
1264 "нэг мянга хоёр зуун гучин дөрөв, зууны тавин зургаа"
1265 );
1266 assert_eq!(f(-0.5, 1).unwrap(), "хасах тэг, аравны тав");
1267 assert_eq!(f(-1.5, 1).unwrap(), "хасах нэг, аравны тав");
1268 assert_eq!(f(-12.34, 2).unwrap(), "хасах арван хоёр, зууны гучин дөрөв");
1269 // The f64-artefact cases: MN reads str(value), not float2tuple, so
1270 // 1.005 -> "005" -> 5 and 2.675 -> "675" -> 675 fall out of the repr.
1271 assert_eq!(f(1.005, 3).unwrap(), "нэг, мянганы тав");
1272 assert_eq!(
1273 f(2.675, 3).unwrap(),
1274 "хоёр, мянганы зургаан зуун далан тав"
1275 );
1276 }
1277
1278 /// Every `to: "cardinal_dec"` corpus row (Decimal input).
1279 #[test]
1280 fn decimal_cardinal_corpus() {
1281 assert_eq!(dec("0.01", 2).unwrap(), "тэг, зууны нэг");
1282 // The trailing zero survives: str(Decimal("1.10")) == "1.10", right="10".
1283 assert_eq!(dec("1.10", 2).unwrap(), "нэг, зууны арав");
1284 assert_eq!(
1285 dec("12.345", 3).unwrap(),
1286 "арван хоёр, мянганы гурван зуун дөчин тав"
1287 );
1288 // #603: full precision, no f64 rounding at trillion scale.
1289 assert_eq!(
1290 dec("98746251323029.99", 2).unwrap(),
1291 "ерэн найман их наяд долоон зуун дөчин зургаан тэрбум хоёр зуун \
1292 тавин нэг сая гурван зуун хорин гурван мянга хорин ес, зууны ерэн ес"
1293 );
1294 assert_eq!(dec("0.001", 3).unwrap(), "тэг, мянганы нэг");
1295 }
1296
1297 /// Branch ordering and boundaries confirmed against the live interpreter.
1298 #[test]
1299 fn edge_cases() {
1300 // int(right)==0 early return runs BEFORE the >6 raise.
1301 assert_eq!(dec("1.00000000", 8).unwrap(), "нэг");
1302 assert_eq!(dec("1.000000", 6).unwrap(), "нэг");
1303 assert_eq!(dec("12.000", 3).unwrap(), "арван хоёр");
1304 // Non-zero fraction beyond six places raises NotImplementedError().
1305 assert!(matches!(
1306 dec("1.10000000", 8),
1307 Err(N2WError::NotImplemented(m)) if m.is_empty()
1308 ));
1309 assert!(matches!(
1310 f(1.1234567, 7),
1311 Err(N2WError::NotImplemented(m)) if m.is_empty()
1312 ));
1313 // Signed zero: str(-0.0)=="-0.0" -> is_negative, but the zero-fraction
1314 // early return drops the sign, so "тэг".
1315 assert_eq!(f(-0.0, 1).unwrap(), "тэг");
1316 // cardinal_from_decimal casts to float first (currency fractional cents).
1317 assert_eq!(
1318 LangMn::new()
1319 .cardinal_from_decimal(&BigDecimal::from_str("67.5").unwrap())
1320 .unwrap(),
1321 "жаран долоо, аравны тав"
1322 );
1323 }
1324
1325 fn fv(v: f64, p: u32) -> FloatValue {
1326 FloatValue::Float { value: v, precision: p }
1327 }
1328
1329 fn dv(s: &str, p: u32) -> FloatValue {
1330 FloatValue::Decimal { value: BigDecimal::from_str(s).unwrap(), precision: p }
1331 }
1332
1333 /// The full cardinal entry: whole-value routing is MN's own — `str(value)`
1334 /// with no "." goes to int(str), which raises ValueError on scientific
1335 /// reprs (the binding derives precision as abs(Decimal(str).exponent),
1336 /// hence 16/20/2 here — the e-form branch never reads it).
1337 #[test]
1338 fn cardinal_entry_corpus() {
1339 let l = LangMn::new();
1340 assert!(matches!(
1341 l.cardinal_float_entry(&fv(1e16, 16), None),
1342 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '1e+16'"
1343 ));
1344 assert!(matches!(
1345 l.cardinal_float_entry(&fv(1e20, 20), None),
1346 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '1e+20'"
1347 ));
1348 assert!(matches!(
1349 l.cardinal_float_entry(&dv("1E+2", 2), None),
1350 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '1E+2'"
1351 ));
1352 assert!(matches!(
1353 l.cardinal_float_entry(&dv("1E+20", 20), None),
1354 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '1E+20'"
1355 ));
1356 // Whole values still route through the fraction branch's early return
1357 // (visible ".") or the int arm (no ".").
1358 assert_eq!(l.cardinal_float_entry(&fv(5.0, 1), None).unwrap(), "тав");
1359 assert_eq!(l.cardinal_float_entry(&dv("5.00", 2), None).unwrap(), "тав");
1360 assert_eq!(l.cardinal_float_entry(&dv("100", 0), None).unwrap(), "зуу");
1361 assert_eq!(l.cardinal_float_entry(&fv(-0.0, 1), None).unwrap(), "тэг");
1362 assert_eq!(
1363 l.cardinal_float_entry(&dv("12345.000", 3), None).unwrap(),
1364 "арван хоёр мянга гурван зуун дөчин тав"
1365 );
1366 }
1367
1368 /// `to_ordinal(float)`: int(value) truncation, then the integer body —
1369 /// so 1e+16 works here (never stringified) and ±0.0 is the IndexError.
1370 #[test]
1371 fn ordinal_entry_corpus() {
1372 let l = LangMn::new();
1373 assert_eq!(l.ordinal_float_entry(&fv(2.5, 1)).unwrap(), "хоёр дугаар");
1374 assert_eq!(l.ordinal_float_entry(&fv(-1.5, 1)).unwrap(), "хасах нэг дүгээр");
1375 assert_eq!(l.ordinal_float_entry(&fv(3.25, 2)).unwrap(), "гурав дугаар");
1376 assert_eq!(l.ordinal_float_entry(&fv(100.0, 1)).unwrap(), "зуу дугаар");
1377 assert_eq!(l.ordinal_float_entry(&fv(-1000.0, 1)).unwrap(), "хасах нэг мянга дугаар");
1378 assert_eq!(l.ordinal_float_entry(&fv(1e16, 16)).unwrap(), "арван тунамал дугаар");
1379 assert_eq!(
1380 l.ordinal_float_entry(&dv("1E+20", 20)).unwrap(),
1381 "зуун их ингүүмэл дугаар"
1382 );
1383 assert_eq!(l.ordinal_float_entry(&dv("5.00", 2)).unwrap(), "тав дугаар");
1384 // PR #661: to_ordinal(0) no longer raises IndexError; whole/truncated
1385 // zero floats now ordinalise to "тэг дугаар".
1386 assert_eq!(l.ordinal_float_entry(&fv(0.0, 1)).unwrap(), "тэг дугаар");
1387 assert_eq!(l.ordinal_float_entry(&fv(-0.0, 1)).unwrap(), "тэг дугаар");
1388 assert_eq!(l.ordinal_float_entry(&fv(0.5, 1)).unwrap(), "тэг дугаар");
1389 assert_eq!(l.ordinal_float_entry(&dv("0", 0)).unwrap(), "тэг дугаар");
1390 }
1391
1392 /// `to_ordinal_num(float)`: int(value), rendered as digits + suffix.
1393 #[test]
1394 fn ordinal_num_entry_corpus() {
1395 let l = LangMn::new();
1396 assert_eq!(
1397 l.ordinal_num_float_entry(&fv(-1000000.0, 1), "-1000000.0").unwrap(),
1398 "-1000000 дугаар"
1399 );
1400 assert_eq!(l.ordinal_num_float_entry(&fv(-1.5, 1), "-1.5").unwrap(), "-1 дүгээр");
1401 assert_eq!(
1402 l.ordinal_num_float_entry(&fv(1e16, 16), "1e+16").unwrap(),
1403 "10000000000000000 дугаар"
1404 );
1405 assert_eq!(
1406 l.ordinal_num_float_entry(&dv("1E+20", 20), "1E+20").unwrap(),
1407 "100000000000000000000 дугаар"
1408 );
1409 assert_eq!(l.ordinal_num_float_entry(&dv("5.00", 2), "5.00").unwrap(), "5 дугаар");
1410 // PR #661: to_ordinal_num(0) no longer raises; int(0.5)=int(-0.0)=0.
1411 assert_eq!(l.ordinal_num_float_entry(&fv(0.5, 1), "0.5").unwrap(), "0 дугаар");
1412 assert_eq!(l.ordinal_num_float_entry(&fv(-0.0, 1), "-0.0").unwrap(), "0 дугаар");
1413 }
1414
1415 /// `to_year(float)`: `value < 0` (numeric — -0.0 takes no prefix), abs()
1416 /// before the all_suffixed cardinal, " он" appended.
1417 #[test]
1418 fn year_entry_corpus() {
1419 let l = LangMn::new();
1420 assert_eq!(l.year_float_entry(&fv(-1000.0, 1)).unwrap(), "МЭӨ нэг мянган он");
1421 assert_eq!(l.year_float_entry(&fv(-1000000.0, 1)).unwrap(), "МЭӨ нэг сая он");
1422 assert_eq!(l.year_float_entry(&fv(-0.0, 1)).unwrap(), "тэг он");
1423 assert_eq!(l.year_float_entry(&fv(0.5, 1)).unwrap(), "тэг, аравны таван он");
1424 assert_eq!(l.year_float_entry(&fv(-1.5, 1)).unwrap(), "МЭӨ нэг, аравны таван он");
1425 assert_eq!(l.year_float_entry(&fv(3.25, 2)).unwrap(), "гурван, зууны хорин таван он");
1426 assert_eq!(l.year_float_entry(&fv(99.0, 1)).unwrap(), "ерэн есөн он");
1427 assert_eq!(l.year_float_entry(&fv(1234.0, 1)).unwrap(), "нэг мянга хоёр зуун гучин дөрвөн он");
1428 assert_eq!(l.year_float_entry(&dv("5.00", 2)).unwrap(), "таван он");
1429 assert_eq!(l.year_float_entry(&dv("100", 0)).unwrap(), "зуун он");
1430 assert_eq!(l.year_float_entry(&dv("1.50", 2)).unwrap(), "нэг, зууны тавин он");
1431 // to_year re-enters the stringifying cardinal, so e-form raises.
1432 assert!(matches!(
1433 l.year_float_entry(&fv(1e16, 16)),
1434 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '1e+16'"
1435 ));
1436 assert!(matches!(
1437 l.year_float_entry(&dv("1E+2", 2)),
1438 Err(N2WError::Value(_))
1439 ));
1440 }
1441
1442 /// The `all_suffixed` kwarg, and the quirk-1 drop across the negative
1443 /// recursion (-5 stays "хасах тав" even when suffixed).
1444 #[test]
1445 fn all_suffixed_kwarg() {
1446 let l = LangMn::new();
1447 let t = Kwargs(vec![("all_suffixed".into(), KwVal::Bool(true))]);
1448 let f_ = Kwargs(vec![("all_suffixed".into(), KwVal::Bool(false))]);
1449 let none = Kwargs(vec![("all_suffixed".into(), KwVal::None)]);
1450 let bad = Kwargs(vec![("gender".into(), KwVal::Str("f".into()))]);
1451 assert_eq!(l.to_cardinal_kw(&BigInt::from(3), &t).unwrap(), "гурван");
1452 assert_eq!(l.to_cardinal_kw(&BigInt::from(100), &t).unwrap(), "зуун");
1453 assert_eq!(
1454 l.to_cardinal_kw(&BigInt::from(1234), &t).unwrap(),
1455 "нэг мянга хоёр зуун гучин дөрвөн"
1456 );
1457 assert_eq!(l.to_cardinal_kw(&BigInt::from(-5), &t).unwrap(), "хасах тав");
1458 assert_eq!(l.to_cardinal_kw(&BigInt::from(1234), &f_).unwrap(), "нэг мянга хоёр зуун гучин дөрөв");
1459 // all_suffixed=None behaves like the default (falsy).
1460 assert_eq!(l.to_cardinal_kw(&BigInt::from(3), &none).unwrap(), "гурав");
1461 assert!(matches!(
1462 l.to_cardinal_kw(&BigInt::from(3), &bad),
1463 Err(N2WError::Fallback(_))
1464 ));
1465 // Floats accept the flag too: Python's to_cardinal threads it into
1466 // both _int2word calls of the fraction branch.
1467 assert_eq!(
1468 l.to_cardinal_float_kw(&fv(1.5, 1), None, &t).unwrap(),
1469 "нэг, аравны таван"
1470 );
1471 }
1472
1473 /// The Inf parse becomes the ValueError MN's `int("Infinity")` raises;
1474 /// everything else passes through Base's Decimal(value).
1475 #[test]
1476 fn str_to_number_infinity() {
1477 let l = LangMn::new();
1478 assert!(matches!(
1479 l.str_to_number("Infinity"),
1480 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: 'Infinity'"
1481 ));
1482 assert!(matches!(
1483 l.str_to_number("-Infinity"),
1484 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '-Infinity'"
1485 ));
1486 assert!(matches!(l.str_to_number("1e3"), Ok(ParsedNumber::Dec(_))));
1487 assert!(matches!(l.str_to_number("NaN"), Ok(ParsedNumber::NaN)));
1488 // "1e3" parses to Decimal('1E+3'); the cardinal entry then raises.
1489 assert!(matches!(
1490 l.cardinal_float_entry(&dv("1E+3", 3), None),
1491 Err(N2WError::Value(m)) if m == "invalid literal for int() with base 10: '1E+3'"
1492 ));
1493 }
1494}