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}