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] = [
"",
"ḥade",
"kilte",
"seleste",
"arba'te",
"ḥamushte",
"shidushte",
"shew'ate",
"shemonte",
"tish'ate",
];
const TENS: [&str; 10] = [
"", "'aserte", "'isra", "selasa", "arba'a", "ḥamsa", "sisa", "seb'a", "semanya", "tis'a",
];
const ZERO_WORD: &str = "bado";
const HUNDRED: &str = "mi'ti";
const THOUSAND: &str = "shiḥ";
const MILLION: &str = "miliyon";
const NEGWORD: &str = "tetsabi'i ";
const POINTWORD: &str = "neṭebi";
const AND: &str = " n ";
const ORDINAL_SUFFIX: &str = "ay";
const CURRENCY_FORMS: [(&str, [&str; 2], [&str; 2]); 4] = [
("ETB", ["birri", "birri"], ["santim", "santim"]),
("ERN", ["nakfa", "nakfa"], ["santim", "santim"]),
("USD", ["dolar", "dolar"], ["sent", "sent"]),
("EUR", ["euro", "euro"], ["sent", "sent"]),
];
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![
"nay".to_string(),
"neṭebi".to_string(),
"tetsabi'i".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 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> {
Ok(format!(
"{}{}",
self.cardinal_float_entry(value, None)?,
ORDINAL_SUFFIX
))
}
fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
}
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 {
"neṭebi"
}
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> {
Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
}
fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
Ok(format!("{}{}", value, ORDINAL_SUFFIX))
}
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, "bado euro"),
("EUR", "1", true, "ḥade euro"),
("EUR", "2", true, "kilte euro"),
("EUR", "100", true, "mi'ti euro"),
("EUR", "12.34", false, "'aserte n kilte euro selasa n arba'te sent"),
("EUR", "0.01", false, "bado euro ḥade sent"),
("EUR", "1.0", false, "ḥade euro"),
("EUR", "99.99", false, "tis'a n tish'ate euro tis'a n tish'ate sent"),
("EUR", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te euro ḥamsa n shidushte sent"),
("EUR", "-12.34", false, "tetsabi'i 'aserte n kilte euro selasa n arba'te sent"),
("EUR", "1000000", true, "miliyon euro"),
("EUR", "0.5", false, "bado euro ḥamsa sent"),
("USD", "0", true, "bado dolar"),
("USD", "1", true, "ḥade dolar"),
("USD", "2", true, "kilte dolar"),
("USD", "100", true, "mi'ti dolar"),
("USD", "12.34", false, "'aserte n kilte dolar selasa n arba'te sent"),
("USD", "0.01", false, "bado dolar ḥade sent"),
("USD", "1.0", false, "ḥade dolar"),
("USD", "99.99", false, "tis'a n tish'ate dolar tis'a n tish'ate sent"),
("USD", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te dolar ḥamsa n shidushte sent"),
("USD", "-12.34", false, "tetsabi'i 'aserte n kilte dolar selasa n arba'te sent"),
("USD", "1000000", true, "miliyon dolar"),
("USD", "0.5", false, "bado dolar ḥamsa sent"),
("GBP", "0", true, "bado birri"),
("GBP", "1", true, "ḥade birri"),
("GBP", "2", true, "kilte birri"),
("GBP", "100", true, "mi'ti birri"),
("GBP", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("GBP", "0.01", false, "bado birri ḥade santim"),
("GBP", "1.0", false, "ḥade birri"),
("GBP", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("GBP", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("GBP", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("GBP", "1000000", true, "miliyon birri"),
("GBP", "0.5", false, "bado birri ḥamsa santim"),
("JPY", "0", true, "bado birri"),
("JPY", "1", true, "ḥade birri"),
("JPY", "2", true, "kilte birri"),
("JPY", "100", true, "mi'ti birri"),
("JPY", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("JPY", "0.01", false, "bado birri ḥade santim"),
("JPY", "1.0", false, "ḥade birri"),
("JPY", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("JPY", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("JPY", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("JPY", "1000000", true, "miliyon birri"),
("JPY", "0.5", false, "bado birri ḥamsa santim"),
("KWD", "0", true, "bado birri"),
("KWD", "1", true, "ḥade birri"),
("KWD", "2", true, "kilte birri"),
("KWD", "100", true, "mi'ti birri"),
("KWD", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("KWD", "0.01", false, "bado birri ḥade santim"),
("KWD", "1.0", false, "ḥade birri"),
("KWD", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("KWD", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("KWD", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("KWD", "1000000", true, "miliyon birri"),
("KWD", "0.5", false, "bado birri ḥamsa santim"),
("BHD", "0", true, "bado birri"),
("BHD", "1", true, "ḥade birri"),
("BHD", "2", true, "kilte birri"),
("BHD", "100", true, "mi'ti birri"),
("BHD", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("BHD", "0.01", false, "bado birri ḥade santim"),
("BHD", "1.0", false, "ḥade birri"),
("BHD", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("BHD", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("BHD", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("BHD", "1000000", true, "miliyon birri"),
("BHD", "0.5", false, "bado birri ḥamsa santim"),
("INR", "0", true, "bado birri"),
("INR", "1", true, "ḥade birri"),
("INR", "2", true, "kilte birri"),
("INR", "100", true, "mi'ti birri"),
("INR", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("INR", "0.01", false, "bado birri ḥade santim"),
("INR", "1.0", false, "ḥade birri"),
("INR", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("INR", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("INR", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("INR", "1000000", true, "miliyon birri"),
("INR", "0.5", false, "bado birri ḥamsa santim"),
("CNY", "0", true, "bado birri"),
("CNY", "1", true, "ḥade birri"),
("CNY", "2", true, "kilte birri"),
("CNY", "100", true, "mi'ti birri"),
("CNY", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("CNY", "0.01", false, "bado birri ḥade santim"),
("CNY", "1.0", false, "ḥade birri"),
("CNY", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("CNY", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("CNY", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("CNY", "1000000", true, "miliyon birri"),
("CNY", "0.5", false, "bado birri ḥamsa santim"),
("CHF", "0", true, "bado birri"),
("CHF", "1", true, "ḥade birri"),
("CHF", "2", true, "kilte birri"),
("CHF", "100", true, "mi'ti birri"),
("CHF", "12.34", false, "'aserte n kilte birri selasa n arba'te santim"),
("CHF", "0.01", false, "bado birri ḥade santim"),
("CHF", "1.0", false, "ḥade birri"),
("CHF", "99.99", false, "tis'a n tish'ate birri tis'a n tish'ate santim"),
("CHF", "1234.56", false, "shiḥ n kilte mi'ti n selasa n arba'te birri ḥamsa n shidushte santim"),
("CHF", "-12.34", false, "tetsabi'i 'aserte n kilte birri selasa n arba'te santim"),
("CHF", "1000000", true, "miliyon birri"),
("CHF", "0.5", false, "bado birri ḥamsa santim"),
];
#[rustfmt::skip]
const CHEQUE_CORPUS: &[(&str, &str, Option<&str>)] = &[
("EUR", "1234.56", Some("SHIḤ N KILTE MI'TI N SELASA N ARBA'TE AND 56/100 EURO")),
("USD", "1234.56", Some("SHIḤ N KILTE MI'TI N SELASA N ARBA'TE AND 56/100 DOLAR")),
("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(),
"kilte birri"
);
assert_eq!(
ti.to_currency(&value_of("2", true), "ETB", true, None, false)
.unwrap(),
"kilte birri"
);
assert_eq!(
ti.to_currency(&value_of("2", true), "ERN", true, None, false)
.unwrap(),
"kilte nakfa"
);
}
#[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"), "kilte euro sisa n shew'ate sent");
assert_eq!(c("12.345"), "'aserte n kilte euro selasa n arba'te sent");
assert_eq!(c("0.005"), "bado euro");
}
#[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()),
"ḥamushte euro"
);
assert_eq!(
c(CurrencyValue::parse("5", false, false, false).unwrap()),
"ḥamushte euro"
);
assert_eq!(
c(CurrencyValue::parse("5.00", false, false, false).unwrap()),
"ḥamushte euro"
);
assert_eq!(
c(CurrencyValue::parse("5.0", false, true, true).unwrap()),
"ḥamushte euro"
);
}
#[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(),
"'aserte n kilte euro"
);
}
#[test]
fn separator_is_honoured() {
let ti = LangTi::new();
assert_eq!(
ti.to_currency(&value_of("12.34", false), "EUR", true, Some(", "), false)
.unwrap(),
"'aserte n kilte euro, selasa n arba'te sent"
);
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 euro"
);
}
#[test]
fn adjective_is_ignored() {
let ti = LangTi::new();
assert_eq!(
ti.to_currency(&value_of("2", true), "EUR", true, None, true)
.unwrap(),
"kilte euro"
);
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(),
"bado euro"
);
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", "bado neṭebi bado"),
("0.5", "bado neṭebi ḥamushte"),
("1.0", "ḥade neṭebi bado"),
("1.5", "ḥade neṭebi ḥamushte"),
("2.25", "kilte neṭebi kilte ḥamushte"),
("3.14", "seleste neṭebi ḥade arba'te"),
("0.01", "bado neṭebi bado ḥade"),
("0.1", "bado neṭebi ḥade"),
("0.99", "bado neṭebi tish'ate tish'ate"),
("1.01", "ḥade neṭebi bado ḥade"),
("12.34", "'aserte n kilte neṭebi seleste arba'te"),
("99.99", "tis'a n tish'ate neṭebi tish'ate tish'ate"),
("100.5", "mi'ti neṭebi ḥamushte"),
("1234.56", "shiḥ n kilte mi'ti n selasa n arba'te neṭebi ḥamushte shidushte"),
("-0.5", "tetsabi'i bado neṭebi ḥamushte"),
("-1.5", "tetsabi'i ḥade neṭebi ḥamushte"),
("-12.34", "tetsabi'i 'aserte n kilte neṭebi seleste arba'te"),
("1.005", "ḥade neṭebi bado bado ḥamushte"),
("2.675", "kilte neṭebi shidushte shew'ate ḥamushte"),
];
#[rustfmt::skip]
const DECIMAL_CARDINAL_CORPUS: &[(&str, &str)] = &[
("0.01", "bado neṭebi bado ḥade"),
("1.10", "ḥade neṭebi ḥade bado"),
("12.345", "'aserte n kilte neṭebi seleste arba'te ḥamushte"),
("98746251323029.99", "98746251323029 neṭebi tish'ate tish'ate"),
("0.001", "bado neṭebi bado bado ḥade"),
];
#[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(),
"bado neṭebi ḥamushte"
);
}
#[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(),
"tetsabi'i bado neṭebi bado"
);
}
#[test]
fn integer_modes_unchanged() {
let ti = LangTi::new();
assert_eq!(
ti.to_cardinal(&BigInt::from(1234)).unwrap(),
"shiḥ n kilte mi'ti n selasa n arba'te"
);
assert_eq!(ti.to_cardinal(&BigInt::from(0)).unwrap(), "bado");
assert_eq!(
ti.to_ordinal(&BigInt::from(11)).unwrap(),
"'aserte n ḥadeay"
);
assert_eq!(ti.to_ordinal_num(&BigInt::from(-1)).unwrap(), "-1ay");
assert_eq!(
ti.to_year(&BigInt::from(1999)).unwrap(),
"shiḥ n tish'ate mi'ti n tis'a n tish'ate"
);
}
}