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}