Skip to main content

num2words2_core/
lang_pl.rs

1//! Port of `lang_PL.py` (Polish).
2//!
3//! Shape: **self-contained**. `Num2Word_PL` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives `_int2word` over 3-digit chunks. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check** — the only ceiling is the `THOUSANDS` table (see
9//! below), which raises `KeyError` rather than `OverflowError`.
10//!
11//! Inherited from `Num2Word_Base` (unchanged by PL, so the trait defaults do
12//! the right thing):
13//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`
14//!   * `to_year(value)        -> self.to_cardinal(value)` → default delegates
15//!     through `&self`, picking up the `to_cardinal` override below.
16//!
17//! # Faithfully reproduced Python bugs
18//!
19//! This is a port, not a rewrite. The following are all wrong-looking but are
20//! exactly what Python emits, verified against the interpreter:
21//!
22//! 1. `HUNDREDS` tuples hold **one** form, yet `last_fragment_to_ordinal`
23//!    indexes them with `level` (0 or 1). `HUNDREDS[n3][1]` therefore raises
24//!    `IndexError: tuple index out of range` — e.g. `to_ordinal(110000)`,
25//!    `to_ordinal(120000)`, `to_ordinal(210000)` all blow up. Modelled by
26//!    [`hundreds_at`].
27//! 2. The `else` arm of `last_fragment_to_ordinal` appends ordinal words but
28//!    `to_ordinal` then concatenates the level suffix **without a separator**,
29//!    so `to_ordinal(123000)` == "sto dwudziesty trzecitysięczny".
30//! 3. Four table typos, fixed upstream by PR #660 and adopted here:
31//!    `TWENTIES_ORDINALS[3][1]` "trzydiesto" → "trzydziesto",
32//!    `HUNDREDS_ORDINALS[8][1]` "ośiemset" → "osiemset",
33//!    `HUNDREDS_ORDINALS[5][1]` "pięcset" → "pięćset", and
34//!    `prefixes_ordinal[3]` "milairdowy" → "miliardowy" — so
35//!    `to_ordinal(10**9)` == "miliardowy".
36//! 4. `to_ordinal(0)` returned an `IndexError` upstream (the `while last == 0`
37//!    loop pops the only fragment, then indexes an empty list); PR #668 fixes
38//!    it to "zerowy", adopted here.
39//! 5. `to_ordinal(n)` for **every** negative `n` raises
40//!    `ValueError: invalid literal for int() with base 10: '-'`, because the
41//!    minus sign survives into either `splitbyx`'s head chunk or
42//!    `get_digits`'s `"%03d"` slice. `to_cardinal` is unaffected (it strips
43//!    the sign first), and `to_ordinal_num` is unaffected (it returns the
44//!    input untouched).
45//! 6. `to_currency`'s integer path second-guesses its own `pluralize`: any
46//!    value above 1 whose cardinal *ends with* "jeden" is forced to form 1
47//!    instead. Polish grammar wants form 2 there ("dwadzieścia jeden
48//!    złotych"), so Python emits the ungrammatical "dwadzieścia jeden
49//!    złote". See [`LangPl::to_currency`].
50//!
51//! # Error variants
52//!
53//! Polish raises three exception types that the four-way mapping in
54//! PORTING.md (Overflow/Type/NotImplemented/ZeroDivision) does not cover:
55//! `ValueError`, `IndexError` and `KeyError`. These map to the dedicated
56//! `N2WError::Value`/`Index`/`Key` variants in `base.rs`. They are Python
57//! crashes rather than deliberate raises, but the exception *type* is
58//! observable, so parity means reproducing it rather than tidying it into a
59//! `TypeError`. See [`value_error`], [`index_error`], [`key_error`].
60
61use crate::base::{Lang, N2WError, Result};
62use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
63use crate::floatpath::FloatValue;
64use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
65use num_bigint::BigInt;
66use num_integer::Integer;
67use num_traits::{FromPrimitive, One, Signed, Zero};
68use std::collections::HashMap;
69
70const ZERO: &str = "zero";
71const NEGWORD: &str = "minus";
72
73/// `ONES`; index 0 is absent in Python (guarded by `n1 > 0`).
74const ONES: [&str; 10] = [
75    "", "jeden", "dwa", "trzy", "cztery", "pięć", "sześć", "siedem", "osiem", "dziewięć",
76];
77
78/// `ONES_ORDINALS`, keys 1..=19, two forms each. Index 0 is absent in Python.
79const ONES_ORDINALS: [(&str, &str); 20] = [
80    ("", ""), // absent in Python
81    ("pierwszy", "pierwszo"),
82    ("drugi", "dwu"),
83    ("trzeci", "trzy"),
84    ("czwarty", "cztero"),
85    ("piąty", "pięcio"),
86    ("szósty", "sześcio"),
87    ("siódmy", "siedmio"),
88    ("ósmy", "ośmio"),
89    ("dziewiąty", "dziewięcio"),
90    ("dziesiąty", "dziesięcio"),
91    ("jedenasty", "jedenasto"),
92    ("dwunasty", "dwunasto"),
93    ("trzynasty", "trzynasto"),
94    ("czternasty", "czternasto"),
95    ("piętnasty", "piętnasto"),
96    ("szesnasty", "szesnasto"),
97    ("siedemnasty", "siedemnasto"),
98    ("osiemnasty", "osiemnasto"),
99    ("dziewiętnasty", "dziewiętnasto"),
100];
101
102/// `TENS`, keys 0..=9 (the 10..19 teens, keyed by the units digit).
103const TENS: [&str; 10] = [
104    "dziesięć",
105    "jedenaście",
106    "dwanaście",
107    "trzynaście",
108    "czternaście",
109    "piętnaście",
110    "szesnaście",
111    "siedemnaście",
112    "osiemnaście",
113    "dziewiętnaście",
114];
115
116/// `TWENTIES`, keys 2..=9. Indices 0/1 are absent in Python (guarded `n2 > 1`).
117const TWENTIES: [&str; 10] = [
118    "",
119    "",
120    "dwadzieścia",
121    "trzydzieści",
122    "czterdzieści",
123    "pięćdziesiąt",
124    "sześćdziesiąt",
125    "siedemdziesiąt",
126    "osiemdziesiąt",
127    "dziewięćdziesiąt",
128];
129
130/// `TWENTIES_ORDINALS`, keys 2..=9. Key 3's stem "trzydiesto" was a typo,
131/// fixed to "trzydziesto" by PR #660.
132const TWENTIES_ORDINALS: [(&str, &str); 10] = [
133    ("", ""), // absent in Python
134    ("", ""), // absent in Python
135    ("dwudziesty", "dwudziesto"),
136    ("trzydziesty", "trzydziesto"), // PR #660 fixed the "trzydiesto" typo
137    ("czterdziesty", "czterdziesto"),
138    ("pięćdziesiąty", "pięćdziesięcio"),
139    ("sześćdziesiąty", "sześćdziesięcio"),
140    ("siedemdziesiąty", "siedemdziesięcio"),
141    ("osiemdziesiąty", "osiemdziesięcio"),
142    ("dziewięćdziesiąty", "dziewięćdziesięcio"),
143];
144
145/// `HUNDREDS`, keys 1..=9. **One form only** — see bug 1 in the module docs.
146const HUNDREDS: [&str; 10] = [
147    "",
148    "sto",
149    "dwieście",
150    "trzysta",
151    "czterysta",
152    "pięćset",
153    "sześćset",
154    "siedemset",
155    "osiemset",
156    "dziewięćset",
157];
158
159/// `HUNDREDS_ORDINALS`, keys 1..=9. The "pięcset"/"ośiemset" stem typos were
160/// fixed to "pięćset"/"osiemset" by PR #660.
161const HUNDREDS_ORDINALS: [(&str, &str); 10] = [
162    ("", ""), // absent in Python
163    ("setny", "stu"),
164    ("dwusetny", "dwustu"),
165    ("trzysetny", "trzystu"),
166    ("czterysetny", "czterystu"),
167    ("pięćsetny", "pięćset"), // PR #660 fixed the "pięcset" typo
168    ("sześćsetny", "sześćset"),
169    ("siedemsetny", "siedemset"),
170    ("osiemsetny", "osiemset"), // PR #660 fixed the "ośiemset" typo
171    ("dziewięćsetny", "dziewięćset"),
172];
173
174/// `prefixes_ordinal`, keys 1..=3 only. Index 0 is absent in Python and
175/// unreachable (guarded by `level > 0`); index >= 4 is a `KeyError`.
176const PREFIXES_ORDINAL: [&str; 4] = [
177    "",
178    "tysięczny",
179    "milionowy",
180    "miliardowy", // PR #660 fixed the "milairdowy" typo
181];
182
183/// `prefixes` — the 10^(6*x) stems.
184const PREFIXES: [&str; 10] = [
185    "mi", "bi", "try", "kwadry", "kwinty", "seksty", "septy", "okty", "nony", "decy",
186];
187
188/// `suffixes` — 10^x / 10^(x+3).
189const SUFFIXES: [&str; 2] = ["lion", "liard"];
190
191// --- Python exception encoding -------------------------------------------
192//
193// N2WError models only Overflow/Type/NotImplemented/ZeroDivision. Polish also
194// raises ValueError, IndexError and KeyError. Encode the Python name in the
195// message so a later phase can remap without re-deriving the semantics.
196
197// These three mirror crashes in lang_PL.py, not deliberate raises: the
198// exception *type* is observable behaviour a caller may catch, so parity
199// requires reproducing it rather than tidying it into a TypeError.
200fn value_error(msg: String) -> N2WError {
201    N2WError::Value(msg)
202}
203
204fn index_error(msg: &str) -> N2WError {
205    N2WError::Index(msg.to_string())
206}
207
208fn key_error(key: String) -> N2WError {
209    N2WError::Key(key)
210}
211
212/// Python's `int(s)` for the digit-string shapes this module produces.
213/// `int("-")` and `int("")` raise `ValueError`; `int("001")` == 1.
214fn parse_int(s: &str) -> Result<BigInt> {
215    BigInt::parse_bytes(s.as_bytes(), 10).ok_or_else(|| {
216        value_error(format!(
217            "invalid literal for int() with base 10: '{}'",
218            s
219        ))
220    })
221}
222
223/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
224///
225/// Reproduces the negative-input hazard exactly: the head chunk `n[:start]`
226/// can be `"-"` alone (→ `ValueError`) or `"-d"` (→ a negative chunk that
227/// trips `get_digits` later).
228fn splitbyx(n: &str, x: usize) -> Result<Vec<BigInt>> {
229    let chars: Vec<char> = n.chars().collect();
230    let length = chars.len();
231    let slice = |i: usize, j: usize| -> String { chars[i..j.min(length)].iter().collect() };
232
233    let mut out: Vec<BigInt> = Vec::new();
234    if length > x {
235        let start = length % x;
236        if start > 0 {
237            out.push(parse_int(&slice(0, start))?);
238        }
239        let mut i = start;
240        while i < length {
241            out.push(parse_int(&slice(i, i + x))?);
242            i += x;
243        }
244    } else {
245        out.push(parse_int(n)?);
246    }
247    Ok(out)
248}
249
250/// Python's `"%03d" % n`: field width 3 **including** the sign.
251/// `-1` → `"-01"`, `-12` → `"-12"`, `-123` → `"-123"`, `1` → `"001"`.
252fn fmt_03(n: &BigInt) -> String {
253    let mag = n.abs().to_string();
254    if n.is_negative() {
255        let pad = 3usize.saturating_sub(1 + mag.len());
256        format!("-{}{}", "0".repeat(pad), mag)
257    } else {
258        let pad = 3usize.saturating_sub(mag.len());
259        format!("{}{}", "0".repeat(pad), mag)
260    }
261}
262
263/// Port of `utils.get_digits(n)`:
264/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
265/// (units, tens, hundreds).
266///
267/// For negative `n` the `'-'` survives into the slice and `int('-')` raises
268/// `ValueError` — this is the sole reason `to_ordinal` rejects negatives.
269fn get_digits(n: &BigInt) -> Result<[usize; 3]> {
270    let s = fmt_03(n);
271    let chars: Vec<char> = s.chars().collect();
272    // fmt_03 always yields >= 3 chars, so the [-3:] slice is total.
273    let tail = &chars[chars.len() - 3..];
274    let mut a = [0usize; 3];
275    for (k, c) in tail.iter().rev().enumerate() {
276        a[k] = c
277            .to_digit(10)
278            .ok_or_else(|| value_error(format!("invalid literal for int() with base 10: '{}'", c)))?
279            as usize;
280    }
281    Ok(a)
282}
283
284/// `HUNDREDS[n3][level]` — the 1-element tuple. `level == 1` is an
285/// `IndexError` in Python; see bug 1 in the module docs.
286fn hundreds_at(n3: usize, level: usize) -> Result<&'static str> {
287    if level != 0 {
288        return Err(index_error("tuple index out of range"));
289    }
290    HUNDREDS
291        .get(n3)
292        .filter(|_| n3 >= 1)
293        .copied()
294        .ok_or_else(|| key_error(n3.to_string()))
295}
296
297/// `HUNDREDS_ORDINALS[n3][level]` — 2-element tuples, keys 1..=9.
298fn hundreds_ordinals_at(n3: usize, level: usize) -> Result<&'static str> {
299    let t = HUNDREDS_ORDINALS
300        .get(n3)
301        .filter(|_| n3 >= 1)
302        .ok_or_else(|| key_error(n3.to_string()))?;
303    match level {
304        0 => Ok(t.0),
305        1 => Ok(t.1),
306        _ => Err(index_error("tuple index out of range")),
307    }
308}
309
310/// `ONES_ORDINALS[k][level]` — 2-element tuples, keys 1..=19.
311fn ones_ordinals_at(k: usize, level: usize) -> Result<&'static str> {
312    let t = ONES_ORDINALS
313        .get(k)
314        .filter(|_| k >= 1)
315        .ok_or_else(|| key_error(k.to_string()))?;
316    match level {
317        0 => Ok(t.0),
318        1 => Ok(t.1),
319        _ => Err(index_error("tuple index out of range")),
320    }
321}
322
323/// `TWENTIES_ORDINALS[n2][level]` — 2-element tuples, keys 2..=9.
324fn twenties_ordinals_at(n2: usize, level: usize) -> Result<&'static str> {
325    let t = TWENTIES_ORDINALS
326        .get(n2)
327        .filter(|_| n2 >= 2)
328        .ok_or_else(|| key_error(n2.to_string()))?;
329    match level {
330        0 => Ok(t.0),
331        1 => Ok(t.1),
332        _ => Err(index_error("tuple index out of range")),
333    }
334}
335
336/// `Num2Word_PL.CURRENCY_FORMS` — PL's **own** class-body dict.
337///
338/// This is the one place where the `lang_EUR.py` trap does *not* apply.
339/// `Num2Word_EN.__init__` rewrites EUR/GBP and adds ~24 codes by mutating
340/// `Num2Word_EUR.CURRENCY_FORMS` in place, and the 16 classes that inherit
341/// that dict see the result. `Num2Word_PL` subclasses `Num2Word_Base` and
342/// declares its own dict, so none of EN's edits reach it. Confirmed on the
343/// live interpreter after a full `import num2words2`:
344/// `CONVERTER_CLASSES["pl"].CURRENCY_FORMS` holds exactly these four codes,
345/// and it `is not` EUR's dict. Hence JPY/CHF/INR/CNY/KWD/BHD all raise
346/// NotImplementedError for Polish — which is what the corpus freezes.
347///
348/// Arity is load-bearing: every entry carries **three** forms on both sides
349/// because `Num2Word_PL.pluralize` indexes 0 (n == 1), 1 (2-4) or 2 (rest).
350/// Dropping the third form would silently turn "pięć złotych" into a panic
351/// or the wrong case.
352fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
353    const CENTS: [&str; 3] = ["cent", "centy", "centów"];
354
355    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
356    m.insert(
357        "PLN",
358        CurrencyForms::new(
359            &["złoty", "złote", "złotych"],
360            &["grosz", "grosze", "groszy"],
361        ),
362    );
363    m.insert("EUR", CurrencyForms::new(&["euro", "euro", "euro"], &CENTS));
364    m.insert(
365        "USD",
366        CurrencyForms::new(
367            &[
368                "dolar amerykański",
369                "dolary amerykańskie",
370                "dolarów amerykańskich",
371            ],
372            &CENTS,
373        ),
374    );
375    m.insert(
376        "GBP",
377        CurrencyForms::new(
378            &[
379                "funt brytyjski",
380                "funty brytyjskie",
381                "funtów brytyjskich",
382            ],
383            &["pens", "pensy", "pensów"],
384        ),
385    );
386    m
387}
388
389pub struct LangPl {
390    /// `THOUSANDS`: chunk index → the three plural forms. Keys 1..=21, i.e.
391    /// up to 1000^21 == 10^63 ("decyliard"). A chunk index of 22 or more is a
392    /// `KeyError`, which is Polish's de facto (and rather abrupt) MAXVAL.
393    thousands: HashMap<usize, [String; 3]>,
394    /// `CURRENCY_FORMS`, built once here rather than per `to_currency` call.
395    currency_forms: HashMap<&'static str, CurrencyForms>,
396}
397
398impl Default for LangPl {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404impl LangPl {
405    pub fn new() -> Self {
406        let mut thousands: HashMap<usize, [String; 3]> = HashMap::new();
407        thousands.insert(
408            1,
409            [
410                "tysiąc".to_string(),
411                "tysiące".to_string(),
412                "tysięcy".to_string(),
413            ],
414        );
415
416        // Python: `for idx, (p, s) in enumerate(itertools.product(prefixes, suffixes))`
417        // — product varies the *last* iterable fastest, so the order is
418        // (mi,lion), (mi,liard), (bi,lion), (bi,liard), ... giving
419        // THOUSANDS[2]="milion", [3]="miliard", [4]="bilion", ..., [21]="decyliard".
420        let mut idx = 0usize;
421        for p in PREFIXES.iter() {
422            for s in SUFFIXES.iter() {
423                let name = format!("{}{}", p, s);
424                thousands.insert(
425                    idx + 2,
426                    [name.clone(), format!("{}y", name), format!("{}ów", name)],
427                );
428                idx += 1;
429            }
430        }
431
432        LangPl {
433            thousands,
434            currency_forms: build_currency_forms(),
435        }
436    }
437
438    /// `THOUSANDS[i]`, raising `KeyError` past 21.
439    fn thousands_at(&self, i: usize) -> Result<&[String; 3]> {
440        self.thousands
441            .get(&i)
442            .ok_or_else(|| key_error(i.to_string()))
443    }
444
445    /// Port of `Num2Word_PL.pluralize`.
446    ///
447    /// ```python
448    /// if n == 1: form = 0
449    /// elif 5 > n % 10 > 1 and (n % 100 < 10 or n % 100 > 20): form = 1
450    /// else: form = 2
451    /// ```
452    /// `n` is a 3-digit chunk and always non-negative by the time this runs
453    /// (`get_digits` rejects negative chunks first), but `mod_floor` is used
454    /// anyway to keep Python's `%` semantics rather than Rust's `%`.
455    fn pluralize(&self, n: &BigInt, forms: &[String; 3]) -> String {
456        let form = if n.is_one() {
457            0usize
458        } else {
459            let m10 = n.mod_floor(&BigInt::from(10));
460            let m100 = n.mod_floor(&BigInt::from(100));
461            let chained = m10 < BigInt::from(5) && m10 > BigInt::one();
462            if chained && (m100 < BigInt::from(10) || m100 > BigInt::from(20)) {
463                1
464            } else {
465                2
466            }
467        };
468        forms[form].clone()
469    }
470
471    /// Port of `Num2Word_PL._int2word`. Called only with non-negative values
472    /// from `to_cardinal` (which strips the sign), but `to_ordinal`'s
473    /// `pre_part` can hand it a negative, which raises `ValueError` via
474    /// `get_digits` exactly as Python does.
475    fn int2word(&self, n: &BigInt) -> Result<String> {
476        if n.is_zero() {
477            return Ok(ZERO.to_string());
478        }
479
480        let mut words: Vec<String> = Vec::new();
481        let chunks = splitbyx(&n.to_string(), 3)?;
482        let mut i = chunks.len();
483        for x in chunks.iter() {
484            i -= 1;
485
486            if x.is_zero() {
487                continue;
488            }
489
490            let [n1, n2, n3] = get_digits(x)?;
491
492            if n3 > 0 {
493                words.push(HUNDREDS[n3].to_string());
494            }
495
496            if n2 > 1 {
497                words.push(TWENTIES[n2].to_string());
498            }
499
500            if n2 == 1 {
501                words.push(TENS[n1].to_string());
502            } else if n1 > 0 && !(i > 0 && x.is_one()) {
503                words.push(ONES[n1].to_string());
504            }
505
506            if i > 0 {
507                let forms = self.thousands_at(i)?;
508                words.push(self.pluralize(x, forms));
509            }
510        }
511
512        Ok(words.join(" "))
513    }
514
515    /// Port of `Num2Word_PL.last_fragment_to_ordinal`.
516    ///
517    /// `level` is 0 or 1 — `to_ordinal` passes `0 if level == 0 else 1`.
518    fn last_fragment_to_ordinal(
519        &self,
520        last: &BigInt,
521        words: &mut Vec<String>,
522        level: usize,
523    ) -> Result<()> {
524        let [n1, n2, n3] = get_digits(last)?;
525        let last_two = n2 * 10 + n1;
526
527        if last_two == 0 {
528            words.push(hundreds_ordinals_at(n3, level)?.to_string());
529        } else if level == 1 && last.is_one() {
530            return Ok(());
531        } else if last_two < 20 {
532            // NB: HUNDREDS has a single form — this is the IndexError path
533            // when level == 1 (e.g. to_ordinal(110000)).
534            if n3 > 0 {
535                words.push(hundreds_at(n3, level)?.to_string());
536            }
537            words.push(ones_ordinals_at(last_two, level)?.to_string());
538        } else if last_two % 10 == 0 {
539            // Same IndexError path when level == 1 (e.g. to_ordinal(120000)).
540            if n3 > 0 {
541                words.push(hundreds_at(n3, level)?.to_string());
542            }
543            words.push(twenties_ordinals_at(n2, level)?.to_string());
544        } else {
545            // This arm hardcodes form 0 regardless of `level`, which is why
546            // to_ordinal(123000) == "sto dwudziesty trzecitysięczny".
547            if n3 > 0 {
548                words.push(hundreds_at(n3, 0)?.to_string());
549            }
550            words.push(twenties_ordinals_at(n2, 0)?.to_string());
551            words.push(ones_ordinals_at(n1, 0)?.to_string());
552        }
553        Ok(())
554    }
555
556    /// The body of `Num2Word_PL.to_cardinal` after the whole-`float`
557    /// short-circuit, i.e. the string-driven decimal path:
558    ///
559    /// ```python
560    /// n = str(number).replace(",", ".")
561    /// if "." in n:
562    ///     is_negative = n.startswith("-")
563    ///     abs_n = n[1:] if is_negative else n
564    ///     left, right = abs_n.split(".")
565    ///     decimal_parts = []
566    ///     for digit in right:
567    ///         if digit == "0": decimal_parts.append(ZERO[0])
568    ///         else:            decimal_parts.append(ONES[int(digit)][0])
569    ///     decimal_part = " ".join(decimal_parts)
570    ///     result = "%s %s %s" % (self._int2word(int(left)),
571    ///                            self.pointword, decimal_part)
572    ///     if is_negative: result = self.negword + " " + result
573    ///     return result
574    /// else:
575    ///     is_negative = n.startswith("-")
576    ///     if is_negative:
577    ///         abs_n = n[1:]
578    ///         return self.negword + " " + self._int2word(int(abs_n))
579    ///     else:
580    ///         return self._int2word(int(n))
581    /// ```
582    ///
583    /// `n` here is already `str(number)` — the caller supplies the shortest
584    /// round-trip repr of an `f64` (`format!("{}", f)`) or the reconstructed
585    /// `str(Decimal)`. Both use `.` for the point, so the `,`→`.` replace is a
586    /// no-op kept only for fidelity.
587    ///
588    /// The `else` (no-dot) arm is reachable only from an integer `Decimal`
589    /// (precision 0); an `f64` that survives the whole-number short-circuit
590    /// always formats with a `.`.
591    fn cardinal_from_str(&self, number: &str) -> Result<String> {
592        let n = number.replace(',', ".");
593        if let Some(dot) = n.find('.') {
594            let is_negative = n.starts_with('-');
595            // abs_n = n[1:] if is_negative else n
596            let (abs_start, dot) = if is_negative { (1usize, dot) } else { (0usize, dot) };
597            let left = &n[abs_start..dot];
598            let right = &n[dot + 1..];
599
600            // decimal digits, spoken individually
601            let mut decimal_parts: Vec<&str> = Vec::new();
602            for ch in right.chars() {
603                if ch == '0' {
604                    decimal_parts.push(ZERO);
605                } else {
606                    // ONES[int(digit)][0]; digit is 1..=9 here (0 handled above).
607                    let d = ch.to_digit(10).ok_or_else(|| {
608                        value_error(format!(
609                            "invalid literal for int() with base 10: '{}'",
610                            ch
611                        ))
612                    })? as usize;
613                    decimal_parts.push(ONES[d]);
614                }
615            }
616            let decimal_part = decimal_parts.join(" ");
617
618            // self._int2word(int(left))
619            let left_word = self.int2word(&parse_int(left)?)?;
620            let result = format!("{} {} {}", left_word, self.pointword(), decimal_part);
621            if is_negative {
622                Ok(format!("{} {}", NEGWORD, result))
623            } else {
624                Ok(result)
625            }
626        } else {
627            let is_negative = n.starts_with('-');
628            if is_negative {
629                let abs_n = &n[1..];
630                Ok(format!("{} {}", NEGWORD, self.int2word(&parse_int(abs_n)?)?))
631            } else {
632                self.int2word(&parse_int(&n)?)
633            }
634        }
635    }
636}
637
638/// Python's `int(s)` acceptance for the strings PL's `to_ordinal` splits: an
639/// optional sign followed by ASCII digits. Anything else — a '.' or the 'E'
640/// of a scientific `str(Decimal)` — fails, which the callers turn into the
641/// ValueError `splitbyx`'s `int()` raises.
642fn is_plain_int_str(s: &str) -> bool {
643    let t = s.strip_prefix('-').unwrap_or(s);
644    !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit())
645}
646
647impl Lang for LangPl {
648    /// This language's own `to_currency(currency=...)` default,
649    /// read from the live Python signature. Only 44 of 156 use EUR.
650    fn default_currency(&self) -> &str {
651        "EUR"
652    }
653
654    /// This language's own `to_currency(separator=...)` default,
655    /// read from the live Python signature. Base's is ",", but only
656    /// 36 of 149 languages actually use it — most default to " " or a
657    /// conjunction, so inheriting Base's comma silently corrupts them.
658    fn default_separator(&self) -> &str {
659        ","
660    }
661
662    fn negword(&self) -> &str {
663        NEGWORD
664    }
665
666    fn pointword(&self) -> &str {
667        "przecinek"
668    }
669
670    /// Port of `Num2Word_PL.to_cardinal`, integer path only.
671    ///
672    /// Python stringifies the input and looks for `"."`; `str(int)` never
673    /// contains one, so integers always take the `else` branch. The float
674    /// branch (`pointword`, per-digit decimals) is out of scope.
675    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
676        if value.is_negative() {
677            // Python: abs_n = n[1:]; negword + " " + self._int2word(int(abs_n))
678            let result = self.int2word(&value.abs())?;
679            Ok(format!("{} {}", NEGWORD, result))
680        } else {
681            self.int2word(value)
682        }
683    }
684
685    /// Port of `Num2Word_PL.to_ordinal`.
686    ///
687    /// `if number % 1 != 0: raise NotImplementedError()` is unreachable for
688    /// integers, so it is not modelled. Raises `IndexError` for 0, `KeyError`
689    /// for level >= 4 (>= 10^12), and `ValueError` for every negative.
690    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
691        // PR savoirfairelinux/num2words#668: splitbyx("0") yields a single
692        // zero fragment; the pop loop below empties the list and then indexes
693        // it (IndexError). Handle zero explicitly.
694        if value.is_zero() {
695            return Ok("zerowy".to_string());
696        }
697        let mut words: Vec<String> = Vec::new();
698        let mut fragments = splitbyx(&value.to_string(), 3)?;
699
700        let mut level: usize = 0;
701        // fragments[-1] on an empty list → IndexError. splitbyx always yields
702        // at least one element, so only the pop loop below can empty it.
703        let mut last = fragments
704            .last()
705            .ok_or_else(|| index_error("list index out of range"))?
706            .clone();
707        while last.is_zero() {
708            level += 1;
709            fragments.pop();
710            last = fragments
711                .last()
712                .ok_or_else(|| index_error("list index out of range"))?
713                .clone();
714        }
715
716        if fragments.len() > 1 {
717            let pow = BigInt::from(1000).pow(level as u32);
718            let pre_part = self.int2word(&(value - &last * pow))?;
719            words.push(pre_part);
720        }
721
722        self.last_fragment_to_ordinal(&last, &mut words, if level == 0 { 0 } else { 1 })?;
723
724        let mut output = words.join(" ");
725        if last.is_one() && level > 0 && !output.is_empty() {
726            output.push(' ');
727        }
728        if level > 0 {
729            // prefixes_ordinal has keys 1..=3; level >= 4 → KeyError.
730            let p = PREFIXES_ORDINAL
731                .get(level)
732                .filter(|_| level >= 1)
733                .copied()
734                .ok_or_else(|| key_error(level.to_string()))?;
735            output.push_str(p);
736        }
737        Ok(output)
738    }
739
740    /// Float / `Decimal` cardinal path.
741    ///
742    /// PL overrides **`to_cardinal`** (not `to_cardinal_float`) and handles
743    /// non-integers inline off `str(number)`, so it never touches
744    /// `base.float2tuple`. Two consequences:
745    ///
746    /// * The f64-artefact and banker's-rounding traps of the base float path
747    ///   do **not** apply here: PL reads the *shortest repr digits*
748    ///   (`str(2.675) == "2.675"` → "sześć siedem pięć"), never
749    ///   `abs(value-pre)*10**precision`.
750    /// * `precision_override` (the `precision=` kwarg) is ignored, exactly as
751    ///   Python's `to_cardinal(self, number)` ignores it — verified on the
752    ///   interpreter: `num2words(1.5, lang="pl", precision=3)` is unchanged.
753    ///
754    /// ```python
755    /// def to_cardinal(self, number):
756    ///     if isinstance(number, float) and number == int(number):
757    ///         return self._int2word(int(number))
758    ///     n = str(number).replace(",", ".")
759    ///     ...
760    /// ```
761    ///
762    /// The whole-`float` short-circuit calls `_int2word(int(number))` **without
763    /// stripping the sign**, so a negative whole float (`-1.0`, `-9999.0`)
764    /// reproduces Python's `ValueError` via `get_digits` — do not route it
765    /// through the sign-stripping integer `to_cardinal`. A `Decimal` is not a
766    /// `float`, so it skips the short-circuit and always takes the string arm
767    /// (hence `Decimal("-5")` → "minus pięć", but `-5.0` → `ValueError`).
768    fn to_cardinal_float(
769        &self,
770        value: &FloatValue,
771        _precision_override: Option<u32>,
772    ) -> Result<String> {
773        match value {
774            FloatValue::Float { value: f, .. } => {
775                // isinstance(number, float) and number == int(number)
776                if *f == f.trunc() {
777                    let n = BigInt::from_f64(*f).ok_or_else(|| {
778                        // int(nan/inf) raises in Python too; unreachable for
779                        // the corpus. Kept as a ValueError rather than a panic.
780                        value_error(format!("cannot convert float {} to integer", f))
781                    })?;
782                    // Sign intentionally NOT stripped — mirrors _int2word(int(number)).
783                    return self.int2word(&n);
784                }
785                // n = str(number): Rust's `{}` is shortest round-trip, matching
786                // Python's `repr`/`str` over the finite non-exponential range PL
787                // is exercised on.
788                self.cardinal_from_str(&format!("{}", f))
789            }
790            FloatValue::Decimal { value: d, .. } => {
791                // n = str(number): the General-Decimal-Arithmetic string, so
792                // trailing zeros survive ("5.0" → "pięć przecinek zero") and
793                // scientific forms keep their 'E' ("1E+2") and die in int().
794                self.cardinal_from_str(&python_decimal_str(d))
795            }
796        }
797    }
798
799    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values
800    /// included. PL's `to_cardinal` must see every float/Decimal itself: the
801    /// whole-`float` short-circuit feeds `_int2word(int(number))` **without
802    /// stripping the sign** (so `-1.0` is ValueError while `1.0` is "jeden"),
803    /// and a whole `Decimal` is *not* a float, so it takes the string arm and
804    /// keeps its fractional field ("5.0" → "pięć przecinek zero"). The trait
805    /// default's `as_whole_int()` → integer-path shortcut would erase both.
806    fn cardinal_float_entry(
807        &self,
808        value: &FloatValue,
809        precision_override: Option<u32>,
810    ) -> Result<String> {
811        self.to_cardinal_float(value, precision_override)
812    }
813
814    /// `to_ordinal(float/Decimal)`.
815    ///
816    /// ```python
817    /// if number % 1 != 0:
818    ///     raise NotImplementedError()
819    /// fragments = list(splitbyx(str(number), 3))
820    /// ...
821    /// ```
822    ///
823    /// A fractional value raises NotImplementedError; a whole one is split
824    /// from `str(number)` — where any '.' or 'E' dies in `int()` with
825    /// ValueError. `repr(float)` always carries one or the other, so *every*
826    /// float is ValueError; only a fixed-notation whole `Decimal` reaches
827    /// the real ordinal path (negatives then die in `get_digits` exactly as
828    /// ints do).
829    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
830        if value.as_whole_int().is_none() {
831            // NotImplementedError. The binding treats NotImplemented as
832            // "fall back to the original Python", which re-raises the same
833            // NotImplementedError — observably identical either way.
834            return Err(N2WError::NotImplemented(String::new()));
835        }
836        match value {
837            FloatValue::Float { value, .. } => Err(value_error(format!(
838                "invalid literal for int() with base 10: '{}'",
839                value
840            ))),
841            FloatValue::Decimal { value: d, .. } => {
842                let s = python_decimal_str(d);
843                if is_plain_int_str(&s) {
844                    // The integer port reproduces IndexError for 0, KeyError
845                    // for level >= 4 and ValueError for negatives.
846                    self.to_ordinal(&d.with_scale(0).as_bigint_and_exponent().0)
847                } else {
848                    Err(value_error(format!(
849                        "invalid literal for int() with base 10: '{}'",
850                        s
851                    )))
852                }
853            }
854        }
855    }
856
857
858    /// `Decimal('-0.0')` per mode. `BigDecimal` cannot carry the sign, so the
859    /// binding cannot demote it to a signed-zero `Float` without losing the
860    /// negword — PL renders it through its own float grammar *with* the
861    /// negword instead of leaning on the Python fallback.
862    ///
863    /// Only cardinal and year need serving here: PL's `to_cardinal(Decimal)`
864    /// (which year delegates to) reads `str(number)` == "-0.0", strips the
865    /// sign textually, and speaks "minus zero przecinek zero". The other two
866    /// modes coincide with the demoted `Float{-0.0}` path and return `None`:
867    ///   * ordinal → `splitbyx("-0.0")` feeds `int("-")` → `ValueError`, which
868    ///     `ordinal_float_entry` already reproduces for `Float{-0.0}`;
869    ///   * ordinal_num → Base echoes `str(number)` == "-0.0", which the default
870    ///     `ordinal_num_float_entry` already returns from `repr_str`.
871    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
872        match to {
873            // to_year delegates to to_cardinal, so both render identically.
874            "cardinal" | "year" => Some(self.cardinal_from_str("-0.0")),
875            _ => None,
876        }
877    }
878
879    /// `converter.str_to_number` — Base's `Decimal(value)`.
880    /// `Decimal("Infinity")` parses fine in Python; the failure happens
881    /// *next*, inside PL's own `to_cardinal`: `str(number)` == "Infinity"
882    /// has no '.', so `int("Infinity")` raises **ValueError**. The binding
883    /// otherwise maps `ParsedNumber::Inf` to the base integer path's
884    /// OverflowError, so the ValueError must be raised here. (NaN needs no
885    /// interception: the binding's ValueError already matches.)
886    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
887        match python_decimal_parse(s)? {
888            ParsedNumber::Inf { negative } => Err(value_error(format!(
889                "invalid literal for int() with base 10: '{}Infinity'",
890                if negative { "-" } else { "" }
891            ))),
892            other => Ok(other),
893        }
894    }
895
896    // to_ordinal_num: PL does not override Num2Word_Base.to_ordinal_num,
897    // which returns the value unchanged → the trait default is correct.
898    //
899    // to_year: PL does not override Num2Word_Base.to_year, which delegates
900    // to to_cardinal — and the trait's `year_float_entry` default routes
901    // back through `cardinal_float_entry` above, so float/Decimal years
902    // pick up the same string-driven grammar ("5.0" → "pięć przecinek
903    // zero", "-1.0" → ValueError).
904
905    // ---- currency -------------------------------------------------------
906    //
907    // `Num2Word_PL` defines exactly three things on this surface:
908    // `CURRENCY_FORMS`, `pluralize` and a `to_currency` override for the
909    // integer path. Everything else — `to_cheque`, `_money_verbose`,
910    // `_cents_verbose`, `_cents_terse` — is inherited from `Num2Word_Base`
911    // untouched (verified with `"x" in type(c).__dict__` on the live class),
912    // so the trait defaults already mirror it.
913    //
914    // Deliberately NOT overridden:
915    //   * `currency_precision` — `Num2Word_PL.CURRENCY_PRECISION` *is*
916    //     `Num2Word_Base.CURRENCY_PRECISION`, and it is still `{}` after a
917    //     full import (EN *rebinds* rather than mutates it, so its 1000s do
918    //     not leak). Every code therefore uses the default divisor of 100,
919    //     including the 3-decimal ones — moot here, since PL implements no
920    //     KWD/BHD at all.
921    //   * `currency_adjective` — `CURRENCY_ADJECTIVES` is likewise Base's
922    //     empty dict, so `adjective=True` is accepted and has no effect.
923    //     PL's int path does not even consult it (see `to_currency`).
924
925    fn lang_name(&self) -> &str {
926        "Num2Word_PL"
927    }
928
929    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
930        self.currency_forms.get(code)
931    }
932
933    /// Port of `Num2Word_PL.pluralize`.
934    ///
935    /// ```python
936    /// if n == 1:
937    ///     form = 0
938    /// elif 5 > n % 10 > 1 and (n % 100 < 10 or n % 100 > 20):
939    ///     form = 1
940    /// else:
941    ///     form = 2
942    /// return forms[form]
943    /// ```
944    ///
945    /// `mod_floor` rather than `%`: Python's `%` floors on negatives. Every
946    /// reachable caller passes a non-negative `n` (`to_currency` takes `abs`,
947    /// `parse_currency_parts` returns `abs`), so the two agree today, but the
948    /// port matches the source's semantics rather than relying on that.
949    ///
950    /// Note this duplicates the rule in the inherent [`LangPl::pluralize`]
951    /// used by `int2word` for the THOUSANDS scale words. Python has one method
952    /// serving both call sites; the split here exists only because the
953    /// cardinal path takes a fixed `&[String; 3]` and is already frozen. The
954    /// unit test at the bottom of this file pins the two together.
955    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
956        let form = plural_form_index(n);
957        // `forms[form]` → IndexError if the tuple is short. All four PL
958        // entries carry three forms, so this is unreachable; modelled for
959        // fidelity rather than papered over with an unwrap.
960        forms
961            .get(form)
962            .cloned()
963            .ok_or_else(|| index_error("tuple index out of range"))
964    }
965
966    /// Port of `Num2Word_PL.to_currency`.
967    ///
968    /// ```python
969    /// def to_currency(self, val, currency="EUR", cents=True,
970    ///                 separator=",", adjective=False):
971    ///     if isinstance(val, int):
972    ///         try:
973    ///             cr1, cr2 = self.CURRENCY_FORMS[currency]
974    ///         except (KeyError, AttributeError):
975    ///             return super().to_currency(val, ...)
976    ///         minus_str = self.negword if val < 0 else ""
977    ///         abs_val = abs(val)
978    ///         money_str = self.to_cardinal(abs_val)
979    ///         if abs_val > 1 and money_str.endswith("jeden"):
980    ///             currency_str = cr1[1] if len(cr1) > 1 else cr1[0]
981    ///         else:
982    ///             currency_str = self.pluralize(abs_val, cr1)
983    ///         return ("%s %s %s" % (minus_str, money_str, currency_str)).strip()
984    ///     return super().to_currency(val, ...)
985    /// ```
986    ///
987    /// Only the *integer* path is PL's own; floats fall through to
988    /// `Num2Word_Base.to_currency` (i.e. [`default_to_currency`]) unchanged.
989    /// That split is why `CurrencyValue`'s Int/Decimal distinction cannot be
990    /// collapsed: `1` gives "jeden euro" while `1.0` gives "jeden euro, zero
991    /// centów".
992    ///
993    /// Two quirks are reproduced verbatim:
994    ///
995    /// 1. **The "jeden" override** (bug 6 in the module docs). `abs_val > 1`
996    ///    plus a cardinal ending in "jeden" forces form 1, overriding
997    ///    `pluralize`, which would have said form 2. So `to_currency(21,
998    ///    "USD")` is "dwadzieścia jeden dolary amerykańskie" where Polish
999    ///    wants "dolarów amerykańskich". It fires for 21/101/1001/10121/…,
1000    ///    all confirmed against the interpreter.
1001    /// 2. **`minus_str` is the bare negword**, not Base's
1002    ///    `"%s " % negword.strip()`. The `"%s %s %s"` join supplies the space
1003    ///    and the trailing `.strip()` eats the leading one when the value is
1004    ///    non-negative — so both branches still land on "minus X" / "X".
1005    ///
1006    /// `adjective` is threaded to `super()` but never applied on the int path,
1007    /// exactly as in Python. It makes no observable difference: PL's
1008    /// `CURRENCY_ADJECTIVES` is Base's empty dict.
1009    fn to_currency(
1010        &self,
1011        val: &CurrencyValue,
1012        currency: &str,
1013        cents: bool,
1014        separator: Option<&str>,
1015        adjective: bool,
1016    ) -> Result<String> {
1017        // The trait hands us None when the caller omitted `separator=`;
1018        // resolve it through this language's own default (Base's ",").
1019        let separator = separator.unwrap_or(self.default_separator());
1020
1021        if let CurrencyValue::Int(v) = val {
1022            // `except (KeyError, AttributeError): return super().to_currency(...)`.
1023            // super() then re-does the same lookup and raises
1024            // NotImplementedError, so an unknown code with an int argument
1025            // still reports `Currency code "XYZ" not implemented for
1026            // "Num2Word_PL"` — verified on the interpreter for `(5, "XYZ")`.
1027            let cr1 = match self.currency_forms.get(currency) {
1028                Some(forms) => &forms.unit,
1029                None => {
1030                    return default_to_currency(self, val, currency, cents, separator, adjective)
1031                }
1032            };
1033
1034            let minus_str = if v.is_negative() { NEGWORD } else { "" };
1035            let abs_val = v.abs();
1036            let money_str = self.to_cardinal(&abs_val)?;
1037
1038            let currency_str = if abs_val > BigInt::one() && money_str.ends_with("jeden") {
1039                // `cr1[1] if len(cr1) > 1 else cr1[0]` — the bare index on the
1040                // else arm is an IndexError for an empty tuple. Unreachable
1041                // for PL's table; transcribed rather than simplified.
1042                if cr1.len() > 1 {
1043                    cr1[1].clone()
1044                } else {
1045                    cr1.first()
1046                        .cloned()
1047                        .ok_or_else(|| index_error("tuple index out of range"))?
1048                }
1049            } else {
1050                // Fully qualified: the inherent `LangPl::pluralize` shadows the
1051                // trait method for `self.pluralize(..)`, and it takes a
1052                // `&[String; 3]` that a `&[String]` cannot coerce to.
1053                Lang::pluralize(self, &abs_val, cr1)?
1054            };
1055
1056            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1057                .trim()
1058                .to_string());
1059        }
1060
1061        // Floats/Decimals: `super().to_currency(...)` verbatim.
1062        default_to_currency(self, val, currency, cents, separator, adjective)
1063    }
1064}
1065
1066/// The form index `Num2Word_PL.pluralize` selects for `n`.
1067///
1068/// Split out so the trait `pluralize` and the test below share one expression
1069/// of the rule. The inherent [`LangPl::pluralize`] on the (frozen) cardinal
1070/// path states it independently; `plural_rule_matches_cardinal_path` asserts
1071/// they cannot drift.
1072fn plural_form_index(n: &BigInt) -> usize {
1073    if n.is_one() {
1074        return 0;
1075    }
1076    let m10 = n.mod_floor(&BigInt::from(10));
1077    let m100 = n.mod_floor(&BigInt::from(100));
1078    let chained = m10 < BigInt::from(5) && m10 > BigInt::one();
1079    if chained && (m100 < BigInt::from(10) || m100 > BigInt::from(20)) {
1080        1
1081    } else {
1082        2
1083    }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089    use bigdecimal::BigDecimal;
1090
1091    /// `Num2Word_PL.pluralize` is a single Python method serving both the
1092    /// cardinal path (THOUSANDS scale words) and the currency path. The port
1093    /// states the rule twice — the inherent method predates this change and is
1094    /// frozen — so pin the two together rather than trusting inspection.
1095    #[test]
1096    fn plural_rule_matches_cardinal_path() {
1097        let lang = LangPl::new();
1098        let forms = [
1099            "a".to_string(),
1100            "b".to_string(),
1101            "c".to_string(),
1102        ];
1103        for n in 0i64..=1000 {
1104            let n = BigInt::from(n);
1105            let via_cardinal = lang.pluralize(&n, &forms);
1106            let via_currency = Lang::pluralize(&lang, &n, &forms).unwrap();
1107            assert_eq!(via_cardinal, via_currency, "n = {}", n);
1108        }
1109    }
1110
1111    /// The three form buckets, spot-checked against the live interpreter:
1112    /// `1` → form 0, `2..=4` (outside the 11-20 teens) → form 1, rest → 2.
1113    #[test]
1114    fn plural_form_buckets() {
1115        let cases: &[(i64, usize)] = &[
1116            (0, 2),
1117            (1, 0),
1118            (2, 1),
1119            (4, 1),
1120            (5, 2),
1121            (11, 2),
1122            (12, 2),
1123            (14, 2),
1124            (21, 2),
1125            (22, 1),
1126            (25, 2),
1127            (100, 2),
1128            (1234, 1),
1129        ];
1130        for (n, want) in cases {
1131            assert_eq!(plural_form_index(&BigInt::from(*n)), *want, "n = {}", n);
1132        }
1133    }
1134
1135    fn f(lang: &LangPl, v: f64) -> Result<String> {
1136        // precision is ignored on the float arm; the binding derives it, but
1137        // PL's str-based path does not consult it. 0 is fine here.
1138        lang.to_cardinal_float(&FloatValue::Float { value: v, precision: 0 }, None)
1139    }
1140
1141    fn dec(lang: &LangPl, s: &str) -> Result<String> {
1142        let value: BigDecimal = s.parse().unwrap();
1143        let precision = match s.split_once('.') {
1144            Some((_, frac)) => frac.len() as u32,
1145            None => 0,
1146        };
1147        lang.to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1148    }
1149
1150    #[test]
1151    fn float_corpus() {
1152        let lang = LangPl::new();
1153        let cases: &[(f64, &str)] = &[
1154            (0.0, "zero"),
1155            (0.5, "zero przecinek pięć"),
1156            (1.0, "jeden"),
1157            (1.5, "jeden przecinek pięć"),
1158            (2.25, "dwa przecinek dwa pięć"),
1159            (3.14, "trzy przecinek jeden cztery"),
1160            (0.01, "zero przecinek zero jeden"),
1161            (0.1, "zero przecinek jeden"),
1162            (0.99, "zero przecinek dziewięć dziewięć"),
1163            (1.01, "jeden przecinek zero jeden"),
1164            (12.34, "dwanaście przecinek trzy cztery"),
1165            (99.99, "dziewięćdziesiąt dziewięć przecinek dziewięć dziewięć"),
1166            (100.5, "sto przecinek pięć"),
1167            (1234.56, "tysiąc dwieście trzydzieści cztery przecinek pięć sześć"),
1168            (-0.5, "minus zero przecinek pięć"),
1169            (-1.5, "minus jeden przecinek pięć"),
1170            (-12.34, "minus dwanaście przecinek trzy cztery"),
1171            // f64-artefact cases: PL reads shortest repr digits, not float2tuple.
1172            (1.005, "jeden przecinek zero zero pięć"),
1173            (2.675, "dwa przecinek sześć siedem pięć"),
1174        ];
1175        for (v, want) in cases {
1176            assert_eq!(&f(&lang, *v).unwrap(), want, "v = {}", v);
1177        }
1178    }
1179
1180    #[test]
1181    fn whole_floats_and_negatives() {
1182        let lang = LangPl::new();
1183        // Whole floats short-circuit to _int2word(int(number)).
1184        assert_eq!(f(&lang, 0.0).unwrap(), "zero");
1185        assert_eq!(f(&lang, 1.0).unwrap(), "jeden");
1186        assert_eq!(f(&lang, 100.0).unwrap(), "sto");
1187        assert_eq!(
1188            f(&lang, 9999.0).unwrap(),
1189            "dziewięć tysięcy dziewięćset dziewięćdziesiąt dziewięć"
1190        );
1191        assert_eq!(f(&lang, 1e16).unwrap(), "dziesięć biliardów");
1192        assert_eq!(f(&lang, 1e21).unwrap(), "tryliard");
1193        // 1e23 is not exactly 10^23; int(1e23) == 99999999999999991611392,
1194        // so from_f64 must be exact, not a shortest-repr string parse.
1195        assert_eq!(
1196            f(&lang, 1e23).unwrap(),
1197            "dziewięćdziesiąt dziewięć tryliardów dziewięćset dziewięćdziesiąt \
1198             dziewięć trylionów dziewięćset dziewięćdziesiąt dziewięć biliardów \
1199             dziewięćset dziewięćdziesiąt dziewięć bilionów dziewięćset \
1200             dziewięćdziesiąt dziewięć miliardów dziewięćset dziewięćdziesiąt \
1201             jeden milionów sześćset jedenaście tysięcy trzysta \
1202             dziewięćdziesiąt dwa"
1203        );
1204        // Negative whole floats reproduce Python's ValueError (int(-1.0) fed
1205        // to _int2word → get_digits sees '-').
1206        for v in [-1.0, -5.0, -9999.0] {
1207            match f(&lang, v) {
1208                Err(N2WError::Value(m)) => {
1209                    assert_eq!(m, "invalid literal for int() with base 10: '-'")
1210                }
1211                other => panic!("v = {} expected ValueError, got {:?}", v, other),
1212            }
1213        }
1214    }
1215
1216    #[test]
1217    fn decimal_corpus() {
1218        let lang = LangPl::new();
1219        let cases: &[(&str, &str)] = &[
1220            ("0.01", "zero przecinek zero jeden"),
1221            ("1.10", "jeden przecinek jeden zero"),
1222            ("12.345", "dwanaście przecinek trzy cztery pięć"),
1223            (
1224                "98746251323029.99",
1225                "dziewięćdziesiąt osiem bilionów siedemset czterdzieści sześć \
1226                 miliardów dwieście pięćdziesiąt jeden milionów trzysta \
1227                 dwadzieścia trzy tysiące dwadzieścia dziewięć przecinek \
1228                 dziewięć dziewięć",
1229            ),
1230            ("0.001", "zero przecinek zero zero jeden"),
1231            // Integer Decimals take the no-dot arm; the sign is stripped there,
1232            // unlike a negative whole float.
1233            ("5", "pięć"),
1234            ("-5", "minus pięć"),
1235            ("0", "zero"),
1236            ("5.0", "pięć przecinek zero"),
1237            ("1.00", "jeden przecinek zero zero"),
1238            ("0.0", "zero przecinek zero"),
1239            ("0.00", "zero przecinek zero zero"),
1240            ("0.99", "zero przecinek dziewięć dziewięć"),
1241            ("-12.34", "minus dwanaście przecinek trzy cztery"),
1242        ];
1243        for (s, want) in cases {
1244            assert_eq!(&dec(&lang, s).unwrap(), want, "dec = {}", s);
1245        }
1246    }
1247}