Skip to main content

num2words2_core/
lang_pap.rs

1//! Port of `lang_PAP.py` (Papiamento).
2//!
3//! Registry check: `CONVERTER_CLASSES["pap"]` is `lang_PAP.Num2Word_PAP()`
4//! (`__init__.py:396`), so this is the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_PAP` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
8//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
9//! `self.MAXVAL`. `to_cardinal` is overridden outright and drives its own
10//! recursive `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at
11//! their trait defaults here, and there is **no overflow check** at all — see
12//! the "billion cliff" note below for what happens past 10^9 instead.
13//!
14//! Inherited from `Num2Word_Base` and left unchanged by PAP:
15//!   * `is_title` stays `False`, so `title()` is the identity. `setup()` does
16//!     assign `exclude_title = ["i", "punto", "menos"]`, but with `is_title`
17//!     false it is dead data — reproduced below only for documentation.
18//!
19//! # Faithfully reproduced Python behaviour that looks wrong
20//!
21//! 1. **The billion cliff.** `_int_to_word` handles 0..10^9-1 and then simply
22//!    `return str(number)`. There is no `milyard`/`biyon` card and no
23//!    `OverflowError`: `to_cardinal(10**9)` returns the *digits* `"1000000000"`,
24//!    and `to_cardinal(10**21)` returns `"1000000000000000000000"`. Corpus rows
25//!    confirm this for 10^9, 1234567890, 10^10, 10^11, 10^12, 10^15, 10^18 and
26//!    10^21. This is why the value must stay a `BigInt`: the fallback is a
27//!    lossless decimal rendering of an arbitrarily large integer.
28//! 2. **`to_ordinal` never calls `verify_ordinal`.** Base's guard against
29//!    negative/float ordinals is bypassed, so `to_ordinal(-1)` does not raise —
30//!    it returns `"di menos un"` (corpus-confirmed). Likewise `to_ordinal(0)`
31//!    returns `"di sero"` rather than crashing.
32//! 3. **`to_ordinal` only special-cases 1.** Every other value, including
33//!    those past the billion cliff, is `"di " + to_cardinal(n)` — hence
34//!    `to_ordinal(10**9) == "di 1000000000"`.
35//! 4. **`to_ordinal_num` is `str(number) + "i"`** with no suffix logic and no
36//!    sign handling: `to_ordinal_num(-1) == "-1i"`.
37//! 5. **`tens[1]` is unreachable.** `setup()` sets `tens = ["", "dies", ...]`,
38//!    but `_int_to_word` routes 10..=19 through `teens` before the `< 100`
39//!    branch can index `tens[1]`. Kept in the table verbatim anyway.
40//! 6. **Thousands drop "un" but millions keep it.** The `< 1000000` branch
41//!    guards its prefix with `if t > 1` (so 1000 → `"mil"`, not `"un mil"`),
42//!    while the `< 1000000000` branch has no such guard (so 10^6 →
43//!    `"un miyon"`). The asymmetry is in the Python; both are corpus-confirmed.
44//! 7. **`to_currency` never raises for an unknown code.** It reads
45//!    `CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`, so
46//!    GBP/JPY/KWD/BHD/INR/CNY/CHF quietly render as ANG florins rather than
47//!    raising NotImplementedError. The inherited `to_cheque` does the opposite
48//!    (`CURRENCY_FORMS[currency]` inside a `try`), so **the same code raises
49//!    from `to_cheque` and succeeds from `to_currency`** — corpus-confirmed on
50//!    both sides.
51//! 8. **`to_currency` ignores `CURRENCY_PRECISION`, `pluralize` and
52//!    `adjective`.** It is a wholesale override that never calls up to
53//!    `Num2Word_Base.to_currency`, so none of Base's machinery runs: no
54//!    `parse_currency_parts`, no ROUND_HALF_UP, no divisor. `adjective=True`
55//!    is accepted and dropped on the floor (`CURRENCY_ADJECTIVES` is empty
56//!    anyway), and its own `pluralize` is dead code on this path — the plural
57//!    is picked inline with `cr1[1] if left != 1 else cr1[0]`. The visible
58//!    consequence is that the 0-decimal (JPY) and 3-decimal (KWD/BHD) corpus
59//!    rows behave like ordinary /100 currencies: `0.5 JPY` is
60//!    `"sero florin sinkuenta sèn"`, where Base would have rounded to a whole
61//!    unit and dropped the subunit entirely.
62//! 9. **Cents are truncated, not rounded**, and read off the *decimal string*:
63//!    `int(str(abs(val)).split(".")[1][:2].ljust(2, "0"))`. So `0.5` → `50`
64//!    sèn (right-padded), `1.005` → `0` sèn (dropped, no carry), and `2.675` →
65//!    `67` sèn, not the `68` that Base's ROUND_HALF_UP would give. Reproduced
66//!    below with exact `BigDecimal` arithmetic rather than string slicing —
67//!    see `to_currency` for why the two agree.
68//!
69//! # Error variants
70//!
71//! The four integer modes raise nothing: every in-scope corpus row for "pap" is
72//! `"ok": true`, and `to_cardinal`/`to_ordinal`/`to_ordinal_num`/`to_year` are
73//! total over the integers because the billion cliff returns digits instead of
74//! raising. `to_currency` is likewise total (unknown codes fall back rather
75//! than raise — see the currency section). Only the inherited `to_cheque`
76//! raises: `NotImplementedError` for a code outside PAP's own four.
77//!
78//! # Sign handling
79//!
80//! Python's `to_cardinal` works on `str(number)`: it tests `n.startswith("-")`
81//! and recurses on `n[1:]`. For integer input `str()` emits a leading `"-"`
82//! exactly for negatives and nothing else, so that string dance is equivalent
83//! to `negword + to_cardinal(abs(n))`, which is what this port does. The
84//! trailing `.strip()` on the Python result then only ever removes the space
85//! that `negword` ("menos ") itself contributes — and it never does, since the
86//! recursive result is non-empty and unpadded. Reproduced as `trim()` for
87//! parity regardless.
88
89use crate::base::{Lang, N2WError, Result};
90use crate::currency::{CurrencyForms, CurrencyValue};
91use crate::floatpath::FloatValue;
92use bigdecimal::BigDecimal;
93use num_bigint::BigInt;
94use num_integer::Integer;
95use num_traits::{One, Signed, ToPrimitive, Zero};
96use std::collections::HashMap;
97use std::str::FromStr;
98
99/// `setup(): self.ones`. Index 0 is `""` (the `< 10` branch reaches it only
100/// for 0, which the `number == 0` guard has already claimed).
101const ONES: [&str; 10] = [
102    "", "un", "dos", "tres", "kuater", "sinku", "seis", "shete", "ocho", "nuebe",
103];
104
105/// `setup(): self.teens`, indexed `number - 10` for 10..=19.
106const TEENS: [&str; 10] = [
107    "dies",
108    "diesun",
109    "diesdos",
110    "diestres",
111    "dieskuater",
112    "diessinku",
113    "diesseis",
114    "diesshete",
115    "diesocho",
116    "diesnuebe",
117];
118
119/// `setup(): self.tens`. Index 0 is `""` and index 1 ("dies") is unreachable
120/// — see bug note 5.
121const TENS: [&str; 10] = [
122    "", "dies", "binti", "trinta", "kuarenta", "sinkuenta", "sesenta", "setenta", "ochenta",
123    "nobenta",
124];
125
126const ZERO_WORD: &str = "sero";
127const HUNDRED: &str = "shen";
128const THOUSAND: &str = "mil";
129const MILLION: &str = "miyon";
130const NEGWORD: &str = "menos ";
131const POINTWORD: &str = "punto";
132
133/// `Num2Word_PAP.CURRENCY_FORMS`, exactly the four entries the class body
134/// declares — ANG, AWG, USD, EUR, in that insertion order.
135///
136/// PAP declares its **own** class-attribute dict, so the `lang_EUR.py` trap
137/// does not apply: `Num2Word_EN.__init__`'s in-place mutation lands on
138/// `Num2Word_EUR.CURRENCY_FORMS`, a different object. The live interpreter
139/// agrees — `CONVERTER_CLASSES["pap"].CURRENCY_FORMS` has these four keys and
140/// no EN-injected AUD/CAD/JPY/KWD/... See the corpus rows for JPY and KWD:
141/// they render "florin", i.e. the fallback below, not EN's yen/dinar.
142///
143/// Both PAP form tuples are singular==plural, which is why every corpus row
144/// reads the same regardless of count.
145fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
146    const SEN: [&str; 2] = ["sèn", "sèn"];
147
148    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
149    m.insert("ANG", CurrencyForms::new(&FLORIN, &SEN));
150    m.insert("AWG", CurrencyForms::new(&FLORIN, &SEN));
151    m.insert("USD", CurrencyForms::new(&["dolar", "dolar"], &SEN));
152    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &SEN));
153    m
154}
155
156/// The unit forms of the **first** `CURRENCY_FORMS` entry — see
157/// `currency_fallback` on the struct for why that matters.
158const FLORIN: [&str; 2] = ["florin", "florin"];
159
160pub struct LangPap {
161    /// `setup(): self.exclude_title`. Dead data in Python (`is_title` is
162    /// `False`), retained so the trait method reports what Python holds.
163    exclude_title: Vec<String>,
164    /// `CURRENCY_FORMS`. Built once here rather than per call.
165    currency_forms: HashMap<&'static str, CurrencyForms>,
166    /// `list(self.CURRENCY_FORMS.values())[0]` — PAP's `to_currency` passes
167    /// this as the `dict.get` default, so an **unknown code silently renders
168    /// as ANG** instead of raising (bug note 7).
169    ///
170    /// Python resolves it positionally through dict insertion order, which
171    /// since 3.7 is guaranteed to be class-body order: ANG is written first,
172    /// so the fallback is ANG's `(("florin","florin"), ("sèn","sèn"))`. A
173    /// `HashMap` has no order to index, so the entry is materialised here
174    /// instead of recovered from the table. Verified against the live
175    /// interpreter and against the GBP/JPY/KWD/BHD/INR/CNY/CHF corpus rows,
176    /// which all say "florin".
177    currency_fallback: CurrencyForms,
178}
179
180impl Default for LangPap {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl LangPap {
187    pub fn new() -> Self {
188        LangPap {
189            exclude_title: vec!["i".to_string(), "punto".to_string(), "menos".to_string()],
190            currency_forms: build_currency_forms(),
191            currency_fallback: CurrencyForms::new(&FLORIN, &["sèn", "sèn"]),
192        }
193    }
194
195    /// Port of `Num2Word_PAP._int_to_word`.
196    ///
197    /// Only ever called with a **non-negative** value: `to_cardinal` peels the
198    /// sign off before calling. (Python would happily negative-index
199    /// `self.ones[-3]` here and return "shete" for -3, but no in-scope path
200    /// reaches that, so the small-index conversions below cannot fail.)
201    fn int_to_word(&self, number: &BigInt) -> String {
202        if number.is_zero() {
203            return ZERO_WORD.to_string();
204        }
205        if *number < BigInt::from(10) {
206            // Safe: 0 < number < 10.
207            return ONES[number.to_usize().unwrap()].to_string();
208        }
209        if *number < BigInt::from(20) {
210            // Safe: 10 <= number < 20.
211            return TEENS[(number - BigInt::from(10)).to_usize().unwrap()].to_string();
212        }
213        if *number < BigInt::from(100) {
214            // Python: t, o = divmod(number, 10)
215            let (t, o) = number.div_mod_floor(&BigInt::from(10));
216            let t = t.to_usize().unwrap(); // 2..=9
217            let o = o.to_usize().unwrap(); // 0..=9
218            // Python: self.tens[t] + (" i " + self.ones[o] if o else "")
219            let mut out = TENS[t].to_string();
220            if o != 0 {
221                out.push_str(" i ");
222                out.push_str(ONES[o]);
223            }
224            return out;
225        }
226        if *number < BigInt::from(1000) {
227            // Python: h, r = divmod(number, 100)
228            let (h, r) = number.div_mod_floor(&BigInt::from(100));
229            let hu = h.to_usize().unwrap(); // 1..=9
230            // Python: base = (self.ones[h] + " " if h > 1 else "") + self.hundred
231            let mut out = String::new();
232            if hu > 1 {
233                out.push_str(ONES[hu]);
234                out.push(' ');
235            }
236            out.push_str(HUNDRED);
237            // Python: base + (" i " + self._int_to_word(r) if r else "")
238            if !r.is_zero() {
239                out.push_str(" i ");
240                out.push_str(&self.int_to_word(&r));
241            }
242            return out;
243        }
244        if *number < BigInt::from(1_000_000) {
245            // Python: t, r = divmod(number, 1000)
246            let (t, r) = number.div_mod_floor(&BigInt::from(1000));
247            // Python: base = (self._int_to_word(t) + " " if t > 1 else "")
248            //                + self.thousand
249            // Note the `t > 1` guard: 1000 -> "mil", never "un mil".
250            let mut out = String::new();
251            if t > BigInt::from(1) {
252                out.push_str(&self.int_to_word(&t));
253                out.push(' ');
254            }
255            out.push_str(THOUSAND);
256            // Python: base + (" " + self._int_to_word(r) if r else "")
257            if !r.is_zero() {
258                out.push(' ');
259                out.push_str(&self.int_to_word(&r));
260            }
261            return out;
262        }
263        if *number < BigInt::from(1_000_000_000) {
264            // Python: m, r = divmod(number, 1000000)
265            let (m, r) = number.div_mod_floor(&BigInt::from(1_000_000));
266            // Python: base = self._int_to_word(m) + " " + self.million
267            // No `m > 1` guard here, unlike the thousands branch above:
268            // 10**6 -> "un miyon".
269            let mut out = self.int_to_word(&m);
270            out.push(' ');
271            out.push_str(MILLION);
272            if !r.is_zero() {
273                out.push(' ');
274                out.push_str(&self.int_to_word(&r));
275            }
276            return out;
277        }
278        // Python: return str(number) — the billion cliff (bug note 1).
279        number.to_string()
280    }
281
282    /// Port of `Num2Word_PAP.to_cardinal` operating on the Python `str(number)`
283    /// — the exact form Python's own code splits on:
284    ///
285    /// ```python
286    /// n = str(number).strip()
287    /// if n.startswith("-"):
288    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
289    /// if "." in n:
290    ///     left, right = n.split(".", 1)
291    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
292    ///     for digit in right:
293    ///         ret += " " + (self.ones[int(digit)] or "sero")
294    ///     return ret.strip()
295    /// return self._int_to_word(int(n))
296    /// ```
297    ///
298    /// The recursion is string-level in Python: `to_cardinal(n[1:])` hands a
299    /// `str` straight back, whose `str(number)` is then a no-op. Reproduced as
300    /// such rather than folded into an `abs()`. One level deep in practice — no
301    /// repr yields a second leading "-". Because the sign is read off the
302    /// *string*, negative zero keeps its "-" (`str(-0.0) == "-0.0"`), so `-0.0`
303    /// renders "menos sero punto sero" — a case the base float path, which tests
304    /// `value < 0.0`, would strip. That divergence is why PAP cannot inherit the
305    /// default `to_cardinal_float`.
306    fn cardinal_from_str(&self, n: &str) -> Result<String> {
307        // n = str(number).strip(). Python strips its own whitespace set and Rust
308        // trims Unicode's; no repr contains either.
309        let n = n.trim();
310
311        // if n.startswith("-"): return (self.negword + self.to_cardinal(n[1:])).strip()
312        if let Some(rest) = n.strip_prefix('-') {
313            let inner = self.cardinal_from_str(rest)?;
314            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
315        }
316
317        // if "." in n: left, right = n.split(".", 1) — the *first* dot only, so
318        // a stray second dot would land in `right` and reach int() as a char.
319        // Unreachable from a real repr.
320        let (left, right) = match n.split_once('.') {
321            Some(halves) => halves,
322            // return self._int_to_word(int(n))
323            None => return Ok(self.int_to_word(&py_int(n)?)),
324        };
325
326        // ret = self._int_to_word(int(left)) + " " + self.pointword
327        //
328        // `int(left)` is the whole integer part, so the billion cliff (bug note
329        // 1) applies: at 1e9 and above it comes back as bare digits
330        // ("98746251323029 punto nuebe nuebe").
331        let mut ret = self.int_to_word(&py_int(left)?);
332        ret.push(' ');
333        ret.push_str(POINTWORD);
334
335        // for digit in right: ret += " " + (self.ones[int(digit)] or "sero")
336        //
337        // Per *character* — no grouping, no rounding, no padding: the fraction
338        // is exactly the digits the repr carried. `self.ones[0]` is `""`
339        // (falsy), so the `or "sero"` is what turns a fraction digit 0 into a
340        // word — a different mechanism from `_int_to_word`'s `number == 0`
341        // guard, but the same output.
342        for digit in right.chars() {
343            let word = ONES[py_int_digit(digit)?];
344            ret.push(' ');
345            ret.push_str(if word.is_empty() { ZERO_WORD } else { word });
346        }
347        // Cosmetic: `_int_to_word` never returns "", so nothing is trimmed here.
348        Ok(ret.trim().to_string())
349    }
350}
351
352impl Lang for LangPap {
353
354    fn cardinal_float_entry(
355        &self,
356        value: &crate::floatpath::FloatValue,
357        precision_override: Option<u32>,
358    ) -> crate::base::Result<String> {
359        // Python's to_cardinal routes every float/Decimal through this
360        // language's own decimal grammar — 5.0 keeps its ".0" tail
361        // ("comma nulla"), unlike Base's whole-value integer route.
362        self.to_cardinal_float(value, precision_override)
363    }
364
365    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
366    /// `"promé"` when `number == 1`, else `"di " + to_cardinal(number)`,
367    /// for *any* input (no `verify_ordinal`). `number == 1` is *numeric*
368    /// equality, so `1.0` and `Decimal("1.00")` both take the special word.
369    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
370    /// before the transformation, exactly as in Python.
371    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
372        let is_one = match value {
373            FloatValue::Float { value: f, .. } => *f == 1.0,
374            FloatValue::Decimal { value: d, .. } => d == &bigdecimal::BigDecimal::from(1),
375        };
376        if is_one {
377            return Ok("promé".to_string());
378        }
379        let cardinal = self.cardinal_float_entry(value, None)?;
380        Ok(format!("di {}", cardinal))
381    }
382
383    /// `to_ordinal_num(float/Decimal)`: `str(number) + "i"` — no `== 1`
384    /// special case here. `repr_str` is the dispatcher's exact `str(value)`
385    /// (float repr / `Decimal.__str__`), so trailing zeros and `1E+2`-style
386    /// exponent forms survive verbatim.
387    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
388        Ok(format!("{}i", repr_str))
389    }
390
391    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
392    /// Infinity sentinel becomes the ValueError this language's own
393    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
394    /// fails); the shared dispatcher would otherwise report Base's
395    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
396    /// ValueError for it already matches `int("NaN")`.
397    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
398        match crate::strnum::python_decimal_parse(s)? {
399            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
400                "invalid literal for int() with base 10: 'Infinity'".into(),
401            )),
402            p => Ok(p),
403        }
404    }
405
406    /// This language's own `to_currency(currency=...)` default,
407    /// read from the live Python signature. Only 44 of 156 use EUR.
408    fn default_currency(&self) -> &str {
409        "ANG"
410    }
411
412    /// This language's own `to_currency(separator=...)` default,
413    /// read from the live Python signature. Base's is ",", but only
414    /// 36 of 149 languages actually use it — most default to " " or a
415    /// conjunction, so inheriting Base's comma silently corrupts them.
416    fn default_separator(&self) -> &str {
417        " "
418    }
419
420    fn negword(&self) -> &str {
421        NEGWORD
422    }
423
424    fn pointword(&self) -> &str {
425        "punto"
426    }
427
428    // `is_title` stays false (the base default), so `title()` is the identity
429    // and `exclude_title` never actually gates anything.
430    fn exclude_title(&self) -> &[String] {
431        &self.exclude_title
432    }
433
434    /// Port of `Num2Word_PAP.to_cardinal`, integer path only.
435    ///
436    /// The Python operates on `str(number)`; for integers the `"." in n` branch
437    /// (the float path) is unreachable, so only the sign test and the
438    /// `_int_to_word(int(n))` tail remain.
439    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
440        if *value < BigInt::zero() {
441            // Python: (self.negword + self.to_cardinal(n[1:])).strip()
442            let inner = self.to_cardinal(&-value)?;
443            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
444        }
445        Ok(self.int_to_word(value))
446    }
447
448    /// Port of `Num2Word_PAP.to_ordinal`.
449    ///
450    /// No `verify_ordinal` call, so negatives and zero pass straight through
451    /// (bug note 2).
452    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
453        if *value == BigInt::from(1) {
454            return Ok("promé".to_string());
455        }
456        Ok(format!("di {}", self.to_cardinal(value)?))
457    }
458
459    /// Port of `Num2Word_PAP.to_ordinal_num`: `str(number) + "i"`.
460    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
461        Ok(format!("{}i", value))
462    }
463
464    /// Port of `Num2Word_PAP.to_year`: ignores `longval` and delegates.
465    fn to_year(&self, value: &BigInt) -> Result<String> {
466        self.to_cardinal(value)
467    }
468
469    /// Port of `Num2Word_PAP.to_cardinal` for non-integer input.
470    ///
471    /// PAP overrides `to_cardinal` (not `to_cardinal_float`) and handles floats
472    /// and Decimals inline by splitting `str(number)` — so the port reconstructs
473    /// that exact string and hands it to [`Self::cardinal_from_str`]. The float
474    /// and Decimal arms of `str()` are not interchangeable (issue #603): a float
475    /// stringifies to shortest round-trip and goes exponential outside
476    /// [1e-4, 1e16), while a Decimal stringifies exactly and goes exponential on
477    /// its own written exponent. Reproducing both is what makes the exponent-form
478    /// `ValueError` fire exactly where Python's does (`int("1e+16")`).
479    ///
480    /// PAP's `to_cardinal(self, number)` takes **no** `precision=` kwarg, and the
481    /// live interpreter confirms the kwarg is dropped before this method is
482    /// reached (`num2words(0.5, lang='pap', precision=3) == "sero punto sinku"`),
483    /// so `precision_override` is ignored here.
484    fn to_cardinal_float(
485        &self,
486        value: &FloatValue,
487        precision_override: Option<u32>,
488    ) -> Result<String> {
489        let _ = precision_override;
490        self.cardinal_from_str(&python_str(value))
491    }
492
493    // ---- currency -------------------------------------------------------
494    //
495    // PAP overrides `to_currency` and `pluralize`, and defines its own
496    // `CURRENCY_FORMS`. Everything else on the currency path — `to_cheque`,
497    // `_money_verbose`, `_cents_verbose`, `_cents_terse` — is inherited from
498    // `Num2Word_Base` unchanged (confirmed via `__qualname__` on the live
499    // instance), and the trait defaults already mirror those, so they are not
500    // overridden here.
501    //
502    // `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` on the live
503    // instance, so `currency_precision` (default 100 == Base's
504    // `.get(code, 100)` on an empty dict) and `currency_adjective` (default
505    // `None`) are left alone too.
506    //
507    // `cardinal_from_decimal` stays at its raising default: PAP's own
508    // `to_currency` has no fractional-cents branch to reach it, and Base's —
509    // the only caller — is never entered.
510
511    fn lang_name(&self) -> &str {
512        "Num2Word_PAP"
513    }
514
515    /// `CURRENCY_FORMS[code]`, strictly — **no ANG fallback here**.
516    ///
517    /// This hook feeds the inherited `to_cheque`, whose Python is
518    /// `try: self.CURRENCY_FORMS[currency] except KeyError: raise
519    /// NotImplementedError`. The fallback is a quirk of `to_currency`'s
520    /// `.get(...)` call alone (bug note 7), so it lives there and must not
521    /// leak into this lookup — doing so would turn the seven expected
522    /// `cheque:*` NotImplementedError rows into florin cheques.
523    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
524        self.currency_forms.get(code)
525    }
526
527    /// Port of `Num2Word_PAP.pluralize`.
528    ///
529    /// Note `forms[-1]`, not `forms[1]`: PAP takes the *last* form, so it is
530    /// total for any arity and — unlike `Num2Word_EUR.pluralize` — cannot
531    /// IndexError. The empty-tuple guard returns `""` rather than raising.
532    ///
533    /// Dead on PAP's own currency path (bug note 8); ported because the class
534    /// defines it and it is reachable as a public method.
535    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
536        // Python: if not forms: return ""
537        if forms.is_empty() {
538            return Ok(String::new());
539        }
540        // Python: return forms[0] if n == 1 else forms[-1]
541        Ok(if n.is_one() {
542            forms[0].clone()
543        } else {
544            forms[forms.len() - 1].clone()
545        })
546    }
547
548    /// Port of `Num2Word_PAP.to_currency` — a wholesale override that shares
549    /// nothing with `Num2Word_Base.to_currency` (bug note 8).
550    ///
551    /// # Why `has_decimal` is not consulted
552    ///
553    /// Base gates the cents segment on `has_decimal or right > 0`; PAP gates it
554    /// on `if cents and right:` — the truthiness of the integer `right` alone.
555    /// So the int/float split that is load-bearing everywhere else is invisible
556    /// here: `1` and `1.0` both give `right == 0` and both print `"un euro"`,
557    /// and `Decimal("5.00")` prints `"sinku euro"` where Base would say
558    /// "five euro, zero cents". The `Int`/`Decimal` variants are still kept
559    /// apart below, but only because `str(int)` has no `"."` to split — they
560    /// converge on the same answer.
561    ///
562    /// # Why exact arithmetic reproduces Python's string slicing
563    ///
564    /// Python computes the parts by slicing `str(abs(val))`:
565    /// `left = int(parts[0])` and
566    /// `right = int(parts[1][:2].ljust(2, "0"))`. The shim hands us
567    /// `BigDecimal::from_str(str(val))` — the *same* decimal string, parsed
568    /// losslessly — so for any plain (non-exponent) decimal notation the slice
569    /// is exactly `trunc(|v|)` and `trunc(frac(|v|) * 100)`: taking the first
570    /// two fractional digits and right-padding with `"0"` *is* a truncating
571    /// scale-2 shift. `0.5` → `"5"` → `"50"` → 50 == trunc(0.5 × 100); `1.005`
572    /// → `"00"` → 0 == trunc(0.5); `2.675` → `"67"` → 67 == trunc(67.5).
573    ///
574    /// # Known divergence: exponent-form `str(value)`
575    ///
576    /// The equivalence above holds only while `str(value)` is plain decimal
577    /// notation. When it is not — a float with `|v| >= 1e16` or `0 < |v| < 1e-4`
578    /// (`str(1e16) == "1e+16"`), or a `Decimal` with adjusted exponent outside
579    /// roughly -6..15 (`str(Decimal("0.000000001")) == "1E-9"`) — Python feeds
580    /// that text to `int()` and raises **ValueError**, e.g.
581    /// `invalid literal for int() with base 10: '1e+16'`. Nastier still,
582    /// `Decimal("1.23E+4")` *does* contain a ".", so it splits into
583    /// `("1", "23E+4")` and silently renders 12300 as "un euro binti i tres
584    /// sèn". This port raises neither and renders 12300 correctly.
585    ///
586    /// This is **not fixable from inside this file**: the trigger is the
587    /// *notation* of `str(val)`, which `CurrencyValue` does not carry. Both
588    /// `1e-05` (a float) and `Decimal("0.00001")` arrive here as the identical
589    /// `BigDecimal { digits: 1, scale: 5 }` with `has_decimal: true`, yet
590    /// Python raises for the first and returns "sero euro" for the second.
591    /// Reproducing it would require the boundary to pass the original string
592    /// through. No corpus row exercises any of this, and the inherited
593    /// `to_cheque` is immune (Base does `Decimal(str(val))`, which parses
594    /// exponent form happily) — verified against the live interpreter.
595    fn to_currency(
596        &self,
597        val: &CurrencyValue,
598        currency: &str,
599        cents: bool,
600        separator: Option<&str>,
601        adjective: bool,
602    ) -> Result<String> {
603        // The trait resolves an omitted kwarg through `default_separator()`.
604        let separator = separator.unwrap_or(self.default_separator());
605        // Python's signature accepts `adjective` and never reads it.
606        let _ = adjective;
607
608        // Python: is_negative = val < 0; val = abs(val)
609        let is_negative = val.is_negative();
610
611        // Python: parts = str(val).split(".")
612        //         left  = int(parts[0]) if parts[0] else 0
613        //         right = int(parts[1][:2].ljust(2, "0"))
614        //                 if len(parts) > 1 and parts[1] else 0
615        let (left, right) = match val {
616            // str(int) never contains ".", so `parts` is a 1-element list and
617            // `right` stays 0 — an int can never print sèn.
618            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
619            CurrencyValue::Decimal { value, .. } => {
620                let abs = value.abs();
621                // with_scale(0) truncates toward zero; `abs` is non-negative,
622                // so this is int(parts[0]).
623                let int_part = abs.with_scale(0);
624                let frac = &abs - &int_part;
625                let cents_part = (frac * BigDecimal::from(100)).with_scale(0);
626                (
627                    int_part.as_bigint_and_exponent().0,
628                    cents_part.as_bigint_and_exponent().0,
629                )
630            }
631        };
632
633        // Python: cr1, cr2 = self.CURRENCY_FORMS.get(
634        //             currency, list(self.CURRENCY_FORMS.values())[0])
635        let forms = self
636            .currency_forms
637            .get(currency)
638            .unwrap_or(&self.currency_fallback);
639        let (cr1, cr2) = (&forms.unit, &forms.subunit);
640
641        // Python: result = self._int_to_word(left) + " "
642        //                  + (cr1[1] if left != 1 else cr1[0])
643        // Both indexes are direct tuple subscripts in Python, so a 1-element
644        // form tuple would IndexError. Every PAP entry has two, making this
645        // unreachable — mapped rather than unwrapped so the exception *type*
646        // survives if the table ever changes.
647        let unit = if left.is_one() { cr1.first() } else { cr1.get(1) }
648            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
649        let mut result = format!("{} {}", self.int_to_word(&left), unit);
650
651        // Python: if cents and right:
652        //             result += separator + self._int_to_word(right) + " "
653        //                       + (cr2[1] if right != 1 else cr2[0])
654        // Note `right` is tested for truthiness, so 0 sèn prints nothing at
655        // all — and that `separator` is *not* followed by a space of its own.
656        if cents && !right.is_zero() {
657            let subunit = if right.is_one() { cr2.first() } else { cr2.get(1) }
658                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
659            result.push_str(separator);
660            result.push_str(&self.int_to_word(&right));
661            result.push(' ');
662            result.push_str(subunit);
663        }
664
665        // Python: if is_negative: result = self.negword + result
666        //         return result.strip()
667        if is_negative {
668            result.insert_str(0, NEGWORD);
669        }
670        Ok(result.trim().to_string())
671    }
672}
673
674// ---- the float/Decimal path's `str(number)` reconstruction --------------
675//
676// PAP's `to_cardinal` works entirely on `str(number)`, so a faithful port has
677// to hand `cardinal_from_str` the byte-for-byte string CPython would produce.
678// These helpers are lifted from the sibling `lang_ceb.rs` port (Cebuano shares
679// PAP's exact `_int_to_word`/`str`-splitting structure); each is documented and
680// mass-verified against CPython there. Only the language constants differ.
681
682/// CPython's `str(number)` for the value that reached PAP's `to_cardinal`.
683fn python_str(value: &FloatValue) -> String {
684    match value {
685        FloatValue::Float { value, .. } => py_str_f64(*value),
686        FloatValue::Decimal { value, .. } => py_str_decimal(value),
687    }
688}
689
690/// Python's `int(s)` for a whole token. CPython's message is
691/// `invalid literal for int() with base 10: '<s>'`, quoted exactly — this is
692/// what surfaces for exponent-form strings that carry no ".": `int("1e+16")`.
693///
694/// The accepted grammars differ only in ways that cannot bite here: Python's
695/// `int` also takes surrounding whitespace, a `+` sign and `_` separators, none
696/// of which a `str(float)`/`str(Decimal)` can emit.
697fn py_int(s: &str) -> Result<BigInt> {
698    BigInt::from_str(s)
699        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
700}
701
702/// Python's `int(ch)` for a *single* character, as the fraction loop calls it.
703/// The message quotes the one offending character, matching CPython — which is
704/// why a Decimal like `1.23E+4` reports `'E'` rather than `'23E+4'`.
705///
706/// One unreachable divergence: CPython's `int()` accepts any Unicode `Nd`
707/// codepoint, while `char::to_digit(10)` is ASCII-only. No `str(float)` or
708/// `str(Decimal)` can emit a non-ASCII digit, so they agree on every string
709/// that reaches here.
710fn py_int_digit(ch: char) -> Result<usize> {
711    ch.to_digit(10)
712        .map(|d| d as usize)
713        .ok_or_else(|| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch)))
714}
715
716/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
717/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
718///
719/// Rust's shortest `{:e}` and CPython's `repr` agree on every double except an
720/// exact tie, where CPython breaks to even and Rust breaks away from zero.
721/// Rust's fixed-precision `{:.*e}` *is* correctly rounded half-to-even, so
722/// re-emitting the same number of significant digits through it applies
723/// CPython's rule — kept only if it still round-trips.
724fn shortest_repr_digits(a: f64) -> (String, i32) {
725    let split = |s: &str| -> (String, i32) {
726        let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
727        (
728            mant.chars().filter(|c| *c != '.').collect(),
729            exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
730        )
731    };
732
733    let shortest = format!("{:e}", a);
734    let (digits, decpt) = split(&shortest);
735
736    let ties_even = format!("{:.*e}", digits.len() - 1, a);
737    if ties_even.parse::<f64>() == Ok(a) {
738        return split(&ties_even);
739    }
740    (digits, decpt)
741}
742
743/// CPython's `str(float)` / `repr(float)`
744/// (`PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)`):
745///
746/// * exponent form when `decpt <= -4 || decpt > 16` (`str(1e16) == "1e+16"`,
747///   `str(1e-05) == "1e-05"`), with the exponent `%+.02d`;
748/// * otherwise positional, appending `.0` when nothing follows the point
749///   (so `str(1.0) == "1.0"`).
750///
751/// The sign is the sign *bit*, not `v < 0.0`, so `str(-0.0) == "-0.0"` and
752/// PAP's `startswith("-")` fires on negative zero.
753fn py_str_f64(v: f64) -> String {
754    // Unreachable from the shim (it derives `precision` from a finite Decimal),
755    // handled anyway so this is a faithful `str()`: PAP would go on to raise
756    // ValueError on `int("inf")`/`int("nan")`, exactly as Python does.
757    if v.is_nan() {
758        return "nan".to_string();
759    }
760    if v.is_infinite() {
761        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
762    }
763
764    let sign = if v.is_sign_negative() { "-" } else { "" };
765    let (digits, decpt) = shortest_repr_digits(v.abs());
766    let ndig = digits.len() as i32;
767
768    if decpt <= -4 || decpt > 16 {
769        let mantissa = if ndig > 1 {
770            format!("{}.{}", &digits[..1], &digits[1..])
771        } else {
772            // No ADD_DOT_0 in exponent form: `str(1e16)` is "1e+16".
773            digits.clone()
774        };
775        let exp = decpt - 1;
776        let (esign, eabs) = if exp < 0 {
777            ("-", -(exp as i64))
778        } else {
779            ("+", exp as i64)
780        };
781        return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
782    }
783    if decpt <= 0 {
784        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
785    } else if decpt >= ndig {
786        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
787    } else {
788        format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
789    }
790}
791
792/// CPython's `Decimal.__str__` (`_pydecimal.Decimal.__str__`).
793///
794/// Read the digits and exponent off `as_bigint_and_exponent()` and reassemble by
795/// Python's rule — `BigDecimal`'s own `Display` disagrees on `1E+2` (Python
796/// keeps it exponential, so PAP raises), on `0.0` (Python keeps the ".0"), and
797/// on the `e`/`E` case. `BigDecimal::from_str` keeps the written scale
798/// (`"1.10"` stays coefficient 110 / scale 2), which is what makes the trailing
799/// "sero" appear, and `(coefficient, -scale)` is exactly Python's `(_int, _exp)`.
800fn py_str_decimal(value: &BigDecimal) -> String {
801    let (coefficient, scale) = value.as_bigint_and_exponent();
802    let exp = -scale;
803    let sign = if coefficient.is_negative() { "-" } else { "" };
804    let int_digits = coefficient.abs().to_string();
805    let ndig = int_digits.len() as i64;
806
807    let leftdigits = exp + ndig;
808    let dotplace = if exp <= 0 && leftdigits > -6 {
809        leftdigits
810    } else {
811        1
812    };
813
814    let (intpart, fracpart) = if dotplace <= 0 {
815        (
816            "0".to_string(),
817            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
818        )
819    } else if dotplace >= ndig {
820        (
821            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
822            String::new(),
823        )
824    } else {
825        (
826            int_digits[..dotplace as usize].to_string(),
827            format!(".{}", &int_digits[dotplace as usize..]),
828        )
829    };
830
831    // `['e', 'E'][context.capitals]`, and the default context has capitals=1.
832    let exponent = if leftdigits == dotplace {
833        String::new()
834    } else {
835        format!("E{:+}", leftdigits - dotplace)
836    };
837
838    format!("{}{}{}{}", sign, intpart, fracpart, exponent)
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use bigdecimal::BigDecimal;
845    use std::str::FromStr;
846
847    /// A currency call shaped like the binding's: `separator: None` means the
848    /// kwarg was omitted, so `default_separator()` (" ") applies. `arg` is the
849    /// corpus's `repr(value)`, and the int/float split is read off it exactly
850    /// as the Python shim does.
851    fn cur(arg: &str, code: &str) -> String {
852        let is_int = !arg.contains('.');
853        let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
854        LangPap::new().to_currency(&v, code, true, None, false).unwrap()
855    }
856
857    fn cheque(arg: &str, code: &str) -> Result<String> {
858        LangPap::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
859    }
860
861    /// Every `"lang": "pap", "to": "currency:*"` row in `bench/corpus.jsonl`.
862    ///
863    /// The four fallback codes below (JPY/KWD/BHD/CHF stand in for the seven)
864    /// are the load-bearing ones: they prove `to_currency` neither raises on an
865    /// unknown code nor honours a 0-/3-decimal divisor (bug notes 7 and 8).
866    #[test]
867    fn corpus_currency() {
868        for (code, unit) in [("EUR", "euro"), ("USD", "dolar"), ("GBP", "florin"),
869                             ("JPY", "florin"), ("KWD", "florin"), ("BHD", "florin"),
870                             ("INR", "florin"), ("CNY", "florin"), ("CHF", "florin")] {
871            assert_eq!(cur("0", code), format!("sero {unit}"));
872            assert_eq!(cur("1", code), format!("un {unit}"));
873            assert_eq!(cur("2", code), format!("dos {unit}"));
874            assert_eq!(cur("100", code), format!("shen {unit}"));
875            assert_eq!(cur("1000000", code), format!("un miyon {unit}"));
876            assert_eq!(cur("1.0", code), format!("un {unit}"));
877            assert_eq!(cur("0.01", code), format!("sero {unit} un sèn"));
878            assert_eq!(cur("0.5", code), format!("sero {unit} sinkuenta sèn"));
879            assert_eq!(cur("12.34", code), format!("diesdos {unit} trinta i kuater sèn"));
880            assert_eq!(cur("-12.34", code), format!("menos diesdos {unit} trinta i kuater sèn"));
881            assert_eq!(
882                cur("99.99", code),
883                format!("nobenta i nuebe {unit} nobenta i nuebe sèn")
884            );
885            assert_eq!(
886                cur("1234.56", code),
887                format!("mil dos shen i trinta i kuater {unit} sinkuenta i seis sèn")
888            );
889        }
890    }
891
892    /// Every `"lang": "pap", "to": "cheque:*"` row. EUR/USD resolve; the other
893    /// seven raise, because `to_cheque` subscripts `CURRENCY_FORMS` instead of
894    /// `.get`-ing it with the ANG default (bug note 7).
895    #[test]
896    fn corpus_cheque() {
897        assert_eq!(
898            cheque("1234.56", "EUR").unwrap(),
899            "MIL DOS SHEN I TRINTA I KUATER AND 56/100 EURO"
900        );
901        assert_eq!(
902            cheque("1234.56", "USD").unwrap(),
903            "MIL DOS SHEN I TRINTA I KUATER AND 56/100 DOLAR"
904        );
905        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
906            match cheque("1234.56", code) {
907                Err(N2WError::NotImplemented(m)) => assert_eq!(
908                    m,
909                    format!("Currency code \"{code}\" not implemented for \"Num2Word_PAP\"")
910                ),
911                other => panic!("{code}: expected NotImplementedError, got {other:?}"),
912            }
913        }
914    }
915
916    /// Off-corpus checks against the live interpreter, covering the paths the
917    /// corpus leaves untested. Each expectation was read out of Python.
918    #[test]
919    fn quirks_match_python() {
920        // Truncation, not ROUND_HALF_UP: Base would say sesenta i ocho.
921        assert_eq!(cur("2.675", "EUR"), "dos euro sesenta i shete sèn");
922        // Sub-cent digits vanish without carrying.
923        assert_eq!(cur("1.005", "EUR"), "un euro");
924        assert_eq!(cur("12.999", "EUR"), "diesdos euro nobenta i nuebe sèn");
925        // has_decimal is never consulted: Decimal("5.00") prints no sèn.
926        let d = CurrencyValue::Decimal {
927            value: BigDecimal::from_str("5.00").unwrap(),
928            has_decimal: true,
929            is_float: false,
930        };
931        assert_eq!(
932            LangPap::new().to_currency(&d, "EUR", true, None, false).unwrap(),
933            "sinku euro"
934        );
935        // A negative that rounds to nothing keeps its negword.
936        assert_eq!(cur("-0.001", "EUR"), "menos sero euro");
937        assert_eq!(cur("-1", "EUR"), "menos un euro");
938        // The billion cliff reaches to_currency as digits.
939        assert_eq!(cur("1000000000", "EUR"), "1000000000 euro");
940        // AWG is the fallback's twin, and ANG is the fallback itself.
941        assert_eq!(cur("1", "AWG"), "un florin");
942        assert_eq!(cur("1", "ANG"), "un florin");
943        // cents=False drops the segment entirely (PAP has no terse branch).
944        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
945        assert_eq!(
946            LangPap::new().to_currency(&v, "EUR", false, None, false).unwrap(),
947            "diesdos euro"
948        );
949        // adjective=True is accepted and ignored.
950        assert_eq!(
951            LangPap::new().to_currency(&v, "EUR", true, None, true).unwrap(),
952            "diesdos euro trinta i kuater sèn"
953        );
954        // An explicit separator is inserted raw, with no space of its own —
955        // PAP's default " " *is* the whole gap, so a caller passing "," gets
956        // "euro,trinta", not "euro, trinta".
957        assert_eq!(
958            LangPap::new().to_currency(&v, "EUR", true, Some(","), false).unwrap(),
959            "diesdos euro,trinta i kuater sèn"
960        );
961        // pluralize: forms[-1], and "" for an empty tuple.
962        let l = LangPap::new();
963        let forms = vec!["florin".to_string(), "florins".to_string(), "x".to_string()];
964        assert_eq!(l.pluralize(&BigInt::one(), &forms).unwrap(), "florin");
965        assert_eq!(l.pluralize(&BigInt::from(2), &forms).unwrap(), "x");
966        assert_eq!(l.pluralize(&BigInt::from(2), &[]).unwrap(), "");
967    }
968
969    /// A `float` cardinal call as the binding shapes it. `precision` is ignored
970    /// by PAP, so any value round-trips; the raw f64 carries the artefacts.
971    fn cf(arg: &str) -> String {
972        let v = FloatValue::Float {
973            value: arg.parse::<f64>().unwrap(),
974            precision: 0,
975        };
976        LangPap::new().to_cardinal_float(&v, None).unwrap()
977    }
978
979    /// A `Decimal` cardinal call: the written scale of the string is preserved by
980    /// `BigDecimal::from_str`, which is what the exact arm needs.
981    fn cd(arg: &str) -> String {
982        let v = FloatValue::Decimal {
983            value: BigDecimal::from_str(arg).unwrap(),
984            precision: 0,
985        };
986        LangPap::new().to_cardinal_float(&v, None).unwrap()
987    }
988
989    /// Every `"lang": "pap", "to": "cardinal"` row whose `arg` carries a dot —
990    /// i.e. float input — from `bench/corpus.jsonl`.
991    #[test]
992    fn corpus_cardinal_float() {
993        assert_eq!(cf("0.0"), "sero punto sero");
994        assert_eq!(cf("0.5"), "sero punto sinku");
995        assert_eq!(cf("1.0"), "un punto sero");
996        assert_eq!(cf("1.5"), "un punto sinku");
997        assert_eq!(cf("2.25"), "dos punto dos sinku");
998        assert_eq!(cf("3.14"), "tres punto un kuater");
999        assert_eq!(cf("0.01"), "sero punto sero un");
1000        assert_eq!(cf("0.1"), "sero punto un");
1001        assert_eq!(cf("0.99"), "sero punto nuebe nuebe");
1002        assert_eq!(cf("1.01"), "un punto sero un");
1003        assert_eq!(cf("12.34"), "diesdos punto tres kuater");
1004        assert_eq!(cf("99.99"), "nobenta i nuebe punto nuebe nuebe");
1005        assert_eq!(cf("100.5"), "shen punto sinku");
1006        assert_eq!(
1007            cf("1234.56"),
1008            "mil dos shen i trinta i kuater punto sinku seis"
1009        );
1010        assert_eq!(cf("-0.5"), "menos sero punto sinku");
1011        assert_eq!(cf("-1.5"), "menos un punto sinku");
1012        assert_eq!(cf("-12.34"), "menos diesdos punto tres kuater");
1013        // The f64-artefact cases: base.float2tuple would reconstruct 675/005,
1014        // and str(float) carries those exact digits too.
1015        assert_eq!(cf("1.005"), "un punto sero sero sinku");
1016        assert_eq!(cf("2.675"), "dos punto seis shete sinku");
1017    }
1018
1019    /// Every `"lang": "pap", "to": "cardinal_dec"` row — Decimal input, exact.
1020    #[test]
1021    fn corpus_cardinal_dec() {
1022        assert_eq!(cd("0.01"), "sero punto sero un");
1023        // Trailing zero survives: str(Decimal("1.10")) == "1.10" -> "... un sero".
1024        assert_eq!(cd("1.10"), "un punto un sero");
1025        assert_eq!(cd("12.345"), "diesdos punto tres kuater sinku");
1026        // Trillion-scale exact value, past the billion cliff: the integer part
1027        // renders as bare digits.
1028        assert_eq!(
1029            cd("98746251323029.99"),
1030            "98746251323029 punto nuebe nuebe"
1031        );
1032        assert_eq!(cd("0.001"), "sero punto sero sero un");
1033    }
1034
1035    /// Off-corpus edges read out of the live interpreter.
1036    #[test]
1037    fn float_edges_match_python() {
1038        // Negative zero keeps its sign because PAP tests the *string*, not
1039        // `value < 0` — the divergence from the base float path.
1040        assert_eq!(cf("-0.0"), "menos sero punto sero");
1041        // Large / tiny floats stringify to exponent form, which has no "." (or a
1042        // stray 'e'), so PAP feeds it to int() and raises ValueError — quoting
1043        // the exact offending token, as CPython does.
1044        for (arg, tok) in [("1e16", "1e+16"), ("1e21", "1e+21"), ("1e-05", "1e-05")] {
1045            let v = FloatValue::Float {
1046                value: arg.parse::<f64>().unwrap(),
1047                precision: 0,
1048            };
1049            match LangPap::new().to_cardinal_float(&v, None) {
1050                Err(N2WError::Value(m)) => assert_eq!(
1051                    m,
1052                    format!("invalid literal for int() with base 10: '{}'", tok)
1053                ),
1054                other => panic!("{arg}: expected ValueError, got {other:?}"),
1055            }
1056        }
1057        // A Decimal whose str keeps a "." but embeds an exponent splits, then
1058        // trips on the 'E' digit: int('E') -> ValueError, quoting just 'E'.
1059        let v = FloatValue::Decimal {
1060            value: BigDecimal::from_str("1.23E+4").unwrap(),
1061            precision: 0,
1062        };
1063        match LangPap::new().to_cardinal_float(&v, None) {
1064            Err(N2WError::Value(m)) => {
1065                assert_eq!(m, "invalid literal for int() with base 10: 'E'")
1066            }
1067            other => panic!("1.23E+4: expected ValueError, got {other:?}"),
1068        }
1069        // Precision override is dropped (PAP's signature has no such kwarg).
1070        let v = FloatValue::Float {
1071            value: 0.5,
1072            precision: 1,
1073        };
1074        assert_eq!(
1075            LangPap::new().to_cardinal_float(&v, Some(3)).unwrap(),
1076            "sero punto sinku"
1077        );
1078    }
1079}