Skip to main content

num2words2_core/
lang_ps.rs

1//! Port of `lang_PS.py` (Pashto).
2//!
3//! Registry check: `__init__.py` line 361 maps `"ps"` → `lang_PS.Num2Word_PS()`,
4//! so this is the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_PS` subclasses `Num2Word_Base` but its
7//! `setup()` defines only flat `ones`/`tens` lists plus three scale words — no
8//! `high_numwords`/`mid_numwords`/`low_numwords`. `Num2Word_Base.__init__`
9//! guards the card machinery behind
10//! `if any(hasattr(self, f) for f in ["high_numwords", "mid_numwords", "low_numwords"])`,
11//! so for PS `self.cards` is never built and `self.MAXVAL` is never set
12//! (verified in the interpreter: `hasattr(o, 'cards') == False`,
13//! `hasattr(o, 'MAXVAL') == False`). `to_cardinal` is overridden outright and
14//! drives a recursive `_int_to_word`. Consequently `cards`/`maxval`/`merge`
15//! stay at their trait defaults here, and **there is no overflow check** — PS
16//! never raises. Every in-scope corpus row for "ps" is `ok: true`; there are no
17//! `Index`/`Key`/`Value` crash sites to reproduce.
18//!
19//! Method map (all four in-scope modes are explicitly defined by PS itself,
20//! nothing in-scope is inherited from `Num2Word_Base`):
21//!   * `to_cardinal(number)`    → sign-strip on the *decimal string*, then `_int_to_word`
22//!   * `to_ordinal(number)`     → `to_cardinal(number) + "-م"`
23//!   * `to_ordinal_num(number)` → `str(number) + "."`
24//!   * `to_year(val, longval=True)` → `to_cardinal(val)`; `longval` is accepted
25//!     and then ignored, so year output is identical to cardinal output.
26//!
27//! # Faithfully reproduced Python bugs
28//!
29//! This is a port, not a rewrite. Everything below looks wrong and is exactly
30//! what Python emits — each one is pinned by a frozen-corpus row.
31//!
32//! 1. **No teens.** `ones`/`tens` are combined mechanically, so 11..19 render
33//!    as "ten one", "ten two", … instead of the real Pashto forms (یوولس,
34//!    دولس, …). Corpus: `11` → "لس یو", `12` → "لس دوه", `19` → "لس نهه".
35//!    This also leaks into every higher scale, because the thousands/millions
36//!    branches recurse through the same routine: `12345` → "لس دوه زره درې سل
37//!    څلوېښت پنځه" ("ten two thousand three hundred forty five"), and `10^7`
38//!    → "لس میلیون". Do not "fix" this into یوولس — the corpus says otherwise.
39//! 2. **`zero` is an English word in a Pashto table.** `_int_to_word(0)` reads
40//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is `""`,
41//!    which is falsy, so the conditional *always* takes the `"zero"` branch —
42//!    the `self.ones[0]` arm is unreachable dead code. Pashto صفر is never
43//!    emitted. Corpus: `0` → "zero", and `to_ordinal(0)` → "zero-م".
44//! 3. **`negword` is English too**: `"minus "`, not منفي. Corpus: `-42` →
45//!    "minus څلوېښت دوه". Note it carries a *trailing space* (unlike most
46//!    modules), and PS concatenates it directly rather than via
47//!    `base.default_to_cardinal` (which would `.trim()` it and re-add one
48//!    space). Same result here, but the mechanism differs — see [`NEGWORD`].
49//! 4. **Digits fall out for anything ≥ 10^9.** The `_int_to_word` cascade stops
50//!    at the millions branch; the `else` arm is a bare `return str(number)`.
51//!    So the "words" for a billion are the *numerals*: corpus `10^9` →
52//!    "1000000000", `1234567890` → "1234567890", `10^21` →
53//!    "1000000000000000000000". `to_ordinal(10^9)` → "1000000000-م" — a
54//!    Pashto ordinal suffix glued onto an Arabic-numeral string. There is no
55//!    ceiling and no `OverflowError`; arbitrarily large BigInts just stringify.
56//!    This is why [`int_to_word`] must take `&BigInt` and only narrow to `u32`
57//!    *after* proving `number < 10^9`.
58//! 5. **`to_ordinal_num` ignores the language entirely** and appends a Western
59//!    `"."`, so it is really the German/ordinal-dot convention. It also does no
60//!    sign or range handling: corpus `-1` → "-1.", `0` → "0.".
61//!
62//! # Cross-call mutable state
63//!
64//! None. `setup()` only assigns constant tables; no method sets a flag that
65//! another consumes (no `_pending_ordinal`-style handshake as in `lang_ES`).
66//! The four in-scope modes are pure functions of their argument, so the
67//! stateless Rust path is faithful.
68//!
69//! # The currency surface
70//!
71//! `Num2Word_PS` declares its **own** `CURRENCY_FORMS` class attribute, so the
72//! `lang_EUR`/`Num2Word_EN` shared-dict mutation trap described in
73//! `PORTING_CURRENCY.md` does not apply here — nothing writes into PS's dict.
74//! Verified in the interpreter: `CONVERTER_CLASSES['ps'].CURRENCY_FORMS` holds
75//! exactly the three literal entries `AFN`, `EUR`, `USD` (in that insertion
76//! order), `CURRENCY_PRECISION` is `{}` and `CURRENCY_ADJECTIVES` is `{}`
77//! (both inherited unchanged from `Num2Word_Base`). So precision is 100 for
78//! *every* code and the adjective hook is never populated — both stay at their
79//! trait defaults.
80//!
81//! Method ownership (checked via `__qualname__`):
82//!   * `to_currency`     → **`Num2Word_PS`** — a full override that shares no
83//!     code with `Num2Word_Base.to_currency`. It is ported wholesale below;
84//!     `currency::default_to_currency` is *not* delegated to.
85//!   * `to_cheque`       → `Num2Word_Base` — inherited verbatim, so the trait
86//!     default (`currency::default_to_cheque`) already is the port. It needs
87//!     only [`Lang::currency_forms`] + [`Lang::lang_name`] from us.
88//!   * `_money_verbose`  → `Num2Word_Base` — `return self.to_cardinal(number)`,
89//!     identical to the trait default. Reached only through `to_cheque`.
90//!   * `pluralize` / `_cents_verbose` / `_cents_terse` → never reached: PS's
91//!     `to_currency` selects forms inline and calls `_int_to_word` directly,
92//!     and `to_cheque` renders its subunit as digits. Left at their defaults
93//!     (`pluralize`'s default raises, mirroring Python's abstract method).
94//!
95//! ## Faithfully reproduced Python bugs, currency half
96//!
97//! 6. **An unknown currency code silently becomes AFN.** `to_currency` reads
98//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
99//!    — a `.get` with a *default*, not a subscript, so it never raises. The
100//!    default is the first value in insertion order, i.e. **AFN**. Corpus:
101//!    `currency:GBP 12.34` → "لس دوه افغانۍ دېرش څلور پول" — a request for
102//!    pounds answered in afghanis. Same for JPY/KWD/BHD/INR/CNY/CHF. This is
103//!    why [`LangPs::to_currency`] deliberately bypasses the strict
104//!    [`Lang::currency_forms`] hook; see [`FALLBACK_CURRENCY`].
105//! 7. **`to_currency` ignores `CURRENCY_PRECISION` entirely.** There is no
106//!    divisor anywhere in PS's override — the cents field is always parsed as
107//!    *two* decimal digits off the string. So the 3-decimal currencies
108//!    (KWD/BHD) and the 0-decimal one (JPY) all render 2-digit subunits like
109//!    everything else. Corpus proves both ends: `currency:JPY 12.34` →
110//!    "لس دوه افغانۍ **دېرش څلور پول**" (a zero-decimal currency showing
111//!    cents), and `currency:KWD 1234.56` → "… **پنځوس شپږ پول**" (56, not
112//!    560). `currency::default_to_currency`'s divisor==1 pre-rounding must
113//!    therefore never run for PS — another reason the override is total.
114//! 8. **Cents are truncated to two digits, not rounded.** `parts[1][:2]` slices
115//!    the fractional *text*: `1.999` → "99", not 100-and-carry. And
116//!    `.ljust(2, "0")` pads a short fraction on the **right**, so `0.5` → "50"
117//!    cents, correctly, while `0.001` → "00" → 0 → the cents segment vanishes.
118//!    Corpus: `0.5` → "zero euros پنځوس cents".
119//! 9. **`to_currency` and `to_cheque` disagree about unknown codes.**
120//!    `to_currency("GBP")` happily prints afghanis (bug 6), but `to_cheque`
121//!    is `Num2Word_Base`'s, which *subscripts* `self.CURRENCY_FORMS[currency]`
122//!    inside `try/except KeyError` and re-raises
123//!    `NotImplementedError('Currency code "GBP" not implemented for
124//!    "Num2Word_PS"')`. Two methods, one table, opposite policies. The corpus
125//!    pins both: every `currency:GBP` row is `ok: true`, every `cheque:GBP`
126//!    row is `err: NotImplementedError`.
127//! 10. **The int/float distinction is invisible in PS's output.** Unlike
128//!    `Num2Word_Base.to_currency`, PS never calls `isinstance(val, int)`; it
129//!    branches on `if cents and right:` instead. `str(1)` → "1" → no "." →
130//!    `right = 0`, and `str(1.0)` → "1.0" → `parts[1] == "0"` → `int("00")` →
131//!    `right = 0` as well. Both suppress the cents segment, so `1` and `1.0`
132//!    both give "یو euro" (corpus confirms) — the same *output* base.py would
133//!    reach by two different routes. [`LangPs::split_value`] still honours
134//!    [`CurrencyValue`]'s two arms rather than collapsing them, because the
135//!    *string* they stand for differs and that string is what PS slices.
136//!
137//! ## Known divergence: exponent notation (unreachable from the corpus)
138//!
139//! PS's `to_currency` does `int(str(val).split(".")[0])` — a **string**
140//! operation on `str(value)`. The `Lang` trait receives a [`CurrencyValue`],
141//! which the shim built by parsing that string into a `BigDecimal`, and the
142//! parse is lossy for exactly one thing: whether the string used exponent
143//! notation. Python's `str()` switches to e-notation at `|x| >= 1e16` or
144//! `0 < |x| < 1e-4`, and `int("1e+16")` raises **ValueError**:
145//!
146//! | input | `str(value)` | Python | this port |
147//! |---|---|---|---|
148//! | `1e16` (float) | `"1e+16"` | `ValueError` | "10000000000000000 euros" |
149//! | `Decimal("1E+2")` | `"1E+2"` | `ValueError` | "یو سل euros" |
150//! | `1e-05` (float) | `"1e-05"` | `ValueError` | "zero euros" |
151//! | `Decimal("0.00001")` | `"0.00001"` | "zero euros" | "zero euros" ✓ |
152//!
153//! The positive-exponent half *is* recoverable here (`scale < 0` happens only
154//! for e-notation input), but the negative-exponent half is not: `"1e-05"` and
155//! `"0.00001"` both parse to the same `BigDecimal` (digits 1, scale 5), and
156//! Python raises for the first and succeeds for the second. Reproducing this
157//! properly needs the raw `str(value)` at the boundary, not a `BigDecimal` —
158//! a shared-boundary change (`currency.rs` / `__init__.py`) that is out of
159//! scope for a single language file, and one that every sibling language with
160//! the same `str(val).split(".")` shape needs too (`lang_bs.rs`, `lang_bo.rs`,
161//! `lang_br.rs`, … all carry the identical gap). Left divergent and reported
162//! rather than half-fixed. A 9,834-case differential fuzz against the live
163//! `Num2Word_PS` found no other disagreement.
164
165use crate::base::{Lang, N2WError, Result};
166use crate::currency::{CurrencyForms, CurrencyValue};
167use crate::floatpath::FloatValue;
168use crate::strnum::{python_decimal_parse, ParsedNumber};
169use bigdecimal::BigDecimal;
170use num_bigint::BigInt;
171use num_integer::Integer;
172use num_traits::{One, Signed, ToPrimitive, Zero};
173use std::collections::HashMap;
174
175/// `self.negword`. Keeps the trailing space of the Python attribute: PS builds
176/// its output as `self.negword + word` with no separator of its own, so the
177/// space is load-bearing rather than cosmetic.
178const NEGWORD: &str = "minus ";
179
180/// `self.pointword`. Only reachable through the float path (`"." in n`), which
181/// is out of scope; declared to mirror the attribute.
182const POINTWORD: &str = "point";
183
184/// The zero word. Python writes `self.ones[0] if self.ones[0] else "zero"`,
185/// but `ones[0]` is `""` (falsy), so this English literal is the only value
186/// that branch can ever produce. See module bug #2.
187const ZERO_WORD: &str = "zero";
188
189/// `self.ones`. Index 0 is `""` and is never emitted: the `number == 0` guard
190/// intercepts it first, and the hundreds branch only ever indexes 1..=9.
191const ONES: [&str; 10] = [
192    "", "یو", "دوه", "درې", "څلور", "پنځه", "شپږ", "اووه", "اته", "نهه",
193];
194
195/// `self.tens`. Index 0 is `""` and is unreachable: the `number < 100` branch
196/// is only entered when `number >= 10`, so `number // 10` is 1..=9.
197const TENS: [&str; 10] = [
198    "", "لس", "شل", "دېرش", "څلوېښت", "پنځوس", "شپېته", "اویا", "اتیا", "نوي",
199];
200
201const HUNDRED: &str = "سل";
202const THOUSAND: &str = "زره";
203const MILLION: &str = "میلیون";
204
205/// The ordinal suffix appended by `to_ordinal`: ASCII hyphen U+002D followed by
206/// ARABIC LETTER MEEM U+0645.
207const ORDINAL_SUFFIX: &str = "-م";
208
209/// First value the `_int_to_word` cascade cannot name: `10**9`. At or above
210/// this, Python's `else` arm returns `str(number)` verbatim (module bug #4).
211fn billion() -> BigInt {
212    BigInt::from(1_000_000_000u32)
213}
214
215/// `Num2Word_PS.to_currency`'s own default separator, `" "`.
216///
217/// Confirmed against `inspect.signature`:
218/// `(val, currency='AFN', cents=True, separator=' ', adjective=False)`.
219const PS_SEPARATOR: &str = " ";
220
221/// The separator the callers hand us when the Python caller omitted one.
222///
223/// PS declares `separator=" "`, but the `Lang` trait takes the separator as a
224/// plain argument, and *both* callers substitute a default before the value
225/// ever reaches Rust — `num2words2/__init__.py`'s currency fast path sends
226/// `kwargs.get("separator", ",")` and `bench/diff_test.py` hardcodes `","`.
227/// That `","` is **`Num2Word_Base`'s** default, not PS's. By the time we are
228/// called, "caller omitted separator" and "caller asked for a comma" are the
229/// same string and the information is gone.
230///
231/// Every currency row in the frozen corpus comes from
232/// `num2words(v, lang="ps", to="currency", currency=c)` with no `separator=`,
233/// so Python renders them through PS's own `" "` ("لس دوه euros دېرش څلور
234/// cents"). Mapping `","` back to `" "` restores PS's default and reproduces
235/// all 108 rows. The one input it gets wrong is an *explicit*
236/// `separator=","`, which Python renders as "لس دوه euros,دېرش څلور cents"
237/// (no space — PS concatenates the separator raw) and which we render with a
238/// space. Fixing that properly means teaching the shim to pass the
239/// converter's own default; `__init__.py` is off-limits here and shared by
240/// ~150 languages, so the narrower divergence is the deliberate choice.
241/// `lang_bs.rs`, `lang_bo.rs`, `lang_br.rs`, `lang_ca.rs` and `lang_es.rs`
242/// hit the identical trap and resolve it the same way — see `concerns`.
243const SEPARATOR_UNSET: &str = ",";
244
245/// The key `list(self.CURRENCY_FORMS.values())[0]` resolves to.
246///
247/// PS's `to_currency` falls back on the **first value** of its own
248/// `CURRENCY_FORMS`, and Python dicts have preserved insertion order since
249/// 3.7, so the class literal's first entry — `AFN` — is what every
250/// unrecognised code silently becomes (bug 6). Verified in the interpreter:
251/// `list(CONVERTER_CLASSES['ps'].CURRENCY_FORMS.values())[0]` is
252/// `(('افغانۍ', 'افغانۍ'), ('پول', 'پول'))`.
253const FALLBACK_CURRENCY: &str = "AFN";
254
255pub struct LangPs {
256    /// `Num2Word_PS.CURRENCY_FORMS`, built once in [`LangPs::new`] and reused
257    /// for the life of the converter. The pyo3 binding holds each language in
258    /// a `OnceLock`, so this table is constructed exactly once per process —
259    /// rebuilding it per call is what made an earlier revision of this port
260    /// 10x slower than the Python it replaces.
261    currency_forms: HashMap<&'static str, CurrencyForms>,
262    /// The [`FALLBACK_CURRENCY`] entry, cloned out so the `.get(..., default)`
263    /// fallback needs no second hash lookup and cannot panic.
264    afn: CurrencyForms,
265}
266
267impl Default for LangPs {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273impl LangPs {
274    pub fn new() -> Self {
275        // Python:
276        //   CURRENCY_FORMS = {
277        //       "AFN": (("افغانۍ", "افغانۍ"), ("پول", "پول")),
278        //       "USD": (("dollar", "dollars"), ("cent", "cents")),
279        //       "EUR": (("euro", "euros"), ("cent", "cents")),
280        //   }
281        //
282        // Two forms per side, exactly as Python has them; `select_form`
283        // indexes [0]/[1] and nothing else, so the arity is load-bearing.
284        // AFN's two unit forms are *identical* ("افغانۍ" is invariant), which
285        // is why `currency:GBP 1` and `currency:GBP 2` both end in "افغانۍ" —
286        // that is Pashto, not a bug.
287        let afn = CurrencyForms::new(&["افغانۍ", "افغانۍ"], &["پول", "پول"]);
288
289        let currency_forms: HashMap<&'static str, CurrencyForms> = [
290            (FALLBACK_CURRENCY, afn.clone()),
291            (
292                "USD",
293                CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
294            ),
295            (
296                "EUR",
297                CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
298            ),
299        ]
300        .into_iter()
301        .collect();
302
303        LangPs { currency_forms, afn }
304    }
305
306    /// Python's `parts = str(val).split(".")` → `(left, right, is_negative)`.
307    ///
308    /// ```python
309    /// if val < 0:
310    ///     is_negative = True
311    ///     val = abs(val)
312    /// parts = str(val).split(".")
313    /// left = int(parts[0]) if parts[0] else 0
314    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
315    /// ```
316    ///
317    /// `abs` runs *before* `str`, so the sign never reaches the split; taking
318    /// `.abs()` of the digits is the same thing.
319    ///
320    /// `parts[0]` is empty only for a string starting with "." (`".5"`), which
321    /// neither `str(int)` nor `str(float)` nor `str(Decimal)` ever produces —
322    /// so the `if parts[0] else 0` guard is dead and `left` is always the
323    /// integer part.
324    fn split_value(val: &CurrencyValue) -> (BigInt, u32, bool) {
325        match val {
326            // `str(int)` has no ".", so `len(parts) == 1` and `right` stays 0.
327            // This is the arm that keeps `1` distinct from `1.0` at the type
328            // level; PS happens to render both the same (bug 10), but the
329            // string being sliced genuinely differs and we model that.
330            CurrencyValue::Int(v) => (v.abs(), 0, v.is_negative()),
331            CurrencyValue::Decimal { value: d, .. } => {
332                let is_negative = d.is_negative();
333                // `value == digits * 10^-scale`, with `digits`/`scale` taken
334                // verbatim from the `str(value)` the shim parsed — so
335                // reconstructing the plain-notation string from them
336                // reproduces Python's `str(abs(val))` exactly.
337                let (digits, scale) = d.as_bigint_and_exponent();
338                let digits = digits.abs();
339
340                if scale <= 0 {
341                    // No "." in the rendered string: the value is integral as
342                    // written. `scale == 0` is the ordinary case
343                    // (`Decimal("100")` → "100"). `scale < 0` means exponent
344                    // notation, where Python takes a different (broken) route
345                    // — see `concerns`.
346                    let pow = BigInt::from(10u32).pow((-scale) as u32);
347                    return (digits * pow, 0, is_negative);
348                }
349
350                let pow = BigInt::from(10u32).pow(scale as u32);
351                let (left, frac) = digits.div_rem(&pow);
352                // `parts[1]` is `frac` zero-padded on the left to `scale`
353                // digits, so `int(parts[1][:2].ljust(2, "0"))` is exactly
354                // "the first two fractional digits read as a 2-digit number":
355                // floor division that keeps leading zeros and pads a short
356                // fraction on the right.
357                //   0.5     -> frac 5,   scale 1 -> 5*100/10   = 50  (ljust)
358                //   0.01    -> frac 1,   scale 2 -> 1*100/100  = 1
359                //   12.34   -> frac 34,  scale 2 -> 34*100/100 = 34
360                //   1.999   -> frac 999, scale 3 -> 999*100/1000 = 99 (bug 8)
361                //   0.001   -> frac 1,   scale 3 -> 1*100/1000   = 0
362                // Always < 100, so `to_u32` cannot fail.
363                let right = (frac * BigInt::from(100u32) / &pow).to_u32().unwrap_or(0);
364                (left, right, is_negative)
365            }
366        }
367    }
368
369    /// Python's inline `cr[1] if n != 1 else cr[0]`.
370    ///
371    /// Indexing `forms[0]`/`forms[1]` is sound because every entry built in
372    /// [`LangPs::new`] carries exactly two forms per side, matching the
373    /// Python literal. Note the comparison is against the *whole* value, so
374    /// 21 takes the plural (`cr[1]`) — PS has no "ends in 1" rule.
375    fn select_form(n: &BigInt, forms: &[String]) -> String {
376        if n.is_one() {
377            forms[0].clone()
378        } else {
379            forms[1].clone()
380        }
381    }
382
383    /// Python's `_int_to_word`, branch for branch and in the original order:
384    /// zero, then negative, then the `< 10 / 100 / 1000 / 10**6 / 10**9`
385    /// cascade, then the bare `str(number)` fallback.
386    fn int_to_word(&self, number: &BigInt) -> String {
387        // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
388        if number.is_zero() {
389            return ZERO_WORD.to_string();
390        }
391
392        // `if number < 0: return self.negword + self._int_to_word(abs(number))`
393        //
394        // Dead code on every path, integer and currency alike: `to_cardinal`
395        // strips the sign from the decimal string before calling in,
396        // `to_currency` pre-`abs()`es and prepends "minus " itself, and each
397        // recursive call passes a quotient or remainder of an already-positive
398        // value. Ported anyway so the cascade matches Python arm for arm; note
399        // it would yield a doubled "minus " if it ever were reached through
400        // `to_cardinal`.
401        if number.is_negative() {
402            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
403        }
404
405        // The `else` arm: everything the cascade cannot name stringifies.
406        // Checked before narrowing precisely because BigInt has no ceiling here.
407        if number >= &billion() {
408            return number.to_string();
409        }
410
411        // Proven above: 0 < number < 10**9, so the value fits u32 (max
412        // 999_999_999 < 2**32). This is the only point where narrowing is
413        // sound; `to_string()` on the wide value already happened.
414        let n = number
415            .to_u32()
416            .expect("0 < number < 10^9 was proven above, so u32 conversion cannot fail");
417        self.pos_to_word(n)
418    }
419
420    /// The naming cascade for `0 < n < 10**9`.
421    ///
422    /// Invariant `n > 0` is upheld by every caller: [`int_to_word`] handles 0
423    /// first, the `if remainder:` guards suppress zero remainders, and the
424    /// quotients are >= 1 because the branch was entered on `n >= 1000` /
425    /// `n >= 10**6`. Were `n == 0` ever to arrive it would return `ONES[0]`
426    /// (`""`) rather than "zero" — the divergence is unreachable, matching
427    /// Python's own reachability.
428    fn pos_to_word(&self, n: u32) -> String {
429        if n < 10 {
430            // `return self.ones[number]`
431            ONES[n as usize].to_string()
432        } else if n < 100 {
433            let tens_val = (n / 10) as usize;
434            let ones_val = (n % 10) as usize;
435            if ones_val == 0 {
436                TENS[tens_val].to_string()
437            } else {
438                format!("{} {}", TENS[tens_val], ONES[ones_val])
439            }
440        } else if n < 1000 {
441            // `result = self.ones[hundreds_val] + " " + self.hundred`
442            // Note 100 → "یو سل" ("one hundred"), never a bare "سل".
443            let hundreds_val = (n / 100) as usize;
444            let remainder = n % 100;
445            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
446            if remainder != 0 {
447                result.push(' ');
448                result.push_str(&self.pos_to_word(remainder));
449            }
450            result
451        } else if n < 1_000_000 {
452            let thousands_val = n / 1000;
453            let remainder = n % 1000;
454            let mut result = format!("{} {}", self.pos_to_word(thousands_val), THOUSAND);
455            if remainder != 0 {
456                result.push(' ');
457                result.push_str(&self.pos_to_word(remainder));
458            }
459            result
460        } else {
461            // n < 10**9, guaranteed by the caller.
462            let millions_val = n / 1_000_000;
463            let remainder = n % 1_000_000;
464            let mut result = format!("{} {}", self.pos_to_word(millions_val), MILLION);
465            if remainder != 0 {
466                result.push(' ');
467                result.push_str(&self.pos_to_word(remainder));
468            }
469            result
470        }
471    }
472
473}
474
475/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
476/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
477/// two-digit-padded exponent otherwise. `Num2Word_PS.to_cardinal` is driven
478/// entirely by this string — `str(1e16) == "1e+16"` has no `"."`, so `int()`
479/// raises `ValueError` there. Mirrors `lang_sk.rs`'s `python_repr_f64`.
480fn python_repr_f64(f: f64) -> String {
481    if f.is_nan() {
482        return "nan".to_string();
483    }
484    if f.is_infinite() {
485        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
486    }
487
488    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
489    let neg = f.is_sign_negative();
490    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
491    let (mantissa, exp_s) = match s.split_once('e') {
492        Some(parts) => parts,
493        None => return s, // unreachable for finite f64
494    };
495    let exp: i32 = exp_s.parse().unwrap_or(0);
496    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
497    let ndigits = digits.len() as i32;
498    let decpt = exp + 1;
499
500    let body = if decpt <= -4 || decpt > 16 {
501        let mut m = String::new();
502        m.push_str(&digits[..1]);
503        if ndigits > 1 {
504            m.push('.');
505            m.push_str(&digits[1..]);
506        }
507        let e = decpt - 1;
508        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
509        format!("{}e{}{:02}", m, esign, eabs)
510    } else if decpt <= 0 {
511        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
512    } else if decpt >= ndigits {
513        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
514    } else {
515        let dp = decpt as usize;
516        format!("{}.{}", &digits[..dp], &digits[dp..])
517    };
518
519    if neg {
520        format!("-{}", body)
521    } else {
522        body
523    }
524}
525
526/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
527/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
528/// `< -6` — so `Decimal("1E+2")` prints `"1E+2"` and feeds `int()` a
529/// `ValueError`. Mirrors `lang_sk.rs`'s `python_str_decimal`.
530fn python_str_decimal(bd: &BigDecimal) -> String {
531    let (coeff, scale) = bd.as_bigint_and_exponent();
532    let py_exp: i64 = -scale; // Decimal._exp
533    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
534    let int_str = coeff.abs().to_string(); // Decimal._int
535    let ndigits = int_str.len() as i64;
536    let leftdigits = py_exp + ndigits;
537
538    // dotplace, non-engineering branch of _pydecimal.__str__.
539    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
540        leftdigits
541    } else {
542        1
543    };
544
545    let (intpart, fracpart) = if dotplace <= 0 {
546        (
547            "0".to_string(),
548            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
549        )
550    } else if dotplace >= ndigits {
551        (
552            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
553            String::new(),
554        )
555    } else {
556        let dp = dotplace as usize;
557        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
558    };
559
560    let exp = if leftdigits == dotplace {
561        String::new()
562    } else {
563        // "%+d" — a sign but no zero-padding, unlike float repr.
564        format!("E{:+}", leftdigits - dotplace)
565    };
566
567    let sign = if neg { "-" } else { "" };
568    format!("{}{}{}{}", sign, intpart, fracpart, exp)
569}
570
571/// Python's `int(s)` on a fragment of `str(number)`: only an optionally
572/// signed ASCII digit run parses; anything with an `e`/`E` (exponent form,
573/// "Infinity") raises `ValueError` with the literal quoted — exactly the
574/// error `to_cardinal(1e16)` shows.
575fn py_int(s: &str) -> Result<BigInt> {
576    let err = || N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s));
577    let t = s.trim();
578    let (negative, body) = match t.strip_prefix('-') {
579        Some(rest) => (true, rest),
580        None => (false, t.strip_prefix('+').unwrap_or(t)),
581    };
582    if body.is_empty() || !body.chars().all(|c| c.is_ascii_digit()) {
583        return Err(err());
584    }
585    let n: BigInt = body.parse().map_err(|_| err())?;
586    Ok(if negative { -n } else { n })
587}
588
589impl Lang for LangPs {
590    /// This language's own `to_currency(currency=...)` default,
591    /// read from the live Python signature. Only 44 of 156 use EUR.
592    fn default_currency(&self) -> &str {
593        "AFN"
594    }
595
596    /// This language's own `to_currency(separator=...)` default,
597    /// read from the live Python signature. Base's is ",", but only
598    /// 36 of 149 languages actually use it — most default to " " or a
599    /// conjunction, so inheriting Base's comma silently corrupts them.
600    fn default_separator(&self) -> &str {
601        " "
602    }
603
604    fn negword(&self) -> &str {
605        NEGWORD
606    }
607
608    fn pointword(&self) -> &str {
609        "point"
610    }
611
612    /// Python's `to_cardinal`.
613    ///
614    /// The original works on `str(number)`: it strips a leading `"-"` off the
615    /// *decimal text* and sets `ret = self.negword`, then feeds the remainder
616    /// back through `int()`. Removing the sign character from a decimal
617    /// rendering is exactly `abs()`, so operating on the BigInt directly is
618    /// equivalent for integral input.
619    ///
620    /// The `"." in n` branch (float/point path) cannot trigger: `str()` of an
621    /// integer never contains a dot. Out of scope regardless.
622    ///
623    /// The trailing `.strip()` is preserved for fidelity even though it is a
624    /// no-op here — `negword` supplies a *leading* space only when followed by
625    /// a word, and `_int_to_word` never returns padded output.
626    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
627        let (ret, n) = if value.is_negative() {
628            (NEGWORD, value.abs())
629        } else {
630            ("", value.clone())
631        };
632        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
633    }
634
635    /// `return cardinal + "-م"`. No sign, zero or range handling whatsoever, so
636    /// `to_ordinal(0)` == "zero-م" and `to_ordinal(-1)` == "minus یو-م".
637    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
638        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
639    }
640
641    /// `return str(number) + "."` — the numeral, untouched, plus a dot.
642    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
643        Ok(format!("{}.", value))
644    }
645
646    /// `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
647    /// `longval` is ignored, so years get no century/"nineteen-eighty" split —
648    /// 1999 is the plain cardinal "یو زره نهه سل نوي نهه". Overridden
649    /// explicitly rather than leaning on the identical trait default, to keep
650    /// the correspondence with the Python method visible.
651    fn to_year(&self, value: &BigInt) -> Result<String> {
652        self.to_cardinal(value)
653    }
654
655    /// The float/Decimal half of `Num2Word_PS.to_cardinal`.
656    ///
657    /// ```python
658    /// n = str(number).strip()
659    /// if n.startswith("-"):
660    ///     n = n[1:]
661    ///     ret = self.negword          # "minus "
662    /// else:
663    ///     ret = ""
664    /// if "." in n:
665    ///     left, right = n.split(".", 1)
666    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
667    ///     for digit in right:
668    ///         ret += self._int_to_word(int(digit)) + " "
669    ///     return ret.strip()
670    /// else:
671    ///     return (ret + self._int_to_word(int(n))).strip()
672    /// ```
673    ///
674    /// PS overrides `to_cardinal` outright and handles non-integers inline on
675    /// `str(number)`; it never delegates to `Num2Word_Base.to_cardinal_float`
676    /// and never reads `self.precision`. `precision_override` is therefore
677    /// dropped on the floor exactly as Python does (confirmed: `precision=`
678    /// leaves PS's cardinal output unchanged). Every digit — integer part and
679    /// each fractional character alike — is named by the same `_int_to_word`
680    /// the integer path uses, so the "no teens" and `>= 10**9` stringify bugs
681    /// (module bugs 1 and 4) leak into the float output too.
682    fn to_cardinal_float(
683        &self,
684        value: &FloatValue,
685        _precision_override: Option<u32>,
686    ) -> Result<String> {
687        // n = str(number).strip() — repr(float) / str(Decimal), exponent forms
688        // included: `str(1e16)` is "1e+16" (no "."), so the else branch feeds
689        // it to int() and raises ValueError, exactly as Python.
690        let n_full = match value {
691            FloatValue::Float { value, .. } => python_repr_f64(*value),
692            FloatValue::Decimal { value, .. } => python_str_decimal(value),
693        };
694        let n_full = n_full.trim();
695
696        // if n.startswith("-"): n = n[1:]; ret = self.negword  else: ret = ""
697        let (n, neg) = match n_full.strip_prefix('-') {
698            Some(rest) => (rest, true),
699            None => (n_full, false),
700        };
701
702        let mut ret = String::new();
703        if neg {
704            // `ret = self.negword` — "minus ", trailing space included and
705            // prepended raw (PS supplies no separator of its own).
706            ret.push_str(NEGWORD);
707        }
708
709        if let Some((left, right)) = n.split_once('.') {
710            // `ret += self._int_to_word(int(left)) + " " + pointword + " "` —
711            // `int_to_word`, so `left >= 10**9` stringifies to its numerals
712            // (bug 4), e.g. 98746251323029.
713            ret.push_str(&self.int_to_word(&py_int(left)?));
714            ret.push(' ');
715            ret.push_str(POINTWORD);
716            ret.push(' ');
717            // `for digit in right: ret += self._int_to_word(int(digit)) + " "`
718            // — `int('e')` (the exponent of "1.5e+16") raises ValueError.
719            for ch in right.chars() {
720                let mut buf = [0u8; 4];
721                let d = py_int(ch.encode_utf8(&mut buf))?;
722                ret.push_str(&self.int_to_word(&d));
723                ret.push(' ');
724            }
725            // `.strip()` — trims the trailing space left by the digit loop.
726            Ok(ret.trim().to_string())
727        } else {
728            // No "." → Python's else branch: `(ret + _int_to_word(int(n)))
729            // .strip()` — exponent forms ("1e+16", "1E+3") raise here.
730            ret.push_str(&self.int_to_word(&py_int(n)?));
731            Ok(ret.trim().to_string())
732        }
733    }
734
735    /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
736    /// float/Decimal through the `str(number)` algorithm, so a whole value
737    /// keeps its visible point: `5.0` -> "پنځه point zero", `-0.0` ->
738    /// "minus zero point zero", `Decimal("5.00")` -> "پنځه point zero zero".
739    /// The base default's whole-value integer shortcut must not fire here.
740    fn cardinal_float_entry(
741        &self,
742        value: &FloatValue,
743        precision_override: Option<u32>,
744    ) -> Result<String> {
745        self.to_cardinal_float(value, precision_override)
746    }
747
748    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-م"` — the
749    /// cardinal being the string algorithm above, suffix glued on raw.
750    /// Exponent forms raise ValueError before the suffix is appended.
751    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
752        Ok(format!(
753            "{}{}",
754            self.to_cardinal_float(value, None)?,
755            ORDINAL_SUFFIX
756        ))
757    }
758
759    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is
760    /// the binding's Python `str(value)`, so exponent forms echo verbatim:
761    /// `to_ordinal_num(1e16)` == "1e+16.".
762    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
763        Ok(format!("{}.", repr_str))
764    }
765
766    /// `to_year(float/Decimal)`: PS's `to_year` forwards to `to_cardinal`,
767    /// which for a float/Decimal is the string algorithm above.
768    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
769        self.to_cardinal_float(value, None)
770    }
771
772    /// Base's `str_to_number` parses "Infinity" *successfully*; PS's
773    /// ValueError comes later, from `int("Infinity")` inside `to_cardinal`.
774    /// The parse is the plain default and the two specials are served natively
775    /// by the [`Lang::inf_result`] / [`Lang::nan_result`] hooks below.
776    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
777        python_decimal_parse(s)
778    }
779
780    /// `Decimal("Infinity")` / `-Infinity`. PS's `to_cardinal` does
781    /// `int(str(number))`, and `str(Decimal("Infinity"))` is "Infinity" —
782    /// `int("Infinity")` raises **ValueError**, not the base path's
783    /// OverflowError. `-Infinity` strips its "-" textually first, so both signs
784    /// raise with the token "Infinity". Every mode reaches the same `int()`
785    /// (`to_ordinal`/`to_year` forward to `to_cardinal`), so `to` is unread.
786    fn inf_result(&self, _negative: bool, _to: &str) -> Result<String> {
787        Err(N2WError::Value(
788            "invalid literal for int() with base 10: 'Infinity'".into(),
789        ))
790    }
791
792    /// `Decimal("NaN")`. `str(Decimal("NaN"))` is "NaN"; `int("NaN")` raises
793    /// **ValueError** — the same type the base default gives, made byte-exact.
794    fn nan_result(&self, _to: &str) -> Result<String> {
795        Err(N2WError::Value(
796            "invalid literal for int() with base 10: 'NaN'".into(),
797        ))
798    }
799
800    // ---- currency ----------------------------------------------------
801    //
802    // Left at their trait defaults, each for a checked reason:
803    //
804    //   currency_precision  — PS inherits `Num2Word_Base.CURRENCY_PRECISION`,
805    //                         which is `{}`, so `.get(code, 100)` is 100 for
806    //                         every code. The default returns 100. (PS's own
807    //                         `to_currency` never reads it at all — bug 7 —
808    //                         but `to_cheque` does, and 100 is what it wants:
809    //                         "56/100" in the corpus.)
810    //   currency_adjective  — `CURRENCY_ADJECTIVES` is `{}`; nothing on this
811    //                         surface reads it and `adjective=True` is dropped.
812    //   money_verbose       — `Num2Word_Base._money_verbose` is
813    //                         `return self.to_cardinal(number)`, which is the
814    //                         default. Reached only via `to_cheque`.
815    //   cents_verbose /
816    //   cents_terse /
817    //   pluralize           — unreachable; PS's `to_currency` picks forms
818    //                         inline and `to_cheque` emits digits.
819    //   cardinal_from_decimal — no fractional-cents path exists here: PS
820    //                         truncates the cents field to two digits (bug 8),
821    //                         so `right` is always a whole 0..=99.
822    //   to_cheque           — inherited from `Num2Word_Base`, so
823    //                         `currency::default_to_cheque` already *is* the
824    //                         port. Traced below.
825
826    fn lang_name(&self) -> &str {
827        "Num2Word_PS"
828    }
829
830    /// `self.CURRENCY_FORMS[code]` — the **strict** lookup.
831    ///
832    /// This hook models Python's *subscript*, which is what
833    /// `Num2Word_Base.to_cheque` uses (`self.CURRENCY_FORMS[currency]` inside
834    /// a `try/except KeyError`). Returning `None` for an unknown code is what
835    /// makes `currency::default_to_cheque` raise the exact
836    /// `NotImplementedError` the corpus pins for
837    /// cheque:GBP/JPY/KWD/BHD/INR/CNY/CHF — message verified byte for byte
838    /// against the interpreter: `Currency code "GBP" not implemented for
839    /// "Num2Word_PS"`.
840    ///
841    /// [`LangPs::to_currency`] deliberately does **not** call this: PS's own
842    /// override uses `.get(code, <first value>)` and must never raise (bug 9).
843    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
844        self.currency_forms.get(code)
845    }
846
847    /// Port of `Num2Word_PS.to_currency`.
848    ///
849    /// ```python
850    /// def to_currency(self, val, currency="AFN", cents=True,
851    ///                 separator=" ", adjective=False):
852    ///     is_negative = False
853    ///     if val < 0:
854    ///         is_negative = True
855    ///         val = abs(val)
856    ///     parts = str(val).split(".")
857    ///     left = int(parts[0]) if parts[0] else 0
858    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
859    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
860    ///         currency, list(self.CURRENCY_FORMS.values())[0]
861    ///     )
862    ///     left_str = self._int_to_word(left)
863    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
864    ///     if cents and right:
865    ///         cents_str = self._int_to_word(right)
866    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
867    ///     if is_negative:
868    ///         result = self.negword + result
869    ///     return result.strip()
870    /// ```
871    ///
872    /// Shares nothing with `Num2Word_Base.to_currency`, so
873    /// `currency::default_to_currency` is bypassed entirely — no divisor, no
874    /// `isinstance(val, int)` branch, no `pluralize`, no
875    /// `parse_currency_parts`. See bugs 6-10.
876    ///
877    /// `adjective` is accepted and dropped on the floor exactly as Python does
878    /// — `CURRENCY_ADJECTIVES` is never read on this path, so `adjective=True`
879    /// changes nothing.
880    ///
881    /// `_int_to_word` is called with the already-`abs`'d `left`, so its
882    /// negative arm stays unreachable here and "minus " is prepended exactly
883    /// once, at the end.
884    fn to_currency(
885        &self,
886        val: &CurrencyValue,
887        currency: &str,
888        cents: bool,
889        separator: Option<&str>,
890        _adjective: bool,
891    ) -> Result<String> {
892        // Trait now hands us None when the caller omitted separator=;
893        // resolve it to this language's own default before the ported body.
894        let separator = separator.unwrap_or(self.default_separator());
895        // Restore PS's own `separator=" "` default; see SEPARATOR_UNSET.
896        let separator = if separator == SEPARATOR_UNSET {
897            PS_SEPARATOR
898        } else {
899            separator
900        };
901
902        let (left, right, is_negative) = Self::split_value(val);
903
904        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — bug 6.
905        // Bypasses the `currency_forms` hook on purpose; that one is strict
906        // because `to_cheque` needs it to be.
907        let forms = self.currency_forms.get(currency).unwrap_or(&self.afn);
908
909        // `left_str = self._int_to_word(left)` — note `_int_to_word`, not
910        // `to_cardinal`: identical for a non-negative int, but it means the
911        // >= 10^9 digit fallback (bug 4) applies to money too.
912        let left_str = self.int_to_word(&left);
913        let currency_word = Self::select_form(&left, &forms.unit);
914        let mut result = format!("{} {}", left_str, currency_word);
915
916        // `if cents and right:` — a zero `right` suppresses the segment, which
917        // is why the float 1.0 renders as a bare "یو euro" (bug 10) and why
918        // 0.001 renders as "zero euros" (bug 8).
919        if cents && right != 0 {
920            let right_big = BigInt::from(right);
921            let cents_str = self.int_to_word(&right_big);
922            let cents_word = Self::select_form(&right_big, &forms.subunit);
923            result.push_str(separator);
924            result.push_str(&cents_str);
925            result.push(' ');
926            result.push_str(&cents_word);
927        }
928
929        if is_negative {
930            // `result = self.negword + result` — "minus ", trailing space
931            // included. Prepended raw, with no separator of its own.
932            result.insert_str(0, NEGWORD);
933        }
934
935        // `.strip()`. A no-op for every reachable input — `_int_to_word` never
936        // returns "" (the `number == 0` guard intercepts the only index that
937        // holds an empty string), so nothing pads the ends. Kept for fidelity.
938        Ok(result.trim().to_string())
939    }
940}
941
942#[cfg(test)]
943mod float_tests {
944    use super::*;
945    use bigdecimal::BigDecimal;
946    use std::str::FromStr;
947
948    fn f(value: f64, precision: u32) -> String {
949        LangPs::new()
950            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
951            .unwrap()
952    }
953
954    fn d(s: &str) -> String {
955        let value = BigDecimal::from_str(s).unwrap();
956        let precision = (-value.as_bigint_and_exponent().1).unsigned_abs() as u32;
957        LangPs::new()
958            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
959            .unwrap()
960    }
961
962    // --- frozen corpus: `to == "cardinal"` with a dotted float `arg` ---
963    #[test]
964    fn corpus_float_rows() {
965        assert_eq!(f(0.0, 1), "zero point zero");
966        assert_eq!(f(0.5, 1), "zero point پنځه");
967        assert_eq!(f(1.0, 1), "یو point zero");
968        assert_eq!(f(1.5, 1), "یو point پنځه");
969        assert_eq!(f(2.25, 2), "دوه point دوه پنځه");
970        assert_eq!(f(3.14, 2), "درې point یو څلور");
971        assert_eq!(f(0.01, 2), "zero point zero یو");
972        assert_eq!(f(0.1, 1), "zero point یو");
973        assert_eq!(f(0.99, 2), "zero point نهه نهه");
974        assert_eq!(f(1.01, 2), "یو point zero یو");
975        assert_eq!(f(12.34, 2), "لس دوه point درې څلور");
976        assert_eq!(f(99.99, 2), "نوي نهه point نهه نهه");
977        assert_eq!(f(100.5, 1), "یو سل point پنځه");
978        assert_eq!(f(1234.56, 2), "یو زره دوه سل دېرش څلور point پنځه شپږ");
979        assert_eq!(f(-0.5, 1), "minus zero point پنځه");
980        assert_eq!(f(-1.5, 1), "minus یو point پنځه");
981        assert_eq!(f(-12.34, 2), "minus لس دوه point درې څلور");
982        // The two f64-artefact cases: PS reads `repr`, so the digits are the
983        // repr's, reconstructed by formatting to `precision` places.
984        assert_eq!(f(1.005, 3), "یو point zero zero پنځه");
985        assert_eq!(f(2.675, 3), "دوه point شپږ اووه پنځه");
986    }
987
988    // --- frozen corpus: `to == "cardinal_dec"` (Decimal input) ---
989    #[test]
990    fn corpus_decimal_rows() {
991        assert_eq!(d("0.01"), "zero point zero یو");
992        // Trailing zero survives: `str(Decimal("1.10")) == "1.10"`, fraction "10".
993        assert_eq!(d("1.10"), "یو point یو zero");
994        assert_eq!(d("12.345"), "لس دوه point درې څلور پنځه");
995        // Left part >= 10**9 stringifies to its numerals (bug 4).
996        assert_eq!(
997            d("98746251323029.99"),
998            "98746251323029 point نهه نهه"
999        );
1000        assert_eq!(d("0.001"), "zero point zero zero یو");
1001    }
1002
1003    // `str(-0.0) == "-0.0"`, so the sign is carried even though `-0.0 < 0.0`
1004    // is false: formatting the signed f64 preserves the sign bit.
1005    #[test]
1006    fn negative_zero_float() {
1007        assert_eq!(f(-0.0, 1), "minus zero point zero");
1008    }
1009
1010    // precision_override is dropped, exactly like Python (`precision=` leaves
1011    // PS's cardinal untouched because it renders `str(number)`).
1012    #[test]
1013    fn precision_override_ignored() {
1014        let v = FloatValue::Float {
1015            value: 2.675,
1016            precision: 3,
1017        };
1018        assert_eq!(
1019            LangPs::new().to_cardinal_float(&v, Some(1)).unwrap(),
1020            "دوه point شپږ اووه پنځه"
1021        );
1022        assert_eq!(
1023            LangPs::new().to_cardinal_float(&v, Some(5)).unwrap(),
1024            "دوه point شپږ اووه پنځه"
1025        );
1026    }
1027}