Skip to main content

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}