Skip to main content

num2words2_core/
lang_bg.rs

1//! Port of `lang_BG.py` (Bulgarian).
2//!
3//! Shape: **self-contained**. `Num2Word_BG` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `any(hasattr(...))` guard in `Num2Word_Base.__init__` never fires: Python
6//! never builds `self.cards` and never sets `self.MAXVAL`. `to_cardinal`,
7//! `to_ordinal`, `to_ordinal_num` and `to_year` are all overridden outright
8//! and drive `_int_to_word` recursively. Consequently `cards`/`maxval`/`merge`
9//! stay at their trait defaults here, and **there is no overflow check at
10//! all** — `to_cardinal(10**600)` simply recurses and answers.
11//!
12//! Nothing is inherited from `Num2Word_Base` in scope: all four modes are
13//! overridden. `Num2Word_BG._setup` is dead code (it calls `super()._setup()`,
14//! but `Num2Word_Base` defines only `setup()`, not `_setup()`, and `__init__`
15//! calls `self.setup()` — the no-op base one). Were `_setup` ever invoked it
16//! would raise `AttributeError`; nothing invokes it, so it is not modelled.
17//! `is_title` is never set and `title()` is never called by BG's own
18//! `to_cardinal`, so no title-casing path exists.
19//!
20//! # The float / Decimal cardinal path
21//!
22//! BG **overrides `to_cardinal`** and handles non-integers inline; it does
23//! *not* override `to_cardinal_float`, and `Num2Word_Base.to_cardinal_float`
24//! is unreachable because the dispatcher only ever calls `converter
25//! .to_cardinal(number)` for `to="cardinal"`. So the trait's
26//! [`Lang::to_cardinal_float`] hook is implemented against BG's own body
27//! rather than delegating to `floatpath::default_to_cardinal_float`. The two
28//! differ in five observable ways:
29//!
30//! 13. **BG branches on `isinstance(n, float)`, so `Decimal` input never
31//!     reaches the float branch at all** — it falls through to the integer
32//!     tail and is silently **truncated**: `num2words(Decimal("12.345"),
33//!     lang="bg")` == "дванадесет", `Decimal("0.001")` == "нула",
34//!     `Decimal("-1.5")` == "минус един". The whole #603 exact-precision
35//!     apparatus is therefore dead for BG; the corpus's five `cardinal_dec`
36//!     rows are all bare integers. Base would have said "дванадесет точка три
37//!     четири пет".
38//! 14. **The fractional digits use `self.ones[d]`, not `self.to_cardinal(d)`.**
39//!     Base renders each post-decimal digit through `to_cardinal`, which for
40//!     BG forces `masculine=True`; BG's own loop indexes the neuter `ones`
41//!     table directly. They differ on exactly the two keys of
42//!     `ones_masculine`: `3.14` == "три точка **едно** четири" (not "един")
43//!     and `2.25` == "два точка **две** пет" (not "два"), while the integer
44//!     part of `2.25` *is* the masculine "два". Same class, same digit, two
45//!     spellings, one string apart.
46//! 15. **The sign is taken from `n < 0`, not from Base's `value < 0 and
47//!     pre == 0`.** BG writes `negword` and then `abs(pre)`, so it needs no
48//!     special case for `int(-0.5) == 0`; `-0.5` == "минус нула точка пет" and
49//!     `-12.34` == "минус дванадесет точка три четири" come out of the same
50//!     branch. Note BG concatenates `self.negword` raw — the trailing space is
51//!     the word separator — where Base does `self.negword.strip()` and joins.
52//! 16. **`precision=` is accepted by the dispatcher and then thrown away.**
53//!     `num2words(..., precision=N)` assigns `converter.precision = N` before
54//!     the call, but BG's float branch calls `float2tuple` first, and
55//!     `float2tuple` *reassigns* `self.precision` from `repr(value)` before
56//!     anything reads it. So `num2words(1.005, lang="bg", precision=1)` is
57//!     still "един точка нула нула пет". `precision_override` is accordingly
58//!     ignored here — the one language-visible consequence of issue #580 not
59//!     applying. (`self.precision` is instance state, but `float2tuple`
60//!     always writes it before `to_cardinal` reads it, so nothing leaks
61//!     between calls and a stateless port is exact.)
62//! 17. **`except BaseException` swallows `float2tuple`'s OverflowError.**
63//!     `float2tuple` computes `abs(value - pre) * 10**self.precision`, and
64//!     CPython's `float * int` converts the exact int with `PyLong_AsDouble`,
65//!     which raises `OverflowError("int too large to convert to float")` once
66//!     `10**precision` passes ~1.8e308. `repr(5e-324)` is "5e-324", so its
67//!     precision is 324 and the multiply raises — whereupon BG's blanket
68//!     `except BaseException` re-runs `self._int_to_cardinal(int(n))` and
69//!     answers **"нула"**. Verified live: `5e-324`, `-5e-324` and `1e-310` all
70//!     give "нула", while `1e-308` (precision 308, the last one that fits)
71//!     spells out all 308 digits. Modelled in [`base_float2tuple`].
72//!
73//! The `except BaseException` wrapper is otherwise inert: its body re-runs the
74//! same `self._int_to_cardinal(int(n))` that raised, so `int(n)`'s own
75//! failures propagate unchanged — `float("inf")` raises `OverflowError` and
76//! `float("nan")` raises `ValueError`, both from the `n != int(n)` test itself.
77//!
78//! # Faithfully reproduced Python oddities
79//!
80//! This is a port, not a rewrite. Everything below is exactly what Python
81//! emits, verified row-by-row against the frozen corpus:
82//!
83//! 1. **Ordinals of negatives lose the masculine form that cardinals keep.**
84//!    `to_cardinal` strips the sign itself and recurses, so
85//!    `to_cardinal(-42)` reaches `_int_to_cardinal(42)` → `masculine=True` →
86//!    "минус четиридесет и два". But `_int_to_ordinal` calls
87//!    `_int_to_cardinal(-42)` *with the sign still attached*, hitting that
88//!    method's own `n < 0` branch, which calls `_int_to_word(-n)` **without**
89//!    `masculine=True`. So the ordinal is built on "минус четиридесет и две"
90//!    (neuter "две", not masculine "два") and yields
91//!    "минус четиридесет и двети". Same for `to_ordinal(-1)` →
92//!    "минус едноти" ("едно", not "един") and `to_ordinal(-21)` →
93//!    "минус двадесет и едноти". The `n < 0` branch of `_int_to_cardinal` is
94//!    reachable *only* through `_int_to_ordinal`; `to_cardinal` never reaches
95//!    it. Modelled in [`LangBg::int_to_cardinal`].
96//! 2. **Ordinals are formed by gluing a suffix onto the cardinal**, so
97//!    non-table values read as run-ons rather than real Bulgarian:
98//!    `to_ordinal(0)` == "нулати", `to_ordinal(200)` == "двестати",
99//!    `to_ordinal(2000)` == "две хилядити", `to_ordinal(10**6)` ==
100//!    "един милионти", `to_ordinal(10**12)` == "хиляда милиардати".
101//!    Only the 28 keys in `ordinals` (1..=20, 30..=90 by ten, 100, 1000) get
102//!    a genuine ordinal word.
103//! 3. **`to_ordinal_num` uses Python's floor modulo on negatives**, which
104//!    flips the suffix versus a truncating `%`. `-999 % 10 == 1` in Python
105//!    (not `-9`), so `to_ordinal_num(-999)` == "-999-ви"; `-42 % 10 == 8`, so
106//!    `to_ordinal_num(-42)` == "-42-ми". Rust's `%` truncates, so this uses
107//!    `mod_floor`. See [`LangBg::to_ordinal_num`].
108//! 4. **`self.scale` and `self.ones_feminine` are dead tables.** `scale` maps
109//!    100/1000/10**6/10**9/10**12 to singular/plural pairs but `_int_to_word`
110//!    hardcodes every one of those words inline and never reads it. Nothing
111//!    in the module ever passes `feminine=True`, so "една"/"две" are
112//!    unreachable in the four in-scope modes. Both are kept below for
113//!    structural fidelity and flagged as unreachable.
114//! 5. **`to_year`'s "N хиляди" branch is dead.** It is guarded by
115//!    `1000 <= n < 2000`, where `n // 1000` is always exactly 1, so the
116//!    `thousands == 1` arm always wins and the `else` never runs. Kept
117//!    verbatim.
118//! 6. **The 100/1000 scale words carry no agreement.** Above 10**9 the
119//!    billions count recurses through `_int_to_word` and is suffixed with a
120//!    flat " милиарда", so `10**15` == "един милион милиарда" and `10**21` ==
121//!    "хиляда милиарда милиарда" — the library has no numword above
122//!    "милиард" and stacks it instead.
123//!
124//! # Currency
125//!
126//! `Num2Word_BG` overrides `to_currency` **wholesale** and inherits
127//! `to_cheque` from `Num2Word_Base`. That split drives every choice below:
128//!
129//! 7. **The two surfaces raise different messages for the same missing code.**
130//!    BG's own `to_currency` says `Currency "KWD" not implemented for
131//!    "Num2Word_BG"`, while the inherited `to_cheque` says `Currency code
132//!    "KWD" ...`. Verified against the live interpreter. This is why
133//!    [`LangBg::to_currency`] cannot delegate to
134//!    `currency::default_to_currency` — that helper emits Base's "code"
135//!    wording.
136//! 8. **BG never calls `pluralize`.** It inlines `cr1[0] if left == 1 else
137//!    cr1[1]`, and the inherited `to_cheque` takes `cr1[-1]` unconditionally.
138//!    So `pluralize` stays at the trait default (raise), exactly mirroring
139//!    `Num2Word_Base.pluralize`, and is unreachable.
140//! 9. **`adjective=` is accepted and never read.** BG's signature carries it,
141//!    the body ignores it, and `CURRENCY_ADJECTIVES` is empty. So
142//!    `to_currency(12.34, "USD", adjective=True)` == the `False` output.
143//! 10. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** BG calls
144//!    `parse_currency_parts` without a `divisor` and hardcodes `* 100` in its
145//!    fractional-cents probe, so the divisor is always 100. BG's own
146//!    `CURRENCY_PRECISION` is `{}` regardless, so the trait's default of 100
147//!    is already right and Base's `divisor == 1` normalisation is dead here —
148//!    **JPY is a 100-subunit currency in BG** ("нула йени и петдесет сена").
149//! 11. **The gender of the unit word is never agreed with.** `money_str` comes
150//!    from `_int_to_cardinal`, which forces `masculine=True`, so a feminine or
151//!    neuter unit still gets a masculine numeral: `to_currency(1, "JPY")` ==
152//!    "един йена" (not "една йена") and `to_currency(0.01, "GBP")` == "нула
153//!    паунда и един пени" (not "едно пени"). Corpus-confirmed.
154//! 12. **`CURRENCY_FORMS` is BG's own class attribute**, so — unlike the 16
155//!    classes that read `Num2Word_EUR`'s shared dict — it is untouched by
156//!    `Num2Word_EN.__init__`'s in-place mutation. The live interpreter
157//!    confirms exactly five codes at runtime; no AUD/CAD/CHF/KWD/INR leak in,
158//!    so every other ISO code raises NotImplementedError.
159//!
160//! # Errors
161//!
162//! None of the four integer modes can fail. Every table lookup is guarded by
163//! a range check (`tens` is only indexed with 10..=19 or a multiple of ten in
164//! 20..=90; `ones` only with 0..=9), there is no `MAXVAL` comparison, and the
165//! `try/except BaseException` wrappers in `to_cardinal`/`to_ordinal`/
166//! `to_ordinal_num` only exist to catch `float`/`str` coercion failures that
167//! integer input cannot trigger — their `except` bodies re-run the same call
168//! and are unreachable here. So all four always return `Ok`.
169//!
170//! The currency surface can fail only on an unknown currency code, with the
171//! two distinct `NotImplemented` messages described in oddity 7.
172
173use crate::base::{Lang, N2WError, Result};
174use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
175use crate::floatpath::FloatValue;
176use bigdecimal::BigDecimal;
177use num_bigint::BigInt;
178use num_integer::Integer;
179use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
180use std::collections::HashMap;
181
182/// `self.negword`. Note the **trailing space**: BG's `to_cardinal` and
183/// `_int_to_cardinal` concatenate it raw (`self.negword + ...`), unlike
184/// `Num2Word_Base.to_cardinal` which does `"%s " % self.negword.strip()`.
185const NEGWORD: &str = "минус ";
186
187/// `self.pointword`. BG's `to_cardinal` splices it as `" " + self.pointword`
188/// and never runs it through `title()` (Base does), which is a distinction
189/// without a difference: `is_title` is false for BG.
190const POINTWORD: &str = "точка";
191
192/// `self.ones`, keys 0..=9. Index 0 ("нула") doubles as the zero word.
193const ONES: [&str; 10] = [
194    "нула", "едно", "две", "три", "четири", "пет", "шест", "седем", "осем", "девет",
195];
196
197/// `self.tens`. Keys are 10..=19 plus the multiples of ten 20..=90; every
198/// other key is absent in Python, but `_int_to_word` can only ever produce
199/// those, so no `KeyError` is reachable.
200fn tens_word(n: u32) -> Option<&'static str> {
201    Some(match n {
202        10 => "десет",
203        11 => "единадесет",
204        12 => "дванадесет",
205        13 => "тринадесет",
206        14 => "четиринадесет",
207        15 => "петнадесет",
208        16 => "шестнадесет",
209        17 => "седемнадесет",
210        18 => "осемнадесет",
211        19 => "деветнадесет",
212        20 => "двадесет",
213        30 => "тридесет",
214        40 => "четиридесет",
215        50 => "петдесет",
216        60 => "шестдесет",
217        70 => "седемдесет",
218        80 => "осемдесет",
219        90 => "деветдесет",
220        _ => return None,
221    })
222}
223
224/// `self.ordinals`. The only values that get a real ordinal word; everything
225/// else falls through to the suffix-gluing branch of `_int_to_ordinal`.
226fn ordinal_word(n: u32) -> Option<&'static str> {
227    Some(match n {
228        1 => "първи",
229        2 => "втори",
230        3 => "трети",
231        4 => "четвърти",
232        5 => "пети",
233        6 => "шести",
234        7 => "седми",
235        8 => "осми",
236        9 => "девети",
237        10 => "десети",
238        11 => "единадесети",
239        12 => "дванадесети",
240        13 => "тринадесети",
241        14 => "четиринадесети",
242        15 => "петнадесети",
243        16 => "шестнадесети",
244        17 => "седемнадесети",
245        18 => "осемнадесети",
246        19 => "деветнадесети",
247        20 => "двадесети",
248        30 => "тридесети",
249        40 => "четиридесети",
250        50 => "петдесети",
251        60 => "шестдесети",
252        70 => "седемдесети",
253        80 => "осемдесети",
254        90 => "деветдесети",
255        100 => "стотен",
256        1000 => "хиляден",
257        _ => return None,
258    })
259}
260
261/// Python's `if feminine and n in self.ones_feminine / elif masculine and n in
262/// self.ones_masculine / else self.ones[n]` chain.
263///
264/// `ones_feminine`/`ones_masculine` hold only keys 1 and 2, so any other digit
265/// falls through to `ones` regardless of the flags. `feminine` is never `true`
266/// from any in-scope caller (see module docs, oddity 4).
267fn ones_form(n: u32, masculine: bool, feminine: bool) -> &'static str {
268    if feminine {
269        // self.ones_feminine
270        match n {
271            1 => return "една",
272            2 => return "две",
273            _ => {}
274        }
275    }
276    if masculine {
277        // self.ones_masculine
278        match n {
279            1 => return "един",
280            2 => return "два",
281            _ => {}
282        }
283    }
284    ONES[n as usize]
285}
286
287/// `Num2Word_BG.CURRENCY_FORMS`, verbatim from the class body.
288///
289/// BG declares its own class attribute rather than inheriting
290/// `Num2Word_EUR`'s, so `Num2Word_EN.__init__`'s in-place mutation of the
291/// shared EUR dict does **not** reach it. Confirmed against the live
292/// interpreter: the runtime table is exactly these five codes. Note EUR keeps
293/// the invariant `("евро", "евро")` here — that is BG's own literal, not the
294/// EUR-module one English rewrites.
295///
296/// Every entry carries exactly two forms, which is what makes the direct
297/// `cr[0]`/`cr[1]` indexing in [`index_form`] unreachable-by-IndexError.
298fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
299    const CENTS: [&str; 2] = ["цент", "цента"];
300
301    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
302    m.insert(
303        "BGN",
304        CurrencyForms::new(&["лев", "лева"], &["стотинка", "стотинки"]),
305    );
306    m.insert("EUR", CurrencyForms::new(&["евро", "евро"], &CENTS));
307    m.insert("USD", CurrencyForms::new(&["долар", "долара"], &CENTS));
308    m.insert("GBP", CurrencyForms::new(&["паунд", "паунда"], &["пени", "пенса"]));
309    m.insert("JPY", CurrencyForms::new(&["йена", "йени"], &["сен", "сена"]));
310    m
311}
312
313/// Python's `cr[0] if n == 1 else cr[1]` on a `CURRENCY_FORMS` tuple.
314///
315/// BG indexes the tuple directly, so a one-form entry would raise IndexError.
316/// All five BG entries have two forms, so that is unreachable — but the type
317/// is mapped rather than panicked so the exception survives if the table ever
318/// changes. This is *not* `pluralize`: BG never calls it (oddity 8).
319fn index_form(forms: &[String], singular: bool) -> Result<&str> {
320    forms
321        .get(if singular { 0 } else { 1 })
322        .map(String::as_str)
323        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
324}
325
326/// Python's `int(value)` on a `float`, including the two ways it fails.
327///
328/// BG reaches this through the `n != int(n)` test, and its `except
329/// BaseException` handler re-runs `self._int_to_cardinal(int(n))` — the very
330/// call that raised — so both exceptions propagate to the caller unchanged.
331/// Live-checked: `to_cardinal(float("inf"))` raises OverflowError,
332/// `to_cardinal(float("nan"))` raises ValueError, with these messages.
333///
334/// Not reachable through the shim as it stands (it derives `precision` from
335/// `abs(Decimal(str(value)).as_tuple().exponent)` first, and `abs("F")` raises
336/// TypeError on the Python side long before Rust is called), but modelled so
337/// the port is a faithful whole.
338fn python_int_of_float(value: f64) -> Result<BigInt> {
339    if value.is_nan() {
340        return Err(N2WError::Value("cannot convert float NaN to integer".into()));
341    }
342    if value.is_infinite() {
343        return Err(N2WError::Overflow(
344            "cannot convert float infinity to integer".into(),
345        ));
346    }
347    // `trunc` on a finite double is exactly representable, so `from_f64` is
348    // total here; it returns None only for NaN/inf, both ruled out above.
349    Ok(BigInt::from_f64(value.trunc()).expect("finite f64 truncates exactly"))
350}
351
352/// `Num2Word_Base.float2tuple(value)`, float arm — with the OverflowError it
353/// can raise made explicit, because BG's `except BaseException` catches it.
354///
355/// ```python
356/// pre = int(value)
357/// self.precision = abs(Decimal(str(value)).as_tuple().exponent)
358/// post = abs(value - pre) * 10**self.precision
359/// if abs(round(post) - post) < 0.01:
360///     post = int(round(post))
361/// else:
362///     post = int(math.floor(post))
363/// return pre, post
364/// ```
365///
366/// `precision` is the shim's, i.e. exactly the `self.precision` this method
367/// assigns — the port deliberately does not reimplement `repr(float)`.
368///
369/// Deliberately *not* `floatpath::float2tuple`, for two reasons, both
370/// measured rather than assumed:
371///
372/// * That port scales by `10f64.powi(precision)`. Python multiplies by the
373///   exact int `10**precision`, which CPython converts with `PyLong_AsDouble`
374///   — correctly rounded. `powi` multiplies repeatedly and drifts an ulp off
375///   the correctly-rounded value for **252 of the 309 exponents 0..=308**,
376///   starting at `10**33` (`1.0000000000000001e33` vs `1e33`).
377/// * Past `10**308` `powi` returns `inf` and the caller ends up stringifying a
378///   saturated `i128`, where Python raises OverflowError — which BG catches
379///   and turns into "нула" (oddity 17). A `Result` is the only way to say that.
380///
381/// Both divergences need `precision >= 33`, which no corpus row reaches; they
382/// are fixed here anyway because BG must own the raise either way.
383fn base_float2tuple(value: f64, precision: u32) -> Result<(BigInt, BigInt)> {
384    let pre = python_int_of_float(value)?;
385
386    // `float * int` → CONVERT_TO_DOUBLE → PyLong_AsDouble, which raises
387    // rather than returning inf. num-bigint's `to_f64` *does* return
388    // Some(inf), so the finiteness check is what reproduces the raise.
389    let scale = match BigInt::from(10).pow(precision).to_f64() {
390        Some(s) if s.is_finite() => s,
391        _ => {
392            return Err(N2WError::Overflow(
393                "int too large to convert to float".into(),
394            ))
395        }
396    };
397
398    // `value - pre`: for a non-integral double `|value| < 2**52`, so `pre` is
399    // exact in f64 and this is the same subtraction Python performs.
400    let post = (value - value.trunc()).abs() * scale;
401
402    // Python's round() is round-half-to-EVEN (`round(2.5) == 2`); Rust's
403    // `f64::round()` is half-away-from-zero and would break every exact tie.
404    let rounded = post.round_ties_even();
405    let out = if (rounded - post).abs() < 0.01 {
406        // The rescue arm: 2.675 really does give 674.9999999999998 here, and
407        // this is what pulls it back to 675. Recomputing from the decimal
408        // string would give the mathematically right — and so wrong — answer.
409        rounded
410    } else {
411        post.floor()
412    };
413    Ok((
414        pre,
415        BigInt::from_f64(out).expect("finite scale keeps `out` finite"),
416    ))
417}
418
419/// `abs(Decimal(str(f)).as_tuple().exponent)` for an f64.
420///
421/// Mirrors `floatpath::float_repr_precision`, which is private to that module.
422/// Rust's `{}` for f64 is shortest-round-trip, the same contract as Python's
423/// `repr`, so counting the digits after the point agrees. The two part company
424/// only in exponent form (`repr(1e21)` is "1e+21" → precision 21, while Rust
425/// prints all 22 digits → precision 0), which this cannot reach: its only
426/// caller feeds it a subunit count in `[0, 100)`.
427fn repr_precision(f: f64) -> u32 {
428    let s = format!("{}", f);
429    match s.split_once('.') {
430        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
431        _ => 0,
432    }
433}
434
435pub struct LangBg {
436    /// `CURRENCY_FORMS`, built once in [`LangBg::new`] and only ever read.
437    /// Rebuilding it per call is what made an earlier revision of this port
438    /// slower than the Python it replaces.
439    currency_forms: HashMap<&'static str, CurrencyForms>,
440}
441
442impl LangBg {
443    pub fn new() -> Self {
444        LangBg {
445            currency_forms: build_currency_forms(),
446        }
447    }
448
449    /// `_int_to_word(n, masculine=False, feminine=False)`.
450    ///
451    /// Expects `n >= 0` — every caller strips the sign first. Recurses on the
452    /// billions count, which is unbounded, so this is `BigInt` throughout.
453    fn int_to_word(&self, n: &BigInt, masculine: bool, feminine: bool) -> String {
454        if n.is_zero() {
455            return ONES[0].to_string();
456        }
457
458        let mut parts: Vec<String> = Vec::new();
459        let mut n = n.clone();
460
461        let two = BigInt::from(2);
462        let billion = BigInt::from(1_000_000_000u64);
463        let million = BigInt::from(1_000_000u64);
464        let thousand = BigInt::from(1000u32);
465        let hundred = BigInt::from(100u32);
466
467        // Handle billions. `billions` may itself exceed 10**9 — the library
468        // has no numword above "милиард", so it recurses and stacks the word
469        // (10**21 -> "хиляда милиарда милиарда"). The recursive call passes
470        // neither flag, so the count is always neuter.
471        if n >= billion {
472            let billions = &n / &billion;
473            if billions.is_one() {
474                parts.push("един милиард".to_string());
475            } else if billions == two {
476                parts.push("два милиарда".to_string());
477            } else {
478                parts.push(format!(
479                    "{} милиарда",
480                    self.int_to_word(&billions, false, false)
481                ));
482            }
483            n %= &billion;
484        }
485
486        // Handle millions. `n < 10**9` here, so `millions < 1000`.
487        if n >= million {
488            let millions = &n / &million;
489            if millions.is_one() {
490                parts.push("един милион".to_string());
491            } else if millions == two {
492                parts.push("два милиона".to_string());
493            } else {
494                parts.push(format!(
495                    "{} милиона",
496                    self.int_to_word(&millions, false, false)
497                ));
498            }
499            n %= &million;
500        }
501
502        // Handle thousands. `n < 10**6` here, so `thousands < 1000`.
503        if n >= thousand {
504            let thousands = &n / &thousand;
505            if thousands.is_one() {
506                // Bare "хиляда" — no "един" prefix, unlike милион/милиард.
507                parts.push("хиляда".to_string());
508            } else if thousands == two {
509                parts.push("две хиляди".to_string());
510            } else {
511                parts.push(format!(
512                    "{} хиляди",
513                    self.int_to_word(&thousands, false, false)
514                ));
515            }
516            n %= &thousand;
517        }
518
519        // Handle hundreds. 100/200/300 are suppletive; 400..900 are
520        // `ones[h] + "стотин"` (-> "четиристотин", "петстотин", ...).
521        if n >= hundred {
522            let hundreds = (&n / &hundred).to_u32().expect("hundreds is 1..=9");
523            match hundreds {
524                1 => parts.push("сто".to_string()),
525                2 => parts.push("двеста".to_string()),
526                3 => parts.push("триста".to_string()),
527                _ => parts.push(format!("{}стотин", ONES[hundreds as usize])),
528            }
529            n %= &hundred;
530        }
531
532        // Tens and ones. `n < 100` from here, so a u32 is provably enough.
533        let small = n.to_u32().expect("n < 100 after the hundreds step");
534        if small >= 20 {
535            let tens = (small / 10) * 10;
536            parts.push(tens_word(tens).expect("multiple of ten in 20..=90").to_string());
537            let unit = small % 10;
538            if unit > 0 {
539                // Bulgarian joins the last unit with "и" as a separate word.
540                parts.push("и".to_string());
541                parts.push(ones_form(unit, masculine, feminine).to_string());
542            }
543        } else if small >= 10 {
544            parts.push(tens_word(small).expect("10..=19").to_string());
545        } else if small > 0 {
546            parts.push(ones_form(small, masculine, feminine).to_string());
547        }
548
549        parts.join(" ")
550    }
551
552    /// `_int_to_cardinal(n)`.
553    ///
554    /// The `n < 0` branch is reachable **only** via `_int_to_ordinal`
555    /// (`to_cardinal` strips the sign before ever calling this), and it drops
556    /// `masculine=True` — see module docs, oddity 1.
557    fn int_to_cardinal(&self, n: &BigInt) -> String {
558        if n.is_zero() {
559            return ONES[0].to_string();
560        }
561
562        if n.is_negative() {
563            // Note: no `masculine=True` here, unlike the positive path.
564            return format!("{}{}", NEGWORD, self.int_to_word(&(-n), false, false));
565        }
566
567        self.int_to_word(n, true, false)
568    }
569
570    /// `Num2Word_BG.to_ordinal(n)` fed a float/Decimal, resolved to its
571    /// observable result.
572    ///
573    /// `_int_to_ordinal(n)` first probes `n in self.ordinals` — a whole
574    /// float/Decimal hash-matches its int key (`5.0` → "пети",
575    /// `Decimal("1E+2")` → "стотен") — and otherwise builds
576    /// `_int_to_cardinal(n)`, whose dict lookups succeed for every whole
577    /// value (`-1.0` → "минус едно" → "минус едноти") but raise KeyError on a
578    /// fractional residue; `to_ordinal`'s bare `except BaseException` then
579    /// retries as `_int_to_ordinal(int(n))`. Whole values produce the exact
580    /// same words the truncated retry would, so the observable result is
581    /// *always* `_int_to_ordinal(int(n))` — truncation toward zero.
582    fn ordinal_numeric(&self, value: &FloatValue) -> String {
583        let trunc = match value {
584            FloatValue::Float { value, .. } => {
585                BigInt::from_f64(value.trunc()).unwrap_or_else(BigInt::zero)
586            }
587            FloatValue::Decimal { value, .. } => value.with_scale(0).as_bigint_and_exponent().0,
588        };
589        self.int_to_ordinal(&trunc)
590    }
591
592    /// `Num2Word_BG._int_to_cardinal(n)` fed a float/Decimal with **no**
593    /// exception net (the `to_year` path). Whole values ride the dict
594    /// lookups; a fractional one raises KeyError from `self.ones[…]`.
595    /// Note the negative branch drops `masculine=True`, so "-21.0" reads
596    /// "минус двадесет и едно" — unlike the masculine int path.
597    fn cardinal_year_numeric(&self, value: &FloatValue) -> Result<String> {
598        let (neg, frac, whole) = decompose_numeric(value);
599
600        // `if n == 0: return self.ones[0]` — numeric, so -0.0 lands here.
601        if whole.is_zero() && !frac {
602            return Ok(ONES[0].to_string());
603        }
604        if frac {
605            // `_int_to_word` reaches `self.ones[<fraction>]` → KeyError.
606            return Err(N2WError::Key(frac_key_msg(value)));
607        }
608        if neg {
609            // No masculine flag on the negative recursion (oddity 1).
610            return Ok(format!("{}{}", NEGWORD, self.int_to_word(&whole, false, false)));
611        }
612        Ok(self.int_to_word(&whole, true, false))
613    }
614
615    /// `_int_to_ordinal(n)`: table lookup, else glue a suffix onto the
616    /// cardinal.
617    fn int_to_ordinal(&self, n: &BigInt) -> String {
618        if let Some(word) = n.to_u32().and_then(ordinal_word) {
619            return word.to_string();
620        }
621
622        let cardinal = self.int_to_cardinal(n);
623
624        // Python slices `cardinal[:-4]` / `[:-3]` *by character*. Since the
625        // guard is `endswith(suffix)` and each suffix is exactly that many
626        // characters ("един" = 4, "два"/"три" = 3), dropping the suffix is
627        // identical to dropping the character count — `strip_suffix` is
628        // exactly faithful and cannot split a UTF-8 boundary.
629        if let Some(stem) = cardinal.strip_suffix("един") {
630            format!("{}първи", stem)
631        } else if let Some(stem) = cardinal.strip_suffix("два") {
632            format!("{}втори", stem)
633        } else if let Some(stem) = cardinal.strip_suffix("три") {
634            format!("{}трети", stem)
635        } else if cardinal.ends_with('т') {
636            format!("{}и", cardinal)
637        } else {
638            format!("{}ти", cardinal)
639        }
640    }
641
642    /// `Num2Word_BG.to_cardinal(n)` where Python's `n` is a **float**.
643    ///
644    /// ```python
645    /// try:
646    ///     if isinstance(n, float) and n != int(n):
647    ///         pre, post = self.float2tuple(n)
648    ///         if n < 0:
649    ///             result = self.negword
650    ///             pre = abs(pre)
651    ///         else:
652    ///             result = ""
653    ///         result += self._int_to_cardinal(pre)
654    ///         if self.precision > 0:
655    ///             result += " " + self.pointword
656    ///             post_str = str(post)
657    ///             post_str = "0" * (self.precision - len(post_str)) + post_str
658    ///             for digit in post_str:
659    ///                 result += " " + self.ones[int(digit)]
660    ///         return result.strip()
661    ///     if n < 0:
662    ///         return self.negword + self.to_cardinal(-n)
663    ///     return self._int_to_cardinal(int(n))
664    /// except BaseException:
665    ///     return self._int_to_cardinal(int(n))
666    /// ```
667    ///
668    /// `precision` is `self.precision` as `float2tuple` would have set it, and
669    /// the caller-supplied `precision=` kwarg is *not* it — see oddity 16.
670    fn to_cardinal_f64(&self, n: f64, precision: u32) -> Result<String> {
671        // `n != int(n)` evaluates `int(n)` first, so inf/nan raise right here
672        // and the `except` body raises the same thing again (oddity 17).
673        let pre_int = python_int_of_float(n)?;
674
675        // `isinstance(n, float) and n != int(n)`. NaN is already gone, so this
676        // is just "has a fractional part".
677        if n != n.trunc() {
678            let (pre, post) = match base_float2tuple(n, precision) {
679                Ok(t) => t,
680                // `except BaseException: return self._int_to_cardinal(int(n))`.
681                // Reachable: precision >= 309 (i.e. |n| < 1e-308) overflows the
682                // `10**precision` float cast, and every such n truncates to 0,
683                // so this answers "нула" — sign and all detail discarded.
684                Err(_) => return Ok(self.int_to_cardinal(&pre_int)),
685            };
686
687            // `if n < 0` — not Base's `value < 0 and pre == 0`. BG writes the
688            // sign itself and abs()es `pre`, which covers int(-0.5) == 0 for
689            // free. `-0.0 < 0` is false in Python and in Rust alike.
690            let (mut result, pre) = if n < 0.0 {
691                (NEGWORD.to_string(), pre.abs())
692            } else {
693                (String::new(), pre)
694            };
695
696            // `pre` is now >= 0, so this takes `_int_to_cardinal`'s positive
697            // path: the integer part is masculine ("два точка две пет").
698            result.push_str(&self.int_to_cardinal(&pre));
699
700            // Always true for a non-integral double — `repr` only goes to
701            // exponent form at >= 1e16, where every double is integral — but
702            // Python tests it, so the port does too.
703            if precision > 0 {
704                result.push(' ');
705                result.push_str(POINTWORD);
706
707                // `"0" * (self.precision - len(post_str)) + post_str`. Python's
708                // `str * negative` is "", so an over-long post is left alone.
709                let mut post_str = post.to_string();
710                let len = post_str.chars().count();
711                if (precision as usize) > len {
712                    post_str = format!("{}{}", "0".repeat(precision as usize - len), post_str);
713                }
714
715                // `for digit in post_str` — every character, *not* Base's
716                // `for i in range(self.precision)`. Identical whenever post
717                // fits its precision, which is every case I could find.
718                for ch in post_str.chars() {
719                    let d = ch
720                        .to_digit(10)
721                        .expect("float2tuple yields a non-negative decimal integer");
722                    result.push(' ');
723                    // `self.ones[int(digit)]` — the NEUTER table, where Base
724                    // would recurse into to_cardinal and get masculine
725                    // "един"/"два" for 1 and 2 (oddity 14).
726                    result.push_str(ONES[d as usize]);
727                }
728            }
729            return Ok(result.trim().to_string());
730        }
731
732        // "For integers" — an integral float such as 1.0 or -3.0.
733        if n < 0.0 {
734            // `self.negword + self.to_cardinal(-n)`: the recursion re-enters
735            // the positive path, so the magnitude *is* masculine. `-n` is
736            // integral too, so this bottoms out immediately.
737            return Ok(format!("{}{}", NEGWORD, self.to_cardinal_f64(-n, precision)?));
738        }
739        Ok(self.int_to_cardinal(&pre_int))
740    }
741
742    /// `Num2Word_BG.to_cardinal(n)` where Python's `n` is a **Decimal**.
743    ///
744    /// The float branch is guarded by `isinstance(n, float)`, and a Decimal is
745    /// not one, so it never runs — `float2tuple`'s exact-precision Decimal arm
746    /// is dead code for BG and the value is simply truncated (oddity 13):
747    ///
748    /// ```python
749    /// if n < 0:
750    ///     return self.negword + self.to_cardinal(-n)
751    /// return self._int_to_cardinal(int(n))
752    /// ```
753    fn to_cardinal_bigdecimal(&self, n: &BigDecimal) -> String {
754        if n.is_negative() {
755            // `-n` is positive, so the recursion lands on the tail below and
756            // the magnitude keeps its masculine agreement. Decimal("-0.0") is
757            // *not* < 0 in Python, and BigDecimal has no signed zero either,
758            // so both answer plain "нула".
759            return format!("{}{}", NEGWORD, self.to_cardinal_bigdecimal(&-n.clone()));
760        }
761        // `int(n)` truncates toward zero; so does `with_scale(0)`.
762        self.int_to_cardinal(&n.with_scale(0).as_bigint_and_exponent().0)
763    }
764}
765
766impl Default for LangBg {
767    fn default() -> Self {
768        Self::new()
769    }
770}
771
772/// `(numerically negative, has fraction, absolute truncated magnitude)` for
773/// either `FloatValue` arm. "Negative" is a strict `< 0`, so `-0.0` is not.
774fn decompose_numeric(value: &FloatValue) -> (bool, bool, BigInt) {
775    match value {
776        FloatValue::Float { value: v, .. } => {
777            let neg = *v < 0.0;
778            let frac = v.fract() != 0.0;
779            let whole = BigInt::from_f64(v.trunc())
780                .unwrap_or_else(BigInt::zero)
781                .abs();
782            (neg, frac, whole)
783        }
784        FloatValue::Decimal { value: d, .. } => {
785            let neg = d.is_negative();
786            let frac = !d.is_integer();
787            let whole = d.abs().with_scale(0).as_bigint_and_exponent().0;
788            (neg, frac, whole)
789        }
790    }
791}
792
793/// The KeyError payload — Python's missing dict key is the fractional
794/// residue. The corpus compares exception types only, so this is
795/// best-effort text.
796fn frac_key_msg(value: &FloatValue) -> String {
797    match value {
798        FloatValue::Float { value, .. } => format!("{}", value),
799        FloatValue::Decimal { value, .. } => format!("{}", value),
800    }
801}
802
803impl Lang for LangBg {
804    /// This language's own `to_currency(currency=...)` default,
805    /// read from the live Python signature. Only 44 of 156 use EUR.
806    fn default_currency(&self) -> &str {
807        "BGN"
808    }
809
810    /// This language's own `to_currency(separator=...)` default,
811    /// read from the live Python signature. Base's is ",", but only
812    /// 36 of 149 languages actually use it — most default to " " or a
813    /// conjunction, so inheriting Base's comma silently corrupts them.
814    fn default_separator(&self) -> &str {
815        " и"
816    }
817
818    fn negword(&self) -> &str {
819        NEGWORD
820    }
821
822    fn pointword(&self) -> &str {
823        POINTWORD
824    }
825
826    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
827        // The float/str branches of Python's `to_cardinal` are out of scope
828        // (integer input only), leaving the sign split and `_int_to_cardinal`.
829        if value.is_negative() {
830            // `self.negword + self.to_cardinal(-n)`: the recursion re-enters
831            // the positive path, so the magnitude *is* masculine here.
832            return Ok(format!("{}{}", NEGWORD, self.int_to_cardinal(&(-value))));
833        }
834        Ok(self.int_to_cardinal(value))
835    }
836
837    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
838        Ok(self.int_to_ordinal(value))
839    }
840
841    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
842        // Python's `%` floors toward negative infinity; Rust's truncates.
843        // `mod_floor` restores Python semantics, which is load-bearing for
844        // negatives: -999 % 10 == 1 -> "-999-ви", -42 % 10 == 8 -> "-42-ми".
845        let m100 = value.mod_floor(&BigInt::from(100)).to_u32().unwrap_or(0);
846        if (11..=19).contains(&m100) {
847            return Ok(format!("{}-ти", value));
848        }
849
850        let m10 = value.mod_floor(&BigInt::from(10)).to_u32().unwrap_or(0);
851        let suffix = match m10 {
852            1 => "-ви",
853            2 => "-ри",
854            7 | 8 => "-ми",
855            _ => "-ти",
856        };
857        Ok(format!("{}{}", value, suffix))
858    }
859
860    fn to_year(&self, value: &BigInt) -> Result<String> {
861        let thousand = BigInt::from(1000u32);
862        let two_thousand = BigInt::from(2000u32);
863
864        if value < &thousand {
865            // Also the path for negatives (BC years): `_int_to_cardinal`
866            // handles the sign, dropping masculine agreement as ever.
867            return Ok(self.int_to_cardinal(value));
868        }
869
870        if value < &two_thousand {
871            // 1000..=1999 -> "хиляда ..." with the millennium spelled out.
872            let thousands = value / &thousand;
873            let remainder = value % &thousand;
874            let mut result = if thousands.is_one() {
875                "хиляда".to_string()
876            } else {
877                // Dead: `thousands` is provably 1 for 1000..=1999.
878                format!("{} хиляди", self.int_to_cardinal(&thousands))
879            };
880            if remainder.is_positive() {
881                result.push(' ');
882                result.push_str(&self.int_to_cardinal(&remainder));
883            }
884            return Ok(result);
885        }
886
887        // 2000 and up: plain cardinal ("две хиляди двадесет и пет").
888        Ok(self.int_to_cardinal(value))
889    }
890
891    /// `to_ordinal(float/Decimal)` — see [`LangBg::ordinal_numeric`]: the
892    /// observable result is always `_int_to_ordinal(int(n))`, with the
893    /// negative cardinal's neuter "едно" quirk ("минус едноти") intact.
894    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
895        Ok(self.ordinal_numeric(value))
896    }
897
898    /// `to_ordinal_num(float/Decimal)`: `str(n)` + a suffix picked by
899    /// `n % 100` / `n % 10` — with Python's per-type `%` semantics. A float
900    /// modulo is floored (so `-2.0 % 10 == 8.0` → "-ми"), a Decimal's is
901    /// truncated (so `Decimal("-3.0") % 10 == -3` → the default "-ти"), and
902    /// any fractional value matches nothing → "-ти".
903    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
904        let suffix = match value {
905            FloatValue::Float { value: v, .. } => {
906                if v.fract() != 0.0 || !v.is_finite() {
907                    "-ти"
908                } else {
909                    let i = BigInt::from_f64(*v).unwrap_or_else(BigInt::zero);
910                    let m100 = i.mod_floor(&BigInt::from(100)).to_u32().unwrap_or(0);
911                    let m10 = i.mod_floor(&BigInt::from(10)).to_u32().unwrap_or(0);
912                    if (11..=19).contains(&m100) {
913                        "-ти"
914                    } else {
915                        match m10 {
916                            1 => "-ви",
917                            2 => "-ри",
918                            7 | 8 => "-ми",
919                            _ => "-ти",
920                        }
921                    }
922                }
923            }
924            FloatValue::Decimal { value: d, .. } => {
925                if !d.is_integer() {
926                    "-ти"
927                } else {
928                    // Decimal % truncates (sign of the dividend), matching
929                    // BigInt's `%`; a negative remainder matches nothing.
930                    let i = d.with_scale(0).as_bigint_and_exponent().0;
931                    let m100 = &i % BigInt::from(100);
932                    let m10 = &i % BigInt::from(10);
933                    if m100 >= BigInt::from(11) && m100 <= BigInt::from(19) {
934                        "-ти"
935                    } else if m10 == BigInt::one() {
936                        "-ви"
937                    } else if m10 == BigInt::from(2) {
938                        "-ри"
939                    } else if m10 == BigInt::from(7) || m10 == BigInt::from(8) {
940                        "-ми"
941                    } else {
942                        "-ти"
943                    }
944                }
945            }
946        };
947        Ok(format!("{}{}", repr_str, suffix))
948    }
949
950    /// `to_year(float/Decimal)` — `Num2Word_BG.to_year` has **no** exception
951    /// net, so the KeyError `_int_to_word` raises on a fractional residue
952    /// propagates (`0.5` → KeyError), while whole values ride the dict
953    /// lookups to the same words as their int counterparts — including the
954    /// neuter "минус едно" negatives ("-21.0" → "минус двадесет и едно").
955    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
956        let (neg, frac, whole) = decompose_numeric(value);
957
958        // `if n < 1000` — every negative and every value under 1000.
959        if neg || whole < BigInt::from(1000) {
960            return self.cardinal_year_numeric(value);
961        }
962        if whole < BigInt::from(2000) {
963            // thousands = n // 1000 == 1 for this whole range → "хиляда".
964            let mut result = "хиляда".to_string();
965            let remainder = &whole - BigInt::from(1000);
966            if remainder.is_positive() || frac {
967                if frac {
968                    // `_int_to_cardinal(remainder)` dies in `self.ones[…]`.
969                    return Err(N2WError::Key(frac_key_msg(value)));
970                }
971                result.push(' ');
972                result.push_str(&self.int_to_cardinal(&remainder));
973            }
974            return Ok(result);
975        }
976        self.cardinal_year_numeric(value)
977    }
978
979    // ---- float / Decimal ------------------------------------------------
980
981    /// The non-integer half of `Num2Word_BG.to_cardinal`.
982    ///
983    /// Not `floatpath::default_to_cardinal_float`: BG overrides `to_cardinal`
984    /// and inlines its own float handling, and the dispatcher only ever calls
985    /// `converter.to_cardinal(number)` — `Num2Word_Base.to_cardinal_float` is
986    /// unreachable for BG. See oddities 13-17 for the five ways the two differ.
987    ///
988    /// `precision_override` (the `precision=` kwarg, issue #580) is ignored
989    /// because Python ignores it: the dispatcher stores it on
990    /// `converter.precision`, and `float2tuple` overwrites that from
991    /// `repr(value)` before BG's `if self.precision > 0` ever reads it.
992    fn to_cardinal_float(
993        &self,
994        value: &FloatValue,
995        _precision_override: Option<u32>,
996    ) -> Result<String> {
997        match value {
998            FloatValue::Float { value, precision } => self.to_cardinal_f64(*value, *precision),
999            // `isinstance(n, float)` is False → truncate, no fraction at all.
1000            FloatValue::Decimal { value, .. } => Ok(self.to_cardinal_bigdecimal(value)),
1001        }
1002    }
1003
1004    /// The fractional-cents hook, reached from [`LangBg::to_currency`] as
1005    /// Python's `self.to_cardinal(float(right))`.
1006    ///
1007    /// Overridden because the default routes to *Base's* float path, and BG's
1008    /// is different in the ways listed above — most visibly the neuter
1009    /// `self.ones[d]` for the post-decimal digits (oddity 14). With the
1010    /// default, `to_currency(1.011, "USD")` rendered "…и един точка **един**
1011    /// цента" where Python says "…и един точка **едно** цента". The previous
1012    /// revision measured and flagged exactly this divergence and deferred it
1013    /// to the float phase; this is that fix. No corpus row reaches it — every
1014    /// `bg` currency arg has <= 2 decimals, so `has_fractional_cents` is
1015    /// always false — so it is pinned by [`tests::currency_fractional_cents`]
1016    /// against the live interpreter instead.
1017    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1018        // Python casts to float *first* (`float(right)`), so the port does too
1019        // rather than staying in arbitrary precision: the digits can differ.
1020        let f = value
1021            .to_f64()
1022            .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
1023        self.to_cardinal_f64(f, repr_precision(f))
1024    }
1025
1026    // ---- currency -------------------------------------------------------
1027    //
1028    // BG overrides `to_currency` outright and inherits everything else from
1029    // `Num2Word_Base`. So only `lang_name`, `currency_forms` and
1030    // `to_currency` are overridden here:
1031    //
1032    // * `currency_precision` — BG's CURRENCY_PRECISION is `{}`, so Base's
1033    //   `.get(code, 100)` is the trait default already.
1034    // * `currency_adjective` — CURRENCY_ADJECTIVES is `{}` (and unread).
1035    // * `pluralize` — never called; the default's raise mirrors Base's.
1036    // * `money_verbose` — Base's `_money_verbose` is `self.to_cardinal(n)`,
1037    //   which is the trait default and correctly re-enters BG's `to_cardinal`.
1038    // * `cents_verbose` / `cents_terse` — BG's `to_currency` inlines both and
1039    //   never calls them; unreachable.
1040    // * `to_cheque` — inherited verbatim; `default_to_cheque` already matches,
1041    //   down to Base's "Currency code" wording (oddity 7).
1042
1043    fn lang_name(&self) -> &str {
1044        "Num2Word_BG"
1045    }
1046
1047    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1048        self.currency_forms.get(code)
1049    }
1050
1051    /// `Num2Word_BG.to_currency`.
1052    ///
1053    /// Deliberately does **not** delegate to `currency::default_to_currency`:
1054    /// BG's body differs from Base's in the error wording (oddity 7), in
1055    /// keying the cents segment on `isinstance(val, int)` rather than Base's
1056    /// `has_decimal` guard, in inlining pluralization instead of calling
1057    /// `pluralize`, and in hardcoding divisor 100 (oddity 10).
1058    fn to_currency(
1059        &self,
1060        val: &CurrencyValue,
1061        currency: &str,
1062        cents: bool,
1063        separator: Option<&str>,
1064        // Accepted and never read, exactly as in Python (oddity 9).
1065        _adjective: bool,
1066    ) -> Result<String> {
1067        // The trait hands us None when the caller omitted `separator=`;
1068        // resolve it through this language's own default (" и").
1069        let separator = separator.unwrap_or(self.default_separator());
1070
1071        // `is_integer_input = isinstance(val, int)`. BG keys the entire cents
1072        // segment on this alone and never consults `has_decimal`, so a float
1073        // always prints cents — including `1.0` -> "един евро и нула цента".
1074        let is_integer_input = matches!(val, CurrencyValue::Int(_));
1075
1076        // `has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0`.
1077        // Python's Decimal `%` truncates toward zero, as does `with_scale(0)`,
1078        // so the two agree on negatives: -12.34 gives `Decimal("-0.00")`,
1079        // which is falsy, exactly like the zero difference here.
1080        let has_fractional_cents = match val {
1081            CurrencyValue::Int(_) => false,
1082            CurrencyValue::Decimal { value, .. } => {
1083                let scaled = value * BigDecimal::from(100);
1084                &scaled - scaled.with_scale(0) != BigDecimal::zero()
1085            }
1086        };
1087
1088        // Python parses *before* the CURRENCY_FORMS lookup, so an unknown code
1089        // still parses first. Neither step can fail, but the order is kept.
1090        let (left, right, is_negative) =
1091            parse_currency_parts(val, false, has_fractional_cents, 100);
1092
1093        // Note the message: no "code". See oddity 7.
1094        let forms = self.currency_forms.get(currency).ok_or_else(|| {
1095            N2WError::NotImplemented(format!(
1096                "Currency \"{}\" not implemented for \"{}\"",
1097                currency,
1098                self.lang_name()
1099            ))
1100        })?;
1101        let cr1 = &forms.unit;
1102        let cr2 = &forms.subunit;
1103
1104        // Hardcoded in Python, not `self.negword` — same string either way.
1105        let minus_str = if is_negative { "минус " } else { "" };
1106        // `left` is always >= 0 (`parse_currency_parts` abs()es it), so this
1107        // takes `_int_to_cardinal`'s positive path: units are masculine, with
1108        // no agreement against the unit word's gender (oddity 11).
1109        let money_str = self.int_to_cardinal(&left);
1110        let currency_str = index_form(cr1, left.is_one())?;
1111
1112        // For integers, don't show cents.
1113        if is_integer_input {
1114            return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
1115        }
1116
1117        // For floats, always show cents (even if zero).
1118        let right_int = right.as_bigint_and_exponent().0;
1119        let cents_str = if cents {
1120            if has_fractional_cents {
1121                // `self.to_cardinal(float(right)) if right > 0 else self.ones[0]`.
1122                //
1123                // `cardinal_from_decimal` is overridden above to route to BG's
1124                // own float path, so this now matches. It did not before: the
1125                // trait default goes to Base's `to_cardinal_float`, which
1126                // renders each post-decimal digit via `to_cardinal` ->
1127                // `_int_to_word(d, masculine=True)`, disagreeing on exactly
1128                // the two keys of `ones_masculine`:
1129                //   py:  to_currency(1.011, "USD") == "един долар и един точка едно цента"
1130                //   was: ...                          "един долар и един точка един цента"
1131                // No corpus row reaches this branch — every `bg` currency arg
1132                // has <= 2 decimals, so `has_fractional_cents` is always false
1133                // — so `tests::currency_fractional_cents` pins it instead.
1134                if right.is_positive() {
1135                    self.cardinal_from_decimal(&right)?
1136                } else {
1137                    ONES[0].to_string()
1138                }
1139            } else if right_int.is_positive() {
1140                self.int_to_cardinal(&right_int)
1141            } else {
1142                ONES[0].to_string()
1143            }
1144        } else {
1145            // `str(float(right) if isinstance(right, Decimal) else right)`.
1146            // Note there is no zero padding: 0.05 gives "5", where Base's
1147            // `_cents_terse` would give "05".
1148            if has_fractional_cents {
1149                // `str(float(Decimal))`. `right` is a cent count in [0, 100),
1150                // so Rust's shortest-round-trip `{}` and Python's `repr`
1151                // agree; neither reaches exponent form.
1152                let f = right
1153                    .to_f64()
1154                    .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", right)))?;
1155                format!("{}", f)
1156            } else {
1157                right_int.to_string()
1158            }
1159        };
1160
1161        // `cr2[0] if right == 1 else cr2[1]`. Python compares the *Decimal* in
1162        // the fractional case, so this compares `BigDecimal`: at 1.011 USD,
1163        // right is Decimal("1.100") — i.e. 1.1, not 1 — and takes the plural.
1164        let cents_currency = index_form(cr2, right == BigDecimal::one())?;
1165
1166        Ok(format!(
1167            "{}{} {}{} {} {}",
1168            minus_str, money_str, currency_str, separator, cents_str, cents_currency
1169        ))
1170    }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::*;
1176    use std::str::FromStr;
1177
1178    /// A Python `int` arg (corpus `arg` with no dot).
1179    fn int(s: &str) -> CurrencyValue {
1180        CurrencyValue::parse(s, true, false, false).unwrap()
1181    }
1182
1183    /// A Python `float` arg. `has_decimal` is always true for a float repr —
1184    /// and BG ignores the flag entirely (it keys on `isinstance(val, int)`),
1185    /// which `float_ignores_has_decimal` pins.
1186    fn float(s: &str) -> CurrencyValue {
1187        CurrencyValue::parse(s, false, true, true).unwrap()
1188    }
1189
1190    /// Every `"lang": "bg", "to": "currency:*"` row of the frozen corpus that
1191    /// succeeds, verbatim.
1192    #[test]
1193    fn currency_corpus_rows() {
1194        let bg = LangBg::new();
1195        let rows: Vec<(&str, CurrencyValue, &str)> = vec![
1196            ("EUR", int("0"), "нула евро"),
1197            ("EUR", int("1"), "един евро"),
1198            ("EUR", int("2"), "два евро"),
1199            ("EUR", int("100"), "сто евро"),
1200            ("EUR", float("12.34"), "дванадесет евро и тридесет и четири цента"),
1201            ("EUR", float("0.01"), "нула евро и един цент"),
1202            ("EUR", float("1.0"), "един евро и нула цента"),
1203            ("EUR", float("99.99"), "деветдесет и девет евро и деветдесет и девет цента"),
1204            ("EUR", float("1234.56"), "хиляда двеста тридесет и четири евро и петдесет и шест цента"),
1205            ("EUR", float("-12.34"), "минус дванадесет евро и тридесет и четири цента"),
1206            ("EUR", int("1000000"), "един милион евро"),
1207            ("EUR", float("0.5"), "нула евро и петдесет цента"),
1208            ("USD", int("0"), "нула долара"),
1209            ("USD", int("1"), "един долар"),
1210            ("USD", int("2"), "два долара"),
1211            ("USD", int("100"), "сто долара"),
1212            ("USD", float("12.34"), "дванадесет долара и тридесет и четири цента"),
1213            ("USD", float("0.01"), "нула долара и един цент"),
1214            ("USD", float("1.0"), "един долар и нула цента"),
1215            ("USD", float("99.99"), "деветдесет и девет долара и деветдесет и девет цента"),
1216            ("USD", float("1234.56"), "хиляда двеста тридесет и четири долара и петдесет и шест цента"),
1217            ("USD", float("-12.34"), "минус дванадесет долара и тридесет и четири цента"),
1218            ("USD", int("1000000"), "един милион долара"),
1219            ("USD", float("0.5"), "нула долара и петдесет цента"),
1220            ("GBP", int("0"), "нула паунда"),
1221            ("GBP", int("1"), "един паунд"),
1222            ("GBP", int("2"), "два паунда"),
1223            ("GBP", int("100"), "сто паунда"),
1224            ("GBP", float("12.34"), "дванадесет паунда и тридесет и четири пенса"),
1225            ("GBP", float("0.01"), "нула паунда и един пени"),
1226            ("GBP", float("1.0"), "един паунд и нула пенса"),
1227            ("GBP", float("99.99"), "деветдесет и девет паунда и деветдесет и девет пенса"),
1228            ("GBP", float("1234.56"), "хиляда двеста тридесет и четири паунда и петдесет и шест пенса"),
1229            ("GBP", float("-12.34"), "минус дванадесет паунда и тридесет и четири пенса"),
1230            ("GBP", int("1000000"), "един милион паунда"),
1231            ("GBP", float("0.5"), "нула паунда и петдесет пенса"),
1232            // JPY is a *100*-subunit currency in BG: `to_currency` hardcodes
1233            // divisor 100 and never reads CURRENCY_PRECISION (oddity 10).
1234            ("JPY", int("0"), "нула йени"),
1235            ("JPY", int("1"), "един йена"),
1236            ("JPY", int("2"), "два йени"),
1237            ("JPY", int("100"), "сто йени"),
1238            ("JPY", float("12.34"), "дванадесет йени и тридесет и четири сена"),
1239            ("JPY", float("0.01"), "нула йени и един сен"),
1240            ("JPY", float("1.0"), "един йена и нула сена"),
1241            ("JPY", float("99.99"), "деветдесет и девет йени и деветдесет и девет сена"),
1242            ("JPY", float("1234.56"), "хиляда двеста тридесет и четири йени и петдесет и шест сена"),
1243            ("JPY", float("-12.34"), "минус дванадесет йени и тридесет и четири сена"),
1244            ("JPY", int("1000000"), "един милион йени"),
1245            ("JPY", float("0.5"), "нула йени и петдесет сена"),
1246        ];
1247        for (code, v, want) in rows {
1248            let got = bg.to_currency(&v, code, true, None, false).unwrap();
1249            assert_eq!(got, want, "currency:{} {:?}", code, v);
1250        }
1251    }
1252
1253    /// BGN — BG's `to_currency` default currency. No corpus rows; values
1254    /// cross-checked against the live interpreter.
1255    #[test]
1256    fn currency_bgn_default() {
1257        let bg = LangBg::new();
1258        let d = bg.default_currency();
1259        assert_eq!(d, "BGN");
1260        assert_eq!(bg.to_currency(&int("1"), d, true, None, false).unwrap(), "един лев");
1261        assert_eq!(bg.to_currency(&int("2"), d, true, None, false).unwrap(), "два лева");
1262        assert_eq!(
1263            bg.to_currency(&float("1.0"), d, true, None, false).unwrap(),
1264            "един лев и нула стотинки"
1265        );
1266        // "един стотинка": masculine numeral, feminine unit (oddity 11).
1267        assert_eq!(
1268            bg.to_currency(&float("0.01"), d, true, None, false).unwrap(),
1269            "нула лева и един стотинка"
1270        );
1271    }
1272
1273    /// The 48 `err: NotImplementedError` currency rows span 5 codes; the
1274    /// message is BG's own, *without* Base's "code" (oddity 7).
1275    #[test]
1276    fn currency_unknown_code_uses_bg_message() {
1277        let bg = LangBg::new();
1278        for code in ["KWD", "BHD", "INR", "CNY", "CHF"] {
1279            for v in [int("0"), int("1000000"), float("12.34"), float("-12.34")] {
1280                match bg.to_currency(&v, code, true, None, false) {
1281                    Err(N2WError::NotImplemented(m)) => assert_eq!(
1282                        m,
1283                        format!("Currency \"{}\" not implemented for \"Num2Word_BG\"", code)
1284                    ),
1285                    other => panic!("currency:{} {:?}: expected NotImplemented, got {:?}", code, v, other),
1286                }
1287            }
1288        }
1289    }
1290
1291    /// Every `"lang": "bg", "to": "cheque:*"` row, via the inherited
1292    /// `default_to_cheque`. Note `AND` stays English and the unit takes
1293    /// `cr1[-1]` (plural) unconditionally.
1294    #[test]
1295    fn cheque_corpus_rows() {
1296        let bg = LangBg::new();
1297        let val = BigDecimal::from_str("1234.56").unwrap();
1298        for (code, want) in [
1299            ("EUR", "ХИЛЯДА ДВЕСТА ТРИДЕСЕТ И ЧЕТИРИ AND 56/100 ЕВРО"),
1300            ("USD", "ХИЛЯДА ДВЕСТА ТРИДЕСЕТ И ЧЕТИРИ AND 56/100 ДОЛАРА"),
1301            ("GBP", "ХИЛЯДА ДВЕСТА ТРИДЕСЕТ И ЧЕТИРИ AND 56/100 ПАУНДА"),
1302            ("JPY", "ХИЛЯДА ДВЕСТА ТРИДЕСЕТ И ЧЕТИРИ AND 56/100 ЙЕНИ"),
1303        ] {
1304            assert_eq!(bg.to_cheque(&val, code).unwrap(), want, "cheque:{}", code);
1305        }
1306    }
1307
1308    /// `to_cheque` keeps *Base's* wording — "Currency code" — unlike
1309    /// `to_currency` above. Same class, same missing code, two messages.
1310    #[test]
1311    fn cheque_unknown_code_uses_base_message() {
1312        let bg = LangBg::new();
1313        let val = BigDecimal::from_str("1234.56").unwrap();
1314        for code in ["KWD", "BHD", "INR", "CNY", "CHF"] {
1315            match bg.to_cheque(&val, code) {
1316                Err(N2WError::NotImplemented(m)) => assert_eq!(
1317                    m,
1318                    format!("Currency code \"{}\" not implemented for \"Num2Word_BG\"", code)
1319                ),
1320                other => panic!("cheque:{}: expected NotImplemented, got {:?}", code, other),
1321            }
1322        }
1323    }
1324
1325    /// `adjective=True` is accepted and ignored (oddity 9).
1326    #[test]
1327    fn adjective_is_ignored() {
1328        let bg = LangBg::new();
1329        let v = float("12.34");
1330        assert_eq!(
1331            bg.to_currency(&v, "USD", true, None, true).unwrap(),
1332            bg.to_currency(&v, "USD", true, None, false).unwrap(),
1333        );
1334        assert!(bg.currency_adjective("USD").is_none());
1335    }
1336
1337    /// BG branches on `isinstance(val, int)`, never on `has_decimal`: a float
1338    /// prints cents whatever the flag says. Pins that the flag is unread.
1339    #[test]
1340    fn float_ignores_has_decimal() {
1341        let bg = LangBg::new();
1342        let with = CurrencyValue::parse("1.0", false, true, true).unwrap();
1343        let without = CurrencyValue::parse("1.0", false, false, false).unwrap();
1344        assert_eq!(
1345            bg.to_currency(&with, "EUR", true, None, false).unwrap(),
1346            "един евро и нула цента"
1347        );
1348        assert_eq!(
1349            bg.to_currency(&without, "EUR", true, None, false).unwrap(),
1350            "един евро и нула цента"
1351        );
1352    }
1353
1354    /// `cents=False`: `str(right)` with no zero padding — "5", not "05".
1355    /// Cross-checked against the live interpreter.
1356    #[test]
1357    fn cents_terse_path_is_unpadded_str() {
1358        let bg = LangBg::new();
1359        assert_eq!(
1360            bg.to_currency(&float("12.34"), "USD", false, None, false).unwrap(),
1361            "дванадесет долара и 34 цента"
1362        );
1363        assert_eq!(
1364            bg.to_currency(&float("0.05"), "USD", false, None, false).unwrap(),
1365            "нула долара и 5 цента"
1366        );
1367    }
1368
1369    /// An explicit `separator=` still wins over BG's " и" default.
1370    #[test]
1371    fn explicit_separator_overrides_default() {
1372        let bg = LangBg::new();
1373        assert_eq!(bg.default_separator(), " и");
1374        assert_eq!(
1375            bg.to_currency(&float("12.34"), "USD", true, Some(","), false).unwrap(),
1376            "дванадесет долара, тридесет и четири цента"
1377        );
1378    }
1379
1380    /// BG never reaches `pluralize`; the default's raise mirrors Base's.
1381    #[test]
1382    fn pluralize_is_unreachable_and_still_raises() {
1383        let bg = LangBg::new();
1384        let forms = vec!["лев".to_string(), "лева".to_string()];
1385        assert!(matches!(
1386            bg.pluralize(&BigInt::from(1), &forms),
1387            Err(N2WError::NotImplemented(_))
1388        ));
1389    }
1390
1391    // ---- float / Decimal ------------------------------------------------
1392
1393    /// A Python `float` arg. `precision` is what the shim derives from
1394    /// `repr(value)`; it is quoted from the live interpreter, never guessed.
1395    fn f(value: f64, precision: u32) -> FloatValue {
1396        FloatValue::Float { value, precision }
1397    }
1398
1399    /// A Python `Decimal` arg. `precision` is `abs(as_tuple().exponent)`.
1400    fn dec(s: &str, precision: u32) -> FloatValue {
1401        FloatValue::Decimal {
1402            value: BigDecimal::from_str(s).unwrap(),
1403            precision,
1404        }
1405    }
1406
1407    /// Every `"lang": "bg", "to": "cardinal"` corpus row whose `arg` has a dot.
1408    #[test]
1409    fn cardinal_float_corpus_rows() {
1410        let bg = LangBg::new();
1411        let rows: Vec<(f64, u32, &str)> = vec![
1412            (0.0, 1, "нула"),
1413            (0.5, 1, "нула точка пет"),
1414            (1.0, 1, "един"),
1415            (1.5, 1, "един точка пет"),
1416            // Integer part masculine "два", fraction neuter "две" — one string
1417            // apart, same digit (oddity 14).
1418            (2.25, 2, "два точка две пет"),
1419            (3.14, 2, "три точка едно четири"),
1420            (0.01, 2, "нула точка нула едно"),
1421            (0.1, 1, "нула точка едно"),
1422            (0.99, 2, "нула точка девет девет"),
1423            (1.01, 2, "един точка нула едно"),
1424            (12.34, 2, "дванадесет точка три четири"),
1425            (99.99, 2, "деветдесет и девет точка девет девет"),
1426            (100.5, 1, "сто точка пет"),
1427            (1234.56, 2, "хиляда двеста тридесет и четири точка пет шест"),
1428            (-0.5, 1, "минус нула точка пет"),
1429            (-1.5, 1, "минус един точка пет"),
1430            (-12.34, 2, "минус дванадесет точка три четири"),
1431            // The two f64-artefact rows. 1.005 * 1000 is 4.999999999999999
1432            // away from 1000 -> post 5, padded to "005". 2.675 gives
1433            // 674.9999999999998, which the `< 0.01` heuristic rescues to 675.
1434            (1.005, 3, "един точка нула нула пет"),
1435            (2.675, 3, "два точка шест седем пет"),
1436        ];
1437        for (v, p, want) in rows {
1438            assert_eq!(bg.to_cardinal_float(&f(v, p), None).unwrap(), want, "{}", v);
1439        }
1440    }
1441
1442    /// Every `"lang": "bg", "to": "cardinal_dec"` corpus row.
1443    ///
1444    /// All five are bare integers: `isinstance(n, float)` is false for a
1445    /// Decimal, so BG never enters its float branch and truncates instead
1446    /// (oddity 13). The #603 trillion-scale row is the one that proves the
1447    /// value is not being routed through an f64 on the way: 98746251323029.99
1448    /// keeps its ...029, which a float cast would have rounded to ...030.
1449    #[test]
1450    fn cardinal_decimal_corpus_rows() {
1451        let bg = LangBg::new();
1452        let rows: Vec<(&str, u32, &str)> = vec![
1453            ("0.01", 2, "нула"),
1454            ("1.10", 2, "един"),
1455            ("12.345", 3, "дванадесет"),
1456            (
1457                "98746251323029.99",
1458                2,
1459                "деветдесет и осем хиляди седемстотин четиридесет и шест милиарда \
1460                 двеста петдесет и едно милиона триста двадесет и три хиляди двадесет и девет",
1461            ),
1462            ("0.001", 3, "нула"),
1463        ];
1464        for (s, p, want) in rows {
1465            assert_eq!(bg.to_cardinal_float(&dec(s, p), None).unwrap(), want, "{}", s);
1466        }
1467    }
1468
1469    /// Decimal negatives and signed zero, cross-checked against the live
1470    /// interpreter. `Decimal("-0.0") < 0` is false in Python, and BigDecimal
1471    /// has no signed zero, so both answer plain "нула" with no sign.
1472    #[test]
1473    fn cardinal_decimal_negatives() {
1474        let bg = LangBg::new();
1475        for (s, p, want) in [
1476            ("-1.5", 1, "минус един"),
1477            ("-0.5", 1, "минус нула"),
1478            ("-12.34", 2, "минус дванадесет"),
1479            ("2.675", 3, "два"),
1480            ("-0.0", 1, "нула"),
1481            ("0.0", 1, "нула"),
1482        ] {
1483            assert_eq!(bg.to_cardinal_float(&dec(s, p), None).unwrap(), want, "{}", s);
1484        }
1485    }
1486
1487    /// Integral floats skip the fraction entirely (`n != int(n)` is false) and
1488    /// take the "For integers" tail, where the sign recursion restores the
1489    /// masculine agreement. Live-checked.
1490    #[test]
1491    fn cardinal_integral_floats() {
1492        let bg = LangBg::new();
1493        for (v, p, want) in [
1494            (0.0, 1, "нула"),
1495            (-0.0, 1, "нула"),
1496            (1.0, 1, "един"),
1497            (2.0, 1, "два"),
1498            (-1.0, 1, "минус един"),
1499            (-3.0, 1, "минус три"),
1500            // repr(1e21) is "1e+21", so the shim hands over precision 21 —
1501            // and the integer tail never looks at it (oddity 16).
1502            (1e21, 21, "хиляда милиарда милиарда"),
1503        ] {
1504            assert_eq!(bg.to_cardinal_float(&f(v, p), None).unwrap(), want, "{}", v);
1505        }
1506    }
1507
1508    /// More float rows from the live interpreter, chosen for the digits that
1509    /// separate the neuter fraction table from the masculine integer one.
1510    #[test]
1511    fn cardinal_float_live_rows() {
1512        let bg = LangBg::new();
1513        for (v, p, want) in [
1514            (1.1, 1, "един точка едно"),
1515            (2.2, 1, "два точка две"),
1516            (1.2, 1, "един точка две"),
1517            (0.02, 2, "нула точка нула две"),
1518            (9.99, 2, "девет точка девет девет"),
1519            (-0.01, 2, "минус нула точка нула едно"),
1520            (-0.001, 3, "минус нула точка нула нула едно"),
1521            (0.001, 3, "нула точка нула нула едно"),
1522            (-2.25, 2, "минус два точка две пет"),
1523            (-100.5, 1, "минус сто точка пет"),
1524            (-1234.56, 2, "минус хиляда двеста тридесет и четири точка пет шест"),
1525            (1000000.5, 1, "един милион точка пет"),
1526            (1e-7, 7, "нула точка нула нула нула нула нула нула едно"),
1527            (
1528                0.5000000001,
1529                10,
1530                "нула точка пет нула нула нула нула нула нула нула нула едно",
1531            ),
1532            (
1533                123456789.123,
1534                3,
1535                "сто двадесет и три милиона четиристотин петдесет и шест хиляди \
1536                 седемстотин осемдесет и девет точка едно две три",
1537            ),
1538        ] {
1539            assert_eq!(bg.to_cardinal_float(&f(v, p), None).unwrap(), want, "{}", v);
1540        }
1541    }
1542
1543    /// `precision=` is dead for BG: the dispatcher writes `converter.precision`
1544    /// and `float2tuple` overwrites it from `repr(value)` before anything reads
1545    /// it (oddity 16). Live-checked at precision 1, 3 and 5.
1546    #[test]
1547    fn precision_override_is_ignored() {
1548        let bg = LangBg::new();
1549        for p in [None, Some(1), Some(3), Some(5), Some(0)] {
1550            assert_eq!(
1551                bg.to_cardinal_float(&f(1.005, 3), p).unwrap(),
1552                "един точка нула нула пет"
1553            );
1554            assert_eq!(
1555                bg.to_cardinal_float(&f(2.675, 3), p).unwrap(),
1556                "два точка шест седем пет"
1557            );
1558            assert_eq!(bg.to_cardinal_float(&dec("12.345", 3), p).unwrap(), "дванадесет");
1559        }
1560    }
1561
1562    /// `10**precision` past ~1.8e308 raises OverflowError inside `float2tuple`,
1563    /// and BG's `except BaseException` answers `_int_to_cardinal(int(n))` —
1564    /// "нула" for every such n, sign discarded (oddity 17). The boundary is
1565    /// live-checked: precision 308 still spells the fraction out in full.
1566    #[test]
1567    fn float2tuple_overflow_is_swallowed() {
1568        let bg = LangBg::new();
1569        // repr(5e-324) == "5e-324" -> precision 324; likewise 1e-310 -> 310.
1570        assert_eq!(bg.to_cardinal_float(&f(5e-324, 324), None).unwrap(), "нула");
1571        assert_eq!(bg.to_cardinal_float(&f(-5e-324, 324), None).unwrap(), "нула");
1572        assert_eq!(bg.to_cardinal_float(&f(1e-310, 310), None).unwrap(), "нула");
1573        assert_eq!(bg.to_cardinal_float(&f(-1e-310, 310), None).unwrap(), "нула");
1574
1575        // 10**308 fits, so this one goes the long way round: "минус нула точка"
1576        // then 307 zeros and a one.
1577        let got = bg.to_cardinal_float(&f(-1e-308, 308), None).unwrap();
1578        assert!(got.starts_with("минус нула точка нула"), "{}", got);
1579        assert!(got.ends_with(" едно"), "{}", got);
1580        assert_eq!(got.split(' ').count(), 3 + 308);
1581    }
1582
1583    /// `int(n)` raises before `n != int(n)` can answer, and the `except` body
1584    /// re-runs the same call, so the exception propagates with Python's type.
1585    /// Not reachable through the shim (it computes `precision` from
1586    /// `Decimal(str(value))` first, which raises TypeError on the Python side),
1587    /// but the messages match the live interpreter.
1588    #[test]
1589    fn non_finite_floats_raise_pythons_types() {
1590        let bg = LangBg::new();
1591        for v in [f64::INFINITY, f64::NEG_INFINITY] {
1592            match bg.to_cardinal_float(&f(v, 0), None) {
1593                Err(N2WError::Overflow(m)) => {
1594                    assert_eq!(m, "cannot convert float infinity to integer")
1595                }
1596                other => panic!("{}: expected Overflow, got {:?}", v, other),
1597            }
1598        }
1599        match bg.to_cardinal_float(&f(f64::NAN, 0), None) {
1600            Err(N2WError::Value(m)) => assert_eq!(m, "cannot convert float NaN to integer"),
1601            other => panic!("nan: expected Value, got {:?}", other),
1602        }
1603    }
1604
1605    /// The fractional-cents branch of `to_currency`, i.e. Python's
1606    /// `self.to_cardinal(float(right))` via `cardinal_from_decimal`. No corpus
1607    /// row reaches it (every `bg` currency arg has <= 2 decimals), so these are
1608    /// quoted from the live interpreter.
1609    ///
1610    /// 1.021 is the one that pins both tables at once: the cents *count* is
1611    /// masculine "два" and its fraction digit is neuter "едно".
1612    #[test]
1613    fn currency_fractional_cents() {
1614        let bg = LangBg::new();
1615        for (arg, code, want) in [
1616            ("1.011", "USD", "един долар и един точка едно цента"),
1617            ("1.021", "USD", "един долар и два точка едно цента"),
1618            ("0.005", "USD", "нула долара и нула точка пет цента"),
1619            ("2.567", "USD", "два долара и петдесет и шест точка седем цента"),
1620            ("3.0125", "USD", "три долара и един точка две пет цента"),
1621            ("0.0075", "USD", "нула долара и нула точка седем пет цента"),
1622            ("-1.011", "USD", "минус един долар и един точка едно цента"),
1623            ("12.345", "USD", "дванадесет долара и тридесет и четири точка пет цента"),
1624            ("0.001", "USD", "нула долара и нула точка едно цента"),
1625            ("9.999", "USD", "девет долара и деветдесет и девет точка девет цента"),
1626            ("1.011", "BGN", "един лев и един точка едно стотинки"),
1627            ("2.567", "BGN", "два лева и петдесет и шест точка седем стотинки"),
1628        ] {
1629            assert_eq!(
1630                bg.to_currency(&float(arg), code, true, None, false).unwrap(),
1631                want,
1632                "{} {}",
1633                arg,
1634                code
1635            );
1636        }
1637    }
1638}