use crate::base::{Lang, N2WError, Result};
use crate::currency::{CurrencyForms, CurrencyValue};
use crate::floatpath::FloatValue;
use crate::strnum::{python_decimal_parse, ParsedNumber};
use bigdecimal::BigDecimal;
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{One, Signed, ToPrimitive, Zero};
use std::collections::HashMap;
use std::str::FromStr;
const ONES: [&str; 10] = [
"",
"ሓደ",
"ክልተ",
"ሰለስተ",
"ኣርባዕተ",
"ሓሙሽተ",
"ሽዱሽተ",
"ሸውዓተ",
"ሸሞንተ",
"ትሽዓተ",
];
const TENS: [&str; 10] = [
"",
"ዓሰርተ",
"ዕስራ",
"ሰላሳ",
"ኣርብዓ",
"ሓምሳ",
"ስሳ",
"ሰብዓ",
"ሰማንያ",
"ተስዓ",
];
const ZERO_WORD: &str = "ባዶ";
const HUNDRED: &str = "ሚእቲ";
const THOUSAND: &str = "ሽሕ";
const MILLION: &str = "ሚልዮን";
const NEGWORD: &str = "ኣሉታ ";
const POINTWORD: &str = "ነጥቢ";
const AND: &str = " ን ";
const ORDINAL_SUFFIX: &str = "ይ";
const ORDINAL_IRREGULARS: [&str; 11] = [
"",
"ቀዳማይ",
"ካልኣይ",
"ሳልሳይ",
"ራብዓይ",
"ሓምሻይ",
"ሻድሻይ",
"ሻብዓይ",
"ሻምናይ",
"ታሽዓይ",
"ዓስራይ",
];
const ORDINAL_PREFIX: &str = "መበል ";
const CURRENCY_FORMS: [(&str, [&str; 2], [&str; 2]); 4] = [
("ETB", ["ብር", "ብር"], ["ሳንቲም", "ሳንቲም"]),
("ERN", ["ናቕፋ", "ናቕፋ"], ["ሳንቲም", "ሳንቲም"]),
("USD", ["ዶላር", "ዶላር"], ["ሰንት", "ሰንት"]),
("EUR", ["ዩሮ", "ዩሮ"], ["ሰንት", "ሰንት"]),
];
const CENTS_PER_UNIT: i64 = 100;
pub struct LangTi {
exclude_title: Vec<String>,
currency_forms: HashMap<&'static str, CurrencyForms>,
fallback_forms: CurrencyForms,
}
impl Default for LangTi {
fn default() -> Self {
Self::new()
}
}
impl LangTi {
pub fn new() -> Self {
let (_, fallback_unit, fallback_subunit) = CURRENCY_FORMS[0];
LangTi {
exclude_title: vec![
"ናይ".to_string(),
"ነጥቢ".to_string(),
"ኣሉታ".to_string(),
],
currency_forms: CURRENCY_FORMS
.iter()
.map(|(code, unit, subunit)| (*code, CurrencyForms::new(unit, subunit)))
.collect(),
fallback_forms: CurrencyForms::new(&fallback_unit, &fallback_subunit),
}
}
fn split_currency_parts(&self, val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
match val {
CurrencyValue::Int(v) => Ok((v.abs(), BigInt::zero())),
CurrencyValue::Decimal { value, .. } => {
let v = value.abs();
if v.as_bigint_and_exponent().1 < 0 {
return Err(N2WError::Value(format!(
"invalid literal for int() with base 10: '{v}'"
)));
}
let left = v.with_scale(0).as_bigint_and_exponent().0;
let frac = &v - BigDecimal::from(left.clone());
let right = (frac * BigDecimal::from(CENTS_PER_UNIT))
.with_scale(0)
.as_bigint_and_exponent()
.0;
Ok((left, right))
}
}
}
fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
if value.is_negative() {
return Err(N2WError::Type(format!(
"Cannot treat negative num {} as ordinal.",
value
)));
}
if value.is_zero() {
return Err(N2WError::Type("Cannot treat zero as ordinal.".into()));
}
Ok(())
}
fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
let whole = match value.as_whole_int() {
Some(i) => i,
None => {
return Err(N2WError::Type(format!(
"Cannot treat float {} as ordinal.",
python_str(value)
)))
}
};
if whole.is_negative() {
return Err(N2WError::Type(format!(
"Cannot treat negative num {} as ordinal.",
python_str(value)
)));
}
if whole.is_zero() {
return Err(N2WError::Type("Cannot treat zero as ordinal.".into()));
}
Ok(whole)
}
fn int_to_word(&self, number: &BigInt) -> String {
if number.is_zero() {
return ZERO_WORD.to_string();
}
if *number < BigInt::from(10) {
return ONES[to_index(number)].to_string();
}
if *number < BigInt::from(100) {
let (t, o) = number.div_mod_floor(&BigInt::from(10));
let t = to_index(&t); let o = to_index(&o); let mut out = TENS[t].to_string();
if o != 0 {
out.push_str(AND);
out.push_str(ONES[o]);
}
return out;
}
if *number < BigInt::from(1000) {
let (h, r) = number.div_mod_floor(&BigInt::from(100));
let h = to_index(&h); let mut out = String::new();
if h > 1 {
out.push_str(ONES[h]);
out.push(' ');
}
out.push_str(HUNDRED);
if !r.is_zero() {
out.push_str(AND);
out.push_str(&self.int_to_word(&r));
}
return out;
}
if *number < BigInt::from(1_000_000) {
let (t, r) = number.div_mod_floor(&BigInt::from(1000));
let mut out = String::new();
if t > BigInt::from(1) {
out.push_str(&self.int_to_word(&t));
out.push(' ');
}
out.push_str(THOUSAND);
if !r.is_zero() {
out.push_str(AND);
out.push_str(&self.int_to_word(&r));
}
return out;
}
if *number < BigInt::from(1_000_000_000) {
let (m, r) = number.div_mod_floor(&BigInt::from(1_000_000));
let mut out = String::new();
if m > BigInt::from(1) {
out.push_str(&self.int_to_word(&m));
out.push(' ');
}
out.push_str(MILLION);
if !r.is_zero() {
out.push_str(AND);
out.push_str(&self.int_to_word(&r));
}
return out;
}
number.to_string()
}
fn cardinal_from_str(&self, n: &str) -> Result<String> {
let n = n.trim();
if let Some(rest) = n.strip_prefix('-') {
let inner = self.cardinal_from_str(rest)?;
return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
}
let (left, right) = match n.split_once('.') {
Some(halves) => halves,
None => return Ok(self.int_to_word(&py_int(n)?)),
};
let mut ret = self.int_to_word(&py_int(left)?);
ret.push(' ');
ret.push_str(POINTWORD);
for digit in right.chars() {
let word = ONES[py_int_digit(digit)?];
ret.push(' ');
ret.push_str(if word.is_empty() { ZERO_WORD } else { word });
}
Ok(ret.trim().to_string())
}
}
fn to_index(n: &BigInt) -> usize {
debug_assert!(!n.is_negative() && *n < BigInt::from(10));
n.to_usize().expect("caller proved this value is in 0..=9")
}
impl Lang for LangTi {
fn cardinal_float_entry(
&self,
value: &crate::floatpath::FloatValue,
precision_override: Option<u32>,
) -> crate::base::Result<String> {
self.to_cardinal_float(value, precision_override)
}
fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
let whole = self.verify_ordinal_num(value)?;
self.to_ordinal(&whole)
}
fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
let whole = self.verify_ordinal_num(value)?;
self.to_ordinal_num(&whole)
}
fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
match python_decimal_parse(s)? {
ParsedNumber::Inf { .. } => Err(N2WError::Value(
"invalid literal for int() with base 10: 'Infinity'".into(),
)),
other => Ok(other),
}
}
fn default_currency(&self) -> &str {
"ETB"
}
fn default_separator(&self) -> &str {
" "
}
fn negword(&self) -> &str {
NEGWORD
}
fn pointword(&self) -> &str {
"ነጥቢ"
}
fn exclude_title(&self) -> &[String] {
&self.exclude_title
}
fn to_cardinal(&self, value: &BigInt) -> Result<String> {
if value.is_negative() {
let inner = self.int_to_word(&value.abs());
return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
}
Ok(self.int_to_word(value))
}
fn to_ordinal(&self, value: &BigInt) -> Result<String> {
self.verify_ordinal(value)?;
if *value <= BigInt::from(10) {
let i = value.to_usize().expect("1..=10 fits a usize");
return Ok(ORDINAL_IRREGULARS[i].to_string());
}
Ok(format!("{}{}", ORDINAL_PREFIX, self.to_cardinal(value)?))
}
fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
self.verify_ordinal(value)?;
if *value <= BigInt::from(10) {
return Ok(format!("{}{}", value, ORDINAL_SUFFIX));
}
Ok(format!("{}{}", ORDINAL_PREFIX, value))
}
fn to_year(&self, value: &BigInt) -> Result<String> {
self.to_cardinal(value)
}
fn to_cardinal_float(
&self,
value: &FloatValue,
precision_override: Option<u32>,
) -> Result<String> {
let _ = precision_override;
self.cardinal_from_str(&python_str(value))
}
fn lang_name(&self) -> &str {
"Num2Word_TI"
}
fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
self.currency_forms.get(code)
}
fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
match forms.last() {
None => Ok(String::new()),
Some(last) => Ok(if n.is_one() {
forms[0].clone()
} else {
last.clone()
}),
}
}
fn to_currency(
&self,
val: &CurrencyValue,
currency: &str,
cents: bool,
separator: Option<&str>,
_adjective: bool,
) -> Result<String> {
let separator = separator.unwrap_or(self.default_separator());
let is_negative = val.is_negative();
let (left, right) = self.split_currency_parts(val)?;
let forms = self
.currency_forms
.get(currency)
.unwrap_or(&self.fallback_forms);
let mut result = format!(
"{} {}",
self.int_to_word(&left),
pick_form(&forms.unit, &left)?
);
if cents && !right.is_zero() {
result.push_str(separator);
result.push_str(&format!(
"{} {}",
self.int_to_word(&right),
pick_form(&forms.subunit, &right)?
));
}
if is_negative {
result = format!("{}{}", NEGWORD, result);
}
Ok(result.trim().to_string())
}
}
fn pick_form(forms: &[String], n: &BigInt) -> Result<String> {
let idx = if n.is_one() { 0 } else { 1 };
forms
.get(idx)
.cloned()
.ok_or_else(|| N2WError::Index("tuple index out of range".into()))
}
fn python_str(value: &FloatValue) -> String {
match value {
FloatValue::Float { value, .. } => py_str_f64(*value),
FloatValue::Decimal { value, .. } => py_str_decimal(value),
}
}
fn py_int(s: &str) -> Result<BigInt> {
BigInt::from_str(s)
.map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
}
fn py_int_digit(ch: char) -> Result<usize> {
ch.to_digit(10)
.map(|d| d as usize)
.ok_or_else(|| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch)))
}
fn shortest_repr_digits(a: f64) -> (String, i32) {
let split = |s: &str| -> (String, i32) {
let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
(
mant.chars().filter(|c| *c != '.').collect(),
exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
)
};
let shortest = format!("{:e}", a);
let (digits, decpt) = split(&shortest);
let ties_even = format!("{:.*e}", digits.len() - 1, a);
if ties_even.parse::<f64>() == Ok(a) {
return split(&ties_even);
}
(digits, decpt)
}
fn py_str_f64(v: f64) -> String {
if v.is_nan() {
return "nan".to_string();
}
if v.is_infinite() {
return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
}
let sign = if v.is_sign_negative() { "-" } else { "" };
let (digits, decpt) = shortest_repr_digits(v.abs());
let ndig = digits.len() as i32;
if decpt <= -4 || decpt > 16 {
let mantissa = if ndig > 1 {
format!("{}.{}", &digits[..1], &digits[1..])
} else {
digits.clone()
};
let exp = decpt - 1;
let (esign, eabs) = if exp < 0 {
("-", -(exp as i64))
} else {
("+", exp as i64)
};
return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
}
if decpt <= 0 {
format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
} else if decpt >= ndig {
format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
} else {
format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
}
}
fn py_str_decimal(value: &BigDecimal) -> String {
let (coefficient, scale) = value.as_bigint_and_exponent();
let exp = -scale;
let sign = if coefficient.is_negative() { "-" } else { "" };
let int_digits = coefficient.abs().to_string();
let ndig = int_digits.len() as i64;
let leftdigits = exp + ndig;
let dotplace = if exp <= 0 && leftdigits > -6 {
leftdigits
} else {
1
};
let (intpart, fracpart) = if dotplace <= 0 {
(
"0".to_string(),
format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
)
} else if dotplace >= ndig {
(
format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
String::new(),
)
} else {
(
int_digits[..dotplace as usize].to_string(),
format!(".{}", &int_digits[dotplace as usize..]),
)
};
let exponent = if leftdigits == dotplace {
String::new()
} else {
format!("E{:+}", leftdigits - dotplace)
};
format!("{}{}{}{}", sign, intpart, fracpart, exponent)
}
#[cfg(test)]
mod tests {
use super::*;
use bigdecimal::BigDecimal;
use std::str::FromStr;
#[rustfmt::skip]
const CURRENCY_CORPUS: &[(&str, &str, bool, &str)] = &[
("EUR", "0", true, "ባዶ ዩሮ"),
("EUR", "1", true, "ሓደ ዩሮ"),
("EUR", "2", true, "ክልተ ዩሮ"),
("EUR", "100", true, "ሚእቲ ዩሮ"),
("EUR", "12.34", false, "ዓሰርተ ን ክልተ ዩሮ ሰላሳ ን ኣርባዕተ ሰንት"),
("EUR", "0.01", false, "ባዶ ዩሮ ሓደ ሰንት"),
("EUR", "1.0", false, "ሓደ ዩሮ"),
("EUR", "99.99", false, "ተስዓ ን ትሽዓተ ዩሮ ተስዓ ን ትሽዓተ ሰንት"),
("EUR", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ዩሮ ሓምሳ ን ሽዱሽተ ሰንት"),
("EUR", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ዩሮ ሰላሳ ን ኣርባዕተ ሰንት"),
("EUR", "1000000", true, "ሚልዮን ዩሮ"),
("EUR", "0.5", false, "ባዶ ዩሮ ሓምሳ ሰንት"),
("USD", "0", true, "ባዶ ዶላር"),
("USD", "1", true, "ሓደ ዶላር"),
("USD", "2", true, "ክልተ ዶላር"),
("USD", "100", true, "ሚእቲ ዶላር"),
("USD", "12.34", false, "ዓሰርተ ን ክልተ ዶላር ሰላሳ ን ኣርባዕተ ሰንት"),
("USD", "0.01", false, "ባዶ ዶላር ሓደ ሰንት"),
("USD", "1.0", false, "ሓደ ዶላር"),
("USD", "99.99", false, "ተስዓ ን ትሽዓተ ዶላር ተስዓ ን ትሽዓተ ሰንት"),
("USD", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ዶላር ሓምሳ ን ሽዱሽተ ሰንት"),
("USD", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ዶላር ሰላሳ ን ኣርባዕተ ሰንት"),
("USD", "1000000", true, "ሚልዮን ዶላር"),
("USD", "0.5", false, "ባዶ ዶላር ሓምሳ ሰንት"),
("GBP", "0", true, "ባዶ ብር"),
("GBP", "1", true, "ሓደ ብር"),
("GBP", "2", true, "ክልተ ብር"),
("GBP", "100", true, "ሚእቲ ብር"),
("GBP", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("GBP", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("GBP", "1.0", false, "ሓደ ብር"),
("GBP", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("GBP", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("GBP", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("GBP", "1000000", true, "ሚልዮን ብር"),
("GBP", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
("JPY", "0", true, "ባዶ ብር"),
("JPY", "1", true, "ሓደ ብር"),
("JPY", "2", true, "ክልተ ብር"),
("JPY", "100", true, "ሚእቲ ብር"),
("JPY", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("JPY", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("JPY", "1.0", false, "ሓደ ብር"),
("JPY", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("JPY", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("JPY", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("JPY", "1000000", true, "ሚልዮን ብር"),
("JPY", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
("KWD", "0", true, "ባዶ ብር"),
("KWD", "1", true, "ሓደ ብር"),
("KWD", "2", true, "ክልተ ብር"),
("KWD", "100", true, "ሚእቲ ብር"),
("KWD", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("KWD", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("KWD", "1.0", false, "ሓደ ብር"),
("KWD", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("KWD", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("KWD", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("KWD", "1000000", true, "ሚልዮን ብር"),
("KWD", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
("BHD", "0", true, "ባዶ ብር"),
("BHD", "1", true, "ሓደ ብር"),
("BHD", "2", true, "ክልተ ብር"),
("BHD", "100", true, "ሚእቲ ብር"),
("BHD", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("BHD", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("BHD", "1.0", false, "ሓደ ብር"),
("BHD", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("BHD", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("BHD", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("BHD", "1000000", true, "ሚልዮን ብር"),
("BHD", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
("INR", "0", true, "ባዶ ብር"),
("INR", "1", true, "ሓደ ብር"),
("INR", "2", true, "ክልተ ብር"),
("INR", "100", true, "ሚእቲ ብር"),
("INR", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("INR", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("INR", "1.0", false, "ሓደ ብር"),
("INR", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("INR", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("INR", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("INR", "1000000", true, "ሚልዮን ብር"),
("INR", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
("CNY", "0", true, "ባዶ ብር"),
("CNY", "1", true, "ሓደ ብር"),
("CNY", "2", true, "ክልተ ብር"),
("CNY", "100", true, "ሚእቲ ብር"),
("CNY", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("CNY", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("CNY", "1.0", false, "ሓደ ብር"),
("CNY", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("CNY", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("CNY", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("CNY", "1000000", true, "ሚልዮን ብር"),
("CNY", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
("CHF", "0", true, "ባዶ ብር"),
("CHF", "1", true, "ሓደ ብር"),
("CHF", "2", true, "ክልተ ብር"),
("CHF", "100", true, "ሚእቲ ብር"),
("CHF", "12.34", false, "ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("CHF", "0.01", false, "ባዶ ብር ሓደ ሳንቲም"),
("CHF", "1.0", false, "ሓደ ብር"),
("CHF", "99.99", false, "ተስዓ ን ትሽዓተ ብር ተስዓ ን ትሽዓተ ሳንቲም"),
("CHF", "1234.56", false, "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ብር ሓምሳ ን ሽዱሽተ ሳንቲም"),
("CHF", "-12.34", false, "ኣሉታ ዓሰርተ ን ክልተ ብር ሰላሳ ን ኣርባዕተ ሳንቲም"),
("CHF", "1000000", true, "ሚልዮን ብር"),
("CHF", "0.5", false, "ባዶ ብር ሓምሳ ሳንቲም"),
];
#[rustfmt::skip]
const CHEQUE_CORPUS: &[(&str, &str, Option<&str>)] = &[
("EUR", "1234.56", Some("ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ AND 56/100 ዩሮ")),
("USD", "1234.56", Some("ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ AND 56/100 ዶላር")),
("GBP", "1234.56", None),
("JPY", "1234.56", None),
("KWD", "1234.56", None),
("BHD", "1234.56", None),
("INR", "1234.56", None),
("CNY", "1234.56", None),
("CHF", "1234.56", None),
];
fn value_of(arg: &str, is_int: bool) -> CurrencyValue {
CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap()
}
#[test]
fn currency_matches_corpus() {
let ti = LangTi::new();
for (code, arg, is_int, expected) in CURRENCY_CORPUS {
let got = ti
.to_currency(&value_of(arg, *is_int), code, true, None, false)
.unwrap_or_else(|_| panic!("{code} {arg} raised"));
assert_eq!(&got, expected, "currency:{code} arg={arg}");
}
}
#[test]
fn cheque_matches_corpus() {
let ti = LangTi::new();
for (code, arg, expected) in CHEQUE_CORPUS {
let got = ti.to_cheque(&BigDecimal::from_str(arg).unwrap(), code);
match (got, expected) {
(Ok(g), Some(e)) => assert_eq!(&g, e, "cheque:{code}"),
(Err(N2WError::NotImplemented(m)), None) => assert_eq!(
m,
format!("Currency code \"{code}\" not implemented for \"Num2Word_TI\"")
),
(g, e) => panic!("cheque:{code}: got {g:?}, expected {e:?}"),
}
}
}
#[test]
fn unknown_code_falls_back_in_currency_but_not_cheque() {
let ti = LangTi::new();
assert!(ti.currency_forms("GBP").is_none());
assert_eq!(
ti.to_currency(&value_of("2", true), "GBP", true, None, false).unwrap(),
"ክልተ ብር"
);
assert_eq!(
ti.to_currency(&value_of("2", true), "ETB", true, None, false).unwrap(),
"ክልተ ብር"
);
assert_eq!(
ti.to_currency(&value_of("2", true), "ERN", true, None, false).unwrap(),
"ክልተ ናቕፋ"
);
}
#[test]
fn cents_truncate_rather_than_round() {
let ti = LangTi::new();
let c = |a: &str| ti.to_currency(&value_of(a, false), "EUR", true, None, false).unwrap();
assert_eq!(c("2.675"), "ክልተ ዩሮ ስሳ ን ሸውዓተ ሰንት");
assert_eq!(c("12.345"), "ዓሰርተ ን ክልተ ዩሮ ሰላሳ ን ኣርባዕተ ሰንት");
assert_eq!(c("0.005"), "ባዶ ዩሮ");
}
#[test]
fn has_decimal_is_not_consulted() {
let ti = LangTi::new();
let c = |v: CurrencyValue| ti.to_currency(&v, "EUR", true, None, false).unwrap();
assert_eq!(c(CurrencyValue::parse("5", true, false, false).unwrap()), "ሓሙሽተ ዩሮ");
assert_eq!(c(CurrencyValue::parse("5", false, false, false).unwrap()), "ሓሙሽተ ዩሮ");
assert_eq!(c(CurrencyValue::parse("5.00", false, false, false).unwrap()), "ሓሙሽተ ዩሮ");
assert_eq!(c(CurrencyValue::parse("5.0", false, true, true).unwrap()), "ሓሙሽተ ዩሮ");
}
#[test]
fn cents_flag_suppresses_the_segment() {
let ti = LangTi::new();
assert_eq!(
ti.to_currency(&value_of("12.34", false), "EUR", false, None, false).unwrap(),
"ዓሰርተ ን ክልተ ዩሮ"
);
}
#[test]
fn separator_is_honoured() {
let ti = LangTi::new();
assert_eq!(
ti.to_currency(&value_of("12.34", false), "EUR", true, Some(", "), false).unwrap(),
"ዓሰርተ ን ክልተ ዩሮ, ሰላሳ ን ኣርባዕተ ሰንት"
);
assert_eq!(ti.default_separator(), " ");
}
#[test]
fn billion_cliff_reaches_currency() {
let ti = LangTi::new();
assert_eq!(
ti.to_currency(&value_of("1000000000000000.0", false), "EUR", true, None, false).unwrap(),
"1000000000000000 ዩሮ"
);
}
#[test]
fn adjective_is_ignored() {
let ti = LangTi::new();
assert_eq!(
ti.to_currency(&value_of("2", true), "EUR", true, None, true).unwrap(),
"ክልተ ዩሮ"
);
assert!(ti.currency_adjective("EUR").is_none());
}
#[test]
fn precision_is_always_the_default() {
let ti = LangTi::new();
for code in ["EUR", "USD", "ETB", "ERN", "KWD", "BHD", "JPY"] {
assert_eq!(ti.currency_precision(code), 100, "{code}");
}
}
#[test]
fn exponent_notation_is_a_value_error() {
let ti = LangTi::new();
for s in ["1e+21", "1E+21", "1.5e+16", "1e+16"] {
let v = CurrencyValue::parse(s, false, true, true).unwrap();
assert!(
matches!(ti.to_currency(&v, "EUR", true, None, false), Err(N2WError::Value(_))),
"{s} should be a ValueError"
);
}
let v = CurrencyValue::parse("1000000000000000000000", false, false, false).unwrap();
assert!(ti.to_currency(&v, "EUR", true, None, false).is_ok());
}
#[test]
fn negative_exponent_floats_diverge_from_python() {
let ti = LangTi::new();
let small = CurrencyValue::parse("0.00001", false, true, true).unwrap();
assert_eq!(
ti.to_currency(&small, "EUR", true, None, false).unwrap(),
"ባዶ ዩሮ"
);
let as_float_repr = CurrencyValue::parse("0.00001", false, true, true).unwrap();
assert_eq!(
format!("{:?}", small.clone()),
format!("{as_float_repr:?}"),
"if these ever differ, the divergence is fixable"
);
}
#[test]
fn bigdecimal_assumptions_hold() {
for s in ["0", "5", "5.00", "1.0", "0.5", "12.345", "1000000000000000.0"] {
assert!(
BigDecimal::from_str(s).unwrap().as_bigint_and_exponent().1 >= 0,
"{s} must not parse to a negative scale"
);
}
let trunc = |s: &str| BigDecimal::from_str(s).unwrap().with_scale(0).to_string();
assert_eq!(trunc("12.999"), "12");
assert_eq!(trunc("0.5"), "0");
}
fn float_val(arg: &str) -> FloatValue {
let value: f64 = arg.parse().unwrap();
let precision = arg.split_once('.').map_or(0, |(_, f)| f.len() as u32);
FloatValue::Float { value, precision }
}
fn dec_val(arg: &str) -> FloatValue {
let value = BigDecimal::from_str(arg).unwrap();
let precision = value.as_bigint_and_exponent().1.max(0) as u32;
FloatValue::Decimal { value, precision }
}
#[rustfmt::skip]
const FLOAT_CARDINAL_CORPUS: &[(&str, &str)] = &[
("0.0", "ባዶ ነጥቢ ባዶ"),
("0.5", "ባዶ ነጥቢ ሓሙሽተ"),
("1.0", "ሓደ ነጥቢ ባዶ"),
("1.5", "ሓደ ነጥቢ ሓሙሽተ"),
("2.25", "ክልተ ነጥቢ ክልተ ሓሙሽተ"),
("3.14", "ሰለስተ ነጥቢ ሓደ ኣርባዕተ"),
("0.01", "ባዶ ነጥቢ ባዶ ሓደ"),
("0.1", "ባዶ ነጥቢ ሓደ"),
("0.99", "ባዶ ነጥቢ ትሽዓተ ትሽዓተ"),
("1.01", "ሓደ ነጥቢ ባዶ ሓደ"),
("12.34", "ዓሰርተ ን ክልተ ነጥቢ ሰለስተ ኣርባዕተ"),
("99.99", "ተስዓ ን ትሽዓተ ነጥቢ ትሽዓተ ትሽዓተ"),
("100.5", "ሚእቲ ነጥቢ ሓሙሽተ"),
("1234.56", "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ ነጥቢ ሓሙሽተ ሽዱሽተ"),
("-0.5", "ኣሉታ ባዶ ነጥቢ ሓሙሽተ"),
("-1.5", "ኣሉታ ሓደ ነጥቢ ሓሙሽተ"),
("-12.34", "ኣሉታ ዓሰርተ ን ክልተ ነጥቢ ሰለስተ ኣርባዕተ"),
("1.005", "ሓደ ነጥቢ ባዶ ባዶ ሓሙሽተ"),
("2.675", "ክልተ ነጥቢ ሽዱሽተ ሸውዓተ ሓሙሽተ"),
];
#[rustfmt::skip]
const DECIMAL_CARDINAL_CORPUS: &[(&str, &str)] = &[
("0.01", "ባዶ ነጥቢ ባዶ ሓደ"),
("1.10", "ሓደ ነጥቢ ሓደ ባዶ"),
("12.345", "ዓሰርተ ን ክልተ ነጥቢ ሰለስተ ኣርባዕተ ሓሙሽተ"),
("98746251323029.99", "98746251323029 ነጥቢ ትሽዓተ ትሽዓተ"),
("0.001", "ባዶ ነጥቢ ባዶ ባዶ ሓደ"),
];
#[test]
fn float_cardinal_matches_corpus() {
let ti = LangTi::new();
for (arg, expected) in FLOAT_CARDINAL_CORPUS {
let got = ti.to_cardinal_float(&float_val(arg), None).unwrap();
assert_eq!(&got, expected, "cardinal float arg={arg}");
}
}
#[test]
fn decimal_cardinal_matches_corpus() {
let ti = LangTi::new();
for (arg, expected) in DECIMAL_CARDINAL_CORPUS {
let got = ti.to_cardinal_float(&dec_val(arg), None).unwrap();
assert_eq!(&got, expected, "cardinal_dec arg={arg}");
}
}
#[test]
fn precision_override_is_ignored() {
let ti = LangTi::new();
assert_eq!(
ti.to_cardinal_float(&float_val("0.5"), Some(3)).unwrap(),
"ባዶ ነጥቢ ሓሙሽተ"
);
}
#[test]
fn negative_zero_keeps_negword() {
let ti = LangTi::new();
let neg_zero = FloatValue::Float { value: -0.0, precision: 1 };
assert_eq!(
ti.to_cardinal_float(&neg_zero, None).unwrap(),
"ኣሉታ ባዶ ነጥቢ ባዶ"
);
}
#[test]
fn ordinals_1_to_10_are_irregular() {
let ti = LangTi::new();
let expected = [
(1, "ቀዳማይ"), (2, "ካልኣይ"), (3, "ሳልሳይ"), (4, "ራብዓይ"), (5, "ሓምሻይ"),
(6, "ሻድሻይ"), (7, "ሻብዓይ"), (8, "ሻምናይ"), (9, "ታሽዓይ"), (10, "ዓስራይ"),
];
for (n, want) in expected {
assert_eq!(ti.to_ordinal(&BigInt::from(n)).unwrap(), want, "ordinal {n}");
}
}
#[test]
fn ordinals_from_11_take_the_mebel_prefix() {
let ti = LangTi::new();
let cases = [
(11, "መበል ዓሰርተ ን ሓደ"),
(20, "መበል ዕስራ"),
(25, "መበል ዕስራ ን ሓሙሽተ"),
(100, "መበል ሚእቲ"),
(1234, "መበል ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ"),
];
for (n, want) in cases {
assert_eq!(ti.to_ordinal(&BigInt::from(n)).unwrap(), want, "ordinal {n}");
assert!(want.ends_with(&ti.to_cardinal(&BigInt::from(n)).unwrap()));
}
}
#[test]
fn ordinal_rejects_zero_and_negatives() {
let ti = LangTi::new();
for n in [0i64, -1, -11, -100] {
let v = BigInt::from(n);
assert!(
matches!(ti.to_ordinal(&v), Err(N2WError::Type(_))),
"to_ordinal({n}) must raise TypeError"
);
assert!(
matches!(ti.to_ordinal_num(&v), Err(N2WError::Type(_))),
"to_ordinal_num({n}) must raise TypeError"
);
}
assert!(matches!(
ti.to_ordinal(&BigInt::from(-1)),
Err(N2WError::Type(ref m)) if m == "Cannot treat negative num -1 as ordinal."
));
assert!(matches!(
ti.to_ordinal(&BigInt::from(0)),
Err(N2WError::Type(ref m)) if m == "Cannot treat zero as ordinal."
));
}
#[test]
fn ordinal_num_follows_the_formal_convention() {
let ti = LangTi::new();
let cases = [
(1, "1ይ"), (2, "2ይ"), (9, "9ይ"), (10, "10ይ"),
(11, "መበል 11"), (21, "መበል 21"), (100, "መበል 100"),
];
for (n, want) in cases {
assert_eq!(ti.to_ordinal_num(&BigInt::from(n)).unwrap(), want, "{n}");
}
}
#[test]
fn float_ordinals_verify_then_delegate() {
let ti = LangTi::new();
assert_eq!(ti.ordinal_float_entry(&float_val("5.0")).unwrap(), "ሓምሻይ");
assert_eq!(ti.ordinal_float_entry(&float_val("11.0")).unwrap(), "መበል ዓሰርተ ን ሓደ");
assert_eq!(ti.ordinal_num_float_entry(&float_val("5.0"), "5.0").unwrap(), "5ይ");
assert_eq!(ti.ordinal_num_float_entry(&float_val("11.0"), "11.0").unwrap(), "መበል 11");
assert!(matches!(
ti.ordinal_float_entry(&float_val("3.14")),
Err(N2WError::Type(ref m)) if m == "Cannot treat float 3.14 as ordinal."
));
assert!(matches!(
ti.ordinal_num_float_entry(&float_val("3.14"), "3.14"),
Err(N2WError::Type(ref m)) if m == "Cannot treat float 3.14 as ordinal."
));
assert!(matches!(
ti.ordinal_float_entry(&float_val("-2.0")),
Err(N2WError::Type(ref m)) if m == "Cannot treat negative num -2.0 as ordinal."
));
assert!(matches!(
ti.ordinal_float_entry(&float_val("0.0")),
Err(N2WError::Type(ref m)) if m == "Cannot treat zero as ordinal."
));
}
#[test]
fn integer_modes_unchanged() {
let ti = LangTi::new();
assert_eq!(ti.to_cardinal(&BigInt::from(1234)).unwrap(), "ሽሕ ን ክልተ ሚእቲ ን ሰላሳ ን ኣርባዕተ");
assert_eq!(ti.to_cardinal(&BigInt::from(0)).unwrap(), "ባዶ");
assert_eq!(ti.to_ordinal(&BigInt::from(11)).unwrap(), "መበል ዓሰርተ ን ሓደ");
assert!(matches!(ti.to_ordinal_num(&BigInt::from(-1)), Err(N2WError::Type(_))));
assert_eq!(ti.to_year(&BigInt::from(1999)).unwrap(), "ሽሕ ን ትሽዓተ ሚእቲ ን ተስዓ ን ትሽዓተ");
}
}