Skip to main content

num2words2_core/
lang_ti.rs

1//! Port of `lang_TI.py` (Tigrinya).
2//!
3//! # Deliberate divergences from upstream
4//!
5//! Two, both documented below: the **script** (Ge'ez, not Latin) and the
6//! **ordinal algorithm** (suppletive + `መበል`, not a glued suffix). Everything
7//! else is ported verbatim, bug for bug.
8//!
9//! ## 1. Ge'ez script
10//!
11//! Upstream `lang_TI.py` spells every numeral in **Latin transliteration**
12//! ("ḥade", "mi'ti", "shiḥ"). Tigrinya is written in Ge'ez, and the sibling
13//! Ethiopic language in this crate (`lang_am.rs`, Amharic) already emits Ge'ez,
14//! so the transliteration was an upstream accident rather than a choice. The
15//! word tables here are the Ge'ez spellings: "ሓደ", "ሚእቲ", "ሽሕ".
16//!
17//! This is a lexicon change only — the composition rules, the `> 1` multiplier
18//! guards, the billion cliff and the currency fallback are all still ported
19//! verbatim. The frozen-corpus fixtures in this file were re-spelled to match;
20//! every other assertion they make is unchanged. (Upstream's *missing*
21//! `verify_ordinal` is the one piece of behaviour this module does not keep —
22//! §2 below restores the guard.)
23//!
24//! One consequence worth naming, inherited structure rather than new
25//! behaviour: **the billion cliff still emits ASCII digits.** Above 10^9
26//! upstream returns `str(number)`; those are Western digits, not Ge'ez
27//! numerals, and that is untouched.
28//!
29//! ## 2. Ordinal algorithm
30//!
31//! Upstream builds *every* ordinal as `to_cardinal(n) + "ay"`, which produces
32//! the non-word "ሓደኣይ" for 1st. Tigrinya has two arms instead:
33//!
34//! * **1..=10 are suppletive** — distinct forms with their own vowel shifts
35//!   and endings, not derivable from the cardinal: ቀዳማይ, ካልኣይ, ሳልሳይ, ራብዓይ,
36//!   ሓምሻይ, ሻድሻይ, ሻብዓይ, ሻምናይ, ታሽዓይ, ዓስራይ. See [`ORDINAL_IRREGULARS`].
37//! * **11 and up take the free-standing word መበል** followed by the plain,
38//!   uninflected cardinal: "መበል ዓሰርተ ን ሓደ", "መበል ሚእቲ". See
39//!   [`ORDINAL_PREFIX`].
40//!
41//! The **digit** form (`to_ordinal_num`) follows the same two arms, which is
42//! the formal/textbook convention: ይ suffixed to the digit up to 10 ("1ይ",
43//! "10ይ"), then the መበል prefix on the bare digit ("መበል 11", "መበል 100").
44//! Upstream's universal "ay" suffix is doubly wrong here — it applies one
45//! suffix at every magnitude, and the suffix itself is two letters where only
46//! ይ is written. A colloquial alternative seen in media does extend ይ to all
47//! numbers ("11ይ", "100ይ"); switching to it means deleting the range test in
48//! [`Lang::to_ordinal_num`] and always taking the suffix arm.
49//!
50//! `verify_ordinal` is restored, so non-integers, negatives and zero now raise
51//! `TypeError` instead of passing through — see [`LangTi::verify_ordinal`] and
52//! [`LangTi::verify_ordinal_num`]. The guard is **stricter than Base** in one
53//! respect: Python lets zero through and every other language in this crate
54//! renders a "zeroth", but Tigrinya has no ordinal for zero.
55//!
56//! Scope of the change, deliberately narrow:
57//!
58//! * Only the **masculine** series is modelled. The feminine forms replace
59//!   *-ay* with *-eyti* (ቀዳመይቲ, …); nothing in the `num2words` API carries
60//!   grammatical gender for ordinals, so there is nowhere to select them from.
61//! * A **whole** float or Decimal is not an error — it routes into the
62//!   integer path, so `to_ordinal(5.0)` is "ሓምሻይ" and `to_ordinal_num(5.0)`
63//!   is "5ይ". Only genuinely fractional values raise.
64//!
65//! Shape: **self-contained**. `Num2Word_TI` subclasses `Num2Word_Base` but
66//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the guard in
67//! `Num2Word_Base.__init__` never fires: Python builds **no** `self.cards` and
68//! never sets `self.MAXVAL` (verified: `hasattr(n, "cards") is False`).
69//! `to_cardinal` is overridden outright and drives `_int_to_word`, so
70//! `cards`/`maxval`/`merge` stay at their trait defaults here and `splitnum`/
71//! `clean`/`merge` are unreachable. There is **no overflow check** at any size.
72//!
73//! Inherited from `Num2Word_Base` and left alone by TI:
74//!   * `is_title` stays `False`, so `title()` is the identity. TI's
75//!     `to_cardinal` never calls `title()` anyway.
76//!
77//! Every method in scope is overridden by TI, so nothing here relies on a
78//! base-class default.
79//!
80//! # Structure
81//!
82//! Tigrinya composes with a single connector `" ን "` at every level, and
83//! suppresses the multiplier word when it would be one ("ሓደ"): 100 is
84//! "ሚእቲ" (not "ሓደ ሚእቲ"), 1000 is "ሽሕ", 10^6 is "ሚልዮን". Note the
85//! `> 1` guard is present on the millions branch too, unlike some sibling
86//! languages (e.g. `lang_PAP`) that only guard the thousands.
87//!
88//! Teens are not lexicalised: 11 is "ዓሰርተ ን ሓደ" (literally "ten and
89//! one"), built by the generic `< 100` branch. That is the Python behaviour,
90//! not an omission.
91//!
92//! # Faithfully reproduced Python bugs
93//!
94//! This is a port, not a rewrite. The following are all wrong-looking but are
95//! exactly what Python emits, verified against the interpreter:
96//!
97//! 1. **The billion cliff.** `_int_to_word` has no branch above 10^9 and ends
98//!    with `return str(number)`, so any `abs(n) >= 10**9` falls out as raw
99//!    ASCII digits instead of words: `to_cardinal(10**9) == "1000000000"` and
100//!    `to_cardinal(10**21) == "1000000000000000000000"`. No exception, no
101//!    words — the digits are the output. Modelled in [`LangTi::int_to_word`].
102//! 2. The cliff leaks into the other modes: `to_ordinal(10**9)` is
103//!    `"መበል 1000000000"`, the same digits `to_ordinal_num(10**9)` produces.
104//!    The cliff itself is the inherited bug; the ordinal fix above changed
105//!    only how the digits are framed.
106//! 3. ~~`to_ordinal` never calls `verify_ordinal`~~ — **fixed**, see the
107//!    ordinal divergence above. Upstream let zero and negatives through and
108//!    suffixed them ("ባዶኣይ", "ኣሉታ ሓደኣይ"); both now raise `TypeError`.
109//! 4. `to_year` ignores its `longval` parameter entirely and delegates to
110//!    `to_cardinal`, so there is no BC/AD handling and no year-pairing:
111//!    `to_year(1999)` is the plain cardinal, and `to_year(-500)` is
112//!    `"ኣሉታ ሓሙሽተ ሚእቲ"` ("negative five hundred") rather than
113//!    anything resembling "500 BC".
114//!
115//! # Currency
116//!
117//! `Num2Word_TI` subclasses `Num2Word_Base` *directly* (MRO verified:
118//! `Num2Word_TI -> Num2Word_Base -> object`), so it never sees the
119//! `Num2Word_EUR` class dict that `Num2Word_EN.__init__` mutates at import
120//! time. Its `CURRENCY_FORMS` is its own four-entry class attribute and is
121//! read verbatim.
122//!
123//! TI overrides `to_currency` **wholesale** and shares almost none of Base's
124//! currency machinery. The consequences are all observable:
125//!
126//! 1. **`to_currency` never raises `NotImplementedError`.**
127//!    `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])` falls
128//!    back to the *first inserted* entry — ETB — for every unknown code. So
129//!    `to_currency(0, "GBP")` is "ባዶ ብር", and JPY/KWD/BHD/INR/CNY/CHF all
130//!    silently render as Ethiopian ብር. `to_cheque` is Base's and *does*
131//!    raise, which is why the corpus has `cheque:GBP` erroring while
132//!    `currency:GBP` succeeds.
133//! 2. **No precision table.** `CURRENCY_PRECISION` is `{}`, so `.get(code,100)`
134//!    is always 100: KWD/BHD get 2 decimals rather than 3, and JPY still
135//!    renders subunits (`to_currency(12.34,"JPY")` ends "…ሰላሳ ን ኣርባዕተ
136//!    ሳንቲም"). Both the 1000- and 1-divisor branches are dead for TI.
137//! 3. **No `pluralize`, no adjective.** `to_currency` indexes the form tuple
138//!    inline (`cr1[1] if left != 1 else cr1[0]`) and accepts `adjective`
139//!    without ever reading it. `CURRENCY_ADJECTIVES` is `{}` anyway.
140//! 4. **Cents truncate, they do not round.** `right` is built from the first
141//!    two *characters* after the "." in `str(val)`, so 2.675 is 67 ሳንቲም
142//!    (ROUND_HALF_UP would say 68), 12.345 is 34, and 0.005 truncates to 0 —
143//!    which, being falsy, drops the cents segment entirely ("ባዶ ዩሮ").
144//! 5. **`left`/`right` go through `_int_to_word`, not `to_cardinal`**, so the
145//!    billion cliff applies to the units too: `to_currency(1e15)` is
146//!    "1000000000000000 ዩሮ" (digits, verified against the interpreter).
147//!
148//! Base's `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse`
149//! are all inherited unchanged, so their trait defaults already match; only the
150//! data tables, the class name, `pluralize` and `to_currency` are overridden.
151//!
152//! # Float / Decimal cardinal path
153//!
154//! `pointword` ("ነጥቢ") and the `"." in n` branch of `to_cardinal` are the
155//! float *cardinal* path. TI overrides `to_cardinal` (not `to_cardinal_float`)
156//! and handles non-integers **inline on the string** `str(number)`, so the port
157//! overrides [`Lang::to_cardinal_float`] to reconstruct that exact string
158//! ([`python_str`]) and run TI's split-and-per-digit logic
159//! ([`LangTi::cardinal_from_str`]). Neither `PORTING_FLOAT.md` trap applies: TI
160//! never calls `float2tuple`, so there is no banker's-`round()` and no
161//! `abs(value-pre)*10**precision` f64 artefact to preserve — the fraction digits
162//! are read straight off the repr. `precision=` is dropped by the dispatcher
163//! before `to_cardinal` is reached, so `precision_override` is ignored.
164//!
165//! `cardinal_from_decimal` (Base's fractional-cents entry point) stays at its
166//! default. Nothing reaches it: TI's `to_currency` never calls it, and
167//! `default_to_currency` (its only other caller) is unreachable because
168//! `to_currency` is overridden here.
169
170use crate::base::{Lang, N2WError, Result};
171use crate::currency::{CurrencyForms, CurrencyValue};
172use crate::floatpath::FloatValue;
173use crate::strnum::{python_decimal_parse, ParsedNumber};
174use bigdecimal::BigDecimal;
175use num_bigint::BigInt;
176use num_integer::Integer;
177use num_traits::{One, Signed, ToPrimitive, Zero};
178use std::collections::HashMap;
179use std::str::FromStr;
180
181/// `setup(): self.ones`. Index 0 is the empty string, exactly as in Python.
182/// The `< 10` branch is guarded by the zero test above it, so index 0 is only
183/// ever reachable via the (out-of-scope) decimal-digit loop, which maps it to
184/// "ባዶ" instead.
185const ONES: [&str; 10] = [
186    "",
187    "ሓደ",
188    "ክልተ",
189    "ሰለስተ",
190    "ኣርባዕተ",
191    "ሓሙሽተ",
192    "ሽዱሽተ",
193    "ሸውዓተ",
194    "ሸሞንተ",
195    "ትሽዓተ",
196];
197
198/// `setup(): self.tens`. Index 0 is unreachable (`number >= 10` in the only
199/// branch that reads this table).
200const TENS: [&str; 10] = [
201    "",
202    "ዓሰርተ",
203    "ዕስራ",
204    "ሰላሳ",
205    "ኣርብዓ",
206    "ሓምሳ",
207    "ስሳ",
208    "ሰብዓ",
209    "ሰማንያ",
210    "ተስዓ",
211];
212
213const ZERO_WORD: &str = "ባዶ";
214const HUNDRED: &str = "ሚእቲ";
215const THOUSAND: &str = "ሽሕ";
216const MILLION: &str = "ሚልዮን";
217
218/// `setup(): self.negword`. The trailing space is part of the Python literal.
219const NEGWORD: &str = "ኣሉታ ";
220/// `setup(): self.pointword`. Float path only — unused in scope.
221const POINTWORD: &str = "ነጥቢ";
222
223/// The connector joining every level: "ten *and* one", "hundred *and* five".
224const AND: &str = " ን ";
225
226/// `to_ordinal_num` suffix, for the 1..=10 arm only.
227///
228/// **Not** upstream's "ay". Every spelled-out irregular from 1 to 10 ends in
229/// the 6th-order ይ, so the typographic suffix on a digit is the single letter
230/// ይ — "1ይ", "10ይ". Upstream's two-letter "ኣይ" ("11ኣይ") is not what is
231/// written.
232const ORDINAL_SUFFIX: &str = "ይ";
233
234/// Tigrinya ordinals 1-10 are suppletive: they are not the cardinal plus a
235/// suffix but distinct forms with their own vowel shifts and endings
236/// (*-ay*, *-shay*, *-nay*). Index 0 is unused — the table is only consulted
237/// for 1..=10, and zero has no ordinal in this scheme.
238///
239/// These are the **masculine** forms, which are the default for software and
240/// UI localisation. The feminine series replaces *-ay* with *-eyti*
241/// (ቀዳመይቲ, ካልኣይቲ, …) and is not modelled: nothing in the `num2words` API
242/// carries grammatical gender for ordinals.
243const ORDINAL_IRREGULARS: [&str; 11] = [
244    "",
245    "ቀዳማይ",
246    "ካልኣይ",
247    "ሳልሳይ",
248    "ራብዓይ",
249    "ሓምሻይ",
250    "ሻድሻይ",
251    "ሻብዓይ",
252    "ሻምናይ",
253    "ታሽዓይ",
254    "ዓስራይ",
255];
256
257/// From 11 up, Tigrinya stops inflecting and prefixes the free-standing word
258/// መበል to the plain cardinal: 11th is "መበል ዓሰርተ ን ሓደ", 100th "መበል ሚእቲ".
259/// The trailing space is part of the prefix.
260const ORDINAL_PREFIX: &str = "መበል ";
261
262/// `Num2Word_TI.CURRENCY_FORMS`, in Python's class-body **insertion order**.
263///
264/// The order is load-bearing and this must stay a sequence rather than an
265/// unordered literal: `to_currency` falls back to
266/// `list(self.CURRENCY_FORMS.values())[0]` for unknown codes, which since
267/// Python 3.7 is the first *inserted* entry. Verified against the live
268/// interpreter — `list(c.CURRENCY_FORMS.values())[0]` is ETB's ብር/ሳንቲም
269/// pair — and against the corpus, where every unimplemented code renders as
270/// ብር.
271///
272/// Each side carries exactly two forms, matching the Python tuples. TI's
273/// singular and plural are identical throughout ("ብር"/"ብር"), so the
274/// `left != 1` branch is invisible in the output — but the arity is what the
275/// inline `cr1[1]` index depends on, so it is kept exact.
276const CURRENCY_FORMS: [(&str, [&str; 2], [&str; 2]); 4] = [
277    ("ETB", ["ብር", "ብር"], ["ሳንቲም", "ሳንቲም"]),
278    ("ERN", ["ናቕፋ", "ናቕፋ"], ["ሳንቲም", "ሳንቲም"]),
279    ("USD", ["ዶላር", "ዶላር"], ["ሰንት", "ሰንት"]),
280    ("EUR", ["ዩሮ", "ዩሮ"], ["ሰንት", "ሰንት"]),
281];
282
283/// Python's `str(val).split(".")` keeps at most the first two fractional
284/// characters (`parts[1][:2].ljust(2, "0")`), i.e. hundredths.
285const CENTS_PER_UNIT: i64 = 100;
286
287pub struct LangTi {
288    /// `setup(): self.exclude_title`. Dead data in Python (`is_title` is
289    /// `False`), retained so the trait method reports what Python holds.
290    exclude_title: Vec<String>,
291    /// `CURRENCY_FORMS` as a lookup. Built once in [`LangTi::new`] and only
292    /// ever read afterwards — rebuilding it per call is what made an earlier
293    /// revision of this port slower than the Python it replaces.
294    currency_forms: HashMap<&'static str, CurrencyForms>,
295    /// `list(self.CURRENCY_FORMS.values())[0]` — the ETB entry, precomputed.
296    ///
297    /// Held separately because [`Lang::currency_forms`] must keep returning
298    /// `None` for unknown codes (Base's `to_cheque` relies on that to raise
299    /// `NotImplementedError`), while `to_currency` must instead substitute
300    /// this. The two lookups genuinely disagree, so they cannot share a path.
301    fallback_forms: CurrencyForms,
302}
303
304impl Default for LangTi {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310impl LangTi {
311    pub fn new() -> Self {
312        let (_, fallback_unit, fallback_subunit) = CURRENCY_FORMS[0];
313        LangTi {
314            exclude_title: vec![
315                "ናይ".to_string(),
316                "ነጥቢ".to_string(),
317                "ኣሉታ".to_string(),
318            ],
319            currency_forms: CURRENCY_FORMS
320                .iter()
321                .map(|(code, unit, subunit)| (*code, CurrencyForms::new(unit, subunit)))
322                .collect(),
323            fallback_forms: CurrencyForms::new(&fallback_unit, &fallback_subunit),
324        }
325    }
326
327    /// Python's `str(val).split(".")` surgery, the head of `to_currency`:
328    ///
329    /// ```text
330    /// val   = abs(val)
331    /// parts = str(val).split(".")
332    /// left  = int(parts[0]) if parts[0] else 0
333    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
334    /// ```
335    ///
336    /// TI is the one language that runs `str(val)` *inside* `to_currency` and
337    /// then does text surgery on it, but the shim parses that string into a
338    /// `BigDecimal` at the boundary and the text is gone before this runs. So
339    /// the split is reconstructed arithmetically. For every value whose `str()`
340    /// is plain decimal notation the two are provably identical:
341    ///
342    /// * `parts[0]` is the integer part, so `left` is `trunc(abs(val))`; abs
343    ///   has already been taken, so truncation and floor agree.
344    /// * `parts[1][:2].ljust(2,"0")` takes the first two fractional digits and
345    ///   right-pads, which is exactly `trunc(frac * 100)`: "5" -> "50" -> 50,
346    ///   "01" -> 1, "345" -> "34" -> 34.
347    /// * a `str()` with no "." parses to scale <= 0, whose `frac` is 0, giving
348    ///   `right == 0` — the same answer as Python's `len(parts) > 1` guard.
349    ///   That covers both `int` input and `Decimal("5")`, and `Decimal("5.00")`
350    ///   lands on `int("00") == 0` either way.
351    ///
352    /// Consequently `has_decimal` is *not* consulted: TI branches on the text
353    /// of `str(val)`, never on `isinstance`, so int `5`, `Decimal("5")`,
354    /// `Decimal("5.00")` and `5.0` all collapse to the same "ሓሙሽተ ዩሮ"
355    /// (verified against the interpreter). This is the one place where reading
356    /// `has_decimal` would be actively wrong.
357    ///
358    /// # Known divergence: negative-exponent floats
359    ///
360    /// The guard below catches *positive* exponent forms, but the mirror case
361    /// cannot be caught from here. `float 1e-05` and `Decimal("0.00001")` reach
362    /// this function as **byte-identical state** — same digits (1), same scale
363    /// (5), same `has_decimal` (true) — yet Python disagrees about them:
364    /// `str(1e-05)` is "1e-05" and raises ValueError, while
365    /// `str(Decimal("0.00001"))` is "0.00001" and yields "ባዶ ዩሮ". Only the
366    /// discarded string tells them apart, so no rule available here can be both
367    /// sound and complete; guessing on scale would misfire on legitimate small
368    /// `Decimal`s. The plain-decimal reading is chosen, which means floats with
369    /// `0 < |v| < 1e-4` return "ባዶ <unit>" where Python raises ValueError.
370    /// Fixing it needs `CurrencyValue` to carry the original `str(val)`.
371    fn split_currency_parts(&self, val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
372        match val {
373            // str(int) never contains ".", so parts has one element.
374            CurrencyValue::Int(v) => Ok((v.abs(), BigInt::zero())),
375            CurrencyValue::Decimal { value, .. } => {
376                let v = value.abs();
377                // A negative scale means `str(val)` used exponent notation
378                // ("1e+21", "1.5e+16", "1E+21") — a plain decimal string always
379                // parses to scale >= 0, so this has no false positives. Python
380                // then feeds a token containing "e" to int(), which always
381                // raises ValueError: from parts[0] ("1e+21") when the repr has
382                // no ".", or from parts[1][:2] ("5e") when it does. Both raise
383                // the same type, so the branch is collapsed here.
384                //
385                // The *message* is best-effort, since the literal Python quotes
386                // is the string this boundary discarded. It comes out exact for
387                // the common float reprs ("1e+21", "1e+16" — bigdecimal Displays
388                // those verbatim); it differs for `Decimal("1E+21")` (we
389                // lowercase the "E") and for fractional mantissas, where Python
390                // quotes the mangled fragment "5e" rather than "1.5e+16". The
391                // variant is what callers catch, and that is exact.
392                if v.as_bigint_and_exponent().1 < 0 {
393                    return Err(N2WError::Value(format!(
394                        "invalid literal for int() with base 10: '{v}'"
395                    )));
396                }
397                let left = v.with_scale(0).as_bigint_and_exponent().0;
398                let frac = &v - BigDecimal::from(left.clone());
399                let right = (frac * BigDecimal::from(CENTS_PER_UNIT))
400                    .with_scale(0)
401                    .as_bigint_and_exponent()
402                    .0;
403                Ok((left, right))
404            }
405        }
406    }
407
408    /// `Num2Word_Base.verify_ordinal`, which upstream `Num2Word_TI` never
409    /// calls — that omission is the bug this restores (module header, §2).
410    ///
411    /// **Stricter than Base.** Python's `verify_ordinal` rejects only
412    /// non-integers and negatives; `abs(0) == 0`, so zero passes and every
413    /// other language in this crate renders a "zeroth". Tigrinya has no
414    /// ordinal for zero — neither arm of the rule produces a real word for it
415    /// ("መበል ባዶ" is not something anyone says) — so zero is rejected here too.
416    ///
417    /// This is the integer branch. The float/Decimal branch lives in
418    /// [`LangTi::verify_ordinal_num`], which adds Base's `errmsg_floatord`
419    /// check ahead of these two, so `to_ordinal(3.14)` raises rather than
420    /// rendering a decimal ordinal. A *whole* float is not an error — it
421    /// routes into the integer path and lands back here.
422    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
423        if value.is_negative() {
424            // Base's `errmsg_negord`, verbatim — the wording other languages
425            // in this crate already raise.
426            return Err(N2WError::Type(format!(
427                "Cannot treat negative num {} as ordinal.",
428                value
429            )));
430        }
431        if value.is_zero() {
432            // No Python precedent for this message: upstream never rejects
433            // zero anywhere, so there is no wording to match.
434            return Err(N2WError::Type("Cannot treat zero as ordinal.".into()));
435        }
436        Ok(())
437    }
438
439    /// `verify_ordinal` for a float/Decimal. The float check fires first, then
440    /// the sign check, then the zero check — Base's ordering, with this
441    /// module's extra zero rejection appended.
442    ///
443    /// Both messages quote `str(value)`, not the truncated integer, matching
444    /// Python's `errmsg_floatord % value` / `errmsg_negord % value`. Returns
445    /// the whole value so a whole float (`5.0`) can route into the integer
446    /// path and render "ሓምሻይ" rather than a decimal form.
447    fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
448        let whole = match value.as_whole_int() {
449            Some(i) => i,
450            None => {
451                return Err(N2WError::Type(format!(
452                    "Cannot treat float {} as ordinal.",
453                    python_str(value)
454                )))
455            }
456        };
457        if whole.is_negative() {
458            return Err(N2WError::Type(format!(
459                "Cannot treat negative num {} as ordinal.",
460                python_str(value)
461            )));
462        }
463        if whole.is_zero() {
464            return Err(N2WError::Type("Cannot treat zero as ordinal.".into()));
465        }
466        Ok(whole)
467    }
468
469    /// Port of `Num2Word_TI._int_to_word`.
470    ///
471    /// Only ever called with a **non-negative** value: `to_cardinal` peels the
472    /// sign off before calling, so the `divmod`s below match Python's
473    /// (Python's floor-division semantics only diverge from Rust's truncating
474    /// `/` on negatives). All the small-index conversions are therefore
475    /// provably in range.
476    fn int_to_word(&self, number: &BigInt) -> String {
477        // Python: if number == 0: return "ባዶ"
478        if number.is_zero() {
479            return ZERO_WORD.to_string();
480        }
481
482        // Python: if number < 10: return self.ones[number]
483        if *number < BigInt::from(10) {
484            // Safe: 0 < number < 10.
485            return ONES[to_index(number)].to_string();
486        }
487
488        // Python: t, o = divmod(number, 10)
489        //         return self.tens[t] + (" ን " + self.ones[o] if o else "")
490        if *number < BigInt::from(100) {
491            let (t, o) = number.div_mod_floor(&BigInt::from(10));
492            let t = to_index(&t); // 1..=9
493            let o = to_index(&o); // 0..=9
494            let mut out = TENS[t].to_string();
495            if o != 0 {
496                out.push_str(AND);
497                out.push_str(ONES[o]);
498            }
499            return out;
500        }
501
502        // Python: h, r = divmod(number, 100)
503        //         base = (self.ones[h] + " " if h > 1 else "") + self.hundred
504        //         return base + (" ን " + self._int_to_word(r) if r else "")
505        if *number < BigInt::from(1000) {
506            let (h, r) = number.div_mod_floor(&BigInt::from(100));
507            let h = to_index(&h); // 1..=9
508            let mut out = String::new();
509            // The `h > 1` guard: 100 -> "ሚእቲ", never "ሓደ ሚእቲ".
510            if h > 1 {
511                out.push_str(ONES[h]);
512                out.push(' ');
513            }
514            out.push_str(HUNDRED);
515            if !r.is_zero() {
516                out.push_str(AND);
517                out.push_str(&self.int_to_word(&r));
518            }
519            return out;
520        }
521
522        // Python: t, r = divmod(number, 1000)
523        //         base = (self._int_to_word(t) + " " if t > 1 else "")
524        //                + self.thousand
525        //         return base + (" ን " + self._int_to_word(r) if r else "")
526        if *number < BigInt::from(1_000_000) {
527            let (t, r) = number.div_mod_floor(&BigInt::from(1000));
528            let mut out = String::new();
529            // The `t > 1` guard: 1000 -> "ሽሕ", never "ሓደ ሽሕ".
530            if t > BigInt::from(1) {
531                out.push_str(&self.int_to_word(&t));
532                out.push(' ');
533            }
534            out.push_str(THOUSAND);
535            if !r.is_zero() {
536                out.push_str(AND);
537                out.push_str(&self.int_to_word(&r));
538            }
539            return out;
540        }
541
542        // Python: m, r = divmod(number, 1000000)
543        //         base = (self._int_to_word(m) + " " if m > 1 else "")
544        //                + self.million
545        //         return base + (" ን " + self._int_to_word(r) if r else "")
546        if *number < BigInt::from(1_000_000_000) {
547            let (m, r) = number.div_mod_floor(&BigInt::from(1_000_000));
548            let mut out = String::new();
549            // The `m > 1` guard is present here too: 10**6 -> "ሚልዮን".
550            if m > BigInt::from(1) {
551                out.push_str(&self.int_to_word(&m));
552                out.push(' ');
553            }
554            out.push_str(MILLION);
555            if !r.is_zero() {
556                out.push_str(AND);
557                out.push_str(&self.int_to_word(&r));
558            }
559            return out;
560        }
561
562        // Python: return str(number) — the billion cliff (bug note 1).
563        // `number` is unbounded here (values reach 10**21+ in the corpus), so
564        // this must stay BigInt: never cast.
565        number.to_string()
566    }
567
568    /// Port of `Num2Word_TI.to_cardinal` for **non-integer** input.
569    ///
570    /// TI's `to_cardinal` works entirely on `str(number)` — the float/Decimal
571    /// path is `n.split(".", 1)` with a per-*character* fraction loop, never
572    /// `float2tuple`. So the two `PORTING_FLOAT.md` traps do not apply: there is
573    /// no `round()` and no `abs(value-pre)*10**precision` arithmetic — nothing to
574    /// round banker's-vs-away, and no f64 multiply artefact to preserve. The one
575    /// thing that must be exact is `str(number)` itself, which
576    /// [`python_str`] reconstructs byte-for-byte for both arms.
577    ///
578    /// ```python
579    /// n = str(number).strip()
580    /// if n.startswith("-"):
581    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
582    /// if "." in n:
583    ///     left, right = n.split(".", 1)
584    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
585    ///     for digit in right:
586    ///         ret += " " + (self.ones[int(digit)] or "ባዶ")
587    ///     return ret.strip()
588    /// return self._int_to_word(int(n))
589    /// ```
590    ///
591    /// The recursion is string-level in Python: `to_cardinal(n[1:])` hands a
592    /// `str` straight back, whose `str(number)` is then a no-op. Reproduced as
593    /// such rather than folded into an `abs()` — one level deep in practice, since
594    /// no repr yields a second leading "-". Because the sign is read off the
595    /// *string*, negative zero keeps its "-" (`str(-0.0) == "-0.0"`), so `-0.0`
596    /// renders "ኣሉታ ባዶ ነጥቢ ባዶ" — a case the base float path, which
597    /// tests `value < 0.0`, would strip. That divergence is precisely why TI
598    /// cannot inherit the default `to_cardinal_float`.
599    fn cardinal_from_str(&self, n: &str) -> Result<String> {
600        // n = str(number).strip(). Python strips its own whitespace set and Rust
601        // trims Unicode's; no repr contains either.
602        let n = n.trim();
603
604        // if n.startswith("-"):
605        //     return (self.negword + self.to_cardinal(n[1:])).strip()
606        if let Some(rest) = n.strip_prefix('-') {
607            let inner = self.cardinal_from_str(rest)?;
608            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
609        }
610
611        // if "." in n: left, right = n.split(".", 1) — the *first* dot only, so
612        // a stray second dot would land in `right` and reach int() as a char.
613        // Unreachable from a real repr.
614        let (left, right) = match n.split_once('.') {
615            Some(halves) => halves,
616            // return self._int_to_word(int(n))
617            None => return Ok(self.int_to_word(&py_int(n)?)),
618        };
619
620        // ret = self._int_to_word(int(left)) + " " + self.pointword
621        //
622        // `int(left)` is the whole integer part, so the billion cliff (bug note
623        // 1) applies: at 1e9 and above it comes back as bare digits
624        // ("98746251323029 ነጥቢ ትሽዓተ ትሽዓተ").
625        let mut ret = self.int_to_word(&py_int(left)?);
626        ret.push(' ');
627        ret.push_str(POINTWORD);
628
629        // for digit in right: ret += " " + (self.ones[int(digit)] or "ባዶ")
630        //
631        // Per *character* — no grouping, no rounding, no padding: the fraction
632        // is exactly the digits the repr carried. `self.ones[0]` is `""`
633        // (falsy), so the `or "ባዶ"` is what turns a fraction digit 0 into a
634        // word — a different mechanism from `_int_to_word`'s `number == 0`
635        // guard, but the same output.
636        for digit in right.chars() {
637            let word = ONES[py_int_digit(digit)?];
638            ret.push(' ');
639            ret.push_str(if word.is_empty() { ZERO_WORD } else { word });
640        }
641        // Cosmetic: `_int_to_word` never returns "", so nothing is trimmed here.
642        Ok(ret.trim().to_string())
643    }
644}
645
646/// Convert a BigInt the caller has proven to be a small non-negative index.
647///
648/// Every call site above is guarded by an explicit range check, so the
649/// conversion cannot fail.
650fn to_index(n: &BigInt) -> usize {
651    debug_assert!(!n.is_negative() && *n < BigInt::from(10));
652    // `expect` documents the invariant; the guards make it unreachable.
653    n.to_usize().expect("caller proved this value is in 0..=9")
654}
655
656impl Lang for LangTi {
657
658    fn cardinal_float_entry(
659        &self,
660        value: &crate::floatpath::FloatValue,
661        precision_override: Option<u32>,
662    ) -> crate::base::Result<String> {
663        // Python's to_cardinal routes every float/Decimal through this
664        // language's own decimal grammar — 5.0 keeps its ".0" tail
665        // ("comma nulla"), unlike Base's whole-value integer route.
666        self.to_cardinal_float(value, precision_override)
667    }
668
669    /// `to_ordinal(float/Decimal)`. Guarded by
670    /// [`LangTi::verify_ordinal_num`]: a non-integer raises `TypeError`
671    /// rather than rendering a decimal ordinal. A *whole* float routes into
672    /// the integer path, so `to_ordinal(5.0)` is "ሓምሻይ".
673    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
674        let whole = self.verify_ordinal_num(value)?;
675        self.to_ordinal(&whole)
676    }
677
678    /// `to_ordinal_num(float/Decimal)`. Same guard as the word form, so the
679    /// binding's repr is never framed as an ordinal: `3.14` raises rather than
680    /// producing "መበል 3.14". A whole float takes the integer digit rule —
681    /// `5.0` is "5ይ", `11.0` is "መበል 11".
682    fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
683        let whole = self.verify_ordinal_num(value)?;
684        self.to_ordinal_num(&whole)
685    }
686
687    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
688    /// interception: Python parses "Infinity" fine and the ValueError only
689    /// fires later, inside TI's `int("Infinity")` (`to_cardinal` recurses
690    /// past the sign, finds no "." and calls `int()`). The binding otherwise
691    /// hard-codes `ParsedNumber::Inf` to the base integer path's
692    /// OverflowError before any TI code runs, so the ValueError must be
693    /// raised here. The sign is stripped by the recursion before `int()`
694    /// sees it, so both signs quote the same literal.
695    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
696        match python_decimal_parse(s)? {
697            ParsedNumber::Inf { .. } => Err(N2WError::Value(
698                "invalid literal for int() with base 10: 'Infinity'".into(),
699            )),
700            other => Ok(other),
701        }
702    }
703
704    /// This language's own `to_currency(currency=...)` default,
705    /// read from the live Python signature. Only 44 of 156 use EUR.
706    fn default_currency(&self) -> &str {
707        "ETB"
708    }
709
710    /// This language's own `to_currency(separator=...)` default,
711    /// read from the live Python signature. Base's is ",", but only
712    /// 36 of 149 languages actually use it — most default to " " or a
713    /// conjunction, so inheriting Base's comma silently corrupts them.
714    fn default_separator(&self) -> &str {
715        " "
716    }
717
718    fn negword(&self) -> &str {
719        NEGWORD
720    }
721
722    fn pointword(&self) -> &str {
723        "ነጥቢ"
724    }
725
726    // `is_title` stays false (the base default), so `title()` is the identity
727    // and `exclude_title` never actually gates anything.
728    fn exclude_title(&self) -> &[String] {
729        &self.exclude_title
730    }
731
732    /// Port of `Num2Word_TI.to_cardinal`, integer path only.
733    ///
734    /// Python operates on `n = str(number).strip()`. For integer input the
735    /// `"." in n` branch (the float path) is unreachable, leaving only the
736    /// sign test and the `_int_to_word(int(n))` tail.
737    ///
738    /// The Python sign branch recurses through `to_cardinal(n[1:])` — dropping
739    /// the leading "-" from the *string*, which for integer input is exactly
740    /// `value.abs()`, and which cannot re-enter the sign branch. The `.strip()`
741    /// is a no-op in practice (`negword` supplies the separating space and
742    /// `int_to_word` never returns an empty string for a non-zero input), but
743    /// it is reproduced for fidelity.
744    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
745        if value.is_negative() {
746            // Python: (self.negword + self.to_cardinal(n[1:])).strip()
747            let inner = self.int_to_word(&value.abs());
748            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
749        }
750        Ok(self.int_to_word(value))
751    }
752
753    /// Tigrinya ordinals. **Diverges from upstream** (see the module header).
754    ///
755    /// Upstream is `to_cardinal(number) + "ay"` for every input, which yields
756    /// the non-word "ሓደኣይ" for 1st. The real rule has two arms:
757    ///
758    /// * 1..=10 are suppletive — [`ORDINAL_IRREGULARS`].
759    /// * everything else is [`ORDINAL_PREFIX`] ("መበል ") plus the plain
760    ///   cardinal, with no inflection of the cardinal itself.
761    ///
762    /// Guarded by [`LangTi::verify_ordinal`], so zero and negatives raise
763    /// `TypeError` rather than falling into the second arm.
764    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
765        self.verify_ordinal(value)?;
766        if *value <= BigInt::from(10) {
767            let i = value.to_usize().expect("1..=10 fits a usize");
768            return Ok(ORDINAL_IRREGULARS[i].to_string());
769        }
770        Ok(format!("{}{}", ORDINAL_PREFIX, self.to_cardinal(value)?))
771    }
772
773    /// The digit form. **Diverges from upstream** (`str(number) + "ay"`).
774    ///
775    /// Tigrinya's numeric-ordinal notation mirrors the spoken rule rather than
776    /// using one universal suffix — the formal/textbook convention:
777    ///
778    /// * 1..=10 take the ይ suffix on the digit: "1ይ", "2ይ", "10ይ".
779    /// * 11 and up drop the suffix and take the መበል prefix on the bare digit:
780    ///   "መበል 11", "መበል 21", "መበል 100".
781    ///
782    /// The colloquial alternative (seen in media and headlines) extends ይ to
783    /// every number — "11ይ", "100ይ" — ignoring መበል for brevity. Switching to
784    /// it means deleting the range test and always taking the suffix arm.
785    ///
786    /// Guarded by [`LangTi::verify_ordinal`] like the word form, so zero and
787    /// negatives raise instead of producing "መበል -1".
788    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
789        self.verify_ordinal(value)?;
790        if *value <= BigInt::from(10) {
791            return Ok(format!("{}{}", value, ORDINAL_SUFFIX));
792        }
793        Ok(format!("{}{}", ORDINAL_PREFIX, value))
794    }
795
796    /// Port of `Num2Word_TI.to_year`: ignores `longval` and delegates
797    /// (bug note 4).
798    fn to_year(&self, value: &BigInt) -> Result<String> {
799        self.to_cardinal(value)
800    }
801
802    /// Port of `Num2Word_TI.to_cardinal` for non-integer input.
803    ///
804    /// TI overrides `to_cardinal` (not `to_cardinal_float`) and handles floats
805    /// and Decimals inline by splitting `str(number)`, so the port reconstructs
806    /// that exact string via [`python_str`] and hands it to
807    /// [`Self::cardinal_from_str`]. The float and Decimal arms of `str()` are not
808    /// interchangeable (issue #603): a float stringifies to shortest round-trip
809    /// and goes exponential outside `[1e-4, 1e16)`, while a Decimal stringifies
810    /// exactly and goes exponential on its own written exponent. Reproducing both
811    /// is what makes the exponent-form `ValueError` fire exactly where Python's
812    /// does (`int("1e+16")`).
813    ///
814    /// `Num2Word_TI.to_cardinal(self, number)` takes **no** `precision=` kwarg,
815    /// and the live interpreter confirms the kwarg is dropped before this method
816    /// is reached (`num2words(0.5, lang='ti', precision=3) == "ባዶ ነጥቢ
817    /// ሓሙሽተ"`, unchanged), so `precision_override` is ignored here.
818    fn to_cardinal_float(
819        &self,
820        value: &FloatValue,
821        precision_override: Option<u32>,
822    ) -> Result<String> {
823        let _ = precision_override;
824        self.cardinal_from_str(&python_str(value))
825    }
826
827    // ---- currency -------------------------------------------------------
828    //
829    // TI overrides `to_currency` and `pluralize`; everything else on the
830    // currency path (`to_cheque`, `_money_verbose`, `_cents_verbose`,
831    // `_cents_terse`) is inherited from `Num2Word_Base` unchanged and the
832    // trait defaults already mirror it. `CURRENCY_ADJECTIVES` and
833    // `CURRENCY_PRECISION` are both `{}`, so `currency_adjective` (None) and
834    // `currency_precision` (100) stay at their defaults too.
835
836    fn lang_name(&self) -> &str {
837        "Num2Word_TI"
838    }
839
840    /// `CURRENCY_FORMS[code]` — a *strict* lookup, unlike `to_currency`'s.
841    ///
842    /// Returning `None` for an unknown code is what makes Base's `to_cheque`
843    /// raise `NotImplementedError`, matching the corpus rows for
844    /// `cheque:{GBP,JPY,KWD,BHD,INR,CNY,CHF}`. `to_currency` deliberately does
845    /// *not* come through here; it substitutes [`LangTi::fallback_forms`].
846    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
847        self.currency_forms.get(code)
848    }
849
850    /// Port of `Num2Word_TI.pluralize`:
851    ///
852    /// ```text
853    /// if not forms: return ""
854    /// return forms[0] if n == 1 else forms[-1]
855    /// ```
856    ///
857    /// Note this takes the **last** form, not `forms[1]` as `Num2Word_EUR`'s
858    /// does. Identical for TI's two-form tuples, but the rule is what is being
859    /// ported, so it is kept literal. The empty guard means TI's `pluralize` —
860    /// alone among the ones that matter — cannot raise `IndexError`.
861    ///
862    /// Unreachable on every path this crate exercises: TI's `to_currency`
863    /// indexes the tuple inline and Base's `to_cheque` takes `cr1[-1]`
864    /// directly. Ported because it is a public method on the Python class.
865    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
866        match forms.last() {
867            // Python: if not forms: return ""
868            None => Ok(String::new()),
869            Some(last) => Ok(if n.is_one() {
870                forms[0].clone()
871            } else {
872                last.clone()
873            }),
874        }
875    }
876
877    /// Port of `Num2Word_TI.to_currency`.
878    ///
879    /// ```text
880    /// def to_currency(self, val, currency="ETB", cents=True,
881    ///                 separator=" ", adjective=False):
882    ///     is_negative = val < 0
883    ///     val = abs(val)
884    ///     parts = str(val).split(".")
885    ///     left = int(parts[0]) if parts[0] else 0
886    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
887    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
888    ///         currency, list(self.CURRENCY_FORMS.values())[0])
889    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
890    ///     if cents and right:
891    ///         result += separator + self._int_to_word(right) + " " \
892    ///                 + (cr2[1] if right != 1 else cr2[0])
893    ///     if is_negative:
894    ///         result = self.negword + result
895    ///     return result.strip()
896    /// ```
897    ///
898    /// `adjective` is accepted and never read — that is the Python signature,
899    /// not an oversight here, so no adjective prefixing happens even though
900    /// the parameter exists.
901    fn to_currency(
902        &self,
903        val: &CurrencyValue,
904        currency: &str,
905        cents: bool,
906        separator: Option<&str>,
907        _adjective: bool,
908    ) -> Result<String> {
909        // `None` means the kwarg was omitted, so TI's own default (" ") applies.
910        let separator = separator.unwrap_or(self.default_separator());
911
912        // Python: is_negative = val < 0; val = abs(val)
913        let is_negative = val.is_negative();
914        let (left, right) = self.split_currency_parts(val)?;
915
916        // Python: cr1, cr2 = CURRENCY_FORMS.get(currency, list(...values())[0])
917        // No NotImplementedError here — unknown codes fall back to ETB.
918        let forms = self
919            .currency_forms
920            .get(currency)
921            .unwrap_or(&self.fallback_forms);
922
923        // Python: self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
924        let mut result = format!(
925            "{} {}",
926            self.int_to_word(&left),
927            pick_form(&forms.unit, &left)?
928        );
929
930        // Python: if cents and right:  -- `right == 0` is falsy, so a zero-cent
931        // value ("1.0", "0.005") drops the segment entirely.
932        if cents && !right.is_zero() {
933            result.push_str(separator);
934            result.push_str(&format!(
935                "{} {}",
936                self.int_to_word(&right),
937                pick_form(&forms.subunit, &right)?
938            ));
939        }
940
941        // Python: if is_negative: result = self.negword + result
942        if is_negative {
943            result = format!("{}{}", NEGWORD, result);
944        }
945
946        // Python: return result.strip()
947        Ok(result.trim().to_string())
948    }
949}
950
951/// Python's inline `forms[1] if n != 1 else forms[0]`, as `to_currency` writes
952/// it for both `cr1` and `cr2`.
953///
954/// Python indexes the tuple directly, so a single-form entry with `n != 1`
955/// would raise `IndexError`. Every entry in [`CURRENCY_FORMS`] carries two
956/// forms and the fallback does too, so this is unreachable — but it is mapped
957/// to `N2WError::Index` rather than panicking so the exception type survives if
958/// the table ever changes.
959fn pick_form(forms: &[String], n: &BigInt) -> Result<String> {
960    let idx = if n.is_one() { 0 } else { 1 };
961    forms
962        .get(idx)
963        .cloned()
964        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
965}
966
967// ---- the float/Decimal path's `str(number)` reconstruction --------------
968//
969// TI's `to_cardinal` works entirely on `str(number)`, so a faithful port has to
970// hand `cardinal_from_str` the byte-for-byte string CPython would produce. These
971// helpers mirror the sibling `lang_pap.rs` / `lang_ceb.rs` ports (Tigrinya shares
972// their exact `_int_to_word`/`str`-splitting structure); each is documented and
973// mass-verified against CPython there. Only the language constants differ, and
974// none of those constants appear in the helpers themselves.
975
976/// CPython's `str(number)` for the value that reached TI's `to_cardinal`.
977fn python_str(value: &FloatValue) -> String {
978    match value {
979        FloatValue::Float { value, .. } => py_str_f64(*value),
980        FloatValue::Decimal { value, .. } => py_str_decimal(value),
981    }
982}
983
984/// Python's `int(s)` for a whole token. CPython's message is
985/// `invalid literal for int() with base 10: '<s>'`, quoted exactly — this is
986/// what surfaces for exponent-form strings that carry no ".": `int("1e+16")`.
987///
988/// The accepted grammars differ only in ways that cannot bite here: Python's
989/// `int` also takes surrounding whitespace, a `+` sign and `_` separators, none
990/// of which a `str(float)`/`str(Decimal)` can emit.
991fn py_int(s: &str) -> Result<BigInt> {
992    BigInt::from_str(s)
993        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
994}
995
996/// Python's `int(ch)` for a *single* character, as the fraction loop calls it.
997/// The message quotes the one offending character, matching CPython — which is
998/// why a Decimal like `1.23E+4` reports `'E'` rather than `'23E+4'`.
999///
1000/// One unreachable divergence: CPython's `int()` accepts any Unicode `Nd`
1001/// codepoint, while `char::to_digit(10)` is ASCII-only. No `str(float)` or
1002/// `str(Decimal)` can emit a non-ASCII digit, so they agree on every string
1003/// that reaches here.
1004fn py_int_digit(ch: char) -> Result<usize> {
1005    ch.to_digit(10)
1006        .map(|d| d as usize)
1007        .ok_or_else(|| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch)))
1008}
1009
1010/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
1011/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
1012///
1013/// Rust's shortest `{:e}` and CPython's `repr` agree on every double except an
1014/// exact tie, where CPython breaks to even and Rust breaks away from zero.
1015/// Rust's fixed-precision `{:.*e}` *is* correctly rounded half-to-even, so
1016/// re-emitting the same number of significant digits through it applies
1017/// CPython's rule — kept only if it still round-trips.
1018fn shortest_repr_digits(a: f64) -> (String, i32) {
1019    let split = |s: &str| -> (String, i32) {
1020        let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
1021        (
1022            mant.chars().filter(|c| *c != '.').collect(),
1023            exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
1024        )
1025    };
1026
1027    let shortest = format!("{:e}", a);
1028    let (digits, decpt) = split(&shortest);
1029
1030    let ties_even = format!("{:.*e}", digits.len() - 1, a);
1031    if ties_even.parse::<f64>() == Ok(a) {
1032        return split(&ties_even);
1033    }
1034    (digits, decpt)
1035}
1036
1037/// CPython's `str(float)` / `repr(float)`
1038/// (`PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)`):
1039///
1040/// * exponent form when `decpt <= -4 || decpt > 16` (`str(1e16) == "1e+16"`,
1041///   `str(1e-05) == "1e-05"`), with the exponent `%+.02d`;
1042/// * otherwise positional, appending `.0` when nothing follows the point
1043///   (so `str(1.0) == "1.0"`).
1044///
1045/// The sign is the sign *bit*, not `v < 0.0`, so `str(-0.0) == "-0.0"` and
1046/// TI's `startswith("-")` fires on negative zero.
1047fn py_str_f64(v: f64) -> String {
1048    // Unreachable from the shim (it derives `precision` from a finite Decimal),
1049    // handled anyway so this is a faithful `str()`: TI would go on to raise
1050    // ValueError on `int("inf")`/`int("nan")`, exactly as Python does.
1051    if v.is_nan() {
1052        return "nan".to_string();
1053    }
1054    if v.is_infinite() {
1055        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
1056    }
1057
1058    let sign = if v.is_sign_negative() { "-" } else { "" };
1059    let (digits, decpt) = shortest_repr_digits(v.abs());
1060    let ndig = digits.len() as i32;
1061
1062    if decpt <= -4 || decpt > 16 {
1063        let mantissa = if ndig > 1 {
1064            format!("{}.{}", &digits[..1], &digits[1..])
1065        } else {
1066            // No ADD_DOT_0 in exponent form: `str(1e16)` is "1e+16".
1067            digits.clone()
1068        };
1069        let exp = decpt - 1;
1070        let (esign, eabs) = if exp < 0 {
1071            ("-", -(exp as i64))
1072        } else {
1073            ("+", exp as i64)
1074        };
1075        return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
1076    }
1077    if decpt <= 0 {
1078        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
1079    } else if decpt >= ndig {
1080        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
1081    } else {
1082        format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
1083    }
1084}
1085
1086/// CPython's `Decimal.__str__` (`_pydecimal.Decimal.__str__`).
1087///
1088/// Read the digits and exponent off `as_bigint_and_exponent()` and reassemble by
1089/// Python's rule — `BigDecimal`'s own `Display` disagrees on `1E+2` (Python
1090/// keeps it exponential, so TI raises), on `0.0` (Python keeps the ".0"), and on
1091/// the `e`/`E` case. `BigDecimal::from_str` keeps the written scale (`"1.10"`
1092/// stays coefficient 110 / scale 2), which is what makes the trailing "ባዶ"
1093/// appear, and `(coefficient, -scale)` is exactly Python's `(_int, _exp)`.
1094fn py_str_decimal(value: &BigDecimal) -> String {
1095    let (coefficient, scale) = value.as_bigint_and_exponent();
1096    let exp = -scale;
1097    let sign = if coefficient.is_negative() { "-" } else { "" };
1098    let int_digits = coefficient.abs().to_string();
1099    let ndig = int_digits.len() as i64;
1100
1101    let leftdigits = exp + ndig;
1102    let dotplace = if exp <= 0 && leftdigits > -6 {
1103        leftdigits
1104    } else {
1105        1
1106    };
1107
1108    let (intpart, fracpart) = if dotplace <= 0 {
1109        (
1110            "0".to_string(),
1111            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
1112        )
1113    } else if dotplace >= ndig {
1114        (
1115            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
1116            String::new(),
1117        )
1118    } else {
1119        (
1120            int_digits[..dotplace as usize].to_string(),
1121            format!(".{}", &int_digits[dotplace as usize..]),
1122        )
1123    };
1124
1125    // `['e', 'E'][context.capitals]`, and the default context has capitals=1.
1126    let exponent = if leftdigits == dotplace {
1127        String::new()
1128    } else {
1129        format!("E{:+}", leftdigits - dotplace)
1130    };
1131
1132    format!("{}{}{}{}", sign, intpart, fracpart, exponent)
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138    use bigdecimal::BigDecimal;
1139    use std::str::FromStr;
1140
1141    /// Every `"lang": "ti", "to": "currency:*"` row in the frozen corpus (108),
1142    /// pasted from `bench/corpus.jsonl` rather than retyped.
1143    #[rustfmt::skip]
1144    const CURRENCY_CORPUS: &[(&str, &str, bool, &str)] = &[
1145        ("EUR", "0", true, "ባዶ ዩሮ"),
1146        ("EUR", "1", true, "ሓደ ዩሮ"),
1147        ("EUR", "2", true, "ክልተ ዩሮ"),
1148        ("EUR", "100", true, "ሚእቲ ዩሮ"),
1149        ("EUR", "12.34", false, "ዓሰርተ ን ክልተ ዩሮ ሰላሳ ን ኣርባዕተ ሰንት"),
1150        ("EUR", "0.01", false, "ባዶ ዩሮ ሓደ ሰንት"),
1151        ("EUR", "1.0", false, "ሓደ ዩሮ"),
1152        ("EUR", "99.99", false, "ተስዓ ን ትሽዓተ ዩሮ ተስዓ ን ትሽዓተ ሰንት"),
1153        ("EUR", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ዩሮ ሓምሳ ን ሽዱሽተ ሰንት"),
1154        ("EUR", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ዩሮ ሰላሳ ን ኣርባዕተ ሰንት"),
1155        ("EUR", "1000000", true, "ሚልዮን ዩሮ"),
1156        ("EUR", "0.5", false, "ባዶ ዩሮ ሓምሳ ሰንት"),
1157        ("USD", "0", true, "ባዶ ዶላር"),
1158        ("USD", "1", true, "ሓደ ዶላር"),
1159        ("USD", "2", true, "ክልተ ዶላር"),
1160        ("USD", "100", true, "ሚእቲ ዶላር"),
1161        ("USD", "12.34", false, "ዓሰርተ ን ክልተ ዶላር ሰላሳ ን ኣርባዕተ ሰንት"),
1162        ("USD", "0.01", false, "ባዶ ዶላር ሓደ ሰንት"),
1163        ("USD", "1.0", false, "ሓደ ዶላር"),
1164        ("USD", "99.99", false, "ተስዓ ን ትሽዓተ ዶላር ተስዓ ን ትሽዓተ ሰንት"),
1165        ("USD", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ዶላር ሓምሳ ን ሽዱሽተ ሰንት"),
1166        ("USD", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ዶላር ሰላሳ ን ኣርባዕተ ሰንት"),
1167        ("USD", "1000000", true, "ሚልዮን ዶላር"),
1168        ("USD", "0.5", false, "ባዶ ዶላር ሓምሳ ሰንት"),
1169        ("GBP", "0", true, "ባዶ ብር"),
1170        ("GBP", "1", true, "ሓደ ብር"),
1171        ("GBP", "2", true, "ክልተ ብር"),
1172        ("GBP", "100", true, "ሚእቲ ብር"),
1173        ("GBP", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1174        ("GBP", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1175        ("GBP", "1.0", false, "ሓደ ብር"),
1176        ("GBP", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1177        ("GBP", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1178        ("GBP", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1179        ("GBP", "1000000", true, "ሚልዮን ብር"),
1180        ("GBP", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1181        ("JPY", "0", true, "ባዶ ብር"),
1182        ("JPY", "1", true, "ሓደ ብር"),
1183        ("JPY", "2", true, "ክልተ ብር"),
1184        ("JPY", "100", true, "ሚእቲ ብር"),
1185        ("JPY", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1186        ("JPY", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1187        ("JPY", "1.0", false, "ሓደ ብር"),
1188        ("JPY", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1189        ("JPY", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1190        ("JPY", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1191        ("JPY", "1000000", true, "ሚልዮን ብር"),
1192        ("JPY", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1193        ("KWD", "0", true, "ባዶ ብር"),
1194        ("KWD", "1", true, "ሓደ ብር"),
1195        ("KWD", "2", true, "ክልተ ብር"),
1196        ("KWD", "100", true, "ሚእቲ ብር"),
1197        ("KWD", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1198        ("KWD", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1199        ("KWD", "1.0", false, "ሓደ ብር"),
1200        ("KWD", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1201        ("KWD", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1202        ("KWD", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1203        ("KWD", "1000000", true, "ሚልዮን ብር"),
1204        ("KWD", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1205        ("BHD", "0", true, "ባዶ ብር"),
1206        ("BHD", "1", true, "ሓደ ብር"),
1207        ("BHD", "2", true, "ክልተ ብር"),
1208        ("BHD", "100", true, "ሚእቲ ብር"),
1209        ("BHD", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1210        ("BHD", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1211        ("BHD", "1.0", false, "ሓደ ብር"),
1212        ("BHD", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1213        ("BHD", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1214        ("BHD", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1215        ("BHD", "1000000", true, "ሚልዮን ብር"),
1216        ("BHD", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1217        ("INR", "0", true, "ባዶ ብር"),
1218        ("INR", "1", true, "ሓደ ብር"),
1219        ("INR", "2", true, "ክልተ ብር"),
1220        ("INR", "100", true, "ሚእቲ ብር"),
1221        ("INR", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1222        ("INR", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1223        ("INR", "1.0", false, "ሓደ ብር"),
1224        ("INR", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1225        ("INR", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1226        ("INR", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1227        ("INR", "1000000", true, "ሚልዮን ብር"),
1228        ("INR", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1229        ("CNY", "0", true, "ባዶ ብር"),
1230        ("CNY", "1", true, "ሓደ ብር"),
1231        ("CNY", "2", true, "ክልተ ብር"),
1232        ("CNY", "100", true, "ሚእቲ ብር"),
1233        ("CNY", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1234        ("CNY", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1235        ("CNY", "1.0", false, "ሓደ ብር"),
1236        ("CNY", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1237        ("CNY", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1238        ("CNY", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1239        ("CNY", "1000000", true, "ሚልዮን ብር"),
1240        ("CNY", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1241        ("CHF", "0", true, "ባዶ ብር"),
1242        ("CHF", "1", true, "ሓደ ብር"),
1243        ("CHF", "2", true, "ክልተ ብር"),
1244        ("CHF", "100", true, "ሚእቲ ብር"),
1245        ("CHF", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1246        ("CHF", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
1247        ("CHF", "1.0", false, "ሓደ ብር"),
1248        ("CHF", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
1249        ("CHF", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
1250        ("CHF", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
1251        ("CHF", "1000000", true, "ሚልዮን ብር"),
1252        ("CHF", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
1253    ];
1254
1255    /// Every `"lang": "ti", "to": "cheque:*"` row (9). `None` = NotImplementedError.
1256    #[rustfmt::skip]
1257    const CHEQUE_CORPUS: &[(&str, &str, Option<&str>)] = &[
1258        ("EUR", "1234.56", Some("ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ AND 56/100 ዩሮ")),
1259        ("USD", "1234.56", Some("ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ AND 56/100 ዶላር")),
1260        ("GBP", "1234.56", None),
1261        ("JPY", "1234.56", None),
1262        ("KWD", "1234.56", None),
1263        ("BHD", "1234.56", None),
1264        ("INR", "1234.56", None),
1265        ("CNY", "1234.56", None),
1266        ("CHF", "1234.56", None),
1267    ];
1268
1269    /// Rebuild the `CurrencyValue` the shim would hand us for a corpus `arg`.
1270    /// `arg` is `repr(value)`, so "12.34" is a float and "100" an int — and
1271    /// `has_decimal` mirrors `isinstance(val, float) or "." in str(val)`.
1272    fn value_of(arg: &str, is_int: bool) -> CurrencyValue {
1273        CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap()
1274    }
1275
1276    #[test]
1277    fn currency_matches_corpus() {
1278        let ti = LangTi::new();
1279        for (code, arg, is_int, expected) in CURRENCY_CORPUS {
1280            let got = ti
1281                .to_currency(&value_of(arg, *is_int), code, true, None, false)
1282                .unwrap_or_else(|_| panic!("{code} {arg} raised"));
1283            assert_eq!(&got, expected, "currency:{code} arg={arg}");
1284        }
1285    }
1286
1287    #[test]
1288    fn cheque_matches_corpus() {
1289        let ti = LangTi::new();
1290        for (code, arg, expected) in CHEQUE_CORPUS {
1291            let got = ti.to_cheque(&BigDecimal::from_str(arg).unwrap(), code);
1292            match (got, expected) {
1293                (Ok(g), Some(e)) => assert_eq!(&g, e, "cheque:{code}"),
1294                (Err(N2WError::NotImplemented(m)), None) => assert_eq!(
1295                    m,
1296                    format!("Currency code \"{code}\" not implemented for \"Num2Word_TI\"")
1297                ),
1298                (g, e) => panic!("cheque:{code}: got {g:?}, expected {e:?}"),
1299            }
1300        }
1301    }
1302
1303    /// Unknown codes fall back to `list(CURRENCY_FORMS.values())[0]` (ETB) in
1304    /// `to_currency`, but are still absent from the strict `currency_forms`
1305    /// lookup that `to_cheque` uses. The two must disagree.
1306    #[test]
1307    fn unknown_code_falls_back_in_currency_but_not_cheque() {
1308        let ti = LangTi::new();
1309        assert!(ti.currency_forms("GBP").is_none());
1310        assert_eq!(
1311            ti.to_currency(&value_of("2", true), "GBP", true, None, false).unwrap(),
1312            "ክልተ ብር"
1313        );
1314        // ETB itself is the fallback, so it must render identically.
1315        assert_eq!(
1316            ti.to_currency(&value_of("2", true), "ETB", true, None, false).unwrap(),
1317            "ክልተ ብር"
1318        );
1319        // ERN is the only code whose unit differs from the fallback's.
1320        assert_eq!(
1321            ti.to_currency(&value_of("2", true), "ERN", true, None, false).unwrap(),
1322            "ክልተ ናቕፋ"
1323        );
1324    }
1325
1326    /// `str(val)`-based cents truncate; they never round. Verified against the
1327    /// interpreter: 2.675 -> 67 ሳንቲም, 12.345 -> 34, 0.005 -> no segment.
1328    #[test]
1329    fn cents_truncate_rather_than_round() {
1330        let ti = LangTi::new();
1331        let c = |a: &str| ti.to_currency(&value_of(a, false), "EUR", true, None, false).unwrap();
1332        assert_eq!(c("2.675"), "ክልተ ዩሮ ስሳ ን ሸውዓተ ሰንት");
1333        assert_eq!(c("12.345"), "ዓሰርተ ን ክልተ ዩሮ ሰላሳ ን ኣርባዕተ ሰንት");
1334        assert_eq!(c("0.005"), "ባዶ ዩሮ");
1335    }
1336
1337    /// TI reads the *text* of `str(val)`, never `isinstance`, so int 5,
1338    /// `Decimal("5")`, `Decimal("5.00")` and 5.0 all collapse to one output.
1339    /// This is the case where honouring `has_decimal` would be wrong.
1340    #[test]
1341    fn has_decimal_is_not_consulted() {
1342        let ti = LangTi::new();
1343        let c = |v: CurrencyValue| ti.to_currency(&v, "EUR", true, None, false).unwrap();
1344        assert_eq!(c(CurrencyValue::parse("5", true, false, false).unwrap()), "ሓሙሽተ ዩሮ");
1345        assert_eq!(c(CurrencyValue::parse("5", false, false, false).unwrap()), "ሓሙሽተ ዩሮ");
1346        assert_eq!(c(CurrencyValue::parse("5.00", false, false, false).unwrap()), "ሓሙሽተ ዩሮ");
1347        assert_eq!(c(CurrencyValue::parse("5.0", false, true, true).unwrap()), "ሓሙሽተ ዩሮ");
1348    }
1349
1350    /// `cents=False` still routes through `_int_to_word` — TI never calls
1351    /// `_cents_terse`, so the flag only ever suppresses the whole segment.
1352    #[test]
1353    fn cents_flag_suppresses_the_segment() {
1354        let ti = LangTi::new();
1355        assert_eq!(
1356            ti.to_currency(&value_of("12.34", false), "EUR", false, None, false).unwrap(),
1357            "ዓሰርተ ን ክልተ ዩሮ"
1358        );
1359    }
1360
1361    /// An explicit separator overrides TI's " " default; `None` must not.
1362    #[test]
1363    fn separator_is_honoured() {
1364        let ti = LangTi::new();
1365        assert_eq!(
1366            ti.to_currency(&value_of("12.34", false), "EUR", true, Some(", "), false).unwrap(),
1367            "ዓሰርተ ን ክልተ ዩሮ, ሰላሳ ን ኣርባዕተ ሰንት"
1368        );
1369        assert_eq!(ti.default_separator(), " ");
1370    }
1371
1372    /// `left` goes through `_int_to_word`, so the billion cliff reaches
1373    /// currency too: verified `to_currency(1e15, "EUR")` is digits + " ዩሮ".
1374    #[test]
1375    fn billion_cliff_reaches_currency() {
1376        let ti = LangTi::new();
1377        assert_eq!(
1378            ti.to_currency(&value_of("1000000000000000.0", false), "EUR", true, None, false).unwrap(),
1379            "1000000000000000 ዩሮ"
1380        );
1381    }
1382
1383    /// `adjective` is accepted and never read by the Python signature.
1384    #[test]
1385    fn adjective_is_ignored() {
1386        let ti = LangTi::new();
1387        assert_eq!(
1388            ti.to_currency(&value_of("2", true), "EUR", true, None, true).unwrap(),
1389            "ክልተ ዩሮ"
1390        );
1391        assert!(ti.currency_adjective("EUR").is_none());
1392    }
1393
1394    /// `CURRENCY_PRECISION` is `{}`, so every code is 2-decimal.
1395    #[test]
1396    fn precision_is_always_the_default() {
1397        let ti = LangTi::new();
1398        for code in ["EUR", "USD", "ETB", "ERN", "KWD", "BHD", "JPY"] {
1399            assert_eq!(ti.currency_precision(code), 100, "{code}");
1400        }
1401    }
1402
1403    /// Exponent-form `str(val)` feeds an "e" token to `int()` in Python, which
1404    /// raises ValueError. A plain decimal string always parses to scale >= 0,
1405    /// so a negative scale is a sound detector for it.
1406    #[test]
1407    fn exponent_notation_is_a_value_error() {
1408        let ti = LangTi::new();
1409        for s in ["1e+21", "1E+21", "1.5e+16", "1e+16"] {
1410            let v = CurrencyValue::parse(s, false, true, true).unwrap();
1411            assert!(
1412                matches!(ti.to_currency(&v, "EUR", true, None, false), Err(N2WError::Value(_))),
1413                "{s} should be a ValueError"
1414            );
1415        }
1416        // ...while the plain-decimal spelling of the same magnitude does not.
1417        let v = CurrencyValue::parse("1000000000000000000000", false, false, false).unwrap();
1418        assert!(ti.to_currency(&v, "EUR", true, None, false).is_ok());
1419    }
1420
1421    /// Pins the one **known divergence** from Python, so it fails loudly if
1422    /// `CurrencyValue` ever starts carrying the original `str(val)` and this
1423    /// can finally be made exact.
1424    ///
1425    /// Python raises ValueError for `float 1e-05` (repr "1e-05") but returns
1426    /// "ባዶ ዩሮ" for `Decimal("0.00001")`. Both arrive here as digits=1,
1427    /// scale=5, has_decimal=true — identical — so this port cannot honour both
1428    /// and takes the plain-decimal reading. See `split_currency_parts`.
1429    #[test]
1430    fn negative_exponent_floats_diverge_from_python() {
1431        let ti = LangTi::new();
1432        let small = CurrencyValue::parse("0.00001", false, true, true).unwrap();
1433        // Correct for Decimal("0.00001"); Python would raise for float 1e-05.
1434        assert_eq!(
1435            ti.to_currency(&small, "EUR", true, None, false).unwrap(),
1436            "ባዶ ዩሮ"
1437        );
1438        // The two inputs really are indistinguishable at this boundary.
1439        let as_float_repr = CurrencyValue::parse("0.00001", false, true, true).unwrap();
1440        assert_eq!(
1441            format!("{:?}", small.clone()),
1442            format!("{as_float_repr:?}"),
1443            "if these ever differ, the divergence is fixable"
1444        );
1445    }
1446
1447    /// Guards the assumption `split_currency_parts` rests on: `with_scale(0)`
1448    /// truncates toward zero and plain decimal strings never yield scale < 0.
1449    #[test]
1450    fn bigdecimal_assumptions_hold() {
1451        for s in ["0", "5", "5.00", "1.0", "0.5", "12.345", "1000000000000000.0"] {
1452            assert!(
1453                BigDecimal::from_str(s).unwrap().as_bigint_and_exponent().1 >= 0,
1454                "{s} must not parse to a negative scale"
1455            );
1456        }
1457        let trunc = |s: &str| BigDecimal::from_str(s).unwrap().with_scale(0).to_string();
1458        assert_eq!(trunc("12.999"), "12");
1459        assert_eq!(trunc("0.5"), "0");
1460    }
1461
1462    /// Build the `FloatValue` the shim hands to `to_cardinal_float` for a float
1463    /// corpus `arg` (`repr(value)`): precision = fractional digit count.
1464    fn float_val(arg: &str) -> FloatValue {
1465        let value: f64 = arg.parse().unwrap();
1466        let precision = arg.split_once('.').map_or(0, |(_, f)| f.len() as u32);
1467        FloatValue::Float { value, precision }
1468    }
1469
1470    /// Build the `FloatValue::Decimal` for a `cardinal_dec` row.
1471    fn dec_val(arg: &str) -> FloatValue {
1472        let value = BigDecimal::from_str(arg).unwrap();
1473        let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1474        FloatValue::Decimal { value, precision }
1475    }
1476
1477    /// Every `"lang": "ti", "to": "cardinal"` row with a float `arg` in the
1478    /// frozen corpus, pasted verbatim.
1479    #[rustfmt::skip]
1480    const FLOAT_CARDINAL_CORPUS: &[(&str, &str)] = &[
1481        ("0.0", "ባዶ ነጥቢ ባዶ"),
1482        ("0.5", "ባዶ ነጥቢ ሓሙሽተ"),
1483        ("1.0", "ሓደ ነጥቢ ባዶ"),
1484        ("1.5", "ሓደ ነጥቢ ሓሙሽተ"),
1485        ("2.25", "ክልተ ነጥቢ ክልተ ሓሙሽተ"),
1486        ("3.14", "ሰለስተ ነጥቢ ሓደ ኣርባዕተ"),
1487        ("0.01", "ባዶ ነጥቢ ባዶ ሓደ"),
1488        ("0.1", "ባዶ ነጥቢ ሓደ"),
1489        ("0.99", "ባዶ ነጥቢ ትሽዓተ ትሽዓተ"),
1490        ("1.01", "ሓደ ነጥቢ ባዶ ሓደ"),
1491        ("12.34", "ዓሰርተ ን ክልተ ነጥቢ ሰለስተ ኣርባዕተ"),
1492        ("99.99", "ተስዓ ን ትሽዓተ ነጥቢ ትሽዓተ ትሽዓተ"),
1493        ("100.5", "ሚእቲ ነጥቢ ሓሙሽተ"),
1494        ("1234.56", "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ነጥቢ ሓሙሽተ ሽዱሽተ"),
1495        ("-0.5", "ኣሉታ ባዶ ነጥቢ ሓሙሽተ"),
1496        ("-1.5", "ኣሉታ ሓደ ነጥቢ ሓሙሽተ"),
1497        ("-12.34", "ኣሉታ ዓሰርተ ን ክልተ ነጥቢ ሰለስተ ኣርባዕተ"),
1498        ("1.005", "ሓደ ነጥቢ ባዶ ባዶ ሓሙሽተ"),
1499        ("2.675", "ክልተ ነጥቢ ሽዱሽተ ሸውዓተ ሓሙሽተ"),
1500    ];
1501
1502    /// Every `"lang": "ti", "to": "cardinal_dec"` row.
1503    #[rustfmt::skip]
1504    const DECIMAL_CARDINAL_CORPUS: &[(&str, &str)] = &[
1505        ("0.01", "ባዶ ነጥቢ ባዶ ሓደ"),
1506        ("1.10", "ሓደ ነጥቢ ሓደ ባዶ"),
1507        ("12.345", "ዓሰርተ ን ክልተ ነጥቢ ሰለስተ ኣርባዕተ ሓሙሽተ"),
1508        ("98746251323029.99", "98746251323029 ነጥቢ ትሽዓተ ትሽዓተ"),
1509        ("0.001", "ባዶ ነጥቢ ባዶ ባዶ ሓደ"),
1510    ];
1511
1512    #[test]
1513    fn float_cardinal_matches_corpus() {
1514        let ti = LangTi::new();
1515        for (arg, expected) in FLOAT_CARDINAL_CORPUS {
1516            let got = ti.to_cardinal_float(&float_val(arg), None).unwrap();
1517            assert_eq!(&got, expected, "cardinal float arg={arg}");
1518        }
1519    }
1520
1521    #[test]
1522    fn decimal_cardinal_matches_corpus() {
1523        let ti = LangTi::new();
1524        for (arg, expected) in DECIMAL_CARDINAL_CORPUS {
1525            let got = ti.to_cardinal_float(&dec_val(arg), None).unwrap();
1526            assert_eq!(&got, expected, "cardinal_dec arg={arg}");
1527        }
1528    }
1529
1530    /// The `precision=` kwarg is dropped by the dispatcher before TI's
1531    /// `to_cardinal` runs, so `precision_override` must not change the output.
1532    #[test]
1533    fn precision_override_is_ignored() {
1534        let ti = LangTi::new();
1535        assert_eq!(
1536            ti.to_cardinal_float(&float_val("0.5"), Some(3)).unwrap(),
1537            "ባዶ ነጥቢ ሓሙሽተ"
1538        );
1539    }
1540
1541    /// Negative zero keeps its sign because it is read off `str(-0.0) == "-0.0"`,
1542    /// not from `value < 0.0` — verified against the interpreter.
1543    #[test]
1544    fn negative_zero_keeps_negword() {
1545        let ti = LangTi::new();
1546        let neg_zero = FloatValue::Float { value: -0.0, precision: 1 };
1547        assert_eq!(
1548            ti.to_cardinal_float(&neg_zero, None).unwrap(),
1549            "ኣሉታ ባዶ ነጥቢ ባዶ"
1550        );
1551    }
1552
1553    /// Ordinals 1..=10 are suppletive, not cardinal + suffix.
1554    #[test]
1555    fn ordinals_1_to_10_are_irregular() {
1556        let ti = LangTi::new();
1557        let expected = [
1558            (1, "ቀዳማይ"), (2, "ካልኣይ"), (3, "ሳልሳይ"), (4, "ራብዓይ"), (5, "ሓምሻይ"),
1559            (6, "ሻድሻይ"), (7, "ሻብዓይ"), (8, "ሻምናይ"), (9, "ታሽዓይ"), (10, "ዓስራይ"),
1560        ];
1561        for (n, want) in expected {
1562            assert_eq!(ti.to_ordinal(&BigInt::from(n)).unwrap(), want, "ordinal {n}");
1563        }
1564    }
1565
1566    /// From 11 up the rule is "መበል " + the *plain* cardinal, with the
1567    /// cardinal left exactly as `to_cardinal` produces it.
1568    #[test]
1569    fn ordinals_from_11_take_the_mebel_prefix() {
1570        let ti = LangTi::new();
1571        let cases = [
1572            (11, "መበል ዓሰርተ ን ሓደ"),
1573            (20, "መበል ዕስራ"),
1574            (25, "መበል ዕስራ ን ሓሙሽተ"),
1575            (100, "መበል ሚእቲ"),
1576            (1234, "መበል ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ"),
1577        ];
1578        for (n, want) in cases {
1579            assert_eq!(ti.to_ordinal(&BigInt::from(n)).unwrap(), want, "ordinal {n}");
1580            // The prefixed arm must not disturb the cardinal it wraps.
1581            assert!(want.ends_with(&ti.to_cardinal(&BigInt::from(n)).unwrap()));
1582        }
1583    }
1584
1585    /// The restored `verify_ordinal` guard: negatives AND zero raise
1586    /// TypeError, in both the word form and the digit form.
1587    #[test]
1588    fn ordinal_rejects_zero_and_negatives() {
1589        let ti = LangTi::new();
1590        for n in [0i64, -1, -11, -100] {
1591            let v = BigInt::from(n);
1592            assert!(
1593                matches!(ti.to_ordinal(&v), Err(N2WError::Type(_))),
1594                "to_ordinal({n}) must raise TypeError"
1595            );
1596            assert!(
1597                matches!(ti.to_ordinal_num(&v), Err(N2WError::Type(_))),
1598                "to_ordinal_num({n}) must raise TypeError"
1599            );
1600        }
1601        // Negatives keep Base's wording; zero gets its own (no Python
1602        // precedent exists, since upstream never rejects zero).
1603        assert!(matches!(
1604            ti.to_ordinal(&BigInt::from(-1)),
1605            Err(N2WError::Type(ref m)) if m == "Cannot treat negative num -1 as ordinal."
1606        ));
1607        assert!(matches!(
1608            ti.to_ordinal(&BigInt::from(0)),
1609            Err(N2WError::Type(ref m)) if m == "Cannot treat zero as ordinal."
1610        ));
1611    }
1612
1613    /// The digit form mirrors the spoken rule: ይ on the digit up to 10, the
1614    /// መበል prefix from 11 up. Upstream's universal "ay" suffix is gone.
1615    #[test]
1616    fn ordinal_num_follows_the_formal_convention() {
1617        let ti = LangTi::new();
1618        let cases = [
1619            (1, "1ይ"), (2, "2ይ"), (9, "9ይ"), (10, "10ይ"),
1620            (11, "መበል 11"), (21, "መበል 21"), (100, "መበል 100"),
1621        ];
1622        for (n, want) in cases {
1623            assert_eq!(ti.to_ordinal_num(&BigInt::from(n)).unwrap(), want, "{n}");
1624        }
1625    }
1626
1627    /// Fractional values raise in both ordinal modes; whole ones route into
1628    /// the integer path instead of rendering a decimal ordinal.
1629    #[test]
1630    fn float_ordinals_verify_then_delegate() {
1631        let ti = LangTi::new();
1632
1633        // Whole -> integer path, both arms of the rule.
1634        assert_eq!(ti.ordinal_float_entry(&float_val("5.0")).unwrap(), "ሓምሻይ");
1635        assert_eq!(ti.ordinal_float_entry(&float_val("11.0")).unwrap(), "መበል ዓሰርተ ን ሓደ");
1636        assert_eq!(ti.ordinal_num_float_entry(&float_val("5.0"), "5.0").unwrap(), "5ይ");
1637        assert_eq!(ti.ordinal_num_float_entry(&float_val("11.0"), "11.0").unwrap(), "መበል 11");
1638
1639        // Fractional -> TypeError quoting str(value), not the truncated int.
1640        assert!(matches!(
1641            ti.ordinal_float_entry(&float_val("3.14")),
1642            Err(N2WError::Type(ref m)) if m == "Cannot treat float 3.14 as ordinal."
1643        ));
1644        assert!(matches!(
1645            ti.ordinal_num_float_entry(&float_val("3.14"), "3.14"),
1646            Err(N2WError::Type(ref m)) if m == "Cannot treat float 3.14 as ordinal."
1647        ));
1648
1649        // Sign and zero checks reach the float path too.
1650        assert!(matches!(
1651            ti.ordinal_float_entry(&float_val("-2.0")),
1652            Err(N2WError::Type(ref m)) if m == "Cannot treat negative num -2.0 as ordinal."
1653        ));
1654        assert!(matches!(
1655            ti.ordinal_float_entry(&float_val("0.0")),
1656            Err(N2WError::Type(ref m)) if m == "Cannot treat zero as ordinal."
1657        ));
1658    }
1659
1660    /// The four already-verified integer modes must not have shifted.
1661    #[test]
1662    fn integer_modes_unchanged() {
1663        let ti = LangTi::new();
1664        assert_eq!(ti.to_cardinal(&BigInt::from(1234)).unwrap(), "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ");
1665        assert_eq!(ti.to_cardinal(&BigInt::from(0)).unwrap(), "ባዶ");
1666        assert_eq!(ti.to_ordinal(&BigInt::from(11)).unwrap(), "መበል ዓሰርተ ን ሓደ");
1667        assert!(matches!(ti.to_ordinal_num(&BigInt::from(-1)), Err(N2WError::Type(_))));
1668        assert_eq!(ti.to_year(&BigInt::from(1999)).unwrap(), "ሽሕ ን ትሽዓተ ሚእቲ ን ተስዓ ን ትሽዓተ");
1669    }
1670}