Skip to main content

num2words2_core/
lang_yi.rs

1//! Port of `lang_YI.py` (Yiddish).
2//!
3//! Shape: **self-contained**. `Num2Word_YI` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives a recursive `_int_to_word`. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check at all** — `_int_to_word` falls back to `str(number)`
9//! above 10**9 instead of raising (see bug 3 below).
10//!
11//! Nothing in the four in-scope modes can raise: `to_cardinal` strips the sign
12//! from the *string* before `int()`, every table index is range-guarded by the
13//! `< 10` / `< 100` / `< 1000` cascade, and the `else` arm swallows everything
14//! else. So this port returns `Ok(..)` unconditionally and never constructs an
15//! `N2WError`.
16//!
17//! Inherited from `Num2Word_Base` (YI overrides `to_year` but to the same
18//! effect, and does not touch `verify_ordinal`):
19//!   * `to_year(val, longval=True) -> self.to_cardinal(val)` — the `longval`
20//!     parameter is accepted and ignored, so years are plain cardinals with no
21//!     century splitting: `to_year(1900)` == `to_cardinal(1900)` ==
22//!     "איינס טויזנט נײַן הונדערט", *not* "nineteen hundred".
23//!   * `verify_ordinal` is **never called** — `to_ordinal` accepts negatives
24//!     and simply suffixes the (sign-bearing) cardinal, so `to_ordinal(-1)` ==
25//!     "minus איינס-טער" rather than raising TypeError.
26//!
27//! # Faithfully reproduced Python bugs
28//!
29//! This is a port, not a rewrite. Everything below looks wrong and is exactly
30//! what Python emits — all verified against the frozen corpus:
31//!
32//! 1. **Yiddish has no teens and no "un-" tens compounding.** `_int_to_word`
33//!    handles 10..99 as `tens[n // 10] + " " + ones[n % 10]`, unconditionally.
34//!    There is no `low_numwords`-style 11..19 table, so 11 renders as
35//!    "צען איינס" (literally "ten one") and 12 as "צען צוויי". Real Yiddish
36//!    would use elf/tsvelf and put the unit first ("ein un tsvantsik"); the
37//!    library does neither. 21 == "צוואַנציק איינס".
38//! 2. **Hundreds/thousands/millions take no multiplier elision.** The
39//!    `< 1000` arm is `self.ones[hundreds_val] + " " + self.hundred` with no
40//!    `if hundreds_val > 1` guard, so 100 == "איינס הונדערט" ("one hundred"),
41//!    and by recursion 1000 == "איינס טויזנט" and 10**6 == "איינס מיליאָן".
42//! 3. **Above 10**9 the converter gives up and returns the digits.** The final
43//!    `else` arm of `_int_to_word` is literally `return str(number)`, so
44//!    `to_cardinal(10**9)` == "1000000000" — a *digit string*, not words. It
45//!    does not raise OverflowError; there is no `MAXVAL`. `to_ordinal(10**9)`
46//!    therefore yields "1000000000-טער". Modelled by [`LangYi::int_to_word`].
47//! 4. **`ones[0]` is `""`, so zero routes through a dead ternary to the
48//!    English word.** `_int_to_word` opens with
49//!    `return self.ones[0] if self.ones[0] else "zero"` — `""` is falsy, so the
50//!    branch is unreachable and zero is always the *English* "zero", never a
51//!    Yiddish word. Hence `to_cardinal(0)` == "zero" and `to_ordinal(0)` ==
52//!    "zero-טער". See [`ZERO`].
53//! 5. **`negword` is the English "minus "** (not "מינוס"), and `to_cardinal`
54//!    prepends it raw rather than via `parse_minus`, so the output mixes
55//!    scripts: `to_cardinal(-100)` == "minus איינס הונדערט".
56//! 6. **`to_ordinal` is cardinal + a hyphenated suffix, with no agreement and
57//!    no last-word inflection.** `return cardinal + "-טער"` — the suffix is
58//!    glued to whatever came out, including the digit-string fallback (bug 3)
59//!    and the English "zero" (bug 4).
60//!
61//! # Dead code in the source, reproduced anyway
62//!
63//! `_int_to_word`'s `if number < 0` arm is unreachable from all four integer
64//! modes: `to_cardinal` slices the "-" off the *string* (`n = n[1:]`) before
65//! `int(n)`, so `_int_to_word` only ever sees a non-negative value. It is
66//! unreachable from `to_currency` too, which takes `abs(val)` before splitting.
67//! It is kept in [`LangYi::int_to_word`] for fidelity — with the same ordering
68//! quirk that `number == 0` is tested *before* `number < 0`.
69//!
70//! # Currency
71//!
72//! `Num2Word_YI` overrides `to_currency` **wholesale** and shares almost
73//! nothing with `Num2Word_Base`'s version. It does not call `pluralize`, does
74//! not consult `CURRENCY_PRECISION`, does not use `parse_currency_parts`, and
75//! ignores its own `adjective` parameter. So `currency::default_to_currency` is
76//! bypassed entirely and the divisor is hard-wired to 100 for every code.
77//!
78//! `to_cheque` is *not* overridden, so `Num2Word_Base.to_cheque` runs — which
79//! means the two entry points disagree about unknown currency codes:
80//!
81//! | code       | `to_currency`            | `to_cheque`          |
82//! |------------|--------------------------|----------------------|
83//! | EUR / USD  | its own forms            | its own forms        |
84//! | anything else | silently uses **EUR** | NotImplementedError  |
85//!
86//! That asymmetry is bug 7 below and is exactly what the corpus records.
87//!
88//! ## More faithfully reproduced Python bugs
89//!
90//! 7. **`to_currency` never raises for an unknown code.** The lookup is
91//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
92//!    — a `.get` with a *default*, not a `[]`. The default is the first value in
93//!    a dict written `{"EUR": ..., "USD": ...}`, so every unrecognised code
94//!    (GBP, JPY, KWD, INR, ...) renders in euros: `to_currency(1, "GBP")` ==
95//!    "איינס אייראָ". Only `to_cheque`, inherited from the base, raises
96//!    `NotImplementedError`. See [`FALLBACK_CODE`].
97//! 8. **Cents are truncated to two digits and right-padded, never rounded.**
98//!    `int(parts[1][:2].ljust(2, "0"))` slices the *decimal string*: `0.5`
99//!    means **50** cents (not 5), `1.239` gives 23 cents (not 24), and `1.005`
100//!    gives 0. Modelled arithmetically as `floor(frac * 100)`.
101//! 9. **A zero cents value suppresses the whole segment**, because the guard is
102//!    the truthiness test `if cents and right:`. So the float `1.0` prints
103//!    "איינס אייראָ" with no cents — the `isinstance(val, int)` split that
104//!    `Num2Word_Base.to_currency` relies on is absent here, and `1` and `1.0`
105//!    coincidentally agree. `1.005` loses its cents the same way (bug 8).
106//! 10. **Currency mixes scripts and pluralizes in English.** USD carries the
107//!     ASCII `("dollar", "dollars")`/`("cent", "cents")` rather than Yiddish, so
108//!     `to_currency(2, "USD")` == "צוויי dollars", and a negative prepends the
109//!     English `negword`: "minus צען צוויי dollars דרײַסיק פיר cents".
110//! 11. **EUR's singular and plural are the same string** (`("אייראָ", "אייראָ")`,
111//!     `("צענט", "צענט")`), so the `left != 1` test is a no-op for EUR — and for
112//!     every code that falls back to it (bug 7).
113//! 12. **`to_currency(0.0..1)` says "zero".** `left == 0` routes through
114//!     `_int_to_word(0)`, which is the English "zero" (bug 4): `to_currency(0.01)`
115//!     == "zero אייראָ איינס צענט".
116//!
117//! ## Unreachable from Rust: the sci-notation `ValueError`
118//!
119//! `to_currency` does `int(str(abs(val)).split(".")[0])`. Python's `repr` of a
120//! float switches to exponent form at `1e16` and below `1e-4`, and `int()`
121//! rejects that spelling — so `to_currency(1e20)` raises **ValueError**
122//! (`invalid literal for int() with base 10: '1e+20'`), as does `1e-5`. The
123//! shim hands this core `str(value)` already parsed into a `BigDecimal`, so the
124//! "1e+20" *spelling* — the sole cause of the crash — is gone before the port
125//! can see it, and these inputs render normally here instead of raising. No
126//! corpus row covers it; see the reported concerns.
127//!
128//! # The float / Decimal cardinal path
129//!
130//! `Num2Word_YI` does **not** define `to_cardinal_float`; it overrides
131//! `to_cardinal` and handles non-integers *inline*, working entirely on the
132//! *string* `str(number)`:
133//!
134//! ```python
135//! n = str(number).strip()
136//! if n.startswith("-"):
137//!     n = n[1:]
138//!     ret = self.negword
139//! else:
140//!     ret = ""
141//! if "." in n:
142//!     left, right = n.split(".", 1)
143//!     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
144//!     for digit in right:
145//!         ret += self._int_to_word(int(digit)) + " "
146//!     return ret.strip()
147//! else:
148//!     return (ret + self._int_to_word(int(n))).strip()
149//! ```
150//!
151//! So the fraction is the literal digits of the repr, one word per character —
152//! no `float2tuple`, no `10**precision`, no `< 0.01` rescue, no rounding and no
153//! padding. The port therefore reproduces `str()` exactly ([`py_str_f64`] /
154//! [`py_str_decimal`]) and runs the same string body ([`LangYi::cardinal_from_repr`]).
155//! Because the base float2tuple is *not* on this path, [`to_cardinal_float`]'s
156//! `precision_override` (the `precision=` kwarg) is **inert** — `to_cardinal`
157//! has no such parameter and never reads `self.precision`, verified against the
158//! live interpreter.
159//!
160//! [`to_cardinal_float`]: LangYi
161//!
162//! Three consequences, all matching the live interpreter and reproduced here:
163//!
164//! * **The f64 artefacts come from `repr`, not arithmetic.** `2.675` prints
165//!   "זעקס זיבן פינף" (6 7 5) because `str(2.675) == "2.675"` (shortest
166//!   round-trip), and `1.005` prints "zero zero פינף" because
167//!   `str(1.005) == "1.005"`. There is no `674.9999…`→`675` heuristic here; the
168//!   repr already carries the intended digits, so `py_str_f64` must be a
169//!   byte-exact `repr` — including CPython's tie-to-**even** last digit, which
170//!   Rust's shortest `{:e}` alone gets wrong (see [`shortest_repr_digits`]).
171//! * **A Decimal keeps every written digit.** `str(Decimal("1.10")) == "1.10"`,
172//!   so the trailing "zero" appears — something the float `1.1` could never
173//!   express (`str(1.1) == "1.1"`). Handled by [`py_str_decimal`], not
174//!   `BigDecimal`'s own `Display`, which normalises differently.
175//! * **An exponent-form repr reaches `int()` and raises `ValueError`.** Unlike
176//!   the `to_currency` case above — where the shim pre-parses away the spelling
177//!   — the float path carries the raw `f64`, so `py_str_f64` reconstructs the
178//!   real repr and the crash *is* reproducible: `to_cardinal_float(1e16)` and
179//!   `(1e-5)` raise `ValueError: invalid literal for int() with base 10:
180//!   '1e+16'` / `'1e-05'`, and `1.5e20` (which keeps a ".") dies on the single
181//!   char `'e'` after already emitting the `5`. See bug 13 below.
182//!
183//! ## One more faithfully reproduced Python bug
184//!
185//! 13. **`_int_to_word(int(left))` inherits the `>= 10**9` digit-string
186//!     fallback (bug 3).** The integer part of a large value comes back as bare
187//!     digits: `to_cardinal_float(Decimal("98746251323029.99"))` ==
188//!     "98746251323029 point נײַן נײַן", not words.
189
190use crate::base::{Lang, N2WError, Result};
191use crate::currency::{CurrencyForms, CurrencyValue};
192use crate::floatpath::FloatValue;
193use crate::strnum::{python_decimal_parse, ParsedNumber};
194use bigdecimal::BigDecimal;
195use num_bigint::BigInt;
196use num_traits::{One, Signed, ToPrimitive, Zero};
197use std::collections::HashMap;
198use std::str::FromStr;
199
200/// `_int_to_word(0)`. Python computes `self.ones[0] if self.ones[0] else "zero"`
201/// and `self.ones[0]` is `""` — falsy — so this English literal is the only
202/// reachable result. See module bug 4.
203const ZERO: &str = "zero";
204
205/// `self.negword`. English, with a trailing space, and used verbatim (not via
206/// `parse_minus`, which would `.strip()` it). See module bug 5.
207const NEGWORD: &str = "minus ";
208
209/// `self.ones`; index 0 is `""` in Python and is never reached as a word —
210/// the `number == 0` guard fires first. See module bug 4.
211const ONES: [&str; 10] = [
212    "",
213    "איינס",
214    "צוויי",
215    "דרײַ",
216    "פיר",
217    "פינף",
218    "זעקס",
219    "זיבן",
220    "אַכט",
221    "נײַן",
222];
223
224/// `self.tens`; index 0 is `""` and unreachable (`number < 10` handles it).
225/// Index 1 is "צען" (ten) — Yiddish here has no separate teens table, so this
226/// entry doubles as the tens-digit word for 10..19. See module bug 1.
227const TENS: [&str; 10] = [
228    "",
229    "צען",
230    "צוואַנציק",
231    "דרײַסיק",
232    "פערציק",
233    "פופציק",
234    "זעכציק",
235    "זיבעציק",
236    "אַכציק",
237    "נײַנציק",
238];
239
240/// `self.hundred`.
241const HUNDRED: &str = "הונדערט";
242/// `self.thousand`.
243const THOUSAND: &str = "טויזנט";
244/// `self.million`.
245const MILLION: &str = "מיליאָן";
246
247/// `to_ordinal`'s suffix, glued on with a literal hyphen. See module bug 6.
248const ORDINAL_SUFFIX: &str = "-טער";
249
250/// The ceiling of `_int_to_word`'s word-producing cascade. At or above this,
251/// Python returns `str(number)`. See module bug 3.
252const FALLBACK_LIMIT: u64 = 1_000_000_000;
253
254/// `self.__class__.__name__`, for the NotImplementedError `to_cheque` raises.
255const LANG_NAME: &str = "Num2Word_YI";
256
257/// `CURRENCY_FORMS["EUR"]`'s unit word. Python stores it in *both* the singular
258/// and the plural slot, so `left != 1` cannot change the output. See bug 11.
259const EURO: &str = "אייראָ";
260/// `CURRENCY_FORMS["EUR"]`'s subunit word — likewise identical in both slots.
261const EURO_CENT: &str = "צענט";
262
263/// The code that `list(self.CURRENCY_FORMS.values())[0]` resolves to.
264///
265/// `CURRENCY_FORMS` is the dict literal `{"EUR": ..., "USD": ...}` and Python
266/// dicts preserve insertion order, so the first value is always EUR's. That
267/// value is `to_currency`'s `.get(currency, <default>)` fallback, which is why
268/// an unknown code renders in euros rather than raising. See module bug 7.
269const FALLBACK_CODE: &str = "EUR";
270
271/// `Num2Word_YI.to_currency`'s declared default `separator=" "`, which
272/// *overrides* `Num2Word_Base.to_currency`'s `separator=","`.
273///
274/// Neither caller into this core can express "the caller omitted `separator`":
275/// `__init__.py` sends `kwargs.get("separator", ",")` and `bench/diff_test.py`
276/// hardcodes `","`. Both substitute the **base** default, so YI's own default
277/// is erased before the value reaches Rust. The corpus was frozen from the pure
278/// Python path, where the omitted kwarg correctly resolves to `" "`:
279///
280/// ```text
281/// {"lang": "yi", "to": "currency:EUR", "arg": "12.34",
282///  "out": "צען צוויי אייראָ דרײַסיק פיר צענט"}
283/// ```
284///
285/// — a space, not a comma. So [`separator_for`] reads an incoming `","` as
286/// "unsupplied" and restores YI's default. See the reported concerns: this is a
287/// boundary defect shared by every language whose `to_currency` overrides the
288/// separator default (DE `" und"`, ES `" y"`, BG `" и"`, ~50 more).
289const DEFAULT_SEPARATOR: &str = " ";
290
291/// `Num2Word_Base.to_currency`'s `separator=","`, which both call sites send
292/// verbatim when the caller supplies nothing. See [`DEFAULT_SEPARATOR`].
293const BASE_SEPARATOR_DEFAULT: &str = ",";
294
295/// Resolve the separator YI's Python signature would have applied.
296///
297/// An explicit `separator=","` is byte-identical to an omitted one by the time
298/// it arrives here, so it renders as `" "` — the one input this cannot get
299/// right, and one no implementation behind this ABI could. Every other explicit
300/// separator passes through untouched and stays faithful.
301fn separator_for(passed: &str) -> &str {
302    if passed == BASE_SEPARATOR_DEFAULT {
303        DEFAULT_SEPARATOR
304    } else {
305        passed
306    }
307}
308
309// ---- the str()-based float path -------------------------------------------
310//
311// `Num2Word_YI.to_cardinal` runs on `str(number)`, so the float path is pure
312// string work. These helpers reproduce CPython's `str()` byte-for-byte and its
313// `int()` failure modes; they are numeric formatters and carry no Yiddish word,
314// so they mirror the verified `lang_ceb` helpers exactly.
315
316/// Python's `int(s)` for a whole token. The error message quotes the offending
317/// literal exactly as CPython does, so an exponent-form repr (`"1e+16"`,
318/// `"1E-7"`) surfaces the identical `ValueError` text.
319fn py_int(s: &str) -> Result<BigInt> {
320    BigInt::from_str(s)
321        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
322}
323
324/// Python's `int(ch)` for a *single* character, as the fraction loop calls it.
325///
326/// The message quotes the one offending character, matching CPython — that is
327/// why `1.5e+20` reports `'e'` (after already emitting the `5`) rather than
328/// `'5e+20'`. `char::to_digit(10)` is ASCII-only, but no `str(float)` /
329/// `str(Decimal)` can emit a non-ASCII digit, so the two agree everywhere the
330/// fraction loop can actually reach.
331fn py_int_digit(ch: char) -> Result<usize> {
332    ch.to_digit(10).map(|d| d as usize).ok_or_else(|| {
333        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
334    })
335}
336
337/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
338/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
339///
340/// Rust's `{:e}` and CPython's `repr` are both "shortest string that reads back
341/// as the same double" and agree on the digit count always and the digits
342/// themselves almost always — they part company only on an **exact tie**, where
343/// CPython breaks to **even** and Rust's shortest `{:e}` breaks **away from
344/// zero** (e.g. the double `670352580196876.25` reprs `...876.2`, not `.3`).
345/// Since YI's whole fraction *is* the repr string, a wrong last digit is a
346/// wrong word. Rust's fixed-precision `{:.*e}` rounds half-to-even, so
347/// re-emitting the same significant-digit count applies CPython's tie rule; it
348/// is kept only if it still round-trips, otherwise we fall back to the shortest
349/// digits rather than emit a string that reads back as a different double.
350fn shortest_repr_digits(a: f64) -> (String, i32) {
351    let split = |s: &str| -> (String, i32) {
352        let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
353        (
354            mant.chars().filter(|c| *c != '.').collect(),
355            // `{:e}` normalises to exactly one digit before the point, so the
356            // decimal point sits one place left of where CPython counts it.
357            exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
358        )
359    };
360
361    let shortest = format!("{:e}", a);
362    let (digits, decpt) = split(&shortest);
363
364    let ties_even = format!("{:.*e}", digits.len() - 1, a);
365    if ties_even.parse::<f64>() == Ok(a) {
366        return split(&ties_even);
367    }
368    (digits, decpt)
369}
370
371/// CPython's `str(float)` / `repr(float)` — where YI's float path begins.
372///
373/// `PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)`: shortest round-trip
374/// digits, then switch to exponent form when `decpt <= -4 || decpt > 16`
375/// (`str(1e15) == "1000000000000000.0"` but `str(1e16) == "1e+16"`;
376/// `str(0.0001) == "0.0001"` but `str(1e-05) == "1e-05"`), formatting the
377/// exponent `%+.02d`; otherwise print positionally and append `.0` if nothing
378/// follows the point (the reason `1.0` is "איינס point zero", not "איינס").
379fn py_str_f64(v: f64) -> String {
380    // Unreachable from the shim (which derives `precision` from a finite
381    // Decimal repr), but handled so this is a faithful `str()` rather than a
382    // faithful-in-context one; `cardinal_from_repr` would then raise on
383    // `int("inf")`/`int("nan")`, exactly as Python does.
384    if v.is_nan() {
385        return "nan".to_string();
386    }
387    if v.is_infinite() {
388        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
389    }
390
391    // Sign from the sign *bit*, not `v < 0.0`, so `str(-0.0) == "-0.0"` and the
392    // leading-"-" branch fires: -0.0 renders "minus zero point zero".
393    let sign = if v.is_sign_negative() { "-" } else { "" };
394    let (digits, decpt) = shortest_repr_digits(v.abs());
395    let ndig = digits.len() as i32;
396
397    if decpt <= -4 || decpt > 16 {
398        let mantissa = if ndig > 1 {
399            format!("{}.{}", &digits[..1], &digits[1..])
400        } else {
401            // No ADD_DOT_0 in exponent form: `str(1e16)` is "1e+16".
402            digits.clone()
403        };
404        let exp = decpt - 1;
405        let (esign, eabs) = if exp < 0 {
406            ("-", -(exp as i64))
407        } else {
408            ("+", exp as i64)
409        };
410        return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
411    }
412    if decpt <= 0 {
413        // "0." + leading zeros + digits: 0.5 -> "0.5", 1e-4 -> "0.0001".
414        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
415    } else if decpt >= ndig {
416        // Nothing after the point, so Py_DTSF_ADD_DOT_0 supplies ".0".
417        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
418    } else {
419        format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
420    }
421}
422
423/// CPython's `Decimal.__str__` (`to-scientific-string`), ported from
424/// `_pydecimal.Decimal.__str__`. `BigDecimal`'s own `Display` disagrees on
425/// `1E+2` (would give `100`), `0.0` (would give `0`) and the exponent's case,
426/// each of which would change YI's output or its exception, so the digits and
427/// exponent are read off `as_bigint_and_exponent()` and reassembled by Python's
428/// rule. `BigDecimal::from_str` keeps the written scale rather than normalising
429/// (`"1.10"` stays coefficient 110 / scale 2), which is what makes the trailing
430/// "zero" appear, and `(coefficient, -scale)` is exactly Python's `(_int, _exp)`.
431fn py_str_decimal(value: &BigDecimal) -> String {
432    // BigDecimal stores value = coefficient * 10^-scale, so Python's `_exp`
433    // (which counts the other way) is the negated scale.
434    let (coefficient, scale) = value.as_bigint_and_exponent();
435    let exp = -scale;
436    let sign = if coefficient.is_negative() { "-" } else { "" };
437    let int_digits = coefficient.abs().to_string();
438    let ndig = int_digits.len() as i64;
439
440    let leftdigits = exp + ndig;
441    let dotplace = if exp <= 0 && leftdigits > -6 {
442        leftdigits
443    } else {
444        1
445    };
446
447    let (intpart, fracpart) = if dotplace <= 0 {
448        (
449            "0".to_string(),
450            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
451        )
452    } else if dotplace >= ndig {
453        (
454            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
455            String::new(),
456        )
457    } else {
458        (
459            int_digits[..dotplace as usize].to_string(),
460            format!(".{}", &int_digits[dotplace as usize..]),
461        )
462    };
463
464    // `['e', 'E'][context.capitals]`, and the default context has capitals=1.
465    let exponent = if leftdigits == dotplace {
466        String::new()
467    } else {
468        format!("E{:+}", leftdigits - dotplace)
469    };
470
471    format!("{}{}{}{}", sign, intpart, fracpart, exponent)
472}
473
474pub struct LangYi {
475    /// `CURRENCY_FORMS`, built once. `get_lang` keeps the `LangYi` in a
476    /// `OnceLock` and hands out `&'static` refs, so this table is constructed
477    /// exactly once per process rather than per call.
478    forms: HashMap<&'static str, CurrencyForms>,
479    /// `list(self.CURRENCY_FORMS.values())[0]` — the entry `to_currency` falls
480    /// back to for an unknown code (module bug 7). Cloned out of `forms` rather
481    /// than re-spelled so the two cannot drift apart.
482    fallback_forms: CurrencyForms,
483}
484
485impl LangYi {
486    pub fn new() -> Self {
487        let mut forms = HashMap::new();
488        // Both EUR slots hold the same word; the arity (2) still matters
489        // because to_currency indexes cr1[0]/cr1[1] directly. See bug 11.
490        forms.insert("EUR", CurrencyForms::new(&[EURO, EURO], &[EURO_CENT, EURO_CENT]));
491        // USD is ASCII English, not Yiddish — see module bug 10.
492        forms.insert(
493            "USD",
494            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
495        );
496        let fallback_forms = forms[FALLBACK_CODE].clone();
497        LangYi { forms, fallback_forms }
498    }
499
500    /// Python's `_int_to_word`.
501    ///
502    /// The `< 0` arm is dead from every in-scope caller (see module docs) but
503    /// is reproduced, including the quirk that `== 0` is tested first.
504    fn int_to_word(&self, number: &BigInt) -> String {
505        if number.is_zero() {
506            return ZERO.to_string();
507        }
508        if number.is_negative() {
509            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
510        }
511        // Python's final `else: return str(number)`. Hoisted above the u64
512        // narrowing because it is what makes that narrowing sound.
513        if number >= &BigInt::from(FALLBACK_LIMIT) {
514            return number.to_string();
515        }
516        // Proven bounded: 0 < number < 10**9, so the value fits u64 and the
517        // cascade below can use fixed-width arithmetic without truncating.
518        let n = number
519            .to_u64()
520            .expect("0 < number < 10**9 always fits in u64");
521        self.int_to_word_small(n)
522    }
523
524    /// The word-producing cascade of `_int_to_word`, for `0 < n < 10**9`.
525    ///
526    /// Recurses on remainders only (never on 0 — Python guards each with
527    /// `if remainder:`), so `n == 0` cannot arrive here from `int_to_word`.
528    /// The `n == 0` guard is kept anyway to mirror Python's own entry check.
529    fn int_to_word_small(&self, n: u64) -> String {
530        if n == 0 {
531            return ZERO.to_string();
532        }
533        if n < 10 {
534            ONES[n as usize].to_string()
535        } else if n < 100 {
536            let tens_val = (n / 10) as usize;
537            let ones_val = (n % 10) as usize;
538            if ones_val == 0 {
539                // No teens table: 10 => "צען", 20 => "צוואַנציק". Bug 1.
540                TENS[tens_val].to_string()
541            } else {
542                // 11 => "צען איינס", 21 => "צוואַנציק איינס". Bug 1.
543                format!("{} {}", TENS[tens_val], ONES[ones_val])
544            }
545        } else if n < 1000 {
546            let hundreds_val = (n / 100) as usize;
547            let remainder = n % 100;
548            // Direct ONES index, not a recursive call — and no elision for
549            // hundreds_val == 1, so 100 => "איינס הונדערט". Bug 2.
550            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
551            if remainder != 0 {
552                result.push(' ');
553                result.push_str(&self.int_to_word_small(remainder));
554            }
555            result
556        } else if n < 1_000_000 {
557            let thousands_val = n / 1000;
558            let remainder = n % 1000;
559            let mut result = format!("{} {}", self.int_to_word_small(thousands_val), THOUSAND);
560            if remainder != 0 {
561                result.push(' ');
562                result.push_str(&self.int_to_word_small(remainder));
563            }
564            result
565        } else {
566            // n < 10**9 — the caller already handled the str(number) fallback.
567            let millions_val = n / 1_000_000;
568            let remainder = n % 1_000_000;
569            let mut result = format!("{} {}", self.int_to_word_small(millions_val), MILLION);
570            if remainder != 0 {
571                result.push(' ');
572                result.push_str(&self.int_to_word_small(remainder));
573            }
574            result
575        }
576    }
577
578    /// Python's `Num2Word_YI.to_cardinal` operating on `str(number)` — the
579    /// form its own code works in for a non-integer. `n` is already the repr
580    /// ([`py_str_f64`] / [`py_str_decimal`] produced it):
581    ///
582    /// ```python
583    /// n = str(number).strip()
584    /// if n.startswith("-"):
585    ///     n = n[1:]
586    ///     ret = self.negword
587    /// else:
588    ///     ret = ""
589    /// if "." in n:
590    ///     left, right = n.split(".", 1)
591    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
592    ///     for digit in right:
593    ///         ret += self._int_to_word(int(digit)) + " "
594    ///     return ret.strip()
595    /// else:
596    ///     return (ret + self._int_to_word(int(n))).strip()
597    /// ```
598    ///
599    /// The sign is stripped from the *string* before the dot check, so both
600    /// branches carry the raw `negword` ("minus ", not via `parse_minus`).
601    /// `split(".", 1)` splits on the first dot only; `int(left)` / `int(digit)`
602    /// raise `ValueError` on an exponent-form repr (`"1e+16"`) or a stray `'e'`.
603    /// `int(left)` inherits `int_to_word`'s `>= 10**9` digit-string fallback
604    /// (bug 13). `self.pointword` is used raw — no `title`, matching the inline
605    /// body (unlike `Num2Word_Base.to_cardinal_float`, which title-cases it).
606    fn cardinal_from_repr(&self, n: &str) -> Result<String> {
607        // n = str(number).strip(). Python strips its own whitespace set and
608        // Rust trims Unicode's; no repr contains either.
609        let n = n.trim();
610
611        // if n.startswith("-"): n = n[1:]; ret = self.negword; else: ret = ""
612        let (n, ret) = match n.strip_prefix('-') {
613            Some(rest) => (rest, NEGWORD),
614            None => (n, ""),
615        };
616
617        match n.split_once('.') {
618            // if "." in n:
619            Some((left, right)) => {
620                // ret += int_to_word(int(left)) + " " + pointword + " "
621                let mut out = String::from(ret);
622                out.push_str(&self.int_to_word(&py_int(left)?));
623                out.push(' ');
624                out.push_str(self.pointword());
625                out.push(' ');
626                // for digit in right: ret += int_to_word(int(digit)) + " "
627                for digit in right.chars() {
628                    out.push_str(&self.int_to_word(&BigInt::from(py_int_digit(digit)?)));
629                    out.push(' ');
630                }
631                // return ret.strip()
632                Ok(out.trim().to_string())
633            }
634            // else: return (ret + int_to_word(int(n))).strip()
635            None => Ok(format!("{}{}", ret, self.int_to_word(&py_int(n)?))
636                .trim()
637                .to_string()),
638        }
639    }
640}
641
642impl Default for LangYi {
643    fn default() -> Self {
644        Self::new()
645    }
646}
647
648impl Lang for LangYi {
649
650    fn cardinal_float_entry(
651        &self,
652        value: &crate::floatpath::FloatValue,
653        precision_override: Option<u32>,
654    ) -> crate::base::Result<String> {
655        // Python's to_cardinal routes every float/Decimal through this
656        // language's own decimal grammar — 5.0 keeps its ".0" tail
657        // ("comma nulla"), unlike Base's whole-value integer route.
658        self.to_cardinal_float(value, precision_override)
659    }
660
661    /// `to_ordinal(float/Decimal)`. YI's `to_ordinal` is
662    /// `self.to_cardinal(number) + "-טער"` for *every* input, so the float
663    /// entry is the float cardinal plus the suffix. An exponent-form Decimal
664    /// repr ("1E+2") still dies in `int()` with ValueError inside the
665    /// cardinal, before the suffix is appended.
666    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
667        Ok(format!(
668            "{}{}",
669            self.cardinal_float_entry(value, None)?,
670            ORDINAL_SUFFIX
671        ))
672    }
673
674    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr the
675    /// binding computed, dot appended, sign and exponent form included
676    /// ("-0.0.", "1e+16.").
677    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
678        Ok(format!("{}.", repr_str))
679    }
680
681    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
682    /// interception: Python parses "Infinity" fine and the ValueError only
683    /// fires later, inside YI's `int("Infinity")` (`to_cardinal` reads
684    /// `str(number)`, strips the sign, finds no "." and calls `int()`).
685    /// The binding otherwise hard-codes `ParsedNumber::Inf` to the base
686    /// integer path's OverflowError before any YI code runs, so the
687    /// ValueError must be raised here. The sign is sliced off before
688    /// `int()` sees it, so both signs quote the same literal.
689    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
690        match python_decimal_parse(s)? {
691            ParsedNumber::Inf { .. } => Err(N2WError::Value(
692                "invalid literal for int() with base 10: 'Infinity'".into(),
693            )),
694            other => Ok(other),
695        }
696    }
697
698    /// This language's own `to_currency(currency=...)` default,
699    /// read from the live Python signature. Only 44 of 156 use EUR.
700    fn default_currency(&self) -> &str {
701        "EUR"
702    }
703
704    /// This language's own `to_currency(separator=...)` default,
705    /// read from the live Python signature. Base's is ",", but only
706    /// 36 of 149 languages actually use it — most default to " " or a
707    /// conjunction, so inheriting Base's comma silently corrupts them.
708    fn default_separator(&self) -> &str {
709        " "
710    }
711
712    fn negword(&self) -> &str {
713        NEGWORD
714    }
715
716    fn pointword(&self) -> &str {
717        "point"
718    }
719
720    // cards / maxval / merge stay at their trait defaults: Python never builds
721    // self.cards for YI (no *_numwords attributes), so splitnum/clean/merge are
722    // unreachable and MAXVAL does not exist. There is no overflow check.
723
724    /// Python's `to_cardinal`, integer path.
725    ///
726    /// Python works on `str(number)`: it strips a leading "-" from the *string*
727    /// and stashes `negword`, so `_int_to_word` only ever sees a non-negative
728    /// value. The float branch (`"." in n`) is out of scope. The trailing
729    /// `.strip()` is a no-op for integer input — `_int_to_word` never returns
730    /// leading or trailing whitespace — but is reproduced verbatim.
731    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
732        let (ret, magnitude) = if value.is_negative() {
733            (NEGWORD, value.abs())
734        } else {
735            ("", value.clone())
736        };
737        Ok(format!("{}{}", ret, self.int_to_word(&magnitude))
738            .trim()
739            .to_string())
740    }
741
742    /// Python's `to_cardinal`, float/Decimal path — the `"." in n` branch of
743    /// the same method, reached when the dispatcher hands over a non-integer.
744    /// There is no separate `to_cardinal_float` in `Num2Word_YI`; the body is
745    /// the inline string walk ported in [`LangYi::cardinal_from_repr`], fed by
746    /// an exact `str(number)` (see the module docs).
747    ///
748    /// `precision_override` (the `precision=` kwarg) is deliberately ignored:
749    /// `num2words(..., precision=N)` sets `converter.precision`, but
750    /// `Num2Word_YI.to_cardinal` takes no such parameter and never reads
751    /// `self.precision`. Verified inert against the live interpreter —
752    /// `precision=3` leaves `0.5` as "zero point פינף", not padded. The
753    /// `FloatValue`'s own `precision` field is likewise unread: the repr
754    /// string carries the digit count by construction.
755    fn to_cardinal_float(
756        &self,
757        value: &FloatValue,
758        _precision_override: Option<u32>,
759    ) -> Result<String> {
760        // n = str(number) — the float and Decimal arms are not interchangeable
761        // (issue #603): `str` of a Decimal keeps every written digit, so
762        // Decimal("1.10") ends in a trailing "zero" the float 1.1 could never
763        // express, and Decimal("98746251323029.99") keeps its trillion-scale
764        // integer part exact where a float cast would have rounded it.
765        let n = match value {
766            FloatValue::Float { value, .. } => py_str_f64(*value),
767            FloatValue::Decimal { value, .. } => py_str_decimal(value),
768        };
769        self.cardinal_from_repr(&n)
770    }
771
772    /// Python's `to_ordinal`: `self.to_cardinal(number) + "-טער"`.
773    ///
774    /// No `verify_ordinal` call, so negatives pass through; no last-word
775    /// inflection, so the suffix lands on the digit-string fallback and on the
776    /// English "zero" too. Bugs 3, 4 and 6.
777    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
778        let cardinal = self.to_cardinal(value)?;
779        Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
780    }
781
782    /// Python's `to_ordinal_num`: `str(number) + "."`.
783    ///
784    /// Overrides `Num2Word_Base.to_ordinal_num` (which returns the value
785    /// unchanged), so the trait default is *not* correct here — the trailing
786    /// dot is required. The sign is kept: `to_ordinal_num(-1)` == "-1.".
787    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
788        Ok(format!("{}.", value))
789    }
790
791    /// Python's `to_year(val, longval=True)`: `return self.to_cardinal(val)`.
792    ///
793    /// `longval` is accepted and ignored — no century splitting, no "hundred"
794    /// idiom. Same result as the trait default; spelled out because YI does
795    /// physically override the method.
796    fn to_year(&self, value: &BigInt) -> Result<String> {
797        self.to_cardinal(value)
798    }
799
800    // ---- currency ----------------------------------------------------
801
802    fn lang_name(&self) -> &str {
803        LANG_NAME
804    }
805
806    /// `CURRENCY_FORMS[code]`, with **no** fallback.
807    ///
808    /// This is the strict lookup, and it is what the inherited `to_cheque`
809    /// needs: `Num2Word_Base.to_cheque` does `self.CURRENCY_FORMS[currency]`
810    /// and converts the `KeyError` into `NotImplementedError`, so returning
811    /// `None` here makes `currency::default_to_cheque` raise the identical
812    /// message for GBP/JPY/KWD/BHD/INR/CNY/CHF.
813    ///
814    /// `to_currency` deliberately does *not* route through this — it applies
815    /// the EUR fallback instead. That asymmetry is module bug 7.
816    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
817        self.forms.get(code)
818    }
819
820    // currency_precision stays at the trait default of 100: Num2Word_YI never
821    // defines CURRENCY_PRECISION and Num2Word_Base's is `{}`, so
822    // `.get(code, 100)` is 100 for every code — including the ones that are
823    // 3-decimal (KWD/BHD) or 0-decimal (JPY) elsewhere. to_cheque therefore
824    // prints "56/100" for KWD, and to_currency ignores precision outright.
825    //
826    // currency_adjective stays at the default (None): CURRENCY_ADJECTIVES is
827    // `{}`, and to_currency ignores its `adjective` argument regardless.
828    //
829    // pluralize stays at the default (raises NotImplementedError), exactly as
830    // Num2Word_Base.pluralize does — Num2Word_YI never overrides it and
831    // nothing reachable calls it: to_currency inlines its own
832    // `cr1[1] if left != 1 else cr1[0]`, and to_cheque takes `cr1[-1]`
833    // unconditionally.
834    //
835    // money_verbose / cents_verbose / cents_terse stay at their defaults
836    // (`self.to_cardinal(number)`), which is what Num2Word_Base defines.
837    // to_cheque reaches money_verbose; to_currency calls _int_to_word directly
838    // and reaches none of them.
839
840    /// Python's `Num2Word_YI.to_currency` — a wholesale override that shares
841    /// no code with `Num2Word_Base.to_currency`.
842    ///
843    /// ```python
844    /// is_negative = False
845    /// if val < 0:
846    ///     is_negative = True
847    ///     val = abs(val)
848    /// parts = str(val).split(".")
849    /// left = int(parts[0]) if parts[0] else 0
850    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
851    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
852    /// left_str = self._int_to_word(left)
853    /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
854    /// if cents and right:
855    ///     cents_str = self._int_to_word(right)
856    ///     result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
857    /// if is_negative:
858    ///     result = self.negword + result
859    /// return result.strip()
860    /// ```
861    ///
862    /// `adjective` is in the signature and never read — hence `_adjective`.
863    fn to_currency(
864        &self,
865        val: &CurrencyValue,
866        currency: &str,
867        cents: bool,
868        separator: Option<&str>,
869        _adjective: bool,
870    ) -> Result<String> {
871        // Trait now hands us None when the caller omitted separator=;
872        // resolve it to this language's own default before the ported body.
873        let separator = separator.unwrap_or(self.default_separator());
874        let is_negative = val.is_negative();
875
876        // Python takes abs() *first*, then splits str(val) on ".".
877        let (left, right) = match val {
878            // `str(int)` never contains a ".", so `len(parts) > 1` is False
879            // and `right` stays 0. An int can never print cents — though here
880            // that is incidental rather than the deliberate
881            // `isinstance(val, int)` branch the base class uses (bug 9).
882            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
883            CurrencyValue::Decimal { value: d, .. } => {
884                let v = d.abs();
885                // `int(parts[0])` — the digits left of the point, i.e. floor
886                // (v is already non-negative), matching with_scale(0)'s
887                // truncation.
888                let integer = v.with_scale(0);
889                let frac = &v - &integer;
890                // `int(parts[1][:2].ljust(2, "0"))` — two decimal digits,
891                // padded on the right and truncated beyond, which is exactly
892                // floor(frac * 100): "5" -> 50, "239" -> 23, "005" -> 0.
893                // Truncation, never rounding. See module bug 8.
894                let right = (frac * BigDecimal::from(100)).with_scale(0);
895                (
896                    integer.as_bigint_and_exponent().0,
897                    right.as_bigint_and_exponent().0,
898                )
899            }
900        };
901
902        // `.get(currency, <first value>)` — an unknown code borrows EUR's
903        // forms rather than raising. See module bug 7.
904        let forms = self.forms.get(currency).unwrap_or(&self.fallback_forms);
905
906        let one = BigInt::one();
907        let mut result = format!(
908            "{} {}",
909            self.int_to_word(&left),
910            if left == one { &forms.unit[0] } else { &forms.unit[1] }
911        );
912
913        // `if cents and right:` — truthiness, so right == 0 drops the segment
914        // even for a float. This is why 1.0 has no cents. See module bug 9.
915        if cents && !right.is_zero() {
916            result.push_str(separator_for(separator));
917            result.push_str(&self.int_to_word(&right));
918            result.push(' ');
919            result.push_str(if right == one {
920                &forms.subunit[0]
921            } else {
922                &forms.subunit[1]
923            });
924        }
925
926        if is_negative {
927            // The raw negword ("minus ", trailing space), not parse_minus.
928            result = format!("{}{}", NEGWORD, result);
929        }
930        // `.strip()` is a no-op — _int_to_word never returns padding and no
931        // form is empty — but is reproduced verbatim.
932        Ok(result.trim().to_string())
933    }
934
935    // to_cheque is NOT overridden: Num2Word_YI inherits
936    // Num2Word_Base.to_cheque, which currency::default_to_cheque already
937    // ports. It reaches YI only through lang_name / currency_forms /
938    // currency_precision / money_verbose above, and needs no help:
939    //   to_cheque(1234.56, "EUR") -> whole=1234, sub=56, divisor=100
940    //     -> "<to_cardinal(1234)> AND 56/100 אייראָ".upper()
941    // Rust's str::to_uppercase leaves Hebrew-script text alone exactly as
942    // Python's str.upper() does, so only the ASCII forms shift:
943    // "dollars" -> "DOLLARS", and the literal "and" -> "AND".
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    fn f(v: f64, precision: u32) -> Result<String> {
951        LangYi::new().to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
952    }
953
954    fn d(s: &str, precision: u32) -> Result<String> {
955        LangYi::new().to_cardinal_float(
956            &FloatValue::Decimal {
957                value: BigDecimal::from_str(s).unwrap(),
958                precision,
959            },
960            None,
961        )
962    }
963
964    fn value_err(r: Result<String>) -> String {
965        match r {
966            Err(N2WError::Value(m)) => m,
967            other => panic!("expected ValueError, got {:?}", other),
968        }
969    }
970
971    /// Every `"to": "cardinal"` row in bench/corpus.jsonl whose arg is a float.
972    #[test]
973    fn corpus_float_rows() {
974        assert_eq!(f(0.0, 1).unwrap(), "zero point zero");
975        assert_eq!(f(0.5, 1).unwrap(), "zero point פינף");
976        assert_eq!(f(1.0, 1).unwrap(), "איינס point zero");
977        assert_eq!(f(1.5, 1).unwrap(), "איינס point פינף");
978        assert_eq!(f(2.25, 2).unwrap(), "צוויי point צוויי פינף");
979        assert_eq!(f(3.14, 2).unwrap(), "דרײַ point איינס פיר");
980        assert_eq!(f(0.01, 2).unwrap(), "zero point zero איינס");
981        assert_eq!(f(0.1, 1).unwrap(), "zero point איינס");
982        assert_eq!(f(0.99, 2).unwrap(), "zero point נײַן נײַן");
983        assert_eq!(f(1.01, 2).unwrap(), "איינס point zero איינס");
984        assert_eq!(f(12.34, 2).unwrap(), "צען צוויי point דרײַ פיר");
985        assert_eq!(f(99.99, 2).unwrap(), "נײַנציק נײַן point נײַן נײַן");
986        assert_eq!(f(100.5, 1).unwrap(), "איינס הונדערט point פינף");
987        assert_eq!(
988            f(1234.56, 2).unwrap(),
989            "איינס טויזנט צוויי הונדערט דרײַסיק פיר point פינף זעקס"
990        );
991        assert_eq!(f(-0.5, 1).unwrap(), "minus zero point פינף");
992        assert_eq!(f(-1.5, 1).unwrap(), "minus איינס point פינף");
993        assert_eq!(f(-12.34, 2).unwrap(), "minus צען צוויי point דרײַ פיר");
994        assert_eq!(f(1.005, 3).unwrap(), "איינס point zero zero פינף");
995        assert_eq!(f(2.675, 3).unwrap(), "צוויי point זעקס זיבן פינף");
996    }
997
998    /// Every `"to": "cardinal_dec"` row in bench/corpus.jsonl.
999    #[test]
1000    fn corpus_decimal_rows() {
1001        assert_eq!(d("0.01", 2).unwrap(), "zero point zero איינס");
1002        // The trailing "zero" is the point of this row: Decimal("1.10") keeps
1003        // its written scale, which the float 1.1 could never express.
1004        assert_eq!(d("1.10", 2).unwrap(), "איינס point איינס zero");
1005        assert_eq!(d("12.345", 3).unwrap(), "צען צוויי point דרײַ פיר פינף");
1006        // Issue #603: the exact-Decimal arm at trillion scale. The integer
1007        // part exceeds 10**9, so bug 3 hands back bare digits (bug 13).
1008        assert_eq!(
1009            d("98746251323029.99", 2).unwrap(),
1010            "98746251323029 point נײַן נײַן"
1011        );
1012        assert_eq!(d("0.001", 3).unwrap(), "zero point zero zero איינס");
1013    }
1014
1015    /// Non-corpus rows verified against the live interpreter.
1016    #[test]
1017    fn live_interpreter_rows() {
1018        assert_eq!(
1019            f(1234567.89, 2).unwrap(),
1020            "איינס מיליאָן צוויי הונדערט דרײַסיק פיר טויזנט פינף הונדערט זעכציק זיבן point אַכט נײַן"
1021        );
1022        // Integer part >= 10**9: the digit-string fallback (bugs 3/13).
1023        assert_eq!(f(1500000000.25, 2).unwrap(), "1500000000 point צוויי פינף");
1024        // str(-0.0) == "-0.0": the sign bit survives repr, so negword fires.
1025        assert_eq!(f(-0.0, 1).unwrap(), "minus zero point zero");
1026        // A negative Decimal takes the same string-level "-" strip.
1027        assert_eq!(d("-12.34", 2).unwrap(), "minus צען צוויי point דרײַ פיר");
1028    }
1029
1030    /// The f64-artefact cases. YI reads `str()`, not `float2tuple`, so the
1031    /// repr's digits are used verbatim — no `10**precision`, no `< 0.01`
1032    /// rescue, no banker's rounding anywhere on this path.
1033    #[test]
1034    fn f64_artefacts_come_from_repr_not_float2tuple() {
1035        assert_eq!(f(1.005, 3).unwrap(), "איינס point zero zero פינף");
1036        assert_eq!(f(2.675, 3).unwrap(), "צוויי point זעקס זיבן פינף");
1037        // repr keeps all 17 fractional digits of 0.1+0.2 ("0.30000000000000004")
1038        // and every one of them becomes a word — no rounding, no truncation.
1039        // Built rather than spelled out so the zero count cannot drift.
1040        let expected = format!("zero point דרײַ {} פיר", ["zero"; 15].join(" "));
1041        assert_eq!(f(0.1 + 0.2, 17).unwrap(), expected);
1042    }
1043
1044    /// `precision=` sets `converter.precision`, which YI never reads.
1045    #[test]
1046    fn precision_override_is_inert() {
1047        let l = LangYi::new();
1048        let v = FloatValue::Float { value: 2.675, precision: 3 };
1049        for over in [None, Some(0), Some(1), Some(2), Some(5), Some(17)] {
1050            assert_eq!(
1051                l.to_cardinal_float(&v, over).unwrap(),
1052                "צוויי point זעקס זיבן פינף"
1053            );
1054        }
1055    }
1056
1057    /// Bug 13's companion: a repr in exponent form reaches `int()` and raises,
1058    /// with CPython's exact message. Verified against the live interpreter.
1059    #[test]
1060    fn exponent_form_repr_raises_value_error() {
1061        // 1e15 still prints positionally, so it survives (as digits, by bug 3).
1062        assert_eq!(f(1e15, 1).unwrap(), "1000000000000000 point zero");
1063        // One decade up, decpt > 16 and repr flips to "1e+16" — no "." at all,
1064        // so the whole token hits int().
1065        assert_eq!(
1066            value_err(f(1e16, 0)),
1067            "invalid literal for int() with base 10: '1e+16'"
1068        );
1069        // The "-" is stripped from the *string* first, so the message quotes
1070        // the unsigned literal.
1071        assert_eq!(
1072            value_err(f(-1e16, 0)),
1073            "invalid literal for int() with base 10: '1e+16'"
1074        );
1075        assert_eq!(
1076            value_err(f(1e-5, 0)),
1077            "invalid literal for int() with base 10: '1e-05'"
1078        );
1079        // "1.5e+20" keeps a ".", so the crash comes from the fraction loop on
1080        // the single char 'e' — after the '5' was already emitted.
1081        assert_eq!(
1082            value_err(f(1.5e20, 0)),
1083            "invalid literal for int() with base 10: 'e'"
1084        );
1085        // Decimal.__str__ goes scientific at leftdigits <= -6: "1E-7".
1086        assert_eq!(
1087            value_err(d("1E-7", 7)),
1088            "invalid literal for int() with base 10: '1E-7'"
1089        );
1090    }
1091}