Skip to main content

num2words2_core/
lang_oc.rs

1//! Port of `lang_OC.py` (Occitan). Registry key `"oc"` → `Num2Word_OC`.
2//!
3//! Shape: **self-contained**. `Num2Word_OC` subclasses `Num2Word_Base` but its
4//! `setup` defines only flat `ones`/`tens` lists plus `hundred`/`thousand`/
5//! `million` strings — it never sets `high_numwords`, `mid_numwords` or
6//! `low_numwords`. `Num2Word_Base.__init__` gates card construction behind
7//! `if any(hasattr(self, field) for field in [...])`, so for OC that branch is
8//! skipped entirely: **`self.cards` is never built and `self.MAXVAL` is never
9//! assigned**. `to_cardinal` is overridden outright and drives a recursive
10//! `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at their trait
11//! defaults here, and **there is no overflow check at any magnitude** — see
12//! bug 1 below for what happens instead.
13//!
14//! Inherited from `Num2Word_Base` and *not* overridden by OC: nothing in
15//! scope. OC overrides all four of `to_cardinal`, `to_ordinal`,
16//! `to_ordinal_num` and `to_year` itself, so no base-class behaviour leaks in.
17//! (`to_year(self, val, longval=True)` ignores `longval` and simply forwards
18//! to `to_cardinal`.)
19//!
20//! # Faithfully reproduced Python bugs / oddities
21//!
22//! This is a port, not a rewrite. Everything below looks wrong and is exactly
23//! what Python emits, each line confirmed against the frozen corpus:
24//!
25//! 1. **Numbers ≥ 10^9 are not spelled at all.** `_int_to_word`'s final `else`
26//!    is `return str(number)  # Fallback for very large numbers`, so
27//!    `to_cardinal(10**9)` == `"1000000000"` — the decimal digits, verbatim.
28//!    This is not an error path: it returns `Ok`. It composes with everything
29//!    downstream, giving `to_ordinal(10**9)` == `"1000000000-en"` and
30//!    `to_cardinal(-10**9)` == `"minus 1000000000"`. Because there is no
31//!    MAXVAL, this holds for arbitrarily large BigInts (corpus covers 10^21).
32//! 2. **No teens.** `tens[1]` is "dètz" and 11..=19 fall through the generic
33//!    `tens[t] + " " + ones[o]` arm, so 11 == "dètz un", 15 == "dètz cinc",
34//!    19 == "dètz nòu". Real Occitan has "onze"/"quinze"/"dètz-e-nòu".
35//! 3. **No elision or hyphens.** 21 == "vint un" (Occitan: "vint-e-un"),
36//!    99 == "nonanta nòu".
37//! 4. **Bare hundreds/thousands/millions take an explicit "un".** 100 ==
38//!    "un cent" (Occitan: "cent"), 1000 == "un mil", 10^6 == "un milion".
39//!    Note the asymmetry: the hundreds arm indexes `ones[hundreds_val]`
40//!    directly, while the thousands/millions arms *recurse*, which is why
41//!    100000 == "un cent mil" (recursion produces "un cent" for the 100).
42//! 5. **`million` is never pluralised**: 999999999 ends "...nòu milion...".
43//! 6. `_int_to_word(0)` reads `self.ones[0] if self.ones[0] else "zero"`.
44//!    `ones[0]` is hardcoded `""` in `setup`, which is falsy, so this
45//!    conditional is a constant-folded no-op that always yields "zero".
46//!    Modelled as a plain `"zero"` return.
47//!
48//! # Error variants
49//!
50//! For integer input, all four cardinal-family modes are total: OC has no
51//! MAXVAL, no dict lookups and no `int()` of a user token, so nothing can
52//! raise. `to_currency` is likewise total (see below — it never raises, not
53//! even for an unknown code). The only raising path in scope is `to_cheque`,
54//! which OC inherits unmodified from `Num2Word_Base` and which turns a missing
55//! `CURRENCY_FORMS` key into `NotImplementedError`. The remaining `ok: false`
56//! rows in the corpus for `"oc"` are `fraction` (TypeError) and `w2n_cardinal`
57//! (Words2NumError) — both out of scope per PORTING.md.
58//!
59//! # Currency surface
60//!
61//! `Num2Word_OC` declares its own two-entry `CURRENCY_FORMS` (EUR, USD) and
62//! overrides `to_currency` **wholesale** — it shares no code with
63//! `Num2Word_Base.to_currency` and therefore never reaches `pluralize`,
64//! `_money_verbose`, `_cents_verbose`, `_cents_terse`, `parse_currency_parts`
65//! or `CURRENCY_PRECISION`. Those hooks are deliberately left at their trait
66//! defaults here. See [`LangOc::to_currency`] for the four behavioural
67//! divergences from base that this buys.
68//!
69//! `to_cheque` is *not* overridden in Python, so `Num2Word_Base.to_cheque`
70//! runs and `crate::currency::default_to_cheque` serves it unchanged. It needs
71//! only [`Lang::currency_forms`] (for the `KeyError` -> `NotImplementedError`
72//! conversion), [`Lang::lang_name`] (for the message), the default
73//! `currency_precision` of 100 and the default `money_verbose`, which routes
74//! back through OC's own `to_cardinal`.
75//!
76//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both `{}` — OC declares
77//! neither, and the empty dicts it inherits from `Num2Word_Base` are never
78//! mutated by another module (only `Num2Word_EUR`'s table is, by
79//! `Num2Word_EN.__init__`; OC does not descend from `Num2Word_EUR`). Confirmed
80//! against the live interpreter, not the source:
81//!
82//! ```text
83//! CONVERTER_CLASSES['oc'].CURRENCY_FORMS
84//!   {'EUR': (('èuro', 'èuros'), ('centim', 'centims')),
85//!    'USD': (('dollar', 'dollars'), ('cent', 'cents'))}
86//! CONVERTER_CLASSES['oc'].CURRENCY_PRECISION   {}
87//! CONVERTER_CLASSES['oc'].CURRENCY_ADJECTIVES  {}
88//! ```
89
90use crate::base::{Lang, N2WError, Result};
91use crate::currency::{CurrencyForms, CurrencyValue};
92use crate::floatpath::FloatValue;
93use bigdecimal::BigDecimal;
94use num_bigint::BigInt;
95use num_traits::{One, Signed, Zero};
96use std::collections::HashMap;
97use std::str::FromStr;
98
99/// `setup`: `self.negword = "minus "`. Note the trailing space; it is part of
100/// the constant and is what separates the sign from the number.
101const NEGWORD: &str = "minus ";
102
103/// `setup`: `self.ones`. Index 0 is the empty string (see bug 6).
104const ONES: [&str; 10] = [
105    "", "un", "dos", "tres", "quatre", "cinc", "sièis", "sèt", "uèch", "nòu",
106];
107
108/// `setup`: `self.tens`. Index 0 is empty and index 1 is the bare "dètz",
109/// which is why there are no teen forms (bug 2).
110const TENS: [&str; 10] = [
111    "",
112    "dètz",
113    "vint",
114    "trenta",
115    "quaranta",
116    "cinquanta",
117    "seissanta",
118    "setanta",
119    "ochanta",
120    "nonanta",
121];
122
123const HUNDRED: &str = "cent";
124const THOUSAND: &str = "mil";
125const MILLION: &str = "milion";
126
127/// `setup`: `self.pointword = "point"`. Live on the float/Decimal path, where
128/// OC interpolates it raw (no `title()`) between the integral part and the
129/// spelled-out fraction digits.
130const POINTWORD: &str = "point";
131
132/// `Num2Word_OC.to_currency`'s own default: `separator=" "`. Note this is *not*
133/// `Num2Word_Base`'s `","` — see [`BASE_DEFAULT_SEPARATOR`].
134const SEPARATOR: &str = " ";
135
136/// `Num2Word_Base.to_currency`'s default separator, which the callers use as a
137/// de-facto "caller said nothing" sentinel.
138///
139/// Python resolves a default argument at the *call site*: omit `separator=` and
140/// `Num2Word_OC.to_currency` binds its own `" "`. The `Lang` trait takes
141/// `separator: &str` unconditionally, so the resolution has already happened by
142/// the time we are called — and both callers hardcode **base**'s default rather
143/// than the language's:
144///
145/// * `num2words2/__init__.py`'s Rust fast path: `kwargs.get("separator", ",")`
146/// * `bench/diff_test.py`: `_rust.to_currency(lang, arg, is_int, code, True, ",", False)`
147///
148/// The frozen corpus was generated through the *Python* converter with no
149/// `separator=` kwarg, so every `"oc"` currency row shows OC's `" "`:
150///
151/// ```text
152/// {"lang": "oc", "to": "currency:EUR", "arg": "12.34",
153///  "out": "dètz dos èuros trenta quatre centims"}
154/// ```
155///
156/// Taking the incoming `","` at face value would render
157/// `"dètz dos èuros,trenta quatre centims"` and fail all 60 rows that carry
158/// cents. So `","` is read back as "unset" and OC's own default restored.
159/// This is right across the whole reachable input space bar one case — an
160/// explicit `separator=","`, which Python renders with a comma and this
161/// renders with a space. A real fix belongs at the boundary (pass
162/// `Option<&str>` and let the language supply its default), which is `base.rs`
163/// / `currency.rs` / `__init__.py` and out of scope for this file; it is
164/// flagged in the report. `lang_as.rs`, `lang_ba.rs` and `lang_br.rs` — the
165/// other languages whose Python signature carries a non-`","` default — resolve
166/// it the same way.
167const BASE_DEFAULT_SEPARATOR: &str = ",";
168
169pub struct LangOc {
170    /// `Num2Word_OC.CURRENCY_FORMS`, built once in [`LangOc::new`].
171    ///
172    /// The binding holds each converter in a `OnceLock` and hands out `&'static`
173    /// references, so this is constructed one time per process. Rebuilding it
174    /// per `to_currency` call is what made an earlier revision of this port an
175    /// order of magnitude slower than the Python it replaces.
176    forms: HashMap<&'static str, CurrencyForms>,
177}
178
179impl Default for LangOc {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl LangOc {
186    pub fn new() -> Self {
187        // CURRENCY_FORMS = {
188        //     "EUR": (("èuro", "èuros"), ("centim", "centims")),
189        //     "USD": (("dollar", "dollars"), ("cent", "cents")),
190        // }
191        // Two forms per side, exactly as Python: `to_currency` indexes [0] and
192        // [1] directly, so the arity is load-bearing.
193        let mut forms = HashMap::with_capacity(2);
194        forms.insert(
195            "EUR",
196            CurrencyForms::new(&["èuro", "èuros"], &["centim", "centims"]),
197        );
198        forms.insert(
199            "USD",
200            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
201        );
202        LangOc { forms }
203    }
204
205    /// `list(self.CURRENCY_FORMS.values())[0]` — the fallback `to_currency`
206    /// uses for an unrecognised code (bug 7).
207    ///
208    /// Python dicts iterate in insertion order and `CURRENCY_FORMS` is a class
209    /// body literal, so "the first value" is permanently EUR's. That ordering
210    /// is the *only* thing pinning this entry, which is why it is spelled out
211    /// as a constant here rather than derived from the `HashMap` (which has no
212    /// order to derive it from).
213    fn fallback_forms(&self) -> &CurrencyForms {
214        self.forms
215            .get("EUR")
216            .expect("EUR is the first CURRENCY_FORMS entry and is inserted in new()")
217    }
218
219    /// Python's `_int_to_word`.
220    ///
221    /// Every threshold below is compared against a `BigInt`; the value is
222    /// deliberately never narrowed to a fixed-width int, because the final
223    /// `else` (bug 1) must stringify arbitrarily large inputs. Indices into
224    /// `ONES`/`TENS` are narrowed only after the surrounding guard has proven
225    /// them to be in `0..10`.
226    fn int_to_word(&self, number: &BigInt) -> String {
227        // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
228        // — ones[0] is "" (falsy), so this is unconditionally "zero" (bug 6).
229        if number.is_zero() {
230            return "zero".to_string();
231        }
232
233        // `if number < 0: return self.negword + self._int_to_word(abs(number))`.
234        // Dead code on every in-scope path: `to_cardinal` strips the sign from
235        // the *string* before calling in, so this arm is only reachable from
236        // `to_currency` (out of scope). Ported anyway for fidelity — it is the
237        // reason a stray negative yields "minus " glued on with no extra space.
238        if number.is_negative() {
239            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
240        }
241
242        let ten = BigInt::from(10);
243        let hundred = BigInt::from(100);
244        let thousand = BigInt::from(1000);
245        let million = BigInt::from(1_000_000);
246        let billion = BigInt::from(1_000_000_000);
247
248        if number < &ten {
249            // 1..=9 — narrowing is safe under the guard.
250            let i = u32::try_from(number).expect("0 < number < 10") as usize;
251            ONES[i].to_string()
252        } else if number < &hundred {
253            // Both operands are positive here, so Python's floor-division `//`
254            // and `%` agree with Rust's truncating `/` and `%`.
255            let tens_val = u32::try_from(number / &ten).expect("< 10") as usize;
256            let ones_val = u32::try_from(number % &ten).expect("< 10") as usize;
257            if ones_val == 0 {
258                TENS[tens_val].to_string()
259            } else {
260                // No hyphen, no "e" (bugs 2 and 3).
261                format!("{} {}", TENS[tens_val], ONES[ones_val])
262            }
263        } else if number < &thousand {
264            let hundreds_val = u32::try_from(number / &hundred).expect("< 10") as usize;
265            let remainder = number % &hundred;
266            // Indexes ONES directly rather than recursing, so 100 == "un cent"
267            // (bug 4).
268            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
269            if !remainder.is_zero() {
270                result.push(' ');
271                result.push_str(&self.int_to_word(&remainder));
272            }
273            result
274        } else if number < &million {
275            let thousands_val = number / &thousand;
276            let remainder = number % &thousand;
277            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
278            if !remainder.is_zero() {
279                result.push(' ');
280                result.push_str(&self.int_to_word(&remainder));
281            }
282            result
283        } else if number < &billion {
284            let millions_val = number / &million;
285            let remainder = number % &million;
286            // MILLION is never pluralised (bug 5).
287            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
288            if !remainder.is_zero() {
289                result.push(' ');
290                result.push_str(&self.int_to_word(&remainder));
291            }
292            result
293        } else {
294            // `return str(number)  # Fallback for very large numbers` (bug 1).
295            // Not an error — a successful return of the bare decimal digits.
296            number.to_string()
297        }
298    }
299
300    /// `Num2Word_OC.to_cardinal`, driven by the **string** form of the value
301    /// rather than the value itself — this is the float/Decimal branch:
302    ///
303    /// ```python
304    /// n = str(number).strip()
305    /// if n.startswith("-"):
306    ///     n = n[1:]
307    ///     ret = self.negword
308    /// else:
309    ///     ret = ""
310    /// if "." in n:
311    ///     left, right = n.split(".", 1)
312    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
313    ///     for digit in right:
314    ///         ret += self._int_to_word(int(digit)) + " "
315    ///     return ret.strip()
316    /// else:
317    ///     return (ret + self._int_to_word(int(n))).strip()
318    /// ```
319    ///
320    /// The integer [`Lang::to_cardinal`] models the same method on the *value*,
321    /// which for an int is equivalent (stripping "-" from `str(int)` is just
322    /// `abs`) and much cheaper. Once a "." can appear that equivalence breaks,
323    /// so this arm follows Python literally.
324    ///
325    /// Details that look like slips and are not:
326    ///
327    /// * OC does **not** recurse on the sign (unlike some siblings): it strips
328    ///   exactly one leading "-" from the string and records `negword` as a
329    ///   flat prefix on `ret`.
330    /// * `split(".", 1)` splits on the **first** dot only, so a stray second
331    ///   dot lands in `right` and reaches `int(digit)` as a bad literal.
332    ///   `str()` never emits two dots, but `split_once` keeps the semantics.
333    /// * Each fraction character is rendered through `_int_to_word(int(digit))`,
334    ///   **not** a bare `ones[]` lookup — so a `'0'` digit becomes "zero"
335    ///   (via bug 6), where the integer path's `ones[0]` would be "".
336    /// * `int(digit)` is per **character**, so a malformed fraction (the 'e' of
337    ///   "1.5e+16") raises `ValueError` quoting one character, where a
338    ///   malformed whole `n` (the "1e+16" of an exponent literal) quotes the
339    ///   entire literal.
340    fn cardinal_from_str(&self, n: &str) -> Result<String> {
341        // `n = str(number).strip()` — str()'s output never carries surrounding
342        // whitespace, so this only ever no-ops. Reproduced, not assumed away.
343        let n = n.trim();
344
345        // `if n.startswith("-"): n = n[1:]; ret = self.negword else: ret = ""`.
346        let (ret_prefix, n) = match n.strip_prefix('-') {
347            Some(rest) => (NEGWORD, rest),
348            None => ("", n),
349        };
350
351        if let Some((left, right)) = n.split_once('.') {
352            // `ret += int_to_word(int(left)) + " " + pointword + " "`.
353            // `int(left)` runs before the loop, so a bad left part raises first.
354            let mut ret = format!(
355                "{}{} {} ",
356                ret_prefix,
357                self.int_to_word(&python_int(left)?),
358                POINTWORD
359            );
360            // `for digit in right: ret += int_to_word(int(digit)) + " "`.
361            for ch in right.chars() {
362                // `int(digit)` on a single character: a non-digit (the 'e' of
363                // "1.5e+16") raises ValueError quoting just that character.
364                let d = python_int(&ch.to_string())?;
365                ret.push_str(&self.int_to_word(&d));
366                ret.push(' ');
367            }
368            // `return ret.strip()`.
369            Ok(ret.trim().to_string())
370        } else {
371            // `return (ret + int_to_word(int(n))).strip()`. Reached by an
372            // integral Decimal ("5" -> "cinc") and by exponent-form floats
373            // ("1e+16"), where `int(n)` then raises.
374            Ok(format!("{}{}", ret_prefix, self.int_to_word(&python_int(n)?))
375                .trim()
376                .to_string())
377        }
378    }
379}
380
381/// CPython's `repr(float)` — which for a `float` is also `str(float)`, and
382/// therefore the entire input to OC's float path.
383///
384/// Two halves, both load-bearing.
385///
386/// # 1. The digits
387///
388/// `repr` is shortest-round-trip, and so is Rust's `{}`/`{:e}`, so the digits
389/// agree — *except on exact ties*, where the double sits precisely halfway
390/// between two equally short candidates that both round-trip. CPython's dtoa
391/// breaks those to **even**; Rust's shortest formatter does not (it disagrees
392/// with CPython on roughly 1 double in 10,000 sampled uniformly).
393/// `670352580196876.25` is exactly such a value: CPython prints
394/// `670352580196876.2`, Rust's `{:e}` gives `...3`.
395///
396/// The repair is to re-derive the digits through Rust's *exact* formatter
397/// (`{:.n$}`), which **is** round-half-to-even, once `{:e}` has told us how
398/// many fractional digits the shortest form has. A tie cannot change that
399/// count (a carry would produce a *shorter* representation, which the shortest
400/// algorithm would have found first), so the count is safe to take from `{:e}`
401/// even though the final digit is not.
402///
403/// # 2. The placement
404///
405/// CPython switches to exponent notation iff `decpt <= -4 || decpt > 16`,
406/// pads the exponent to two digits, and appends `.0` to anything that would
407/// otherwise look like an integer. Rust's `{}` does none of this, so `1e16`
408/// and `1.0` would both come out wrong. Both matter here: `str(1.0)` is `"1.0"`
409/// -> `"un point zero"`, and `str(1e16)` is `"1e+16"` -> `ValueError`.
410///
411/// The `precision` that `FloatValue::Float` carries is deliberately *not* used
412/// to shortcut this. It is `abs(Decimal(str(value)).as_tuple().exponent)`,
413/// which for an exponent-form repr is the *exponent*, not a digit count:
414/// `1e16` arrives with `precision == 16`.
415fn python_float_repr(v: f64) -> String {
416    // repr(nan) / repr(inf) / repr(-inf). OC feeds these straight to int(),
417    // which rejects them like any other bad literal.
418    if v.is_nan() {
419        return "nan".to_string();
420    }
421    if v.is_infinite() {
422        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
423    }
424    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0", and OC renders that
425    // "minus zero point zero".
426    let sign = if v.is_sign_negative() { "-" } else { "" };
427    let a = v.abs();
428
429    // `{:e}` is shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the digits
430    // and the decimal-point position fall straight out. `decpt` is CPython's:
431    // the value is `0.<digits> * 10**decpt`.
432    let s = format!("{:e}", a);
433    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
434    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
435    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
436    let mut decpt = exp + 1;
437
438    // Tie repair — see the doc comment. Only reachable when the shortest form
439    // has fractional digits; `a == 0.0` is excluded because `{:e}` reports it
440    // as "0e0" and there is nothing to round.
441    let frac_digits = digits.chars().count() as i32 - decpt;
442    if frac_digits > 0 && a != 0.0 {
443        let t = format!("{:.*}", frac_digits as usize, a);
444        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
445        let all = format!("{}{}", ip, fp);
446        let trimmed = all.trim_start_matches('0');
447        if !trimmed.is_empty() {
448            let lead = all.chars().count() - trimmed.chars().count();
449            digits = trimmed.to_string();
450            decpt = ip.chars().count() as i32 - lead as i32;
451        }
452    }
453
454    let n = digits.chars().count() as i32;
455
456    if decpt <= -4 || decpt > 16 {
457        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
458        let e = decpt - 1;
459        let mut out = String::from(sign);
460        let mut it = digits.chars();
461        out.push(it.next().expect("a finite double has at least one digit"));
462        if n > 1 {
463            out.push('.');
464            out.push_str(it.as_str());
465        }
466        out.push('e');
467        out.push(if e < 0 { '-' } else { '+' });
468        out.push_str(&format!("{:02}", (e as i64).abs()));
469        out
470    } else if decpt <= 0 {
471        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
472    } else if decpt >= n {
473        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
474        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
475    } else {
476        let k = decpt as usize;
477        format!(
478            "{}{}.{}",
479            sign,
480            digits.chars().take(k).collect::<String>(),
481            digits.chars().skip(k).collect::<String>()
482        )
483    }
484}
485
486/// CPython's `str(Decimal)` — the spec's to-scientific-string, transcribed
487/// from `_pydecimal.Decimal.__str__`:
488///
489/// ```python
490/// leftdigits = self._exp + len(self._int)
491/// if self._exp <= 0 and leftdigits > -6:
492///     dotplace = leftdigits          # no exponent required
493/// else:
494///     dotplace = 1                   # scientific: 1 digit left of the point
495/// if dotplace <= 0:
496///     intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
497/// elif dotplace >= len(self._int):
498///     intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
499/// else:
500///     intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
501/// exp = '' if leftdigits == dotplace else 'E' + "%+d" % (leftdigits-dotplace)
502/// return sign + intpart + fracpart + exp
503/// ```
504///
505/// `BigDecimal` is the same (unscaled, scale) pair as Python's `(_int, _exp)`
506/// — `_exp == -scale` — and `from_str` preserves the scale as written, so
507/// `Decimal("1.10")`'s trailing zero survives the crossing.
508///
509/// This reads `as_bigint_and_exponent()` rather than `BigDecimal`'s own
510/// `Display`, which is **not** `str(Decimal)`: it renders `Decimal("0.00")` as
511/// `"0"`, losing the two digits OC would have spoken (`"zero point zero zero"`).
512/// The capital `E` and the unpadded exponent are Python's too.
513///
514/// # The negative-zero hole
515///
516/// Python's `Decimal` carries a sign flag independent of its digits, so
517/// `str(Decimal("-0.0"))` is `"-0.0"` and OC prepends `minus`. `BigInt` has no
518/// negative zero, so `BigDecimal::from_str("-0.0")` discards the sign before
519/// this function ever sees it, and the `minus` is lost. The discriminator is
520/// the original string, which the `FloatValue::Decimal` boundary does not
521/// carry — the *float* `-0.0` is fine, because f64 keeps its sign bit. Flagged
522/// in the port report; no corpus row falls in the hole.
523fn python_decimal_str(d: &BigDecimal) -> String {
524    let (unscaled, scale) = d.as_bigint_and_exponent();
525    let sign = if unscaled.is_negative() { "-" } else { "" };
526    // Python's `_int`: the unsigned coefficient. BigInt renders ASCII digits.
527    let int_digits = unscaled.abs().to_string();
528    let exp: i64 = -scale;
529    let ndig = int_digits.chars().count() as i64;
530    let leftdigits = exp + ndig;
531    let dotplace = if exp <= 0 && leftdigits > -6 {
532        leftdigits
533    } else {
534        1
535    };
536
537    let (intpart, fracpart) = if dotplace <= 0 {
538        (
539            "0".to_string(),
540            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
541        )
542    } else if dotplace >= ndig {
543        (
544            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
545            String::new(),
546        )
547    } else {
548        let k = dotplace as usize;
549        (
550            int_digits.chars().take(k).collect::<String>(),
551            format!(".{}", int_digits.chars().skip(k).collect::<String>()),
552        )
553    };
554
555    // `"%+d"` — always signed, never zero-padded. Capital E: context.capitals
556    // defaults to 1.
557    let exp_part = if leftdigits == dotplace {
558        String::new()
559    } else {
560        format!("E{:+}", leftdigits - dotplace)
561    };
562
563    format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
564}
565
566/// `str(number)` for whatever the Python dispatcher handed the converter.
567///
568/// The `FloatValue` split is exactly Python's `isinstance(value, Decimal)`:
569/// the two arms stringify by different rules and must not be collapsed.
570fn python_str(v: &FloatValue) -> String {
571    match v {
572        FloatValue::Float { value, .. } => python_float_repr(*value),
573        FloatValue::Decimal { value, .. } => python_decimal_str(value),
574    }
575}
576
577/// Python's `int(s)`, for the strings `str()` can produce.
578///
579/// `BigInt::from_str` and `int()` agree on everything reachable here: plain
580/// ASCII digit runs with an optional sign. They diverge on inputs `str(float)`
581/// and `str(Decimal)` cannot emit, so the divergence is unreachable rather than
582/// papered over. The message is Python's, quoting the offending literal.
583fn python_int(s: &str) -> Result<BigInt> {
584    BigInt::from_str(s).map_err(|_| {
585        N2WError::Value(format!(
586            "invalid literal for int() with base 10: '{}'",
587            s
588        ))
589    })
590}
591
592impl Lang for LangOc {
593
594    fn cardinal_float_entry(
595        &self,
596        value: &crate::floatpath::FloatValue,
597        precision_override: Option<u32>,
598    ) -> crate::base::Result<String> {
599        // Python's to_cardinal routes every float/Decimal through this
600        // language's own decimal grammar — 5.0 keeps its ".0" tail
601        // ("comma nulla"), unlike Base's whole-value integer route.
602        self.to_cardinal_float(value, precision_override)
603    }
604
605    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
606    /// `to_cardinal(number) + "-en"` for *any* input (no
607    /// `verify_ordinal`), so the float path is the float cardinal put through
608    /// the same literal transformation: `5.0` -> "cinc point zero-en".
609    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
610    /// before the transformation, exactly as in Python.
611    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
612        let cardinal = self.cardinal_float_entry(value, None)?;
613        Ok(format!("{}-en", cardinal))
614    }
615
616    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is the
617    /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
618    /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
619    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
620        Ok(format!("{}.", repr_str))
621    }
622
623    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
624    /// Infinity sentinel becomes the ValueError this language's own
625    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
626    /// fails); the shared dispatcher would otherwise report Base's
627    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
628    /// ValueError for it already matches `int("NaN")`.
629    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
630        match crate::strnum::python_decimal_parse(s)? {
631            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
632                "invalid literal for int() with base 10: 'Infinity'".into(),
633            )),
634            p => Ok(p),
635        }
636    }
637
638    /// This language's own `to_currency(currency=...)` default,
639    /// read from the live Python signature. Only 44 of 156 use EUR.
640    fn default_currency(&self) -> &str {
641        "EUR"
642    }
643
644    /// This language's own `to_currency(separator=...)` default,
645    /// read from the live Python signature. Base's is ",", but only
646    /// 36 of 149 languages actually use it — most default to " " or a
647    /// conjunction, so inheriting Base's comma silently corrupts them.
648    fn default_separator(&self) -> &str {
649        " "
650    }
651
652    fn negword(&self) -> &str {
653        NEGWORD
654    }
655
656    fn pointword(&self) -> &str {
657        POINTWORD
658    }
659
660    /// Python's `to_cardinal`.
661    ///
662    /// The original works on `str(number)`: it strips a leading "-" textually,
663    /// records `negword`, and re-parses the remainder with `int()`. For integer
664    /// input the `"." in n` split is unreachable, so this reduces to sign-strip
665    /// + `_int_to_word` + `.strip()`.
666    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
667        // `n = str(number).strip()` — no-op on a BigInt's decimal form.
668        // `if n.startswith("-"): n = n[1:]; ret = self.negword`
669        let (ret, n) = if value.is_negative() {
670            (NEGWORD, value.abs())
671        } else {
672            ("", value.clone())
673        };
674
675        // `return (ret + self._int_to_word(int(n))).strip()`.
676        // The trailing `.strip()` is a no-op for integers — `_int_to_word`
677        // never pads its result, and NEGWORD's space is interior once
678        // concatenated — but it is applied here to match the source exactly.
679        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
680    }
681
682    /// Python: `return self.to_cardinal(number) + "-en"`.
683    ///
684    /// Unconditional suffix — no agreement, no special-casing of 0 or of
685    /// negatives, and no guard against the bug-1 digit fallback. Hence
686    /// `to_ordinal(0)` == "zero-en", `to_ordinal(-1)` == "minus un-en" and
687    /// `to_ordinal(10**9)` == "1000000000-en".
688    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
689        Ok(format!("{}-en", self.to_cardinal(value)?))
690    }
691
692    /// Python: `return str(number) + "."`.
693    ///
694    /// Note this ignores `_int_to_word` entirely, so it is total and keeps the
695    /// minus sign: `to_ordinal_num(-1)` == "-1.".
696    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
697        Ok(format!("{}.", value))
698    }
699
700    /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
701    ///
702    /// `longval` is accepted and ignored; there is no era handling, so a
703    /// negative year keeps the plain "minus " prefix rather than gaining a
704    /// "BC"-style suffix: `to_year(-500)` == "minus cinc cent".
705    fn to_year(&self, value: &BigInt) -> Result<String> {
706        self.to_cardinal(value)
707    }
708
709    /// The float/Decimal cardinal path.
710    ///
711    /// OC reaches non-integers through its own `to_cardinal` override, not
712    /// through `Num2Word_Base.to_cardinal_float`, so this hook exists only to
713    /// route to the same string-driven logic and stop the base implementation
714    /// running. There is **no `float2tuple` here, no `10**precision` scaling,
715    /// no banker's rounding, and none of the f64 artefacts that path exists to
716    /// preserve** — OC's whole float path is `n = str(number).strip()` followed
717    /// by pure string surgery. The artefact cases are trivially right because
718    /// `str(2.675)` is `"2.675"` (repr is shortest-round-trip), so the digits
719    /// are `6 7 5` by construction; there is nothing to compute and nothing to
720    /// rescue. See [`python_float_repr`] and [`python_decimal_str`] for the two
721    /// stringifications this port lives or dies on.
722    ///
723    /// `precision_override` is the `precision=` kwarg. `__init__.py` applies it
724    /// by assigning `converter.precision`, which OC never reads (its
725    /// `to_cardinal` consults `str(number)`, never `self.precision`) — so it is
726    /// accepted and **ignored**, matching the live interpreter.
727    fn to_cardinal_float(
728        &self,
729        value: &FloatValue,
730        _precision_override: Option<u32>,
731    ) -> Result<String> {
732        self.cardinal_from_str(&python_str(value))
733    }
734
735    // ---- currency ------------------------------------------------------
736
737    /// Used only for `to_cheque`'s NotImplementedError message — OC's
738    /// `to_currency` has no error path that mentions the class name.
739    fn lang_name(&self) -> &str {
740        "Num2Word_OC"
741    }
742
743    /// `self.CURRENCY_FORMS[code]`.
744    ///
745    /// Deliberately a plain lookup with **no** EUR fallback: this hook exists
746    /// for `Num2Word_Base.to_cheque`, which does `self.CURRENCY_FORMS[currency]`
747    /// inside a `try` and converts the `KeyError` into `NotImplementedError`.
748    /// `None` here is what produces that. OC's `to_currency` does *not* go
749    /// through this — it calls `.get(currency, <first value>)` and can never
750    /// miss, so it consults `LangOc::fallback_forms` itself.
751    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
752        self.forms.get(code)
753    }
754
755    // `currency_precision` is not overridden: OC's CURRENCY_PRECISION is the
756    // empty dict inherited from Num2Word_Base, so `.get(code, 100)` is 100 for
757    // every code — the trait default. `to_cheque` reads it (giving the "56/100"
758    // in "... AND 56/100 ÈUROS"); OC's `to_currency` never looks at it, which
759    // is why KWD/BHD get 2-decimal cents and JPY is not rounded to a whole unit
760    // (bug 7 below).
761    //
762    // `currency_adjective` is not overridden: CURRENCY_ADJECTIVES is `{}`.
763    //
764    // `pluralize` is not overridden: it is abstract in Num2Word_Base and OC
765    // reaches it from neither `to_currency` (which indexes `cr1`/`cr2` by hand)
766    // nor `to_cheque` (which takes `cr1[-1]` unconditionally). The trait
767    // default raises, matching Python's `raise NotImplementedError`.
768    //
769    // `money_verbose` / `cents_verbose` / `cents_terse` are not overridden:
770    // Python's `_money_verbose` is `return self.to_cardinal(number)` — the
771    // trait default — and it is the only one of the three OC can reach, via
772    // `to_cheque`. `to_currency` calls `_int_to_word` directly instead.
773    //
774    // `to_cheque` is not overridden: Num2Word_Base.to_cheque runs verbatim, and
775    // `currency::default_to_cheque` is its port. Traced below.
776
777    /// Python's `Num2Word_OC.to_currency` — a full override that shares nothing
778    /// with `Num2Word_Base.to_currency`:
779    ///
780    /// ```python
781    /// def to_currency(self, val, currency="EUR", cents=True,
782    ///                 separator=" ", adjective=False):
783    ///     is_negative = False
784    ///     if val < 0:
785    ///         is_negative = True
786    ///         val = abs(val)
787    ///
788    ///     parts = str(val).split(".")
789    ///     left = int(parts[0]) if parts[0] else 0
790    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
791    ///
792    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
793    ///         currency, list(self.CURRENCY_FORMS.values())[0]
794    ///     )
795    ///
796    ///     left_str = self._int_to_word(left)
797    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
798    ///
799    ///     if cents and right:
800    ///         cents_str = self._int_to_word(right)
801    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
802    ///
803    ///     if is_negative:
804    ///         result = self.negword + result
805    ///
806    ///     return result.strip()
807    /// ```
808    ///
809    /// # Faithfully reproduced Python bugs / oddities (continuing the list above)
810    ///
811    /// 7. **An unknown currency code silently becomes EUR.** The lookup is
812    ///    `.get(currency, list(self.CURRENCY_FORMS.values())[0])`, and the first
813    ///    value is EUR's. So `to_currency(1, "JPY")` is `"un èuro"`, not an
814    ///    error — this method has no `NotImplementedError` path at all, unlike
815    ///    every other language and unlike OC's own `to_cheque`. The corpus
816    ///    pins it for GBP, JPY, KWD, BHD, INR, CNY and CHF, all 12 rows each:
817    ///    `{"lang": "oc", "to": "currency:JPY", "arg": "1", "out": "un èuro"}`.
818    /// 8. **The int/float split is done by string, not by type.** Base branches
819    ///    on `isinstance(val, int)` and skips cents for a true int while still
820    ///    rendering them for `1.0`. OC instead asks whether `str(val)` contains
821    ///    a `"."`, then throws the answer away anyway: `1.0` -> `parts[1] == "0"`
822    ///    -> `right == 0` -> falsy -> `if cents and right` skips the segment. So
823    ///    int `1` and float `1.0` both give `"un èuro"` — the same output base
824    ///    reaches by the opposite reasoning. `CurrencyValue`'s two arms are
825    ///    still kept distinct here because they produce different strings for
826    ///    `str(val)` ("1" vs "1.0"), which is what the code actually branches on.
827    /// 9. **Cents are truncated to 2 digits regardless of the currency.**
828    ///    `parts[1][:2]` is hardcoded, so a 3-decimal currency loses its mils
829    ///    (KWD 1.234 -> "vint tres centims", not 234 fils) and a 0-decimal one
830    ///    gains cents that cannot exist (JPY 0.5 -> "cinquanta centims"). Both
831    ///    follow from bug 7 anyway, since neither code is in the table.
832    /// 10. **`cents=False` omits the cents, it does not abbreviate them.**
833    ///    Base's `cents=False` means "render the subunit as digits" via
834    ///    `_cents_terse`. OC's `if cents and right:` simply drops the whole
835    ///    segment, so `to_currency(12.34, cents=False)` is `"dètz dos èuros"`.
836    ///    `_cents_terse` is unreachable from here.
837    /// 11. **`adjective` is accepted and completely ignored** — no
838    ///    `prefix_currency` call, and `CURRENCY_ADJECTIVES` is empty regardless.
839    ///
840    /// # `str(val)` on the Rust side, and the one place this port diverges
841    ///
842    /// `parts = str(val).split(".")` runs on the value *after* `abs()`, so the
843    /// string never carries a sign. `CurrencyValue::Decimal` was parsed from the
844    /// `str(value)` the Python shim produced, and `BigDecimal` preserves scale,
845    /// so `to_string()` round-trips that literal exactly ("1.0" stays "1.0",
846    /// not "1"; "0.01" stays "0.01") for every value whose `str` is in plain
847    /// decimal form. That covers the whole corpus and everything a caller is
848    /// likely to pass.
849    ///
850    /// It does not cover values whose `str` is *exponential*, because Python
851    /// and `BigDecimal` switch to exponential notation at different
852    /// magnitudes. `int()` rejects an exponential token, so Python raises
853    /// `ValueError` wherever its own `repr` went exponential — meaning the
854    /// disagreement is over *whether the ValueError happens*, not over any
855    /// spelled-out string:
856    ///
857    /// | value | `str(val)` | Python | here |
858    /// |---|---|---|---|
859    /// | `1e15` | `1000000000000000.0` | `"1000000000000000 èuros"` | same |
860    /// | `0.0001` | `0.0001` | `"zero èuros"` | same |
861    /// | `1e-05` | `1e-05` | ValueError | `"zero èuros"` |
862    /// | `1e-06` | `1e-06` | ValueError | `"zero èuros"` |
863    /// | `1e-07` | `1e-07` | ValueError | ValueError (`N2WError::Value`) |
864    /// | `1e+16` | `1e+16` | ValueError | ValueError (`N2WError::Value`) |
865    ///
866    /// The window is exactly `1e-06 ..= 1e-05`-ish: Python's float `repr` goes
867    /// exponential below `1e-4`, while `BigDecimal`'s `Display` holds out until
868    /// it would print more than five leading zeros. Outside that window the two
869    /// agree, including on the exception *type* at `1e-07` and `1e+16`, where
870    /// `BigDecimal` also prints exponentially and `BigInt::from_str` rejects it
871    /// — the same `ValueError`-shaped failure by the same route.
872    ///
873    /// **This is not fixable in this file.** `CurrencyValue::Decimal` carries a
874    /// parsed `BigDecimal`, not the string it was parsed from, and float and
875    /// `Decimal` reprs disagree in precisely this window: Python's
876    /// `str(1e-05)` is `"1e-05"` (-> ValueError) while
877    /// `str(Decimal("1e-5"))` is `"0.00001"` (-> `"zero èuros"`). Both arrive
878    /// here as the identical `BigDecimal { int_val: 1, scale: 5 }`, so no rule
879    /// applied to the value can be right for both — the two inputs are already
880    /// indistinguishable by the time OC is called. Making it exact needs
881    /// `CurrencyValue::Decimal` to keep the original `str(value)` alongside the
882    /// number, which is a `currency.rs` change and out of scope here. Flagged
883    /// in the report; no corpus row falls in the window.
884    fn to_currency(
885        &self,
886        val: &CurrencyValue,
887        currency: &str,
888        cents: bool,
889        separator: Option<&str>,
890        _adjective: bool, // bug 11: accepted and ignored
891    ) -> Result<String> {
892        // Trait now hands us None when the caller omitted separator=;
893        // resolve it to this language's own default before the ported body.
894        let separator = separator.unwrap_or(self.default_separator());
895        // Restore OC's own `separator=" "` default; see BASE_DEFAULT_SEPARATOR.
896        let separator = if separator == BASE_DEFAULT_SEPARATOR {
897            SEPARATOR
898        } else {
899            separator
900        };
901
902        // `if val < 0: is_negative = True; val = abs(val)`. The `abs` is folded
903        // into the stringification below, which is the only place `val` is read
904        // afterwards.
905        let is_negative = val.is_negative();
906
907        // `parts = str(val).split(".")`
908        let s = match val {
909            CurrencyValue::Int(i) => i.abs().to_string(),
910            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
911        };
912        // Python's `split(".")` splits on *every* dot; `parts[0]` and `parts[1]`
913        // are the first two segments. `split` + two `next()`s reproduces that,
914        // where `splitn(2, ..)` would fold a hypothetical third segment into
915        // `parts[1]`. `str(val)` never has two dots, but the distinction costs
916        // nothing.
917        let mut segments = s.split('.');
918        let p0 = segments.next().unwrap_or("");
919        let p1 = segments.next();
920
921        // `left = int(parts[0]) if parts[0] else 0`
922        let left = if p0.is_empty() {
923            BigInt::zero()
924        } else {
925            // Python's bare `int()` raises ValueError on a non-numeric token,
926            // which is the only way this call can fail.
927            BigInt::from_str(p0).map_err(|e| N2WError::Value(e.to_string()))?
928        };
929
930        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
931        // — `len(parts) > 1` is `p1.is_some()`, `parts[1]` truthiness is the
932        // non-empty check. Sliced and padded by `chars`, never by bytes.
933        let right = match p1 {
934            Some(frac) if !frac.is_empty() => {
935                let mut digits: String = frac.chars().take(2).collect();
936                while digits.chars().count() < 2 {
937                    digits.push('0'); // `.ljust(2, "0")`
938                }
939                BigInt::from_str(&digits).map_err(|e| N2WError::Value(e.to_string()))?
940            }
941            _ => BigInt::zero(),
942        };
943
944        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(...values())[0])`
945        // — bug 7: the fallback is EUR, so this never fails.
946        let forms = match self.forms.get(currency) {
947            Some(f) => f,
948            None => self.fallback_forms(),
949        };
950        let (cr1, cr2) = (&forms.unit, &forms.subunit);
951
952        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
953        let left_str = self.int_to_word(&left);
954        let mut result = format!(
955            "{} {}",
956            left_str,
957            if left.is_one() { &cr1[0] } else { &cr1[1] }
958        );
959
960        // `if cents and right:` — bug 10: an omission, not a terse rendering.
961        if cents && !right.is_zero() {
962            // `result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])`
963            result.push_str(separator);
964            result.push_str(&self.int_to_word(&right));
965            result.push(' ');
966            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
967        }
968
969        // `if is_negative: result = self.negword + result`. NEGWORD's own
970        // trailing space is what separates it from the number; there is no
971        // `.strip()` on it here, unlike `to_cardinal`'s `"%s " % negword.strip()`
972        // idiom elsewhere in the library. Same result, different route.
973        if is_negative {
974            result = format!("{}{}", NEGWORD, result);
975        }
976
977        // `return result.strip()` — a no-op on every reachable input (nothing
978        // pads the ends), kept to match the source.
979        Ok(result.trim().to_string())
980    }
981}
982
983#[cfg(test)]
984mod float_tests {
985    use super::*;
986
987    /// `num2words(v, lang="oc")` for a float.
988    fn f(v: f64) -> Result<String> {
989        LangOc::new().to_cardinal_float(
990            // The shim feeds abs(Decimal(str(v)).as_tuple().exponent); OC
991            // ignores it, so a deliberately wrong value pins that it stays so.
992            &FloatValue::Float { value: v, precision: 99 },
993            None,
994        )
995    }
996
997    /// `num2words(Decimal(s), lang="oc")`.
998    fn d(s: &str) -> Result<String> {
999        LangOc::new().to_cardinal_float(
1000            &FloatValue::Decimal {
1001                value: BigDecimal::from_str(s).unwrap(),
1002                precision: 99,
1003            },
1004            None,
1005        )
1006    }
1007
1008    /// Every `"to": "cardinal"` corpus row for oc whose arg has a dot.
1009    #[test]
1010    fn corpus_cardinal_float() {
1011        assert_eq!(f(0.0).unwrap(), "zero point zero");
1012        assert_eq!(f(0.5).unwrap(), "zero point cinc");
1013        assert_eq!(f(1.0).unwrap(), "un point zero");
1014        assert_eq!(f(1.5).unwrap(), "un point cinc");
1015        assert_eq!(f(2.25).unwrap(), "dos point dos cinc");
1016        assert_eq!(f(3.14).unwrap(), "tres point un quatre");
1017        assert_eq!(f(0.01).unwrap(), "zero point zero un");
1018        assert_eq!(f(0.1).unwrap(), "zero point un");
1019        assert_eq!(f(0.99).unwrap(), "zero point nòu nòu");
1020        assert_eq!(f(1.01).unwrap(), "un point zero un");
1021        assert_eq!(f(12.34).unwrap(), "dètz dos point tres quatre");
1022        assert_eq!(f(99.99).unwrap(), "nonanta nòu point nòu nòu");
1023        assert_eq!(f(100.5).unwrap(), "un cent point cinc");
1024        assert_eq!(
1025            f(1234.56).unwrap(),
1026            "un mil dos cent trenta quatre point cinc sièis"
1027        );
1028        assert_eq!(f(-0.5).unwrap(), "minus zero point cinc");
1029        assert_eq!(f(-1.5).unwrap(), "minus un point cinc");
1030        assert_eq!(f(-12.34).unwrap(), "minus dètz dos point tres quatre");
1031    }
1032
1033    /// Every `"to": "cardinal_dec"` corpus row for oc.
1034    #[test]
1035    fn corpus_cardinal_decimal() {
1036        assert_eq!(d("0.01").unwrap(), "zero point zero un");
1037        // The trailing zero is a character, not a computed remainder.
1038        assert_eq!(d("1.10").unwrap(), "un point un zero");
1039        assert_eq!(d("12.345").unwrap(), "dètz dos point tres quatre cinc");
1040        // Issue #603: the Decimal arm never float-casts, so the ".99" survives
1041        // at trillion scale. The left part is past 10^9, hence the bare digits.
1042        assert_eq!(
1043            d("98746251323029.99").unwrap(),
1044            "98746251323029 point nòu nòu"
1045        );
1046        assert_eq!(d("0.001").unwrap(), "zero point zero zero un");
1047    }
1048
1049    /// The two artefact cases base's float path exists to rescue. OC never
1050    /// scales by `10**precision`, so `str()` hands it the right digits and the
1051    /// `< 0.01` heuristic is not merely unused but unreachable.
1052    #[test]
1053    fn float_artefacts_are_not_reachable() {
1054        // base.float2tuple computes 674.9999999999998 for this one.
1055        assert_eq!(f(2.675).unwrap(), "dos point sièis sèt cinc");
1056        assert_eq!(f(1.005).unwrap(), "un point zero zero cinc");
1057        // Banker's rounding never enters either: these are literal digits.
1058        assert_eq!(f(2.5).unwrap(), "dos point cinc");
1059        assert_eq!(f(0.005).unwrap(), "zero point zero zero cinc");
1060    }
1061
1062    /// CPython breaks exact shortest-repr ties to even; Rust's `{:e}` does not.
1063    /// 670352580196876.2 is such a value. Live-interpreter verified.
1064    #[test]
1065    fn shortest_repr_ties_go_to_even() {
1066        assert_eq!(f(670352580196876.2).unwrap(), "670352580196876 point dos");
1067    }
1068
1069    /// `str(float)`'s two placement quirks, both observable here.
1070    #[test]
1071    fn float_repr_placement() {
1072        // ADD_DOT_0: an integral float still has a fraction to speak.
1073        assert_eq!(f(1.0).unwrap(), "un point zero");
1074        assert_eq!(f(-1.0).unwrap(), "minus un point zero");
1075        // repr(-0.0) keeps the sign bit; `value < 0.0` would not.
1076        assert_eq!(f(-0.0).unwrap(), "minus zero point zero");
1077        // The digit fallback in _int_to_word reaches the float path too.
1078        assert_eq!(f(1000000000.5).unwrap(), "1000000000 point cinc");
1079        assert_eq!(f(1e15).unwrap(), "1000000000000000 point zero");
1080        // Just inside the exponent threshold (decpt == 16).
1081        assert_eq!(f(9999999999999998.0).unwrap(), "9999999999999998 point zero");
1082        // 1e-4 stays positional (decpt == -3); 1e-5 does not.
1083        assert_eq!(f(0.0001).unwrap(), "zero point zero zero zero un");
1084        // A big-but-spellable integral part (< 10^9).
1085        assert_eq!(
1086            f(123456789.5).unwrap(),
1087            "un cent vint tres milion quatre cent cinquanta sièis mil sèt cent ochanta nòu point cinc"
1088        );
1089    }
1090
1091    /// Exponent notation reaches int() as a bad literal. Two distinct messages,
1092    /// because two distinct int() calls raise. Live-interpreter verified.
1093    #[test]
1094    fn exponent_notation_raises_value_error() {
1095        // No "." in the literal -> int(n) raises, quoting all of it. The sign
1096        // is stripped by the negword branch *before* int() sees the string, so
1097        // the message never carries a "-".
1098        for (v, lit) in [
1099            (1e16, "1e+16"),
1100            (1e21, "1e+21"),
1101            (1e-5, "1e-05"),
1102            (1e300, "1e+300"),
1103            (-1e16, "1e+16"),
1104            (-1e-5, "1e-05"),
1105            (f64::INFINITY, "inf"),
1106            (f64::NEG_INFINITY, "inf"),
1107        ] {
1108            match f(v) {
1109                Err(N2WError::Value(m)) => assert_eq!(
1110                    m,
1111                    format!("invalid literal for int() with base 10: '{}'", lit)
1112                ),
1113                other => panic!("{}: expected Value, got {:?}", lit, other),
1114            }
1115        }
1116        // NaN's repr has no sign to strip.
1117        match f(f64::NAN) {
1118            Err(N2WError::Value(m)) => {
1119                assert_eq!(m, "invalid literal for int() with base 10: 'nan'")
1120            }
1121            other => panic!("nan: expected Value, got {:?}", other),
1122        }
1123        // A "." *is* present -> the fraction loop raises on one character.
1124        for v in [1.5e16, 1.5e-5] {
1125            match f(v) {
1126                Err(N2WError::Value(m)) => {
1127                    assert_eq!(m, "invalid literal for int() with base 10: 'e'")
1128                }
1129                other => panic!("{}: expected Value, got {:?}", v, other),
1130            }
1131        }
1132    }
1133
1134    /// `str(Decimal)` is not `str(float)` and not `BigDecimal`'s Display.
1135    #[test]
1136    fn decimal_str_rules() {
1137        // Display would say "0"; str(Decimal("0.00")) keeps both digits.
1138        assert_eq!(d("0.00").unwrap(), "zero point zero zero");
1139        assert_eq!(d("5.00").unwrap(), "cinc point zero zero");
1140        // Integral Decimals have no "." at all -> the no-dot branch.
1141        assert_eq!(d("5").unwrap(), "cinc");
1142        assert_eq!(d("-1").unwrap(), "minus un");
1143        assert_eq!(d("-0.5").unwrap(), "minus zero point cinc");
1144        // Exactly at the scientific threshold: adjusted exponent -6 stays
1145        // positional, -7 flips to "1E-7" and int() then raises.
1146        assert_eq!(d("0.000001").unwrap(), "zero point zero zero zero zero zero un");
1147        match d("0.0000001") {
1148            Err(N2WError::Value(m)) => {
1149                assert_eq!(m, "invalid literal for int() with base 10: '1E-7'")
1150            }
1151            other => panic!("0.0000001: expected Value, got {:?}", other),
1152        }
1153        // str(Decimal) goes scientific at exp > 0 with a capital E.
1154        match d("1E+16") {
1155            Err(N2WError::Value(m)) => {
1156                assert_eq!(m, "invalid literal for int() with base 10: '1E+16'")
1157            }
1158            other => panic!("1E+16: expected Value, got {:?}", other),
1159        }
1160    }
1161
1162    /// OC never reads `self.precision`, so `precision=` is inert. Verified
1163    /// against the live interpreter.
1164    #[test]
1165    fn precision_override_is_ignored() {
1166        let l = LangOc::new();
1167        let v = FloatValue::Float { value: 2.675, precision: 3 };
1168        assert_eq!(
1169            l.to_cardinal_float(&v, Some(1)).unwrap(),
1170            "dos point sièis sèt cinc"
1171        );
1172        assert_eq!(
1173            l.to_cardinal_float(&v, Some(9)).unwrap(),
1174            "dos point sièis sèt cinc"
1175        );
1176    }
1177}