Skip to main content

num2words2_core/
lang_am.rs

1//! Port of `lang_AM.py` (Amharic).
2//!
3//! Registry check: `CONVERTER_CLASSES["am"]` → `lang_AM.Num2Word_AM()`, which
4//! is the class ported here.
5//!
6//! Shape: **engine**. `Num2Word_AM` subclasses `lang_EUR.Num2Word_EUR` →
7//! `Num2Word_Base`, defines `mid_numwords`/`low_numwords` + `merge`, and lets
8//! the base `splitnum`/`clean` machinery drive the fold. `to_cardinal` *is*
9//! overridden, but only to bolt two things onto the base algorithm (the
10//! negative-recursion form and the `value == 100` special case) — the
11//! splitnum/clean core is still the engine, so `cards`/`maxval`/`merge` are
12//! all live here.
13//!
14//! # Inheritance chain, resolved
15//!
16//! * `Num2Word_EUR.setup()` builds `self.high_numwords` from the Latin illion
17//!   prefixes. **AM's `set_high_numwords` ignores its `high` argument
18//!   entirely**, so none of that reaches `cards` — `gen_high_numwords` is dead
19//!   code for this language and is deliberately not ported.
20//! * `Num2Word_EUR.set_high_numwords` (the `word + GIGA_SUFFIX` routine that
21//!   would emit mixed-script tokens like "trሚሊዮን") is fully overridden. The
22//!   Python source comments on this explicitly.
23//! * `Num2Word_Base.__init__` → `set_numwords()` calls `set_high_numwords`
24//!   *first*, and AM's version does `self.cards.clear()` before populating.
25//!   Since mid/low are inserted afterwards, the clear is harmless — but it
26//!   does mean `cards` holds *exactly* the 10 high entries below plus mid+low,
27//!   with no 10^36+ rungs. Hence `MAXVAL = 1000 * 10**33 = 10**36`.
28//! * `Num2Word_Base.verify_ordinal` supplies the `TypeError` on negatives.
29//! * `Num2Word_Base.to_splitnum` / `inflect` drive `to_year`'s second branch.
30//!
31//! * `Num2Word_Base.to_cardinal_float` / `float2tuple` are **not** overridden
32//!   anywhere in AM's chain, so the float/Decimal path is the base one and
33//!   [`Lang::to_cardinal_float`]'s default ([`crate::floatpath`]) already
34//!   serves it. See "Float and Decimal input" below for why that is enough.
35//!
36//! * `Num2Word_EUR.pluralize` (`forms[0 if n == 1 else 1]`) and
37//!   `Num2Word_EUR.CURRENCY_ADJECTIVES` are inherited as-is. `CURRENCY_FORMS`
38//!   is *not*: AM shadows it with its own three-code table, so the
39//!   `Num2Word_EN.__init__` mutation that rewrites `Num2Word_EUR`'s shared
40//!   class dict in place never reaches AM. Verified against the live
41//!   interpreter — `Num2Word_AM.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS`
42//!   is `False`, and the table holds exactly ETB/USD/JPY. Every other code
43//!   (EUR and GBP included) raises NotImplementedError.
44//! * `CURRENCY_PRECISION` resolves to `Num2Word_Base`'s empty dict, so
45//!   `.get(code, 100)` is 100 for every code and the trait's default
46//!   `currency_precision` already matches. Not overridden here.
47//!
48//! # Float and Decimal input
49//!
50//! **No `to_cardinal_float` override belongs here.** AM reaches the float path
51//! through its `to_cardinal` override, whose dispatch is byte-for-byte the one
52//! `Num2Word_Base.to_cardinal` already performs:
53//!
54//! ```python
55//! try:
56//!     assert int(value) == value
57//! except (ValueError, TypeError, AssertionError):
58//!     return self.to_cardinal_float(value)
59//! ```
60//!
61//! So AM only *looks* like one of the 146 languages that hand-roll float
62//! handling inside `to_cardinal`; it delegates to the untouched
63//! `Num2Word_Base.to_cardinal_float`, and the trait default over
64//! [`crate::floatpath::default_to_cardinal_float`] is that method. The two
65//! places AM's override actually diverges from Base — the negword recursion
66//! and the `value == 100` short-circuit — are both reached *through* the float
67//! path, because `to_cardinal_float` calls back into `self.to_cardinal(pre)`
68//! for the integral part and once per fractional digit. That callback is a
69//! virtual dispatch in Python and a trait dispatch here, so both land on AM's
70//! override and both quirks survive:
71//!
72//! * `100.5` → "መቶ ነጥብ አምስት", *not* "አንድ መቶ ነጥብ አምስት" — the `== 100`
73//!   short-circuit fires on `pre`. (Corpus-confirmed; contrast quirk 1, where
74//!   a nested 100 keeps its "አንድ".) `-100.5` → "ሰልቢ መቶ ነጥብ አምስት" likewise.
75//! * `-12.34` → "ሰልቢ አሥራ ሁለት ነጥብ ሦስት አራት" via `to_cardinal(-12)` recursing.
76//!   Base would have produced the identical string through `"%s " %
77//!   negword.strip()`, since AM's negword is "ሰልቢ " — trailing space included.
78//! * `-0.5` → "ሰልቢ ዜሮ ነጥብ አምስት" comes from the *other* sign path:
79//!   `int(-0.5) == 0` carries no minus, so `to_cardinal_float`'s own
80//!   `if value < 0 and pre == 0` prepends `negword.strip()`. AM's recursion is
81//!   never reached here.
82//!
83//! `0.0` and `1.0` never see this path at all: `int(0.0) == 0.0` holds, the
84//! assert passes, and they take the integer branch — hence corpus rows "0.0" →
85//! "ዜሮ" and "1.0" → "አንድ" with no "ነጥብ". Reaching `to_cardinal_float(2.0)`
86//! *directly* does yield "ሁለት ነጥብ ዜሮ", which is why the entry test below pins
87//! it separately from the dispatcher's behaviour.
88//!
89//! The f64 artefacts `float2tuple` depends on are load-bearing and are the
90//! shared port's business, not AM's — `2.675` reaches it as
91//! `674.9999999999998` and is rescued to `675` by the `< 0.01` heuristic, so
92//! AM prints "ሁለት ነጥብ ስድስት ሰባት አምስት". Likewise the Float/Decimal split is
93//! observable here: `98746251323029.99` ends "…ዘጠኝ ስምንት" as an f64 (its repr
94//! is already `…029.98`) but "…ዘጠኝ ዘጠኝ" as a `Decimal`. Both are pinned below.
95//!
96//! # Faithfully reproduced Python quirks
97//!
98//! All of these are what the interpreter actually emits and are confirmed
99//! against the frozen corpus. They are preserved verbatim, not fixed:
100//!
101//! 1. **`to_cardinal(100)` == "መቶ", but 100 nested inside a larger number is
102//!    "አንድ መቶ".** The `if value == 100` short-circuit in `to_cardinal` only
103//!    fires at top level; `merge` reaches its `rnum > lnum` arm for the
104//!    (1, 100) pair and prefixes "አንድ". So `100` → "መቶ" while `1100` →
105//!    "አንድ ሺህ አንድ መቶ" and `101` → "አንድ መቶ አንድ". Corpus-confirmed.
106//! 2. **`low_numwords` has a typo at 16**: "አስራ ስድስት" — every other teen uses
107//!    "አሥራ" (with ሥ), 16 alone uses "አስራ" (with ስ). Kept verbatim, so
108//!    `to_cardinal(16)` == "አስራ ስድስት" and `to_ordinal(16)` == "አስራ ስድስተኛ".
109//!    Note `ords` *does* contain the correctly-spelled "አሥራ ስድስት" key — which
110//!    never matches anything (see 3).
111//! 3. **The 9 multi-word `ords` keys are unreachable dead entries.**
112//!    `to_ordinal` looks the suffix up as `outwords[-1].split("-")[-1]`, i.e. a
113//!    single *space-delimited* token, so keys like "አሥራ አንድ" (which contain a
114//!    space) can never be hit. They are retained here for table fidelity. The
115//!    output is unaffected: 11 → "አሥራ አንደኛ" either way, because the fallback
116//!    path ordinalises just the trailing "አንድ" → "አንደኛ".
117//! 4. **`to_ordinal` splits on "-" but re-joins with " ".** EN joins back with
118//!    "-"; AM uses `" ".join(lastwords)`. Latent, not observable: AM's `merge`
119//!    never emits a hyphen, so `lastwords` always has exactly one element and
120//!    the join is a no-op. Reproduced anyway — see [`LangAm::to_ordinal`].
121//! 5. **`exclude_title` lists "አሉታዊ" but `negword` is "ሰልቢ ".** The exclusion
122//!    list can therefore never protect the negative word. Moot in practice:
123//!    `is_title` is `False` in `Num2Word_Base.__init__` and *nothing* in the
124//!    library ever sets it True, so `title()` is unconditionally the identity.
125//!    Both fields are carried over for completeness.
126//! 6. **`to_year` on negatives leans on Python's floor `//` and `%`.**
127//!    `to_year(-44)` == "ሰልቢ አንድ መቶ አምሳ ስድስት" — literally "negative one
128//!    hundred fifty six" for the year 44 BC. This falls out of
129//!    `divmod(-44, 100) == (-1, 56)`: the high part renders as `to_cardinal(-1)`
130//!    → "ሰልቢ አንድ" and the low part as the *positive* 56. Rust's `/` and `%`
131//!    truncate toward zero and would give `(0, -44)` instead, so this port uses
132//!    `div_mod_floor`/`div_floor`/`mod_floor` throughout. Corpus-confirmed.
133//!
134//! # Faithfully reproduced Python quirks — currency
135//!
136//! 7. **`CURRENCY_FORMS` entries are 3-tuples, not 2-tuples.** Every other
137//!    table in the library is `(unit_forms, subunit_forms)`; AM's carries a
138//!    third element, a per-currency default separator (`" ከ"`). See
139//!    `AmCurrency`. This single shape difference drives quirks 8 and 9.
140//! 8. **`to_cheque` is dead for Amharic — every call raises.** AM does not
141//!    override it, and the inherited `Num2Word_Base.to_cheque` opens with
142//!    `cr1, _cr2 = self.CURRENCY_FORMS[currency]` inside a `try` that catches
143//!    only `KeyError`. Against a 3-tuple that unpack raises `ValueError:
144//!    too many values to unpack (expected 2)`, which sails straight through
145//!    the handler. So the currency code only selects *which* exception:
146//!    ETB/USD/JPY → ValueError, everything else → NotImplementedError. The
147//!    corpus pins both halves (`cheque:USD` → ValueError, `cheque:EUR` →
148//!    NotImplementedError). See [`LangAm::to_cheque`].
149//! 9. **`to_currency` ignores `CURRENCY_PRECISION` and hardcodes 100.** The
150//!    divisor is the literal `100` in `has_fractional_cents`, and
151//!    `parse_currency_parts` is called without a `divisor=` kwarg, taking its
152//!    100 default. JPY therefore renders sen — `12.34 JPY` → "…ከ ሠላሳ አራት ሴን"
153//!    — where `Num2Word_Base.to_currency` would consult the precision table
154//!    and skip the subunit for a 0-decimal currency. Moot in practice, since
155//!    AM's `CURRENCY_PRECISION` is empty anyway, but the two would diverge if
156//!    it ever gained an entry. Corpus-confirmed.
157//! 10. **`to_currency` accepts `adjective` and never reads it.** The parameter
158//!    is in the signature but the body has no `if adjective:` branch, so
159//!    `CURRENCY_ADJECTIVES` — inherited intact from EUR — is unreachable
160//!    through this language. `adjective=True` changes nothing. The table is
161//!    still exposed via [`Lang::currency_adjective`] for data fidelity, the
162//!    same way the dead `ords` entries are kept above.
163//! 11. **A caller-supplied `separator=","` is silently overridden.** The guard
164//!    is `if separator == ","`, a *value* test, not an "was the kwarg omitted"
165//!    test — so passing the base default explicitly still swaps in `" ከ"`.
166//! 12. **A custom separator double-spaces.** The format string is
167//!    `"%s%s %s%s %s %s"`: the separator is glued to the unit word with no
168//!    space and followed by `" %s"`. AM's own `" ከ"` is written with a leading
169//!    space to compensate, so `separator=" ና "` yields `"… ብር ና  ሠላሳ አራት …"`
170//!    with two spaces. Falls out of the format string; not special-cased.
171//!
172//! # Error variants
173//!
174//! * `to_ordinal`/`to_ordinal_num` on a negative → `verify_ordinal` raises
175//!   `TypeError` → [`N2WError::Type`]. Corpus-confirmed for -1, -7, -21, -42,
176//!   -100, -999, -1000, -1000000.
177//! * `to_cardinal(v)` for `v >= 10**36` → `OverflowError` → [`N2WError::Overflow`].
178//!   (Not exercised by the corpus; the largest case is 10^21.)
179//! * `to_currency(v, "<unknown>")` → `NotImplementedError` →
180//!   [`N2WError::NotImplemented`]. Raised *before* `to_cardinal` runs, so
181//!   `to_currency(10**40, "EUR")` is NotImplementedError while
182//!   `to_currency(10**40, "USD")` is OverflowError. The ordering is preserved.
183//! * `to_cheque(v, "ETB"|"USD"|"JPY")` → `ValueError` → [`N2WError::Value`]
184//!   (quirk 8). Any other code → `NotImplementedError`.
185
186use crate::base::{
187    clean, set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result,
188};
189use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
190use crate::floatpath::{default_to_cardinal_float, float2tuple, FloatValue};
191use crate::strnum::python_decimal_str;
192use bigdecimal::BigDecimal;
193use num_bigint::BigInt;
194use num_integer::Integer;
195use num_traits::{One, Signed, ToPrimitive, Zero};
196use std::collections::HashMap;
197
198/// `GIGA_SUFFIX` on `Num2Word_AM`. Referenced via `self.GIGA_SUFFIX` in
199/// `set_high_numwords`, so it lands at 10^9.
200const GIGA_SUFFIX: &str = "ቢሊዮን";
201/// `MEGA_SUFFIX` on `Num2Word_AM`. Lands at 10^6.
202const MEGA_SUFFIX: &str = "ሚሊዮን";
203
204/// The hundreds word, used both as a card and as `to_year`'s `hightxt`.
205const HUNDRED_TXT: &str = "መቶ";
206
207/// The generic ordinal suffix appended when `ords` misses.
208const ORD_SUFFIX: &str = "ኛ";
209
210/// The zero word, as `to_currency` hardcodes it.
211///
212/// Python writes the bare literal `"ዜሮ"` in the `right > 0` guard rather than
213/// calling `self.to_cardinal(0)`. The two agree (card 0 is the same word), but
214/// they are separate sources in the original and are kept separate here.
215const ZERO_TXT: &str = "ዜሮ";
216
217/// `to_currency`'s hardcoded subunit divisor.
218///
219/// Python writes `100` twice as a literal — once in `has_fractional_cents` and
220/// once implicitly, by calling `parse_currency_parts` without `divisor=`. It
221/// never consults `CURRENCY_PRECISION`. See module docs (9).
222const CENTS_DIVISOR: i64 = 100;
223
224/// One `Num2Word_AM.CURRENCY_FORMS` entry.
225///
226/// AM's entries are **3-tuples** — `(unit_forms, subunit_forms, separator)` —
227/// where the rest of the library carries 2-tuples. The third slot is a
228/// per-currency default separator that `to_currency` unpacks and substitutes
229/// whenever `separator` is still `","`.
230///
231/// The extra element is modelled here rather than flattened because it is
232/// load-bearing twice over: it supplies the separator, and its mere presence
233/// is what makes the inherited `to_cheque` raise ValueError. See module docs
234/// (7) and (8).
235struct AmCurrency {
236    /// The tuple's first two elements, in the shape the `Lang` trait wants.
237    forms: CurrencyForms,
238    /// The tuple's third element — `to_currency`'s `default_separator` local.
239    separator: &'static str,
240}
241
242/// `Num2Word_AM.CURRENCY_FORMS`.
243///
244/// AM declares this in its own class body, shadowing `Num2Word_EUR`'s. That
245/// matters: `Num2Word_EN.__init__` mutates *EUR's* dict in place at import
246/// time (adding ~24 codes and rewriting EUR/GBP), and none of that lands here.
247/// So the table is exactly these three codes — confirmed against the live
248/// interpreter, and pinned by the corpus, where `currency:EUR` and
249/// `currency:GBP` both expect NotImplementedError.
250fn build_currency() -> HashMap<&'static str, AmCurrency> {
251    // Both subunit tables are the same word twice; EUR's pluralize indexes 0
252    // or 1 and gets the identical string either way. Kept as written.
253    const SANTIM: [&str; 2] = ["ሳንቲም", "ሳንቲም"];
254    const SEP: &str = " ከ";
255
256    let mut m: HashMap<&'static str, AmCurrency> = HashMap::new();
257    m.insert(
258        "ETB",
259        AmCurrency {
260            forms: CurrencyForms::new(&["ብር", "ብር"], &SANTIM),
261            separator: SEP,
262        },
263    );
264    m.insert(
265        "USD",
266        AmCurrency {
267            forms: CurrencyForms::new(&["ዶላር", "ዶላር"], &SANTIM),
268            separator: SEP,
269        },
270    );
271    m.insert(
272        "JPY",
273        AmCurrency {
274            forms: CurrencyForms::new(&["የን", "የን"], &["ሴን", "ሴን"]),
275            separator: SEP,
276        },
277    );
278    m
279}
280
281/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged.
282///
283/// Dead for this language: `Num2Word_AM.to_currency` takes an `adjective`
284/// parameter and never reads it, and nothing else consults the table. Kept for
285/// data fidelity — see module docs (10). Note the codes here are mostly ones
286/// AM has no `CURRENCY_FORMS` entry for, so they would raise before an
287/// adjective could ever apply.
288fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
289    [
290        ("AUD", "Australian"),
291        ("BYN", "Belarusian"),
292        ("CAD", "Canadian"),
293        ("EEK", "Estonian"),
294        ("USD", "US"),
295        ("RUB", "Russian"),
296        ("NOK", "Norwegian"),
297        ("MXN", "Mexican"),
298        ("RON", "Romanian"),
299        ("INR", "Indian"),
300        ("HUF", "Hungarian"),
301        ("ISK", "íslenskar"),
302        ("UZS", "Uzbekistan"),
303        ("SAR", "Saudi"),
304        ("JPY", "Japanese"),
305        ("KRW", "Korean"),
306    ]
307    .into_iter()
308    .collect()
309}
310
311/// Best-effort `str(number)` for the TypeError messages (the corpus compares
312/// exception types only, so the float arm's repr need not be byte-exact).
313fn am_py_num_str(value: &FloatValue) -> String {
314    match value {
315        FloatValue::Float { value, .. } => format!("{}", value),
316        FloatValue::Decimal { value, .. } => python_decimal_str(value),
317    }
318}
319
320pub struct LangAm {
321    cards: Cards,
322    maxval: BigInt,
323    ords: HashMap<&'static str, &'static str>,
324    exclude_title: Vec<String>,
325    hundred: BigInt,
326    ten: BigInt,
327    currency: HashMap<&'static str, AmCurrency>,
328    currency_adjectives: HashMap<&'static str, &'static str>,
329}
330
331impl Default for LangAm {
332    fn default() -> Self {
333        Self::new()
334    }
335}
336
337impl LangAm {
338    pub fn new() -> Self {
339        let mut cards = Cards::new();
340
341        // Num2Word_AM.set_high_numwords: clears cards, then installs exactly
342        // these ten rungs. The `high` argument (the EUR Latin-prefix illion
343        // list) is never read. Descending order matches the dict literal in
344        // the Python source, which is what fixes `list(cards.keys())[0]`.
345        let ten_big = BigInt::from(10u8);
346        for (exp, word) in [
347            (33u32, "ዴሲሊዮን"),
348            (30, "ኖኒሊዮን"),
349            (27, "ኦክቲሊዮን"),
350            (24, "ሴፕቲሊዮን"),
351            (21, "ሴክስቲሊዮን"),
352            (18, "ኲንቲሊዮን"),
353            (15, "ኳድሪሊዮን"),
354            (12, "ትሪሊዮን"),
355            (9, GIGA_SUFFIX),
356            (6, MEGA_SUFFIX),
357        ] {
358            cards.insert(ten_big.pow(exp), word);
359        }
360
361        // Num2Word_AM.setup: mid_numwords
362        set_mid_numwords(
363            &mut cards,
364            &[
365                (1000, "ሺህ"),
366                (100, HUNDRED_TXT),
367                (90, "ዘጠና"),
368                (80, "ሰማንያ"),
369                (70, "ሰባ"),
370                (60, "ስድሳ"),
371                (50, "አምሳ"),
372                (40, "አርባ"),
373                (30, "ሠላሳ"),
374            ],
375        );
376
377        // Num2Word_AM.setup: low_numwords → values 20 down to 0.
378        // "አስራ ስድስት" (16) is the source's own typo — see module docs (2).
379        set_low_numwords(
380            &mut cards,
381            &[
382                "ሃያ",
383                "አሥራ ዘጠኝ",
384                "አሥራ ስምንት",
385                "አሥራ ሰባት",
386                "አስራ ስድስት",
387                "አሥራ አምስት",
388                "አሥራ አራት",
389                "አሥራ ሦስት",
390                "አሥራ ሁለት",
391                "አሥራ አንድ",
392                "አሥር",
393                "ዘጠኝ",
394                "ስምንት",
395                "ሰባት",
396                "ስድስት",
397                "አምስት",
398                "አራት",
399                "ሦስት",
400                "ሁለት",
401                "አንድ",
402                "ዜሮ",
403            ],
404        );
405
406        // Num2Word_Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0].
407        // Cards is stored descending, so `highest()` is that first key: 10^33.
408        // → MAXVAL = 10^36.
409        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
410
411        // Num2Word_AM.setup: self.ords. The nine multi-word entries below are
412        // dead — see module docs (3) — but are kept for table fidelity.
413        let ords: HashMap<&str, &str> = [
414            ("አንድ", "አንደኛ"),
415            ("ሁለት", "ሁለተኛ"),
416            ("ሦስት", "ሦስተኛ"),
417            ("አራት", "አራተኛ"),
418            ("አምስት", "አምስተኛ"),
419            ("ስድስት", "ስድስተኛ"),
420            ("ሰባት", "ሰባተኛ"),
421            ("ስምንት", "ስምንተኛ"),
422            ("ዘጠኝ", "ዘጠነኛ"),
423            ("አሥር", "አሥረኛ"),
424            // --- unreachable from here down (keys contain a space) ---
425            ("አሥራ አንድ", "አሥራ አንደኛ"),
426            ("አሥራ ሁለት", "አሥራ ሁለተኛ"),
427            ("አሥራ ሦስት", "አሥራ ሦስተኛ"),
428            ("አሥራ አራት", "አሥራ አራተኛ"),
429            ("አሥራ አምስት", "አሥራ አምስተኛ"),
430            ("አሥራ ስድስት", "አሥራ ስድስተኛ"),
431            ("አሥራ ሰባት", "አሥራ ሰባተኛ"),
432            ("አሥራ ስምንት", "አሥራ ስምንተኛ"),
433            ("አሥራ ዘጠኝ", "አሥራ ዘጠነኛ"),
434        ]
435        .into_iter()
436        .collect();
437
438        LangAm {
439            cards,
440            maxval,
441            ords,
442            // Num2Word_AM.setup: self.exclude_title. Never consulted, since
443            // is_title is always False — see module docs (5).
444            exclude_title: vec!["እና".into(), "ነጥብ".into(), "አሉታዊ".into()],
445            hundred: BigInt::from(100),
446            ten: BigInt::from(10),
447            // Built once here, never per call. `to_currency`/`to_cheque` only
448            // read these tables, and rebuilding them per call is what made an
449            // earlier revision of this port slower than the Python it replaces.
450            currency: build_currency(),
451            currency_adjectives: build_currency_adjectives(),
452        }
453    }
454
455    /// `Num2Word_Base.verify_ordinal`. The float branch (`errmsg_floatord`)
456    /// cannot fire for BigInt input; only the negative branch is reachable.
457    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
458        if value.is_negative() {
459            return Err(N2WError::Type(format!(
460                "Cannot treat negative num {} as ordinal.",
461                value
462            )));
463        }
464        Ok(())
465    }
466
467    /// `verify_ordinal` for a float/Decimal: the float check fires first,
468    /// then the negative one — both TypeError with Base's default wording
469    /// (AM never overrides the messages). `-0.0` passes both. Returns the
470    /// whole value on success.
471    fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
472        let whole = match value.as_whole_int() {
473            Some(i) => i,
474            None => {
475                return Err(N2WError::Type(format!(
476                    "Cannot treat float {} as ordinal.",
477                    am_py_num_str(value)
478                )))
479            }
480        };
481        if whole.is_negative() {
482            return Err(N2WError::Type(format!(
483                "Cannot treat negative num {} as ordinal.",
484                am_py_num_str(value)
485            )));
486        }
487        Ok(whole)
488    }
489
490    /// Python's `self.to_cardinal(x)` for a Decimal — AM's own `to_cardinal`
491    /// asserts `int(value) == value` (whole → integer path, with the bare
492    /// "መቶ" for 100) and otherwise falls into `Num2Word_Base
493    /// .to_cardinal_float` ("ነጥብ" + a word per digit).
494    fn cardinal_num_dec(&self, x: &BigDecimal) -> Result<String> {
495        if x.is_integer() {
496            return self.to_cardinal(&x.with_scale(0).as_bigint_and_exponent().0);
497        }
498        let precision = x.as_bigint_and_exponent().1.max(0) as u32;
499        default_to_cardinal_float(
500            self,
501            &FloatValue::Decimal {
502                value: x.clone(),
503                precision,
504            },
505            None,
506        )
507    }
508
509    /// `Num2Word_Base.inflect`, specialised to AM's only caller.
510    ///
511    /// Python: `text.split("/")`; return `text[0]` if `value == 1` else
512    /// `"".join(text)`. AM only ever passes "መቶ", which contains no "/", so
513    /// both arms collapse to "መቶ" for every `value` — including the negative
514    /// `high` that `to_year` hands it. Kept as a function to keep the call
515    /// shape honest.
516    fn inflect(&self, _value: &BigInt, text: &str) -> String {
517        // No "/" in AM's hightxt, so split/join round-trips unchanged.
518        text.to_string()
519    }
520
521    /// The `except KeyError: raise NotImplementedError(...)` handler that
522    /// `to_currency` and the inherited `to_cheque` spell out identically.
523    fn currency_not_implemented(&self, currency: &str) -> N2WError {
524        N2WError::NotImplemented(format!(
525            "Currency code \"{}\" not implemented for \"{}\"",
526            currency,
527            self.lang_name()
528        ))
529    }
530}
531
532impl Lang for LangAm {
533    /// This language's own `to_currency(currency=...)` default,
534    /// read from the live Python signature. Only 44 of 156 use EUR.
535    fn default_currency(&self) -> &str {
536        "ETB"
537    }
538
539    /// This language's own `to_currency(separator=...)` default,
540    /// read from the live Python signature. Base's is ",", but only
541    /// 36 of 149 languages actually use it — most default to " " or a
542    /// conjunction, so inheriting Base's comma silently corrupts them.
543    fn default_separator(&self) -> &str {
544        ","
545    }
546
547    fn cards(&self) -> &Cards {
548        &self.cards
549    }
550    fn maxval(&self) -> &BigInt {
551        &self.maxval
552    }
553    fn negword(&self) -> &str {
554        // Note the trailing space: AM's to_cardinal concatenates negword
555        // directly (`self.negword + ...`) rather than going through the
556        // base's `"%s " % negword.strip()`.
557        "ሰልቢ "
558    }
559    fn pointword(&self) -> &str {
560        "ነጥብ"
561    }
562    fn exclude_title(&self) -> &[String] {
563        &self.exclude_title
564    }
565
566    /// `Num2Word_AM.merge`.
567    ///
568    /// The final `else` is reachable for AM (unlike EN's ", " arm which is
569    /// mostly cosmetic): e.g. 1100 folds ("አንድ ሺህ", 1000) with ("አንድ መቶ", 100)
570    /// where `lnum >= 100` and `rnum == 100`, so the `lnum >= 100 > rnum`
571    /// guard fails (100 > 100 is false) and control falls through to the
572    /// additive default. Both that arm and the `100 > lnum > rnum` arm emit a
573    /// plain space, so they are textually identical — only the returned
574    /// magnitude differs, and both add.
575    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
576        let (ltext, lnum) = l;
577        let (rtext, rnum) = r;
578        let hundred = &self.hundred;
579
580        if lnum.is_one() && rnum < hundred {
581            (rtext.to_string(), rnum.clone())
582        } else if hundred > lnum && lnum > rnum {
583            (format!("{} {}", ltext, rtext), lnum + rnum)
584        } else if lnum >= hundred && hundred > rnum {
585            (format!("{} {}", ltext, rtext), lnum + rnum)
586        } else if rnum > lnum {
587            (format!("{} {}", ltext, rtext), lnum * rnum)
588        } else {
589            // Default case: lnum > rnum, both could be >= 100
590            (format!("{} {}", ltext, rtext), lnum + rnum)
591        }
592    }
593
594    /// `Num2Word_AM.to_cardinal`.
595    ///
596    /// Diverges from `Num2Word_Base.to_cardinal` in two ways, both preserved:
597    /// negatives *recurse* (`negword + to_cardinal(-value)`) instead of being
598    /// flattened with `abs()`, and `value == 100` short-circuits to a bare
599    /// "መቶ". The negative check runs **before** the MAXVAL check, so
600    /// `to_cardinal(-10**36)` still overflows — via the recursive call.
601    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
602        if value.is_negative() {
603            let inner = self.to_cardinal(&(-value))?;
604            return Ok(format!("{}{}", self.negword(), inner));
605        }
606
607        if value >= &self.maxval {
608            return Err(N2WError::Overflow(format!(
609                "abs({}) must be less than {}.",
610                value, self.maxval
611            )));
612        }
613
614        // `out` is "" at this point in Python; the concatenations below are
615        // therefore no-ops, but the 100 short-circuit is not.
616        if value == &self.hundred {
617            return Ok(self.title(HUNDRED_TXT));
618        }
619
620        // Unreachable in practice: card 0 exists and `value >= 0` here, so
621        // splitnum always finds a rung. Python would hit
622        // `TypeError: object of type 'NoneType' has no len()` inside clean().
623        let tree = splitnum(self, value)
624            .ok_or_else(|| N2WError::Type("object of type 'NoneType' has no len()".into()))?;
625        let words = match clean(self, tree) {
626            Node::Leaf(t, _) => t,
627            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
628        };
629        Ok(self.title(&words))
630    }
631
632    /// `Num2Word_AM.to_ordinal`.
633    ///
634    /// Ordinalises only the final space-delimited token: look it up in `ords`,
635    /// else append "ኛ". The `split("-")` / `" ".join(...)` asymmetry is the
636    /// source's, not a transcription slip — see module docs (4).
637    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
638        self.verify_ordinal(value)?;
639
640        // Python `.split(" ")` — a single-space split that keeps empties,
641        // which is Rust's `split(' ')`, *not* `split_whitespace()`.
642        let cardinal = self.to_cardinal(value)?;
643        let mut outwords: Vec<String> = cardinal.split(' ').map(String::from).collect();
644
645        // `outwords` is never empty: "".split(" ") == [""] in Python too.
646        let last = outwords[outwords.len() - 1].clone();
647        let mut lastwords: Vec<String> = last.split('-').map(String::from).collect();
648        let lastword = lastwords[lastwords.len() - 1].to_lowercase();
649
650        // Ethiopic has no case, so `.lower()` is the identity here; kept for
651        // faithfulness to the Python.
652        let newlast = match self.ords.get(lastword.as_str()) {
653            Some(o) => (*o).to_string(),
654            None => format!("{}{}", lastword, ORD_SUFFIX),
655        };
656
657        let li = lastwords.len() - 1;
658        lastwords[li] = self.title(&newlast);
659        let oi = outwords.len() - 1;
660        // Python: `outwords[-1] = " ".join(lastwords)` — split on "-", joined
661        // on " ". No-op in practice (lastwords has length 1 always).
662        outwords[oi] = lastwords.join(" ");
663
664        Ok(outwords.join(" "))
665    }
666
667    /// `Num2Word_AM.to_ordinal_num`: `"%s%s" % (value, self.to_ordinal(value)[-1:])`.
668    ///
669    /// `[-1:]` is a *character* slice on a Python str, so this takes the last
670    /// Unicode scalar — always "ኛ" — and glues it to the decimal digits.
671    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
672        self.verify_ordinal(value)?;
673        let ord = self.to_ordinal(value)?;
674        // `[-1:]` on an empty string yields "" rather than raising.
675        let suffix: String = ord.chars().last().map(String::from).unwrap_or_default();
676        Ok(format!("{}{}", value, suffix))
677    }
678
679    /// `Num2Word_AM.to_year` (with `longval=True`, the default) plus the
680    /// `Num2Word_Base.to_splitnum` it delegates to.
681    ///
682    /// `if not (val // 100) % 10` → plain cardinal when the hundreds digit is
683    /// zero (covers 0..99, and X000/X0000-style values). Otherwise the
684    /// "<high> መቶ <low>" split form. All arithmetic is Python-floored: see
685    /// module docs (6) for why that is load-bearing on negative years.
686    fn to_year(&self, value: &BigInt) -> Result<String> {
687        if value
688            .div_floor(&self.hundred)
689            .mod_floor(&self.ten)
690            .is_zero()
691        {
692            return self.to_cardinal(value);
693        }
694
695        // --- Num2Word_Base.to_splitnum(val, hightxt="መቶ", longval=True) ---
696        // `high, low = val` raises TypeError on an int, so Python falls into
697        // the `divmod(val, divisor)` branch with divisor=100.
698        let (high, low) = value.div_mod_floor(&self.hundred);
699        let mut out: Vec<String> = Vec::new();
700
701        if !high.is_zero() {
702            // hightxt = self.title(self.inflect(high, hightxt)) → always "መቶ".
703            let hightxt = self.title(&self.inflect(&high, HUNDRED_TXT));
704            out.push(self.to_cardinal(&high)?);
705            if !low.is_zero() {
706                // longval is True; jointxt is "" so its append is skipped.
707                if !hightxt.is_empty() {
708                    out.push(hightxt);
709                }
710            } else if !hightxt.is_empty() {
711                out.push(hightxt);
712            }
713        }
714
715        if !low.is_zero() {
716            // cents defaults True → words, not "%02d".
717            out.push(self.to_cardinal(&low)?);
718            // lowtxt is "" → the trailing inflect append is skipped.
719        }
720
721        Ok(out.join(" "))
722    }
723
724    /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for any
725    /// fractional or negative value; a whole non-negative one flows through
726    /// `to_cardinal` + the `ords` last-word rewrite — identical to the
727    /// integer ordinal ("5.0" → "አምስተኛ", `100.0` → "መቶኛ").
728    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
729        let whole = self.verify_ordinal_num(value)?;
730        self.to_ordinal(&whole)
731    }
732
733    /// `to_ordinal_num(float/Decimal)`:
734    /// `"%s%s" % (value, self.to_ordinal(value)[-1:])` — the raw `str(value)`
735    /// glued to the ordinal's final character (always "ኛ"): "5.0ኛ",
736    /// "1e+16ኛ". Negatives/fractions die in `verify_ordinal` first.
737    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
738        let whole = self.verify_ordinal_num(value)?;
739        let ord = self.to_ordinal(&whole)?;
740        let suffix: String = ord.chars().last().map(String::from).unwrap_or_default();
741        Ok(format!("{}{}", repr_str, suffix))
742    }
743
744    /// `to_year(float/Decimal)` — `Num2Word_AM.to_year`'s arithmetic on the
745    /// raw value. `(val // 100) % 10` uses Python's per-type semantics
746    /// (float floor-division/modulo, Decimal truncation), and the fallthrough
747    /// is `to_splitnum(val, hightxt="መቶ")`, whose **float** arm goes through
748    /// `float2tuple` (`-21.0` → `(-21, 0)` → "ሰልቢ ሃያ አንድ መቶ",
749    /// `-1.5` → `(-1, 5)` → "ሰልቢ አንድ መቶ አምስት") while the **Decimal** arm
750    /// divmods by 100 (`Decimal("100")` → "አንድ መቶ").
751    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
752        match value {
753            FloatValue::Float { value: v, .. } => {
754                let v = *v;
755                if (v / 100.0).floor().rem_euclid(10.0) == 0.0 {
756                    return self.cardinal_float_entry(value, None);
757                }
758                // to_splitnum, float arm: high/low from base.float2tuple.
759                let (high, low) = float2tuple(value);
760                let mut out: Vec<String> = Vec::new();
761                if !high.is_zero() {
762                    let hightxt = self.title(&self.inflect(&high, HUNDRED_TXT));
763                    out.push(self.to_cardinal(&high)?);
764                    if !hightxt.is_empty() {
765                        out.push(hightxt);
766                    }
767                }
768                if !low.is_zero() {
769                    out.push(self.to_cardinal(&low)?);
770                }
771                Ok(out.join(" "))
772            }
773            FloatValue::Decimal { value: d, .. } => {
774                let hundred = BigDecimal::from(100);
775                // Decimal // truncates toward zero, and its % keeps the
776                // dividend's sign — BigInt's truncating % matches.
777                let q = (d / &hundred).with_scale_round(0, bigdecimal::RoundingMode::Down);
778                let q_int = q.as_bigint_and_exponent().0;
779                if (&q_int % BigInt::from(10)).is_zero() {
780                    return self.cardinal_float_entry(value, None);
781                }
782                // to_splitnum, Decimal arm: `high, low = divmod(val, 100)`.
783                let high = q;
784                let low = d - &high * &hundred;
785                let mut out: Vec<String> = Vec::new();
786                if !high.is_zero() {
787                    out.push(self.cardinal_num_dec(&high)?);
788                    out.push(HUNDRED_TXT.to_string());
789                }
790                if !low.is_zero() {
791                    out.push(self.cardinal_num_dec(&low)?);
792                }
793                Ok(out.join(" "))
794            }
795        }
796    }
797
798    // ---- currency -------------------------------------------------------
799    //
800    // `Num2Word_AM` overrides `to_currency` outright and reaches `to_cardinal`
801    // directly, so `_money_verbose`, `_cents_verbose` and `_cents_terse` are
802    // never called and their trait defaults stand. `CURRENCY_PRECISION` is
803    // Base's empty dict, so `currency_precision`'s default 100 is already
804    // right. `to_cheque` is *not* overridden in Python, but the inherited body
805    // cannot succeed against AM's 3-tuples, so it is spelled out below.
806
807    fn lang_name(&self) -> &str {
808        "Num2Word_AM"
809    }
810
811    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
812        self.currency.get(code).map(|e| &e.forms)
813    }
814
815    /// `Num2Word_EUR.CURRENCY_ADJECTIVES[code]`. Unreachable for AM — see
816    /// module docs (10).
817    fn currency_adjective(&self, code: &str) -> Option<&str> {
818        self.currency_adjectives.get(code).copied()
819    }
820
821    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
822    ///
823    /// Python indexes the tuple directly, so a single-form entry with `n != 1`
824    /// would raise IndexError. All three AM entries carry two forms, so that
825    /// is unreachable — and since both forms are the *same word* in every
826    /// entry, the plural rule is entirely unobservable for this language.
827    /// Mapped to `Index` rather than panicking so the exception type survives
828    /// if the table ever changes.
829    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
830        let form = if n.is_one() { 0 } else { 1 };
831        forms
832            .get(form)
833            .cloned()
834            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
835    }
836
837    /// `Num2Word_AM.to_currency`.
838    fn to_currency(
839        &self,
840        val: &CurrencyValue,
841        currency: &str,
842        cents: bool,
843        separator: Option<&str>,
844        // Python declares `adjective=False` and never reads it — module docs (10).
845        _adjective: bool,
846    ) -> Result<String> {
847        // `is_integer_input = isinstance(val, int)`. Load-bearing: a true int
848        // skips the cents segment entirely, while the float 1.0 still renders
849        // "… ከ ዜሮ ሳንቲም".
850        //
851        // Note this is AM's *only* test. It never asks Base's
852        // `isinstance(val, float) or "." in str(val)` question, so
853        // `has_decimal` is deliberately discarded below — `Decimal("5")`
854        // carries `has_decimal == false` yet still prints "አምስት ዶላር ከ ዜሮ ሳንቲም"
855        // here, where `default_to_currency` would suppress the cents. Verified
856        // against the live interpreter.
857        let is_integer_input = matches!(val, CurrencyValue::Int(_));
858
859        // `has_fractional_cents` is computed on the float branch only, and it
860        // doubles as Python's `isinstance(right, Decimal)` discriminator below:
861        // `parse_currency_parts` returns a Decimal `cents` exactly when
862        // `keep_precision` is set, and a plain int otherwise.
863        let (left, right, is_negative, has_fractional_cents) = match val {
864            // left = abs(val); right = 0; is_negative = val < 0
865            CurrencyValue::Int(v) => (v.abs(), BigDecimal::zero(), v.is_negative(), false),
866            CurrencyValue::Decimal { value, .. } => {
867                // has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0.
868                // The value is a whole number of cents iff scaling by 100
869                // leaves nothing after the point. `with_scale(0)` truncates
870                // and Decimal's `%` takes the sign of the dividend, so the
871                // `!= 0` answer agrees on negatives either way.
872                let scaled = value * BigDecimal::from(CENTS_DIVISOR);
873                let has_fc = &scaled - scaled.with_scale(0) != BigDecimal::zero();
874                // Python passes no `divisor=`, taking the 100 default. Note
875                // the ROUND_HALF_UP quantize inside is a no-op for AM: it only
876                // runs when has_fc is False, i.e. when the value already has
877                // at most two decimal places.
878                let (l, r, neg) = parse_currency_parts(val, false, has_fc, CENTS_DIVISOR);
879                (l, r, neg, has_fc)
880            }
881        };
882
883        // `cr1, cr2, default_separator = self.CURRENCY_FORMS[currency]` — the
884        // 3-element unpack — inside a `try` that maps KeyError to
885        // NotImplementedError. This runs *before* `to_cardinal`, so an unknown
886        // code beats an overflowing value to the raise:
887        // `to_currency(10**40, "EUR")` is NotImplementedError, not Overflow.
888        let entry = self
889            .currency
890            .get(currency)
891            .ok_or_else(|| self.currency_not_implemented(currency))?;
892        let cr1 = &entry.forms.unit;
893        let cr2 = &entry.forms.subunit;
894
895        // `if separator == ",": separator = default_separator`.
896        //
897        // Python tests the *value*, not whether the kwarg was supplied, so an
898        // explicit `separator=","` is swapped out too. `None` means the caller
899        // omitted it; resolving that through `default_separator()` (Base's
900        // ",") before the test reproduces both cases with one comparison.
901        let mut separator = separator.unwrap_or(self.default_separator());
902        if separator == "," {
903            separator = entry.separator;
904        }
905
906        // negword is "ሰልቢ " with a trailing space; `"%s " % negword.strip()`
907        // normalises it back to exactly one.
908        let minus_str = if is_negative {
909            format!("{} ", self.negword().trim())
910        } else {
911            String::new()
912        };
913        let money_str = self.to_cardinal(&left)?;
914
915        if is_integer_input {
916            // Integer: no cents. `cents=` and `adjective=` are both ignored.
917            return Ok(format!(
918                "{}{} {}",
919                minus_str,
920                money_str,
921                self.pluralize(&left, cr1)?
922            ));
923        }
924
925        // For floats, always show cents — even if zero.
926        let right_int = right.as_bigint_and_exponent().0;
927        let cents_str = if cents {
928            if has_fractional_cents {
929                // `self.to_cardinal_float(float(right)) if right > 0 else "ዜሮ"`.
930                // `cardinal_from_decimal` is exactly that: it casts to f64 and
931                // derives precision from the repr, which is what Python's
932                // `float(right)` plus `float2tuple`'s float arm do together.
933                if right > BigDecimal::zero() {
934                    self.cardinal_from_decimal(&right)?
935                } else {
936                    ZERO_TXT.to_string()
937                }
938            } else if right_int.is_positive() {
939                self.to_cardinal(&right_int)?
940            } else {
941                ZERO_TXT.to_string()
942            }
943        } else {
944            // `str(float(right) if isinstance(right, Decimal) else right)`.
945            if has_fractional_cents {
946                // right is in [0, 100), so the cast cannot overflow. Python's
947                // float() would yield inf if it ever did, and str(inf) ==
948                // "inf" matches Rust's Display for f64::INFINITY.
949                format!("{}", right.to_f64().unwrap_or(f64::INFINITY))
950            } else {
951                right_int.to_string()
952            }
953        };
954
955        // `self.pluralize(right, cr2)`.
956        //
957        // `right` is a Python int on the whole-cents branch and a Decimal on
958        // the fractional one; EUR's pluralize only asks `n == 1`, which is
959        // defined for both. The trait hook is typed `&BigInt`, so the Decimal
960        // branch is answered inline — and its answer is never in doubt:
961        // keep_precision is set exactly when `right` has a non-zero fractional
962        // part, so `right == 1` is impossible there and the index is always 1.
963        let cents_word = if has_fractional_cents {
964            cr2.get(1)
965                .cloned()
966                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?
967        } else {
968            self.pluralize(&right_int, cr2)?
969        };
970
971        // "%s%s %s%s %s %s" — the separator is glued to the unit word with no
972        // space of its own, which is why AM's own " ከ" carries a leading one.
973        // A caller-supplied " ና " therefore double-spaces; see module docs (12).
974        Ok(format!(
975            "{}{} {}{} {} {}",
976            minus_str,
977            money_str,
978            self.pluralize(&left, cr1)?,
979            separator,
980            cents_str,
981            cents_word
982        ))
983    }
984
985    /// `Num2Word_Base.to_cheque`, which AM inherits but can never satisfy.
986    ///
987    /// The Python body opens with:
988    ///
989    /// ```python
990    /// try:
991    ///     cr1, _cr2 = self.CURRENCY_FORMS[currency]
992    /// except KeyError:
993    ///     raise NotImplementedError(...)
994    /// ```
995    ///
996    /// Only `KeyError` is handled. AM's entries are 3-tuples (module docs 7),
997    /// so for a code that *is* present the unpack raises `ValueError` from
998    /// inside the `try` and passes straight through the handler. Nothing
999    /// downstream is ever reached — not `CURRENCY_PRECISION`, not
1000    /// `_money_verbose` — so `val` is genuinely unused. The corpus pins both
1001    /// outcomes: `cheque:USD` → ValueError, `cheque:EUR` → NotImplementedError.
1002    fn to_cheque(&self, _val: &BigDecimal, currency: &str) -> Result<String> {
1003        if self.currency.contains_key(currency) {
1004            return Err(N2WError::Value(
1005                "too many values to unpack (expected 2)".into(),
1006            ));
1007        }
1008        Err(self.currency_not_implemented(currency))
1009    }
1010}
1011
1012#[cfg(test)]
1013mod float_tests {
1014    use super::*;
1015    use crate::floatpath::FloatValue;
1016    use std::str::FromStr;
1017
1018    /// Drive the float arm the way the binding does.
1019    ///
1020    /// `precision` is *not* derived here: Python computes it as
1021    /// `abs(Decimal(repr(value)).as_tuple().exponent)`, which depends on
1022    /// `repr(float)` and is deliberately not reimplemented in the core. Every
1023    /// value below carries the precision the live interpreter reported for it.
1024    fn f(value: f64, precision: u32) -> String {
1025        LangAm::new()
1026            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1027            .unwrap()
1028    }
1029
1030    /// Drive the Decimal arm — exact arbitrary precision, never an f64 cast.
1031    fn d(s: &str, precision: u32) -> String {
1032        LangAm::new()
1033            .to_cardinal_float(
1034                &FloatValue::Decimal {
1035                    value: BigDecimal::from_str(s).unwrap(),
1036                    precision,
1037                },
1038                None,
1039            )
1040            .unwrap()
1041    }
1042
1043    /// Every `"to": "cardinal"` corpus row for `am` whose `arg` has a dot and
1044    /// is non-integral, i.e. every row that actually reaches the float path.
1045    /// ("0.0" and "1.0" are corpus rows too, but they satisfy
1046    /// `int(v) == v` and take the integer branch — see `int_valued_floats`.)
1047    #[test]
1048    fn corpus_cardinal_float() {
1049        let rows: &[(f64, u32, &str)] = &[
1050            (0.5, 1, "ዜሮ ነጥብ አምስት"),
1051            (1.5, 1, "አንድ ነጥብ አምስት"),
1052            (2.25, 2, "ሁለት ነጥብ ሁለት አምስት"),
1053            (3.14, 2, "ሦስት ነጥብ አንድ አራት"),
1054            (0.01, 2, "ዜሮ ነጥብ ዜሮ አንድ"),
1055            (0.1, 1, "ዜሮ ነጥብ አንድ"),
1056            (0.99, 2, "ዜሮ ነጥብ ዘጠኝ ዘጠኝ"),
1057            (1.01, 2, "አንድ ነጥብ ዜሮ አንድ"),
1058            (12.34, 2, "አሥራ ሁለት ነጥብ ሦስት አራት"),
1059            (99.99, 2, "ዘጠና ዘጠኝ ነጥብ ዘጠኝ ዘጠኝ"),
1060            // `pre == 100` → AM's `to_cardinal` short-circuit, reached via the
1061            // float path's callback. "መቶ", not "አንድ መቶ".
1062            (100.5, 1, "መቶ ነጥብ አምስት"),
1063            (1234.56, 2, "አንድ ሺህ ሁለት መቶ ሠላሳ አራት ነጥብ አምስት ስድስት"),
1064            // pre == 0 → the sign comes from to_cardinal_float, not to_cardinal.
1065            (-0.5, 1, "ሰልቢ ዜሮ ነጥብ አምስት"),
1066            // pre != 0 → the sign comes from AM's negword recursion.
1067            (-1.5, 1, "ሰልቢ አንድ ነጥብ አምስት"),
1068            (-12.34, 2, "ሰልቢ አሥራ ሁለት ነጥብ ሦስት አራት"),
1069            // The f64-artefact pair. 1.005 → post 4.99999999999989…,
1070            // 2.675 → post 674.9999999999998; the `< 0.01` heuristic in
1071            // float2tuple rounds both back up rather than flooring them.
1072            (1.005, 3, "አንድ ነጥብ ዜሮ ዜሮ አምስት"),
1073            (2.675, 3, "ሁለት ነጥብ ስድስት ሰባት አምስት"),
1074        ];
1075        for &(v, p, want) in rows {
1076            assert_eq!(f(v, p), want, "float {}", v);
1077        }
1078    }
1079
1080    /// Every `"to": "cardinal_dec"` corpus row for `am` — Decimal input.
1081    #[test]
1082    fn corpus_cardinal_dec() {
1083        let rows: &[(&str, u32, &str)] = &[
1084            ("0.01", 2, "ዜሮ ነጥብ ዜሮ አንድ"),
1085            // Trailing zero is significant: Decimal("1.10") has exponent -2, so
1086            // precision is 2 and the final "ዜሮ" is spoken.
1087            ("1.10", 2, "አንድ ነጥብ አንድ ዜሮ"),
1088            ("12.345", 3, "አሥራ ሁለት ነጥብ ሦስት አራት አምስት"),
1089            (
1090                "98746251323029.99",
1091                2,
1092                "ዘጠና ስምንት ትሪሊዮን ሰባት መቶ አርባ ስድስት ቢሊዮን ሁለት መቶ አምሳ አንድ ሚሊዮን \
1093                 ሦስት መቶ ሃያ ሦስት ሺህ ሃያ ዘጠኝ ነጥብ ዘጠኝ ዘጠኝ",
1094            ),
1095            ("0.001", 3, "ዜሮ ነጥብ ዜሮ ዜሮ አንድ"),
1096        ];
1097        for &(s, p, want) in rows {
1098            assert_eq!(d(s, p), want, "decimal {}", s);
1099        }
1100    }
1101
1102    /// Issue #603, visible through Amharic: the same literal takes different
1103    /// arms and must not agree. As an f64 it is already `…029.98` at trillion
1104    /// scale (that *is* its shortest round-trip repr), so it ends "ዘጠኝ ስምንት";
1105    /// as a Decimal it keeps the exact .99 and ends "ዘጠኝ ዘጠኝ".
1106    #[test]
1107    fn float_and_decimal_arms_differ_at_trillion_scale() {
1108        let as_float = f(98746251323029.99, 2);
1109        let as_decimal = d("98746251323029.99", 2);
1110        assert!(as_float.ends_with("ነጥብ ዘጠኝ ስምንት"), "{}", as_float);
1111        assert!(as_decimal.ends_with("ነጥብ ዘጠኝ ዘጠኝ"), "{}", as_decimal);
1112        assert_ne!(as_float, as_decimal);
1113    }
1114
1115    /// Live-interpreter rows the corpus does not carry.
1116    #[test]
1117    fn extra_live_interpreter_rows() {
1118        // Negative *and* pre == -100: both AM divergences at once — the
1119        // recursion supplies "ሰልቢ ", and the recursive call's own `== 100`
1120        // short-circuit supplies the bare "መቶ".
1121        assert_eq!(f(-100.5, 1), "ሰልቢ መቶ ነጥብ አምስት");
1122        // Negative with pre == 0 and a leading zero in the fraction.
1123        assert_eq!(f(-0.01, 2), "ሰልቢ ዜሮ ነጥብ ዜሮ አንድ");
1124        // repr(1e-3) == "0.001" → precision 3, not 1.
1125        assert_eq!(f(0.001, 3), "ዜሮ ነጥብ ዜሮ ዜሮ አንድ");
1126        // A large float: exercises the high cards through the float path.
1127        assert_eq!(
1128            f(12345678901234.5, 1),
1129            "አሥራ ሁለት ትሪሊዮን ሦስት መቶ አርባ አምስት ቢሊዮን ስድስት መቶ ሰባ ስምንት ሚሊዮን \
1130             ዘጠኝ መቶ አንድ ሺህ ሁለት መቶ ሠላሳ አራት ነጥብ አምስት"
1131        );
1132    }
1133
1134    /// Integral floats reach `to_cardinal_float` only if a caller calls it
1135    /// directly — `num2words` sends them down the integer branch instead.
1136    /// Pinned because the two answers differ and the split is easy to lose.
1137    #[test]
1138    fn int_valued_floats() {
1139        assert_eq!(f(2.0, 1), "ሁለት ነጥብ ዜሮ");
1140        assert_eq!(f(100.0, 1), "መቶ ነጥብ ዜሮ");
1141        // …whereas the integer branch, which is what "1.0" → "አንድ" records.
1142        assert_eq!(LangAm::new().to_cardinal(&BigInt::from(1)).unwrap(), "አንድ");
1143    }
1144}