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}