num2words2_core/lang_ku.rs
1//! Port of `lang_KU.py` (Kurdish / Kurmanji).
2//!
3//! Shape: **self-contained**. `Num2Word_KU` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` guard in `Num2Word_Base.__init__` never fires: Python never builds
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives a hand-written `_int_to_word` recursion. Consequently
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
9//! **no overflow check at all** — see bug 1 below for what happens instead.
10//!
11//! Inherited from `Num2Word_Base` but overridden by KU (so the trait defaults
12//! are *not* used):
13//! * `to_ordinal` — KU's own; notably does **not** call `verify_ordinal`,
14//! which is why negative ordinals succeed instead of raising (bug 4).
15//! * `to_ordinal_num` — KU's own: `str(number) + "em"`.
16//! * `to_year` — KU's own: ignores `longval` and the base class's
17//! BC/AD machinery, delegating straight to `to_cardinal`.
18//!
19//! `setup()` also assigns `self.exclude_title = ["û", "xal", "negatîv"]`, but
20//! `Num2Word_Base.__init__` leaves `is_title = False` and KU's `to_cardinal`
21//! never calls `self.title()`. The list is therefore dead — `is_title()` and
22//! `exclude_title()` are left at their trait defaults deliberately.
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! This is a port, not a rewrite. Every item below looks wrong and is exactly
27//! what Python emits; each is pinned by a row in the frozen corpus.
28//!
29//! 1. **`_int_to_word` gives up at 10^9 and returns bare digits.** The final
30//! `return str(number)` has no word forms behind it, so `to_cardinal(10**9)`
31//! == `"1000000000"` — the *numeral*, not words. There is no "milyar"
32//! branch. This is the de facto ceiling, and it degrades silently rather
33//! than raising `OverflowError`. Hence `to_ordinal(10**9)` ==
34//! `"1000000000em"`, identical to `to_ordinal_num(10**9)`.
35//! 2. **Asymmetric "yek" suppression.** The hundreds branch suppresses the
36//! multiplier for exactly one hundred (`if h > 1`), so 100 == `"sed"`, but
37//! the thousand and million branches recurse unconditionally, so 1000 ==
38//! `"yek hezar"` and 10^6 == `"yek milyon"`. The suppression then leaks
39//! through the recursion: 100000 == `"sed hezar"` (not "yek sed hezar") and
40//! 10^8 == `"sed milyon"`.
41//! 3. **Ordinals concatenate "em" with no separator or elision**, producing a
42//! doubled vowel on words already ending in "e"/"ê": `to_ordinal(3)` ==
43//! `"sêem"`, `to_ordinal(10)` == `"dehem"`. Preserved verbatim.
44//! 4. **Negative ordinals do not raise.** KU's `to_ordinal` skips
45//! `verify_ordinal`, so `to_ordinal(-1)` == `"negatîv yekem"` — the "em"
46//! lands on the end of a negated cardinal. `to_ordinal_num(-1)` == `"-1em"`.
47//! 5. **`tens[1]` ("deh") is unreachable.** The `< 100` branch only runs for
48//! `number >= 20` (10..19 is consumed by `teens`), so the tens quotient is
49//! never 1. Kept in the table for positional fidelity.
50//! 6. **`to_ordinal(1)`'s special case is a no-op.** `if number == 1: return
51//! "yekem"` produces exactly what the general path would (`to_cardinal(1)`
52//! + `"em"` == `"yek"` + `"em"`). Harmless, but reproduced as written.
53//!
54//! # Currency
55//!
56//! `Num2Word_KU` declares its **own** `CURRENCY_FORMS` class attribute, so it is
57//! untouched by the `lang_EUR` / `Num2Word_EN.__init__` mutation trap described
58//! in PORTING_CURRENCY.md — EN mutates `Num2Word_EUR`'s dict, and KU does not
59//! share it. The live table is exactly the source literal, four codes wide.
60//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}`, so
61//! `currency_precision` (always 100) and `currency_adjective` (always `None`)
62//! stay at their trait defaults.
63//!
64//! KU overrides `to_currency` outright and defines `pluralize`. Everything else
65//! on the currency path — `to_cheque`, `_money_verbose`, `_cents_verbose`,
66//! `_cents_terse` — is inherited from `Num2Word_Base` unchanged, and the trait
67//! defaults already mirror those.
68//!
69//! ## More faithfully reproduced Python bugs
70//!
71//! 7. **An unknown currency code does not raise; it silently becomes TRY.**
72//! `to_currency` looks the code up with
73//! `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`, and that
74//! default is the *first inserted* entry — TRY. So `to_currency(0, "JPY")` is
75//! `"sifir lîre"`, quietly denominated in Turkish lira. Only `to_cheque`,
76//! which is Base's and subscripts the dict, raises NotImplementedError. The
77//! corpus pins both halves: every one of the nine codes it exercises returns
78//! a value through `to_currency`, while GBP/JPY/KWD/BHD/INR/CNY/CHF raise
79//! through `to_cheque`.
80//! 8. **`cents=False` drops the subunit segment instead of writing it tersely.**
81//! Base would emit `"... 56"` via `_cents_terse`; KU's `if cents and right:`
82//! just omits it, so `to_currency(12.34, "USD", cents=False)` is
83//! `"donzdeh dolar"`.
84//! 9. **`adjective=` is accepted and then completely ignored.** KU never touches
85//! `CURRENCY_ADJECTIVES` (which is empty anyway), so `adjective=True` changes
86//! nothing.
87//! 10. **Cents are truncated to two digits, not rounded, and short fractions are
88//! right-padded.** `int(parts[1][:2].ljust(2, "0"))` means `12.345` -> 34
89//! (not 35), `12.005` -> `int("00")` -> 0 -> *no cents at all*, `0.099` -> 9,
90//! and `1.5` -> 50. A 3-decimal currency gets no special treatment: KU has no
91//! `CURRENCY_PRECISION`, so KWD is truncated at 2 digits like everything else
92//! (and falls back to TRY's forms per bug 7 regardless).
93//! 11. **Bug 1's digit fallback leaks into money.** `to_currency` calls
94//! `_int_to_word` directly, so `to_currency(10**9, "USD")` is
95//! `"1000000000 dolar"` and `to_currency(1234567890.12, "USD")` is
96//! `"1234567890 dolar donzdeh sent"`.
97//! 12. **`pluralize` is dead code.** KU defines it, but `to_currency` inlines its
98//! own `cr1[1] if left != 1 else cr1[0]` and Base's `to_cheque` takes `cr1[-1]`
99//! unconditionally, so nothing in the library reaches it. It is ported anyway
100//! (the trait default *raises*, which would be wrong for KU) and differs from
101//! `Num2Word_EUR.pluralize`: it returns `forms[-1]`, not `forms[1]`, and
102//! answers `""` for an empty tuple rather than raising IndexError.
103//! 13. **The separator is concatenated with no following space.** Base formats
104//! `"%s%s %s %s"`, putting a space *after* the separator; KU writes
105//! `result += separator + self._int_to_word(right) + " " + ...`. KU's own
106//! default (`" "`) hides this, but an explicit `separator=","` yields
107//! `"donzdeh dolar,sî û çar sent"` where Base's languages give `", sî..."`.
108//!
109//! Note that all four of KU's entries — and TRY's fallback — carry *identical*
110//! singular and plural forms (`("lîre", "lîre")`), so the `left != 1` branch is
111//! unobservable through the shipped table. It is ported literally regardless.
112//!
113//! # Notes on the port
114//!
115//! Python's `to_cardinal` is string-driven: it does `n = str(number).strip()`,
116//! tests `n.startswith("-")`, and recurses on the substring `n[1:]`. For
117//! integral input that is exactly "negate and recurse on the absolute value",
118//! which is what [`LangKu::cardinal`] does. The `"." in n` branch (float input)
119//! is out of scope per PORTING.md and omitted.
120//!
121//! `_int_to_word` is never reached with a negative: `to_cardinal` strips the
122//! sign before calling it. (Were it, Python's negative list indexing would make
123//! `self.ones[-1]` == "neh" — a latent trap this port cannot hit, since the
124//! only in-scope caller guarantees a non-negative argument.)
125
126use crate::base::{Lang, N2WError, Result};
127use crate::currency::{CurrencyForms, CurrencyValue};
128use crate::floatpath::FloatValue;
129use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
130use num_bigint::BigInt;
131use num_traits::{One, Signed, ToPrimitive};
132use std::collections::HashMap;
133use std::str::FromStr;
134
135/// `self.negword`. The trailing space is load-bearing: Python builds
136/// `negword + cardinal` and only then `.strip()`s the result.
137const NEGWORD: &str = "negatîv ";
138
139/// `self.ones`. Index 0 is `""` — unreachable from `_int_to_word` (the
140/// `number == 0` guard fires first), but used by the float path Python keeps.
141const ONES: [&str; 10] = [
142 "", "yek", "du", "sê", "çar", "pênc", "şeş", "heft", "heşt", "neh",
143];
144
145/// `self.teens`, indexed by `number - 10` for 10..=19.
146const TEENS: [&str; 10] = [
147 "deh", "yanzdeh", "donzdeh", "sêzdeh", "çardeh", "panzdeh", "şanzdeh", "hivdeh", "hijdeh",
148 "nozdeh",
149];
150
151/// `self.tens`. Index 0 is `""` and index 1 ("deh") is dead code — see bug 5.
152const TENS: [&str; 10] = [
153 "", "deh", "bîst", "sî", "çil", "pêncî", "şêst", "heftê", "heştê", "not",
154];
155
156const HUNDRED: &str = "sed";
157const THOUSAND: &str = "hezar";
158const MILLION: &str = "milyon";
159
160/// The word for zero, emitted by `_int_to_word(0)`.
161const ZERO: &str = "sifir";
162
163/// The joiner between a magnitude and its remainder: Python's `" û "`.
164const AND: &str = " û ";
165
166/// The code `to_currency`'s unknown-code fallback resolves to (bug 7).
167///
168/// Python writes `list(self.CURRENCY_FORMS.values())[0]`, i.e. the first entry
169/// in the class dict's *insertion* order. `CURRENCY_FORMS` is a dict literal
170/// whose first key is "TRY", and dicts have preserved insertion order since
171/// CPython 3.7 — so that expression is exactly TRY's forms. Naming the code
172/// pins the semantics to the literal rather than to a `HashMap`, which has no
173/// order to borrow.
174const FALLBACK_CURRENCY: &str = "TRY";
175
176/// `Num2Word_KU.CURRENCY_FORMS`, in the source dict's insertion order.
177///
178/// KU declares this on its own class, so — unlike the 16 classes that read
179/// `Num2Word_EUR`'s shared dict — nothing mutates it at import time. What is
180/// written here is what runs; verified against the live interpreter.
181fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
182 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
183 // Insertion order matters only for FALLBACK_CURRENCY above; the map itself
184 // is unordered, so the first key is called out by name rather than implied.
185 m.insert("TRY", CurrencyForms::new(&["lîre", "lîre"], &["qurûş", "qurûş"]));
186 m.insert("IQD", CurrencyForms::new(&["dînar", "dînar"], &["fils", "fils"]));
187 m.insert("USD", CurrencyForms::new(&["dolar", "dolar"], &["sent", "sent"]));
188 m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["sent", "sent"]));
189 m
190}
191
192/// Kurdish (Kurmanji).
193///
194/// Stateless apart from the currency tables: `setup()` only assigns constant
195/// tables, and no method stashes state for another to consume.
196pub struct LangKu {
197 /// Built once here rather than per call — `to_currency` only ever reads it.
198 currency_forms: HashMap<&'static str, CurrencyForms>,
199 /// TRY's entry, pre-resolved so the unknown-code fallback (bug 7) is a
200 /// plain borrow instead of a lookup that could itself miss.
201 currency_fallback: CurrencyForms,
202}
203
204impl Default for LangKu {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210impl LangKu {
211 pub fn new() -> Self {
212 let currency_forms = build_currency_forms();
213 // Indexing is deliberate: FALLBACK_CURRENCY must name a key that
214 // `build_currency_forms` actually inserts, and this runs once at
215 // construction, so a bad edit to either fails loudly and immediately
216 // rather than silently changing what unknown codes render as.
217 let currency_fallback = currency_forms[FALLBACK_CURRENCY].clone();
218 LangKu {
219 currency_forms,
220 currency_fallback,
221 }
222 }
223
224 /// Python's `parts = str(abs(val)).split(".")` plus the two `int()` calls
225 /// that consume it, returning `(left, right)`.
226 ///
227 /// KU's `to_currency` is *string*-driven: it stringifies the value and
228 /// slices the text. The Rust boundary only carries the parsed number, so
229 /// this reconstructs the digits `str()` would have printed from the
230 /// `BigDecimal`'s `(digits, scale)` pair — which is exactly what a canonical
231 /// decimal repr encodes: `str(0.099)` == "0.099" <-> digits=99, scale=3.
232 ///
233 /// `right` is Python's `int(parts[1][:2].ljust(2, "0"))`: the first two
234 /// fractional digits, **truncated** (never rounded) and right-padded, hence
235 /// bounded to 0..=99 (bug 10).
236 fn currency_parts(&self, val: &CurrencyValue) -> Result<(BigInt, u64)> {
237 let value = match val {
238 // A true `int`: `str()` has no ".", so `parts` has length 1 and the
239 // `len(parts) > 1` guard leaves `right` at 0. Cents never appear.
240 CurrencyValue::Int(v) => return Ok((v.abs(), 0)),
241 CurrencyValue::Decimal { value, .. } => value,
242 };
243
244 // Python abs()es before str()ing; for a decimal repr the sign is just a
245 // prefix, so abs()ing the parsed value is the same reconstruction.
246 let (digits, scale) = value.abs().as_bigint_and_exponent();
247
248 if scale < 0 {
249 // A negative scale can only come from a string with a positive net
250 // exponent (bigdecimal computes scale = digits_after_point - exp and
251 // never normalises trailing zeros away, so "100" is scale 0, not
252 // -2). Python's str() of such a value keeps the exponent, and every
253 // shape of that then feeds an "e" to int(): "1e+21" -> parts
254 // ["1e+21"] -> int("1e+21"), "1.5E+3" -> parts ["1", "5E+3"] ->
255 // int("5E"). Both raise ValueError, so this is the exception Python
256 // raises, not an approximation of it. See `concerns` for the
257 // small-magnitude ("1e-05") half, which is *not* recoverable here.
258 return Err(N2WError::Value(format!(
259 "invalid literal for int() with base 10: {} carries an exponent \
260 in Python's str() form",
261 value
262 )));
263 }
264 if scale == 0 {
265 // No "." in str(): parts has length 1, so right stays 0.
266 return Ok((digits, 0));
267 }
268
269 let scale = scale as usize;
270 let s = digits.to_string();
271 // str() always prints at least one integer digit ("0.5", never ".5"),
272 // so pad the digit string out to scale+1 before splitting.
273 let padded = if s.len() <= scale {
274 format!("{}{}", "0".repeat(scale + 1 - s.len()), s)
275 } else {
276 s
277 };
278 let (part0, part1) = padded.split_at(padded.len() - scale);
279
280 // `int(parts[0]) if parts[0] else 0` — part0 is never empty here (the
281 // padding above guarantees a digit), so the falsy branch is dead.
282 let left = BigInt::from_str(part0)
283 .map_err(|e| N2WError::Value(e.to_string()))?;
284
285 // `parts[1][:2].ljust(2, "0")`. Sliced by chars per PORTING.md, though
286 // these are all ASCII digits.
287 let mut frac: String = part1.chars().take(2).collect();
288 while frac.len() < 2 {
289 frac.push('0');
290 }
291 let right = frac
292 .parse::<u64>()
293 .map_err(|e| N2WError::Value(e.to_string()))?;
294 Ok((left, right))
295 }
296
297 /// Python's `_int_to_word`. `number` must be non-negative (guaranteed by
298 /// [`LangKu::cardinal`], the only in-scope caller).
299 fn int_to_word(&self, number: &BigInt) -> String {
300 // Bug 1: the Python chain of `if number < ...` branches ends in a bare
301 // `return str(number)`. Anything at or above 10^9 renders as digits.
302 // Checking the ceiling first also proves the remaining value fits a
303 // u64, so the cast below is sound rather than hopeful.
304 let billion = BigInt::from(1_000_000_000u64);
305 if *number >= billion {
306 return number.to_string();
307 }
308
309 // Proven bounded: 0 <= number < 10^9.
310 let n = number
311 .to_u64()
312 .expect("0 <= number < 10^9 is representable as u64");
313 self.int_to_word_small(n)
314 }
315
316 /// The `< 10^9` half of `_int_to_word`, on a value proven to fit a u64.
317 fn int_to_word_small(&self, number: u64) -> String {
318 if number == 0 {
319 return ZERO.to_string();
320 }
321 if number < 10 {
322 return ONES[number as usize].to_string();
323 }
324 if number < 20 {
325 return TEENS[(number - 10) as usize].to_string();
326 }
327 if number < 100 {
328 let (t, o) = (number / 10, number % 10);
329 let mut s = TENS[t as usize].to_string();
330 if o != 0 {
331 s.push_str(AND);
332 s.push_str(ONES[o as usize]);
333 }
334 return s;
335 }
336 if number < 1_000 {
337 let (h, r) = (number / 100, number % 100);
338 // Bug 2: the multiplier is suppressed for h == 1, so 100 == "sed".
339 let mut s = String::new();
340 if h > 1 {
341 s.push_str(ONES[h as usize]);
342 s.push(' ');
343 }
344 s.push_str(HUNDRED);
345 if r != 0 {
346 s.push_str(AND);
347 s.push_str(&self.int_to_word_small(r));
348 }
349 return s;
350 }
351 if number < 1_000_000 {
352 let (t, r) = (number / 1_000, number % 1_000);
353 // No `if t > 1` guard here — hence "yek hezar" for 1000 (bug 2).
354 let mut s = self.int_to_word_small(t);
355 s.push(' ');
356 s.push_str(THOUSAND);
357 if r != 0 {
358 s.push_str(AND);
359 s.push_str(&self.int_to_word_small(r));
360 }
361 return s;
362 }
363 // number < 10^9, enforced by int_to_word before the cast.
364 let (m, r) = (number / 1_000_000, number % 1_000_000);
365 let mut s = self.int_to_word_small(m);
366 s.push(' ');
367 s.push_str(MILLION);
368 if r != 0 {
369 s.push_str(AND);
370 s.push_str(&self.int_to_word_small(r));
371 }
372 s
373 }
374
375 /// Python's `to_cardinal` for integral input.
376 ///
377 /// Python inspects `str(number)` and recurses on the `n[1:]` substring when
378 /// it sees a leading "-"; for integers that is exactly negate-and-recurse.
379 /// The final `.strip()` is reproduced by `trim()` — it is a no-op for every
380 /// integral input (the inner result never has surrounding whitespace), but
381 /// it is what keeps `NEGWORD`'s trailing space from being observable.
382 fn cardinal(&self, value: &BigInt) -> String {
383 if value.is_negative() {
384 let inner = self.cardinal(&value.abs());
385 return format!("{}{}", NEGWORD, inner).trim().to_string();
386 }
387 self.int_to_word(value)
388 }
389
390 /// Python's `to_cardinal` for **float** input: the `"." in n` branch of
391 /// `Num2Word_KU.to_cardinal`, which is *string*-driven and never touches
392 /// `base.float2tuple`. It stringifies the float (`n = str(number)`), splits
393 /// on the first ".", renders the integer part with `_int_to_word`, then
394 /// appends the pointword and each **raw** fractional digit as
395 /// `self.ones[int(digit)] or "sifir"`.
396 ///
397 /// Reading `str(number)`'s digits verbatim is exactly why KU cannot inherit
398 /// base.py's float path. `base.float2tuple` recomputes the fraction as
399 /// `abs(value - pre) * 10**precision` in f64 and loses the final digit for
400 /// long reprs — `str(5.525882872479642)` keeps `...642`, but the f64 product
401 /// rounds it to `...641`. KU sidesteps that entirely by never doing the
402 /// arithmetic. Consequently `precision` is *unused for the digit values*
403 /// (KU's `to_cardinal` takes no `precision=` kwarg); it is threaded in only
404 /// to restore the ".0" of an integer-valued float, whose Rust `Display`
405 /// (unlike Python's `str`) drops the trailing fractional part.
406 fn cardinal_float(&self, value: f64, precision: u32) -> Result<String> {
407 // Python tests `str(number).strip().startswith("-")` — the sign *bit*,
408 // not `value < 0`. `str(-0.0) == "-0.0"` starts with "-", so KU prepends
409 // negword even for negative zero; `is_sign_negative` reproduces that.
410 let neg = value.is_sign_negative();
411 let a = value.abs();
412
413 // Python's `str(float)` switches to exponential form for a nonzero
414 // magnitude below 1e-4 or at/above 1e16 (and prints "inf"/"nan" for a
415 // non-finite value). KU then feeds that text to `int()`, which raises
416 // ValueError: "1e+16" has no "." so `int("1e+16")` fails, and "1.5e+16"
417 // splits a "5e+16" run whose `int("e")` fails. Either path is a
418 // ValueError; reproduce the *variant* (the diff harness compares the
419 // exception type, not its message).
420 if !a.is_finite() || (a != 0.0 && (a < 1e-4 || a >= 1e16)) {
421 return Err(N2WError::Value(format!(
422 "invalid literal for int() with base 10: str({}) is not a plain decimal",
423 value
424 )));
425 }
426
427 // Rust's f64 `Display` is shortest-round-trip like Python's `repr`, so
428 // the digits match across this range — save for a few genuine dtoa
429 // *ties* above ~1e13 where "N.d2" and "N.d3" both round-trip to the same
430 // double and the two libraries break the tie oppositely (see concerns).
431 // Display never uses exponential form, so within [1e-4, 1e16) `s` is a
432 // plain decimal string.
433 let s = format!("{}", a);
434 let (int_str, frac_str): (String, String) = match s.split_once('.') {
435 Some((i, f)) => (i.to_string(), f.to_string()),
436 // No ".": an integer-valued float, whose Python `str` is "N.0".
437 // `precision` is 1 for such a value, recovering the single "0".
438 None => (s, "0".repeat(precision as usize)),
439 };
440
441 // `_int_to_word(int(left))` — `left` is the non-negative integer part.
442 let left = BigInt::from_str(&int_str).map_err(|e| N2WError::Value(e.to_string()))?;
443 let mut ret = self.int_to_word(&left);
444
445 if !frac_str.is_empty() {
446 // `ret += " " + self.pointword`
447 ret.push(' ');
448 ret.push_str(self.pointword());
449 // `for digit in right: ret += " " + (self.ones[int(digit)] or "sifir")`
450 for ch in frac_str.chars() {
451 let d = ch.to_digit(10).ok_or_else(|| {
452 N2WError::Value(format!("non-digit {:?} in fractional part", ch))
453 })?;
454 ret.push(' ');
455 // `ones[0]` is "" (falsy), so a 0 digit becomes "sifir".
456 ret.push_str(if d == 0 { ZERO } else { ONES[d as usize] });
457 }
458 }
459
460 // Python: negative -> `(negword + <inner>).strip()`, positive ->
461 // `<inner>.strip()`. Both strips are no-ops beyond negword's own space.
462 Ok(if neg {
463 format!("{}{}", NEGWORD, ret).trim().to_string()
464 } else {
465 ret.trim().to_string()
466 })
467 }
468}
469
470/// The `ValueError` for a `Decimal` whose `str()` is scientific notation
471/// (`"1E+2"`, `"1E-7"`, `"1.5E+3"`). KU feeds `str(number)` to `int()`: with
472/// no "." the whole (sign-stripped) literal is rejected; with a "." the
473/// per-character digit loop dies on `int("E")`.
474fn decimal_sci_value_error(s: &str) -> N2WError {
475 let n = s.strip_prefix('-').unwrap_or(s);
476 let lit = if n.contains('.') { "E" } else { n };
477 N2WError::Value(format!(
478 "invalid literal for int() with base 10: '{}'",
479 lit
480 ))
481}
482
483impl Lang for LangKu {
484 /// This language's own `to_currency(currency=...)` default,
485 /// read from the live Python signature. Only 44 of 156 use EUR.
486 fn default_currency(&self) -> &str {
487 "TRY"
488 }
489
490 /// This language's own `to_currency(separator=...)` default,
491 /// read from the live Python signature. Base's is ",", but only
492 /// 36 of 149 languages actually use it — most default to " " or a
493 /// conjunction, so inheriting Base's comma silently corrupts them.
494 fn default_separator(&self) -> &str {
495 " "
496 }
497
498 fn negword(&self) -> &str {
499 NEGWORD
500 }
501
502 fn pointword(&self) -> &str {
503 "xal"
504 }
505
506 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
507 Ok(self.cardinal(value))
508 }
509
510 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
511 // Bug 6: this special case is indistinguishable from the general path.
512 // Bug 4: no verify_ordinal call, so negatives fall through happily.
513 if value == &BigInt::from(1u32) {
514 return Ok("yekem".to_string());
515 }
516 Ok(format!("{}em", self.cardinal(value)))
517 }
518
519 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
520 // str(number) + "em" — the sign survives: -1 -> "-1em".
521 Ok(format!("{}em", value))
522 }
523
524 fn to_year(&self, value: &BigInt) -> Result<String> {
525 // KU ignores `longval` and the base class's BC/AD handling entirely.
526 Ok(self.cardinal(value))
527 }
528
529 /// `Num2Word_KU.to_cardinal` handles non-integers inline via `str(number)`,
530 /// so KU does **not** inherit `Num2Word_Base.to_cardinal_float`. Float input
531 /// takes KU's own string algorithm ([`LangKu::cardinal_float`]).
532 ///
533 /// The Decimal arm, by contrast, *is* reproduced bit-for-bit by the base
534 /// path: `str(Decimal)`'s fractional digits equal
535 /// `int(abs(value - int(value)) * 10**precision)` computed in exact
536 /// arbitrary precision, which is what `default_to_cardinal_float`'s Decimal
537 /// branch does — and its per-digit / pointword / sign rendering routes back
538 /// through KU's own `to_cardinal`, `pointword` and `negword`. So delegate
539 /// it rather than re-deriving the string. (The lone gap is a *negative-zero*
540 /// Decimal: KU's `str(Decimal("-0.0"))` starts with "-" and prepends
541 /// negword, but `num_bigint` has no negative zero, so the sign is already
542 /// gone by the time a `BigDecimal` reaches here — unrecoverable at this
543 /// layer, and absent from the corpus. See concerns.)
544 ///
545 /// `precision_override` (the `precision=` kwarg) is dropped for both arms:
546 /// KU's `to_cardinal(number)` takes no such parameter and reads `str(number)`
547 /// regardless, so an override never changes its output.
548 fn to_cardinal_float(
549 &self,
550 value: &FloatValue,
551 _precision_override: Option<u32>,
552 ) -> Result<String> {
553 match value {
554 FloatValue::Float { value, precision } => self.cardinal_float(*value, *precision),
555 FloatValue::Decimal { .. } => {
556 crate::floatpath::default_to_cardinal_float(self, value, None)
557 }
558 }
559 }
560
561 /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
562 ///
563 /// KU routes on the string, not the value: `"." in str(number)` decides
564 /// between the pointword grammar and `int(n)`. So the base default's
565 /// whole→int shortcut is wrong here — `str(5.0)` is `"5.0"` and must read
566 /// `"pênc xal sifir"`, while the point-free `Decimal("5")` stays `"pênc"`,
567 /// and an exponent-form repr (`"1e+16"`, `"1E+2"`) raises the `int()`
568 /// ValueError. The Float arm delegates wholesale to [`LangKu::cardinal_float`],
569 /// which already reproduces the scientific-notation raise, the sign-bit
570 /// negword of `-0.0`, and the restored ".0" of a whole float.
571 fn cardinal_float_entry(
572 &self,
573 value: &FloatValue,
574 precision_override: Option<u32>,
575 ) -> Result<String> {
576 match value {
577 FloatValue::Float {
578 value: f,
579 precision,
580 } => self.cardinal_float(*f, *precision),
581 FloatValue::Decimal { value: d, .. } => {
582 let s = python_decimal_str(d);
583 if s.contains('E') {
584 return Err(decimal_sci_value_error(&s));
585 }
586 if s.contains('.') {
587 // `"." in n`: the pointword grammar, whole values included
588 // (`Decimal("5.00")` -> "pênc xal sifir sifir").
589 return self.to_cardinal_float(value, precision_override);
590 }
591 // Point-free, non-scientific: an integer-valued Decimal —
592 // Python's `return self._int_to_word(int(n))` arm, with the
593 // negword recursion for a leading "-".
594 let i = value
595 .as_whole_int()
596 .expect("a point-free, non-scientific Decimal is whole");
597 Ok(self.cardinal(&i))
598 }
599 }
600 }
601
602 /// `to_ordinal(float/Decimal)`. Python's `number == 1` is a *numeric*
603 /// comparison, so `1.0` and `Decimal("1.00")` hit "yekem"; everything
604 /// else — negative zero included — is the string-routed cardinal plus
605 /// "em": `to_ordinal(5.0)` == `"pênc xal sifirem"`, `to_ordinal(-0.0)` ==
606 /// `"negatîv sifir xal sifirem"`. An exponent-form repr propagates the
607 /// cardinal's ValueError.
608 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
609 if value.as_whole_int() == Some(BigInt::from(1u32)) {
610 return Ok("yekem".to_string());
611 }
612 Ok(format!("{}em", self.cardinal_float_entry(value, None)?))
613 }
614
615 /// `to_ordinal_num(float/Decimal)`: `str(number) + "em"` — the raw repr,
616 /// never an error: `to_ordinal_num(1e16)` == `"1e+16em"` and
617 /// `Decimal("5.00")` == `"5.00em"`.
618 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
619 Ok(format!("{}em", repr_str))
620 }
621
622 /// `to_year(float/Decimal)`: bare `self.to_cardinal(val)`, string routing
623 /// included — `to_year(5.0)` == `"pênc xal sifir"`, `to_year(1e16)`
624 /// raises ValueError.
625 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
626 self.cardinal_float_entry(value, None)
627 }
628
629 /// `str_to_number` is inherited from the base (`Decimal(value)`) and this
630 /// override does not change what parses — `python_decimal_parse` still
631 /// decides. It exists because `Decimal("Infinity")`/`Decimal("NaN")` *do*
632 /// parse in Python, and KU's `to_cardinal` then dies at `int("Infinity")`
633 /// with ValueError — where the bridge hard-wires the *base* integer-path
634 /// errors (OverflowError / "cannot convert NaN"), which KU never
635 /// produces. The one input this would misserve is
636 /// `to_ordinal_num("Infinity")` (Python: `"Infinityem"`), which no corpus
637 /// exercises.
638 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
639 match python_decimal_parse(s)? {
640 ParsedNumber::Inf { .. } => Err(N2WError::Value(
641 "invalid literal for int() with base 10: 'Infinity'".into(),
642 )),
643 ParsedNumber::NaN => Err(N2WError::Value(
644 "invalid literal for int() with base 10: 'NaN'".into(),
645 )),
646 other => Ok(other),
647 }
648 }
649
650 // ---- currency -------------------------------------------------------
651 //
652 // KU overrides `to_currency` and `pluralize`; `to_cheque`, `_money_verbose`,
653 // `_cents_verbose` and `_cents_terse` are Base's, and the trait defaults
654 // already mirror them. `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are
655 // empty on the live class, so `currency_precision` (100) and
656 // `currency_adjective` (None) are left at their defaults too.
657
658 fn lang_name(&self) -> &str {
659 "Num2Word_KU"
660 }
661
662 /// `CURRENCY_FORMS[code]`, subscripted — this is the *strict* lookup that
663 /// Base's `to_cheque` uses, so a miss here is the NotImplementedError the
664 /// corpus expects for GBP/JPY/KWD/BHD/INR/CNY/CHF.
665 ///
666 /// Deliberately **not** the fallback-to-TRY lookup: bug 7 lives inside
667 /// `to_currency`'s own `.get(..., default)` call, and leaking it into this
668 /// hook would silently make every cheque succeed in lira.
669 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
670 self.currency_forms.get(code)
671 }
672
673 /// `Num2Word_KU.pluralize`: `"" if not forms else forms[0] if n == 1 else
674 /// forms[-1]`.
675 ///
676 /// Unreachable in practice (bug 12), but the trait default raises
677 /// NotImplementedError and KU's method plainly does not, so it is ported.
678 /// Note `forms[-1]`, not `Num2Word_EUR`'s `forms[1]`.
679 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
680 if forms.is_empty() {
681 return Ok(String::new());
682 }
683 Ok(if n.is_one() {
684 forms[0].clone()
685 } else {
686 forms[forms.len() - 1].clone()
687 })
688 }
689
690 /// `Num2Word_KU.to_currency`. Replaces Base's wholesale: no
691 /// `parse_currency_parts`, no `CURRENCY_PRECISION`, no `pluralize`, no
692 /// adjective handling, and no NotImplementedError.
693 fn to_currency(
694 &self,
695 val: &CurrencyValue,
696 currency: &str,
697 cents: bool,
698 separator: Option<&str>,
699 // `adjective` is in KU's signature and is never read (bug 9).
700 _adjective: bool,
701 ) -> Result<String> {
702 // None means the caller omitted the kwarg, so KU's own default applies.
703 let separator = separator.unwrap_or(self.default_separator());
704
705 // `is_negative = val < 0` is read from the *original* value, before
706 // abs(). `-0.0 < 0` is False in Python, and BigInt/BigDecimal have no
707 // negative zero, so both sides agree that -0.0 is not negative.
708 let is_negative = val.is_negative();
709 let (left, right) = self.currency_parts(val)?;
710
711 // Bug 7: `.get(currency, list(CURRENCY_FORMS.values())[0])`.
712 let forms = self
713 .currency_forms
714 .get(currency)
715 .unwrap_or(&self.currency_fallback);
716
717 // `cr1[1] if left != 1 else cr1[0]` — Python subscripts the tuple
718 // directly rather than calling pluralize(), so a form short of two
719 // entries would raise IndexError. Every KU entry has two, making this
720 // unreachable; mapped rather than panicked so the exception type would
721 // survive a table change (as in lang_en.rs).
722 let unit = if left.is_one() {
723 forms.unit.first()
724 } else {
725 forms.unit.get(1)
726 }
727 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
728
729 let mut result = format!("{} {}", self.int_to_word(&left), unit);
730
731 // `if cents and right:` — `right` falsy (0) skips the segment, which is
732 // why 1.0 and 12.005 render bare. cents=False drops it outright rather
733 // than falling back to _cents_terse (bug 8).
734 if cents && right != 0 {
735 let subunit = if right == 1 {
736 forms.subunit.first()
737 } else {
738 forms.subunit.get(1)
739 }
740 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
741 // Python calls _int_to_word(right); right is bounded to 0..=99 by
742 // the two-digit truncation, so the small path is the same function.
743 result.push_str(separator);
744 result.push_str(&format!("{} {}", self.int_to_word_small(right), subunit));
745 }
746
747 if is_negative {
748 result = format!("{}{}", NEGWORD, result);
749 }
750 // Python's trailing `.strip()`.
751 Ok(result.trim().to_string())
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use bigdecimal::BigDecimal;
759
760 fn ku() -> LangKu {
761 LangKu::new()
762 }
763
764 fn int(s: &str) -> CurrencyValue {
765 CurrencyValue::parse(s, true, false, false).unwrap()
766 }
767
768 /// `has_decimal` is true for every float repr, matching the shim's
769 /// `is_int = "." not in arg and "e" not in arg.lower()`.
770 fn flt(s: &str) -> CurrencyValue {
771 CurrencyValue::parse(s, false, true, true).unwrap()
772 }
773
774 fn cur(l: &LangKu, v: &CurrencyValue, code: &str) -> String {
775 l.to_currency(v, code, true, None, false).unwrap()
776 }
777
778 /// The frozen corpus rows for `to": "currency:*`, verbatim.
779 #[test]
780 fn corpus_currency_rows() {
781 let l = ku();
782 // EUR — its own entry.
783 assert_eq!(cur(&l, &int("0"), "EUR"), "sifir euro");
784 assert_eq!(cur(&l, &int("1"), "EUR"), "yek euro");
785 assert_eq!(cur(&l, &int("2"), "EUR"), "du euro");
786 assert_eq!(cur(&l, &int("100"), "EUR"), "sed euro");
787 assert_eq!(cur(&l, &flt("12.34"), "EUR"), "donzdeh euro sî û çar sent");
788 assert_eq!(cur(&l, &flt("0.01"), "EUR"), "sifir euro yek sent");
789 assert_eq!(cur(&l, &flt("1.0"), "EUR"), "yek euro");
790 assert_eq!(cur(&l, &flt("99.99"), "EUR"), "not û neh euro not û neh sent");
791 assert_eq!(
792 cur(&l, &flt("1234.56"), "EUR"),
793 "yek hezar û du sed û sî û çar euro pêncî û şeş sent"
794 );
795 assert_eq!(cur(&l, &flt("-12.34"), "EUR"), "negatîv donzdeh euro sî û çar sent");
796 assert_eq!(cur(&l, &int("1000000"), "EUR"), "yek milyon euro");
797 assert_eq!(cur(&l, &flt("0.5"), "EUR"), "sifir euro pêncî sent");
798
799 // USD — its own entry.
800 assert_eq!(cur(&l, &int("0"), "USD"), "sifir dolar");
801 assert_eq!(cur(&l, &flt("12.34"), "USD"), "donzdeh dolar sî û çar sent");
802 assert_eq!(cur(&l, &flt("-12.34"), "USD"), "negatîv donzdeh dolar sî û çar sent");
803 assert_eq!(cur(&l, &int("1000000"), "USD"), "yek milyon dolar");
804
805 // Bug 7: every code KU does not declare silently renders as TRY.
806 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
807 assert_eq!(cur(&l, &int("0"), code), "sifir lîre", "{code}");
808 assert_eq!(cur(&l, &int("1"), code), "yek lîre", "{code}");
809 assert_eq!(cur(&l, &int("2"), code), "du lîre", "{code}");
810 assert_eq!(cur(&l, &int("100"), code), "sed lîre", "{code}");
811 assert_eq!(cur(&l, &flt("12.34"), code), "donzdeh lîre sî û çar qurûş", "{code}");
812 assert_eq!(cur(&l, &flt("0.01"), code), "sifir lîre yek qurûş", "{code}");
813 assert_eq!(cur(&l, &flt("1.0"), code), "yek lîre", "{code}");
814 assert_eq!(
815 cur(&l, &flt("99.99"), code),
816 "not û neh lîre not û neh qurûş",
817 "{code}"
818 );
819 assert_eq!(
820 cur(&l, &flt("1234.56"), code),
821 "yek hezar û du sed û sî û çar lîre pêncî û şeş qurûş",
822 "{code}"
823 );
824 assert_eq!(
825 cur(&l, &flt("-12.34"), code),
826 "negatîv donzdeh lîre sî û çar qurûş",
827 "{code}"
828 );
829 assert_eq!(cur(&l, &int("1000000"), code), "yek milyon lîre", "{code}");
830 assert_eq!(cur(&l, &flt("0.5"), code), "sifir lîre pêncî qurûş", "{code}");
831 }
832 // KWD/BHD get no 3-decimal treatment: CURRENCY_PRECISION is empty.
833 assert_eq!(cur(&l, &flt("1.234"), "KWD"), "yek lîre bîst û sê qurûş");
834 }
835
836 /// The frozen corpus rows for `to": "cheque:*`, verbatim.
837 #[test]
838 fn corpus_cheque_rows() {
839 let l = ku();
840 let v = BigDecimal::from_str("1234.56").unwrap();
841 assert_eq!(
842 l.to_cheque(&v, "EUR").unwrap(),
843 "YEK HEZAR Û DU SED Û SÎ Û ÇAR AND 56/100 EURO"
844 );
845 assert_eq!(
846 l.to_cheque(&v, "USD").unwrap(),
847 "YEK HEZAR Û DU SED Û SÎ Û ÇAR AND 56/100 DOLAR"
848 );
849 // Base's to_cheque subscripts the dict, so unknown codes raise here
850 // even though to_currency quietly falls back to TRY (bug 7).
851 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
852 match l.to_cheque(&v, code) {
853 Err(N2WError::NotImplemented(m)) => assert_eq!(
854 m,
855 format!("Currency code \"{code}\" not implemented for \"Num2Word_KU\"")
856 ),
857 other => panic!("{code}: expected NotImplemented, got {other:?}"),
858 }
859 }
860 }
861
862 /// Bug 10: two-digit truncation with ljust padding, never rounding.
863 #[test]
864 fn cents_truncate_and_pad() {
865 let l = ku();
866 assert_eq!(cur(&l, &flt("12.345"), "USD"), "donzdeh dolar sî û çar sent");
867 assert_eq!(cur(&l, &flt("12.305"), "USD"), "donzdeh dolar sî sent");
868 // "005"[:2] -> "00" -> 0 -> falsy -> the whole segment vanishes.
869 assert_eq!(cur(&l, &flt("12.005"), "USD"), "donzdeh dolar");
870 assert_eq!(cur(&l, &flt("1.5"), "USD"), "yek dolar pêncî sent");
871 assert_eq!(cur(&l, &flt("0.099"), "USD"), "sifir dolar neh sent");
872 assert_eq!(cur(&l, &flt("2.999"), "USD"), "du dolar not û neh sent");
873 }
874
875 /// Bugs 8, 9 and 11, plus the -0.0 sign edge.
876 #[test]
877 fn currency_quirks() {
878 let l = ku();
879 // Bug 8: cents=False omits rather than terse-renders.
880 assert_eq!(
881 l.to_currency(&flt("12.34"), "USD", false, None, false).unwrap(),
882 "donzdeh dolar"
883 );
884 // Bug 9: adjective is ignored.
885 assert_eq!(
886 l.to_currency(&flt("12.34"), "USD", true, None, true).unwrap(),
887 "donzdeh dolar sî û çar sent"
888 );
889 // Bug 11: _int_to_word's digit fallback reaches money.
890 assert_eq!(cur(&l, &int("1000000000"), "USD"), "1000000000 dolar");
891 assert_eq!(
892 cur(&l, &flt("1234567890.12"), "USD"),
893 "1234567890 dolar donzdeh sent"
894 );
895 // -0.0 is not < 0 in Python; no negword.
896 assert_eq!(cur(&l, &flt("-0.0"), "USD"), "sifir dolar");
897 assert_eq!(cur(&l, &flt("-0.5"), "USD"), "negatîv sifir dolar pêncî sent");
898 assert_eq!(cur(&l, &int("-1"), "USD"), "negatîv yek dolar");
899 // Bug 13: the separator is concatenated raw, with no space after it.
900 // Base writes "%s%s %s %s" (space *after* the separator); KU writes
901 // `result += separator + ...`. The default " " hides the difference;
902 // any other separator exposes it.
903 assert_eq!(
904 l.to_currency(&flt("12.34"), "USD", true, Some(","), false).unwrap(),
905 "donzdeh dolar,sî û çar sent"
906 );
907 }
908
909 /// `precision` as the harness computes it:
910 /// `abs(Decimal(str(v)).as_tuple().exponent)`. For a value Rust's `Display`
911 /// prints with no ".", Python's `str` still writes ".0", so precision is 1.
912 fn prec(v: f64) -> u32 {
913 match format!("{}", v.abs()).split_once('.') {
914 Some((_, f)) => f.len() as u32,
915 None => 1,
916 }
917 }
918
919 fn cf(l: &LangKu, v: f64) -> String {
920 l.to_cardinal_float(&FloatValue::Float { value: v, precision: prec(v) }, None)
921 .unwrap()
922 }
923
924 /// The frozen corpus rows for `to": "cardinal` with a float `arg`, verbatim.
925 #[test]
926 fn corpus_cardinal_float_rows() {
927 let l = ku();
928 assert_eq!(cf(&l, 0.0), "sifir xal sifir");
929 assert_eq!(cf(&l, 0.5), "sifir xal pênc");
930 assert_eq!(cf(&l, 1.0), "yek xal sifir");
931 assert_eq!(cf(&l, 1.5), "yek xal pênc");
932 assert_eq!(cf(&l, 2.25), "du xal du pênc");
933 assert_eq!(cf(&l, 3.14), "sê xal yek çar");
934 assert_eq!(cf(&l, 0.01), "sifir xal sifir yek");
935 assert_eq!(cf(&l, 0.1), "sifir xal yek");
936 assert_eq!(cf(&l, 0.99), "sifir xal neh neh");
937 assert_eq!(cf(&l, 1.01), "yek xal sifir yek");
938 assert_eq!(cf(&l, 12.34), "donzdeh xal sê çar");
939 assert_eq!(cf(&l, 99.99), "not û neh xal neh neh");
940 assert_eq!(cf(&l, 100.5), "sed xal pênc");
941 assert_eq!(cf(&l, 1234.56), "yek hezar û du sed û sî û çar xal pênc şeş");
942 assert_eq!(cf(&l, -0.5), "negatîv sifir xal pênc");
943 assert_eq!(cf(&l, -1.5), "negatîv yek xal pênc");
944 assert_eq!(cf(&l, -12.34), "negatîv donzdeh xal sê çar");
945 // The two f64-artefact cases: KU reads str() digits, so no rounding.
946 assert_eq!(cf(&l, 1.005), "yek xal sifir sifir pênc");
947 assert_eq!(cf(&l, 2.675), "du xal şeş heft pênc");
948 }
949
950 /// Live-interpreter edges not in the frozen corpus: -0.0 (sign bit),
951 /// bug 1's digit fallback leaking into the integer part, and sci-notation
952 /// floats that make `int()` raise ValueError.
953 #[test]
954 fn cardinal_float_edges() {
955 let l = ku();
956 // str(-0.0) == "-0.0" starts with "-": negword is prepended.
957 assert_eq!(cf(&l, -0.0), "negatîv sifir xal sifir");
958 // Bug 1: the >=1e9 integer part renders as bare digits.
959 assert_eq!(cf(&l, 1234567890.12), "1234567890 xal yek du");
960 // Python str(1e16)=="1e+16" -> int() ValueError; likewise 1e-5, inf/nan.
961 for v in [1e16, 1.5e16, 1e-5, f64::INFINITY, f64::NAN] {
962 assert!(
963 matches!(
964 l.to_cardinal_float(&FloatValue::Float { value: v, precision: 1 }, None),
965 Err(N2WError::Value(_))
966 ),
967 "{v} should raise ValueError"
968 );
969 }
970 // 1e-4 is the boundary: Python keeps it non-exponential ("0.0001"),
971 // so all four fractional digits (0,0,0,1) render.
972 assert_eq!(cf(&l, 1e-4), "sifir xal sifir sifir sifir yek");
973 }
974
975 /// The frozen corpus rows for `to": "cardinal_dec` (Decimal input), verbatim.
976 /// These route through the base path, which is exact for Decimals.
977 #[test]
978 fn corpus_cardinal_dec_rows() {
979 let l = ku();
980 let dec = |s: &str| {
981 let precision = s.split_once('.').map(|(_, f)| f.len() as u32).unwrap_or(0);
982 FloatValue::Decimal {
983 value: BigDecimal::from_str(s).unwrap(),
984 precision,
985 }
986 };
987 let d = |s: &str| l.to_cardinal_float(&dec(s), None).unwrap();
988 assert_eq!(d("0.01"), "sifir xal sifir yek");
989 assert_eq!(d("1.10"), "yek xal yek sifir");
990 assert_eq!(d("12.345"), "donzdeh xal sê çar pênc");
991 // Issue #603: exact at trillion scale, no float() round.
992 assert_eq!(d("98746251323029.99"), "98746251323029 xal neh neh");
993 assert_eq!(d("0.001"), "sifir xal sifir sifir yek");
994 }
995
996 /// Bug 12: ported despite being unreachable. Note forms[-1], not forms[1].
997 #[test]
998 fn pluralize_takes_last_form() {
999 let l = ku();
1000 let three: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1001 assert_eq!(l.pluralize(&BigInt::from(1), &three).unwrap(), "a");
1002 assert_eq!(l.pluralize(&BigInt::from(2), &three).unwrap(), "c");
1003 assert_eq!(l.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1004 }
1005
1006 /// str() of a value with a positive net exponent feeds an "e" to int().
1007 #[test]
1008 fn exponent_form_raises_value_error() {
1009 let l = ku();
1010 // "1e+21" parses to scale -21; Python: int("1e+21") -> ValueError.
1011 assert!(matches!(
1012 l.to_currency(&flt("1e+21"), "USD", true, None, false),
1013 Err(N2WError::Value(_))
1014 ));
1015 // A plain integral Decimal ("5", scale 0) must NOT trip that branch.
1016 assert_eq!(cur(&l, &flt("5"), "USD"), "pênc dolar");
1017 }
1018}