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(¢s_str);
925 result.push(' ');
926 result.push_str(¢s_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}