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}