Skip to main content

num2words2_core/
lang_is.rs

1//! Port of `lang_IS.py` (Icelandic), via `lang_EUR` → `Num2Word_Base`.
2//!
3//! Registry check: `__init__.py` maps `"is"` → `lang_IS.Num2Word_IS()`.
4//!
5//! Shape: **engine**. `Num2Word_IS.setup` defines `high_numwords` /
6//! `mid_numwords` / `low_numwords` and the class overrides `merge`, so
7//! `Num2Word_Base.to_cardinal` drives `splitnum`/`clean` unchanged. Only
8//! `to_ordinal`, `to_ordinal_num` and `to_year` are overridden.
9//!
10//! # Card construction
11//!
12//! `setup` calls `gen_high_numwords([], [], lows)` with **empty** `units` and
13//! `tens`. The comprehension `[u + t for t in tens for u in units]` over an
14//! empty `tens` yields `[]`, every elision replacement is a no-op on the empty
15//! list, and the function returns `[] + lows` — i.e. `high_numwords` is just
16//! `lows` verbatim. IS therefore skips the whole Latin-prefix machinery that
17//! EN/EUR use; there is no `gen_high_numwords` helper needed here.
18//!
19//! `Num2Word_EUR.set_high_numwords` then pairs those 8 stems with
20//! `range(3 + 6*8, 3, -6)` = 51, 45, …, 9, emitting **both** an illjarður
21//! (long-scale "-illiard") card at `10**n` and an illjón card at `10**(n-3)`:
22//!
23//! ```text
24//! 10^51 oktilljarður   10^48 oktilljón
25//! 10^45 septilljarður  10^42 septilljón
26//! 10^39 sextilljarður  10^36 sextilljón
27//! 10^33 kvintilljarður 10^30 kvintilljón
28//! 10^27 kvaðrilljarður 10^24 kvaðrilljón
29//! 10^21 trilljarður    10^18 trilljón
30//! 10^15 billjarður     10^12 billjón
31//! 10^9  milljarður     10^6  milljón
32//! ```
33//!
34//! `MAXVAL = 1000 * list(self.cards.keys())[0]`. Python's `cards` is an
35//! `OrderedDict` and the first key inserted is `10**51`, so `MAXVAL == 10**54`.
36//! (`Cards::highest()` returns the same value because IS's insertion order —
37//! high descending, then mid descending, then low descending — is already
38//! sorted descending.)
39//!
40//! # Notes on faithfulness
41//!
42//! * `is_title` is never set by IS, so it stays `False` from
43//!   `Num2Word_Base.__init__` and `title()` is an identity. `exclude_title` is
44//!   populated (`["og", "komma", "mínus"]`) but is dead weight — reproduced
45//!   here anyway so the field matches the Python object.
46//! * `genderize` uses `adj.replace(last, ...)`, a **substring** replace over
47//!   the whole adjective phrase, not a replace of the final token only. With
48//!   the real card vocabulary the last token is the only occurrence, so the
49//!   two coincide — but the substring semantics are what is ported.
50//! * `to_ordinal` is a "simplified implementation" (the Python comment says so)
51//!   and produces plainly wrong Icelandic for most composite numbers. See the
52//!   bug list on [`LangIs::to_ordinal`].
53//!
54//! # Currency
55//!
56//! IS declares its **own** `CURRENCY_FORMS` class dict, so the
57//! `Num2Word_EN.__init__` mutation that rewrites `Num2Word_EUR`'s shared table
58//! in place never reaches it. Verified against the live interpreter:
59//! `Num2Word_IS.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` → `False`, and
60//! the instance view holds exactly ISK/EUR/USD. Every other code — including
61//! the ~24 that EN adds to EUR's dict — raises `NotImplementedError`.
62//!
63//! `CURRENCY_PRECISION` is *not* overridden (IS inherits `Num2Word_Base`'s
64//! empty dict, confirmed live), so `.get(code, 100)` is always 100 and the
65//! trait's default `currency_precision` is already exact. There is no
66//! 3-decimal or 0-decimal path here: KWD/BHD/JPY are absent from the forms
67//! table and raise before precision is ever consulted.
68//!
69//! `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` are all
70//! inherited from `Num2Word_Base` unchanged, so their trait defaults stand.
71//!
72//! ## The tuple leak
73//!
74//! `Num2Word_IS.pluralize(self, n, noun)` **shadows**
75//! `Num2Word_EUR.pluralize(self, n, forms)` with an incompatible contract: it
76//! expects a single noun *string*, but `Num2Word_Base.to_currency` — which IS
77//! delegates every float to — calls `self.pluralize(left, cr1)` with the
78//! `CURRENCY_FORMS` **tuple**. Python's duck typing lets that through, every
79//! branch falls out returning the tuple untouched, and the `"%s"` template
80//! then stringifies the tuple itself:
81//!
82//! ```text
83//! num2words(12.34, lang="is", to="currency", currency="EUR")
84//!   -> "tólf ('evra', 'evrur'), þrjátíu og fjórir ('sent', 'sent')"
85//! ```
86//!
87//! That is a genuine upstream bug and it is what the corpus pins, so
88//! [`LangIs::pluralize`] reproduces the tuple repr byte for byte. Note the
89//! asymmetry it creates: `to_currency(2, ...)` (a true `int`) never reaches
90//! `pluralize` — IS's own override indexes `cr1` directly — so ints print
91//! clean ("tveir evrur") while floats leak.
92
93use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
94use crate::currency::{CurrencyForms, CurrencyValue};
95use num_bigint::BigInt;
96use num_integer::Integer;
97use num_traits::{One, Signed, Zero};
98use std::collections::HashMap;
99
100/// `Num2Word_IS.GIGA_SUFFIX` — overrides EUR's "illiard".
101const GIGA_SUFFIX: &str = "illjarður";
102/// `Num2Word_IS.MEGA_SUFFIX` — overrides EUR's "illion".
103const MEGA_SUFFIX: &str = "illjón";
104
105// Gender indices, mirroring the module-level constants in lang_IS.py.
106const KK: usize = 0; // Karlkyn (male)
107const KVK: usize = 1; // Kvenkyn (female)
108const HK: usize = 2; // Hvorugkyn (neuter)
109
110/// `GENDERS`: numeral → (karlkyn, kvenkyn, hvorugkyn).
111///
112/// A slice rather than a map: four entries, and lookup is a linear scan in
113/// both languages' hot paths anyway.
114const GENDERS: &[(&str, [&str; 3])] = &[
115    ("einn", ["einn", "ein", "eitt"]),
116    ("tveir", ["tveir", "tvær", "tvö"]),
117    ("þrír", ["þrír", "þrjár", "þrjú"]),
118    ("fjórir", ["fjórir", "fjórar", "fjögur"]),
119];
120
121/// `PLURALS`: noun → (singular, plural). Only "hundrað" is listed.
122const PLURALS: &[(&str, [&str; 2])] = &[("hundrað", ["hundrað", "hundruð"])];
123
124fn gender_forms(word: &str) -> Option<&'static [&'static str; 3]> {
125    GENDERS.iter().find(|(k, _)| *k == word).map(|(_, v)| v)
126}
127
128fn plural_forms(word: &str) -> Option<&'static [&'static str; 2]> {
129    PLURALS.iter().find(|(k, _)| *k == word).map(|(_, v)| v)
130}
131
132/// Python's `str()` of a tuple whose elements are all `str`.
133///
134/// Required because `Num2Word_IS.pluralize` returns `base.to_currency`'s
135/// `cr1`/`cr2` tuple unchanged and the `"%s"` template stringifies it (see the
136/// module docs). This is the *rendering* half of that bug.
137///
138/// Element quoting is the plain `'…'` form. Python's `repr(str)` would switch
139/// to `"…"` for a string containing an apostrophe, and would escape
140/// backslashes and non-printables — but IS's currency vocabulary is a closed
141/// set of nine plain-letter words, optionally prefixed by a
142/// `CURRENCY_ADJECTIVES` value ("íslenskar ", "US "), so none of those cases
143/// can arise. Non-ASCII stays literal in both languages (Python since PEP
144/// 3138), so "króna" renders as `'króna'`, not `'kr\xf3na'`.
145fn py_tuple_repr(items: &[String]) -> String {
146    let inner: Vec<String> = items.iter().map(|s| format!("'{}'", s)).collect();
147    match inner.len() {
148        0 => "()".to_string(),
149        // Python disambiguates a 1-tuple with a trailing comma: `('x',)`.
150        1 => format!("({},)", inner[0]),
151        _ => format!("({})", inner.join(", ")),
152    }
153}
154
155/// `Num2Word_IS.CURRENCY_FORMS`, the class body verbatim.
156///
157/// IS's own dict, *not* the EUR table EN mutates — see the module docs. Note
158/// it also disagrees with EUR on ISK's subunit: EUR says `("aur", "aurar")`,
159/// IS says `("eyrir", "aurar")`. IS wins; EUR's entry is unreachable here.
160fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
161    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
162    m.insert(
163        "ISK",
164        CurrencyForms::new(&["króna", "krónur"], &["eyrir", "aurar"]),
165    );
166    m.insert("EUR", CurrencyForms::new(&["evra", "evrur"], &["sent", "sent"]));
167    m.insert("USD", CurrencyForms::new(&["dalur", "dalir"], &["sent", "sent"]));
168    m
169}
170
171/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged.
172///
173/// IS defines no `CURRENCY_ADJECTIVES` of its own, and — unlike
174/// `CURRENCY_FORMS` — nothing mutates EUR's copy, so the class-body literal is
175/// what runs (dumped live to confirm all 16 entries).
176///
177/// Almost all of it is dead weight, reproduced because it is what the object
178/// carries:
179///
180/// * `adjective=` is consulted only *after* the `CURRENCY_FORMS` lookup, so
181///   the 14 codes outside ISK/EUR/USD raise `NotImplementedError` first.
182/// * `Num2Word_IS.to_currency`'s int branch ignores `adjective` outright — it
183///   never calls `prefix_currency` — so adjectives reach only the float path.
184/// * EUR is absent from the table, so `adjective=True` on EUR is a silent
185///   no-op.
186///
187/// That leaves ISK ("íslenskar") and USD ("US") on floats as the only live
188/// entries.
189fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
190    [
191        ("AUD", "Australian"),
192        ("BYN", "Belarusian"),
193        ("CAD", "Canadian"),
194        ("EEK", "Estonian"),
195        ("USD", "US"),
196        ("RUB", "Russian"),
197        ("NOK", "Norwegian"),
198        ("MXN", "Mexican"),
199        ("RON", "Romanian"),
200        ("INR", "Indian"),
201        ("HUF", "Hungarian"),
202        ("ISK", "íslenskar"),
203        ("UZS", "Uzbekistan"),
204        ("SAR", "Saudi"),
205        ("JPY", "Japanese"),
206        ("KRW", "Korean"),
207    ]
208    .into_iter()
209    .collect()
210}
211
212pub struct LangIs {
213    cards: Cards,
214    maxval: BigInt,
215    ords: HashMap<&'static str, &'static str>,
216    exclude_title: Vec<String>,
217    currency_forms: HashMap<&'static str, CurrencyForms>,
218    currency_adjectives: HashMap<&'static str, &'static str>,
219}
220
221impl Default for LangIs {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl LangIs {
228    pub fn new() -> Self {
229        // setup(): lows == high_numwords, because gen_high_numwords([], [], lows)
230        // degenerates to `[] + lows` (see module docs).
231        let high = ["okt", "sept", "sext", "kvint", "kvaðr", "tr", "b", "m"];
232
233        let mut cards = Cards::new();
234
235        // Num2Word_EUR.set_high_numwords:
236        //   cap = 3 + 6 * len(high)              -> 51
237        //   for word, n in zip(high, range(cap, 3, -6)):
238        //       cards[10**n]     = word + GIGA_SUFFIX
239        //       cards[10**(n-3)] = word + MEGA_SUFFIX
240        // zip() stops at the shorter sequence; here both have length 8, so the
241        // `n > 3` guard below is belt-and-braces that mirrors range's stop.
242        let cap: i64 = 3 + 6 * high.len() as i64;
243        let mut n = cap;
244        for word in high.iter() {
245            if n <= 3 {
246                break;
247            }
248            cards.insert(
249                BigInt::from(10u8).pow(n as u32),
250                format!("{}{}", word, GIGA_SUFFIX),
251            );
252            cards.insert(
253                BigInt::from(10u8).pow((n - 3) as u32),
254                format!("{}{}", word, MEGA_SUFFIX),
255            );
256            n -= 6;
257        }
258
259        set_mid_numwords(
260            &mut cards,
261            &[
262                (1000, "þúsund"),
263                (100, "hundrað"),
264                (90, "níutíu"),
265                (80, "áttatíu"),
266                (70, "sjötíu"),
267                (60, "sextíu"),
268                (50, "fimmtíu"),
269                (40, "fjörutíu"),
270                (30, "þrjátíu"),
271            ],
272        );
273        set_low_numwords(
274            &mut cards,
275            &[
276                "tuttugu", "nítján", "átján", "sautján", "sextán", "fimmtán", "fjórtán",
277                "þrettán", "tólf", "ellefu", "tíu", "níu", "átta", "sjö", "sex", "fimm",
278                "fjórir", "þrír", "tveir", "einn", "núll",
279            ],
280        );
281
282        // MAXVAL = 1000 * list(self.cards.keys())[0] == 1000 * 10**51 == 10**54.
283        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
284
285        let ords: HashMap<&'static str, &'static str> = [
286            ("einn", "fyrsti"),
287            ("tveir", "annar"),
288            ("þrír", "þriðji"),
289            ("fjórir", "fjórði"),
290            ("fimm", "fimmti"),
291            ("sex", "sjötti"),
292            ("sjö", "sjöundi"),
293            ("átta", "áttundi"),
294            ("níu", "níundi"),
295            ("tíu", "tíundi"),
296            ("ellefu", "ellefti"),
297            ("tólf", "tólfti"),
298        ]
299        .into_iter()
300        .collect();
301
302        LangIs {
303            cards,
304            maxval,
305            ords,
306            exclude_title: vec!["og".into(), "komma".into(), "mínus".into()],
307            currency_forms: build_currency_forms(),
308            currency_adjectives: build_currency_adjectives(),
309        }
310    }
311
312    /// Port of `Num2Word_IS.pluralize(n, noun)` **for the string call site**.
313    ///
314    /// This shadows `Num2Word_EUR.pluralize(n, forms)` with a *different*
315    /// contract — IS takes a single noun string, not a tuple of forms — and
316    /// Python has only the one method serving both call sites. The two are
317    /// split here because the argument types are irreconcilable in Rust:
318    ///
319    /// * `merge` (cardinal path) passes a card word → this inherent method.
320    /// * `base.to_currency` (currency path) passes the `CURRENCY_FORMS` tuple
321    ///   → the [`Lang::pluralize`] impl, which reproduces the tuple leak.
322    ///
323    /// Inherent methods win name resolution over trait methods, so `merge`'s
324    /// `self.pluralize(lnum, rtext)` binds here on the `&str` argument while
325    /// `default_to_currency`'s generic `lang.pluralize(&n, &forms)` binds to
326    /// the trait. The dispatch matches Python's duck typing by construction.
327    ///
328    /// (`Num2Word_IS.pluralize_currency` looks like it ought to serve the
329    /// currency path, but nothing ever calls it — `base.to_currency` calls
330    /// `pluralize`, not `pluralize_currency`. It is dead code in Python and is
331    /// therefore not ported.)
332    fn pluralize(&self, n: &BigInt, noun: &str) -> String {
333        let ten = BigInt::from(10);
334        let hundred = BigInt::from(100);
335        // form = 0 if (n % 10 == 1 and n % 100 != 11) else 1
336        let form = if n.mod_floor(&ten).is_one() && n.mod_floor(&hundred) != BigInt::from(11) {
337            0usize
338        } else {
339            1usize
340        };
341        if form == 0 {
342            return noun.to_string();
343        }
344        if noun.contains(GIGA_SUFFIX) {
345            return noun.replace(GIGA_SUFFIX, "illjarðar");
346        }
347        if noun.contains(MEGA_SUFFIX) {
348            return noun.replace(MEGA_SUFFIX, "illjónir");
349        }
350        match plural_forms(noun) {
351            None => noun.to_string(),
352            Some(forms) => forms[form].to_string(),
353        }
354    }
355
356    /// Port of `Num2Word_IS.genderize(adj, noun)`.
357    ///
358    /// Python's `adj.split()[-1]` would raise `IndexError` on an all-whitespace
359    /// `adj`. That is unreachable: every card word is non-empty and `merge`
360    /// only ever builds `"a b"` / `"a og b"` from non-empty parts. The
361    /// `unwrap_or("")` below therefore never fires; `""` is not in `GENDERS`,
362    /// so it would fall through to the identity return regardless.
363    fn genderize(&self, adj: &str, noun: &str) -> String {
364        let last = match adj.split_whitespace().last() {
365            Some(w) => w,
366            None => "",
367        };
368        let forms = match gender_forms(last) {
369            Some(f) => f,
370            None => return adj.to_string(),
371        };
372        // Default KK; the `elif "illjarð"` arm is redundant in the Python but
373        // kept for a 1:1 reading of the branch order.
374        let gender = if noun.contains("hund") || noun.contains("þús") {
375            HK
376        } else if noun.contains("illjarð") {
377            KK
378        } else if noun.contains("illjón") {
379            KVK
380        } else {
381            KK
382        };
383        // Python: adj.replace(last, GENDERS[last][gender]) — replaces every
384        // occurrence of the token as a substring, not just the final word.
385        adj.replace(last, forms[gender])
386    }
387}
388
389impl Lang for LangIs {
390    /// This language's own `to_currency(currency=...)` default,
391    /// read from the live Python signature. Only 44 of 156 use EUR.
392    fn default_currency(&self) -> &str {
393        "ISK"
394    }
395
396    /// This language's own `to_currency(separator=...)` default,
397    /// read from the live Python signature. Base's is ",", but only
398    /// 36 of 149 languages actually use it — most default to " " or a
399    /// conjunction, so inheriting Base's comma silently corrupts them.
400    fn default_separator(&self) -> &str {
401        ","
402    }
403
404    /// For the `Currency code "X" not implemented for "Y"` message. Python
405    /// interpolates `self.__class__.__name__`, so it names IS even when the
406    /// raise happens inside `Num2Word_Base.to_currency` after IS delegates.
407    fn lang_name(&self) -> &str {
408        "Num2Word_IS"
409    }
410
411    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
412        self.currency_forms.get(code)
413    }
414
415    fn currency_adjective(&self, code: &str) -> Option<&str> {
416        self.currency_adjectives.get(code).copied()
417    }
418
419    // currency_precision is NOT overridden: IS inherits Num2Word_Base's empty
420    // CURRENCY_PRECISION, so `.get(code, 100)` is always 100 — exactly the
421    // trait default. Likewise money_verbose / cents_verbose / cents_terse /
422    // to_cheque, which IS inherits from Num2Word_Base unchanged.
423
424    /// Port of `Num2Word_IS.pluralize(n, noun)` **for the tuple call site** —
425    /// i.e. the one `Num2Word_Base.to_currency` reaches. See the module docs;
426    /// the short version is that this always returns the tuple's `str()`.
427    ///
428    /// Walking the Python with `noun` bound to a `CURRENCY_FORMS` tuple:
429    ///
430    /// ```text
431    /// form = 0 if (n % 10 == 1 and n % 100 != 11) else 1
432    /// if form == 0:                  return noun        # the tuple
433    /// elif self.GIGA_SUFFIX in noun: return noun.replace(...)
434    /// elif self.MEGA_SUFFIX in noun: return noun.replace(...)
435    /// elif noun not in PLURALS:      return noun        # the tuple
436    /// return PLURALS[noun][form]
437    /// ```
438    ///
439    /// Both live arms return `noun` unchanged, so the count `n` has no effect
440    /// whatsoever — corpus row `0.01 EUR` proves it, taking `form == 1` for
441    /// the unit and `form == 0` for the subunit and leaking both tuples.
442    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
443        let ten = BigInt::from(10);
444        let hundred = BigInt::from(100);
445        let form = if n.mod_floor(&ten).is_one() && n.mod_floor(&hundred) != BigInt::from(11) {
446            0usize
447        } else {
448            1usize
449        };
450        if form == 0 {
451            return Ok(py_tuple_repr(forms));
452        }
453        // `self.GIGA_SUFFIX in noun` is a *substring* test when noun is a str
454        // (the merge path) but *element equality* when noun is a tuple — this
455        // path. It could only fire if a currency form were literally
456        // "illjarður"/"illjón", and the body would then raise AttributeError,
457        // because tuples have no `.replace`. Unreachable with IS's table;
458        // ported rather than dropped so the shape survives a table change.
459        if forms.iter().any(|f| f == GIGA_SUFFIX || f == MEGA_SUFFIX) {
460            return Err(N2WError::Attribute(
461                "'tuple' object has no attribute 'replace'".into(),
462            ));
463        }
464        // `elif noun not in PLURALS` — PLURALS is keyed by str, so a tuple key
465        // never hits, the test is always true, and the tuple falls straight
466        // back out. `PLURALS[noun][form]` below it is dead.
467        Ok(py_tuple_repr(forms))
468    }
469
470    /// Port of `Num2Word_IS.to_currency`.
471    ///
472    /// IS intercepts only the true-`int` case and hands everything else to
473    /// `Num2Word_Base.to_currency` verbatim. The int branch is hand-rolled and
474    /// diverges from Base in three observable ways:
475    ///
476    /// 1. It indexes `cr1` directly instead of calling `pluralize`, so ints
477    ///    escape the tuple leak that floats suffer.
478    /// 2. It uses `self.negword` **unstripped** where Base uses
479    ///    `self.negword.strip()` + `" "`. `negword` is `"mínus "`, so the
480    ///    `"%s %s %s"` template emits a *double* space:
481    ///    `to_currency(-12, currency="EUR")` == `"mínus  tólf evrur"`.
482    ///    Confirmed live. The trailing `.strip()` only cleans the ends, so the
483    ///    interior double space survives. No corpus row covers it (the only
484    ///    negative arg, `-12.34`, is a float and takes Base's single-space
485    ///    path), but it is the real behaviour.
486    /// 3. It ignores `adjective=` entirely — no `prefix_currency` call — so
487    ///    `to_currency(2, currency="ISK", adjective=True)` == `"tveir krónur"`,
488    ///    with no "íslenskar". Floats do apply the adjective.
489    fn to_currency(
490        &self,
491        val: &CurrencyValue,
492        currency: &str,
493        cents: bool,
494        separator: Option<&str>,
495        adjective: bool,
496    ) -> Result<String> {
497        // Python: `if isinstance(val, int)` — a real int, never a whole float.
498        // `1` prints no cents; `1.0` prints "núll ('sent', 'sent')".
499        if let CurrencyValue::Int(v) = val {
500            // try:    cr1, cr2 = self.CURRENCY_FORMS[currency]
501            // except (KeyError, AttributeError):
502            //         return super().to_currency(...)
503            //
504            // Only the KeyError half is live (CURRENCY_FORMS is a class attr
505            // that always exists), and it maps to `None` here. super() then
506            // re-raises the same miss as NotImplementedError, so an unknown
507            // code still errors rather than falling back to anything.
508            if let Some(forms) = self.currency_forms(currency) {
509                let minus_str = if v.is_negative() { self.negword() } else { "" };
510                let abs_val = v.abs();
511                let money_str = self.to_cardinal(&abs_val)?;
512
513                // if abs_val == 1: cr1[0]
514                // else:            cr1[1] if len(cr1) > 1 else cr1[0]
515                // The `isinstance(cr1, tuple)` guards are always true for IS's
516                // table, so they collapse into the tuple arms.
517                let currency_str = if abs_val.is_one() {
518                    forms.unit.first()
519                } else {
520                    forms.unit.get(1).or_else(|| forms.unit.first())
521                };
522                // `cr1[0]` on an empty tuple would be IndexError. IS's table
523                // has no empty entry, so this cannot fire.
524                let currency_str = currency_str
525                    .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
526
527                // ("%s %s %s" % (minus_str, money_str, currency_str)).strip()
528                return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
529                    .trim()
530                    .to_string());
531            }
532        }
533
534        // `return super(Num2Word_IS, self).to_currency(val, ...)` — for floats
535        // unconditionally, and for ints whose code missed the table.
536        crate::currency::default_to_currency(
537            self,
538            val,
539            currency,
540            cents,
541            separator.unwrap_or(self.default_separator()),
542            adjective,
543        )
544    }
545
546    fn cards(&self) -> &Cards {
547        &self.cards
548    }
549    fn maxval(&self) -> &BigInt {
550        &self.maxval
551    }
552    fn negword(&self) -> &str {
553        "mínus "
554    }
555    fn pointword(&self) -> &str {
556        "komma"
557    }
558    // is_title stays false (Num2Word_Base.__init__ default; IS never sets it),
559    // so `title()` is an identity and `exclude_title` is never consulted.
560    fn exclude_title(&self) -> &[String] {
561        &self.exclude_title
562    }
563
564    /// Port of `Num2Word_IS.merge`.
565    ///
566    /// ```text
567    /// if lnum == 1 and rnum < 100:            -> (rtext, rnum)
568    /// elif lnum < rnum:                       -> "L R"    , lnum * rnum
569    /// elif lnum > rnum and rnum in self.cards -> "L og R" , lnum + rnum
570    /// else:                                   -> "L R"    , lnum + rnum
571    /// ```
572    ///
573    /// Both middle arms pluralize the right word against the *left* count and
574    /// then genderize the left phrase against the *pluralized* right word —
575    /// order matters, since e.g. `genderize("tveir", "hundruð")` must see the
576    /// already-pluralized "hundruð" (it matches on the "hund" substring, so the
577    /// singular would work too, but "milljón" → "milljónir" does not commute
578    /// with the "illjón" test in general).
579    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
580        let (ltext, lnum) = l;
581        let (rtext, rnum) = r;
582        let hundred = BigInt::from(100);
583
584        if lnum.is_one() && rnum < &hundred {
585            return (rtext.to_string(), rnum.clone());
586        }
587        if lnum < rnum {
588            let rtext = self.pluralize(lnum, rtext);
589            let ltext = self.genderize(ltext, &rtext);
590            return (format!("{} {}", ltext, rtext), lnum * rnum);
591        }
592        if lnum > rnum && self.cards.get(rnum).is_some() {
593            let rtext = self.pluralize(lnum, rtext);
594            let ltext = self.genderize(ltext, &rtext);
595            return (format!("{} og {}", ltext, rtext), lnum + rnum);
596        }
597        (format!("{} {}", ltext, rtext), lnum + rnum)
598    }
599
600    /// Port of `Num2Word_IS.to_ordinal`.
601    ///
602    /// The Python `try: number = int(value) except (ValueError, TypeError):
603    /// return str(value)` guard is a no-op for the integer-only path we
604    /// support, so it is omitted.
605    ///
606    /// # Faithfully reproduced Python bugs
607    ///
608    /// The Python author labels this "a simplified implementation". It is
609    /// wrong Icelandic almost everywhere outside the hardcoded values, and all
610    /// of the following are verbatim corpus expectations:
611    ///
612    /// 1. The catch-all `cardinal + "asti"` glues the suffix onto whatever the
613    ///    cardinal ends with, producing non-words: `to_ordinal(31)` ==
614    ///    "þrjátíu og einnasti", `to_ordinal(42)` == "fjörutíu og tveirasti",
615    ///    `to_ordinal(200)` == "tvö hundruðasti", `to_ordinal(10**7)` ==
616    ///    "tíu milljónirasti".
617    /// 2. `to_ordinal(0)` == "núllasti" — a "zeroth" that no dictionary has.
618    /// 3. **Negatives are not rejected.** `Num2Word_Base.verify_ordinal` is
619    ///    never called, so `to_ordinal(-1)` == "mínus einnasti" instead of
620    ///    raising `TypeError` the way most languages do. There is no error
621    ///    path here at all beyond `to_cardinal`'s overflow check.
622    /// 4. The 20..30 arm hardcodes the tens word but reuses `self.ords` for the
623    ///    unit, so 21 → "tuttugasti og fyrsti" is right while 31 (bug 1) is
624    ///    not — the asymmetry is real.
625    /// 5. Only the *exact* values 30/40/…/90/100/1000 get a proper ordinal;
626    ///    101, 1100, 2000 etc. all fall to the "asti" catch-all.
627    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
628        let cardinal = self.to_cardinal(value)?;
629
630        if let Some(o) = self.ords.get(cardinal.as_str()) {
631            return Ok((*o).to_string());
632        }
633
634        let twenty = BigInt::from(20);
635        let thirty = BigInt::from(30);
636
637        if value == &twenty {
638            return Ok("tuttugasti".to_string());
639        }
640        if value > &twenty && value < &thirty {
641            // self.ords.get(self.to_cardinal(number % 10),
642            //               self.to_cardinal(number % 10))
643            let unit = self.to_cardinal(&value.mod_floor(&BigInt::from(10)))?;
644            let tail = match self.ords.get(unit.as_str()) {
645                Some(o) => (*o).to_string(),
646                None => unit,
647            };
648            return Ok(format!("tuttugasti og {}", tail));
649        }
650
651        // The exact-match ladder, in Python's order.
652        for (n, word) in [
653            (30i64, "þrítugasti"),
654            (40, "fertugasti"),
655            (50, "fimmtugasti"),
656            (60, "sextugasti"),
657            (70, "sjötugasti"),
658            (80, "áttugasti"),
659            (90, "nítugasti"),
660            (100, "hundraðasti"),
661            (1000, "þúsundasti"),
662        ] {
663            if value == &BigInt::from(n) {
664                return Ok(word.to_string());
665            }
666        }
667
668        Ok(format!("{}asti", cardinal))
669    }
670
671    /// Port of `Num2Word_IS.to_ordinal_num`: `str(value) + "."`.
672    ///
673    /// No `verify_ordinal`, no digit inspection — negatives render as "-1.".
674    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
675        Ok(format!("{}.", value))
676    }
677
678    /// `to_ordinal(float/Decimal)`: IS's `to_ordinal` opens with
679    ///
680    /// ```python
681    /// try:
682    ///     number = int(value)
683    /// except (ValueError, TypeError):
684    ///     return str(value)
685    /// ```
686    ///
687    /// — a truncation, not a verify. So there is **no TypeError path**:
688    /// `2.5` → int 2 → "annar", `-1.5` → int -1 → "mínus einnasti",
689    /// `0.5` → "núllasti". `int(nan)`'s ValueError *is* caught, so a float
690    /// NaN returns its own repr "nan"; `int(±inf)` raises OverflowError,
691    /// which the except tuple does not cover, and propagates.
692    fn ordinal_float_entry(&self, value: &crate::floatpath::FloatValue) -> Result<String> {
693        let i: BigInt = match value {
694            crate::floatpath::FloatValue::Float { value: f, .. } => {
695                if f.is_nan() {
696                    // ValueError from int(nan), caught -> str(value).
697                    return Ok("nan".to_string());
698                }
699                if f.is_infinite() {
700                    return Err(N2WError::Overflow(
701                        "cannot convert float infinity to integer".into(),
702                    ));
703                }
704                <BigInt as num_traits::FromPrimitive>::from_f64(f.trunc())
705                    .expect("finite trunc fits BigInt")
706            }
707            // int(Decimal) truncates toward zero; with_scale(0) drops the
708            // fraction the same way.
709            crate::floatpath::FloatValue::Decimal { value: d, .. } => {
710                d.with_scale(0).as_bigint_and_exponent().0
711            }
712        };
713        self.to_ordinal(&i)
714    }
715
716    /// `to_ordinal_num(float/Decimal)`: `str(value) + "."` — the repr
717    /// verbatim, no verify and no truncation: `5.0` → "5.0.", `0.5` → "0.5.",
718    /// `Decimal("5.00")` → "5.00.", `-0.0` → "-0.0.".
719    fn ordinal_num_float_entry(
720        &self,
721        _value: &crate::floatpath::FloatValue,
722        repr_str: &str,
723    ) -> Result<String> {
724        Ok(format!("{}.", repr_str))
725    }
726
727    /// Port of `Num2Word_IS.to_year`: plain `self.to_cardinal(val)`.
728    ///
729    /// IS discards the `suffix`/`longval` kwargs entirely — no century
730    /// splitting, no BC/AD marker. `to_year(-500)` == "mínus fimm hundruð".
731    fn to_year(&self, value: &BigInt) -> Result<String> {
732        self.to_cardinal(value)
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use bigdecimal::BigDecimal;
740    use std::str::FromStr;
741
742    /// Drive a corpus row the way `num2words2-py`'s binding does, so the
743    /// argument literal decides int-vs-float exactly as `repr(value)` did on
744    /// the Python side. That split is the whole ballgame for IS: `1` takes
745    /// IS's own int branch and prints "einn evra", while `1.0` falls through
746    /// to `Num2Word_Base.to_currency` and prints
747    /// "einn ('evra', 'evrur'), núll ('sent', 'sent')".
748    ///
749    /// `has_decimal` is `!is_int` because every non-int row in the corpus is a
750    /// Python `float`, for which `isinstance(val, float)` short-circuits the
751    /// guard to true regardless of the repr.
752    ///
753    /// `cents=true` / `separator=None` / `adjective=false` mirror
754    /// `bench/diff_test.py`, i.e. the kwargs left at their defaults.
755    fn run_currency(arg: &str, code: &str) -> Result<String> {
756        let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
757        let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
758        LangIs::new().to_currency(&v, code, true, None, false)
759    }
760
761    /// The corpus records failures by Python exception name only.
762    fn err_name(e: &N2WError) -> &'static str {
763        match e {
764            N2WError::NotImplemented(_) => "NotImplementedError",
765            N2WError::Overflow(_) => "OverflowError",
766            N2WError::Type(_) => "TypeError",
767            N2WError::Value(_) => "ValueError",
768            N2WError::Index(_) => "IndexError",
769            N2WError::Key(_) => "KeyError",
770            N2WError::Attribute(_) => "AttributeError",
771            _ => "OtherError",
772        }
773    }
774
775    fn check(got: Result<String>, want: std::result::Result<&str, &str>, what: &str) {
776        match (got, want) {
777            (Ok(g), Ok(w)) => assert_eq!(g, w, "{}", what),
778            (Err(e), Err(w)) => assert_eq!(err_name(&e), w, "{}", what),
779            (Ok(g), Err(w)) => panic!("{}: expected {}, got Ok({:?})", what, w, g),
780            (Err(e), Ok(w)) => panic!("{}: expected Ok({:?}), got {:?}", what, w, e),
781        }
782    }
783
784    /// Every `is` currency row in the frozen corpus, verbatim.
785    #[test]
786    fn corpus_currency() {
787        let cases: &[(&str, &str, std::result::Result<&str, &str>)] = &[
788        ("0",        "EUR",   Ok("núll evrur")),
789        ("1",        "EUR",   Ok("einn evra")),
790        ("2",        "EUR",   Ok("tveir evrur")),
791        ("100",      "EUR",   Ok("eitt hundrað evrur")),
792        ("12.34",    "EUR",   Ok("tólf ('evra', 'evrur'), þrjátíu og fjórir ('sent', 'sent')")),
793        ("0.01",     "EUR",   Ok("núll ('evra', 'evrur'), einn ('sent', 'sent')")),
794        ("1.0",      "EUR",   Ok("einn ('evra', 'evrur'), núll ('sent', 'sent')")),
795        ("99.99",    "EUR",   Ok("níutíu og níu ('evra', 'evrur'), níutíu og níu ('sent', 'sent')")),
796        ("1234.56",  "EUR",   Ok("eitt þúsund tvö hundruð þrjátíu og fjórir ('evra', 'evrur'), fimmtíu og sex ('sent', 'sent')")),
797        ("-12.34",   "EUR",   Ok("mínus tólf ('evra', 'evrur'), þrjátíu og fjórir ('sent', 'sent')")),
798        ("1000000",  "EUR",   Ok("ein milljón evrur")),
799        ("0.5",      "EUR",   Ok("núll ('evra', 'evrur'), fimmtíu ('sent', 'sent')")),
800        ("0",        "USD",   Ok("núll dalir")),
801        ("1",        "USD",   Ok("einn dalur")),
802        ("2",        "USD",   Ok("tveir dalir")),
803        ("100",      "USD",   Ok("eitt hundrað dalir")),
804        ("12.34",    "USD",   Ok("tólf ('dalur', 'dalir'), þrjátíu og fjórir ('sent', 'sent')")),
805        ("0.01",     "USD",   Ok("núll ('dalur', 'dalir'), einn ('sent', 'sent')")),
806        ("1.0",      "USD",   Ok("einn ('dalur', 'dalir'), núll ('sent', 'sent')")),
807        ("99.99",    "USD",   Ok("níutíu og níu ('dalur', 'dalir'), níutíu og níu ('sent', 'sent')")),
808        ("1234.56",  "USD",   Ok("eitt þúsund tvö hundruð þrjátíu og fjórir ('dalur', 'dalir'), fimmtíu og sex ('sent', 'sent')")),
809        ("-12.34",   "USD",   Ok("mínus tólf ('dalur', 'dalir'), þrjátíu og fjórir ('sent', 'sent')")),
810        ("1000000",  "USD",   Ok("ein milljón dalir")),
811        ("0.5",      "USD",   Ok("núll ('dalur', 'dalir'), fimmtíu ('sent', 'sent')")),
812        ("0",        "GBP",   Err("NotImplementedError")),
813        ("1",        "GBP",   Err("NotImplementedError")),
814        ("2",        "GBP",   Err("NotImplementedError")),
815        ("100",      "GBP",   Err("NotImplementedError")),
816        ("12.34",    "GBP",   Err("NotImplementedError")),
817        ("0.01",     "GBP",   Err("NotImplementedError")),
818        ("1.0",      "GBP",   Err("NotImplementedError")),
819        ("99.99",    "GBP",   Err("NotImplementedError")),
820        ("1234.56",  "GBP",   Err("NotImplementedError")),
821        ("-12.34",   "GBP",   Err("NotImplementedError")),
822        ("1000000",  "GBP",   Err("NotImplementedError")),
823        ("0.5",      "GBP",   Err("NotImplementedError")),
824        ("0",        "JPY",   Err("NotImplementedError")),
825        ("1",        "JPY",   Err("NotImplementedError")),
826        ("2",        "JPY",   Err("NotImplementedError")),
827        ("100",      "JPY",   Err("NotImplementedError")),
828        ("12.34",    "JPY",   Err("NotImplementedError")),
829        ("0.01",     "JPY",   Err("NotImplementedError")),
830        ("1.0",      "JPY",   Err("NotImplementedError")),
831        ("99.99",    "JPY",   Err("NotImplementedError")),
832        ("1234.56",  "JPY",   Err("NotImplementedError")),
833        ("-12.34",   "JPY",   Err("NotImplementedError")),
834        ("1000000",  "JPY",   Err("NotImplementedError")),
835        ("0.5",      "JPY",   Err("NotImplementedError")),
836        ("0",        "KWD",   Err("NotImplementedError")),
837        ("1",        "KWD",   Err("NotImplementedError")),
838        ("2",        "KWD",   Err("NotImplementedError")),
839        ("100",      "KWD",   Err("NotImplementedError")),
840        ("12.34",    "KWD",   Err("NotImplementedError")),
841        ("0.01",     "KWD",   Err("NotImplementedError")),
842        ("1.0",      "KWD",   Err("NotImplementedError")),
843        ("99.99",    "KWD",   Err("NotImplementedError")),
844        ("1234.56",  "KWD",   Err("NotImplementedError")),
845        ("-12.34",   "KWD",   Err("NotImplementedError")),
846        ("1000000",  "KWD",   Err("NotImplementedError")),
847        ("0.5",      "KWD",   Err("NotImplementedError")),
848        ("0",        "BHD",   Err("NotImplementedError")),
849        ("1",        "BHD",   Err("NotImplementedError")),
850        ("2",        "BHD",   Err("NotImplementedError")),
851        ("100",      "BHD",   Err("NotImplementedError")),
852        ("12.34",    "BHD",   Err("NotImplementedError")),
853        ("0.01",     "BHD",   Err("NotImplementedError")),
854        ("1.0",      "BHD",   Err("NotImplementedError")),
855        ("99.99",    "BHD",   Err("NotImplementedError")),
856        ("1234.56",  "BHD",   Err("NotImplementedError")),
857        ("-12.34",   "BHD",   Err("NotImplementedError")),
858        ("1000000",  "BHD",   Err("NotImplementedError")),
859        ("0.5",      "BHD",   Err("NotImplementedError")),
860        ("0",        "INR",   Err("NotImplementedError")),
861        ("1",        "INR",   Err("NotImplementedError")),
862        ("2",        "INR",   Err("NotImplementedError")),
863        ("100",      "INR",   Err("NotImplementedError")),
864        ("12.34",    "INR",   Err("NotImplementedError")),
865        ("0.01",     "INR",   Err("NotImplementedError")),
866        ("1.0",      "INR",   Err("NotImplementedError")),
867        ("99.99",    "INR",   Err("NotImplementedError")),
868        ("1234.56",  "INR",   Err("NotImplementedError")),
869        ("-12.34",   "INR",   Err("NotImplementedError")),
870        ("1000000",  "INR",   Err("NotImplementedError")),
871        ("0.5",      "INR",   Err("NotImplementedError")),
872        ("0",        "CNY",   Err("NotImplementedError")),
873        ("1",        "CNY",   Err("NotImplementedError")),
874        ("2",        "CNY",   Err("NotImplementedError")),
875        ("100",      "CNY",   Err("NotImplementedError")),
876        ("12.34",    "CNY",   Err("NotImplementedError")),
877        ("0.01",     "CNY",   Err("NotImplementedError")),
878        ("1.0",      "CNY",   Err("NotImplementedError")),
879        ("99.99",    "CNY",   Err("NotImplementedError")),
880        ("1234.56",  "CNY",   Err("NotImplementedError")),
881        ("-12.34",   "CNY",   Err("NotImplementedError")),
882        ("1000000",  "CNY",   Err("NotImplementedError")),
883        ("0.5",      "CNY",   Err("NotImplementedError")),
884        ("0",        "CHF",   Err("NotImplementedError")),
885        ("1",        "CHF",   Err("NotImplementedError")),
886        ("2",        "CHF",   Err("NotImplementedError")),
887        ("100",      "CHF",   Err("NotImplementedError")),
888        ("12.34",    "CHF",   Err("NotImplementedError")),
889        ("0.01",     "CHF",   Err("NotImplementedError")),
890        ("1.0",      "CHF",   Err("NotImplementedError")),
891        ("99.99",    "CHF",   Err("NotImplementedError")),
892        ("1234.56",  "CHF",   Err("NotImplementedError")),
893        ("-12.34",   "CHF",   Err("NotImplementedError")),
894        ("1000000",  "CHF",   Err("NotImplementedError")),
895        ("0.5",      "CHF",   Err("NotImplementedError")),
896        ];
897        for (arg, code, want) in cases {
898            check(run_currency(arg, code), *want, &format!("to_currency({}, {})", arg, code));
899        }
900    }
901
902    /// Every `is` cheque row in the frozen corpus, verbatim.
903    #[test]
904    fn corpus_cheque() {
905        let cases: &[(&str, &str, std::result::Result<&str, &str>)] = &[
906        ("1234.56",  "EUR",   Ok("EITT ÞÚSUND TVÖ HUNDRUÐ ÞRJÁTÍU OG FJÓRIR AND 56/100 EVRUR")),
907        ("1234.56",  "USD",   Ok("EITT ÞÚSUND TVÖ HUNDRUÐ ÞRJÁTÍU OG FJÓRIR AND 56/100 DALIR")),
908        ("1234.56",  "GBP",   Err("NotImplementedError")),
909        ("1234.56",  "JPY",   Err("NotImplementedError")),
910        ("1234.56",  "KWD",   Err("NotImplementedError")),
911        ("1234.56",  "BHD",   Err("NotImplementedError")),
912        ("1234.56",  "INR",   Err("NotImplementedError")),
913        ("1234.56",  "CNY",   Err("NotImplementedError")),
914        ("1234.56",  "CHF",   Err("NotImplementedError")),
915        ];
916        for (arg, code, want) in cases {
917            let v = BigDecimal::from_str(arg).unwrap();
918            check(LangIs::new().to_cheque(&v, code), *want, &format!("to_cheque({}, {})", arg, code));
919        }
920    }
921
922    /// The exact NotImplementedError text, which the corpus records only by
923    /// exception name. `self.__class__.__name__` names IS even though the
924    /// raise happens inside `Num2Word_Base.to_currency` after IS delegates.
925    #[test]
926    fn missing_currency_message() {
927        let l = LangIs::new();
928        // int path: IS's own lookup misses, delegates to super(), super raises.
929        let e = l
930            .to_currency(&CurrencyValue::Int(1.into()), "GBP", true, None, false)
931            .unwrap_err();
932        match e {
933            N2WError::NotImplemented(m) => {
934                assert_eq!(m, "Currency code \"GBP\" not implemented for \"Num2Word_IS\"")
935            }
936            other => panic!("expected NotImplemented, got {:?}", other),
937        }
938        // cheque path raises the same message from the same template.
939        let e = l.to_cheque(&BigDecimal::from(1), "JPY").unwrap_err();
940        match e {
941            N2WError::NotImplemented(m) => {
942                assert_eq!(m, "Currency code \"JPY\" not implemented for \"Num2Word_IS\"")
943            }
944            other => panic!("expected NotImplemented, got {:?}", other),
945        }
946    }
947
948    /// Behaviour the corpus does not cover, pinned against the live Python.
949    ///
950    /// All four were produced by running `Num2Word_IS` in the interpreter; see
951    /// the notes on [`LangIs::to_currency`].
952    #[test]
953    fn uncovered_by_corpus() {
954        let l = LangIs::new();
955        let int = |n: i64| CurrencyValue::Int(n.into());
956        let flt = |s: &str| CurrencyValue::Decimal {
957            value: BigDecimal::from_str(s).unwrap(),
958            has_decimal: true,
959            is_float: true,
960        };
961
962        // 1. Negative *int* keeps IS's unstripped negword -> DOUBLE space.
963        //    (The corpus's only negative arg, -12.34, is a float and takes
964        //    Base's single-space path -- covered above.)
965        assert_eq!(
966            l.to_currency(&int(-12), "EUR", true, None, false).unwrap(),
967            "mínus  tólf evrur"
968        );
969        assert_eq!(
970            l.to_currency(&int(-1), "EUR", true, None, false).unwrap(),
971            "mínus  einn evra"
972        );
973
974        // 2. ISK, the default currency, is never exercised by the corpus.
975        assert_eq!(l.to_currency(&int(1), "ISK", true, None, false).unwrap(), "einn króna");
976        assert_eq!(l.to_currency(&int(2), "ISK", true, None, false).unwrap(), "tveir krónur");
977        assert_eq!(
978            l.to_currency(&flt("12.34"), "ISK", true, None, false).unwrap(),
979            "tólf ('króna', 'krónur'), þrjátíu og fjórir ('eyrir', 'aurar')"
980        );
981
982        // 3. adjective=True is ignored on the int path, applied on the float
983        //    path (and there it is prefixed *inside* the leaked tuple).
984        assert_eq!(l.to_currency(&int(2), "ISK", true, None, true).unwrap(), "tveir krónur");
985        assert_eq!(
986            l.to_currency(&flt("2.5"), "ISK", true, None, true).unwrap(),
987            "tveir ('íslenskar króna', 'íslenskar krónur'), fimmtíu ('eyrir', 'aurar')"
988        );
989
990        // 4. cents=false swaps _cents_verbose for _cents_terse (width 2, since
991        //    IS inherits Base's empty CURRENCY_PRECISION -> divisor 100).
992        assert_eq!(
993            l.to_currency(&flt("12.34"), "EUR", false, None, false).unwrap(),
994            "tólf ('evra', 'evrur'), 34 ('sent', 'sent')"
995        );
996        // A caller-supplied separator replaces the "," default.
997        assert_eq!(
998            l.to_currency(&flt("12.34"), "EUR", true, Some(" og"), false).unwrap(),
999            "tólf ('evra', 'evrur') og þrjátíu og fjórir ('sent', 'sent')"
1000        );
1001
1002        // 5. has_decimal, not the numeric value, gates the cents segment:
1003        //    Decimal("5") and Decimal("5.00") are numerically equal.
1004        let d5 = CurrencyValue::Decimal { value: BigDecimal::from_str("5").unwrap(), has_decimal: false, is_float: false };
1005        let d500 = CurrencyValue::Decimal { value: BigDecimal::from_str("5.00").unwrap(), has_decimal: true, is_float: false };
1006        assert_eq!(l.to_currency(&d5, "EUR", true, None, false).unwrap(), "fimm ('evra', 'evrur')");
1007        assert_eq!(
1008            l.to_currency(&d500, "EUR", true, None, false).unwrap(),
1009            "fimm ('evra', 'evrur'), núll ('sent', 'sent')"
1010        );
1011
1012        // 6. Negative cheque, and a cheque whose cents are zero.
1013        assert_eq!(
1014            l.to_cheque(&BigDecimal::from_str("-1234.56").unwrap(), "EUR").unwrap(),
1015            "MINUS EITT ÞÚSUND TVÖ HUNDRUÐ ÞRJÁTÍU OG FJÓRIR AND 56/100 EVRUR"
1016        );
1017        assert_eq!(
1018            l.to_cheque(&BigDecimal::from_str("1.0").unwrap(), "ISK").unwrap(),
1019            "EINN AND 00/100 KRÓNUR"
1020        );
1021    }
1022
1023    /// `pluralize` on the tuple call site ignores `n` entirely -- both live
1024    /// branches return the tuple untouched. Guards the leak against a
1025    /// well-meaning "fix".
1026    #[test]
1027    fn pluralize_tuple_is_count_independent() {
1028        let l = LangIs::new();
1029        let forms: Vec<String> = vec!["evra".into(), "evrur".into()];
1030        for n in [0i64, 1, 2, 11, 21, 34, 101] {
1031            // UFCS is required: the inherent `pluralize(&self, n, &str)` wins
1032            // plain method resolution, which is precisely why `merge` keeps
1033            // getting the string behaviour while `default_to_currency` --
1034            // generic over `L: Lang`, where only the trait method is visible --
1035            // gets the tuple one.
1036            assert_eq!(
1037                Lang::pluralize(&l, &BigInt::from(n), &forms).unwrap(),
1038                "('evra', 'evrur')",
1039                "n = {}",
1040                n
1041            );
1042        }
1043        // The string call site (merge's) is unaffected and still pluralizes.
1044        assert_eq!(l.pluralize(&BigInt::from(2), "hundrað"), "hundruð");
1045        assert_eq!(l.pluralize(&BigInt::from(1), "hundrað"), "hundrað");
1046    }
1047}