num2words2_core/lang_sl.rs
1//! Port of `lang_SL.py` (Slovenian) via its `lang_EUR` → `Num2Word_Base` ancestry.
2//!
3//! Registry check: `__init__.py` maps `"sl"` to `lang_SL.Num2Word_SL()`, so this
4//! is the right class. Note the registry holds a *module-level singleton*, which
5//! matters for `ordflag` — see "Cross-call mutable state" below.
6//!
7//! Shape: **engine**. SL supplies `mid_numwords`/`low_numwords` and inherits
8//! `set_high_numwords` from `Num2Word_EUR` (long scale, step -6, pairing
9//! `GIGA_SUFFIX = "ilijard"` at 10^n with `MEGA_SUFFIX = "ilijon"` at 10^(n-3)).
10//! `Num2Word_Base.to_cardinal` drives `splitnum`/`clean`/`merge`.
11//!
12//! Card table: 100 high words ("cent" + the generated Latin stems) zipped with
13//! `range(603, 3, -6)`, giving 10^603 "centilijard" … 10^9 "milijard" and
14//! 10^600 "centilijon" … 10^6 "milijon". `MAXVAL = 1000 * 10^603 = 10^606`, so
15//! values genuinely reach BigInt range — never narrow to a fixed-width int.
16//! Python takes `list(cards.keys())[0]` (first *inserted*); insertion order here
17//! is strictly descending, so `Cards::highest()` picks the same key.
18//!
19//! # Cross-call mutable state (IMPORTANT — flagged for the dispatcher)
20//!
21//! `Num2Word_SL` carries `self.ordflag`, set in `to_ordinal`, consumed by
22//! `merge`, and cleared afterwards. Because the converter is a shared singleton
23//! and `to_ordinal` does **not** clear the flag on an exception path
24//! (`ordflag = True` → `to_cardinal` raises OverflowError → `ordflag = False` is
25//! never reached), a single `to_ordinal(10**606)` permanently poisons the
26//! instance and every later `to_cardinal` silently renders in ordinal mode.
27//! This Rust port is stateless: `merge` takes `ordflag` as a parameter, the
28//! cardinal path always passes `false`, and the ordinal path runs the same
29//! engine through `SlOrdinal` with `true`. The leak is therefore NOT reproduced
30//! (it is unreachable in-process here), and the Python dispatcher must be taught
31//! not to depend on it.
32//!
33//! # Faithfully reproduced Python quirks
34//!
35//! - `to_ordinal(0)` is not in the `ordinals` table, so it falls through to the
36//! flag path and yields the non-word "niči" (cardinal "nič" + "i").
37//! - `merge`'s 10^6 `else` arm tests `ntext.endswith("d")` twice; the second
38//! (`ntext += "e"`) is dead code, shadowed by the first (`ntext += "a"`).
39//! Kept verbatim.
40//! - The `2 < cnum < 5` arm's `elif not ntext.endswith("d")` is a redundant
41//! negation of the branch it already failed. Kept verbatim.
42//! - `if cnum == 1: return next` returns the *original* right tuple, discarding
43//! any `ctext`/`ntext` edits made above it (e.g. the "ena" → "en" trim at
44//! `nnum >= 1000`). Mirrored by returning `(rtext, rnum)` untouched.
45//! - `to_ordinal` calls `verify_ordinal` twice (harmless, kept).
46//! - `to_ordinal_num` carries a literal `# Is this correct??` comment upstream;
47//! it just appends ".".
48//!
49//! Python `len()`/slicing on `str` counts *characters*, and Slovenian numwords
50//! contain multi-byte č/š/ž. Every trim here goes through `drop_last_char`
51//! (char-wise) or `strip_suffix` (exact-suffix, byte-safe), never byte offsets.
52
53use crate::base::{
54 default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
55};
56use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
57use crate::floatpath::{float2tuple, FloatValue};
58use bigdecimal::BigDecimal;
59use num_bigint::BigInt;
60use num_integer::Integer;
61use num_traits::{One, Signed, ToPrimitive, Zero};
62use std::collections::HashMap;
63use std::sync::OnceLock;
64
65/// Port of `Num2Word_EUR.gen_high_numwords`.
66///
67/// Duplicated locally rather than imported from `lang_en` so this file stays
68/// self-contained (the registry is generated mechanically; no cross-language
69/// module dependency).
70fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
71 // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
72 let mut out: Vec<String> = Vec::new();
73 for t in tens {
74 for u in units {
75 out.push(format!("{}{}", u, t));
76 }
77 }
78 out.reverse();
79
80 const REPLACEMENTS: &[(&str, &str)] = &[
81 ("novemn", "noven"),
82 ("novemo", "novo"),
83 ("octoo", "octo"),
84 ("quintd", "quind"),
85 ("quintn", "quin"),
86 ("quintq", "quinq"),
87 ("quints", "quins"),
88 ("quintt", "quint"),
89 ("quintv", "quinv"),
90 ("septenn", "septen"),
91 ("septent", "sept"),
92 ("sexn", "sen"),
93 ("sexs", "ses"),
94 ("tresd", "tred"),
95 ("tresn", "tren"),
96 ("tress", "tres"),
97 ("tresv", "trev"),
98 ("unno", "uno"),
99 ];
100 for (k, v) in REPLACEMENTS {
101 out = out.iter().map(|o| o.replace(k, v)).collect();
102 }
103 out.extend(lows.iter().map(|s| s.to_string()));
104 out
105}
106
107/// Python `s[:-1]` / `s[: len(s) - 1]` — drop the last **character**.
108fn drop_last_char(s: &str) -> String {
109 let mut it = s.chars();
110 it.next_back();
111 it.as_str().to_string()
112}
113
114fn pow10(n: u32) -> BigInt {
115 BigInt::from(10u8).pow(n)
116}
117
118/// `abs(Decimal(str(f)).as_tuple().exponent)` for an f64 — the fractional-digit
119/// count of its shortest round-trip repr.
120///
121/// A local copy of `floatpath`'s private helper (SL's `to_cardinal_float`
122/// recomputes `self.precision` inside `float2tuple(float(value))`, so it needs
123/// this reduction rather than the repr-derived precision the dispatcher carried
124/// in the `FloatValue`). Rust's `{}` for f64 is shortest-round-trip like
125/// Python's `repr`, so counting the digits after the point matches. Only
126/// non-integral values reach SL's float path — integral floats (`1.0`) take the
127/// integer branch of `Num2Word_Base.to_cardinal` — so the exponent-form arm is
128/// defensive only.
129fn float_repr_precision(f: f64) -> u32 {
130 let s = format!("{}", f);
131 match s.split_once('.') {
132 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
133 _ => 0,
134 }
135}
136
137/// Best-effort Python `str(value)` for the verify_ordinal TypeError
138/// messages. Only the exception *type* is corpus-observable; the exact
139/// digits ride along for debuggability. `str(Decimal)` is exact via
140/// `python_decimal_str`; a whole float displays as Rust would ("5" for 5.0),
141/// which is close enough for an error string.
142fn sl_float_repr(value: &FloatValue) -> String {
143 match value {
144 FloatValue::Float { value, .. } => format!("{}", value),
145 FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
146 }
147}
148
149/// `Num2Word_SL.CURRENCY_FORMS` — note the **3-tuple** shape.
150///
151/// SL shadows `Num2Word_EUR.CURRENCY_FORMS` with its own class-body dict, so
152/// none of the EUR/EN runtime mutation described in PORTING_CURRENCY.md reaches
153/// it: `Num2Word_EN.__init__` writes into `Num2Word_EUR`'s dict, which SL never
154/// reads. Verified against the live interpreter — SL's table is exactly these
155/// two codes, and every other code raises NotImplementedError.
156///
157/// Each entry is `(unit_forms, subunit_forms, cur_separator)`: a third element
158/// that no other class in the library carries. `CurrencyForms` models only the
159/// first two, so the separator rides alongside it in the map value. Its value
160/// (`""` for both codes) is load-bearing twice:
161///
162/// * `to_currency` branches on `if cur_separator:` — empty means the cents
163/// segment is joined by a plain space, never by the `separator=" in"` kwarg.
164/// * `Num2Word_Base.to_cheque` unpacks the entry into **two** names, so the
165/// third element makes every implemented code raise ValueError.
166///
167/// Four unit forms per code, indexed 0..=3 by `pluralize`, so the arity is
168/// load-bearing (dropping one turns "pet evrov" into an IndexError).
169fn build_currency_forms() -> HashMap<&'static str, (CurrencyForms, &'static str)> {
170 const CENTS: [&str; 4] = ["cent", "centa", "cente", "centov"];
171
172 let mut m: HashMap<&'static str, (CurrencyForms, &'static str)> = HashMap::new();
173 m.insert(
174 "EUR",
175 (
176 CurrencyForms::new(&["evro", "evra", "evre", "evrov"], &CENTS),
177 "",
178 ),
179 );
180 m.insert(
181 "USD",
182 (
183 CurrencyForms::new(&["dolar", "dolarja", "dolarje", "dolarjev"], &CENTS),
184 "",
185 ),
186 );
187 m
188}
189
190/// `Num2Word_SL.pluralize`'s index selection, over the `n % 100` residue.
191///
192/// ```python
193/// if n % 100 == 1: return forms[0]
194/// elif n % 100 == 2: return forms[1]
195/// elif n % 100 in [3,4]: return forms[2]
196/// else: return forms[3]
197/// ```
198///
199/// The residue is a `BigDecimal` because Python has two callers with two types:
200/// the `pluralize` hook is handed an `int`, while `to_currency`'s
201/// fractional-cents branch hands it a `Decimal`. Python compares both against
202/// the same literals — `Decimal("1.00") == 1` is True, and `BigDecimal`'s
203/// `PartialEq` is likewise numeric rather than representational, so one
204/// comparison ladder serves both.
205///
206/// The 1/2/3/4 literals are interned rather than rebuilt per comparison: this
207/// runs on every `to_currency` call, twice.
208fn plural_form_index(rem: &BigDecimal) -> usize {
209 static SMALL: OnceLock<[BigDecimal; 4]> = OnceLock::new();
210 let s = SMALL.get_or_init(|| {
211 [
212 BigDecimal::from(1),
213 BigDecimal::from(2),
214 BigDecimal::from(3),
215 BigDecimal::from(4),
216 ]
217 });
218 if *rem == s[0] {
219 0
220 } else if *rem == s[1] {
221 1
222 } else if *rem == s[2] || *rem == s[3] {
223 2
224 } else {
225 3
226 }
227}
228
229/// `forms[plural_form_index(rem)]`.
230///
231/// Python indexes the tuple directly, so a form list shorter than the selected
232/// index raises IndexError. Both SL entries carry four forms, so that is
233/// unreachable — but it is mapped to `Index` rather than panicking so the
234/// exception type survives if the table ever changes.
235fn pick_form(rem: &BigDecimal, forms: &[String]) -> Result<String> {
236 forms
237 .get(plural_form_index(rem))
238 .cloned()
239 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
240}
241
242/// Port of `Num2Word_SL.merge`, with `self.ordflag` lifted to a parameter.
243fn sl_merge(l: (&str, &BigInt), r: (&str, &BigInt), ordflag: bool) -> (String, BigInt) {
244 // Python: ctext, cnum, ntext, nnum = curr + next
245 let (ltext, cnum) = l;
246 let (rtext, nnum) = r;
247 let mut ctext = ltext.to_string();
248 let mut ntext = rtext.to_string();
249
250 let m6 = pow10(6); // 10**6 / 1000000
251 let m9 = pow10(9); // 1000000000
252 let c2 = BigInt::from(2u8);
253 let c5 = BigInt::from(5u8);
254 let c10 = BigInt::from(10u8);
255 let c20 = BigInt::from(20u8);
256 let c100 = BigInt::from(100u8); // 10**2
257 let c1000 = BigInt::from(1000u16);
258
259 if ctext.ends_with("dve") && ordflag && *nnum <= m6 {
260 // "dve" -> "dva"
261 ctext = format!("{}a", drop_last_char(&ctext));
262 }
263
264 if ctext == "dve" && !ordflag && *nnum < m9 {
265 ctext = "dva".to_string();
266 }
267
268 if (ctext.ends_with("tri") || ctext.ends_with("štiri")) && *nnum == m6 && !ordflag {
269 if ctext.ends_with("štiri") {
270 // "štiri" -> "štir" (char-wise: š is 2 bytes)
271 ctext = drop_last_char(&ctext);
272 }
273 ctext.push_str("je"); // "trije" / "štirje"
274 }
275
276 if *cnum >= c20 && *cnum < c100 && *nnum == c2 {
277 ntext = "dva".to_string();
278 }
279
280 if ctext.ends_with("ena") && *nnum >= c1000 {
281 // "ena" -> "en"
282 ctext = drop_last_char(&ctext);
283 }
284
285 if cnum.is_one() {
286 if *nnum < m6 || ordflag {
287 // Python `return next`: the ORIGINAL right tuple — any ctext/ntext
288 // edits above are discarded.
289 return (rtext.to_string(), nnum.clone());
290 }
291 ctext = String::new();
292 }
293
294 let val: BigInt;
295 if nnum > cnum {
296 if *nnum >= m6 {
297 if ordflag {
298 ntext.push('t');
299 } else if *cnum == c2 {
300 if ntext.ends_with('d') {
301 ntext.push('i');
302 } else {
303 ntext.push('a');
304 }
305 } else if *cnum > c2 && *cnum < c5 {
306 if ntext.ends_with('d') {
307 ntext.push('e');
308 } else if !ntext.ends_with('d') {
309 // Redundant negation upstream; kept verbatim.
310 ntext.push('i');
311 }
312 } else if ctext.ends_with("en") {
313 if ntext.ends_with('d') || ntext.ends_with('n') {
314 // Python: ntext += "" — deliberate no-op.
315 }
316 } else if ctext.ends_with("dve") && ntext.ends_with('n') {
317 ctext = format!("{}a", drop_last_char(&ctext));
318 ntext.push('a');
319 } else if ctext.ends_with("je") && ntext.ends_with('n') {
320 ntext.push('i');
321 } else if ntext.ends_with('d') {
322 ntext.push('a');
323 } else if ntext.ends_with('n') {
324 // Python: ntext += "" — deliberate no-op.
325 } else if ntext.ends_with('d') {
326 // Dead branch upstream (shadowed by the `+= "a"` arm above).
327 ntext.push('e');
328 } else {
329 ntext.push_str("ov");
330 }
331 }
332
333 if *nnum >= c100 && !ordflag && !ctext.is_empty() {
334 ctext.push(' ');
335 }
336
337 val = cnum * nnum;
338 } else {
339 if *nnum < c10 && c10 < *cnum && *cnum < c100 {
340 // Python: ntext, ctext = ctext, ntext + "in" (RHS evaluated first)
341 let old_ctext = ctext;
342 ctext = format!("{}in", ntext);
343 ntext = old_ctext;
344 } else if *cnum >= c100 && !ordflag {
345 ctext.push(' ');
346 }
347 val = cnum + nnum;
348 }
349
350 (format!("{}{}", ctext, ntext), val)
351}
352
353/// The `Num2Word_SL.to_ordinal` "flag method": the identical engine, run with
354/// `ordflag = True`. Borrows `LangSl`'s tables so the 10^603 card build isn't
355/// repeated.
356struct SlOrdinal<'a> {
357 cards: &'a Cards,
358 maxval: &'a BigInt,
359}
360
361impl Lang for SlOrdinal<'_> {
362 fn cards(&self) -> &Cards {
363 self.cards
364 }
365 fn maxval(&self) -> &BigInt {
366 self.maxval
367 }
368 fn negword(&self) -> &str {
369 "minus "
370 }
371 fn pointword(&self) -> &str {
372 "vejica"
373 }
374 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
375 sl_merge(l, r, true)
376 }
377}
378
379pub struct LangSl {
380 cards: Cards,
381 maxval: BigInt,
382 /// `self.ords`, in Python dict **insertion order** — `to_ordinal` breaks on
383 /// the first suffix hit, so the order is load-bearing.
384 ords: Vec<(&'static str, &'static str)>,
385 /// `CURRENCY_FORMS`, built once here rather than per `to_currency` call.
386 /// Value is `(forms, cur_separator)` — see `build_currency_forms`.
387 currency_forms: HashMap<&'static str, (CurrencyForms, &'static str)>,
388}
389
390impl Default for LangSl {
391 fn default() -> Self {
392 Self::new()
393 }
394}
395
396impl LangSl {
397 pub fn new() -> Self {
398 // Num2Word_EUR.setup()
399 let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
400 let units = [
401 "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
402 ];
403 let tens = [
404 "dec",
405 "vigint",
406 "trigint",
407 "quadragint",
408 "quinquagint",
409 "sexagint",
410 "septuagint",
411 "octogint",
412 "nonagint",
413 ];
414 let mut high = vec!["cent".to_string()];
415 high.extend(gen_high_numwords(&units, &tens, &lows));
416
417 let mut cards = Cards::new();
418
419 // Num2Word_EUR.set_high_numwords: cap = 3 + 6*len(high) = 603;
420 // zip(high, range(cap, 3, -6)) — 100 words, 100 exponents (603..=9).
421 // SL: GIGA_SUFFIX = "ilijard" at 10^n, MEGA_SUFFIX = "ilijon" at 10^(n-3).
422 let cap: i64 = 3 + 6 * high.len() as i64;
423 let mut n = cap;
424 for word in high.iter() {
425 if n <= 3 {
426 break; // range() exhausted; zip() stops at the shorter sequence.
427 }
428 cards.insert(pow10(n as u32), format!("{}ilijard", word));
429 cards.insert(pow10((n - 3) as u32), format!("{}ilijon", word));
430 n -= 6;
431 }
432
433 set_mid_numwords(
434 &mut cards,
435 &[
436 (1000, "tisoč"),
437 (900, "devetsto"),
438 (800, "osemsto"),
439 (700, "sedemsto"),
440 (600, "šeststo"),
441 (500, "petsto"),
442 (400, "štiristo"),
443 (300, "tristo"),
444 (200, "dvesto"),
445 (100, "sto"),
446 (90, "devetdeset"),
447 (80, "osemdeset"),
448 (70, "sedemdeset"),
449 (60, "šestdeset"),
450 (50, "petdeset"),
451 (40, "štirideset"),
452 (30, "trideset"),
453 ],
454 );
455 set_low_numwords(
456 &mut cards,
457 &[
458 "dvajset",
459 "devetnajst",
460 "osemnajst",
461 "sedemnajst",
462 "šestnajst",
463 "petnajst",
464 "štirinajst",
465 "trinajst",
466 "dvanajst",
467 "enajst",
468 "deset",
469 "devet",
470 "osem",
471 "sedem",
472 "šest",
473 "pet",
474 "štiri",
475 "tri",
476 "dve",
477 "ena",
478 "nič",
479 ],
480 );
481
482 // MAXVAL = 1000 * list(cards.keys())[0] = 1000 * 10**603 = 10**606.
483 // Insertion order is strictly descending here, so first == highest.
484 let maxval = cards.highest().cloned().unwrap_or_else(|| BigInt::from(0u8)) * 1000;
485
486 LangSl {
487 cards,
488 maxval,
489 ords: vec![
490 ("ena", "prv"),
491 ("dve", "drug"),
492 ("tri", "tretj"),
493 ("štiri", "četrt"),
494 ("sedem", "sedm"),
495 ("osem", "osm"),
496 ("sto", "stot"),
497 ("tisoč", "tisoč"),
498 ("milijon", "milijont"),
499 ],
500 // Built once, never per call: `to_currency` only ever reads it.
501 currency_forms: build_currency_forms(),
502 }
503 }
504
505 /// `self.CURRENCY_FORMS[currency]` with Python's `except KeyError` arm.
506 ///
507 /// Both `to_currency` and `Num2Word_Base.to_cheque` wrap the subscript in
508 /// the same try/except and raise the same NotImplementedError, so the
509 /// lookup and its error live in one place.
510 fn lookup_currency(&self, currency: &str) -> Result<&(CurrencyForms, &'static str)> {
511 self.currency_forms.get(currency).ok_or_else(|| {
512 N2WError::NotImplemented(format!(
513 "Currency code \"{}\" not implemented for \"{}\"",
514 currency,
515 self.lang_name()
516 ))
517 })
518 }
519
520 /// `Num2Word_Base.verify_ordinal` for integral input: negatives raise
521 /// TypeError via `errmsg_negord` (SL does not override that message).
522 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
523 if value.is_negative() {
524 return Err(N2WError::Type(format!(
525 "Cannot treat negative num {} as ordinal.",
526 value
527 )));
528 }
529 Ok(())
530 }
531
532 /// SL's `setup` overrides `errmsg_toobig`; `base.rs` hardcodes the
533 /// `Num2Word_Base` wording, so re-emit with SL's text. Type is unchanged.
534 fn retag_overflow(&self, r: Result<String>, value: &BigInt) -> Result<String> {
535 match r {
536 Err(N2WError::Overflow(_)) => Err(N2WError::Overflow(format!(
537 "Number is too large to convert to words (abs({}) > {}).",
538 value.abs(),
539 self.maxval
540 ))),
541 other => other,
542 }
543 }
544}
545
546/// The literal `ordinals` table inside `Num2Word_SL.to_ordinal`.
547/// Note 0 is absent — that is why `to_ordinal(0)` falls through to "niči".
548fn simple_ordinal(value: &BigInt) -> Option<&'static str> {
549 let n = value.to_u32()?;
550 Some(match n {
551 1 => "prvi",
552 2 => "drugi",
553 3 => "tretji",
554 4 => "četrti",
555 5 => "peti",
556 6 => "šesti",
557 7 => "sedmi",
558 8 => "osmi",
559 9 => "deveti",
560 10 => "deseti",
561 11 => "enajsti",
562 12 => "dvanajsti",
563 13 => "trinajsti",
564 14 => "štirinajsti",
565 15 => "petnajsti",
566 16 => "šestnajsti",
567 17 => "sedemnajsti",
568 18 => "osemnajsti",
569 19 => "devetnajsti",
570 20 => "dvajseti",
571 30 => "trideseti",
572 40 => "štirideseti",
573 50 => "petdeseti",
574 60 => "šestdeseti",
575 70 => "sedemdeseti",
576 80 => "osemdeseti",
577 90 => "devetdeseti",
578 100 => "stoti",
579 1000 => "tisoči",
580 _ => return None,
581 })
582}
583
584impl Lang for LangSl {
585 /// This language's own `to_currency(currency=...)` default,
586 /// read from the live Python signature. Only 44 of 156 use EUR.
587 fn default_currency(&self) -> &str {
588 "EUR"
589 }
590
591 /// This language's own `to_currency(separator=...)` default,
592 /// read from the live Python signature. Base's is ",", but only
593 /// 36 of 149 languages actually use it — most default to " " or a
594 /// conjunction, so inheriting Base's comma silently corrupts them.
595 fn default_separator(&self) -> &str {
596 " in"
597 }
598
599 fn cards(&self) -> &Cards {
600 &self.cards
601 }
602 fn maxval(&self) -> &BigInt {
603 &self.maxval
604 }
605 fn negword(&self) -> &str {
606 "minus "
607 }
608 fn pointword(&self) -> &str {
609 "vejica"
610 }
611 // setup() sets exclude_title = [] but leaves is_title False, so title() is
612 // the identity — the base defaults already match.
613
614 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
615 sl_merge(l, r, false)
616 }
617
618 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
619 let r = default_to_cardinal(self, value);
620 self.retag_overflow(r, value)
621 }
622
623 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
624 self.verify_ordinal(value)?;
625
626 // Python: `num = int(value)` cannot fail for integral input, so the
627 // `except (ValueError, TypeError): return str(value)` arm is dead here.
628 if let Some(word) = simple_ordinal(value) {
629 return Ok(word.to_string());
630 }
631
632 // Python calls verify_ordinal a second time before setting the flag.
633 self.verify_ordinal(value)?;
634
635 // ordflag = True; outword = self.to_cardinal(value); ordflag = False
636 let eng = SlOrdinal {
637 cards: &self.cards,
638 maxval: &self.maxval,
639 };
640 let r = default_to_cardinal(&eng, value);
641 let mut outword = self.retag_overflow(r, value)?;
642
643 // for key in self.ords: if outword.endswith(key): ... break
644 // `outword[: len(outword) - len(key)]` with a confirmed suffix is
645 // exactly `strip_suffix`, and stays char-correct for č/š/ž.
646 for (key, replacement) in &self.ords {
647 if let Some(stem) = outword.strip_suffix(key) {
648 outword = format!("{}{}", stem, replacement);
649 break;
650 }
651 }
652
653 Ok(format!("{}i", outword))
654 }
655
656 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
657 self.verify_ordinal(value)?;
658 Ok(format!("{}.", value))
659 }
660
661 fn to_year(&self, value: &BigInt) -> Result<String> {
662 // Num2Word_SL.to_year ignores `longval` and defers to to_cardinal.
663 self.to_cardinal(value)
664 }
665
666 /// Port of `Num2Word_SL.to_cardinal_float(self, value)`.
667 ///
668 /// SL's own one-arg override, NOT `Num2Word_Base.to_cardinal_float` (the
669 /// default this trait method would otherwise route to). It diverges twice:
670 ///
671 /// 1. **Fraction as a single number.** Where the base loops the padded
672 /// fractional string digit by digit, SL emits it whole:
673 /// `self.to_cardinal(int(post_str))`. So `12.34` is "dvanajst vejica
674 /// štiriintrideset" (34), never "... tri štiri"; `12.345` is
675 /// "dvanajst vejica tristo petinštirideset" (345).
676 ///
677 /// 2. **Always float-casts, even Decimal.** Python does
678 /// `self.float2tuple(float(value))` unconditionally, so the exact-Decimal
679 /// arm of `base.float2tuple` is never taken here. This *reintroduces*
680 /// issue #603 that the Decimal arm exists to avoid: `Decimal(
681 /// "98746251323029.99")` casts to the f64 `98746251323029.98`, so the
682 /// fraction reads 98 ("osemindevetdeset"), not 99. Likewise
683 /// `Decimal("1.10")` casts to `1.1` — precision 1, "ena", not the
684 /// precision-2 "deset" the exact arm would give. Reproduced by taking
685 /// `to_f64()` on the `Decimal` variant and running the Float branch.
686 ///
687 /// SL's signature has **no** `precision=` parameter, so `precision_override`
688 /// is dead: even when the dispatcher pre-sets `self.precision`,
689 /// `float2tuple` immediately overwrites it from `repr(float(value))`.
690 /// Verified live — `num2words(0.5, lang='sl', precision=5)` is still
691 /// "nič vejica pet". Ignored here to match.
692 ///
693 /// Only non-integral values arrive: `Num2Word_Base.to_cardinal` sends
694 /// integral floats (`1.0`, `0.0`) down its integer branch, so `precision`
695 /// is always `>= 1` in practice and the fraction segment always fires.
696 fn to_cardinal_float(
697 &self,
698 value: &FloatValue,
699 _precision_override: Option<u32>,
700 ) -> Result<String> {
701 // `float(value)` — cast unconditionally, including the Decimal arm.
702 let f: f64 = match value {
703 FloatValue::Float { value, .. } => *value,
704 FloatValue::Decimal { value, .. } => value
705 .to_f64()
706 .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?,
707 };
708
709 // `pre, post = self.float2tuple(float(value))`. precision is recomputed
710 // from repr(f) exactly as base.float2tuple's float branch does, NOT read
711 // off the FloatValue (whose Decimal arm carries the exact exponent).
712 let precision = float_repr_precision(f);
713 let (pre, post) = float2tuple(&FloatValue::Float { value: f, precision });
714
715 // post = str(post); post = "0" * (precision - len(post)) + post
716 let post_str = post.to_string();
717 let post_str = format!(
718 "{}{}",
719 "0".repeat((precision as usize).saturating_sub(post_str.len())),
720 post_str
721 );
722
723 let mut out = vec![self.to_cardinal(&pre)?];
724
725 // if value < 0 and pre == 0: out = [negword.strip()] + out
726 // `value.is_negative()` reads the ORIGINAL sign (Decimal or float),
727 // matching Python's `value < 0` on the untouched parameter.
728 if value.is_negative() && pre.is_zero() {
729 out.insert(0, self.negword().trim().to_string());
730 }
731
732 if precision > 0 {
733 out.push(self.title(self.pointword()));
734 // self.to_cardinal(int(post_str)) — the whole fraction, one number.
735 // post_str is all digits, so the parse cannot fail; 0 on the empty
736 // string mirrors nothing reachable but keeps this total.
737 let post_num =
738 BigInt::parse_bytes(post_str.as_bytes(), 10).unwrap_or_else(BigInt::zero);
739 out.push(self.to_cardinal(&post_num)?);
740 }
741
742 Ok(out.join(" "))
743 }
744
745 /// `to_ordinal(float/Decimal)`.
746 ///
747 /// `verify_ordinal(value)` runs first: a non-integral value raises
748 /// TypeError (`errmsg_floatord`), a negative one TypeError
749 /// (`errmsg_negord`) — note `-0.0` passes both (`int(-0.0) == -0.0` and
750 /// `abs(-0.0) == -0.0` numerically). A surviving value is whole and
751 /// non-negative; `num = int(value)` then walks the same table/ordflag
752 /// machinery as the integer path, and `to_cardinal(<whole float>)`
753 /// inside the ordflag arm takes Base's integer branch (the
754 /// `assert int(value) == value` passes), so the output is byte-identical
755 /// to `to_ordinal(int(value))`: `5.0` -> "peti", `0.0`/`-0.0` -> "niči",
756 /// `1e+16` -> "desetbilijardti".
757 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
758 let Some(i) = value.as_whole_int() else {
759 return Err(N2WError::Type(format!(
760 "Cannot treat float {} as ordinal.",
761 sl_float_repr(value)
762 )));
763 };
764 if i.is_negative() {
765 return Err(N2WError::Type(format!(
766 "Cannot treat negative num {} as ordinal.",
767 sl_float_repr(value)
768 )));
769 }
770 self.to_ordinal(&i)
771 }
772
773 /// `to_ordinal_num(float/Decimal)` — `verify_ordinal(value)`, then
774 /// `str(value) + "."` with the *original* string form: `5.0` -> "5.0.",
775 /// `-0.0` -> "-0.0.", `1e+16` -> "1e+16.", `Decimal("5.00")` -> "5.00.".
776 /// Non-integral or negative values raise TypeError exactly as above.
777 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
778 let Some(i) = value.as_whole_int() else {
779 return Err(N2WError::Type(format!(
780 "Cannot treat float {} as ordinal.",
781 repr_str
782 )));
783 };
784 if i.is_negative() {
785 return Err(N2WError::Type(format!(
786 "Cannot treat negative num {} as ordinal.",
787 repr_str
788 )));
789 }
790 Ok(format!("{}.", repr_str))
791 }
792
793 // year_float_entry: SL's to_year is `self.to_cardinal(val)`, which is
794 // exactly what the trait default (via cardinal_float_entry) does — whole
795 // values take the integer branch, fractional ones SL's own
796 // to_cardinal_float above.
797
798 // ---- currency -------------------------------------------------------
799 //
800 // SL supplies `CURRENCY_FORMS`, `pluralize` and a `to_currency` that
801 // replaces Base's outright — it reimplements the int/float split by hand
802 // "to avoid `is_int_with_cents` issue" (its words). `to_cheque` is Base's,
803 // and crashes on SL's data (see below). The rest of the currency path is
804 // never reached, so the trait defaults stand:
805 //
806 // * `currency_precision` — `CURRENCY_PRECISION` is Base's empty dict
807 // (EN rebinds rather than mutates it, so nothing leaks in); the default
808 // 100 is what `.get(code, 100)` returns for every code. Moot regardless:
809 // SL's `to_currency` never consults it and hardcodes a divisor of 100,
810 // so the `divisor == 1` (JPY) and `divisor == 1000` (KWD/BHD) branches
811 // do not exist for SL. Those codes are absent from `CURRENCY_FORMS` and
812 // raise NotImplementedError first anyway — corpus rows confirm all 12
813 // JPY/KWD/BHD args raise rather than rendering.
814 // * `money_verbose` / `cents_verbose` / `cents_terse` — SL's
815 // `to_currency` inlines `self.to_cardinal` and never calls the first
816 // two; `cents=False` therefore does nothing and the third is dead
817 // (`to_currency(12.34, "EUR", cents=False)` still says "štiriintrideset
818 // centov", not "34"). Verified live.
819 // * `cardinal_from_decimal` — left at the default per
820 // PORTING_CURRENCY.md. This is the one known gap; see `to_currency`.
821 //
822 // `currency_adjective` is the one place where "default = Base's" is not the
823 // whole story. SL does *not* define `CURRENCY_ADJECTIVES`, so it inherits
824 // `Num2Word_EUR`'s populated 16-entry dict rather than Base's empty one —
825 // but SL's `to_currency` ignores its own `adjective` kwarg entirely, and
826 // `default_to_currency`/`default_to_cheque` (the only other readers of the
827 // hook) are both overridden here. The table is unreachable, so it is not
828 // transcribed; `adjective=True` is a no-op in Python too. Verified live:
829 // `to_currency(12.34, "EUR", adjective=True)` == the plain rendering.
830
831 fn lang_name(&self) -> &str {
832 "Num2Word_SL"
833 }
834
835 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
836 self.currency_forms.get(code).map(|(forms, _)| forms)
837 }
838
839 /// Port of `Num2Word_SL.pluralize(n, forms)`.
840 ///
841 /// Python's `%` on `int` floors, so a negative `n` wraps *up* into a
842 /// positive residue: `(-99) % 100 == 1` selects `forms[0]`. Rust's `%`
843 /// truncates — `-99 % 100 == -99`, which matches nothing and falls to
844 /// `forms[3]` — so `mod_floor` is required. The two agree on `-1`/`-100`
845 /// and disagree on `-99`/`-98`/`-97`, which is why the test pins `-99`.
846 ///
847 /// Unreachable from `to_currency` (both operands are absolute values), but
848 /// `pluralize` is a public hook and the residue rule is the whole point.
849 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
850 let rem = n.mod_floor(&BigInt::from(100));
851 pick_form(&BigDecimal::from(rem), forms)
852 }
853
854 /// Port of `Num2Word_SL.to_currency`.
855 ///
856 /// Three of the five parameters are dead upstream and so are ignored here:
857 ///
858 /// * `separator` — SL declares `separator=" in"` and then never reads it,
859 /// using the third `CURRENCY_FORMS` element instead. The generated
860 /// `default_separator()` above still reports " in" because that is the
861 /// live signature's default; it simply has no effect. Passing
862 /// `separator=" XX"` changes nothing in Python. Verified live.
863 /// * `cents` — no `_cents_terse` call exists on this path.
864 /// * `adjective` — no `CURRENCY_ADJECTIVES` lookup exists on this path.
865 ///
866 /// # The int/float split
867 ///
868 /// `is_integer_input = isinstance(val, int)` gates the cents segment: a
869 /// true `int` never prints cents, a float always does — including `1.0`,
870 /// which is why `currency:EUR` of `1` is "ena evro" but of `1.0` is "ena
871 /// evro nič centov". `has_decimal` is therefore unused here: SL does not
872 /// consult it, and the `Int`/`Decimal` variants already carry the only
873 /// distinction SL makes.
874 ///
875 /// # Known gap: fractional cents
876 ///
877 /// When `(Decimal(str(val)) * 100) % 1 != 0`, `parse_currency_parts` keeps
878 /// the subunit as a `Decimal` and Python renders it with
879 /// **`Num2Word_SL.to_cardinal_float`** — SL's own one-arg override, which
880 /// emits the fractional digits as a *single number*
881 /// (`to_cardinal(int(post_str))`). `cardinal_from_decimal`'s default routes
882 /// to `Num2Word_Base.to_cardinal_float`, which emits them *digit by digit*.
883 /// The two agree whenever the residue is a single digit and diverge beyond
884 /// it:
885 ///
886 /// ```text
887 /// to_currency(1.005, "EUR") "ena evro nič vejica pet centov" both
888 /// to_currency(1.0025, "EUR") "ena evro nič vejica petindvajset centov" Python
889 /// "ena evro nič vejica dve pet centov" here
890 /// ```
891 ///
892 /// Measured, not assumed: diffed against the live interpreter over 1751
893 /// cases (17 currency codes x 51 ints incl. 10^100 and negatives x 50
894 /// floats, plus cheques). Exactly 8 diverge — 1.0025, 0.0001, 1e-07 and
895 /// 3.14159 under EUR and USD — and all 8 are this one cause. No corpus row
896 /// is among them; reaching the branch needs 4+ decimal places.
897 ///
898 /// Closing it means porting SL's `to_cardinal_float`, which needs
899 /// `repr(float)` semantics that this crate deliberately keeps on the Python
900 /// side (see `currency.rs`'s header): `1e-07` renders "nič vejica ena"
901 /// precisely *because* Python's repr goes exponential and SL then reads the
902 /// zero-padded digits back as one number. That is the later float phase,
903 /// not this one — and note `currency::cardinal_from_bigdecimal` calls
904 /// `default_to_cardinal_float` directly rather than through
905 /// `lang.to_cardinal_float`, so porting SL's override alone will not
906 /// reroute this hook.
907 fn to_currency(
908 &self,
909 val: &CurrencyValue,
910 currency: &str,
911 _cents: bool,
912 _separator: Option<&str>,
913 _adjective: bool,
914 ) -> Result<String> {
915 // Python parses before the CURRENCY_FORMS lookup. Neither arm can
916 // raise, so the order is not observable — but `to_cardinal` (which
917 // *can* raise OverflowError) runs strictly after the lookup, so a
918 // too-large value under an unknown code must report the missing code.
919 // That ordering is preserved below.
920 let (left, right, is_negative, fractional) = match val {
921 CurrencyValue::Int(v) => (v.abs(), BigDecimal::zero(), v.is_negative(), false),
922 CurrencyValue::Decimal { value, .. } => {
923 // has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0.
924 // `with_scale(0)` truncates; `x - trunc(x) != 0` iff x is
925 // non-integral, which is what Python's `% 1 != 0` tests. The
926 // sign is irrelevant to a `!= 0` test, so Decimal's
927 // truncating `%` and a floor agree here.
928 let scaled = value * BigDecimal::from(100);
929 let hfc = &scaled - scaled.with_scale(0) != BigDecimal::zero();
930 // SL calls parse_currency_parts with the default divisor=100.
931 let (l, r, neg) = parse_currency_parts(val, false, hfc, 100);
932 (l, r, neg, hfc)
933 }
934 };
935
936 let (forms, cur_separator) = self.lookup_currency(currency)?;
937
938 // Python: `"%s " % self.negword.strip()` — negword is "minus ", so
939 // this is "minus ". Derived rather than spelled out so the two cannot
940 // drift apart.
941 let minus_str = if is_negative {
942 format!("{} ", self.negword().trim())
943 } else {
944 String::new()
945 };
946 let money_str = self.to_cardinal(&left)?;
947 let left_form = self.pluralize(&left, &forms.unit)?;
948
949 // Integer: no cents.
950 if matches!(val, CurrencyValue::Int(_)) {
951 return Ok(format!("{}{} {}", minus_str, money_str, left_form));
952 }
953
954 // Float: always show cents, even at zero. The `right > 0` guard is
955 // redundant upstream — `to_cardinal(0)` is "nič" too — but it is the
956 // shape Python has, and it is what keeps the fractional branch from
957 // calling the float path on an exact zero.
958 let cents_str = if right > BigDecimal::zero() {
959 if fractional {
960 // Python: self.to_cardinal_float(float(right)). See the
961 // "Known gap" note above — this is not that function.
962 self.cardinal_from_decimal(&right)?
963 } else {
964 // parse_currency_parts already applied `with_scale(0)` on this
965 // path, so the unscaled value is the whole subunit count.
966 self.to_cardinal(&right.as_bigint_and_exponent().0)?
967 }
968 } else {
969 "nič".to_string()
970 };
971
972 // `pluralize(right, cr2_forms)` — Python passes `right` itself, an int
973 // on the exact path and a Decimal on the fractional one.
974 // `parse_currency_parts` guarantees `0 <= right < 100` here (it is
975 // `fraction * 100` with `0 <= fraction < 1`), so `right % 100` is the
976 // identity and no modulo is needed. A fractional residue matches none
977 // of 1/2/3/4 and lands on `forms[3]` — "ena vejica ena centov", never
978 // "…cent".
979 let right_form = pick_form(&right, &forms.subunit)?;
980
981 // `if cur_separator:` — statically false for both SL codes, but ported
982 // rather than folded away: the empty string is data, not a constant.
983 if cur_separator.is_empty() {
984 Ok(format!(
985 "{}{} {} {} {}",
986 minus_str, money_str, left_form, cents_str, right_form
987 ))
988 } else {
989 Ok(format!(
990 "{}{} {}{} {} {}",
991 minus_str, money_str, left_form, cur_separator, cents_str, right_form
992 ))
993 }
994 }
995
996 /// `Num2Word_Base.to_cheque` — inherited, and it cannot succeed for SL.
997 ///
998 /// ```python
999 /// try:
1000 /// cr1, _cr2 = self.CURRENCY_FORMS[currency]
1001 /// except KeyError:
1002 /// raise NotImplementedError(...)
1003 /// ```
1004 ///
1005 /// Every SL entry is a **3-tuple**, so the unpack raises
1006 /// `ValueError: too many values to unpack (expected 2)` — from inside the
1007 /// `try`, where only `KeyError` is caught, so it propagates. An unknown
1008 /// code raises `KeyError` on the subscript first and converts to
1009 /// NotImplementedError as usual. So the implemented codes fail *harder*
1010 /// than the unimplemented ones:
1011 ///
1012 /// ```text
1013 /// cheque:EUR 1234.56 ValueError cheque:GBP 1234.56 NotImplementedError
1014 /// cheque:USD 1234.56 ValueError cheque:JPY 1234.56 NotImplementedError
1015 /// ```
1016 ///
1017 /// Both arms are corpus rows and both are reproduced. `val` is untouched
1018 /// because Python never gets far enough to look at it — the unpack is the
1019 /// first statement after the subscript.
1020 fn to_cheque(&self, _val: &BigDecimal, currency: &str) -> Result<String> {
1021 self.lookup_currency(currency)?;
1022 Err(N2WError::Value(
1023 "too many values to unpack (expected 2)".into(),
1024 ))
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use std::str::FromStr;
1032
1033 /// Drive a corpus row exactly as `bench/diff_test.py::_currency_call`
1034 /// does — `arg` is `repr(value)`, so the int/float split comes from the
1035 /// literal's shape, not from re-parsing the number.
1036 fn currency(arg: &str, code: &str) -> Result<String> {
1037 let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
1038 let val = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
1039 LangSl::new().to_currency(&val, code, true, None, false)
1040 }
1041
1042 fn cheque(arg: &str, code: &str) -> Result<String> {
1043 LangSl::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
1044 }
1045
1046 /// The 12 args every currency code is exercised with in the corpus.
1047 const ARGS: [&str; 12] = [
1048 "0", "1", "2", "100", "12.34", "0.01", "1.0", "99.99", "1234.56", "-12.34", "1000000",
1049 "0.5",
1050 ];
1051
1052 /// The 24 rendering rows: SL's only two implemented codes x `ARGS`.
1053 #[test]
1054 fn corpus_currency_implemented() {
1055 let eur = [
1056 "nič evrov",
1057 "ena evro",
1058 "dve evra",
1059 "sto evrov",
1060 "dvanajst evrov štiriintrideset centov",
1061 "nič evrov ena cent",
1062 "ena evro nič centov",
1063 "devetindevetdeset evrov devetindevetdeset centov",
1064 "tisoč dvesto štiriintrideset evrov šestinpetdeset centov",
1065 "minus dvanajst evrov štiriintrideset centov",
1066 "milijon evrov",
1067 "nič evrov petdeset centov",
1068 ];
1069 let usd = [
1070 "nič dolarjev",
1071 "ena dolar",
1072 "dve dolarja",
1073 "sto dolarjev",
1074 "dvanajst dolarjev štiriintrideset centov",
1075 "nič dolarjev ena cent",
1076 "ena dolar nič centov",
1077 "devetindevetdeset dolarjev devetindevetdeset centov",
1078 "tisoč dvesto štiriintrideset dolarjev šestinpetdeset centov",
1079 "minus dvanajst dolarjev štiriintrideset centov",
1080 "milijon dolarjev",
1081 "nič dolarjev petdeset centov",
1082 ];
1083 for (code, want) in [("EUR", &eur), ("USD", &usd)] {
1084 for (arg, want) in ARGS.iter().zip(want.iter()) {
1085 assert_eq!(currency(arg, code).unwrap(), *want, "{} {}", code, arg);
1086 }
1087 }
1088 }
1089
1090 /// The other 84 rows. Every code outside SL's two-entry table raises,
1091 /// including the ones EN adds to the *EUR* dict (SL shadows it) and the
1092 /// 0-decimal/3-decimal ones whose divisor branches SL therefore never has.
1093 #[test]
1094 fn corpus_currency_not_implemented() {
1095 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1096 for arg in ARGS {
1097 match currency(arg, code) {
1098 Err(N2WError::NotImplemented(m)) => assert_eq!(
1099 m,
1100 format!("Currency code \"{}\" not implemented for \"Num2Word_SL\"", code)
1101 ),
1102 other => panic!("{} {}: want NotImplemented, got {:?}", code, arg, other),
1103 }
1104 }
1105 }
1106 }
1107
1108 /// `to_cheque` never succeeds: the implemented codes hit the 3-tuple
1109 /// unpack (ValueError), the rest hit the subscript (NotImplementedError).
1110 #[test]
1111 fn corpus_cheque() {
1112 for code in ["EUR", "USD"] {
1113 assert!(matches!(cheque("1234.56", code), Err(N2WError::Value(_))), "{}", code);
1114 }
1115 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1116 assert!(
1117 matches!(cheque("1234.56", code), Err(N2WError::NotImplemented(_))),
1118 "{}",
1119 code
1120 );
1121 }
1122 }
1123
1124 /// `pluralize`'s four residue classes, and the `% 100` wrap that puts
1125 /// 101/102 back on the 1/2 forms.
1126 #[test]
1127 fn plural_residue_classes() {
1128 for (n, want) in [
1129 (0, "nič evrov"),
1130 (3, "tri evre"),
1131 (4, "štiri evre"),
1132 (5, "pet evrov"),
1133 (21, "enaindvajset evrov"),
1134 (101, "sto ena evro"),
1135 (102, "sto dve evra"),
1136 (103, "sto tri evre"),
1137 (104, "sto štiri evre"),
1138 ] {
1139 assert_eq!(currency(&n.to_string(), "EUR").unwrap(), want, "{}", n);
1140 }
1141 // Python's `%` floors, so a negative residue wraps up: (-1) % 100 == 99.
1142 let l = LangSl::new();
1143 let forms: Vec<String> = ["evro", "evra", "evre", "evrov"]
1144 .iter()
1145 .map(|s| s.to_string())
1146 .collect();
1147 assert_eq!(l.pluralize(&BigInt::from(-1), &forms).unwrap(), "evrov");
1148 // (-99) % 100 == 1 under a floor, so this is forms[0], not forms[3].
1149 assert_eq!(l.pluralize(&BigInt::from(-99), &forms).unwrap(), "evro");
1150 }
1151
1152 /// Negative ints keep the cents segment off, and take `negword.strip()`.
1153 #[test]
1154 fn negative_int_has_no_cents() {
1155 assert_eq!(currency("-5", "EUR").unwrap(), "minus pet evrov");
1156 assert_eq!(currency("-1", "EUR").unwrap(), "minus ena evro");
1157 }
1158
1159 /// `cents`, `separator` and `adjective` are all dead upstream.
1160 #[test]
1161 fn ignored_kwargs() {
1162 let l = LangSl::new();
1163 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1164 let want = "dvanajst evrov štiriintrideset centov";
1165 assert_eq!(l.to_currency(&v, "EUR", false, None, false).unwrap(), want);
1166 assert_eq!(l.to_currency(&v, "EUR", true, Some(" XX"), false).unwrap(), want);
1167 assert_eq!(l.to_currency(&v, "EUR", true, None, true).unwrap(), want);
1168 }
1169
1170 /// A float `cardinal` row, called exactly as the py binding builds it:
1171 /// `FloatValue::Float`, precision from repr, `precision_override = None`.
1172 fn cardinal_float(f: f64) -> String {
1173 LangSl::new()
1174 .to_cardinal_float(
1175 &FloatValue::Float {
1176 value: f,
1177 precision: float_repr_precision(f),
1178 },
1179 None,
1180 )
1181 .unwrap()
1182 }
1183
1184 /// A `cardinal_dec` row: `FloatValue::Decimal` carrying the exact BigDecimal
1185 /// and its `abs(exponent)` precision, as the py binding constructs it.
1186 fn cardinal_dec(s: &str) -> String {
1187 let value = BigDecimal::from_str(s).unwrap();
1188 // The py binding carries abs(exponent); SL ignores it (it recomputes
1189 // from repr(float)), so the digit count of the literal is faithful.
1190 let precision = s.split_once('.').map_or(0, |(_, f)| f.len()) as u32;
1191 LangSl::new()
1192 .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1193 .unwrap()
1194 }
1195
1196 /// The `to: "cardinal"` corpus rows whose arg has a dot (float input). SL
1197 /// renders the fraction as a single number, not digit by digit.
1198 #[test]
1199 fn corpus_cardinal_float() {
1200 for (f, want) in [
1201 (0.5, "nič vejica pet"),
1202 (1.5, "ena vejica pet"),
1203 (2.25, "dve vejica petindvajset"),
1204 (3.14, "tri vejica štirinajst"),
1205 (0.01, "nič vejica ena"),
1206 (0.1, "nič vejica ena"),
1207 (0.99, "nič vejica devetindevetdeset"),
1208 (1.01, "ena vejica ena"),
1209 (12.34, "dvanajst vejica štiriintrideset"),
1210 (99.99, "devetindevetdeset vejica devetindevetdeset"),
1211 (100.5, "sto vejica pet"),
1212 (1234.56, "tisoč dvesto štiriintrideset vejica šestinpetdeset"),
1213 (-0.5, "minus nič vejica pet"),
1214 (-1.5, "minus ena vejica pet"),
1215 (-12.34, "minus dvanajst vejica štiriintrideset"),
1216 // f64-artefact cases: 1.005 -> post 5 (padded "005"), 2.675 -> 675.
1217 (1.005, "ena vejica pet"),
1218 (2.675, "dve vejica šeststo petinsedemdeset"),
1219 ] {
1220 assert_eq!(cardinal_float(f), want, "{}", f);
1221 }
1222 }
1223
1224 /// The `to: "cardinal_dec"` corpus rows. SL float-casts every Decimal, which
1225 /// reintroduces #603 (98746251323029.99 -> the f64 ...98) and drops
1226 /// Decimal("1.10")'s trailing zero to precision 1.
1227 #[test]
1228 fn corpus_cardinal_dec() {
1229 for (s, want) in [
1230 ("0.01", "nič vejica ena"),
1231 ("1.10", "ena vejica ena"),
1232 ("12.345", "dvanajst vejica tristo petinštirideset"),
1233 (
1234 "98746251323029.99",
1235 "osemindevetdeset bilijon sedemsto šestinštirideset milijarda dvesto \
1236 enainpetdeset milijon tristo triindvajset tisoč devetindvajset vejica \
1237 osemindevetdeset",
1238 ),
1239 ("0.001", "nič vejica ena"),
1240 ] {
1241 assert_eq!(cardinal_dec(s), want, "{}", s);
1242 }
1243 }
1244
1245 /// The `precision=` kwarg is dead on SL's float path — float2tuple always
1246 /// recomputes precision from repr(float(value)).
1247 #[test]
1248 fn precision_override_ignored() {
1249 let v = FloatValue::Float {
1250 value: 0.5,
1251 precision: 1,
1252 };
1253 assert_eq!(
1254 LangSl::new().to_cardinal_float(&v, Some(5)).unwrap(),
1255 "nič vejica pet"
1256 );
1257 }
1258}