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}