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}