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}